From 7c983d798fa2ca9a366d75e4531d4fa7803ab021 Mon Sep 17 00:00:00 2001 From: "Mr. WorldwideBrown" Date: Sat, 11 Apr 2026 10:48:14 +0530 Subject: [PATCH 01/15] Fix OpenCode config path, FTS extension load order, error messages, and CLAUDE.md stats (#781) --- gitnexus-web/src/services/backend-client.ts | 4 ++- gitnexus/src/cli/ai-context.ts | 12 +++++++-- gitnexus/src/cli/analyze.ts | 5 +++- gitnexus/src/cli/index.ts | 1 + gitnexus/src/cli/setup.ts | 2 +- gitnexus/src/core/lbug/lbug-adapter.ts | 27 +++++++++++++-------- gitnexus/src/core/run-analyze.ts | 4 ++- 7 files changed, 39 insertions(+), 16 deletions(-) diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index d7862a63b..ebc18c17d 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -264,11 +264,13 @@ const fetchWithTimeout = async ( const assertOk = async (response: Response): Promise => { if (response.ok) return; - let message = `Backend returned ${response.status} ${response.statusText}`; + let message = response.statusText; try { const body = await response.json(); if (body && typeof body.error === 'string') { message = body.error; + } else if (body && typeof body.message === 'string') { + message = body.message; } } catch { // Response body was not JSON diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 1c7a95d7a..ae7f984fb 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -26,6 +26,7 @@ interface RepoStats { export interface AIContextOptions { skipAgentsMd?: boolean; + noStats?: boolean; } const GITNEXUS_START_MARKER = ''; @@ -64,6 +65,7 @@ function generateGitNexusContent( stats: RepoStats, generatedSkills?: GeneratedSkillInfo[], groupNames?: string[], + noStats?: boolean, ): string { const generatedRows = generatedSkills && generatedSkills.length > 0 @@ -87,7 +89,7 @@ function generateGitNexusContent( return `${GITNEXUS_START_MARKER} # GitNexus — Code Intelligence -This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows)`}. Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first. @@ -332,7 +334,13 @@ export async function generateAIContextFiles( options?: AIContextOptions, ): Promise<{ files: string[] }> { const groupNames = await findGroupsContainingRegistryName(projectName); - const content = generateGitNexusContent(projectName, stats, generatedSkills, groupNames); + const content = generateGitNexusContent( + projectName, + stats, + generatedSkills, + groupNames, + options?.noStats, + ); const createdFiles: string[] = []; if (!options?.skipAgentsMd) { diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index c77903de0..d520c3404 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -47,6 +47,8 @@ export interface AnalyzeOptions { verbose?: boolean; /** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */ skipAgentsMd?: boolean; + /** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */ + noStats?: boolean; /** Index the folder even when no .git directory is present. */ skipGit?: boolean; } @@ -177,6 +179,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption embeddings: options?.embeddings, skipGit: options?.skipGit, skipAgentsMd: options?.skipAgentsMd, + noStats: options?.noStats, }, { onProgress: (_phase, percent, message) => { @@ -240,7 +243,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption processes: s.processes, }, skillResult.skills, - { skipAgentsMd: options?.skipAgentsMd }, + { skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats }, ); } } catch { diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 75940bcbf..02581ae47 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -26,6 +26,7 @@ program .option('--embeddings', 'Enable embedding generation for semantic search (off by default)') .option('--skills', 'Generate repo-specific skill files from detected communities') .option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md') + .option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md') .option('--skip-git', 'Index a folder without requiring a .git directory') .option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)') .addHelpText( diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index ed941e6a4..8263405a5 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -265,7 +265,7 @@ async function setupOpenCode(result: SetupResult): Promise { return; } - const configPath = path.join(opencodeDir, 'config.json'); + const configPath = path.join(opencodeDir, 'opencode.json'); try { const existing = await readJsonFile(configPath); const config = existing || {}; diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 88a6e9bba..90e663f40 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -909,19 +909,26 @@ export const loadFTSExtension = async (): Promise => { throw new Error('LadybugDB not initialized. Call initLbug first.'); } try { - await conn.query('INSTALL fts'); + // Try loading locally first (no network required) await conn.query('LOAD EXTENSION fts'); ftsLoaded = true; - } catch (err: any) { - const msg = err?.message || ''; - if ( - msg.includes('already loaded') || - msg.includes('already installed') || - msg.includes('already exists') - ) { + } catch { + // Fall back to install + load (requires network) + try { + await conn.query('INSTALL fts'); + await conn.query('LOAD EXTENSION fts'); ftsLoaded = true; - } else { - console.error('GitNexus: FTS extension load failed:', msg); + } catch (err: any) { + const msg = err?.message || ''; + if ( + msg.includes('already loaded') || + msg.includes('already installed') || + msg.includes('already exists') + ) { + ftsLoaded = true; + } else { + console.error('GitNexus: FTS extension load failed:', msg); + } } } }; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index e8a108c71..f7b662705 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -48,6 +48,8 @@ export interface AnalyzeOptions { skipGit?: boolean; /** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */ skipAgentsMd?: boolean; + /** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */ + noStats?: boolean; } export interface AnalyzeResult { @@ -327,7 +329,7 @@ export async function runFullAnalysis( processes: pipelineResult.processResult?.stats.totalProcesses, }, undefined, - { skipAgentsMd: options.skipAgentsMd }, + { skipAgentsMd: options.skipAgentsMd, noStats: options.noStats }, ); } catch { // Best-effort — don't fail the entire analysis for context file issues From d9960c62bfff30d53e9c22295159cf7c26f00bbf Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:20:57 +0100 Subject: [PATCH 02/15] =?UTF-8?q?SM-19:=20Delete=20resolveCallTarget=20?= =?UTF-8?q?=E2=80=94=20replace=20with=20thin=20dispatcher=20(#770)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * SM-19: Replace resolveCallTarget with thin dispatcher Delete the monolithic resolveCallTarget function (~200 lines) and replace it with a 15-line thin dispatcher that routes to resolveMemberCall, resolveStaticCall, or resolveFreeCall. Extract module-alias resolution and file-based member-call fallback into dedicated helper functions. - resolveCallTarget body reduced from ~200 lines to ~15 lines - Extract resolveModuleAliasedCall helper (Python/Ruby module imports) - Extract resolveMemberCallByFile helper (trait dispatch, overload disambiguation) - Extract singleCandidate helper (constructor alias fallback, name-based fallback) - Update unit tests for new dispatcher semantics - Update doc comments referencing deleted D0-D4 paths Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/469eac38-b0c0-4a26-a2ff-3eb06299730b Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * SM-19: Add singleCandidate tail fallback for member calls with unresolvable receiver type Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/469eac38-b0c0-4a26-a2ff-3eb06299730b Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(SM-19): address all PR #770 review findings + fix CI Fixes all 5 test failures (2 unit + 3 integration) and addresses 10 review findings from comment 4225312416. Critical fix — singleCandidate null-route guard The SM-19 dispatcher chained singleCandidate as an unconditional tail fallback for member calls with receiverTypeName. This bypassed the SM-10 R3 null-route contract: when the receiver type IS in the index but file/owner filtering produced zero matches, the old code returned null (genuine miss), but the new code fell through to singleCandidate (false-positive CALLS edge). Root cause: resolveMemberCallByFile returns null for two semantically different reasons — (1) type not found in the index at all, and (2) type found but no candidate matched after narrowing. The dispatcher treated both as "try the next fallback." The old resolveCallTarget exited the entire function on case 2. Fix: after the scoped resolvers both return null, check whether the receiver type resolves in the index. If it does (case 2), null-route — the scoped resolvers made the right decision. If it doesn't (case 1, e.g. PHP 'mixed', dynamic types), singleCandidate is the correct last resort. ctx.resolve is cached so the check is free. This fixes: - Unit: no heritageMap null-route test (was getting 1 edge, expects 0) - Integration: Rust c.trait_only() negative test - Integration: 3 PHP heritage + alias tests (singleCandidate correctly fires when the receiver type is not in the index) Performance (findings #1, #2, #3) - Thread pre-computed tiered result into resolveModuleAliasedCall via new tieredOverride parameter — eliminates the duplicate ctx.resolve call on every module-alias path. - Add countCallableCandidates helper that short-circuits at threshold without allocating an intermediate array — replaces the filterCallableCandidates(...).length > 1 allocation in skipMember. - resolveMemberCallByFile lookupCallableByName caching deferred to a follow-up (finding #2) — the fix requires threading widenCache through the file-scoped resolver which is a larger change. Code quality (findings #4, #5) - Remove dead code: redundant conditional in resolveMemberCallByFile where both branches returned null. - Move WidenCache type declaration from mid-file (between JSDoc blocks) to adjacent to CONSTRUCTOR_TARGET_TYPES with other type declarations. Formatting - Applied prettier to call-processor.ts (CI format check was failing). Verification - tsc --noEmit clean - 3188 unit tests pass (0 skipped real tests) - 1766 resolver integration tests pass - Zero regressions — all PHP, Rust, and no-heritageMap tests green Review: https://github.com/abhigyanpatwari/GitNexus/pull/770#issuecomment-4225312416 * fix(SM-19): restore module-alias narrowing and constructor disambiguation Codex adversarial review on PR #770 surfaced two silent regressions in the SM-19 thin dispatcher: Finding 1 [high] — Typed member calls bypassed module-alias narrowing. When two homonym receiver types are both imported by the caller, the import-scoped tier no longer narrows and the owner/file resolvers see genuine ambiguity. The dispatcher null-routed silently, dropping valid CALLS edges. Fix: consult `resolveModuleAliasedCall` at the top of the typed-member branch so an active alias on `call.receiverName` picks the aliased file before the generic resolvers run. Finding 2 [medium] — Constructor dispatch lost overload disambiguation. When `resolveStaticCall` bails (ambiguous or ownerless Constructor pool) and the caller supplied `overloadHints` / `preComputedArgTypes`, the branch fell straight through to `singleCandidate` — which also bails on multiple same-arity survivors. Fix: between `resolveStaticCall` and `singleCandidate`, run constructor-filtered overload disambiguation on the tiered pool. Only engages when a narrowing signal is present; preserves SM-10 R3 null-route for genuinely ambiguous cases. Tests: - call-processor.test.ts: 3 new dispatcher-level regression tests covering real-homonym alias narrowing, constructor overload disambiguation with `argTypes`, and null-route control - symbol-table.test.ts: update `module alias homonyms` test which previously codified the Finding 1 regression; now asserts resolution to the aliased file's method Verification: 3191 unit + 2398 integration tests pass; tsc --noEmit clean; prettier clean. * refactor(SM-19): address code review findings with clean-code pass Code review on commit f424685e surfaced one P1 correctness regression and two P2 maintainability concerns. This commit closes all ten findings: P1 — Alias helper placement regression - resolveModuleAliasedCall now runs as a FALLBACK in the typed-member branch, after resolveMemberCall/resolveMemberCallByFile return null. Previously it short-circuited BEFORE scoped resolvers, leaking unrelated homonyms from the aliased file when a local var coincidentally matched a module alias. - Added type-file verification guard: alias narrowing only fires when the alias target file is among the receiver type's defining files. Prevents cross-type false positives and hardens SM-10 R3. P2 — Thin-dispatcher drift (roadmap Phase 3) - Extracted disambiguateByOverloadOrArgTypes shared helper. Centralizes the overloadHints → preComputedArgTypes precedence rule used by both member and constructor resolvers. - Folded constructor overload disambiguation into resolveStaticCall as step 4.5 (between the ambiguous-pool bail and the instantiable-class fallback). resolveStaticCall now accepts optional overloadHints / preComputedArgTypes symmetric with resolveMemberCallByFile. - Dispatcher's constructor branch returns to a 2-line delegation. - resolveMemberCallByFile now calls the shared helper instead of inlining the ternary. P2 — Missing test coverage - owner-scoped wins over alias narrowing (alias with unrelated target class must not override unique owner-scoped answer) - alias narrowing rejects unrelated target type (type-file guard) - alias fallthrough: receiverName not in alias map - alias fallthrough: alias target file has no matching method (overloadHints-for-constructor variant transitively covered via the extracted helper's member-path tests; direct dispatcher test deferred as it requires real OverloadHints fixture parsing) P3 — Clarity and durability - Stripped "Codex SM-19 Finding N" prefixes from comments. Replaced with durable explanations of WHY each guarded branch exists. - Added cross-reference comment at the tail-branch resolveModuleAliasedCall call site pointing to the typed-member branch usage. Verification: 3195 unit + 1766 resolver integration + 2398 full integration tests pass. tsc --noEmit clean. prettier clean. Plan: docs/plans/2026-04-11-002-fix-sm19-code-review-findings-plan.md --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar --- gitnexus/src/core/ingestion/call-processor.ts | 560 ++++++++++-------- gitnexus/test/unit/call-processor.test.ts | 322 ++++++++++ gitnexus/test/unit/symbol-table.test.ts | 73 +-- 3 files changed, 661 insertions(+), 294 deletions(-) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index a5258fa8e..5edd72737 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1303,6 +1303,9 @@ export const processCalls = async ( const CONSTRUCTOR_TARGET_TYPES = new Set(['Constructor', 'Class', 'Struct', 'Record']); +/** Per-file cache for module-alias widening. Cleared between files. */ +type WidenCache = Map; + const filterCallableCandidates = ( candidates: readonly SymbolDefinition[], argCount?: number, @@ -1339,6 +1342,40 @@ const filterCallableCandidates = ( ); }; +/** + * Count callable candidates matching the kind + arity filter without + * allocating an intermediate array. Short-circuits once count exceeds + * `threshold` (default 1) — used by the dispatcher's `skipMember` check + * where we only need to know "more than one survivor". + */ +const countCallableCandidates = ( + candidates: readonly SymbolDefinition[], + argCount?: number, + callForm?: 'free' | 'member' | 'constructor', + threshold = 1, +): number => { + let count = 0; + for (const c of candidates) { + // Kind filter (mirrors filterCallableCandidates) + const typeOk = + callForm === 'constructor' + ? CONSTRUCTOR_TARGET_TYPES.has(c.type) + : CALLABLE_TYPES.has(c.type); + if (!typeOk) continue; + // Arity filter + if ( + argCount !== undefined && + c.parameterCount !== undefined && + (argCount < (c.requiredParameterCount ?? c.parameterCount) || argCount > c.parameterCount) + ) { + continue; + } + count++; + if (count > threshold) return count; // early exit + } + return count; +}; + const toResolveResult = (definition: SymbolDefinition, tier: ResolutionTier): ResolveResult => ({ nodeId: definition.nodeId, confidence: TIER_CONFIDENCE[tier], @@ -1428,6 +1465,31 @@ const tryOverloadDisambiguation = ( return matchCandidatesByArgTypes(candidates, argTypes); }; +/** + * Apply overload-hint or arg-type disambiguation to a pre-filtered candidate + * pool. Returns the unique survivor, or null when neither signal is present, + * neither can disambiguate, or the pool remains ambiguous. + * + * Precedence rule: `overloadHints` wins over `preComputedArgTypes` when both + * are supplied. The AST-based disambiguator has access to live type inference + * hooks, whereas `preComputedArgTypes` is a worker-path pre-computation that + * may be coarser-grained. + * + * Single source of truth for the narrowing-signal precedence used by member + * and constructor resolution paths. Add a new narrowing signal here once, not + * at each call site. + */ +const disambiguateByOverloadOrArgTypes = ( + pool: SymbolDefinition[], + overloadHints: OverloadHints | undefined, + preComputedArgTypes: (string | undefined)[] | undefined, +): SymbolDefinition | null => { + if (!overloadHints && !preComputedArgTypes) return null; + if (overloadHints) return tryOverloadDisambiguation(pool, overloadHints); + if (preComputedArgTypes) return matchCandidatesByArgTypes(pool, preComputedArgTypes); + return null; +}; + /** * Collapse Swift-extension duplicate Class/Struct candidates to the primary * definition, preferring the shortest file path. @@ -1450,9 +1512,8 @@ const tryOverloadDisambiguation = ( * kinds, or `length <= 1`). Callers should fall through to their own null * return when this helper returns `null`. * - * Shared between `resolveCallTarget` and `resolveFreeCall` — SM-13 originally - * duplicated this block into both functions. Having a single source of truth - * prevents the two copies from drifting if the heuristic is ever tuned. + * Used by `resolveFreeCall`. Having a single source of truth prevents + * duplication if the heuristic is ever tuned. */ const dedupSwiftExtensionCandidates = ( candidates: readonly SymbolDefinition[], @@ -1467,19 +1528,139 @@ const dedupSwiftExtensionCandidates = ( }; /** - * Resolve a function call to its target node ID using priority strategy: - * A. Narrow candidates by scope tier via ctx.resolve() - * B. Filter to callable symbol kinds (constructor-aware when callForm is set) - * C. Apply arity filtering when parameter metadata is available - * D. Apply receiver-type filtering for member calls with typed receivers - * E. Apply overload disambiguation via argument literal types (when available) + * Thin dispatcher that routes a call to the appropriate specialized resolver. * - * If filtering still leaves multiple candidates, refuse to emit a CALLS edge. + * - `free` → {@link resolveFreeCall} + * - `constructor` → {@link resolveStaticCall} (with pre-resolved tiered pool) + * - `member` with a known receiver type → {@link resolveMemberCall}, with + * file-based fallback for traits/interfaces + * - `member` without receiver type → module-alias check, then tiered lookup + * + * Replaces the former 200+ line function (SM-19: fuzzy-free call resolution). */ -/** Per-file cache for the widen path's lookupCallableByName calls. Cleared between files. */ -type WidenCache = Map; +/** + * Module-alias resolution for member calls without a receiver type. + * + * Handles Python/Ruby `import mod; mod.Symbol()` patterns where the receiver + * is a module name, not a typed variable. Uses `moduleAliasMap` to scope + * candidates to the correct module file. + */ +const resolveModuleAliasedCall = ( + call: Pick, + currentFile: string, + ctx: ResolutionContext, + widenCache?: WidenCache, + tieredOverride?: TieredCandidates, +): ResolveResult | null => { + if (!call.receiverName) return null; + const aliasMap = ctx.moduleAliasMap?.get(currentFile); + if (!aliasMap) return null; + const moduleFile = aliasMap.get(call.receiverName); + if (!moduleFile) return null; -/** @internal Exported for unit tests of D0 skip conditions (SM-11). Do not use outside tests. */ + // Reuse the caller's pre-computed tiered result when available — + // the dispatcher already called ctx.resolve(call.calledName, currentFile). + const tiered = tieredOverride ?? ctx.resolve(call.calledName, currentFile); + if (!tiered) return null; + + // Try member-form, then constructor-form (for `module.ClassName()` patterns) + let filtered = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm).filter( + (c) => c.filePath === moduleFile, + ); + if (filtered.length === 0) { + filtered = filterCallableCandidates(tiered.candidates, call.argCount, 'constructor').filter( + (c) => c.filePath === moduleFile, + ); + } + if (filtered.length === 0) { + // Widen to global callable index scoped to the aliased module file. + const cacheKey = `${call.calledName}\0${moduleFile}`; + let defs = widenCache?.get(cacheKey); + if (!defs) { + defs = ctx.symbols.lookupCallableByName(call.calledName); + widenCache?.set(cacheKey, defs); + } + filtered = filterCallableCandidates(defs, call.argCount, call.callForm).filter( + (c) => c.filePath === moduleFile, + ); + if (filtered.length === 0) { + filtered = filterCallableCandidates(defs, call.argCount, 'constructor').filter( + (c) => c.filePath === moduleFile, + ); + } + } + return filtered.length === 1 ? toResolveResult(filtered[0], tiered.tier) : null; +}; + +/** + * File-based fallback for member calls where owner-scoped resolution fails. + * + * Resolves the receiver type via `ctx.resolve()` and narrows all callable + * symbols with the method name to the receiver type's defining file(s), + * then applies ownerId filtering and overload disambiguation. + * + * Handles Rust trait dispatch (`repo.find()` where `find` is on a trait impl), + * cross-file overloaded methods, and similar patterns where ownerId + * relationships may not be established on all candidates. + */ +const resolveMemberCallByFile = ( + calledName: string, + receiverTypeName: string, + currentFile: string, + ctx: ResolutionContext, + argCount?: number, + callForm?: 'free' | 'member' | 'constructor', + overloadHints?: OverloadHints, + preComputedArgTypes?: (string | undefined)[], +): ResolveResult | null => { + const typeResolved = ctx.resolve(receiverTypeName, currentFile); + if (!typeResolved || typeResolved.candidates.length === 0) return null; + const typeNodeIds = new Set(typeResolved.candidates.map((d) => d.nodeId)); + const typeFiles = new Set(typeResolved.candidates.map((d) => d.filePath)); + + const methodPool = filterCallableCandidates( + ctx.symbols.lookupCallableByName(calledName), + argCount, + callForm, + ); + const fileFiltered = methodPool.filter((c) => typeFiles.has(c.filePath)); + if (fileFiltered.length === 1) { + return toResolveResult(fileFiltered[0], typeResolved.tier); + } + + // ownerId fallback: narrow by ownerId matching the type's nodeId + const pool = fileFiltered.length > 0 ? fileFiltered : methodPool; + const ownerFiltered = pool.filter((c) => c.ownerId && typeNodeIds.has(c.ownerId)); + if (ownerFiltered.length === 1) return toResolveResult(ownerFiltered[0], typeResolved.tier); + + // Overload disambiguation on the narrowed pool + if (fileFiltered.length > 1 || ownerFiltered.length > 1) { + const overloadPool = ownerFiltered.length > 1 ? ownerFiltered : fileFiltered; + const disambiguated = disambiguateByOverloadOrArgTypes( + overloadPool, + overloadHints, + preComputedArgTypes, + ); + if (disambiguated) return toResolveResult(disambiguated, typeResolved.tier); + } + + // Zero-match null-route: receiver type resolved but no candidate matched + // after file-based and owner-based narrowing. Refuse to emit a CALLS edge + // rather than guess — matches the SM-10 R3 null-route contract. + return null; +}; + +/** Return the sole survivor from a tiered pool after callable + arity filtering, or null. */ +const singleCandidate = ( + tiered: TieredCandidates, + argCount?: number, + callForm?: 'free' | 'member' | 'constructor', +): ResolveResult | null => { + const filtered = filterCallableCandidates(tiered.candidates, argCount, callForm); + return filtered.length === 1 ? toResolveResult(filtered[0], tiered.tier) : null; +}; + +/** @internal Exported for unit tests. Do not use outside tests. */ export const _resolveCallTargetForTesting = ( call: Pick< ExtractedCall, @@ -1519,8 +1700,6 @@ const resolveCallTarget = ( const tiered = ctx.resolve(call.calledName, currentFile); if (!tiered) return null; - // SM-13: Free function calls route through resolveFreeCall. - // Handles pure free calls (foo()) and Swift/Kotlin implicit constructors (User()). if (call.callForm === 'free') { return resolveFreeCall( call.calledName, @@ -1532,223 +1711,95 @@ const resolveCallTarget = ( preComputedArgTypes, ); } - - let filteredCandidates = filterCallableCandidates( - tiered.candidates, - call.argCount, - call.callForm, - ); - - // S0. Constructor/static fast path (SM-12): O(1) class + constructor lookup - // via lookupClassByName + lookupMethodByOwner. - // Handles callForm === 'constructor' — explicit `new User()` in Java/TS/C#/etc. - // Free-form class targets (Swift/Kotlin `User()`) are handled by - // resolveFreeCall above (SM-13). - // - // Known gaps (handled by the existing tail fallback at the bottom of - // this function, not S0): - // - `callForm === 'member'` constructor patterns (e.g. Python - // `models.User()` after `import models`, Ruby `User.new`). Extending - // S0 to cover them would require threading receiver-type resolution - // through the module-alias logic; revisit if it shows up as a hot - // spot. if (call.callForm === 'constructor') { - const staticResult = resolveStaticCall( - call.calledName, - currentFile, - ctx, - call.argCount, - tiered, - ); - if (staticResult) return staticResult; - } - - // Module-qualified constructor pattern: e.g. Python `import models; models.User()`. - // The attribute access gives callForm='member', but the callee may be a Class — a valid - // constructor target. Re-try with constructor-form filtering so that `module.ClassName()` - // emits a CALLS edge to the class node. - if (filteredCandidates.length === 0 && call.callForm === 'member') { - filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, 'constructor'); - } - - // Module-alias disambiguation: Python `import auth; auth.User()` — receiverName='auth' - // selects auth.py via moduleAliasMap. Runs for ALL member calls with a known module alias, - // not just ambiguous ones — same-file tier may shadow the correct cross-module target when - // the caller defines a function with the same name as the callee (Issue #417). - // - // Tracks `aliasNarrowed` so the D2 widening step below does NOT undo the alias filtering - // by calling lookupCallableByName again (which would re-introduce homonym candidates from other files). - let aliasNarrowed = false; - if (call.callForm === 'member' && call.receiverName) { - const aliasMap = ctx.moduleAliasMap?.get(currentFile); - if (aliasMap) { - const moduleFile = aliasMap.get(call.receiverName); - if (moduleFile) { - const aliasFiltered = filteredCandidates.filter((c) => c.filePath === moduleFile); - if (aliasFiltered.length > 0) { - filteredCandidates = aliasFiltered; - aliasNarrowed = true; - } else { - // Same-file tier returned a local match, but the alias points elsewhere. - // Widen to global candidates and filter to the aliased module's file. - // Use per-file widenCache to avoid repeated lookupCallableByName for the same - // calledName+moduleFile from multiple call sites in the same file. - const cacheKey = `${call.calledName}\0${moduleFile}`; - let fuzzyDefs = widenCache?.get(cacheKey); - if (!fuzzyDefs) { - fuzzyDefs = ctx.symbols.lookupCallableByName(call.calledName); - widenCache?.set(cacheKey, fuzzyDefs); - } - const widened = filterCallableCandidates(fuzzyDefs, call.argCount, call.callForm).filter( - (c) => c.filePath === moduleFile, - ); - if (widened.length > 0) { - filteredCandidates = widened; - aliasNarrowed = true; - } - } - } - } - } - - // D. Receiver-type filtering: for member calls with a known receiver type, - // resolve the type through the same tiered import infrastructure, then - // filter method candidates to the type's defining file. Fall back to - // fuzzy ownerId matching only when file-based narrowing is inconclusive. - // - // Applied regardless of candidate count — the sole same-file candidate may - // belong to the wrong class (e.g. super.save() should hit the parent's save, - // not the child's own save method in the same file). - if (call.callForm === 'member' && call.receiverTypeName) { - // D0. Delegate to resolveMemberCall (SM-11): owner-scoped + MRO lookup - // before falling back to the expensive D1-D4 fuzzy widening. - // Skip conditions: - // (a) overloadHints or preComputedArgTypes present — the MRO lookup may - // pick the wrong overload for same-return-type overloads since it - // does not consider argument types. D1-D4+E handles those correctly. - // (b) A module alias on call.receiverName is active for this file — the - // alias block above already narrowed `filteredCandidates` to a - // specific file. resolveMemberCall re-resolves `receiverTypeName` - // from scratch via `ctx.resolve`, which ignores that narrowing and - // could pick a homonymous class from the wrong file. Fall through to - // D1-D4 which respects the alias-filtered candidate pool. - // D0 skip for overload disambiguation: only fires when the name actually - // has multiple candidates in the tiered pool. The sequential path sets - // `overloadHints` for every call regardless of whether the method is - // overloaded — skipping D0 unconditionally would make this fast path - // dead code for the sequential pipeline. By gating on - // `filteredCandidates.length > 1`, we preserve the original intent - // (let D1-D4+E pick the right overload when there are multiple) while - // allowing D0 to fire for the common single-candidate case. - const hasOverloadConcern = - (!!overloadHints || !!preComputedArgTypes) && filteredCandidates.length > 1; - // D0 skip for active module alias: only fires when the alias block above - // actually narrowed filteredCandidates. In Python, a local variable can - // shadow an imported module name (e.g. `from models.c import C; c = C()` - // creates both a module alias `c → models/c.py` AND a typed local `c`). - // Checking `aliasNarrowed` rather than `ctx.moduleAliasMap.has(receiverName)` - // ensures D0 still runs when the method isn't in the aliased module — - // which means the receiver is a typed local variable, not a module reference. - if (!hasOverloadConcern && !aliasNarrowed) { - const memberResult = resolveMemberCall( - call.receiverTypeName, + return ( + resolveStaticCall( call.calledName, currentFile, ctx, - heritageMap, call.argCount, + tiered, + overloadHints, + preComputedArgTypes, + ) ?? singleCandidate(tiered, call.argCount, 'constructor') + ); + } + if (call.receiverTypeName) { + // Skip the owner-scoped MRO path when the tiered pool has genuine + // overload ambiguity that needs D1-D4+E handling, not D0. + const skipMember = + (!!overloadHints || !!preComputedArgTypes) && + countCallableCandidates(tiered.candidates, call.argCount, call.callForm) > 1; + // Try owner-scoped (resolveMemberCall) then file-scoped (resolveMemberCallByFile). + const memberResult = + (!skipMember + ? resolveMemberCall( + call.receiverTypeName, + call.calledName, + currentFile, + ctx, + heritageMap, + call.argCount, + ) + : null) ?? + resolveMemberCallByFile( + call.calledName, + call.receiverTypeName, + currentFile, + ctx, + call.argCount, + call.callForm, + overloadHints, + preComputedArgTypes, ); - if (memberResult) return memberResult; + if (memberResult) return memberResult; + + // Module-alias narrowing runs as a FALLBACK, after owner/file-scoped + // resolvers have returned null. This ordering is load-bearing: placing + // alias narrowing first would short-circuit unique owner-scoped answers + // when a local variable coincidentally matches an alias name, leaking + // unrelated homonyms from the aliased file onto the wrong receiver type. + // + // The type-file verification guard is load-bearing for SM-10 R3: an + // alias is only a VALID narrowing signal when the alias target file is + // among the receiver type's defining files. If the alias points at a + // file that does not hold `receiverTypeName`, any candidate we would + // pick from there would belong to an unrelated class — a cross-type + // false positive. ctx.resolve is cached per (name, file), so resolving + // the receiver type a second time here is free. + const typeResolves = ctx.resolve(call.receiverTypeName, currentFile); + const aliasMap = ctx.moduleAliasMap?.get(currentFile); + const aliasTargetFile = + call.receiverName && aliasMap ? aliasMap.get(call.receiverName) : undefined; + if ( + aliasTargetFile && + typeResolves && + typeResolves.candidates.some((c) => c.filePath === aliasTargetFile) + ) { + const aliasResult = resolveModuleAliasedCall(call, currentFile, ctx, widenCache, tiered); + if (aliasResult) return aliasResult; } - // D1. Resolve the receiver type - const typeResolved = ctx.resolve(call.receiverTypeName, currentFile); - if (typeResolved && typeResolved.candidates.length > 0) { - const typeNodeIds = new Set(typeResolved.candidates.map((d) => d.nodeId)); - const typeFiles = new Set(typeResolved.candidates.map((d) => d.filePath)); - - // D2. Widen candidates: same-file tier may miss the parent's method when - // it lives in another file. Query the callable index directly for all - // global methods with this name, then apply arity/kind filtering. - // - // When the candidate set was already narrowed by module-alias - // disambiguation, do NOT widen back to the full callable pool — that - // would undo the alias narrowing and reintroduce homonym candidates - // from other files. - const methodPool = - filteredCandidates.length <= 1 && !aliasNarrowed - ? filterCallableCandidates( - ctx.symbols.lookupCallableByName(call.calledName), - call.argCount, - call.callForm, - ) - : filteredCandidates; - - // D3. File-based: prefer candidates whose filePath matches the resolved type's file - const fileFiltered = methodPool.filter((c) => typeFiles.has(c.filePath)); - if (fileFiltered.length === 1) { - return toResolveResult(fileFiltered[0], tiered.tier); - } - - // D4. ownerId fallback: narrow by ownerId matching the type's nodeId - const pool = fileFiltered.length > 0 ? fileFiltered : methodPool; - const ownerFiltered = pool.filter((c) => c.ownerId && typeNodeIds.has(c.ownerId)); - if (ownerFiltered.length === 1) { - return toResolveResult(ownerFiltered[0], tiered.tier); - } - // E. Try overload disambiguation on the narrowed pool - if (fileFiltered.length > 1 || ownerFiltered.length > 1) { - const overloadPool = ownerFiltered.length > 1 ? ownerFiltered : fileFiltered; - const disambiguated = overloadHints - ? tryOverloadDisambiguation(overloadPool, overloadHints) - : preComputedArgTypes - ? matchCandidatesByArgTypes(overloadPool, preComputedArgTypes) - : null; - if (disambiguated) return toResolveResult(disambiguated, tiered.tier); - return null; - } - - // Zero-match null-route: we committed to receiver narrowing (D1 succeeded) - // but both file-based (D3) and owner-based (D4) filters produced zero - // matches. The lone candidate in `filteredCandidates` does not belong to - // this receiver type — refuse to emit a CALLS edge rather than fall - // through to the permissive single-candidate tail return. - // - // Addresses Codex review finding R3 (PR #744): member calls where - // widening picked a globally-matching symbol that has no - // relationship to the receiver's class hierarchy were silently - // producing false-positive edges. Example: Rust `c.trait_only()` where - // `trait_only` is captured as a Function node with no ownerId — it - // matches the name but fails both file and owner narrowing, so the - // old tail return would pick it incorrectly. - if (fileFiltered.length === 0 && ownerFiltered.length === 0) { - return null; - } + // SM-10 R3 null-route: when the receiver type resolves to indexed types + // but no scoped resolver (nor the guarded alias fallback) produced a + // match, that's a genuine miss — refuse to emit a CALLS edge rather + // than guess via an unscoped singleCandidate that ignores the class + // hierarchy. When the type is NOT in the index (PHP `mixed`, dynamic + // types, unresolvable aliases), the scoped resolvers had nothing to + // work with and singleCandidate is the correct last resort. + if (typeResolves && typeResolves.candidates.length > 0) { + return null; // null-route: type resolved, no candidate matched } + return singleCandidate(tiered, call.argCount, call.callForm); } - - // E. Overload disambiguation: when multiple candidates survive arity + receiver filtering, - // try matching argument types against parameter types (Phase P). - // Sequential path uses AST-based hints; worker path uses pre-computed argTypes. - if (filteredCandidates.length > 1) { - const disambiguated = overloadHints - ? tryOverloadDisambiguation(filteredCandidates, overloadHints) - : preComputedArgTypes - ? matchCandidatesByArgTypes(filteredCandidates, preComputedArgTypes) - : null; - if (disambiguated) return toResolveResult(disambiguated, tiered.tier); - } - - if (filteredCandidates.length !== 1) { - // See `dedupSwiftExtensionCandidates` — returns non-null only when the - // Swift-extension same-name collision heuristic applies. Otherwise null- - // route (ambiguous candidates should not produce a wrong edge). - const deduped = dedupSwiftExtensionCandidates(filteredCandidates, tiered.tier); - if (deduped) return deduped; - return null; - } - - return toResolveResult(filteredCandidates[0], tiered.tier); + // Member call with no inferred receiver type — e.g. Python `mod.fn()` + // where `mod` is a module alias. Module-alias narrowing is the primary + // disambiguation signal here. Also consulted from the typed-member + // branch above as a guarded fallback after owner/file-scoped resolvers. + return ( + resolveModuleAliasedCall(call, currentFile, ctx, widenCache, tiered) ?? + singleCandidate(tiered, call.argCount, call.callForm) + ); }; // ── Scope key helpers ──────────────────────────────────────────────────── @@ -1762,9 +1813,6 @@ const resolveCallTarget = ( // classes (e.g. User.save@100 and Repo.save@200 are distinct keys). // Lookup uses a secondary funcName-only index built in lookupReceiverType. -/** Extract the function name from a scope key ("funcName@startIndex" → "funcName"). */ -const extractFuncNameFromScope = (scope: string): string => scope.slice(0, scope.indexOf('@')); - /** Extract the bare function name from a sourceId. * Handles both unqualified ("Function:filepath:funcName" → "funcName") * and qualified ("Function:filepath:ClassName.funcName" → "funcName"). @@ -1920,17 +1968,10 @@ const resolveFieldOwnership = ( * * After deduplication: * - * - 0 unique matches → `undefined` (owner-scoped path has no answer; D1-D4 - * fallback in `resolveCallTarget` may still find something via callable index) + * - 0 unique matches → `undefined` (owner-scoped path has no answer) * - 1 unique match → return it * - ≥2 unique matches → `undefined` (genuine homonym ambiguity; don't silently pick one) * - * This absorbs what was previously D4's job inside `resolveCallTarget` — "filter - * candidates to those whose ownerId is in the receiver type's nodeId set" — into the - * owner-scoped path, aligning with the plan's target: - * - * `resolveCallTarget` D2 widening → `model.lookupMethodWithMRO(ownerNodeId, name)` - * * The returned `tier` reflects how the owner TYPE was resolved (not the method name). * Threaded out here so callers don't need a second `ctx.resolve(ownerType, ...)` call — * this decouples callers from `ctx.resolve`'s per-file caching contract. @@ -2003,14 +2044,10 @@ const resolveMethodByOwner = ( * method lookup and, when a {@link HeritageMap} is provided, walks the MRO chain * via {@link lookupMethodByOwnerWithMRO}. * - * {@link resolveCallTarget} delegates here for member calls before falling back - * to the more expensive fuzzy-widening path (D1-D4). + * {@link resolveCallTarget} delegates here for member calls. * - * **SEMANTIC CHANGE (2026-04-09):** The confidence tier now reflects how the - * owner TYPE was resolved, not how the method NAME was resolved globally. The - * previous D0 fast path in `resolveCallTarget` used `tiered.tier` from - * `ctx.resolve(calledName, ...)` — a name-based tier that matched what D1-D4 - * fuzzy widening would produce. The new tier is owner-type-based, which is + * **SEMANTIC CHANGE (2026-04-09):** The confidence tier reflects how the + * owner TYPE was resolved, not how the method NAME was resolved globally. * more accurate for owner-scoped resolution (the discriminant IS the class, * not the method name). Downstream consumers that filter CALLS edges by * confidence threshold may see shifted values on otherwise-unchanged code. @@ -2060,14 +2097,10 @@ export const resolveMemberCall = ( * by delegating to {@link resolveStaticCall} when the tiered pool contains * class-like targets. * - * {@link resolveCallTarget} delegates here for `callForm === 'free'` before - * processing constructor and member calls. + * {@link resolveCallTarget} delegates here for `callForm === 'free'`. * - * **Asymmetry vs `resolveCallTarget`:** `resolveFreeCall` intentionally does - * NOT take a `widenCache` parameter and does NOT run a D2 widening - * pass. Member calls (`resolveCallTarget`'s main body) widen via - * `lookupCallableByName` to reach parent-class methods defined in different files; - * free calls have no receiver type and rely exclusively on the tiered pool + * `resolveFreeCall` does not take a `widenCache` parameter. Free calls + * have no receiver type and rely exclusively on the tiered pool * from `ctx.resolve()`. * * @param calledName - The called function name (e.g. 'doStuff') @@ -2182,8 +2215,7 @@ export const resolveFreeCall = ( * Uses {@link SymbolTable.lookupClassByName} for O(1) class lookup and * {@link SymbolTable.lookupMethodByOwner} for constructor resolution. * {@link resolveCallTarget} delegates here for constructor and free-form calls - * that target a class, before falling back to the more expensive fuzzy-widening - * path (D1-D4). + * that target a class. * * Resolution strategy: * 1. `lookupClassByName(className)` — O(1) pre-check; bail early if no class exists. @@ -2224,6 +2256,8 @@ export const resolveStaticCall = ( ctx: ResolutionContext, argCount?: number, tieredOverride?: TieredCandidates, + overloadHints?: OverloadHints, + preComputedArgTypes?: (string | undefined)[], ): ResolveResult | null => { // 1. Pre-check: does a class with this name exist at all? (O(1)) // This guards against the expensive `ctx.resolve` walk when the name @@ -2285,10 +2319,30 @@ export const resolveStaticCall = ( // with two distinct Constructor nodes across multiple class candidates): // the same Constructor nodes are indexed under the class name in the // tiered pool, so `.some(Constructor)` is true here and we defer to - // `filterCallableCandidates` downstream rather than guess which overload - // to pick. Do not remove this check without also handling the ambiguous - // step-3 path explicitly. + // step 4.5 (overload/arg-type disambiguation) or the caller's fallback. + // Do not remove this check without also handling the ambiguous step-3 + // path explicitly. if (typeResolved.candidates.some((c) => c.type === 'Constructor')) { + // 4.5. Overload / arg-type disambiguation for ambiguous or ownerless + // Constructor pools. When the caller supplied a narrowing signal + // (AST-based overload hints from the sequential path, or pre- + // computed arg types from the worker path), give disambiguation a + // chance before null-routing. Symmetric with resolveMemberCallByFile's + // disambiguation pass — both resolvers now share the same signal + // precedence via disambiguateByOverloadOrArgTypes. Only fires when + // at least one narrowing signal is present; preserves SM-10 R3 for + // genuinely ambiguous cases with no disambiguating input. + if (overloadHints || preComputedArgTypes) { + const ctorPool = filterCallableCandidates(typeResolved.candidates, argCount, 'constructor'); + if (ctorPool.length > 1) { + const disambiguated = disambiguateByOverloadOrArgTypes( + ctorPool, + overloadHints, + preComputedArgTypes, + ); + if (disambiguated) return toResolveResult(disambiguated, typeResolved.tier); + } + } return null; } @@ -2527,7 +2581,7 @@ const walkMixedChain = ( continue; } } - // Fallback: fuzzy resolution via resolveCallTarget (cross-file, inherited, etc.) + // Fallback: resolve via resolveCallTarget dispatcher (delegates to resolveMemberCall) const resolved = resolveCallTarget( { calledName: step.name, callForm: 'member', receiverTypeName: currentType }, filePath, diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index a12dd0528..e69250ceb 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -2493,6 +2493,328 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { expect(authSave).toBeDefined(); expect(userSave).toBeUndefined(); }); + + it('module-alias guard (real homonym): both files imported, alias narrows typed member call to aliased file', async () => { + // When both homonym files are imported by the caller, import-scoped + // tiering no longer narrows the tiered pool — the dispatcher sees two + // `save` candidates. Module-alias narrowing is the only remaining + // disambiguation signal. The typed-member branch must consult the alias + // map (as a guarded fallback after owner/file-scoped resolvers fail) or + // null-route silently. + const authModFile = 'src/auth_mod.py'; + const userModFile = 'src/user_mod.py'; + const appFile = 'src/app.py'; + const authUserId = 'class:src/auth_mod.py:User'; + const userUserId = 'class:src/user_mod.py:User'; + const authSaveId = 'method:src/auth_mod.py:save'; + const userSaveId = 'method:src/user_mod.py:save'; + + ctx.symbols.add(authModFile, 'User', authUserId, 'Class'); + ctx.symbols.add(userModFile, 'User', userUserId, 'Class'); + ctx.symbols.add(authModFile, 'save', authSaveId, 'Method', { + ownerId: authUserId, + returnType: 'bool', + }); + ctx.symbols.add(userModFile, 'save', userSaveId, 'Method', { + ownerId: userUserId, + returnType: 'bool', + }); + // BOTH files imported by app.py — creates real ambiguity in tiered pool. + ctx.importMap.set(appFile, new Set([authModFile, userModFile])); + // Alias: `auth` points to auth_mod.py. + ctx.moduleAliasMap.set(appFile, new Map([['auth', authModFile]])); + + // Call `auth.User.save(user)` — receiverName is `auth` (matches alias), + // receiverTypeName is `User` (the class). This is the class-as-receiver + // static-style pattern parse-worker emits when it sees `auth.User.save(x)`. + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'save', + sourceId: 'Function:src/app.py:run', + argCount: 1, + callForm: 'member', + receiverName: 'auth', + receiverTypeName: 'User', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + // Module alias narrows to auth_mod.py. Without it the dispatcher would + // null-route because both User classes own a `save` method and there's + // no heritage or overload signal to pick between them. + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe(authSaveId); + }); + + it('owner-scoped wins over alias narrowing: unique owner-scoped answer beats coincidental alias on unrelated file', async () => { + // Receiver type `User` has exactly one definition, in models.py. Module + // alias `auth → auth.py` exists (because the caller also imports auth.py + // for its own reasons), and auth.py contains an unrelated `Widget` class + // with a homonym `save` method. The caller has `receiverName='auth'` + // (e.g., a local variable coincidentally named `auth`), + // `receiverTypeName='User'`. Owner-scoped resolution must win — alias + // narrowing must not short-circuit a unique correct answer with an + // unrelated homonym from the aliased file. + const modelsFile = 'src/models.py'; + const authFile = 'src/auth.py'; + const appFile = 'src/app.py'; + const modelsUserId = 'class:src/models.py:User'; + const authWidgetId = 'class:src/auth.py:Widget'; + const modelsSaveId = 'method:src/models.py:User:save'; + const authSaveId = 'method:src/auth.py:Widget:save'; + + ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); + ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { + ownerId: modelsUserId, + returnType: 'None', + }); + ctx.symbols.add(authFile, 'save', authSaveId, 'Method', { + ownerId: authWidgetId, + returnType: 'None', + }); + ctx.importMap.set(appFile, new Set([modelsFile, authFile])); + ctx.moduleAliasMap.set(appFile, new Map([['auth', authFile]])); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'save', + sourceId: 'Function:src/app.py:run', + argCount: 1, + callForm: 'member', + receiverName: 'auth', + receiverTypeName: 'User', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + // Owner-scoped runs first and uniquely resolves User.save to models.py. + // Alias narrowing never fires because the scoped resolver already won. + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe(modelsSaveId); + }); + + it('alias narrowing rejects unrelated target type: null-route when alias file does not hold receiver type', async () => { + // Receiver type `User` lives only in models.py, but has no `save` method + // defined. Alias `auth → auth.py`, and auth.py contains an unrelated + // `Widget.save`. Owner-scoped and file-scoped resolvers return null (no + // save on User). Without the type-file verification guard, alias + // narrowing would pick auth.py's `Widget.save` — a cross-type false + // positive. With the guard, auth.py is not in the receiver type's + // defining-files set (which is {models.py}), so alias narrowing bails + // and SM-10 R3 null-routes. + const modelsFile = 'src/models.py'; + const authFile = 'src/auth.py'; + const appFile = 'src/app.py'; + const modelsUserId = 'class:src/models.py:User'; + const authWidgetId = 'class:src/auth.py:Widget'; + const authSaveId = 'method:src/auth.py:Widget:save'; + + ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); + // NO save on User — deliberately absent to force null-route. + ctx.symbols.add(authFile, 'save', authSaveId, 'Method', { + ownerId: authWidgetId, + returnType: 'None', + }); + ctx.importMap.set(appFile, new Set([modelsFile, authFile])); + ctx.moduleAliasMap.set(appFile, new Map([['auth', authFile]])); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'save', + sourceId: 'Function:src/app.py:run', + argCount: 1, + callForm: 'member', + receiverName: 'auth', + receiverTypeName: 'User', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + // Null-route: no CALLS edge. The type-file guard prevented the alias + // from leaking auth.py's Widget.save onto a User-typed receiver. + expect(rels).toHaveLength(0); + }); + + it('alias fallthrough: receiverName not in alias map falls through to owner-scoped resolver', async () => { + // Receiver variable `user` does NOT match any alias entry (alias only + // covers `auth`). Owner-scoped resolution must run to completion and + // pick models.py's User.save — the alias helper's early-bail must not + // interfere with unrelated typed member calls. This exercises the 99% + // hot path where alias narrowing is irrelevant. + const modelsFile = 'src/models.py'; + const authFile = 'src/auth.py'; + const appFile = 'src/app.py'; + const modelsUserId = 'class:src/models.py:User'; + const modelsSaveId = 'method:src/models.py:User:save'; + + ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { + ownerId: modelsUserId, + returnType: 'None', + }); + ctx.importMap.set(appFile, new Set([modelsFile, authFile])); + ctx.moduleAliasMap.set(appFile, new Map([['auth', authFile]])); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'save', + sourceId: 'Function:src/app.py:run', + argCount: 0, + callForm: 'member', + receiverName: 'user', // NOT 'auth' — no alias match + receiverTypeName: 'User', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe(modelsSaveId); + }); + + it('alias fallthrough: alias target file has no matching method falls through to owner-scoped', async () => { + // Alias `auth → empty.py` where empty.py exists in the import map but + // has no `save` method at all. Owner-scoped finds models.py's User.save + // uniquely. Even if the type-file guard let alias narrowing fire (it + // won't, because empty.py isn't in the receiver type's files), the + // helper would return null and resolution must still succeed. + const modelsFile = 'src/models.py'; + const emptyFile = 'src/empty.py'; + const appFile = 'src/app.py'; + const modelsUserId = 'class:src/models.py:User'; + const modelsSaveId = 'method:src/models.py:User:save'; + + ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { + ownerId: modelsUserId, + returnType: 'None', + }); + // empty.py: no symbols at all. + ctx.importMap.set(appFile, new Set([modelsFile, emptyFile])); + ctx.moduleAliasMap.set(appFile, new Map([['auth', emptyFile]])); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'save', + sourceId: 'Function:src/app.py:run', + argCount: 0, + callForm: 'member', + receiverName: 'auth', + receiverTypeName: 'User', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe(modelsSaveId); + }); + + it('constructor overload disambiguation: same-arity ownerless constructors picked via preComputedArgTypes', async () => { + // When two homonym constructors across different files have the same + // arity but different parameter types, `resolveStaticCall` correctly + // bails (step 3 ambiguity → step 4 bail because the tiered pool contains + // Constructor nodes). Step 4.5 then runs overload/arg-type disambiguation + // on the constructor-filtered pool, picking the string overload when the + // caller supplies matching `argTypes` / `preComputedArgTypes`. + const userFile = 'src/models/User.ts'; + const repoFile = 'src/models/Repo.ts'; + const appFile = 'src/app.ts'; + const userClassId = 'Class:src/models/User.ts:User'; + const repoClassId = 'Class:src/models/Repo.ts:User'; + const userCtorId = 'Constructor:src/models/User.ts:User(string)'; + const repoCtorId = 'Constructor:src/models/Repo.ts:User(number)'; + + ctx.symbols.add(userFile, 'User', userClassId, 'Class'); + ctx.symbols.add(repoFile, 'User', repoClassId, 'Class'); + ctx.symbols.add(userFile, 'User', userCtorId, 'Constructor', { + ownerId: userClassId, + parameterCount: 1, + parameterTypes: ['string'], + }); + ctx.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { + ownerId: repoClassId, + parameterCount: 1, + parameterTypes: ['number'], + }); + ctx.importMap.set(appFile, new Set([userFile, repoFile])); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'User', + sourceId: 'Function:src/app.ts:main', + argCount: 1, + callForm: 'constructor', + argTypes: ['string'], + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe(userCtorId); + }); + + it('constructor overload disambiguation: null-routes when disambiguation cannot pick unique survivor', async () => { + // Control test for Finding 2 fix: when `preComputedArgTypes` does not + // match any candidate uniquely, the dispatcher must null-route rather + // than pick arbitrarily. Preserves SM-10 R3. + const userFile = 'src/models/User.ts'; + const repoFile = 'src/models/Repo.ts'; + const appFile = 'src/app.ts'; + const userClassId = 'Class:src/models/User.ts:User'; + const repoClassId = 'Class:src/models/Repo.ts:User'; + const userCtorId = 'Constructor:src/models/User.ts:User(string)'; + const repoCtorId = 'Constructor:src/models/Repo.ts:User(string)'; + + ctx.symbols.add(userFile, 'User', userClassId, 'Class'); + ctx.symbols.add(repoFile, 'User', repoClassId, 'Class'); + // Both constructors take `string` — genuinely ambiguous. + ctx.symbols.add(userFile, 'User', userCtorId, 'Constructor', { + ownerId: userClassId, + parameterCount: 1, + parameterTypes: ['string'], + }); + ctx.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { + ownerId: repoClassId, + parameterCount: 1, + parameterTypes: ['string'], + }); + ctx.importMap.set(appFile, new Set([userFile, repoFile])); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'User', + sourceId: 'Function:src/app.ts:main', + argCount: 1, + callForm: 'constructor', + argTypes: ['string'], + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(0); + }); }); // ---- processAssignmentsFromExtracted: Phase 9 accumulator fallback ---- diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index 4763adfcc..0f12851d6 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -1735,31 +1735,36 @@ describe('resolveMemberCall', () => { }); // --------------------------------------------------------------------------- -// T1: D0 skip-condition tests — verify resolveCallTarget bypasses the -// resolveMemberCall fast path when overloadHints, preComputedArgTypes, or a -// module alias is active. +// T1: resolveCallTarget thin dispatcher (SM-19) — verify the dispatcher +// routes member/constructor/free calls to the appropriate specialized resolver. // --------------------------------------------------------------------------- -describe('resolveCallTarget D0 skip conditions (SM-11)', () => { +// --------------------------------------------------------------------------- +// resolveCallTarget thin dispatcher (SM-19) +// After SM-19, resolveCallTarget is a thin dispatcher that routes to +// resolveMemberCall, resolveStaticCall, or resolveFreeCall. The D0-D4 fuzzy +// widening paths have been removed. +// --------------------------------------------------------------------------- + +describe('resolveCallTarget thin dispatcher (SM-19)', () => { let ctx: ResolutionContext; beforeEach(() => { ctx = createResolutionContext(); }); - it('module alias: picks alias-scoped class over homonym (D0 actually bypassed)', () => { + it('module alias homonyms: dispatcher resolves via module-alias narrowing to aliased file', () => { // Python-style: `import auth; auth.User.save()` where BOTH auth.py and - // other.py define a `User` class with a `save` method. The test proves: + // other.py define a `User` class with a `save` method. // - // 1. Without the alias: resolveMemberCall sees two homonym Users, - // both own `save`, and correctly returns null (refuses to guess). - // 2. With the alias: D0 is skipped via `hasActiveModuleAlias`, and - // D1-D4 — respecting the alias-narrowed filteredCandidates — picks - // the auth.py User.save method. - // - // A regression where D0 silently ran would produce null (ambiguous) - // instead of the correct answer, so this test actually exercises the - // skip path rather than just verifying a single-candidate happy path. + // When both homonym files are imported, owner-scoped resolution sees + // genuine ambiguity (both `User` classes own a `save` method) and the + // only remaining disambiguation signal is the module alias on + // `call.receiverName`. The dispatcher consults alias narrowing as a + // guarded fallback after owner/file-scoped resolvers return null; the + // type-file verification guard requires the alias target file to be + // among the receiver type's defining files before alias narrowing is + // considered a valid signal. ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { returnType: 'None', @@ -1773,37 +1778,25 @@ describe('resolveCallTarget D0 skip conditions (SM-11)', () => { ctx.importMap.set('src/app.py', new Set(['src/auth.py', 'src/other.py'])); ctx.moduleAliasMap.set('src/app.py', new Map([['auth', 'src/auth.py']])); - // Control: without alias narrowing, resolveMemberCall sees both Users - // own `save` and correctly refuses to pick one. - const ambiguous = resolveMemberCall('User', 'save', 'src/app.py', ctx); - expect(ambiguous).toBeNull(); - - // With alias narrowing active, D0 is skipped and D1-D4 picks auth.py's - // User.save because the alias block already narrowed filteredCandidates - // to auth.py (and the D2 widening step is gated on `!aliasNarrowed`). - const aliased = _resolveCallTargetForTesting( + const result = _resolveCallTargetForTesting( { calledName: 'save', callForm: 'member', receiverTypeName: 'User', - receiverName: 'auth', // triggers hasActiveModuleAlias → D0 skipped + receiverName: 'auth', }, 'src/app.py', ctx, ); - expect(aliased).not.toBeNull(); - expect(aliased!.nodeId).toBe('method:auth:User:save'); + // Module-alias narrowing picks auth.py's save, not other.py's. + expect(result).not.toBeNull(); + expect(result?.nodeId).toBe('method:auth:User:save'); }); - it('overloadHints present: D0 bypassed, D1-D4 handles resolution', () => { - // When overloadHints is supplied, the D0 fast path must be skipped - // because lookupMethodByOwner does not consider argument types and - // would pick an arbitrary overload for same-return-type overloads. - // - // This test verifies that the skip does not break resolution: passing - // a dummy overloadHints object should still yield the correct method - // via the D1-D4 path. + it('overloadHints ignored for member calls — resolveMemberCall resolves directly', () => { + // With the thin dispatcher, overloadHints are not passed to resolveMemberCall + // (it does not accept them). Single-candidate member calls still resolve. ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', @@ -1811,8 +1804,6 @@ describe('resolveCallTarget D0 skip conditions (SM-11)', () => { }); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); - // Minimal stub; D1-D4 only calls tryOverloadDisambiguation when there are - // multiple candidates, so an empty object is fine for single-candidate cases. const dummyHints = {} as OverloadHints; const result = _resolveCallTargetForTesting( @@ -1830,10 +1821,10 @@ describe('resolveCallTarget D0 skip conditions (SM-11)', () => { expect(result!.nodeId).toBe('method:User:save'); }); - it('preComputedArgTypes present: D0 bypassed, D1-D4 handles resolution', () => { - // Analogous to the overloadHints case: when preComputedArgTypes is supplied - // (worker path), D0 must be skipped so that type-based overload - // disambiguation in D1-D4 is authoritative. + it('preComputedArgTypes ignored for member calls — resolveMemberCall resolves directly', () => { + // Analogous to the overloadHints case: thin dispatcher delegates to + // resolveMemberCall which resolves the single candidate without needing + // argument-type disambiguation. ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', From 08541e28573ab21d4cb04261767b4fe655778d5c Mon Sep 17 00:00:00 2001 From: "Mr. WorldwideBrown" Date: Sat, 11 Apr 2026 15:54:47 +0530 Subject: [PATCH 03/15] Fix HTTP client vs Express route detection and Spring interface attribution (#780) * fix: correctly identify HTTP client calls vs Express routes in receiver extraction * fix: skip Spring route extraction for Feign client interfaces * fix: address review feedback - receiver walk edge case, regex anchoring, add tests * style: fix prettier formatting in route extractor and test files --- .../group/extractors/http-route-extractor.ts | 12 ++ .../core/ingestion/workers/parse-worker.ts | 51 ++++++- .../unit/group/http-route-extractor.test.ts | 72 ++++++++++ .../test/unit/receiver-extraction.test.ts | 125 ++++++++++++++++++ 4 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 gitnexus/test/unit/receiver-extraction.test.ts diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index 8dfb242bf..ebb4c668d 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -257,6 +257,18 @@ export class HttpRouteExtractor implements ContractExtractor { private scanSpringProviders(content: string, filePath: string): ExtractedContract[] { const out: ExtractedContract[] = []; + + // Skip Feign/client interfaces — annotated methods in interfaces are + // consumers (Feign, JAX-RS proxies), not provider endpoints. + // Anchored to line start (with optional access modifier) so we do not + // match "interface" inside comments or string literals. + if ( + /^\s*(?:public\s+)?interface\s+\w+/m.test(content) && + !/@(?:Rest)?Controller\b/.test(content) + ) { + return out; + } + let classPrefix = ''; const classRm = content.match(/@RequestMapping\s*\(\s*"([^"]+)"/); if (classRm) classPrefix = classRm[1].replace(/\/+$/, ''); diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 7abff42b4..f229b4cad 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -853,6 +853,11 @@ const HTTP_CLIENT_RECEIVERS = new Set([ 'apiclient', 'client', 'httpclient', + 'api', + '$http', + 'session', + 'httpservice', + 'conn', ]); // Decorator names that indicate HTTP route handlers (NestJS, Flask, FastAPI, Spring) @@ -1588,7 +1593,35 @@ const processFileGroup = ( // as Express route registrations. const callNode = captureMap['express_route']; const funcNode = callNode.childForFieldName?.('function') ?? callNode.children?.[0]; - const receiverNode = funcNode?.childForFieldName?.('object') ?? funcNode?.children?.[0]; + // Walk through nested member_expressions and call_expressions to + // reach the innermost receiver identifier. Handles chains like: + // this.httpService.get('/path') -> member chain -> 'httpservice' + // getClient().get('/path') -> call_expression -> 'getclient' + // axios.get('/path') -> bare identifier -> 'axios' + let receiverNode = funcNode?.childForFieldName?.('object') ?? funcNode?.children?.[0]; + while ( + receiverNode?.type === 'member_expression' || + receiverNode?.type === 'call_expression' + ) { + if (receiverNode.type === 'member_expression') { + // Drill into the property (rightmost part) of the member expression + const propNode = receiverNode.childForFieldName?.('property'); + if (propNode) { + receiverNode = propNode; + } else { + break; + } + } else { + // call_expression: unwrap to the function being called + const innerFunc = + receiverNode.childForFieldName?.('function') ?? receiverNode.children?.[0]; + if (innerFunc && innerFunc !== receiverNode) { + receiverNode = innerFunc; + } else { + break; + } + } + } const receiverText = receiverNode?.text?.toLowerCase() ?? ''; if (HTTP_CLIENT_RECEIVERS.has(receiverText)) { @@ -1998,6 +2031,22 @@ const processFileGroup = ( ? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300)) : null; + // Suppress Spring framework hint for methods inside interfaces + // (Feign clients, JAX-RS proxies are consumers, not providers) + if (frameworkHint && definitionNode) { + let classCheck = definitionNode.parent; + while (classCheck) { + if (classCheck.type === 'interface_declaration') { + frameworkHint = null; + break; + } + if (classCheck.type === 'class_declaration' || classCheck.type === 'program') { + break; + } + classCheck = classCheck.parent; + } + } + // Decorators appear on lines immediately before their definition; allow up to // MAX_DECORATOR_SCAN_LINES gap for blank lines / multi-line decorator stacks. const MAX_DECORATOR_SCAN_LINES = 5; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 2290c806a..d4c0db3eb 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -326,6 +326,78 @@ async def create_user(user: UserCreate): }); }); + describe('interface regex anchoring', () => { + it('skips Feign client interfaces (no @Controller)', async () => { + const dir = path.join(tmpDir, 'feign-skip'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/UserClient.java'), + ` +package com.example; +@FeignClient(name = "user-service") +public interface UserClient { + @GetMapping("/users") + List getUsers(); +} +`, + ); + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + expect(contracts.filter((c) => c.role === 'provider')).toHaveLength(0); + }); + + it('does NOT skip when @RestController is present', async () => { + const dir = path.join(tmpDir, 'ctrl-iface'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/UserController.java'), + ` +@RestController +@RequestMapping("/api") +public class UserController { + @GetMapping("/users") + public List list() { return null; } +} +`, + ); + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + expect(contracts.filter((c) => c.role === 'provider').length).toBeGreaterThanOrEqual(1); + }); + + it('does NOT false-positive on interface in comments', async () => { + const dir = path.join(tmpDir, 'iface-comment'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/Api.java'), + ` +// implements the interface UserApi +public class Api { + @GetMapping("/health") + public String health() { return "ok"; } +} +`, + ); + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + expect(contracts.filter((c) => c.role === 'provider').length).toBeGreaterThanOrEqual(1); + }); + + it('does NOT false-positive on interface in a string', async () => { + const dir = path.join(tmpDir, 'iface-str'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/Svc.java'), + ` +public class Svc { + String desc = "implements interface Foo"; + @GetMapping("/status") + public String status() { return desc; } +} +`, + ); + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + expect(contracts.filter((c) => c.role === 'provider').length).toBeGreaterThanOrEqual(1); + }); + }); + describe('path normalization', () => { it('strips trailing slash', async () => { const dir = path.join(tmpDir, 'trailing'); diff --git a/gitnexus/test/unit/receiver-extraction.test.ts b/gitnexus/test/unit/receiver-extraction.test.ts new file mode 100644 index 000000000..1bd6f9c39 --- /dev/null +++ b/gitnexus/test/unit/receiver-extraction.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; +import { getProvider } from '../../src/core/ingestion/languages/index.js'; +import { SupportedLanguages } from 'gitnexus-shared'; + +const HTTP_CLIENT_RECEIVERS = new Set([ + 'axios', + 'request', + 'fetch', + 'http', + 'https', + 'got', + 'ky', + 'superagent', + 'needle', + 'undici', + 'apiclient', + 'client', + 'httpclient', + 'api', + '$http', + 'session', + 'httpservice', + 'conn', +]); + +function extractReceiverText(callNode: SyntaxNode): string { + const funcNode = callNode.childForFieldName?.('function') ?? callNode.children?.[0]; + let receiverNode = funcNode?.childForFieldName?.('object') ?? funcNode?.children?.[0]; + while (receiverNode?.type === 'member_expression' || receiverNode?.type === 'call_expression') { + if (receiverNode.type === 'member_expression') { + const p = receiverNode.childForFieldName?.('property'); + if (p) { + receiverNode = p; + } else { + break; + } + } else { + const inner = receiverNode.childForFieldName?.('function') ?? receiverNode.children?.[0]; + if (inner && inner !== receiverNode) { + receiverNode = inner; + } else { + break; + } + } + } + return receiverNode?.text?.toLowerCase() ?? ''; +} + +function extractExpressRouteReceivers(parser: Parser, code: string) { + const provider = getProvider(SupportedLanguages.TypeScript); + const tree = parser.parse(code); + const query = new Parser.Query(parser.getLanguage(), provider.treeSitterQueries!); + const results: Array<{ method: string; path: string; receiverText: string }> = []; + for (const match of query.matches(tree.rootNode)) { + const cm: Record = {}; + for (const c of match.captures) cm[c.name] = c.node; + if (cm['express_route'] && cm['express_route.method'] && cm['express_route.path']) { + results.push({ + method: cm['express_route.method'].text, + path: cm['express_route.path'].text, + receiverText: extractReceiverText(cm['express_route']), + }); + } + } + return results; +} + +describe('receiver extraction (express_route walk)', () => { + const parser = new Parser(); + parser.setLanguage(TypeScript.typescript); + + it('bare identifier: app.get()', () => { + const r = extractExpressRouteReceivers(parser, 'app.get("/api/users", h);'); + expect(r[0]?.receiverText).toBe('app'); + expect(HTTP_CLIENT_RECEIVERS.has('app')).toBe(false); + }); + + it('bare identifier: axios.get() is HTTP client', () => { + const r = extractExpressRouteReceivers(parser, 'axios.get("/api/users");'); + expect(r[0]?.receiverText).toBe('axios'); + expect(HTTP_CLIENT_RECEIVERS.has('axios')).toBe(true); + }); + + it('member chain: this.httpService.get()', () => { + const r = extractExpressRouteReceivers( + parser, + 'class S { f() { this.httpService.get("/d"); } }', + ); + const hit = r.find((x) => x.path === '/d'); + expect(hit?.receiverText).toBe('httpservice'); + expect(HTTP_CLIENT_RECEIVERS.has('httpservice')).toBe(true); + }); + + it('member chain: this.client.post()', () => { + const r = extractExpressRouteReceivers(parser, 'class A { s() { this.client.post("/x"); } }'); + const hit = r.find((x) => x.path === '/x'); + expect(hit?.receiverText).toBe('client'); + expect(HTTP_CLIENT_RECEIVERS.has('client')).toBe(true); + }); + + it('call_expression: getClient().get()', () => { + const r = extractExpressRouteReceivers(parser, 'getClient().get("/api/data");'); + expect(r.find((x) => x.path === '/api/data')?.receiverText).toBe('getclient'); + }); + + it('call_expression: createHttpClient().post()', () => { + const r = extractExpressRouteReceivers(parser, 'createHttpClient().post("/s");'); + expect(r.find((x) => x.path === '/s')?.receiverText).toBe('createhttpclient'); + }); + + it('mixed: factory().api.get()', () => { + const r = extractExpressRouteReceivers(parser, 'factory().api.get("/items");'); + expect(r.find((x) => x.path === '/items')?.receiverText).toBe('api'); + expect(HTTP_CLIENT_RECEIVERS.has('api')).toBe(true); + }); + + it('router.post() is NOT an HTTP client', () => { + const r = extractExpressRouteReceivers(parser, 'router.post("/api/items", h);'); + expect(r[0]?.receiverText).toBe('router'); + expect(HTTP_CLIENT_RECEIVERS.has('router')).toBe(false); + }); +}); From 49112016645a2c1894cbb72a6e5ff4bafced5371 Mon Sep 17 00:00:00 2001 From: "Mr. WorldwideBrown" Date: Sat, 11 Apr 2026 15:59:52 +0530 Subject: [PATCH 04/15] fix: map diff hunks to symbol line ranges in detect_changes (#779) * fix: map diff hunks to symbol line ranges in detect_changes The detect_changes tool previously used `git diff --name-only` and picked the first 20 arbitrary symbols from each changed file. This produced false positives (unchanged symbols reported as modified) and false negatives (actually changed symbols dropped by the LIMIT). Now uses `git diff -U0` to get unified diff with hunk headers, parses the @@ line ranges, and queries for symbols whose [startLine, endLine] range overlaps the diff hunks. Only truly touched symbols are reported. Also fixed the CONTAINS path match to ENDS WITH to prevent cross-file false positives from substring matching. Fixes #758 * fix: address review feedback - variable shadowing, batch queries, tests - Rename `params` to `queryParams` in detectChanges hunk-mapping loop to avoid shadowing the outer method parameter - Replace N+1 per-symbol process lookup with a single batched query using WHERE n.id IN $ids (same pattern as impact BFS traversal) - Add unit tests for parseDiffHunks covering single/multi file, single/multi hunk, omitted count, pure-deletion, and empty input * style: fix prettier formatting in parse-diff-hunks test --- gitnexus/src/mcp/local/local-backend.ts | 90 ++++++++------- gitnexus/src/storage/git.ts | 35 ++++++ gitnexus/test/unit/parse-diff-hunks.test.ts | 115 ++++++++++++++++++++ 3 files changed, 203 insertions(+), 37 deletions(-) create mode 100644 gitnexus/test/unit/parse-diff-hunks.test.ts diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 91c3a5b69..041bd27eb 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -21,6 +21,7 @@ export { isWriteQuery }; // at MCP server startup — crashes on unsupported Node ABI versions (#89) // git utilities available if needed // import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js'; +import { parseDiffHunks, type FileDiff } from '../../storage/git.js'; import { listRegisteredRepos, cleanupOldKuzuFiles, @@ -1528,33 +1529,31 @@ export class LocalBackend { let diffArgs: string[]; switch (scope) { case 'staged': - diffArgs = ['diff', '--staged', '--name-only']; + diffArgs = ['diff', '--staged', '-U0']; break; case 'all': - diffArgs = ['diff', 'HEAD', '--name-only']; + diffArgs = ['diff', 'HEAD', '-U0']; break; case 'compare': if (!params.base_ref) return { error: 'base_ref is required for "compare" scope' }; - diffArgs = ['diff', params.base_ref, '--name-only']; + diffArgs = ['diff', params.base_ref, '-U0']; break; case 'unstaged': default: - diffArgs = ['diff', '--name-only']; + diffArgs = ['diff', '-U0']; break; } - let changedFiles: string[]; + let diffOutput: string; try { - const output = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' }); - changedFiles = output - .trim() - .split('\n') - .filter((f) => f.length > 0); + diffOutput = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' }); } catch (err: any) { return { error: `Git diff failed: ${err.message}` }; } - if (changedFiles.length === 0) { + const fileDiffs: FileDiff[] = parseDiffHunks(diffOutput); + + if (fileDiffs.length === 0) { return { summary: { changed_count: 0, @@ -1567,27 +1566,39 @@ export class LocalBackend { }; } - // Map changed files to indexed symbols + // Map diff hunks to indexed symbols via range overlap const changedSymbols: any[] = []; - for (const file of changedFiles) { - const normalizedFile = file.replace(/\\/g, '/'); + for (const fileDiff of fileDiffs) { + if (fileDiff.hunks.length === 0) continue; + + // Build range overlap conditions for all hunks in this file + const overlapConditions = fileDiff.hunks + .map((_, i) => `(n.startLine <= $hunkEnd${i} AND n.endLine >= $hunkStart${i})`) + .join(' OR '); + + const queryParams: Record = { filePath: fileDiff.filePath }; + fileDiff.hunks.forEach((hunk, i) => { + queryParams[`hunkStart${i}`] = hunk.startLine; + queryParams[`hunkEnd${i}`] = hunk.endLine; + }); + + const symbolQuery = ` + MATCH (n) WHERE n.filePath ENDS WITH $filePath + AND n.startLine IS NOT NULL AND n.endLine IS NOT NULL + AND (${overlapConditions}) + RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, + n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine + `; + try { - const symbols = await executeParameterized( - repo.id, - ` - MATCH (n) WHERE n.filePath CONTAINS $filePath - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath - LIMIT 20 - `, - { filePath: normalizedFile }, - ); - for (const sym of symbols) { + const rows = await executeParameterized(repo.id, symbolQuery, queryParams); + for (const sym of rows) { changedSymbols.push({ id: sym.id || sym[0], name: sym.name || sym[1], type: sym.type || sym[2], filePath: sym.filePath || sym[3], - change_type: 'Modified', + change_type: 'touched', }); } } catch (e) { @@ -1595,32 +1606,37 @@ export class LocalBackend { } } - // Find affected processes + // Find affected processes -- single batched query instead of N+1 const affectedProcesses = new Map(); - for (const sym of changedSymbols) { + if (changedSymbols.length > 0) { + const symIds = changedSymbols.map((s) => s.id); + const symNameById = new Map(changedSymbols.map((s) => [s.id, s.name])); try { const procs = await executeParameterized( repo.id, ` - MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) - RETURN p.id AS pid, p.heuristicLabel AS label, p.processType AS processType, p.stepCount AS stepCount, r.step AS step + MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) + WHERE n.id IN $ids + RETURN n.id AS nodeId, p.id AS pid, p.heuristicLabel AS label, + p.processType AS processType, p.stepCount AS stepCount, r.step AS step `, - { nodeId: sym.id }, + { ids: symIds }, ); for (const proc of procs) { - const pid = proc.pid || proc[0]; + const nodeId = proc.nodeId || proc[0]; + const pid = proc.pid || proc[1]; if (!affectedProcesses.has(pid)) { affectedProcesses.set(pid, { id: pid, - name: proc.label || proc[1], - process_type: proc.processType || proc[2], - step_count: proc.stepCount || proc[3], + name: proc.label || proc[2], + process_type: proc.processType || proc[3], + step_count: proc.stepCount || proc[4], changed_steps: [], }); } affectedProcesses.get(pid)!.changed_steps.push({ - symbol: sym.name, - step: proc.step || proc[4], + symbol: symNameById.get(nodeId) ?? nodeId, + step: proc.step || proc[5], }); } } catch (e) { @@ -1642,7 +1658,7 @@ export class LocalBackend { summary: { changed_count: changedSymbols.length, affected_count: processCount, - changed_files: changedFiles.length, + changed_files: fileDiffs.length, risk_level: risk, }, changed_symbols: changedSymbols, diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index ebd3f2c55..b0e9e6d3e 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -52,3 +52,38 @@ export const hasGitDir = (dirPath: string): boolean => { return false; } }; + +export interface DiffHunk { + startLine: number; + endLine: number; +} + +export interface FileDiff { + filePath: string; + hunks: DiffHunk[]; +} + +/** + * Parse unified diff output (with -U0) into per-file hunk ranges. + * Extracts the new-file line ranges from @@ hunk headers. + */ +export function parseDiffHunks(diffOutput: string): FileDiff[] { + const files: FileDiff[] = []; + let current: FileDiff | null = null; + for (const line of diffOutput.split('\n')) { + if (line.startsWith('+++ b/')) { + current = { filePath: line.slice(6), hunks: [] }; + files.push(current); + } else if (line.startsWith('@@') && current) { + const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); + if (match) { + const start = parseInt(match[1], 10); + const count = match[2] !== undefined ? parseInt(match[2], 10) : 1; + if (count > 0) { + current.hunks.push({ startLine: start, endLine: start + count - 1 }); + } + } + } + } + return files; +} diff --git a/gitnexus/test/unit/parse-diff-hunks.test.ts b/gitnexus/test/unit/parse-diff-hunks.test.ts new file mode 100644 index 000000000..7b8c3d1a0 --- /dev/null +++ b/gitnexus/test/unit/parse-diff-hunks.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { parseDiffHunks } from '../../src/storage/git.js'; + +describe('parseDiffHunks', () => { + it('parses a single file with one hunk', () => { + const diff = [ + 'diff --git a/src/foo.ts b/src/foo.ts', + '--- a/src/foo.ts', + '+++ b/src/foo.ts', + '@@ -10,0 +11,3 @@ some context', + '+line1', + '+line2', + '+line3', + ].join('\n'); + const result = parseDiffHunks(diff); + expect(result).toHaveLength(1); + expect(result[0].filePath).toBe('src/foo.ts'); + expect(result[0].hunks).toEqual([{ startLine: 11, endLine: 13 }]); + }); + + it('parses multiple hunks in a single file', () => { + const diff = [ + 'diff --git a/src/bar.ts b/src/bar.ts', + '--- a/src/bar.ts', + '+++ b/src/bar.ts', + '@@ -5,2 +5,4 @@ context', + ' unchanged', + '+added', + '@@ -20,0 +22,1 @@ more context', + '+another line', + ].join('\n'); + const result = parseDiffHunks(diff); + expect(result).toHaveLength(1); + expect(result[0].hunks).toHaveLength(2); + expect(result[0].hunks[0]).toEqual({ startLine: 5, endLine: 8 }); + expect(result[0].hunks[1]).toEqual({ startLine: 22, endLine: 22 }); + }); + + it('parses multiple files', () => { + const diff = [ + 'diff --git a/a.ts b/a.ts', + '--- a/a.ts', + '+++ b/a.ts', + '@@ -1,0 +1,2 @@', + '+line', + 'diff --git a/b.ts b/b.ts', + '--- a/b.ts', + '+++ b/b.ts', + '@@ -10,3 +10,5 @@', + ' ctx', + ].join('\n'); + const result = parseDiffHunks(diff); + expect(result).toHaveLength(2); + expect(result[0].filePath).toBe('a.ts'); + expect(result[0].hunks).toEqual([{ startLine: 1, endLine: 2 }]); + expect(result[1].filePath).toBe('b.ts'); + expect(result[1].hunks).toEqual([{ startLine: 10, endLine: 14 }]); + }); + + it('handles single-line hunks without count', () => { + // When count is omitted from @@ header, it defaults to 1 + const diff = ['+++ b/src/single.ts', '@@ -5,0 +6 @@ context', '+one line'].join('\n'); + const result = parseDiffHunks(diff); + expect(result).toHaveLength(1); + expect(result[0].hunks).toEqual([{ startLine: 6, endLine: 6 }]); + }); + + it('skips pure-deletion hunks (count=0)', () => { + const diff = ['+++ b/src/del.ts', '@@ -10,3 +10,0 @@ context'].join('\n'); + const result = parseDiffHunks(diff); + expect(result).toHaveLength(1); + expect(result[0].hunks).toHaveLength(0); + }); + + it('returns empty array for empty diff output', () => { + expect(parseDiffHunks('')).toEqual([]); + }); + + it('returns empty array for diff with no file headers', () => { + expect(parseDiffHunks('nothing useful here\n')).toEqual([]); + }); + + it('assigns hunks to the correct file when files are interleaved', () => { + // Realistic multi-file diff with context lines between + const diff = [ + 'diff --git a/src/alpha.ts b/src/alpha.ts', + 'index abc..def 100644', + '--- a/src/alpha.ts', + '+++ b/src/alpha.ts', + '@@ -100,0 +101,2 @@ export function alpha() {', + '+ const x = 1;', + '+ return x;', + 'diff --git a/src/beta.ts b/src/beta.ts', + 'index 111..222 100644', + '--- a/src/beta.ts', + '+++ b/src/beta.ts', + '@@ -50,0 +51,1 @@ export class Beta {', + '+ private val = 0;', + '@@ -80,0 +82,3 @@ export class Beta {', + '+ doStuff() {', + '+ return this.val;', + '+ }', + ].join('\n'); + const result = parseDiffHunks(diff); + expect(result).toHaveLength(2); + + expect(result[0].filePath).toBe('src/alpha.ts'); + expect(result[0].hunks).toEqual([{ startLine: 101, endLine: 102 }]); + + expect(result[1].filePath).toBe('src/beta.ts'); + expect(result[1].hunks).toHaveLength(2); + expect(result[1].hunks[0]).toEqual({ startLine: 51, endLine: 51 }); + expect(result[1].hunks[1]).toEqual({ startLine: 82, endLine: 84 }); + }); +}); From 6d9ec1009e679da19a8942271d2b1c6a8d2633f5 Mon Sep 17 00:00:00 2001 From: "Mr. WorldwideBrown" Date: Sat, 11 Apr 2026 16:47:40 +0530 Subject: [PATCH 05/15] fix: load VECTOR extension during DB init for semantic search (#782) * fix: load VECTOR extension during DB init for semantic search The VECTOR extension was only loaded inside the embedding generation pipeline (createVectorIndex). On a fresh gitnexus serve session, semantic and hybrid search failed because QUERY_VECTOR_INDEX was unknown. Now loads the VECTOR extension alongside FTS during database initialization in both the single-connection and pool-based paths. Fixes #766 * fix: reset vectorExtensionLoaded on DB close and retry paths The vectorExtensionLoaded flag was not being reset in closeLbug() or the busy-retry cleanup path in withLbugDb(). This caused the VECTOR extension to not be re-loaded after a close+re-init cycle, breaking semantic search on reconnection. Also resets shared.ftsLoaded and shared.vectorLoaded in the pool adapter closeOne() for external DB entries, preventing stale extension state when the pool is re-opened. Adds integration tests covering vector extension loading, idempotency, and state reset on both close and busy-retry paths. * fix: set ftsLoaded flag in initLbugWithDb to avoid redundant extension reloads * fix: set shared.vectorLoaded flag in initLbugWithDb to avoid redundant reloads --- gitnexus/src/core/lbug/lbug-adapter.ts | 34 ++++++++- gitnexus/src/core/lbug/pool-adapter.ts | 40 ++++++++-- .../integration/lbug-vector-extension.test.ts | 75 +++++++++++++++++++ 3 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 gitnexus/test/integration/lbug-vector-extension.test.ts diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 90e663f40..067625edc 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -17,6 +17,7 @@ let db: lbug.Database | null = null; let conn: lbug.Connection | null = null; let currentDbPath: string | null = null; let ftsLoaded = false; +let vectorExtensionLoaded = false; /** Expose the current Database for pool adapter reuse in tests. */ export const getDatabase = (): lbug.Database | null => db; @@ -104,6 +105,7 @@ export const withLbugDb = async (dbPath: string, operation: () => Promise) db = null; currentDbPath = null; ftsLoaded = false; + vectorExtensionLoaded = false; }); // Sleep outside the lock — no need to block others while waiting await new Promise((resolve) => setTimeout(resolve, DB_LOCK_RETRY_DELAY_MS * attempt)); @@ -135,6 +137,7 @@ const doInitLbug = async (dbPath: string) => { db = null; currentDbPath = null; ftsLoaded = false; + vectorExtensionLoaded = false; } // LadybugDB stores the database as a single file (not a directory). @@ -182,6 +185,9 @@ const doInitLbug = async (dbPath: string) => { } } + // Load VECTOR extension for semantic search support + await loadVectorExtension(); + currentDbPath = dbPath; return { db, conn }; }; @@ -807,6 +813,7 @@ export const closeLbug = async (): Promise => { } currentDbPath = null; ftsLoaded = false; + vectorExtensionLoaded = false; }; export const isLbugReady = (): boolean => conn !== null && db !== null; @@ -932,7 +939,32 @@ export const loadFTSExtension = async (): Promise => { } } }; - +/** + * Load the VECTOR extension (required before using QUERY_VECTOR_INDEX). + * Safe to call multiple times -- tracks loaded state via module-level vectorExtensionLoaded. + */ +export const loadVectorExtension = async (): Promise => { + if (vectorExtensionLoaded) return; + if (!conn) { + throw new Error('LadybugDB not initialized. Call initLbug first.'); + } + try { + await conn.query('INSTALL VECTOR'); + await conn.query('LOAD EXTENSION VECTOR'); + vectorExtensionLoaded = true; + } catch (err: any) { + const msg = err?.message || ''; + if ( + msg.includes('already loaded') || + msg.includes('already installed') || + msg.includes('already exists') + ) { + vectorExtensionLoaded = true; + } else { + console.error('GitNexus: VECTOR extension load failed:', msg); + } + } +}; /** * Create a full-text search index on a table * @param tableName - The node table name (e.g., 'File', 'CodeSymbol') diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts index 0bb001dc6..162ddbfa6 100644 --- a/gitnexus/src/core/lbug/pool-adapter.ts +++ b/gitnexus/src/core/lbug/pool-adapter.ts @@ -44,6 +44,7 @@ interface SharedDB { db: lbug.Database; refCount: number; ftsLoaded: boolean; + vectorLoaded: boolean; /** When true, closeOne skips db.close() — the Database is owned externally. */ external?: boolean; } @@ -148,6 +149,8 @@ function closeOne(repoId: string): void { // or remove from cache. Keep the entry so future initLbug() calls // for the same dbPath reuse it instead of hitting a file lock. shared.refCount = 0; + shared.ftsLoaded = false; + shared.vectorLoaded = false; } else { shared.db.close().catch(() => {}); dbCache.delete(entry.dbPath); @@ -276,7 +279,7 @@ async function doInitLbug(repoId: string, dbPath: string): Promise { true, // readOnly ); restoreStdout(); - shared = { db, refCount: 0, ftsLoaded: false }; + shared = { db, refCount: 0, ftsLoaded: false, vectorLoaded: false }; dbCache.set(dbPath, shared); break; } catch (err: any) { @@ -325,6 +328,17 @@ async function doInitLbug(repoId: string, dbPath: string): Promise { } } + // Load VECTOR extension once per shared Database for semantic search support. + if (!shared.vectorLoaded) { + try { + await available[0].query('INSTALL VECTOR'); + await available[0].query('LOAD EXTENSION VECTOR'); + shared.vectorLoaded = true; + } catch { + // VECTOR extension may not be available + } + } + // Register pool entry only after all connections are pre-warmed and FTS is // loaded. Concurrent executeQuery calls see either "not initialized" // (and throw cleanly) or a fully ready pool — never a half-built one. @@ -368,7 +382,7 @@ export async function initLbugWithDb( // closeOne() respects the external flag and skips db.close(). let shared = dbCache.get(dbPath); if (!shared) { - shared = { db: existingDb, refCount: 0, ftsLoaded: false, external: true }; + shared = { db: existingDb, refCount: 0, ftsLoaded: false, vectorLoaded: false, external: true }; dbCache.set(dbPath, shared); } shared.refCount++; @@ -384,10 +398,24 @@ export async function initLbugWithDb( } // Load FTS extension if not already loaded on this Database - try { - await available[0].query('LOAD EXTENSION fts'); - } catch { - // Extension may already be loaded or not installed + if (!shared.ftsLoaded) { + try { + await available[0].query('LOAD EXTENSION fts'); + shared.ftsLoaded = true; + } catch { + // Extension may already be loaded or not installed + } + } + + // Load VECTOR extension for semantic search support + if (!shared.vectorLoaded) { + try { + await available[0].query('INSTALL VECTOR'); + await available[0].query('LOAD EXTENSION VECTOR'); + shared.vectorLoaded = true; + } catch { + // VECTOR extension may not be available + } } pool.set(repoId, { diff --git a/gitnexus/test/integration/lbug-vector-extension.test.ts b/gitnexus/test/integration/lbug-vector-extension.test.ts new file mode 100644 index 000000000..feb51d08f --- /dev/null +++ b/gitnexus/test/integration/lbug-vector-extension.test.ts @@ -0,0 +1,75 @@ +/** + * Integration Tests: Vector extension loading and state reset + * + * Tests: loadVectorExtension idempotency, vectorExtensionLoaded reset + * on closeLbug and busy-retry cleanup paths. + * + * Follows existing lbug integration test patterns (lbug-core-adapter, + * lbug-lock-retry). + */ +import { describe, it, expect } from 'vitest'; +import { withTestLbugDB } from '../helpers/test-indexed-db.js'; + +withTestLbugDB('vector-extension', (handle) => { + describe('loadVectorExtension', () => { + it('loads the VECTOR extension without error', async () => { + const { loadVectorExtension } = await import('../../src/core/lbug/lbug-adapter.js'); + + // Should resolve without throwing -- idempotent if already loaded by doInitLbug + await expect(loadVectorExtension()).resolves.toBeUndefined(); + }); + + it('is idempotent -- calling twice does not throw', async () => { + const { loadVectorExtension } = await import('../../src/core/lbug/lbug-adapter.js'); + + await loadVectorExtension(); + await expect(loadVectorExtension()).resolves.toBeUndefined(); + }); + }); + + describe('vectorExtensionLoaded reset on closeLbug', () => { + it('re-initializes vector extension after close + re-init cycle', async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + // Ensure vector extension is loaded + await adapter.loadVectorExtension(); + + // Close the adapter -- should reset vectorExtensionLoaded + await adapter.closeLbug(); + expect(adapter.isLbugReady()).toBe(false); + + // Re-initialize -- doInitLbug calls loadVectorExtension internally + await adapter.initLbug(handle.dbPath); + expect(adapter.isLbugReady()).toBe(true); + + // loadVectorExtension should succeed (not skip due to stale flag) + await expect(adapter.loadVectorExtension()).resolves.toBeUndefined(); + }); + }); + + describe('vectorExtensionLoaded reset on busy-retry cleanup', () => { + it('withLbugDb resets vectorExtensionLoaded on BUSY retry', async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + // Ensure vector extension is loaded + await adapter.loadVectorExtension(); + + // Simulate a BUSY error on first attempt, success on second. + // The retry path should reset vectorExtensionLoaded so the + // re-initialized DB gets a fresh extension load. + let callCount = 0; + const result = await adapter.withLbugDb(handle.dbPath, async () => { + callCount++; + if (callCount === 1) throw new Error('database is BUSY'); + return 'recovered'; + }); + + expect(result).toBe('recovered'); + expect(callCount).toBe(2); + + // After recovery, vector extension should still be loadable + // (the flag was reset and re-loaded during re-init) + await expect(adapter.loadVectorExtension()).resolves.toBeUndefined(); + }); + }); +}); From 5be0537ce482a130806cfe20d6554aa800408826 Mon Sep 17 00:00:00 2001 From: JWWD | ModusOp Date: Sun, 12 Apr 2026 00:32:06 +1000 Subject: [PATCH 06/15] =?UTF-8?q?Fix=20stack=20overflow=20on=20large=20PHP?= =?UTF-8?q?=20files=20=E2=80=94=20iterative=20AST=20traversal=20(#783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: replace recursive AST traversal with iterative stack to prevent stack overflow on large files Fixes #752 Large PHP files (2000+ lines) with deeply nested AST structures (closures, array literals, chained method calls) cause "Maximum call stack size exceeded" during analysis. This converts three recursive tree traversal functions to iterative loops using explicit stacks: 1. `walk()` in type-env.ts — the main AST walker that processes every node. On a 2,462-line PHP controller, this recurses through 5,000-10,000+ nodes. 2. `findRelationCall()` in languages/php.ts — recursive search for Eloquent relationship calls within method bodies. 3. `findDescendant()` in utils/ast-helpers.ts — generic recursive utility used by PHP property extraction and other parsers. All three now use a while loop with an array-based stack instead of function call recursion, eliminating V8's ~10K frame call stack limit as a constraint. Tested against a production Laravel codebase with 373 PHP files (87,723 lines total, largest file 2,462 lines) — indexes successfully in 17.4s with zero errors, where the recursive version would crash with stack overflow. * Fix: reverse child push order in findRelationCall iterative traversal The iterative stack-based traversal pushed children in forward order, causing the last child to be processed first (LIFO). This reversed the original recursive left-to-right DFS order. Push children in reverse so the first child ends up on top of the stack. Co-Authored-By: Claude Opus 4.6 (1M context) * Fix: move stack declaration before processNode, rename walk to processNode Move the stack initialization above the function that pushes onto it, making the data-flow order match the code order. Rename walk to processNode since it now processes a single node rather than recursively traversing the tree. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- gitnexus/src/core/ingestion/languages/php.ts | 26 +++++++++++-------- gitnexus/src/core/ingestion/type-env.ts | 19 ++++++++++---- .../src/core/ingestion/utils/ast-helpers.ts | 15 +++++++---- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index e93010009..642c6bd83 100644 --- a/gitnexus/src/core/ingestion/languages/php.ts +++ b/gitnexus/src/core/ingestion/languages/php.ts @@ -160,18 +160,22 @@ function extractPhpPropertyDescription(propName: string, propDeclNode: SyntaxNod * Returns description like "hasMany(Post)" or null. */ function extractEloquentRelationDescription(methodNode: SyntaxNode): string | null { - function findRelationCall(node: SyntaxNode): SyntaxNode | null { - if (node.type === 'member_call_expression') { + function findRelationCall(root: SyntaxNode): SyntaxNode | null { + const stack: SyntaxNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === 'member_call_expression') { + const children = node.children ?? []; + const objectNode = children.find( + (c: SyntaxNode) => c.type === 'variable_name' && c.text === '$this', + ); + const nameNode = children.find((c: SyntaxNode) => c.type === 'name'); + if (objectNode && nameNode && ELOQUENT_RELATIONS.has(nameNode.text)) return node; + } const children = node.children ?? []; - const objectNode = children.find( - (c: SyntaxNode) => c.type === 'variable_name' && c.text === '$this', - ); - const nameNode = children.find((c: SyntaxNode) => c.type === 'name'); - if (objectNode && nameNode && ELOQUENT_RELATIONS.has(nameNode.text)) return node; - } - for (const child of node.children ?? []) { - const found = findRelationCall(child); - if (found) return found; + for (let i = children.length - 1; i >= 0; i--) { + stack.push(children[i]); + } } return null; } diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index bac187b26..c8eba5819 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -1088,7 +1088,11 @@ export const buildTypeEnv = ( } }; - const walk = (node: SyntaxNode, currentScope: string): void => { + const stack: Array<{ node: SyntaxNode; scope: string }> = [ + { node: tree.rootNode, scope: FILE_SCOPE }, + ]; + + const processNode = (node: SyntaxNode, currentScope: string): void => { // Fast skip: subtrees that can never contain type-relevant nodes (leaf-like literals). if (SKIP_SUBTREE_TYPES.has(node.type)) return; @@ -1205,14 +1209,19 @@ export const buildTypeEnv = ( } } - // Recurse into children - for (let i = 0; i < node.childCount; i++) { + // Push children onto stack (reverse order so first child is processed first) + for (let i = node.childCount - 1; i >= 0; i--) { const child = node.child(i); - if (child) walk(child, scope); + if (child) stack.push({ node: child, scope }); } }; - walk(tree.rootNode, FILE_SCOPE); + // Iterative traversal using explicit stack instead of recursion + // to avoid "Maximum call stack size exceeded" on large files (2000+ lines) + while (stack.length > 0) { + const { node, scope } = stack.pop()!; + processNode(node, scope); + } // Phase 14: Seed cross-file bindings from upstream files AFTER walk // (local declarations from walk() take precedence — first-writer-wins) diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 825600ddd..49e82938a 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -412,11 +412,16 @@ export const CALL_ARGUMENT_LIST_TYPES = new Set(['arguments', 'argument_list', ' // ============================================================================ /** Walk an AST node depth-first, returning the first descendant with the given type. */ -export function findDescendant(node: SyntaxNode, type: string): SyntaxNode | null { - if (node.type === type) return node; - for (const child of node.children ?? []) { - const found = findDescendant(child, type); - if (found) return found; +export function findDescendant(root: SyntaxNode, type: string): SyntaxNode | null { + const stack: SyntaxNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === type) return node; + // Push in reverse order so left children are visited first (depth-first) + const children = node.children ?? []; + for (let i = children.length - 1; i >= 0; i--) { + stack.push(children[i]); + } } return null; } From 75635638b1183ea3e67acac5b88b34ef5d6bd19e Mon Sep 17 00:00:00 2001 From: smTheApex <61349745+Prota100@users.noreply.github.com> Date: Sun, 12 Apr 2026 01:41:51 +0900 Subject: [PATCH 07/15] feat(csharp): capture interface-to-interface heritage (#789) The C# tree-sitter query set only matched `base_list` on `class_declaration`, so interfaces extending other interfaces (`interface IFoo : IBar`) were never captured as heritage edges. This broke transitive interface implementation chains. For example, given: interface IBase { } interface IFoo : IBase { } class MyClass : IFoo { } only `MyClass -> IFoo` was emitted, and the `IFoo -> IBase` edge was silently dropped. Any analysis that relies on walking the full interface inheritance chain (e.g. "which classes implement IBase?") therefore returned incomplete results. This patch adds two new query patterns mirroring the existing class_declaration heritage patterns, but targeting `interface_declaration`: (interface_declaration name: (identifier) @heritage.class (base_list (identifier) @heritage.extends)) @heritage (interface_declaration name: (identifier) @heritage.class (base_list (generic_name (identifier) @heritage.extends))) @heritage The existing heritage-processor pipeline already handles these captures correctly once the query emits them, so no changes are needed outside of tree-sitter-queries.ts. Testing: - New fixture `csharp-interface-heritage/` covering: * interface : interface (single base) * interface : interface, interface (multiple bases) * class : interface (where that interface derives from others) - 6 new test cases in test/integration/resolvers/csharp.test.ts asserting exactly 4 IMPLEMENTS edges and 0 EXTENDS edges for the fixture. - Full C# resolver suite: 175/175 passing, no regressions. Co-authored-by: Prota100 --- .../src/core/ingestion/tree-sitter-queries.ts | 8 +++ .../src/IAuditableService.cs | 6 +++ .../src/IBarService.cs | 6 +++ .../src/IBaseInterface.cs | 6 +++ .../src/IFooService.cs | 6 +++ .../src/MyService.cs | 12 +++++ .../test/integration/resolvers/csharp.test.ts | 51 +++++++++++++++++++ 7 files changed, 95 insertions(+) create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IAuditableService.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBarService.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBaseInterface.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IFooService.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/MyService.cs diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index a6c8d74b4..7180806ae 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -623,6 +623,14 @@ export const CSHARP_QUERIES = ` (class_declaration name: (identifier) @heritage.class (base_list (generic_name (identifier) @heritage.extends))) @heritage +; Interface inheritance: interface IFoo : IBar / interface IFoo : IBar, IBaz +; Without these patterns, interface-to-interface relationships are never +; captured, so transitive "class X implements IBar" chains are broken. +(interface_declaration name: (identifier) @heritage.class + (base_list (identifier) @heritage.extends)) @heritage +(interface_declaration name: (identifier) @heritage.class + (base_list (generic_name (identifier) @heritage.extends))) @heritage + ; Write access: obj.field = value (assignment_expression left: (member_access_expression diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IAuditableService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IAuditableService.cs new file mode 100644 index 000000000..e468d4edc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IAuditableService.cs @@ -0,0 +1,6 @@ +namespace Contracts; + +public interface IAuditableService : IFooService, IBarService +{ + string AuditTrail { get; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBarService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBarService.cs new file mode 100644 index 000000000..003bdc664 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBarService.cs @@ -0,0 +1,6 @@ +namespace Contracts; + +public interface IBarService +{ + void BarMethod(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBaseInterface.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBaseInterface.cs new file mode 100644 index 000000000..914c68be4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBaseInterface.cs @@ -0,0 +1,6 @@ +namespace Contracts; + +public interface IBaseInterface +{ + void BaseMethod(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IFooService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IFooService.cs new file mode 100644 index 000000000..bda11e0d9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IFooService.cs @@ -0,0 +1,6 @@ +namespace Contracts; + +public interface IFooService : IBaseInterface +{ + void FooMethod(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/MyService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/MyService.cs new file mode 100644 index 000000000..d0e41df1b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/MyService.cs @@ -0,0 +1,12 @@ +namespace Services; + +using Contracts; + +public class MyService : IAuditableService +{ + public string AuditTrail => "audit"; + + public void BaseMethod() { } + public void FooMethod() { } + public void BarMethod() { } +} diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index af2dbb786..83ffd3585 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -1997,3 +1997,54 @@ describe('C# User implements IValidator — interface default method (SM-11)', ( expect(validateCall!.source).toBe('Run'); }); }); + +// --------------------------------------------------------------------------- +// Interface-to-interface heritage (single + multi base interface) +// --------------------------------------------------------------------------- + +describe('C# interface-to-interface heritage', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-interface-heritage'), () => {}); + }, 60000); + + it('detects 1 class and 4 interfaces', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['MyService']); + expect(getNodesByLabel(result, 'Interface')).toEqual([ + 'IAuditableService', + 'IBarService', + 'IBaseInterface', + 'IFooService', + ]); + }); + + it('emits no EXTENDS edges (fixture has no class inheritance)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(0); + }); + + it('emits IMPLEMENTS edge: IFooService → IBaseInterface (single base interface)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + const targets = edgeSet(implements_); + expect(targets).toContain('IFooService → IBaseInterface'); + }); + + it('emits IMPLEMENTS edges: IAuditableService → IFooService, IBarService (multi base interfaces)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + const targets = edgeSet(implements_); + expect(targets).toContain('IAuditableService → IFooService'); + expect(targets).toContain('IAuditableService → IBarService'); + }); + + it('emits IMPLEMENTS edge: MyService → IAuditableService (class implements derived interface)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + const targets = edgeSet(implements_); + expect(targets).toContain('MyService → IAuditableService'); + }); + + it('emits exactly 4 IMPLEMENTS edges total', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(4); + }); +}); From 9364739fb4b804c10c53d02adc6d764ee15f17a7 Mon Sep 17 00:00:00 2001 From: Dave Brophy Date: Sun, 12 Apr 2026 00:24:02 +0700 Subject: [PATCH 08/15] fix: restore tree-sitter-swift postinstall patch for macOS ARM64 (#788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: restore tree-sitter-swift postinstall patch for macOS ARM64 PR #516 (77dcb06) deleted `scripts/patch-tree-sitter-swift.cjs` and the `postinstall` script entry when bumping to `tree-sitter-swift@0.7.1`, since 0.7.1 ships prebuilt darwin-arm64 binaries and no longer needs the patch. PR #538 (01ddc3e) then had to revert `tree-sitter-swift` back to `^0.6.0` (and `tree-sitter` back to `^0.21.1`) because `npm overrides` doesn't apply when gitnexus is installed via `npx -y` (gitnexus isn't the root project, so overrides are silently ignored, producing ERESOLVE errors). PR #538 reverted the grammar package changes but did not restore the patch script, leaving `tree-sitter-swift@0.6.0` unable to build its native binding on macOS ARM64. The symptom is `gitnexus analyze` printing "Skipping swift" or "swift parser not available". `Dockerfile.test` still references `node scripts/patch-tree-sitter-swift.cjs` (added in the same PR #516), confirming the regression — the test image build is also broken. This commit restores the patch script from commit `0c8ec95` (the last revision before it was deleted) and re-adds the `postinstall` entry to `package.json`. No logic changes — it is an exact restoration. The TODO comment in the script ("Remove this script when tree-sitter is upgraded to ^0.22.x") still applies. * style: run prettier on patch-tree-sitter-swift.cjs --- gitnexus/package.json | 1 + gitnexus/scripts/patch-tree-sitter-swift.cjs | 78 ++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 gitnexus/scripts/patch-tree-sitter-swift.cjs diff --git a/gitnexus/package.json b/gitnexus/package.json index 07723f3e3..871524702 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -46,6 +46,7 @@ "test:integration": "vitest run test/integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "postinstall": "node scripts/patch-tree-sitter-swift.cjs", "prepare": "node scripts/build.js", "prepack": "node scripts/build.js" }, diff --git a/gitnexus/scripts/patch-tree-sitter-swift.cjs b/gitnexus/scripts/patch-tree-sitter-swift.cjs new file mode 100644 index 000000000..6580b00e7 --- /dev/null +++ b/gitnexus/scripts/patch-tree-sitter-swift.cjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * WORKAROUND: tree-sitter-swift@0.6.0 binding.gyp build failure + * + * Background: + * tree-sitter-swift@0.6.0's binding.gyp contains an "actions" array that + * invokes `tree-sitter generate` to regenerate parser.c from grammar.js. + * This is intended for grammar developers, but the published npm package + * already ships pre-generated parser files (parser.c, scanner.c), so the + * actions are unnecessary for consumers. Since consumers don't have + * tree-sitter-cli installed, the actions always fail during `npm install`. + * + * Why we can't just upgrade: + * tree-sitter-swift@0.7.1 fixes this (removes postinstall, ships prebuilds), + * but it requires tree-sitter@^0.22.1. The upstream project pins tree-sitter + * to ^0.21.0 and all other grammar packages depend on that version. + * Upgrading tree-sitter would be a separate breaking change. + * + * How this workaround works: + * 1. tree-sitter-swift's own postinstall fails (npm warns but continues) + * 2. This script runs as gitnexus's postinstall + * 3. It removes the "actions" array from binding.gyp + * 4. It rebuilds the native binding with the cleaned binding.gyp + * + * TODO: Remove this script when tree-sitter is upgraded to ^0.22.x, + * which allows using tree-sitter-swift@0.7.1+ directly. + */ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift'); +const bindingPath = path.join(swiftDir, 'binding.gyp'); + +try { + if (!fs.existsSync(bindingPath)) { + process.exit(0); + } + + const content = fs.readFileSync(bindingPath, 'utf8'); + let needsRebuild = false; + + if (content.includes('"actions"')) { + // Strip Python-style comments (#) and trailing commas before JSON parsing + const cleaned = content + .replace(/#[^\n]*/g, '') // Remove # comments + .replace(/,(\s*[\]}])/g, '$1'); // Remove trailing commas before ] or } + const gyp = JSON.parse(cleaned); + + if (gyp.targets && gyp.targets[0] && gyp.targets[0].actions) { + delete gyp.targets[0].actions; + fs.writeFileSync(bindingPath, JSON.stringify(gyp, null, 2) + '\n'); + console.log('[tree-sitter-swift] Patched binding.gyp (removed actions array)'); + needsRebuild = true; + } + } + + // Check if native binding exists + const bindingNode = path.join(swiftDir, 'build', 'Release', 'tree_sitter_swift_binding.node'); + if (!fs.existsSync(bindingNode)) { + needsRebuild = true; + } + + if (needsRebuild) { + console.log('[tree-sitter-swift] Rebuilding native binding...'); + execSync('npx node-gyp rebuild', { + cwd: swiftDir, + stdio: 'pipe', + timeout: 120000, + }); + console.log('[tree-sitter-swift] Native binding built successfully'); + } +} catch (err) { + console.warn('[tree-sitter-swift] Could not build native binding:', err.message); + console.warn( + '[tree-sitter-swift] You may need to manually run: cd node_modules/tree-sitter-swift && npx node-gyp rebuild', + ); +} From 1ff324ca16bfde5b0706a6869b32ea4e47932b20 Mon Sep 17 00:00:00 2001 From: ivkond Date: Sat, 11 Apr 2026 21:46:12 +0300 Subject: [PATCH 09/15] feat(group): bridge.lbug storage + contract matching expansion (1/4 of #606 split) (#795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(group): bridge.lbug storage + contract matching expansion Part 1 of 4 in the split of #606 (ticket: #791, closes #790 with a revised plan per @magyargergo's request). ## What changed Adds the LadybugDB-backed bridge storage infrastructure and extends the contract matching algorithm with wildcard support. All changes are additive: storage.ts, sync.ts, service.ts, cli/group.ts, mcp/tools.ts are left on their upstream main versions and will migrate to the new bridge in follow-up PRs (#792, #793, #794). ### Files **New (844 LOC prod):** - `gitnexus/src/core/group/bridge-db.ts` — atomic write-to-temp with `retryRename` for Windows EBUSY/EPERM, per-item write tolerance via `WriteBridgeReport`, `findContractNode` with three-tier symbol lookup (uid → filePath+name → filePath) - `gitnexus/src/core/group/bridge-schema.ts` — schema DDL - `gitnexus/src/core/group/normalization.ts` — contract ID canonicalization + `dedupeContracts` / `dedupeCrossLinks` helpers used by both matching and bridge write **Modified (+137 LOC prod):** - `gitnexus/src/core/group/matching.ts` — adds `runWildcardMatch` for `grpc::Service/*` wildcard consumers, `buildProviderIndex` helper, and canonical gRPC ID handling in `normalizeContractId` - `gitnexus/src/core/group/types.ts` — `MatchType` gains `'wildcard'`; new `BridgeHandle` and `BridgeMeta` interfaces **New tests (658 LOC):** - `gitnexus/test/unit/group/bridge-db.test.ts` — core write/read round trip, `WriteBridgeReport` shape, dropped-links counter, retryRename behavior on EBUSY/ENOENT/EPERM/EACCES - `gitnexus/test/unit/group/bridge-db-edge.test.ts` — edge cases (malformed meta, missing contract nodes, concurrent access) **Modified tests (+225 LOC):** - `gitnexus/test/unit/group/matching.test.ts` — wildcard consumer matching, gRPC canonical ID handling, same-service guard ### Self-review fixes folded in Carried forward from the original #606 self-review: - `writeBridge` try/finally handle lifecycle + `handleClosed` sentinel - `openBridgeDbReadOnly` partial-handle cleanup - `writeBridgeMeta` uses `retryRename` for Windows consistency - `retryRename` unit tests (was zero coverage) - Per-item try/catch around every CREATE loop so one malformed contract doesn't abort the whole write - Dropped cross-link counter (`linksDroppedMissingNode`) ### Why now magyargergo asked for the #606 PR to be split so we can iterate with confidence (https://github.com/abhigyanpatwari/GitNexus/pull/606#issuecomment-4229612271). This is the foundational layer — pure infra, no user-facing surface, no callers of the new APIs in this PR. Later PRs wire it in. ### How to verify - `cd gitnexus && npx tsc --noEmit` - `cd gitnexus && npx vitest run test/unit/group/bridge-db.test.ts --pool=forks` - `cd gitnexus && npx vitest run test/unit/group/bridge-db-edge.test.ts --pool=forks` - `cd gitnexus && npx vitest run test/unit/group/matching.test.ts --pool=forks` - Pre-commit hook runs clean ### Risk / rollback **Low.** All new code sits under `src/core/group/` in new files plus a minimal `+16/-1` diff to `types.ts` and a `+136/-0` diff to `matching.ts` (both purely additive). No existing callers reference the new APIs (bridge-db, openBridgeOrFallback, runWildcardMatch) — the PRs that wire them in come later in the split chain. Rollback = `git revert` of the merge commit; no state introduced, no schema migration triggered. ### Scope discipline (per GUARDRAILS.md) - Only the 8 files listed above are touched; no drive-by refactors - No CI/release/security config changes - No secrets, tokens, or machine-specific paths - Content is lifted from the #606 branch which already passed CI 11/11 green on `d15b8cb` (before the split) ### Dependencies - **Base:** `main` (no dependencies on other split PRs) - **Blocks:** extractor expansion (#792), sync pipeline (#793), cross-impact feature (#794) - **Related ticket:** #791 Co-authored-by: Claude * fix(group): address @claude review on #795 Addresses the findings from the automated review on PR #795 (https://github.com/abhigyanpatwari/GitNexus/pull/795#issuecomment-4229770000 — posted by @magyargergo / claude-code Action run). ### Medium severity (reviewer flagged as blockers) - **bridge-db.ts `openBridgeDbReadOnly` bak recovery** — the `.bak` recovery path used bare `fsp.rename(bakPath, dbPath)`, which is exactly the scenario most likely to hit Windows EBUSY/EPERM (an interrupted writer still holding the handle for a few ms). Switched to `retryRename` for consistency with the rest of the file's Windows-safe rename path. - **bridge-db.ts `ensureBridgeSchema` error detection** — the inline `msg.includes('already exists')` substring match has been lifted into a named constant `LBUG_ALREADY_EXISTS_MSG` with a comment documenting the coupling to LadybugDB's error message wording and why we can't use `IF NOT EXISTS` (LadybugDB DDL doesn't support it) or typed errors (LadybugDB's JS driver doesn't expose error codes). Also tightened the `catch (err: any)` to `catch (err: unknown)`. - **bridge-db.ts `findContractNode` — extracted out of writeBridge** — the 35-line async closure living inside `writeBridge` has been lifted to three module-level functions: `createContractLookupIndex`, `indexContract`, and `findContractNode`. `findContractNode` is now a pure synchronous function taking a prebuilt index instead of doing its own DB queries. The `writeBridge` cross-link loop is now ~25 lines instead of ~100. - **bridge-db.ts `findContractNode` — N+1 query elimination** — the old inner-closure version issued up to 6 DB round-trips per cross-link (2 endpoints × up to 3 tiers of fallback queries). For a group with 1000 cross-links, that's up to 6000 DB queries just to resolve endpoints. The new version consults an in-memory `ContractLookupIndex` built incrementally as contracts are inserted (`indexContract` called AFTER each successful insert so failed inserts don't poison the index). Cross-link resolution is now O(1) per link instead of O(3) DB queries per link, with zero DB round-trips during the cross-link loop. ### Minor severity - **bridge-db.ts `queryBridge` empty-array guard** — if LadybugDB ever returns an empty `QueryResult[]` at the top level (shouldn't happen with single-statement calls, but driver contract isn't explicit), the old code would call `.getAll()` on `undefined` and crash with a confusing stack. Added an `unwrapQueryResult` helper that throws an explicit `'empty QueryResult array'` error instead, making a potential driver regression visible immediately. - **normalization.ts `contractRichness` weights** — added a block-level comment documenting the weight ordering (+3 for symbolUid, +2 for each symbol-identifying field, +1 for service tag or non-manifest origin) and explicitly noting that the absolute numbers don't matter, only the relative ordering. Matches the "comment for contributors" suggestion in the review. - **bridge-schema.ts `BRIDGE_SCHEMA_VERSION` migration comment** — added a 4-point contract explaining what bumping the constant means ("discard and re-sync" strategy for V1, no in-place migration yet, new migration logic should live in a separate `bridge-migrations.ts` module when it becomes necessary). - **test/unit/group/fixtures.ts** — extracted the `makeContract` helper previously copy-pasted between `bridge-db.test.ts` and `bridge-db-edge.test.ts` into a shared fixtures module. Both test files now import from `./fixtures.js`. Kept the scope minimal: fixtures is NOT a general-purpose factory module, just the shared baseline contract builder. ### New tests Added 9 pure-function unit tests for the now-extracted `findContractNode` in `bridge-db.test.ts`: - returns null on empty index - tier 1 (symbolUid) match, including repo-scope and role-scope isolation - tier 2 (filePath + symbolName) fallback when symbolUid is empty or mismatches - tier 3 (filePath only) when exactly one contract lives in the file, and refusal when multiple do - priority ordering when multiple tiers could resolve These are fully isolated — no DB, no temp directories, no native LadybugDB binding — so they run in <10ms total and are immediately trustworthy as a regression safety net. ### Deliberately deferred (reviewer marked as "fine for now") - `BridgeHandle._db` / `._conn` typing to `unknown` with casts in `bridge-db.ts` — reviewer's note: "The typing is fine for now." - Batch inserts via `UNWIND` — needs LadybugDB support confirmation, tracked as a follow-up; the per-item pattern remains. - `queryBridge` prepared-statement lifecycle — the current pattern (prepare → execute → GC) relies on LadybugDB's internals, worth verifying against their docs in a separate audit. ### Scope discipline (per `GUARDRAILS.md`) - Only files touched by this PR (`bridge-db.ts`, `bridge-schema.ts`, `normalization.ts`, both bridge test files, new `fixtures.ts`) — no drive-by refactors - No CI/release/security config changes - No secrets ### Test + typecheck status - `npx tsc --noEmit` clean - `bridge-db.test.ts`: added 9 `findContractNode` tests, all pass in isolation. The full-file run still hits the pre-existing native LadybugDB cleanup segfault that flakes the reported count — same as every prior commit on this branch, not a regression. - `bridge-db-edge.test.ts`: 4/4 pass - `matching.test.ts`: 28/28 pass - `types.test.ts`: 5/5 pass - `retryRename` tests (4/4) and `findContractNode` tests (9/9) verified in isolation via `-t` filter Co-authored-by: Claude --------- Co-authored-by: Claude --- gitnexus/src/core/group/bridge-db.ts | 588 ++++++++++++++++++ gitnexus/src/core/group/bridge-schema.ts | 60 ++ gitnexus/src/core/group/matching.ts | 136 +++- gitnexus/src/core/group/normalization.ts | 124 ++++ gitnexus/src/core/group/types.ts | 16 +- .../test/unit/group/bridge-db-edge.test.ts | 178 ++++++ gitnexus/test/unit/group/bridge-db.test.ts | 575 +++++++++++++++++ gitnexus/test/unit/group/fixtures.ts | 32 + gitnexus/test/unit/group/matching.test.ts | 225 ++++++- 9 files changed, 1919 insertions(+), 15 deletions(-) create mode 100644 gitnexus/src/core/group/bridge-db.ts create mode 100644 gitnexus/src/core/group/bridge-schema.ts create mode 100644 gitnexus/src/core/group/normalization.ts create mode 100644 gitnexus/test/unit/group/bridge-db-edge.test.ts create mode 100644 gitnexus/test/unit/group/bridge-db.test.ts create mode 100644 gitnexus/test/unit/group/fixtures.ts diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts new file mode 100644 index 000000000..864a79599 --- /dev/null +++ b/gitnexus/src/core/group/bridge-db.ts @@ -0,0 +1,588 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import lbug from '@ladybugdb/core'; +import type { LbugValue } from '@ladybugdb/core'; +import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js'; +import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js'; +import { dedupeContracts, dedupeCrossLinks } from './normalization.js'; + +export function contractNodeId( + repo: string, + contractId: string, + role: string, + filePath: string, +): string { + return createHash('sha256').update(`${repo}\0${contractId}\0${role}\0${filePath}`).digest('hex'); +} + +/* ------------------------------------------------------------------ */ +/* ContractLookupIndex — in-memory lookup for findContractNode */ +/* ------------------------------------------------------------------ */ + +/** + * In-memory index of contract node IDs keyed three ways, mirroring the + * three-tier fallback lookup in {@link findContractNode}. Built once per + * `writeBridge` call after all contracts are successfully inserted, then + * consulted for every cross-link — which eliminates the former N+1 query + * pattern (up to `6 × cross-links` DB round-trips) and turns cross-link + * resolution into constant-time per link. + * + * Keys are deliberately flat strings (not tuples) so `Map` + * works; the separator `\0` can't occur in any legal repo path / file + * path / symbol identifier, which makes the encoding injection-safe. + */ +export interface ContractLookupIndex { + /** tier 1: `repo + role + symbolUid` → contract node id */ + byUid: Map; + /** tier 2: `repo + role + filePath + symbolName` → contract node id */ + byRef: Map; + /** tier 3: `repo + role + filePath` → list of contract node ids in that file */ + byFile: Map; +} + +export function createContractLookupIndex(): ContractLookupIndex { + return { + byUid: new Map(), + byRef: new Map(), + byFile: new Map(), + }; +} + +function uidKey(repo: string, role: string, symbolUid: string): string { + return `${repo}\0${role}\0${symbolUid}`; +} + +function refKey(repo: string, role: string, filePath: string, symbolName: string): string { + return `${repo}\0${role}\0${filePath}\0${symbolName}`; +} + +function fileKey(repo: string, role: string, filePath: string): string { + return `${repo}\0${role}\0${filePath}`; +} + +/** + * Add a successfully-inserted contract to the lookup index. Must be called + * AFTER the DB insert succeeds (not before) so failed inserts don't poison + * the index and cause cross-links to point at non-existent rows. + */ +export function indexContract( + index: ContractLookupIndex, + contract: StoredContract, + nodeId: string, +): void { + if (contract.symbolUid) { + index.byUid.set(uidKey(contract.repo, contract.role, contract.symbolUid), nodeId); + } + index.byRef.set( + refKey(contract.repo, contract.role, contract.symbolRef.filePath, contract.symbolRef.name), + nodeId, + ); + const fk = fileKey(contract.repo, contract.role, contract.symbolRef.filePath); + const existing = index.byFile.get(fk); + if (existing) { + existing.push(nodeId); + } else { + index.byFile.set(fk, [nodeId]); + } +} + +/** + * Resolve a cross-link endpoint (consumer or provider reference) to an + * already-inserted contract node id. Returns `null` if no match — the + * caller is expected to count that as a dropped link in `WriteBridgeReport`. + * + * The resolution order matches the pre-cache DB-query behavior: + * 1. exact `symbolUid` match in the same `(repo, role)` scope + * 2. exact `(filePath, symbolName)` match + * 3. if exactly one contract lives in the file → that one (fallback for + * legacy graph-assisted extractors that couldn't resolve a symbol name) + * + * This is a pure function — no I/O, no DB — so it's trivial to unit-test + * in isolation (which was the reviewer's main clean-code concern on the + * original 35-line inner closure in `writeBridge`). + */ +export function findContractNode( + index: ContractLookupIndex, + repo: string, + role: 'consumer' | 'provider', + symbolUid: string, + filePath: string, + symbolName: string, +): string | null { + if (symbolUid) { + const uidHit = index.byUid.get(uidKey(repo, role, symbolUid)); + if (uidHit !== undefined) return uidHit; + } + + const refHit = index.byRef.get(refKey(repo, role, filePath, symbolName)); + if (refHit !== undefined) return refHit; + + const fileCandidates = index.byFile.get(fileKey(repo, role, filePath)); + if (fileCandidates && fileCandidates.length === 1) return fileCandidates[0]; + + return null; +} + +export async function openBridgeDb(dbPath: string): Promise { + const parentDir = path.dirname(dbPath); + await fsp.mkdir(parentDir, { recursive: true }); + const db = new lbug.Database(dbPath, 0, false, false); // writable + const conn = new lbug.Connection(db); + return { _db: db, _conn: conn, groupDir: parentDir } as BridgeHandle; +} + +/** + * LadybugDB returns an error whose message contains this substring when a + * CREATE NODE TABLE or CREATE REL TABLE statement hits an already-existing + * table. LadybugDB DDL doesn't support IF NOT EXISTS, and its JS driver + * doesn't expose typed error codes, so we match on the message substring — + * the same pattern used by `core/lbug/lbug-adapter.ts`. If a future + * LadybugDB release changes the wording, update this constant. + */ +const LBUG_ALREADY_EXISTS_MSG = 'already exists'; + +export async function ensureBridgeSchema(handle: BridgeHandle): Promise { + const conn = handle._conn as lbug.Connection; + for (const q of BRIDGE_SCHEMA_QUERIES) { + try { + await conn.query(q); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes(LBUG_ALREADY_EXISTS_MSG)) throw err; + } + } +} + +export async function queryBridge( + handle: BridgeHandle, + cypher: string, + params?: Record, +): Promise { + const conn = handle._conn as lbug.Connection; + if (params && Object.keys(params).length > 0) { + const stmt = await conn.prepare(cypher); + if (!stmt.isSuccess()) { + const errMsg = await stmt.getErrorMessage(); + throw new Error(`Bridge query prepare failed: ${errMsg}`); + } + const queryResult = await conn.execute(stmt, params); + const result = unwrapQueryResult(queryResult); + return (await result.getAll()) as T[]; + } + const queryResult = await conn.query(cypher); + const result = unwrapQueryResult(queryResult); + return (await result.getAll()) as T[]; +} + +/** + * LadybugDB's `conn.query` / `conn.execute` can return either a single + * `QueryResult` (for a single statement) or an array of them (when a + * multi-statement script is dispatched). We always pass a single statement, + * so the array form is a wrapper we unwrap here — but an empty top-level + * array would cause `.getAll()` on `undefined` and crash with a confusing + * stack. Throwing an explicit error makes a driver-contract regression + * visible immediately instead of masking it. + */ +function unwrapQueryResult(queryResult: lbug.QueryResult | lbug.QueryResult[]): lbug.QueryResult { + if (Array.isArray(queryResult)) { + if (queryResult.length === 0) { + throw new Error('Bridge query returned an empty QueryResult array'); + } + return queryResult[0]; + } + return queryResult; +} + +export async function closeBridgeDb(handle: BridgeHandle): Promise { + try { + await (handle._conn as lbug.Connection).close(); + } catch { + /* ignore */ + } + try { + await (handle._db as lbug.Database).close(); + } catch { + /* ignore */ + } +} + +/* ------------------------------------------------------------------ */ +/* retryRename — handles transient EBUSY/EPERM/EACCES on Windows */ +/* ------------------------------------------------------------------ */ + +const RETRY_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']); + +export async function retryRename(src: string, dst: string, attempts = 3): Promise { + for (let i = 1; i <= attempts; i++) { + try { + await fsp.rename(src, dst); + return; + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (!code || !RETRY_CODES.has(code) || i === attempts) throw err; + await new Promise((r) => setTimeout(r, 100 * Math.pow(2, i - 1))); + } + } +} + +/* ------------------------------------------------------------------ */ +/* writeBridgeMeta / readBridgeMeta */ +/* ------------------------------------------------------------------ */ + +export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise { + const target = path.join(groupDir, 'meta.json'); + const tmp = `${target}.tmp.${Date.now()}`; + await fsp.writeFile(tmp, JSON.stringify(meta, null, 2), 'utf-8'); + // Use retryRename for consistency with writeBridge's atomic swap — on + // Windows a concurrent reader can cause EBUSY/EPERM even on a tiny + // meta.json, and we don't want meta write to be less robust than the + // bridge.lbug swap it accompanies. + await retryRename(tmp, target); +} + +export async function readBridgeMeta(groupDir: string): Promise { + try { + const content = await fsp.readFile(path.join(groupDir, 'meta.json'), 'utf-8'); + return JSON.parse(content) as BridgeMeta; + } catch { + return { version: 0, generatedAt: '', missingRepos: [] }; + } +} + +/* ------------------------------------------------------------------ */ +/* writeBridge — atomic write-to-temp-then-rename */ +/* ------------------------------------------------------------------ */ + +export interface WriteBridgeInput { + contracts: StoredContract[]; + crossLinks: CrossLink[]; + repoSnapshots: Record; + missingRepos: string[]; +} + +/** + * Non-fatal issues encountered during writeBridge. Callers can log these to + * surface partial-success state without aborting the whole sync. + * `sampleErrors` is capped at MAX_SAMPLE_ERRORS per category to bound memory. + */ +export interface WriteBridgeReport { + contractsInserted: number; + contractsFailed: number; + snapshotsInserted: number; + snapshotsFailed: number; + linksInserted: number; + linksFailed: number; + /** Cross-links skipped because their from/to contract nodes weren't found. */ + linksDroppedMissingNode: number; + sampleErrors: Array<{ + kind: 'contract' | 'snapshot' | 'link'; + id: string; + message: string; + }>; +} + +const MAX_SAMPLE_ERRORS = 10; + +function errMessage(err: unknown): string { + if (err instanceof Error) return err.message; + try { + return String(err); + } catch { + return 'unknown error'; + } +} + +export async function writeBridge( + groupDir: string, + input: WriteBridgeInput, +): Promise { + await fsp.mkdir(groupDir, { recursive: true }); + const contracts = dedupeContracts(input.contracts); + const crossLinks = dedupeCrossLinks(input.crossLinks); + + const finalPath = path.join(groupDir, 'bridge.lbug'); + const tmpPath = path.join(groupDir, 'bridge.lbug.tmp'); + const bakPath = path.join(groupDir, 'bridge.lbug.bak'); + + const report: WriteBridgeReport = { + contractsInserted: 0, + contractsFailed: 0, + snapshotsInserted: 0, + snapshotsFailed: 0, + linksInserted: 0, + linksFailed: 0, + linksDroppedMissingNode: 0, + sampleErrors: [], + }; + + const recordError = (kind: 'contract' | 'snapshot' | 'link', id: string, err: unknown) => { + if (report.sampleErrors.length < MAX_SAMPLE_ERRORS) { + report.sampleErrors.push({ kind, id, message: errMessage(err) }); + } + }; + + // Clean up any leftover tmp + try { + await fsp.rm(tmpPath, { recursive: true, force: true }); + } catch { + /* ignore */ + } + + // 1. Create temp DB, insert all data. + // + // Everything after `openBridgeDb` must run inside a try/finally so that + // if ANY step before the explicit `closeBridgeDb` throws — schema + // creation, a contract insert loop that rethrows, a snapshot write, the + // cross-link loop, or anything else — the handle is still released. A + // leaked handle holds the native LadybugDB file lock on tmpPath, which + // (a) leaks a FD and (b) prevents the next writeBridge call from + // reusing the same tmp slot. + const handle = await openBridgeDb(tmpPath); + let handleClosed = false; + try { + await ensureBridgeSchema(handle); + + // Build the lookup index incrementally as contracts are inserted, so + // failed inserts are never in the index (and therefore never resolved + // by the cross-link loop below). This replaces a previous N+1 query + // pattern where each link made up to 6 DB round-trips to find its + // endpoints — see ContractLookupIndex. + const lookupIndex = createContractLookupIndex(); + + // Insert contracts — tolerate individual failures (e.g., a corrupt meta + // that can't be serialized). The whole sync must not fail because one + // contract is broken. + for (const c of contracts) { + const id = contractNodeId(c.repo, c.contractId, c.role, c.symbolRef.filePath); + try { + await queryBridge( + handle, + `CREATE (n:Contract { + id: $id, + contractId: $contractId, + type: $type, + role: $role, + repo: $repo, + service: $service, + symbolUid: $symbolUid, + filePath: $filePath, + symbolName: $symbolName, + confidence: $confidence, + meta: $meta + })`, + { + id, + contractId: c.contractId, + type: c.type, + role: c.role, + repo: c.repo, + service: c.service ?? '', + symbolUid: c.symbolUid, + filePath: c.symbolRef.filePath, + symbolName: c.symbolName, + confidence: c.confidence, + meta: JSON.stringify(c.meta), + }, + ); + report.contractsInserted++; + // Only index on successful insert — the cross-link loop must never + // resolve to a row that isn't actually in the DB. + indexContract(lookupIndex, c, id); + } catch (err) { + report.contractsFailed++; + recordError('contract', id, err); + } + } + + // Insert repo snapshots + for (const [repoId, snap] of Object.entries(input.repoSnapshots)) { + try { + await queryBridge( + handle, + `CREATE (s:RepoSnapshot { + id: $id, + indexedAt: $indexedAt, + lastCommit: $lastCommit + })`, + { + id: repoId, + indexedAt: snap.indexedAt, + lastCommit: snap.lastCommit, + }, + ); + report.snapshotsInserted++; + } catch (err) { + report.snapshotsFailed++; + recordError('snapshot', repoId, err); + } + } + + // Insert cross-links (tolerating missing nodes). + // + // `findContractNode` consults the in-memory lookup index built above, + // not the DB — that's an O(1) pure-function lookup per endpoint instead + // of the previous 2-3 DB queries. For M cross-links, the previous code + // issued up to 6M round-trips; this version issues zero. + // + // `link.contractId` may differ between the consumer and provider sides + // (e.g. wildcard consumer `grpc::Service/*` → method-level provider + // `grpc::Service/Method`) — that's why we resolve each endpoint + // independently via its own `(repo, role, symbolUid, filePath, symbolName)` + // tuple rather than matching on contractId. + for (const link of crossLinks) { + const linkId = `${link.from.repo}::${link.contractId}->${link.to.repo}::${link.contractId}`; + try { + const fromId = findContractNode( + lookupIndex, + link.from.repo, + 'consumer', + link.from.symbolUid, + link.from.symbolRef.filePath, + link.from.symbolRef.name, + ); + const toId = findContractNode( + lookupIndex, + link.to.repo, + 'provider', + link.to.symbolUid, + link.to.symbolRef.filePath, + link.to.symbolRef.name, + ); + if (!fromId || !toId) { + report.linksDroppedMissingNode++; + continue; + } + await queryBridge( + handle, + ` + MATCH (a:Contract), (b:Contract) + WHERE a.id = $fromId AND b.id = $toId + CREATE (a)-[:ContractLink { + matchType: $matchType, + confidence: $confidence, + contractId: $contractId, + fromRepo: $fromRepo, + toRepo: $toRepo + }]->(b) + `, + { + fromId, + toId, + matchType: link.matchType, + confidence: link.confidence, + contractId: link.contractId, + fromRepo: link.from.repo, + toRepo: link.to.repo, + }, + ); + report.linksInserted++; + } catch (err) { + report.linksFailed++; + recordError('link', linkId, err); + } + } + + // 2. Close temp DB (happy path). The finally block also calls + // closeBridgeDb if we threw above; `handleClosed` prevents a + // double-close on the native handle. + await closeBridgeDb(handle); + handleClosed = true; + } finally { + if (!handleClosed) { + await closeBridgeDb(handle).catch(() => { + /* ignore: cleanup path, best effort */ + }); + } + } + + // 3. Atomic swap: old→.bak, tmp→final, rm .bak + try { + await fsp.access(finalPath); + await retryRename(finalPath, bakPath); + } catch { + /* no existing db */ + } + await retryRename(tmpPath, finalPath); + try { + await fsp.rm(bakPath, { recursive: true, force: true }); + } catch { + /* ignore */ + } + + // 4. Write meta.json + await writeBridgeMeta(groupDir, { + version: BRIDGE_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + missingRepos: input.missingRepos, + }); + + return report; +} + +/* ------------------------------------------------------------------ */ +/* openBridgeDbReadOnly */ +/* ------------------------------------------------------------------ */ + +export async function openBridgeDbReadOnly(groupDir: string): Promise { + const dbPath = path.join(groupDir, 'bridge.lbug'); + try { + await fsp.access(dbPath); + } catch { + // Check for .bak recovery. Use `retryRename` (not `fsp.rename`) for the + // exact same reason the rest of this file does: the scenario that + // triggers bak recovery is an interrupted writer, which on Windows may + // still be holding an open handle on `.bak` for a few milliseconds when + // a reader races in. EBUSY/EPERM retries recover that case silently. + const bakPath = path.join(groupDir, 'bridge.lbug.bak'); + try { + await fsp.access(bakPath); + await retryRename(bakPath, dbPath); + } catch { + return null; + } + } + // Version gate: check meta.json version compatibility + const meta = await readBridgeMeta(groupDir); + if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) { + return null; // incompatible schema version — fallback to JSON or re-sync + } + + // Open the native handle. If Connection construction throws AFTER + // Database was successfully allocated, we'd leak the native Database + // object. Wrap each step separately and tear down the partial handle. + let db: lbug.Database | undefined; + let conn: lbug.Connection | undefined; + try { + db = new lbug.Database(dbPath, 0, false, true); // readOnly + conn = new lbug.Connection(db); + return { _db: db, _conn: conn, groupDir } as BridgeHandle; + } catch { + if (conn) { + try { + await conn.close(); + } catch { + /* ignore */ + } + } + if (db) { + try { + await db.close(); + } catch { + /* ignore */ + } + } + return null; + } +} + +/* ------------------------------------------------------------------ */ +/* bridgeExists */ +/* ------------------------------------------------------------------ */ + +export async function bridgeExists(groupDir: string): Promise { + const handle = await openBridgeDbReadOnly(groupDir); + if (!handle) return false; + await closeBridgeDb(handle); + return true; +} diff --git a/gitnexus/src/core/group/bridge-schema.ts b/gitnexus/src/core/group/bridge-schema.ts new file mode 100644 index 000000000..d61680390 --- /dev/null +++ b/gitnexus/src/core/group/bridge-schema.ts @@ -0,0 +1,60 @@ +/** + * Bridge LadybugDB schema for cross-repo Contract Registry. + * Separate from per-repo schema in lbug/schema.ts. + */ + +/** + * Version of the bridge.lbug schema below. `openBridgeDbReadOnly` compares + * this against `meta.json`'s version field and returns `null` on mismatch, + * which trips the caller into either the JSON fallback path or a fresh + * `group sync` that rebuilds `bridge.lbug` from scratch. + * + * Migration contract for contributors bumping this constant: + * 1. Bump the number (e.g. `1` → `2`). + * 2. Update the DDL below to match the new schema. + * 3. DO NOT attempt an online migration in this file — the version gate + * is intentionally a "discard and re-sync" strategy for V1. An old + * bridge.lbug whose version doesn't match is treated as opaque and + * rebuilt by the next `group sync`. + * 4. If online migration becomes necessary (e.g. when groups accumulate + * large amounts of embedding data), add a migration path as a + * separate `bridge-migrations.ts` module rather than bloating this + * file — keep schema and migration concerns separate. + */ +export const BRIDGE_SCHEMA_VERSION = 1; + +export const CONTRACT_SCHEMA = ` +CREATE NODE TABLE Contract ( + id STRING, + contractId STRING, + type STRING, + role STRING, + repo STRING, + service STRING DEFAULT '', + symbolUid STRING DEFAULT '', + filePath STRING DEFAULT '', + symbolName STRING DEFAULT '', + confidence DOUBLE DEFAULT 0.0, + meta STRING DEFAULT '{}', + PRIMARY KEY (id) +)`; + +export const REPO_SNAPSHOT_SCHEMA = ` +CREATE NODE TABLE RepoSnapshot ( + id STRING, + indexedAt STRING DEFAULT '', + lastCommit STRING DEFAULT '', + PRIMARY KEY (id) +)`; + +export const CONTRACT_LINK_SCHEMA = ` +CREATE REL TABLE ContractLink ( + FROM Contract TO Contract, + matchType STRING, + confidence DOUBLE, + contractId STRING, + fromRepo STRING, + toRepo STRING +)`; + +export const BRIDGE_SCHEMA_QUERIES = [CONTRACT_SCHEMA, REPO_SNAPSHOT_SCHEMA, CONTRACT_LINK_SCHEMA]; diff --git a/gitnexus/src/core/group/matching.ts b/gitnexus/src/core/group/matching.ts index 6d39f4ce4..ec793968b 100644 --- a/gitnexus/src/core/group/matching.ts +++ b/gitnexus/src/core/group/matching.ts @@ -5,6 +5,15 @@ export interface MatchResult { unmatched: StoredContract[]; } +export interface WildcardMatchResult { + matched: CrossLink[]; + remaining: StoredContract[]; +} + +function isGrpcWildcard(cid: string): boolean { + return cid.startsWith('grpc::') && cid.endsWith('/*'); +} + export function normalizeContractId(id: string): string { const colonIdx = id.indexOf('::'); if (colonIdx === -1) return id; @@ -24,6 +33,22 @@ export function normalizeContractId(id: string): string { return id; } case 'grpc': { + // Canonical form: `grpc::[/]`. + // + // The package/service segment is lowercased because gRPC package + // names are effectively case-insensitive across language bindings + // (`auth.AuthService`, `auth.authservice`, `AUTH.AUTHSERVICE` all + // describe the same wire protocol service). The RPC method segment + // is preserved as-is because the HTTP/2 path used on the wire is + // case-sensitive per the gRPC spec (`/Service/MethodName`), and + // method names in generated clients match the proto source exactly. + // + // A package-only id (no slash) and a package/method id are treated + // as DISTINCT canonical forms: `grpc::userservice` does not match + // `grpc::userservice/Login`. That's by design — callers that want + // service-level manifest matching against method-level providers + // should use the gRPC wildcard form `grpc::UserService/*` which is + // handled by runWildcardMatch below. const slashIdx = rest.indexOf('/'); if (slashIdx > 0) { const pkg = rest.substring(0, slashIdx).toLowerCase(); @@ -31,12 +56,12 @@ export function normalizeContractId(id: string): string { return `grpc::${pkg}${method}`; } if (slashIdx === 0) { - // Malformed "package/method" with leading slash — do not lowercase the whole string - // (method segment is case-sensitive per spec). + // Malformed "/method" with leading slash — keep as-is so two + // equally malformed ids can still match each other. return `grpc::${rest}`; } - // No slash: spec is ambiguous (package-only vs full service.method). MVP: lowercase - // the whole token; differs from pkg/method split above where RPC method keeps case. + // No slash: package/service only. Lowercase to match the package + // segment produced by the pkg/method branch above. return `grpc::${rest.toLowerCase()}`; } case 'topic': @@ -66,27 +91,36 @@ function findMatchingKeys(contractId: string, index: Map { const providers = contracts.filter((c) => c.role === 'provider'); - const consumers = contracts.filter((c) => c.role === 'consumer'); - - const providerIndex = new Map(); + const index = new Map(); for (const p of providers) { const key = normalizeContractId(p.contractId); - const list = providerIndex.get(key) || []; + const list = index.get(key) || []; list.push(p); - providerIndex.set(key, list); + index.set(key, list); } + return index; +} + +export function runExactMatch( + contracts: StoredContract[], + providerIndex?: Map, +): MatchResult { + const index = providerIndex ?? buildProviderIndex(contracts); + + // Skip gRPC wildcard consumers — they go to wildcard pass only + const consumers = contracts.filter((c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId)); const matched: CrossLink[] = []; const matchedConsumerIds = new Set(); const matchedProviderIds = new Set(); for (const consumer of consumers) { - const matchingKeys = findMatchingKeys(consumer.contractId, providerIndex); + const matchingKeys = findMatchingKeys(consumer.contractId, index); if (matchingKeys.length === 0) continue; - const allMatchingProviders = matchingKeys.flatMap((k) => providerIndex.get(k) || []); + const allMatchingProviders = matchingKeys.flatMap((k) => index.get(k) || []); for (const provider of allMatchingProviders) { if (provider.repo === consumer.repo) { if (!provider.service || !consumer.service || provider.service === consumer.service) { @@ -118,10 +152,86 @@ export function runExactMatch(contracts: StoredContract[]): MatchResult { } } - const unmatched = contracts.filter((c) => { + // normalUnmatched: contracts that weren't matched in exact pass + const normalUnmatched = contracts.filter((c) => { + if (isGrpcWildcard(c.contractId)) return false; // excluded from exact, handled separately const id = `${c.repo}::${c.contractId}`; return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id); }); + // Re-add gRPC wildcard contracts — they were never in exact matching + const grpcWildcards = contracts.filter((c) => isGrpcWildcard(c.contractId)); + const unmatched = [...normalUnmatched, ...grpcWildcards]; + return { matched, unmatched }; } + +export function runWildcardMatch( + unmatched: StoredContract[], + providerIndex: Map, +): WildcardMatchResult { + const wildcardConsumers = unmatched.filter( + (c) => c.role === 'consumer' && isGrpcWildcard(c.contractId), + ); + const matched: CrossLink[] = []; + const matchedConsumerIds = new Set(); + + for (const consumer of wildcardConsumers) { + const normalized = normalizeContractId(consumer.contractId); + // "grpc::com.example.userservice/*" → "com.example.userservice" + // "grpc::userservice/*" → "userservice" + const fqService = normalized.slice(normalized.indexOf('::') + 2, -2); // strip "grpc::" and "/*" + + for (const [key, providers] of providerIndex) { + // Only match against non-wildcard gRPC providers (method-level IDs) + if (!key.startsWith('grpc::') || key.endsWith('/*')) continue; + const afterPrefix = key.slice(6); // strip "grpc::" + const slashIdx = afterPrefix.indexOf('/'); + if (slashIdx < 0) continue; + const providerFqService = afterPrefix.slice(0, slashIdx); + + // Match: exact FQ service, or bare-name match when consumer has no package + const isMatch = + providerFqService === fqService || + (!fqService.includes('.') && providerFqService.endsWith('.' + fqService)); + + if (!isMatch) continue; + + for (const provider of providers) { + // Skip same-repo same-service (same logic as runExactMatch) + if (provider.repo === consumer.repo) { + if (!provider.service || !consumer.service || provider.service === consumer.service) { + continue; + } + } + + matched.push({ + from: { + repo: consumer.repo, + service: consumer.service, + symbolUid: consumer.symbolUid, + symbolRef: consumer.symbolRef, + }, + to: { + repo: provider.repo, + service: provider.service, + symbolUid: provider.symbolUid, + symbolRef: provider.symbolRef, + }, + type: consumer.type, + contractId: consumer.contractId, // consumer's wildcard ID + matchType: 'wildcard', + confidence: Math.min(provider.confidence, consumer.confidence), + }); + matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`); + } + } + } + + const remaining = unmatched.filter((c) => { + if (c.role !== 'consumer' || !isGrpcWildcard(c.contractId)) return true; + return !matchedConsumerIds.has(`${c.repo}::${c.contractId}`); + }); + + return { matched, remaining }; +} diff --git a/gitnexus/src/core/group/normalization.ts b/gitnexus/src/core/group/normalization.ts new file mode 100644 index 000000000..c99d36850 --- /dev/null +++ b/gitnexus/src/core/group/normalization.ts @@ -0,0 +1,124 @@ +import type { CrossLink, CrossLinkEndpoint, StoredContract } from './types.js'; + +function contractKey(contract: StoredContract): string { + return [contract.repo, contract.contractId, contract.role, contract.symbolRef.filePath].join( + '\0', + ); +} + +function endpointKey(endpoint: CrossLinkEndpoint): string { + return [ + endpoint.repo, + endpoint.service ?? '', + endpoint.symbolRef.filePath, + endpoint.symbolRef.name, + ].join('\0'); +} + +/** + * Score a contract by how much information it carries, so `dedupeContracts` + * can prefer the "richer" record when two contracts collide on the same + * `(repo, contractId, role, filePath)` key. + * + * Weights express a priority ordering, not calibrated probabilities: + * +3 — `symbolUid` resolved (tier 1 of the downstream lookup — highest + * signal because it's the strongest anchor for cross-impact traversal + * and the only one that's robust to renames) + * +2 — any of `filePath`, `symbolRef.name`, or `symbolName` that's more + * specific than the contractId itself (tier 2 signal — resolves + * uniquely in most cases and survives across syncs) + * +1 — `service` tag (monorepo attribution — useful but not sufficient + * on its own) or non-manifest origin (auto-extracted contracts are + * preferred over manifest-declared synthetic ones because the former + * are grounded in real source code) + * + * The absolute numbers don't matter, only their relative ordering. + */ +function contractRichness(contract: StoredContract): number { + let score = 0; + if (contract.symbolUid) score += 3; + if (contract.symbolRef.filePath) score += 2; + if (contract.symbolRef.name && contract.symbolRef.name !== contract.contractId) score += 2; + if (contract.symbolName && contract.symbolName !== contract.contractId) score += 2; + if (contract.service) score += 1; + if (contract.meta.source !== 'manifest') score += 1; + return score; +} + +function mergeContracts(existing: StoredContract, incoming: StoredContract): StoredContract { + const [primary, secondary] = + contractRichness(incoming) > contractRichness(existing) + ? [incoming, existing] + : [existing, incoming]; + const symbolRefName = primary.symbolRef.name || secondary.symbolRef.name; + return { + ...secondary, + ...primary, + symbolUid: primary.symbolUid || secondary.symbolUid, + symbolRef: { + filePath: primary.symbolRef.filePath || secondary.symbolRef.filePath, + name: symbolRefName, + }, + symbolName: primary.symbolName || secondary.symbolName || symbolRefName, + confidence: Math.max(existing.confidence, incoming.confidence), + service: primary.service ?? secondary.service, + meta: { ...secondary.meta, ...primary.meta }, + }; +} + +function mergeEndpoints( + existing: CrossLinkEndpoint, + incoming: CrossLinkEndpoint, +): CrossLinkEndpoint { + return { + repo: existing.repo, + service: existing.service ?? incoming.service, + symbolUid: existing.symbolUid || incoming.symbolUid, + symbolRef: { + filePath: existing.symbolRef.filePath || incoming.symbolRef.filePath, + name: existing.symbolRef.name || incoming.symbolRef.name, + }, + }; +} + +function crossLinkKey(link: CrossLink): string { + return [ + link.type, + link.contractId, + link.matchType, + endpointKey(link.from), + endpointKey(link.to), + ].join('\0'); +} + +export function dedupeContracts(items: StoredContract[]): StoredContract[] { + const deduped = new Map(); + for (const contract of items) { + const key = contractKey(contract); + const existing = deduped.get(key); + deduped.set(key, existing ? mergeContracts(existing, contract) : contract); + } + return [...deduped.values()]; +} + +export function dedupeCrossLinks(items: CrossLink[]): CrossLink[] { + const deduped = new Map(); + for (const link of items) { + const key = crossLinkKey(link); + const existing = deduped.get(key); + if (!existing) { + deduped.set(key, link); + continue; + } + const keepIncoming = link.confidence > existing.confidence; + const primary = keepIncoming ? link : existing; + const secondary = keepIncoming ? existing : link; + deduped.set(key, { + ...primary, + confidence: Math.max(existing.confidence, link.confidence), + from: mergeEndpoints(primary.from, secondary.from), + to: mergeEndpoints(primary.to, secondary.to), + }); + } + return [...deduped.values()]; +} diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index 7ab0f071a..b9ba97582 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -1,5 +1,5 @@ export type ContractType = 'http' | 'grpc' | 'topic' | 'lib' | 'custom'; -export type MatchType = 'exact' | 'manifest' | 'bm25' | 'embedding'; +export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding'; export type ContractRole = 'provider' | 'consumer'; export interface GroupConfig { @@ -131,3 +131,17 @@ export interface OutOfScopeLink { contractId: string; confidence: number; } + +/** Opaque handle to an open bridge LadybugDB. */ +export interface BridgeHandle { + /** Internal — do not access directly. */ + readonly _db: unknown; + readonly _conn: unknown; + readonly groupDir: string; +} + +export interface BridgeMeta { + version: number; + generatedAt: string; + missingRepos: string[]; +} diff --git a/gitnexus/test/unit/group/bridge-db-edge.test.ts b/gitnexus/test/unit/group/bridge-db-edge.test.ts new file mode 100644 index 000000000..ca85468cc --- /dev/null +++ b/gitnexus/test/unit/group/bridge-db-edge.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { + writeBridge, + openBridgeDbReadOnly, + queryBridge, + closeBridgeDb, +} from '../../../src/core/group/bridge-db.js'; +import type { CrossLink } from '../../../src/core/group/types.js'; +import { makeContract } from './fixtures.js'; + +describe('bridge-db edge cases', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-edge-')); + }); + + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + it('test_openBridgeDbReadOnly_version_gate_returns_null_for_incompatible', async () => { + // Create a dummy bridge.lbug file so the access check passes + await fsp.writeFile(path.join(tmpDir, 'bridge.lbug'), 'dummy'); + // Write meta.json with an incompatible version (999) + await fsp.writeFile( + path.join(tmpDir, 'meta.json'), + JSON.stringify({ version: 999, generatedAt: '', missingRepos: [] }), + ); + + const handle = await openBridgeDbReadOnly(tmpDir); + expect(handle).toBeNull(); + }); + + it('test_openBridgeDbReadOnly_bak_recovery_restores_bridge', async () => { + // Write a valid bridge + await writeBridge(tmpDir, { + contracts: [makeContract()], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [], + }); + // Move bridge.lbug → bridge.lbug.bak (simulating interrupted swap) + const dbPath = path.join(tmpDir, 'bridge.lbug'); + const bakPath = path.join(tmpDir, 'bridge.lbug.bak'); + await fsp.rename(dbPath, bakPath); + + // openBridgeDbReadOnly should auto-recover from .bak + const handle = await openBridgeDbReadOnly(tmpDir); + expect(handle).not.toBeNull(); + const rows = await queryBridge<{ repo: string }>( + handle!, + 'MATCH (c:Contract) RETURN c.repo AS repo', + ); + expect(rows).toHaveLength(1); + await closeBridgeDb(handle!); + }); + + it('test_writeBridge_crossLink_with_missing_to_node_silently_skipped', async () => { + const provider = makeContract({ repo: 'backend', role: 'provider' }); + const consumer = makeContract({ + repo: 'frontend', + role: 'consumer', + symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' }, + symbolName: 'fetchUsers', + }); + // CrossLink referencing a 'to' endpoint that doesn't match any contract node + const link: CrossLink = { + from: { + repo: 'frontend', + symbolUid: '', + symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' }, + }, + to: { + repo: 'nonexistent-repo', + symbolUid: 'uid-missing', + symbolRef: { filePath: 'src/missing.ts', name: 'missingFn' }, + }, + type: 'http', + contractId: 'http::GET::/api/users', + matchType: 'exact', + confidence: 1.0, + }; + + // Should not throw — the link is silently skipped + await writeBridge(tmpDir, { + contracts: [provider, consumer], + crossLinks: [link], + repoSnapshots: {}, + missingRepos: [], + }); + + const handle = await openBridgeDbReadOnly(tmpDir); + expect(handle).not.toBeNull(); + // No cross-links should exist since 'to' node was missing + const rows = await queryBridge<{ matchType: string }>( + handle!, + 'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.matchType AS matchType', + ); + expect(rows).toHaveLength(0); + // But contracts should still be present + const contractRows = await queryBridge<{ repo: string }>( + handle!, + 'MATCH (c:Contract) RETURN c.repo AS repo', + ); + expect(contractRows).toHaveLength(2); + await closeBridgeDb(handle!); + }); + + it('test_writeBridge_manifest_grpc_link_with_symbol_uids_persists_queryable_contract_edge', async () => { + const provider = makeContract({ + contractId: 'grpc::auth.AuthService/Login', + type: 'grpc', + role: 'provider', + repo: 'platform/auth', + symbolUid: 'uid-auth-login', + symbolRef: { filePath: 'src/auth.proto', name: 'Login' }, + symbolName: 'auth.AuthService/Login', + }); + const consumer = makeContract({ + contractId: 'grpc::auth.AuthService/Login', + type: 'grpc', + role: 'consumer', + repo: 'platform/orders', + symbolUid: 'uid-orders-client', + symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' }, + symbolName: 'auth.AuthService/Login', + }); + const link: CrossLink = { + from: { + repo: 'platform/orders', + symbolUid: 'uid-orders-client', + symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' }, + }, + to: { + repo: 'platform/auth', + symbolUid: 'uid-auth-login', + symbolRef: { filePath: 'src/auth.proto', name: 'Login' }, + }, + type: 'grpc', + contractId: 'grpc::auth.AuthService/Login', + matchType: 'manifest', + confidence: 1.0, + }; + + await writeBridge(tmpDir, { + contracts: [provider, consumer], + crossLinks: [link], + repoSnapshots: {}, + missingRepos: [], + }); + + const handle = await openBridgeDbReadOnly(tmpDir); + expect(handle).not.toBeNull(); + const rows = await queryBridge<{ + contractId: string; + matchType: string; + fromRepo: string; + toRepo: string; + }>( + handle!, + `MATCH (a:Contract)-[l:ContractLink]->(b:Contract) + RETURN l.contractId AS contractId, l.matchType AS matchType, l.fromRepo AS fromRepo, l.toRepo AS toRepo`, + ); + expect(rows).toEqual([ + { + contractId: 'grpc::auth.AuthService/Login', + matchType: 'manifest', + fromRepo: 'platform/orders', + toRepo: 'platform/auth', + }, + ]); + await closeBridgeDb(handle!); + }); +}); diff --git a/gitnexus/test/unit/group/bridge-db.test.ts b/gitnexus/test/unit/group/bridge-db.test.ts new file mode 100644 index 000000000..e52b14c17 --- /dev/null +++ b/gitnexus/test/unit/group/bridge-db.test.ts @@ -0,0 +1,575 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { + openBridgeDb, + ensureBridgeSchema, + queryBridge, + closeBridgeDb, + contractNodeId, + retryRename, + writeBridge, + openBridgeDbReadOnly, + readBridgeMeta, + bridgeExists, + createContractLookupIndex, + indexContract, + findContractNode, +} from '../../../src/core/group/bridge-db.js'; +import type { CrossLink } from '../../../src/core/group/types.js'; +import { makeContract } from './fixtures.js'; + +describe('bridge-db core', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-test-')); + }); + + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + it('test_openBridgeDb_returns_handle_and_closes', async () => { + const dbPath = path.join(tmpDir, 'test.lbug'); + const handle = await openBridgeDb(dbPath); + expect(handle).toBeDefined(); + expect(handle._db).toBeDefined(); + expect(handle._conn).toBeDefined(); + expect(handle.groupDir).toBe(tmpDir); + // Close should not throw + await closeBridgeDb(handle); + }); + + it('test_ensureBridgeSchema_creates_tables_idempotent', async () => { + const dbPath = path.join(tmpDir, 'test.lbug'); + const handle = await openBridgeDb(dbPath); + await ensureBridgeSchema(handle); + // Run again — should not throw + await ensureBridgeSchema(handle); + const rows = await queryBridge<{ cnt: number }>( + handle, + 'MATCH (c:Contract) RETURN count(c) AS cnt', + ); + expect(rows[0].cnt).toBe(0); + await closeBridgeDb(handle); + }); + + it('test_queryBridge_returns_inserted_data', async () => { + const dbPath = path.join(tmpDir, 'test.lbug'); + const handle = await openBridgeDb(dbPath); + await ensureBridgeSchema(handle); + await queryBridge( + handle, + `CREATE (c:Contract { + id: 'abc123', contractId: 'http::GET::/api', type: 'http', role: 'provider', + repo: 'backend', confidence: 0.9 + })`, + ); + const rows = await queryBridge<{ repo: string; confidence: number }>( + handle, + 'MATCH (c:Contract) RETURN c.repo AS repo, c.confidence AS confidence', + ); + expect(rows).toHaveLength(1); + expect(rows[0].repo).toBe('backend'); + expect(rows[0].confidence).toBe(0.9); + await closeBridgeDb(handle); + }); + + it('test_queryBridge_parameterized', async () => { + const dbPath = path.join(tmpDir, 'test.lbug'); + const handle = await openBridgeDb(dbPath); + await ensureBridgeSchema(handle); + await queryBridge( + handle, + `CREATE (c:Contract { + id: 'p1', contractId: 'http::GET::/api', type: 'http', role: 'provider', + repo: 'backend', confidence: 0.9 + })`, + ); + const rows = await queryBridge<{ repo: string }>( + handle, + 'MATCH (c:Contract) WHERE c.repo = $r RETURN c.repo AS repo', + { r: 'backend' }, + ); + expect(rows).toHaveLength(1); + expect(rows[0].repo).toBe('backend'); + await closeBridgeDb(handle); + }); + + it('test_contractNodeId_full_sha256', () => { + const id = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/routes.ts'); + expect(id).toHaveLength(64); // full SHA-256 hex + // Same inputs → same hash + const id2 = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/routes.ts'); + expect(id).toBe(id2); + // Different filePath → different hash + const id3 = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/other.ts'); + expect(id).not.toBe(id3); + }); +}); + +describe('writeBridge + read', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-write-')); + }); + + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + it('test_writeBridge_creates_bridge_lbug_file', async () => { + await writeBridge(tmpDir, { + contracts: [makeContract()], + crossLinks: [], + repoSnapshots: { backend: { indexedAt: '2026-01-01', lastCommit: 'abc' } }, + missingRepos: ['missing-repo'], + }); + const exists = await bridgeExists(tmpDir); + expect(exists).toBe(true); + }); + + it('test_writeBridge_returns_report_with_insert_counts', async () => { + const report = await writeBridge(tmpDir, { + contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })], + crossLinks: [], + repoSnapshots: { backend: { indexedAt: '2026-01-01', lastCommit: 'abc' } }, + missingRepos: [], + }); + expect(report.contractsInserted).toBe(2); + expect(report.contractsFailed).toBe(0); + expect(report.snapshotsInserted).toBe(1); + expect(report.snapshotsFailed).toBe(0); + expect(report.linksInserted).toBe(0); + expect(report.linksFailed).toBe(0); + expect(report.linksDroppedMissingNode).toBe(0); + expect(report.sampleErrors).toHaveLength(0); + }); + + it('test_writeBridge_counts_dropped_links_with_missing_nodes', async () => { + // Provider + cross-link that references a non-existent consumer node → + // findContractNode returns null for `from`, link gets dropped. + const provider = makeContract({ role: 'provider' }); + const report = await writeBridge(tmpDir, { + contracts: [provider], + crossLinks: [ + { + from: { + repo: 'ghost', + symbolUid: '', + symbolRef: { filePath: 'nowhere.ts', name: 'ghostFn' }, + }, + to: { + repo: provider.repo, + symbolUid: provider.symbolUid, + symbolRef: provider.symbolRef, + }, + type: 'http', + contractId: provider.contractId, + matchType: 'exact', + confidence: 1.0, + }, + ], + repoSnapshots: {}, + missingRepos: [], + }); + expect(report.linksInserted).toBe(0); + expect(report.linksDroppedMissingNode).toBe(1); + expect(report.linksFailed).toBe(0); + expect(report.contractsInserted).toBe(1); + }); + + it('test_writeBridge_contracts_queryable', async () => { + await writeBridge(tmpDir, { + contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [], + }); + const handle = await openBridgeDbReadOnly(tmpDir); + expect(handle).not.toBeNull(); + const rows = await queryBridge<{ repo: string }>( + handle!, + 'MATCH (c:Contract) RETURN c.repo AS repo', + ); + expect(rows).toHaveLength(2); + await closeBridgeDb(handle!); + }); + + it('test_writeBridge_meta_json_persists_missingRepos', async () => { + await writeBridge(tmpDir, { + contracts: [], + crossLinks: [], + repoSnapshots: {}, + missingRepos: ['repo-a', 'repo-b'], + }); + const meta = await readBridgeMeta(tmpDir); + expect(meta.missingRepos).toEqual(['repo-a', 'repo-b']); + expect(meta.version).toBeGreaterThan(0); + expect(meta.generatedAt).toBeTruthy(); + }); + + it('test_writeBridge_repoSnapshots_queryable', async () => { + await writeBridge(tmpDir, { + contracts: [], + crossLinks: [], + repoSnapshots: { 'hr/backend': { indexedAt: '2026-01-01', lastCommit: 'abc' } }, + missingRepos: [], + }); + const handle = await openBridgeDbReadOnly(tmpDir); + const rows = await queryBridge<{ id: string; indexedAt: string }>( + handle!, + 'MATCH (s:RepoSnapshot) RETURN s.id AS id, s.indexedAt AS indexedAt', + ); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe('hr/backend'); + expect(rows[0].indexedAt).toBe('2026-01-01'); + await closeBridgeDb(handle!); + }); + + it('test_writeBridge_crossLinks_queryable', async () => { + const provider = makeContract({ repo: 'backend', role: 'provider' }); + const consumer = makeContract({ + repo: 'frontend', + role: 'consumer', + symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' }, + symbolName: 'fetchUsers', + }); + const link: CrossLink = { + from: { + repo: 'frontend', + symbolUid: '', + symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' }, + }, + to: { + repo: 'backend', + symbolUid: 'uid-1', + symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' }, + }, + type: 'http', + contractId: 'http::GET::/api/users', + matchType: 'exact', + confidence: 1.0, + }; + await writeBridge(tmpDir, { + contracts: [provider, consumer], + crossLinks: [link], + repoSnapshots: {}, + missingRepos: [], + }); + const handle = await openBridgeDbReadOnly(tmpDir); + const rows = await queryBridge<{ fromRepo: string; toRepo: string; matchType: string }>( + handle!, + 'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo, l.matchType AS matchType', + ); + expect(rows).toHaveLength(1); + expect(rows[0].fromRepo).toBe('frontend'); + expect(rows[0].toRepo).toBe('backend'); + expect(rows[0].matchType).toBe('exact'); + await closeBridgeDb(handle!); + }); + + it('test_writeBridge_duplicate_contracts_and_links_are_deduped', async () => { + const provider = makeContract({ + repo: 'backend', + role: 'provider', + symbolUid: '', + symbolName: 'auth.AuthService/Login', + symbolRef: { filePath: 'src/auth.proto', name: 'Login' }, + contractId: 'grpc::auth.AuthService/Login', + type: 'grpc', + meta: { source: 'manifest' }, + }); + const concreteProvider = makeContract({ + ...provider, + symbolUid: 'uid-auth-login', + symbolName: 'Login', + confidence: 0.85, + meta: { source: 'analyze' }, + }); + const consumer = makeContract({ + repo: 'frontend', + role: 'consumer', + symbolUid: '', + symbolName: 'auth.AuthService/Login', + symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' }, + contractId: 'grpc::auth.AuthService/Login', + type: 'grpc', + meta: { source: 'manifest' }, + }); + const link: CrossLink = { + from: { + repo: 'frontend', + symbolUid: '', + symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' }, + }, + to: { + repo: 'backend', + symbolUid: '', + symbolRef: { filePath: 'src/auth.proto', name: 'Login' }, + }, + type: 'grpc', + contractId: 'grpc::auth.AuthService/Login', + matchType: 'manifest', + confidence: 1, + }; + + await writeBridge(tmpDir, { + contracts: [provider, concreteProvider, consumer], + crossLinks: [link, { ...link }], + repoSnapshots: {}, + missingRepos: [], + }); + + const handle = await openBridgeDbReadOnly(tmpDir); + const contracts = await queryBridge<{ repo: string; symbolUid: string; symbolName: string }>( + handle!, + 'MATCH (c:Contract) RETURN c.repo AS repo, c.symbolUid AS symbolUid, c.symbolName AS symbolName ORDER BY c.repo', + ); + const links = await queryBridge<{ fromRepo: string; toRepo: string }>( + handle!, + 'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo', + ); + + expect(contracts).toHaveLength(2); + expect(contracts[0]).toEqual({ + repo: 'backend', + symbolUid: 'uid-auth-login', + symbolName: 'Login', + }); + expect(links).toHaveLength(1); + await closeBridgeDb(handle!); + }); + + it('test_openBridgeDbReadOnly_returns_null_for_missing', async () => { + const handle = await openBridgeDbReadOnly(path.join(tmpDir, 'nonexistent')); + expect(handle).toBeNull(); + }); + + it('test_bridgeExists_false_for_missing', async () => { + expect(await bridgeExists(path.join(tmpDir, 'nonexistent'))).toBe(false); + }); + + it('test_writeBridge_overwrites_previous', async () => { + await writeBridge(tmpDir, { + contracts: [makeContract()], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [], + }); + await writeBridge(tmpDir, { + contracts: [makeContract({ repo: 'new-repo' })], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [], + }); + const handle = await openBridgeDbReadOnly(tmpDir); + const rows = await queryBridge<{ repo: string }>( + handle!, + 'MATCH (c:Contract) RETURN c.repo AS repo', + ); + expect(rows).toHaveLength(1); + expect(rows[0].repo).toBe('new-repo'); + await closeBridgeDb(handle!); + }); + + it('test_readBridgeMeta_returns_defaults_for_missing', async () => { + const meta = await readBridgeMeta(path.join(tmpDir, 'nonexistent')); + expect(meta.version).toBe(0); + expect(meta.generatedAt).toBe(''); + expect(meta.missingRepos).toEqual([]); + }); +}); + +describe('retryRename', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('retries on EBUSY and eventually succeeds', async () => { + // Spy on fs.promises.rename and make the first two attempts fail with + // EBUSY, then succeed on the third. Verifies that Windows-style + // transient rename failures don't immediately bubble up. + const attempts: Array<[string, string]> = []; + let calls = 0; + const spy = vi.spyOn(fsp, 'rename').mockImplementation(async (src, dst) => { + attempts.push([String(src), String(dst)]); + calls++; + if (calls < 3) { + const err = new Error('resource busy or locked') as NodeJS.ErrnoException; + err.code = 'EBUSY'; + throw err; + } + // Third attempt: pretend the rename worked. + return undefined; + }); + + await retryRename('/src/a', '/dst/b', 3); + + expect(spy).toHaveBeenCalledTimes(3); + expect(attempts.every(([s, d]) => s === '/src/a' && d === '/dst/b')).toBe(true); + }); + + it('rethrows non-retryable errors immediately', async () => { + // A non-retryable code (e.g. ENOENT) should NOT be swallowed into a + // retry loop — that would mask real bugs and waste time. + let calls = 0; + vi.spyOn(fsp, 'rename').mockImplementation(async () => { + calls++; + const err = new Error('no such file') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }); + + await expect(retryRename('/src/a', '/dst/b', 5)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(calls).toBe(1); + }); + + it('gives up after the configured number of attempts', async () => { + let calls = 0; + vi.spyOn(fsp, 'rename').mockImplementation(async () => { + calls++; + const err = new Error('locked') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + }); + + await expect(retryRename('/src/a', '/dst/b', 3)).rejects.toMatchObject({ code: 'EPERM' }); + expect(calls).toBe(3); + }); + + it('retries on EACCES as well', async () => { + let calls = 0; + vi.spyOn(fsp, 'rename').mockImplementation(async () => { + calls++; + if (calls < 2) { + const err = new Error('permission denied') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + } + return undefined; + }); + + await retryRename('/src/a', '/dst/b', 3); + expect(calls).toBe(2); + }); +}); + +describe('findContractNode', () => { + // Pure-function tests for the lookup index + three-tier resolver that + // were previously an inner closure of `writeBridge` and therefore + // untestable in isolation. Every test here builds its own index and + // never touches the DB. + + it('returns null on empty index', () => { + const index = createContractLookupIndex(); + expect(findContractNode(index, 'backend', 'provider', 'uid-1', 'src/a.ts', 'foo')).toBeNull(); + }); + + it('tier 1: returns contract matched by symbolUid', () => { + const index = createContractLookupIndex(); + const c = makeContract({ symbolUid: 'uid-42', repo: 'backend', role: 'provider' }); + indexContract(index, c, 'node-A'); + expect(findContractNode(index, 'backend', 'provider', 'uid-42', 'anywhere.ts', 'anyName')).toBe( + 'node-A', + ); + }); + + it('tier 1 is repo-scoped: same uid in a different repo does not match', () => { + const index = createContractLookupIndex(); + const c = makeContract({ symbolUid: 'uid-42', repo: 'backend' }); + indexContract(index, c, 'node-A'); + expect( + findContractNode(index, 'frontend', 'provider', 'uid-42', 'src/routes.ts', 'getUsers'), + ).toBeNull(); + }); + + it('tier 1 is role-scoped: provider uid match does not resolve consumer query', () => { + const index = createContractLookupIndex(); + const c = makeContract({ symbolUid: 'uid-42', role: 'provider', repo: 'backend' }); + indexContract(index, c, 'node-A'); + expect( + findContractNode(index, 'backend', 'consumer', 'uid-42', 'src/routes.ts', 'getUsers'), + ).toBeNull(); + }); + + it('tier 2: falls through to filePath + symbolName when symbolUid is empty', () => { + const index = createContractLookupIndex(); + const c = makeContract({ + symbolUid: '', + symbolRef: { filePath: 'src/ctrl.ts', name: 'handler' }, + symbolName: 'handler', + }); + indexContract(index, c, 'node-B'); + expect(findContractNode(index, 'backend', 'provider', '', 'src/ctrl.ts', 'handler')).toBe( + 'node-B', + ); + }); + + it('tier 2: falls through when the given symbolUid does not match anything', () => { + const index = createContractLookupIndex(); + const c = makeContract({ + symbolUid: 'uid-real', + symbolRef: { filePath: 'src/ctrl.ts', name: 'handler' }, + }); + indexContract(index, c, 'node-B'); + // Wrong uid; but filePath+name still resolves. + expect( + findContractNode(index, 'backend', 'provider', 'uid-wrong', 'src/ctrl.ts', 'handler'), + ).toBe('node-B'); + }); + + it('tier 3: resolves by filePath alone when exactly one contract lives there', () => { + const index = createContractLookupIndex(); + const c = makeContract({ + symbolUid: '', + symbolRef: { filePath: 'src/solo.ts', name: 'actualName' }, + }); + indexContract(index, c, 'node-C'); + // filePath+name miss (name is wrong), but tier 3 picks the sole entry. + expect(findContractNode(index, 'backend', 'provider', '', 'src/solo.ts', 'wrongName')).toBe( + 'node-C', + ); + }); + + it('tier 3: does NOT resolve when multiple contracts live in the same file', () => { + const index = createContractLookupIndex(); + const a = makeContract({ + symbolUid: '', + symbolRef: { filePath: 'src/multi.ts', name: 'handlerA' }, + }); + const b = makeContract({ + symbolUid: '', + symbolRef: { filePath: 'src/multi.ts', name: 'handlerB' }, + contractId: 'http::GET::/api/b', + }); + indexContract(index, a, 'node-MA'); + indexContract(index, b, 'node-MB'); + // Wrong symbolName → no tier 2 match. Two contracts in the same file + // → tier 3 must refuse to guess. + expect( + findContractNode(index, 'backend', 'provider', '', 'src/multi.ts', 'unknown'), + ).toBeNull(); + }); + + it('prefers tier 1 over tier 2 when both could resolve', () => { + const index = createContractLookupIndex(); + const tier1Contract = makeContract({ + symbolUid: 'uid-1', + symbolRef: { filePath: 'src/a.ts', name: 'first' }, + }); + const tier2Contract = makeContract({ + symbolUid: '', + symbolRef: { filePath: 'src/a.ts', name: 'first' }, + contractId: 'http::POST::/api/x', + }); + indexContract(index, tier1Contract, 'tier1-id'); + indexContract(index, tier2Contract, 'tier2-id'); + expect(findContractNode(index, 'backend', 'provider', 'uid-1', 'src/a.ts', 'first')).toBe( + 'tier1-id', + ); + }); +}); diff --git a/gitnexus/test/unit/group/fixtures.ts b/gitnexus/test/unit/group/fixtures.ts new file mode 100644 index 000000000..a3d63d6b1 --- /dev/null +++ b/gitnexus/test/unit/group/fixtures.ts @@ -0,0 +1,32 @@ +/** + * Shared test fixtures for `test/unit/group/*` test files. Keep this small + * and purpose-built — it's NOT a general-purpose factory. If a builder here + * grows complex enough to need its own module, move it next to the code + * under test (e.g. `bridge-db.fixtures.ts`) instead of ballooning this file. + */ + +import type { StoredContract } from '../../../src/core/group/types.js'; + +/** + * Canonical baseline contract used by bridge-db and related tests. Every + * field is populated so callers get a valid `StoredContract` with zero args, + * and any field can be overridden via the partial — e.g. + * `makeContract({ role: 'consumer', repo: 'frontend' })`. + * + * Prefer passing a `Partial` override for the specific + * field you care about rather than mutating the returned object in place. + */ +export function makeContract(overrides: Partial = {}): StoredContract { + return { + contractId: 'http::GET::/api/users', + type: 'http', + role: 'provider', + symbolUid: 'uid-1', + symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' }, + symbolName: 'getUsers', + confidence: 0.85, + meta: {}, + repo: 'backend', + ...overrides, + }; +} diff --git a/gitnexus/test/unit/group/matching.test.ts b/gitnexus/test/unit/group/matching.test.ts index bbbe5f664..c5713d909 100644 --- a/gitnexus/test/unit/group/matching.test.ts +++ b/gitnexus/test/unit/group/matching.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { runExactMatch, normalizeContractId } from '../../../src/core/group/matching.js'; +import { + runExactMatch, + normalizeContractId, + buildProviderIndex, + runWildcardMatch, +} from '../../../src/core/group/matching.js'; import type { StoredContract } from '../../../src/core/group/types.js'; describe('normalizeContractId', () => { @@ -21,6 +26,16 @@ describe('normalizeContractId', () => { expect(normalizeContractId('grpc::/MyPkg/DoThing')).toBe('grpc::/MyPkg/DoThing'); }); + it('handles malformed grpc with leading slash and no package', () => { + // grpc::/Method — leading slash, no package + expect(normalizeContractId('grpc::/Method')).toBe('grpc::/Method'); + }); + + it('handles grpc with no slash at all', () => { + // grpc::ServiceName — no slash, ambiguous; MVP: lowercase entire token + expect(normalizeContractId('grpc::ServiceName')).toBe('grpc::servicename'); + }); + it('trims and lowercases topic', () => { expect(normalizeContractId('topic:: Employee.Hired ')).toBe('topic::employee.hired'); }); @@ -180,3 +195,211 @@ describe('runExactMatch', () => { expect(unmatched).toHaveLength(0); }); }); + +// --------------------------------------------------------------------------- +// Helpers for Task 6 tests +// --------------------------------------------------------------------------- +function makeGrpcContract( + id: string, + role: 'provider' | 'consumer', + repo: string, + overrides: Partial = {}, +): StoredContract { + return { + contractId: id, + type: 'grpc', + role, + symbolUid: `uid-${repo}-${id}`, + symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` }, + symbolName: `fn-${id}`, + confidence: 0.9, + meta: {}, + repo, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// buildProviderIndex +// --------------------------------------------------------------------------- +describe('buildProviderIndex', () => { + it('test_buildProviderIndex_creates_normalized_keys', () => { + const contracts: StoredContract[] = [ + makeGrpcContract('grpc::Com.Example.UserService/GetUser', 'provider', 'backend'), + makeGrpcContract('grpc::Com.Example.UserService/GetUser', 'consumer', 'frontend'), + ]; + + const index = buildProviderIndex(contracts); + + // Only providers should be in the index + expect(index.size).toBe(1); + // Key should be normalized (lowercased package) + expect(index.has('grpc::com.example.userservice/GetUser')).toBe(true); + expect(index.get('grpc::com.example.userservice/GetUser')).toHaveLength(1); + expect(index.get('grpc::com.example.userservice/GetUser')![0].role).toBe('provider'); + }); +}); + +// --------------------------------------------------------------------------- +// runExactMatch — gRPC wildcard skip +// --------------------------------------------------------------------------- +describe('runExactMatch — gRPC wildcard handling', () => { + it('test_runExactMatch_skips_grpc_wildcard_contracts', () => { + const contracts: StoredContract[] = [ + makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend'), + makeGrpcContract('grpc::com.example.UserService/*', 'provider', 'backend'), + ]; + + const { matched, unmatched } = runExactMatch(contracts); + + // gRPC wildcards should NOT be matched in exact pass + expect(matched).toHaveLength(0); + // Both should appear in unmatched + expect(unmatched).toHaveLength(2); + }); + + it('test_runExactMatch_does_not_skip_http_wildcards', () => { + const contracts: StoredContract[] = [ + { + contractId: 'http::GET::/api/users', + type: 'http', + role: 'provider', + symbolUid: 'uid-backend-http', + symbolRef: { filePath: 'src/backend.ts', name: 'fn-http' }, + symbolName: 'fn-http', + confidence: 0.9, + meta: {}, + repo: 'backend', + }, + { + contractId: 'http::*::/api/users', + type: 'http', + role: 'consumer', + symbolUid: 'uid-frontend-http', + symbolRef: { filePath: 'src/frontend.ts', name: 'fn-http' }, + symbolName: 'fn-http', + confidence: 0.9, + meta: {}, + repo: 'frontend', + }, + ]; + + const { matched } = runExactMatch(contracts); + // HTTP wildcard should still match via findMatchingKeys + expect(matched).toHaveLength(1); + expect(matched[0].contractId).toBe('http::*::/api/users'); + }); +}); + +// --------------------------------------------------------------------------- +// runWildcardMatch +// --------------------------------------------------------------------------- +describe('runWildcardMatch', () => { + it('test_runWildcardMatch_fq_service_match', () => { + const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend'); + const provider = makeGrpcContract( + 'grpc::com.example.UserService/GetUser', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].from.repo).toBe('frontend'); + expect(matched[0].to.repo).toBe('backend'); + }); + + it('test_runWildcardMatch_bare_name_match', () => { + const consumer = makeGrpcContract('grpc::UserService/*', 'consumer', 'frontend'); + const provider = makeGrpcContract( + 'grpc::com.example.UserService/GetUser', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].from.repo).toBe('frontend'); + expect(matched[0].to.repo).toBe('backend'); + }); + + it('test_runWildcardMatch_no_match_different_service', () => { + const consumer = makeGrpcContract('grpc::UserService/*', 'consumer', 'frontend'); + const provider = makeGrpcContract( + 'grpc::com.example.OtherService/GetUser', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched, remaining } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(0); + expect(remaining).toContainEqual(consumer); + }); + + it('test_runWildcardMatch_skips_wildcard_providers', () => { + const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend'); + const provider = makeGrpcContract('grpc::com.example.UserService/*', 'provider', 'backend'); + + const providerIndex = buildProviderIndex([provider]); + const { matched } = runWildcardMatch([consumer], providerIndex); + + // Wildcard provider key ends with /*, so it should be skipped + expect(matched).toHaveLength(0); + }); + + it('test_runWildcardMatch_confidence_min', () => { + const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend', { + confidence: 0.7, + }); + const provider = makeGrpcContract( + 'grpc::com.example.UserService/GetUser', + 'provider', + 'backend', + { + confidence: 0.5, + }, + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].confidence).toBe(0.5); + }); + + it('test_runWildcardMatch_matchType_wildcard', () => { + const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend'); + const provider = makeGrpcContract( + 'grpc::com.example.UserService/GetUser', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].matchType).toBe('wildcard'); + }); + + it('test_runWildcardMatch_contractId_is_consumers', () => { + const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend'); + const provider = makeGrpcContract( + 'grpc::com.example.UserService/GetUser', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].contractId).toBe('grpc::com.example.UserService/*'); + }); +}); From a94d6ef80b1d91dac02634a55b98e7a12c3f9b91 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 01:06:55 +0100 Subject: [PATCH 10/15] Extract registries into `model/` module with SemanticModel interface (#786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat(SM-20): extract registries into model/ module with SemanticModel interface - Create model/type-registry.ts — TypeRegistry interface + factory - Create model/method-registry.ts — MethodRegistry interface + factory - Create model/field-registry.ts — FieldRegistry interface + factory - Create model/semantic-model.ts — SemanticModel interface + factory - Create model/heritage-map.ts — re-export HeritageMap types - Create model/binding-accumulator.ts — re-export BindingAccumulator types - Create model/resolve.ts — move lookupMethodByOwnerWithMRO from call-processor - Update symbol-table.ts — delegate to SemanticModel for registry ops - Update call-processor.ts — re-export lookupMethodByOwnerWithMRO from model/resolve No circular dependencies: model/resolve.ts does NOT import resolution-context.ts. All 775 related unit tests pass with no regressions. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/27ad2975-1a31-4f50-815b-178ee8a95277 * fix: clarify re-export comment per code review feedback Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/27ad2975-1a31-4f50-815b-178ee8a95277 * refactor(SM-20): wire up SemanticModel as first-class resolution input PR #786 extracted TypeRegistry/MethodRegistry/FieldRegistry into model/ behind SemanticModel, but consumers still routed through SymbolTable delegates. This change completes Phase 6 of the fuzzy-lookup elimination roadmap by making call-processor, resolution-context, type-env, and heritage-map query the model directly via `table.model.{types,methods,fields}`. Also absorbs the open PR #786 review findings so the branch lands clean: - Removed duplicate JSDoc block on lookupMethodByOwner (symbol-table.ts) - Added model/index.ts barrel for the public model/ surface - Fixed O(n) buildParentMapFromHeritage BFS via head-pointer queue - Clarified re-export facade framing on binding-accumulator.ts and heritage-map.ts inside model/ - Refined @internal JSDoc on lookupMethodByOwnerWithMRO Changes: - symbol-table.ts: expose `readonly model: SemanticModel` on the SymbolTable interface. SymbolTable delegate wrappers (lookupClassByName etc.) stay as thin pass-throughs for backward compat; deletion is a follow-up once all internal callers are migrated. - model/resolve.ts: lookupMethodByOwnerWithMRO now takes SemanticModel instead of SymbolTable, removing the last SymbolTable import from the model/ module. Preserves circular-dependency firewall. - call-processor.ts: 6 call sites in D0 member resolution, field resolution, ctor override, and ctor disambiguation migrated to model.types/methods/fields. - resolution-context.ts: tier 3 class+impl lookup migrated. - type-env.ts: 5 sites across lookupClassDefsByName, resolveFieldType, and resolveMethodReturnType migrated. - heritage-map.ts: parent/child class-name resolution migrated. Tests: - symbol-table.test.ts: +10 parity and feeding-audit tests covering every model.{types,methods,fields} path (Class, Method, Property, Impl, Function-with-ownerId, Property-without-ownerId skip, arity filtering, clear cascade). - call-processor.test.ts: classLookupSpy now targets ctx.symbols.model.types since the wrapper is bypassed. - type-env.test.ts: createMockSymbolTable and the destructured-call makeSymbolTable helpers gained a model shim that forwards to the (possibly overridden) top-level lookup stubs. Validation: full suite 5603 passed / 159 skipped, resolver integration suite (19 files, 1766 tests) clean, tsc --noEmit clean. * refactor(SM-21): invert ownership — SemanticModel contains SymbolTable Follow-up to SM-20. Previously SymbolTable owned a `model` subfield; this commit turns the ownership direction around so the SemanticModel is the top-level container and SymbolTable is nested as `.symbols`: SemanticModel (top-level, passed everywhere) ├── types (TypeRegistry) ├── methods (MethodRegistry) ├── fields (FieldRegistry) └── symbols (SymbolTable — file-indexed + callable-name index) The owner-scoped registries live directly on the model; file and callable-name lookups go through `.symbols`. Consumers receive a `SemanticModel` and reach into the appropriate field — no more `table.model.types.X` double-hop. Core changes: - symbol-table.ts: createSymbolTable now takes injected TypeRegistry/MethodRegistry/FieldRegistry via a SymbolTableDeps argument. When omitted (test fallback), it creates standalone registries locally and clears them in clear() — production callers always inject. The five registry convenience delegates (lookupClassByName, lookupMethodByOwner, lookupFieldByOwner, lookupClassByQualifiedName, lookupImplByName) remain as thin forwards to the injected registries so standalone SymbolTable use (chiefly tests) stays ergonomic. - model/semantic-model.ts: createSemanticModel() now creates the three registries AND a SymbolTable wired to them, exposing the SymbolTable as `.symbols`. clear() cascades through all four. - resolution-context.ts: `readonly symbols: SymbolTable` field is replaced with `readonly model: SemanticModel`. Internal factory builds a SemanticModel and keeps a local `symbols` alias for backward-compatible inner body. Consumer migrations (src/): - call-processor.ts: ctx.symbols.add/.lookupExactAll/ .lookupCallableByName → ctx.model.symbols.*; ctx.symbols.model.X → ctx.model.X. buildTypeEnv option key renamed symbolTable → model. - type-env.ts: symbolTable parameter renamed model (type SemanticModel), all internal call sites rewritten to use model.types.*, model.methods.*, model.fields.*, model.symbols.lookupExactAll / .lookupCallableByName. - heritage-map.ts: 2 class-lookup sites migrated. - pipeline.ts: ctx.symbols → ctx.model.symbols throughout. Test migrations: - symbol-table.test.ts: parity tests (which validated the old table.model.X hop) replaced with direct SemanticModel coverage via createSemanticModel(). New tests exercise types/methods/fields/ symbols feeding end-to-end. - type-env.test.ts: createMockSymbolTable rebuilt as a SemanticModel-shaped mock that still accepts the legacy flat override bag for backward compat; inline `makeSymbolTable` helpers for destructured-call and importedReturnTypes suites rewritten to match the new shape; buildTypeEnv options `symbolTable: X` and `{ symbolTable }` shorthand renamed to `model:`; one real createSymbolTable-based test rewritten to use createSemanticModel. - call-processor.test.ts, heritage-map.test.ts, heritage-processor.test.ts, symbol-resolver.test.ts: bulk sed `ctx.symbols.` → `ctx.model.symbols.`. call-processor.test.ts spy updated to target `ctx.model.types.lookupClassByName`. Validation: full test suite 5589 passed / 169 skipped / 0 failed; tsc --noEmit clean; pre-commit eslint + prettier + typecheck all green. CLAUDE.md / AGENTS.md stats bumped from an earlier `npx gitnexus analyze` refresh (3965 symbols / 10012 edges / 243 flows). * refactor(SM-22/SM-23): dispatch table + DAG rearchitecture SM-22: Extract registration dispatch table into model/registration-table.ts. Replaces the if/else ladder inside SymbolTable.add() with an O(1) Map fan-out. SemanticModel wires the table per-instance so hooks close over the correct registries. SM-23: DAG rearchitecture. symbol-table.ts is now a pure 2-index leaf (fileIndex + callableByName) with zero imports from model/. All type/method/field routing lives in the model/ layer. Tests migrated to createSemanticModel() + model.symbols access pattern. Tests: 5632 passed, 0 failures. * refactor: delete dead code (skipCallableIndex + model/ facades) Removes the unused skipCallableIndex flag from the registration dispatch table and deletes two facade files that had zero consumers. skipCallableIndex was declared on RoutingDecision and populated for all 10 entries but never read at runtime — semantic-model.ts explicitly documented that the flag was NOT consulted. The callable-index gate lives inside SymbolTable.add() via CALLABLE_TYPES.has(type), which is the single source of truth. Deleting the flag keeps SymbolTable as the sole decision point and removes documentation-as-data. model/binding-accumulator.ts and model/heritage-map.ts were facade pass-throughs of their parent-directory counterparts. Grep confirms no consumer imports either from the model/ path — all usage goes through ../binding-accumulator.js and ../heritage-map.js directly. model/index.ts was the only "user" and re-exported them with a note about unifying the import boundary, but that boundary has no actual consumers today. Resolves review findings M-01 and M-03 from .context/compound-engineering/ce-review/20260411-144641-59605d93/maintainability.json Tests: 5631 passed, 0 failures (1 less than pre-Unit-1: the skipCallableIndex-specific assertion was removed). * refactor: remove lookupMethodByOwnerWithMRO backward-compat shim call-processor.ts re-exported lookupMethodByOwnerWithMRO from ./model/resolve.js as a backward-compat shim for symbol-table.test.ts. The function already lives in model/resolve.ts and is re-exported properly from model/index.ts (the barrel) — the call-processor shim was a duplicate export path with no durable reason to exist. Migrated the test import from call-processor.js to model/index.js (the canonical barrel). Deleted the re-export statement and the stale "re-exported for backward compatibility" comment block. Hoisted the remaining import to the top of the file with the other imports; the bottom-of-file position was a relic of the shim pattern. Resolves review finding M-02 from .context/compound-engineering/ce-review/20260411-144641-59605d93/maintainability.json Tests: 5631 passed, 0 failures. * refactor: harden registration dispatch runtime safety Two hardening changes in semantic-model.ts, both closing silent-failure paths in the SM-series dispatcher-bypass failure mode. 1. model.symbols.clear() now cascades to the owner-scoped registries. Previously, the SymbolTable facade exposed rawSymbols.clear directly, which only emptied fileIndex + callableByName — the types/methods/ fields registries stayed populated. Any caller holding a SymbolTable reference that invoked .clear() left the model in a split state where subsequent .add() calls double-registered in the registries. No current caller exercises this path, but it was a latent phantom- resolution risk that didn't belong in a public API. Extracted the cascade into a single cascadeClear closure wired into both model.clear() and the facade's clear field. 2. runExhaustivenessGuard now throws instead of console.warn on drift. The production short-circuit via NODE_ENV === 'production' is preserved, so real users never see the throw — but CI and dev runs now fail loudly if a NodeLabel is added to gitnexus-shared without being placed in one of the three registration-table allowlists. The previous warn-only behavior was silent in test output volume; SM-19 already documented dispatcher-bypass as the dominant silent-failure mode in this codebase. Test-first: added test/unit/model/semantic-model.test.ts covering model.symbols.clear() cascade (4 registries × clear = 4 tests), the existing model.clear() cascade (regression guard), and a happy-path construction test that verifies the current allowlists have zero drift. Resolves correctness P2 finding (symbols.clear() partial clear), correctness P3 (exhaustiveness warn-only), and kieran-typescript KT-03 (same exhaustiveness finding, agreement boost). Tests: 5638 passed (+7 new), 0 failures. * docs: fix stale JSDoc references in resolveStaticCall call-processor.ts:2215-2216 referenced SymbolTable.lookupClassByName and SymbolTable.lookupMethodByOwner via {@link}. Both methods were removed from SymbolTable during SM-20 — they now live on TypeRegistry and MethodRegistry respectively, accessible via model.types and model.methods. Other SymbolTable.* references in the codebase (lookupExactFull, add, lookupCallableByName in call-processor.ts:593, symbol-table.ts:86, type-extractors/types.ts:57) target methods that are still on SymbolTable and remain valid. Resolves correctness P3 and kieran-typescript KT-02 (same finding, agreement boost). * refactor: deduplicate ALL_NODE_LABELS constant ALL_NODE_LABELS was private in semantic-model.ts and duplicated verbatim in registration-table.test.ts. Two hardcoded lists meant a new NodeLabel added to gitnexus-shared could land in one copy but not the other, silently drifting the exhaustiveness invariant. Exported ALL_NODE_LABELS from semantic-model.ts, re-exported through model/index.ts for barrel consistency, and switched the test to import it instead of redeclaring. The explanatory comment now describes the single-source-of-truth contract. Resolves maintainability M-04. Tests: 5638 passed, 0 failures. * refactor: add compile-time NodeLabel exhaustiveness check The runtime exhaustiveness guard in semantic-model.ts caught drift at test time. Added a type-level check in registration-table.ts that catches drift at BUILD time — if a new NodeLabel is added to gitnexus-shared without being classified into one of the three allowlists, TypeScript fails the _exhaustiveCheck assignment and names the missing label. The runtime guard stays as belt-and-suspenders: if a future contributor bypasses the type check with @ts-ignore, the runtime guard still fires in dev/test. Implementation: converted the three allowlist Set initializers to use `as const` tuples, then derived a union type from the tuples and asserted `Exclude extends never`. Zero runtime impact — the exported Sets are unchanged, Map.get hot-path performance is unchanged, the test API is unchanged. Resolves kieran-typescript KT-04. Tests: 21/21 registration-table tests pass with zero modifications. * refactor(test): restore type safety to createMockSymbolTable createMockSymbolTable was widened to (overrides: any = {}): any with an eslint-disable-next-line, and every buildTypeEnv call site passed the mock as `model: mockSymbolTable as any`. The widening masked silent false-green tests: buildTypeEnv accesses model.types/methods/fields, and a flat any-typed override could silently return undefined from a path that TypeScript should have caught at compile time. Defined LegacyMockOverrides interface with typed stubs for each method the mock can override (SymbolTable reads + TypeRegistry/MethodRegistry/ FieldRegistry lookups). Return type is now SemanticModel, so the mock object is compile-checked against the real interface — a missing registry method is a type error, not a silent runtime undefined. Removed the eslint-disable and all 9 `as any` casts at call sites (lines 1287, 1300, 1307, 2124, 2138, 5823, 5835, 5850, 5870). The mock's return value now flows through buildTypeEnv's typed `model` option without coercion. Resolves kieran-typescript KT-01 and testing gap TG-02. This was the highest-value cleanup in the plan — the only finding representing real hidden test weakness. Tests: 360 passed | 7 skipped (type-env.test.ts), typecheck clean. * test: close coverage gaps in model/ registries Added direct unit tests for the three owner-scoped registries that previously had only transitive coverage via symbol-table.test.ts and registration-table.test.ts. These new tests pin behaviors that were flagged by the testing reviewer as untested or undertested. method-registry.test.ts (14 tests): - T-01: arity-fallback branch — when argCount matches no overload, fall back to the full pool so fuzzy resolution still has candidates. Previously untested and would have returned undefined instead of a valid candidate if the branch regressed. - T-02: requiredParameterCount range filtering — methods with default parameters accept any argCount in [requiredParameterCount, parameterCount]. Previously untested at the registry level. - Variadic fallback (parameterCount=undefined is retained during arity narrowing, bypassing range check). - Return-type dedup paths: shared returnType → first wins, differing returnTypes → undefined, firstReturnType=undefined → undefined, single-overload skips dedup entirely. type-registry.test.ts (9 tests): - classByName homonym accumulation (two User classes in different packages both returned). - classByQualifiedName disambiguation — same simple name, different FQNs resolve independently. - Partial classes with identical simple + qualified name accumulate in both indexes. - registerImpl stores Rust impl blocks separately from classes. - Multiple impl blocks per type accumulate. field-registry.test.ts (6 tests): - register/lookup round-trip, owner-scope isolation, last-wins on duplicate key (flat map, not overload list). - clear + re-register round-trip. Extended symbol-table.test.ts cascade test (renamed from "both registries" to "all three registries and the nested symbol table") to also assert model.methods and model.fields are cleared — the test name previously implied full coverage but only asserted types + symbols. Resolves testing findings T-01, T-02, T-03, T-05. Tests: 5667 passed (+29 new), 0 failures. * refactor(test): replace brittle reference-equality tests + add intent comments Two cleanups flagged as low-severity P3 by the testing reviewer: 1. registration-table.test.ts: Replaced three reference-equality tests (hook identity via toBe) with behavioral tests that survive a future refactor to per-label closures. The new "class-like behavior group" describe iterates Class/Struct/Interface/Enum/Record/Trait and verifies each one writes to types.registerClass. Same pattern for Method/Constructor. A separate "behavior group isolation" describe verifies class-like hooks don't leak into methods/fields and Impl never pollutes registerClass. Strictly more coverage than the reference-equality tests provided and implementation-independent. 2. symbol-resolver.test.ts: Added a comment above the lookupExactFull and SM-16: getFiles() describes explaining why they intentionally use createSymbolTable() directly instead of createSemanticModel(). The DAG leaf-only behaviors they test do not involve registries, so testing the bare SymbolTable keeps the unit isolated. Prevents a future reader from "fixing" the inconsistency. 3. qualified-class-lookups.test.ts: Added a comment above `const symbolTable = model.symbols` explaining that processParsing writes still reach the owner-scoped registries via SemanticModel's fan-out — the alias is convenience, not a leaf in isolation. Resolves testing T-04, kieran-typescript KT-05, kieran-typescript KT-06. Tests: affected files all green (112 passed in registration-table + symbol-resolver + qualified-class-lookups). * refactor(model): collapse RoutingDecision wrapper and trim barrel surface Two cleanups against the advanced-review findings on post-Unit-9 state: S2 (cross-reviewer agreement — architecture-strategist + code-simplicity): Delete the RoutingDecision single-field wrapper interface. Post-Unit-1 it held exactly one field (hook: RegistrationHook) and added pure ceremony at every call site — `dispatchTable.get(key)!.hook(name, def)` vs the now-direct `dispatchTable.get(key)!(name, def)`. Change the Map type from Map to Map, drop the interface, and update 17 test call sites. A3 (architecture-strategist): Trim model/index.ts barrel surface. createRegistrationTable, RegistrationHook, and RegistrationTableDeps were re-exported from the barrel despite having zero legitimate consumers outside model/ itself. The only callers (semantic-model.ts and registration-table.test.ts) import directly from ./registration-table.js. Barrel exposure invited external callers to construct orphan dispatch tables with independent registries, weakening the SM-21 ownership inversion where SemanticModel is the composition root. Kept CALLABLE_ONLY_LABELS, INERT_LABELS, DISPATCH_LABELS exported since those remain useful for downstream resolution logic and have no construction risk. Resolves review findings: - S2 (code-simplicity P3, 0.85) + architecture-strategist residual - A3 (architecture-strategist P3, 0.82) Tests: 5674 passed, 0 failures. Typecheck clean. * refactor(model): replace runtime exhaustiveness guard with compile-time bijection Replace the three-layer drift protection (hardcoded ALL_NODE_LABELS array + 3 tuple consts + _ExhaustiveLabelCheck type + runExhaustivenessGuard runtime + CI taxonomy test) with a single Record map that structurally proves every invariant at compile time. ## Before - ALL_NODE_LABELS hardcoded in semantic-model.ts (36 entries, could drift) - DISPATCH_LABELS_TUPLE / CALLABLE_ONLY_LABELS_TUPLE / INERT_LABELS_TUPLE private tuples (36 more entries total, could overlap or miss) - _ClassifiedLabel / _UncoveredLabel type-level check (caught missing labels but NOT duplicates across tuples) - runExhaustivenessGuard runtime throw (only defense against duplicates) - NodeLabel taxonomy coverage test in CI (same check as runtime guard) Four defenses for invariants that the type system can express directly. ## After ```ts type LabelBehavior = 'dispatch' | 'callable-only' | 'inert'; const LABEL_BEHAVIOR = { Class: 'dispatch', // ...36 entries... Tool: 'inert', } as const satisfies Record; ``` The `as const satisfies Record` combo enforces: 1. **Every NodeLabel must be a key** — Record requires all K keys. Adding a NodeLabel to gitnexus-shared without classifying it here fails with "Property 'X' is missing in type ..." naming the drifted label. 2. **No non-NodeLabel keys allowed** — `satisfies` with object literals triggers excess-property checking. A typo'd key fails to compile. 3. **No duplicate classification** — impossible by construction; object keys are unique at the source level. 4. **Valid category** — LabelBehavior is a narrow union, typos caught. `ALL_NODE_LABELS`, `DISPATCH_LABELS`, `CALLABLE_ONLY_LABELS`, and `INERT_LABELS` are now derived via `Object.keys(LABEL_BEHAVIOR)` and `filter(l => LABEL_BEHAVIOR[l] === ...)` — single source of truth, structurally impossible to drift. ## Deleted - runExhaustivenessGuard() function in semantic-model.ts (~18 lines) - ALL_NODE_LABELS hardcoded array in semantic-model.ts (~38 lines) - DISPATCH_LABELS_TUPLE / CALLABLE_ONLY_LABELS_TUPLE / INERT_LABELS_TUPLE private consts in registration-table.ts (~30 lines) - _ClassifiedLabel / _UncoveredLabel / _exhaustiveCheck type machinery (~20 lines) ## Kept named proofs: none The `as const satisfies` on the object literal already catches all four drift modes. Named type-level proofs (_MissingFromMap / _ExtraKeysInMap) are pure duplication and were removed per review. ## Also in this commit - S6: trim wrappedAdd narration comments in semantic-model.ts (Step 1/2/3 block comments removed; kept the Function+ownerId WHY note) - A3: tighten model/index.ts barrel — createRegistrationTable, RegistrationHook, RegistrationTableDeps remain direct-imports only; ALL_NODE_LABELS and LabelBehavior re-exported from the new home in registration-table.ts ## Resolves - Advanced-review S4 (runtime guard per-call cost) — guard no longer exists - Advanced-review S1 (tuple three-defenses indirection) — single Record replaces all tuples - Correctness P3 (exhaustiveness warns-only) — structurally impossible to drift - Unit 6 type-level check — subsumed by the Record type - Unit 3 runtime throw — no longer needed Tests: 5674 passed, 0 failures. Typecheck clean. * test(model): delete duplicate closure-isolation spy tests S5 (code-simplicity P3): The 'closure isolation — each hook can only write to its registry' describe block duplicated the 'behavior group isolation' block's coverage via a different mechanism. Behavioral tests (lines 151-174, kept): table.get('Class')!('User', def); expect(deps.methods.lookupMethodByOwner('unrelated', 'User')).toBeUndefined(); expect(deps.fields.lookupFieldByOwner('unrelated', 'User')).toBeUndefined(); Spy tests (deleted, ~55 lines): vi.spyOn(deps.methods, 'register') table.get('Class')!('User', def); expect(methodsSpy).not.toHaveBeenCalled(); Both assert the same invariant — classHook does not touch the methods or fields registries. The behavioral form observes the END STATE of the registry (lookup returns undefined), which is the actual contract. The spy form asserts the IMPLEMENTATION (a specific method was not called), which couples to internal wiring — a refactor to a different register function name would break the spy test while the behavioral test would still pass. Also dropped the now-unused `vi` import from vitest. Tests: 24/24 registration-table.test.ts pass (-4 from spy deletion). * refactor(model): compile-time cross-invariant between CLASS_TYPES and dispatch classHook A1 (architecture-strategist P2, 0.90): CLASS_TYPES in symbol-table.ts and the class-like entries of the dispatch table were two independent hardcoded sets. Adding a new class-like label (e.g. Swift 'Extension') to one but not the other would silently degrade qualifiedName population — the symptom is subtle (partial qualified-name lookups) and no test asserted the co-extensive invariant. Fixed with a single source of truth and a two-layer compile-time enforcement: ## symbol-table.ts - Add `CLASS_TYPES_TUPLE` as `readonly [...] as const satisfies readonly NodeLabel[]`. The `satisfies` forces every tuple entry to be a valid NodeLabel at compile time. - Export derived type `ClassLikeLabel = typeof CLASS_TYPES_TUPLE[number]`. - Derive `CLASS_TYPES` Set from the tuple — same runtime shape as before, now typed `ReadonlySet`. ## registration-table.ts - Import `CLASS_TYPES_TUPLE` and `ClassLikeLabel` from symbol-table.ts. - Narrow the `satisfies` on `LABEL_BEHAVIOR` via intersection: Record & Record This forces every class-like label to have value 'dispatch' at compile time. Adding a label to CLASS_TYPES_TUPLE without classifying it as dispatch in LABEL_BEHAVIOR fails to compile with a type error naming the drifted label. - Build the class-like entries of the dispatch Map by iterating `CLASS_TYPES_TUPLE` at factory time. Adding a label to the tuple automatically wires it to classHook — no second place to update. ## What the design prevents 1. Drift scenario A (A1 original): 'Extension' added to CLASS_TYPES_TUPLE but not to LABEL_BEHAVIOR → compile error on LABEL_BEHAVIOR's satisfies. 2. Drift scenario B: 'Extension' added to CLASS_TYPES_TUPLE but not wired to classHook → impossible because the Map is derived from the tuple. 3. Drift scenario C: class-like label classified as something other than 'dispatch' in LABEL_BEHAVIOR → compile error on the narrowed intersection. Runtime behavior unchanged: same 6 labels in CLASS_TYPES, same 6 class-like entries in the dispatch Map. Tests pin the behavior via the existing behavior-group tests in registration-table.test.ts. DAG unchanged: registration-table.ts already imported from symbol-table.ts (the allowed upward direction). symbol-table.ts still imports nothing from model/. Tests: 5670 passed, 0 failures. Typecheck clean. * test(field-extraction): use SemanticModel facade instead of raw SymbolTable A6 (architecture-strategist P3, 0.85): field-extraction.test.ts created its FieldExtractorContext fixture with `symbolTable: createSymbolTable()` — a raw SymbolTable leaf, not the facade. In production, the context's symbolTable field is always `model.symbols` (the SemanticModel-wrapped facade where .add() dispatches through the owner-scoped registries). The current field extractors don't call symbolTable.add() at all, so this change is behavior-neutral today. The value is architectural consistency — matching the test fixture to the production shape prevents silent drift if a future field extractor starts registering dynamically-discovered properties via the context. Without the fix, such writes would hit the raw leaf and skip the fan-out, and tests would pass even though the symptom (empty FieldRegistry) would manifest in production. Tests: 50/50 field-extraction.test.ts pass. Production tsc --noEmit clean. Test-tsconfig error count unchanged (634 pre-existing errors in unrelated test files, out of scope). * refactor(A5): decouple model/resolve.ts from language registry Move the MroStrategy type into gitnexus-shared and replace the language: SupportedLanguages parameter on lookupMethodByOwnerWithMRO with a direct mroStrategy: MroStrategy literal. Callers derive the strategy from their language provider before invoking the resolver. model/resolve.ts no longer imports from ../languages/index.js, so the model/ layer is free of cross-layer coupling with the language registry — this closes finding A5 from the SM-20/21/22/23 advanced review (plan 006). * feat(A4): add MethodRegistry.lookupMethodByName flat-by-name index Add a secondary `methodsByName: Map` index on MethodRegistry that returns every method with a given unqualified name, accumulated across owners and overloads. The new index shares SymbolDefinition references with methodByOwner — no duplication. This is step 1 of the A4 double-index removal (plan 006). Tier 3 global resolution will switch to this index in Unit 3 so Method and Constructor can be removed from CALLABLE_TYPES in Unit 4. * refactor(A4): extend Tier 3 + memberCallByFile to consult method registry Add model.methods.lookupMethodByName to Tier 3 global resolution in resolution-context.ts and to the callable-pool build in call-processor.ts (resolveMemberCallByFile + D2 widen path). Intentionally behavior-preserving: Method and Constructor are still in CALLABLE_TYPES so the new lookup returns identical candidates that already reach Tier 3 through callableByName. Both paths dedup by nodeId during this intermediate state — Unit 4 shrinks CALLABLE_TYPES and the dedup is removed. Part of plan 006 A4 step 2. * refactor(A4): shrink CALLABLE_TYPES to free callables only CALLABLE_TYPES = {Function, Macro, Delegate}. Method and Constructor are no longer double-indexed in callableByName — they reach resolvers through model.methods.lookupMethodByName instead. Companion changes: - Introduce CALL_TARGET_TYPES = CALLABLE_TYPES ∪ {Method, Constructor} for the resolver's kind filter (filterCallableCandidates, countCallableCandidates). Separates registration semantics (narrow) from the resolver's acceptable-target set (wide). - type-env.ts for-loop return-type inference consults both indexes, treating the union as the authoritative call pool. - resolveMemberCallByFile + D2 widen path keep the nodeId dedup in place: Python/Rust/Kotlin class methods emitted as Function+ownerId still land in both indexes until Unit 5 unblocks the normalization. - Tier 3 global resolution (resolution-context.ts) keeps the same dedup for the same reason. Test updates reflect the new contract: Method/Constructor live in methodsByName, not callableByName. Orphan Method-without-ownerId now lives only in the file index (no registry coverage). Part of plan 006 — closes A4 for strictly-labeled methods. Python/ Rust/Kotlin Function+ownerId normalization is tracked as Unit 5 (blocked). * refactor: rename CALLABLE_TYPES → FREE_CALLABLE_TYPES Pure rename. The constant's meaning changed in Unit 4 (free callables only — no methods, no constructors) so the name now reflects that scope: "callables that have no owner scope". Updates the constant declaration and every consumer in src/ and test/. Closes plan 006 Unit 6. * refactor(A2): strict SymbolTableReader (pure reads) + SymbolTableWriter (+add) Split the SymbolTable interface into three strictly layered surfaces: - SymbolTableReader: lookups + iteration. NO add, NO clear. Holders cannot mutate the table in any way. - SymbolTableWriter extends Reader: + add. NO clear. Holders can register new symbols but cannot trigger a leaf-index reset. - InternalSymbolTable (private, not exported): + clear. The cascading reset capability is reachable only through createSymbolTable's return type, held exclusively by SemanticModel.rawSymbols. SemanticModel.symbols is now typed as SymbolTableWriter — external consumers (workers, processors, pipelines) can register symbols and query them, but cannot reach .clear(). The A2 LSP fix holds: callers holding any public reference cannot desync the leaf indexes from the owner-scoped registries. Delete the transitional `type SymbolTable = SymbolTableReader` alias and migrate every consumer (src + test) to the explicit names: - Field and parameter annotations use SymbolTableReader by default; only code that calls .add() uses SymbolTableWriter. - parsing-processor (workers + sequential paths) takes SymbolTableWriter so it can register extracted symbols. - field-types, call-processor, named-binding-processor, workers/parse-worker: use SymbolTableReader (query-only). - Tests: drop the stale `clear` fields from mock factories and migrate the semantic-model cascade tests from the removed model.symbols.clear() path to model.clear(). Closes plan 006 Unit 7. Industry sources: TypeScript compiler API builder pattern, Salsa ParallelDatabase, .NET IReadOnlyList. See the a2-lsp-clear-contract-research artifact for full citations. * feat(A2): add SemanticModel.resetFileIndex() partial-reset entry point Add a named method that clears only the leaf file and callable indexes without cascading to the three owner-scoped registries (types, methods, fields). Replaces the rare partial-reset use case that was previously reachable via the now-removed symbols.clear() path from A2 (plan 006 Unit 7). JSDoc makes the semantic difference with model.clear() explicit so future readers don't have to guess which method to call for a given reingestion scenario. Test-first: three scenarios cover the partial-vs-full semantics, re-add after reset, and idempotency. Closes plan 006 Unit 8. * docs(S7): trim registration-table module JSDoc Remove the ~24 lines of design-provenance citations from the module JSDoc. The rust-analyzer, TypeScript-compiler, and Fowler references are preserved in git history via the original SM-22 commits and in plan 006 Unit 9. Keep the ownership diagram, behavior-group table, and the 'How to add a new NodeLabel' checklist — those are load-bearing for future contributors. Closes plan 006 Unit 9 (S7 advanced-review finding). * test(S3): migrate type-env.test.ts off LegacyMockOverrides Replace the createMockSymbolTable bridge and LegacyMockOverrides interface with real createSemanticModel() + add() calls across all 14 call sites. Where a test needs a specific registry lookup that can't be pre-populated cleanly, use vi.spyOn on the real registry instead. Pattern breakdown: - Pattern A (pre-populate via model.symbols.add): 13 sites - Pattern B (vi.spyOn on registry lookup): 1 site Deletes LegacyMockOverrides + createMockSymbolTable entirely. The real MethodRegistry arity/returnType semantics match the hand-rolled mock behavior in every migrated case, and no 'as any' casts remain in the file. Closes plan 006 Unit 10 (S3 advanced-review finding). * refactor: remove unused MroStrategy type exports from language-provider and resolve modules * refactor: relocate symbol-table, heritage-map, resolution-context into model/ Use git mv so blame and history follow each file: - gitnexus/src/core/ingestion/symbol-table.ts → model/symbol-table.ts - gitnexus/src/core/ingestion/heritage-map.ts → model/heritage-map.ts - gitnexus/src/core/ingestion/resolution-context.ts → model/resolution-context.ts These three files are part of the SemanticModel layer (file/callable indexes, heritage parent map, tiered resolver) and now sit alongside the registries they collaborate with. Updates every consumer import path across src/ and test/ to the new locations. * refactor(model): enforce pure-leaf DAG + delete legacy re-exports model/ is now a pure leaf: zero upward imports and zero compat shims in its parent processors. Completes the DAG cleanup started in the previous commit. 1. walkBindingChain — moved into model/resolution-context.ts; named-binding-processor.ts deleted. 2. NamedImportMap + NamedImportBinding + isFileInPackageDir — moved into model/resolution-context.ts. Every consumer now imports from the canonical location directly. Legacy re-exports in import-processor.ts deleted. 3. c3Linearize + gatherAncestors — moved into model/resolve.ts. mro-processor.ts imports them back for computeMRO. Legacy c3Linearize re-export from mro-processor.ts deleted. 4. ExtractedHeritage type — moved into model/heritage-map.ts. call-processor.ts, parsing-processor.ts, pipeline.ts, heritage-processor.ts, and the test files now import it from the canonical location. Legacy re-exports in parse-worker.ts and heritage-processor.ts deleted. 5. resolveExtendsType — rewritten in model/heritage-map.ts to take an explicit HeritageResolutionStrategy (A5-style DI). buildHeritageMap accepts an optional getHeritageStrategy callback; production uses getHeritageStrategyForLanguage from heritage-processor.ts. Legacy resolveExtendsType re-export from heritage-processor.ts deleted. Verified: - grep 'from "..' gitnexus/src/core/ingestion/model → empty - grep 'Re-export for legacy' gitnexus/src/core/ingestion → empty - npx tsc --noEmit → clean - npx vitest run → 5686 passing * docs(model): strip phase/plan references from module comments Remove SM-20/21/22/23, A2/A4/A5, plan 006, Unit N labels and historical phrasing ("previously", "legacy", "model-leaf DAG cleanup") from all 10 files in src/core/ingestion/model/. Preserve domain vocabulary (Tier 1/2/3), invariants, and caveats — only the plan archaeology is gone. * refactor(model): tighten interface segregation + compile-time invariants Apply four gated findings from branch-wide code review: - SemanticModel.symbols now typed as SymbolTableReader; MutableSemanticModel widens it back to SymbolTableWriter. ResolutionContext.model is typed as MutableSemanticModel since it owns the lifecycle. Resolvers that only query symbols can annotate their own fields as SemanticModel to drop write access at the type level. - Lookup methods (lookupExactAll, lookupCallableByName, lookupClassByName, lookupClassByQualifiedName, lookupImplByName) now return readonly SymbolDefinition[]. The returned arrays are live views into the internal indexes; the readonly marker prevents accidental caller mutation. walkBindingChain return type narrowed to match. - FREE_CALLABLE_TUPLE + FreeCallableLabel exported from symbol-table.ts as the single source of truth for free-callable labels. LABEL_BEHAVIOR now satisfies Record as a second cross-invariant alongside Record. Adding a label to the tuple without classifying it as 'callable-only' fails at build time. CALLABLE_ONLY_LABELS is now a re-export alias of FREE_CALLABLE_TYPES so the two sets cannot drift. - walkBindingChain fast-exits before allocating its cycle-detection Set when the caller's file has no named bindings. Skips ~200k transient Set allocations per large-repo resolution pass. Also fixes five stale comments flagged by the review: duplicate JSDoc block on RegistrationHook merged; resolve.ts "delegates to mro-processor" direction corrected; RegistrationTableDeps JSDoc names createRegistrationTable (not createSymbolTable); mro-processor.ts "re-exported at top" stale comment removed; gatherAncestors export comment matches reality. tsc --noEmit clean, full test suite green (5786 tests). * refactor(model): resolve four deferred P2 review findings Address the four gated items from the branch-wide review that needed design decisions before applying: F#3 — Method/Constructor without ownerId fallback to callable index. The dispatch hook silently skips owner-scoped labels that lack an owner (an extractor contract violation — AST-degraded parse, or a buggy language extractor). Pre-dispatch-table code let such defs fall through to callableByName and stay reachable at Tier 3 global resolution. This restores that fallback in SymbolTable.add so orphaned Methods and Constructors don't silently vanish. Property deliberately does NOT participate in the fallback to avoid polluting common names like id / name / type. F#4 — Delete MutableSemanticModel.resetFileIndex. The method had zero production callers (only three tests), documented a "rare partial- reingestion flow" that was never implemented, and contained the adversarial-reviewer's double-populate trap: calling resetFileIndex followed by re-adding the same class symbol would push a duplicate SymbolDefinition into TypeRegistry.classByName without ever clearing the first one. If incremental reingestion is ever needed, it can be designed properly with per-file TypeRegistry invalidation. For now, deleting the footgun is safer than documenting it. F#5 — Compile-time dispatch-table completeness check. `LABEL_BEHAVIOR` already enforces "every NodeLabel is classified" via `Record`, but the dispatch-table factory populated its Map with manual `table.set(...)` calls that TypeScript could not correlate back to the `'dispatch'` classification. Add a type-level `DispatchLabel` extracted from `LABEL_BEHAVIOR` via a conditional mapped type, and build the table from an object literal that satisfies `Record`. Adding a new dispatch-classified label without wiring it to a hook now fails the build with a named-key error — no more silent no-op hooks. F#7 — Tier 3 dedup fast-path via MethodRegistry.hasFunctionMethods. The Set-based dedup between callableDefs and methodDefs is only needed when a Python/Rust/Kotlin class method (emitted as Function+ownerId by the worker) lands in both indexes. For TS/Java/C#/C++/Ruby-only repos — where the two indexes are disjoint by construction — the dedup was pure overhead on every global-tier hit. MethodRegistry now tracks whether any Function-typed def was ever registered, and resolution- context branches Tier 3 into a concat-only fast path when that flag is false. Slow path with dedup survives unchanged for mixed-language repos. New tests pin the invariants: hasFunctionMethods flag transitions, Method/Constructor orphan fallback, Property non-fallback, and the MethodRegistry clear() reset. Full test suite green (5756 tests). * refactor(model): close remaining P3 review findings + coverage gaps Address the remaining review items in one batch. Production refactors: - Rename classHook → classLikeHook (M05). The hook handles Class / Struct / Interface / Enum / Record / Trait; the vocabulary used in surrounding docs and the behavior-group table is "class-like". The rename makes the code match the taxonomy without forcing readers through a mental glossary. - Extract MAX_BINDING_CHAIN_DEPTH constant in resolution-context.ts and document it as a known silent false-negative source (ADV-003). Five hops cover the common TypeScript monorepo pattern; raising the cap is a one-line change if a real repo exceeds it. walkBindingChain consumes the constant so the 5 magic number no longer floats free. - Replace defs.filter() allocation in MethodRegistry.lookupMethodByOwner with a two-pass streaming count + conditional materialization (PERF-04). Pure-match and pure-reject arity paths now skip the filtered-array allocation entirely; only the discriminating case (at least one match AND at least one rejection) pays it. - Rewrite NOOP_SYMBOL_TABLE in parse-worker.ts and NOOP_SYMBOL_TABLE_SEQ in parsing-processor.ts to implement all six SymbolTableReader methods (ADV-005). The `as unknown as SymbolTableReader` cast is removed in favor of a direct SymbolTableReader annotation, so future additions to the interface surface as compile errors on the stubs instead of silently falling through. - type-env.ts getCallableUnionCount and getFirstCallable now take `model: SemanticModel` as an explicit argument instead of reaching into the enclosing `model!` non-null assertion (KT-003). Callers enter via an `if (model)` guard and pass the narrowed reference, so the non-null precondition is visible at the type level and the closures cannot be accidentally extracted into a context without the guard. - Tier 3 dedup in resolution-context.ts now covers all four index reads (classDefs, implDefs, callableDefs, methodDefs) via a pushUnique helper (C-03). Previously classDefs and implDefs were spread directly without dedup; any theoretical nodeId collision would have produced duplicates in globalDefs. Test infrastructure: - Extract makeDef / makeMethod factory helpers into test/unit/model/helpers.ts (T-07). The four registry/table test files now import the shared helper and specialize with overrides, removing ~25 lines of duplicated boilerplate and creating a single point of maintenance. New test coverage: - T-01: c3 BFS fallback — cyclic Python hierarchy that fails c3 linearization and must fall back to heritageMap.getAncestors() BFS order. Added to the lookupMethodByOwnerWithMRO describe block. - T-02: Tier 2a-named precedence — verifies the binding chain walker fires before Tier 2a import-scoped when an aliased import `import { User as U } from B` competes with a raw same-name Tier 2a hit. Also pins Tier 1 same-file precedence over Tier 2a-named. - T-03: Tier 3 Function+ownerId dedup — end-to-end test that a Python class method emitted as `Function + ownerId` yields exactly ONE Tier 3 candidate (not two). Companion test pins the fast-path branch for hasFunctionMethods === false repos. - T-06: walkBindingChain guards — circular re-export detection, depth-cap exceeded drop, and boundary case at exactly MAX_BINDING_CHAIN_DEPTH hops resolving successfully. All tests added to a new test/unit/model/resolution-context.test.ts dedicated to ResolutionContext.resolve() tier-precedence invariants. Full suite: 5708 passing (minus the known Windows LBUG lock flake that passes in isolation). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Gergo Magyar --- AGENTS.md | 2 +- CLAUDE.md | 2 +- gitnexus-shared/src/index.ts | 1 + gitnexus-shared/src/mro-strategy.ts | 23 + gitnexus/src/core/ingestion/call-processor.ts | 243 ++-- gitnexus/src/core/ingestion/field-types.ts | 4 +- .../src/core/ingestion/heritage-processor.ts | 59 +- .../src/core/ingestion/import-processor.ts | 30 +- .../src/core/ingestion/language-provider.ts | 13 +- .../core/ingestion/model/field-registry.ts | 53 + .../ingestion/{ => model}/heritage-map.ts | 93 +- gitnexus/src/core/ingestion/model/index.ts | 88 ++ .../core/ingestion/model/method-registry.ts | 204 ++++ .../ingestion/model/registration-table.ts | 333 ++++++ .../{ => model}/resolution-context.ts | 205 +++- gitnexus/src/core/ingestion/model/resolve.ts | 284 +++++ .../core/ingestion/model/semantic-model.ts | 193 ++++ .../src/core/ingestion/model/symbol-table.ts | 381 ++++++ .../src/core/ingestion/model/type-registry.ts | 113 ++ gitnexus/src/core/ingestion/mro-processor.ts | 113 +- .../core/ingestion/named-binding-processor.ts | 47 - .../src/core/ingestion/parsing-processor.ts | 28 +- gitnexus/src/core/ingestion/pipeline.ts | 29 +- gitnexus/src/core/ingestion/symbol-table.ts | 439 ------- gitnexus/src/core/ingestion/type-env.ts | 97 +- .../core/ingestion/workers/parse-worker.ts | 28 +- .../integration/ignore-and-skip-e2e.test.ts | 2 +- .../qualified-class-lookups.test.ts | 30 +- gitnexus/test/unit/call-form.test.ts | 10 +- gitnexus/test/unit/call-processor.test.ts | 446 ++++--- gitnexus/test/unit/field-extraction.test.ts | 12 +- gitnexus/test/unit/heritage-map.test.ts | 124 +- gitnexus/test/unit/heritage-processor.test.ts | 51 +- gitnexus/test/unit/import-processor.test.ts | 2 +- .../test/unit/model/field-registry.test.ts | 74 ++ gitnexus/test/unit/model/helpers.ts | 27 + .../test/unit/model/method-registry.test.ts | 374 ++++++ .../unit/model/registration-table.test.ts | 267 +++++ .../unit/model/resolution-context.test.ts | 173 +++ .../test/unit/model/semantic-model.test.ts | 124 ++ .../test/unit/model/type-registry.test.ts | 146 +++ .../sequential-language-availability.test.ts | 2 +- gitnexus/test/unit/symbol-resolver.test.ts | 274 +++-- gitnexus/test/unit/symbol-table.test.ts | 1024 +++++++++++------ gitnexus/test/unit/type-env.test.ts | 550 +++------ 45 files changed, 4780 insertions(+), 2037 deletions(-) create mode 100644 gitnexus-shared/src/mro-strategy.ts create mode 100644 gitnexus/src/core/ingestion/model/field-registry.ts rename gitnexus/src/core/ingestion/{ => model}/heritage-map.ts (60%) create mode 100644 gitnexus/src/core/ingestion/model/index.ts create mode 100644 gitnexus/src/core/ingestion/model/method-registry.ts create mode 100644 gitnexus/src/core/ingestion/model/registration-table.ts rename gitnexus/src/core/ingestion/{ => model}/resolution-context.ts (55%) create mode 100644 gitnexus/src/core/ingestion/model/resolve.ts create mode 100644 gitnexus/src/core/ingestion/model/semantic-model.ts create mode 100644 gitnexus/src/core/ingestion/model/symbol-table.ts create mode 100644 gitnexus/src/core/ingestion/model/type-registry.ts delete mode 100644 gitnexus/src/core/ingestion/named-binding-processor.ts delete mode 100644 gitnexus/src/core/ingestion/symbol-table.ts create mode 100644 gitnexus/test/unit/model/field-registry.test.ts create mode 100644 gitnexus/test/unit/model/helpers.ts create mode 100644 gitnexus/test/unit/model/method-registry.test.ts create mode 100644 gitnexus/test/unit/model/registration-table.test.ts create mode 100644 gitnexus/test/unit/model/resolution-context.test.ts create mode 100644 gitnexus/test/unit/model/semantic-model.test.ts create mode 100644 gitnexus/test/unit/model/type-registry.test.ts diff --git a/AGENTS.md b/AGENTS.md index e6cefed11..c9e2158c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (3883 symbols, 9861 relationships, 225 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (3975 symbols, 10043 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index 7b0f175b1..fc0abde9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g GitNexus MCP rules are in the ` # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (3883 symbols, 9861 relationships, 225 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (3975 symbols, 10043 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index bd89dfc62..4024bf070 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -19,6 +19,7 @@ export type { NodeTableName, RelType } from './lbug/schema-constants.js'; // Language support export { SupportedLanguages } from './languages.js'; export { getLanguageFromFilename, getSyntaxLanguageFromFilename } from './language-detection.js'; +export type { MroStrategy } from './mro-strategy.js'; // Pipeline progress export type { PipelinePhase, PipelineProgress } from './pipeline.js'; diff --git a/gitnexus-shared/src/mro-strategy.ts b/gitnexus-shared/src/mro-strategy.ts new file mode 100644 index 000000000..6168c67c0 --- /dev/null +++ b/gitnexus-shared/src/mro-strategy.ts @@ -0,0 +1,23 @@ +/** + * MRO (Method Resolution Order) strategy — shared between CLI and any + * future consumer that reasons about multiple-inheritance semantics. + * + * Lives in `gitnexus-shared` so the low-level resolution module + * (`core/ingestion/model/resolve.ts`) does not need to import from + * `languages/` — keeping the `model/` layer free of language-registry + * coupling. + * + * Strategy semantics: + * - `first-wins`: BFS ancestor walk, first match wins (default). + * - `leftmost-base`: BFS ancestor walk, leftmost base wins (C++). + * - `c3`: C3-linearized ancestor order, first match wins (Python). + * - `implements-split`: BFS walk, first match wins (Java/C#/Kotlin) — full + * interface-default ambiguity is handled at graph level. + * - `qualified-syntax`: No auto-resolution (Rust — requires `::m`). + */ +export type MroStrategy = + | 'first-wins' + | 'c3' + | 'leftmost-base' + | 'implements-split' + | 'qualified-syntax'; diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 5edd72737..ce0364a4d 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1,11 +1,11 @@ import { KnowledgeGraph } from '../graph/types.js'; import { ASTCache } from './ast-cache.js'; -import type { SymbolDefinition, SymbolTable } from './symbol-table.js'; -import { CLASS_TYPES, CALLABLE_TYPES } from './symbol-table.js'; +import type { SymbolDefinition, SymbolTableReader } from './model/symbol-table.js'; +import { CLASS_TYPES, CALL_TARGET_TYPES } from './model/symbol-table.js'; import Parser from 'tree-sitter'; -import type { ResolutionContext } from './resolution-context.js'; -import { TIER_CONFIDENCE, type ResolutionTier } from './resolution-context.js'; -import type { TieredCandidates } from './resolution-context.js'; +import type { ResolutionContext } from './model/resolution-context.js'; +import { TIER_CONFIDENCE, type ResolutionTier } from './model/resolution-context.js'; +import type { TieredCandidates } from './model/resolution-context.js'; import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js'; import { getProvider } from './languages/index.js'; import { generateId } from '../../lib/utils.js'; @@ -32,24 +32,24 @@ import { } from './utils/call-analysis.js'; import { buildTypeEnv, isSubclassOf } from './type-env.js'; import type { ConstructorBinding, TypeEnvironment } from './type-env.js'; -import type { HeritageMap } from './heritage-map.js'; -import { c3Linearize } from './mro-processor.js'; +import type { HeritageMap } from './model/heritage-map.js'; import type { BindingAccumulator } from './binding-accumulator.js'; import { getTreeSitterBufferSize } from './constants.js'; import type { ExtractedCall, ExtractedAssignment, - ExtractedHeritage, ExtractedRoute, ExtractedFetchCall, FileConstructorBindings, } from './workers/parse-worker.js'; +import type { ExtractedHeritage } from './model/heritage-map.js'; import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js'; import { extractTemplateComponents } from './vue-sfc-extractor.js'; import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js'; import type { LiteralTypeInferrer } from './type-extractors/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; import { extractParsedCallSite } from './call-sites/extract-language-call-site.js'; +import { lookupMethodByOwnerWithMRO } from './model/resolve.js'; /** Per-file resolved type bindings for exported symbols. * Populated during call processing, consumed by Phase 14 re-resolution pass. */ @@ -204,7 +204,7 @@ function collectExportedBindings( * exported symbols that have callables with known return types. */ export function buildExportedTypeMapFromGraph( graph: KnowledgeGraph, - symbolTable: SymbolTable, + symbolTable: SymbolTableReader, ): ExportedTypeMap { const result: ExportedTypeMap = new Map(); graph.forEachNode((node) => { @@ -652,7 +652,7 @@ function findInterfaceDispatchTargets( const results: ResolveResult[] = []; for (const implFile of implFiles) { - const methods = ctx.symbols.lookupExactAll(implFile, calledName); + const methods = ctx.model.symbols.lookupExactAll(implFile, calledName); for (const method of methods) { if (method.nodeId !== primaryNodeId) { results.push({ @@ -808,7 +808,7 @@ export const processCalls = async ( const importedReturnTypes = importedReturnTypesMap?.get(file.path); const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path); const typeEnv = buildTypeEnv(tree, language, { - symbolTable: ctx.symbols, + model: ctx.model, parentMap, importedBindings, importedReturnTypes, @@ -817,7 +817,7 @@ export const processCalls = async ( extractFunctionName: provider?.methodExtractor?.extractFunctionName, }); if (typeEnv && exportedTypeMap) { - const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph); + const fileExports = collectExportedBindings(typeEnv, file.path, ctx.model.symbols, graph); if (fileExports) exportedTypeMap.set(file.path, fileExports); } if (bindingAccumulator) { @@ -1021,7 +1021,7 @@ export const processCalls = async ( description: item.accessorType, }, }); - ctx.symbols.add(file.path, item.propName, nodeId, 'Property', { + ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', { ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}), ...(item.declaredType ? { declaredType: item.declaredType } : {}), }); @@ -1093,8 +1093,8 @@ export const processCalls = async ( if ( isSubclassOf(ctorType, receiverTypeName, parentMap) || isSubclassOf(ctorType, receiverTypeName, globalParentMap) || - (ctx.symbols.lookupClassByName(ctorType).length > 0 && - ctx.symbols.lookupClassByName(receiverTypeName).length > 0) + (ctx.model.types.lookupClassByName(ctorType).length > 0 && + ctx.model.types.lookupClassByName(receiverTypeName).length > 0) ) { receiverTypeName = ctorType; } @@ -1299,7 +1299,7 @@ export const processCalls = async ( return collectedHeritage; }; -// CALLABLE_TYPES imported from symbol-table.ts — single source of truth. +// FREE_CALLABLE_TYPES imported from symbol-table.ts — single source of truth. const CONSTRUCTOR_TARGET_TYPES = new Set(['Constructor', 'Class', 'Struct', 'Record']); @@ -1320,10 +1320,14 @@ const filterCallableCandidates = ( } else { const types = candidates.filter((c) => CONSTRUCTOR_TARGET_TYPES.has(c.type)); kindFiltered = - types.length > 0 ? types : candidates.filter((c) => CALLABLE_TYPES.has(c.type)); + types.length > 0 ? types : candidates.filter((c) => CALL_TARGET_TYPES.has(c.type)); } } else { - kindFiltered = candidates.filter((c) => CALLABLE_TYPES.has(c.type)); + // CALL_TARGET_TYPES (not FREE_CALLABLE_TYPES) — the post-A4 filter must + // also admit Method and Constructor candidates, which are now unioned + // into the pool from `model.methods.lookupMethodByName` rather than + // `symbols.lookupCallableByName`. + kindFiltered = candidates.filter((c) => CALL_TARGET_TYPES.has(c.type)); } if (kindFiltered.length === 0) return []; @@ -1360,7 +1364,7 @@ const countCallableCandidates = ( const typeOk = callForm === 'constructor' ? CONSTRUCTOR_TARGET_TYPES.has(c.type) - : CALLABLE_TYPES.has(c.type); + : CALL_TARGET_TYPES.has(c.type); if (!typeOk) continue; // Arity filter if ( @@ -1573,11 +1577,27 @@ const resolveModuleAliasedCall = ( ); } if (filtered.length === 0) { - // Widen to global callable index scoped to the aliased module file. + // Widen to global callable+method indexes scoped to the aliased module + // file. Function+ownerId (Python/Rust/Kotlin) is still routed to both + // indexes until Unit 5 unblocks, so dedup by nodeId. const cacheKey = `${call.calledName}\0${moduleFile}`; let defs = widenCache?.get(cacheKey); if (!defs) { - defs = ctx.symbols.lookupCallableByName(call.calledName); + const rawCallable = ctx.model.symbols.lookupCallableByName(call.calledName); + const rawMethods = ctx.model.methods.lookupMethodByName(call.calledName); + const widenCombined: SymbolDefinition[] = []; + const widenSeen = new Set(); + for (const d of rawCallable) { + if (widenSeen.has(d.nodeId)) continue; + widenSeen.add(d.nodeId); + widenCombined.push(d); + } + for (const d of rawMethods) { + if (widenSeen.has(d.nodeId)) continue; + widenSeen.add(d.nodeId); + widenCombined.push(d); + } + defs = widenCombined; widenCache?.set(cacheKey, defs); } filtered = filterCallableCandidates(defs, call.argCount, call.callForm).filter( @@ -1618,11 +1638,26 @@ const resolveMemberCallByFile = ( const typeNodeIds = new Set(typeResolved.candidates.map((d) => d.nodeId)); const typeFiles = new Set(typeResolved.candidates.map((d) => d.filePath)); - const methodPool = filterCallableCandidates( - ctx.symbols.lookupCallableByName(calledName), - argCount, - callForm, - ); + // A4 (plan 006, Unit 4): consult both indexes. Strictly-labeled + // Method/Constructor are disjoint, but Function+ownerId (Python/Rust/ + // Kotlin) is routed into BOTH indexes by `wrappedAdd` until Unit 5 + // unblocks — dedup by nodeId so overload disambiguation doesn't see + // phantom duplicates. + const rawCallablePool = ctx.model.symbols.lookupCallableByName(calledName); + const rawMethodPool = ctx.model.methods.lookupMethodByName(calledName); + const combinedPool: SymbolDefinition[] = []; + const combinedSeen = new Set(); + for (const def of rawCallablePool) { + if (combinedSeen.has(def.nodeId)) continue; + combinedSeen.add(def.nodeId); + combinedPool.push(def); + } + for (const def of rawMethodPool) { + if (combinedSeen.has(def.nodeId)) continue; + combinedSeen.add(def.nodeId); + combinedPool.push(def); + } + const methodPool = filterCallableCandidates(combinedPool, argCount, callForm); const fileFiltered = methodPool.filter((c) => typeFiles.has(c.filePath)); if (fileFiltered.length === 1) { return toResolveResult(fileFiltered[0], typeResolved.tier); @@ -1951,7 +1986,7 @@ const resolveFieldOwnership = ( const classDef = typeResolved.candidates.find((d) => CLASS_LIKE_TYPES.has(d.type)); if (!classDef) return undefined; - return ctx.symbols.lookupFieldByOwner(classDef.nodeId, fieldName) ?? undefined; + return ctx.model.fields.lookupFieldByOwner(classDef.nodeId, fieldName) ?? undefined; }; /** @@ -1987,10 +2022,12 @@ const resolveMethodByOwner = ( const typeResolved = ctx.resolve(receiverTypeName, filePath); if (!typeResolved) return undefined; - // MRO walking needs a language hint; compute once and reuse for every candidate. - // Unknown extension → fall back to plain direct lookup (D1-D4 still runs on miss). + // MRO walking needs a language hint so we can derive the per-language + // strategy; compute it once and reuse for every candidate. Unknown + // extension → fall back to plain direct lookup (D1-D4 still runs on miss). const language = heritageMap ? getLanguageFromFilename(filePath) : null; - const canWalkMRO = heritageMap != null && language != null; + const mroStrategy = language != null ? getProvider(language).mroStrategy : null; + const canWalkMRO = heritageMap != null && mroStrategy != null; // Iterate all class-like candidates tracking the first unambiguous hit. // Zero-allocation fast path: the common case is exactly one class candidate, @@ -2014,11 +2051,11 @@ const resolveMethodByOwner = ( candidate.nodeId, methodName, heritageMap, - ctx.symbols, - language, + ctx.model, + mroStrategy, argCount, ) - : ctx.symbols.lookupMethodByOwner(candidate.nodeId, methodName, argCount); + : ctx.model.methods.lookupMethodByOwner(candidate.nodeId, methodName, argCount); if (!def) continue; if (!firstDef) { firstDef = def; @@ -2212,8 +2249,8 @@ export const resolveFreeCall = ( * Resolve a constructor or static call using class-scoped lookup (no fuzzy lookup). * Used for `new User()` / `User()` calls where the calledName targets a class. * - * Uses {@link SymbolTable.lookupClassByName} for O(1) class lookup and - * {@link SymbolTable.lookupMethodByOwner} for constructor resolution. + * Uses {@link TypeRegistry.lookupClassByName} for O(1) class lookup and + * {@link MethodRegistry.lookupMethodByOwner} for constructor resolution. * {@link resolveCallTarget} delegates here for constructor and free-form calls * that target a class. * @@ -2265,7 +2302,7 @@ export const resolveStaticCall = ( // is supplied, the caller has already paid for the tiered lookup, so this // pre-check still prevents the class-candidate filter + lookupMethodByOwner // loop from running on obviously non-class targets. - const allClasses = ctx.symbols.lookupClassByName(className); + const allClasses = ctx.model.types.lookupClassByName(className); if (allClasses.length === 0) return null; // 2. Scope via ctx.resolve for import-tier information. Reuse the caller's @@ -2296,7 +2333,7 @@ export const resolveStaticCall = ( let firstDef: SymbolDefinition | undefined; let ambiguous = false; for (const candidate of classCandidates) { - const def = ctx.symbols.lookupMethodByOwner(candidate.nodeId, className, argCount); + const def = ctx.model.methods.lookupMethodByOwner(candidate.nodeId, className, argCount); if (!def || def.type !== 'Constructor') continue; if (!firstDef) { firstDef = def; @@ -2372,138 +2409,6 @@ export const resolveStaticCall = ( return null; }; -// --------------------------------------------------------------------------- -// MRO-aware method resolution via HeritageMap (SM-9) -// --------------------------------------------------------------------------- - -/** - * Per-HeritageMap cache of C3 linearization results keyed by owner nodeId. - * - * HeritageMap instances are immutable after construction, so C3 output is - * stable for the lifetime of a HeritageMap. WeakMap lets the cache auto-drain - * when the HeritageMap is garbage collected (end of ingestion run), so we - * never need to manually invalidate it. - * - * `null` is a sentinel for "C3 failed for this owner" (cyclic or inconsistent - * hierarchy) so we don't re-run the expensive linearization repeatedly. - */ -const c3LinearizationCache = new WeakMap>(); - -const getCachedC3Linearization = ( - ownerNodeId: string, - heritageMap: HeritageMap, -): readonly string[] | null => { - let perHmCache = c3LinearizationCache.get(heritageMap); - if (!perHmCache) { - perHmCache = new Map(); - c3LinearizationCache.set(heritageMap, perHmCache); - } - const cached = perHmCache.get(ownerNodeId); - if (cached !== undefined) return cached; - const parentMap = buildParentMapFromHeritage(ownerNodeId, heritageMap); - const result = c3Linearize(ownerNodeId, parentMap, new Map()) ?? null; - perHmCache.set(ownerNodeId, result); - return result; -}; - -/** - * Build a parentMap from HeritageMap for use with c3Linearize. - * Traverses the parent chain starting from startNodeId, collecting all - * parent→children relationships into a Map. - */ -const buildParentMapFromHeritage = ( - startNodeId: string, - heritageMap: HeritageMap, -): Map => { - const parentMap = new Map(); - const visited = new Set(); - const queue = [startNodeId]; - - while (queue.length > 0) { - const nodeId = queue.shift()!; - if (visited.has(nodeId)) continue; - visited.add(nodeId); - const parents = heritageMap.getParents(nodeId); - if (parents.length > 0) { - parentMap.set(nodeId, parents); - for (const p of parents) { - if (!visited.has(p)) queue.push(p); - } - } - } - - return parentMap; -}; - -/** - * Look up a method on an owner class, walking the parent chain via HeritageMap - * when the method isn't found on the direct owner. - * - * Respects the 5 per-language MRO strategies: - * - `first-wins`: BFS ancestor walk, first match wins (default) - * - `leftmost-base`: BFS ancestor walk, leftmost base in declaration order wins (C++); - * HeritageMap preserves insertion order matching source declaration, - * so BFS order is equivalent to leftmost-base semantics - * - `c3`: C3-linearized ancestor order, first match wins (Python) - * - `implements-split`: BFS ancestor walk, first match wins (Java/C#) — - * full ambiguity detection for multiple interface defaults - * is handled by computeMRO at graph level - * - `qualified-syntax`: No auto-resolution (Rust) — returns undefined - * - * Delegates to mro-processor.ts c3Linearize for C3 strategy. - * - * @internal Exported only to enable unit testing in isolation. The proper - * entry point for callers outside this module is {@link resolveMethodByOwner}, - * which handles receiver-type resolution before delegating here. - */ -export const lookupMethodByOwnerWithMRO = ( - ownerNodeId: string, - methodName: string, - heritageMap: HeritageMap, - symbols: SymbolTable, - language: SupportedLanguages, - argCount?: number, -): SymbolDefinition | undefined => { - // Direct lookup first (child override — no walk needed). - // argCount is threaded through so arity-differing overloads on the direct - // owner can be disambiguated before the MRO walk starts. - const direct = symbols.lookupMethodByOwner(ownerNodeId, methodName, argCount); - if (direct) return direct; - - const strategy = getProvider(language).mroStrategy; - - // Rust: requires qualified syntax (::method), no auto-resolution - if (strategy === 'qualified-syntax') return undefined; - - // Determine ancestor walk order based on MRO strategy. - // readonly to accept the cached (frozen) c3 linearization without copying. - let ancestors: readonly string[]; - if (strategy === 'c3') { - // Delegate to mro-processor.ts C3 linearization (memoized per HeritageMap - // so repeated calls for the same owner within an ingestion run reuse the - // linearization instead of rebuilding the parent map and re-running C3). - // c3Linearize returns ancestors only (excludes the owner itself), - // matching heritageMap.getAncestors() semantics. - const c3Result = getCachedC3Linearization(ownerNodeId, heritageMap); - // Fall back to BFS order if C3 fails (cyclic or inconsistent hierarchy). - // Note: BFS order may not preserve Python MRO semantics in these edge - // cases, but cyclic/inconsistent hierarchies are invalid in Python anyway. - ancestors = c3Result ?? heritageMap.getAncestors(ownerNodeId); - } else { - // first-wins, leftmost-base, implements-split: BFS order via HeritageMap - ancestors = heritageMap.getAncestors(ownerNodeId); - } - - // Walk ancestors in MRO order — first match wins. - // argCount narrows overloaded ancestors the same way as the direct lookup. - for (const ancestorId of ancestors) { - const method = symbols.lookupMethodByOwner(ancestorId, methodName, argCount); - if (method) return method; - } - - return undefined; -}; - /** * Create a deduplicated ACCESSES edge emitter for a single source node. * Each (sourceId, fieldNodeId) pair is emitted at most once per source. diff --git a/gitnexus/src/core/ingestion/field-types.ts b/gitnexus/src/core/ingestion/field-types.ts index 5e89d34bb..8dd1a9f6c 100644 --- a/gitnexus/src/core/ingestion/field-types.ts +++ b/gitnexus/src/core/ingestion/field-types.ts @@ -1,7 +1,7 @@ // gitnexus/src/core/ingestion/field-types.ts import type { TypeEnvironment } from './type-env.js'; -import type { SymbolTable } from './symbol-table.js'; +import type { SymbolTableReader } from './model/symbol-table.js'; import { SupportedLanguages } from 'gitnexus-shared'; /** @@ -57,7 +57,7 @@ export interface FieldExtractorContext { /** Type environment for resolution */ typeEnv: TypeEnvironment; /** Symbol table for FQN lookups */ - symbolTable: SymbolTable; + symbolTable: SymbolTableReader; /** Current file path */ filePath: string; /** Language ID */ diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index 37c3653a8..bc8628fa5 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -19,47 +19,34 @@ import { ASTCache } from './ast-cache.js'; import Parser from 'tree-sitter'; import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js'; import { generateId } from '../../lib/utils.js'; -import { getLanguageFromFilename } from 'gitnexus-shared'; +import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { yieldToEventLoop } from './utils/event-loop.js'; -import { SupportedLanguages } from 'gitnexus-shared'; import { getProvider } from './languages/index.js'; import { getTreeSitterBufferSize } from './constants.js'; -import type { ExtractedHeritage } from './workers/parse-worker.js'; -import type { ResolutionContext } from './resolution-context.js'; -import { TIER_CONFIDENCE } from './resolution-context.js'; +import type { + ExtractedHeritage, + HeritageResolutionStrategy, + HeritageStrategyLookup, +} from './model/heritage-map.js'; +import { resolveExtendsType } from './model/heritage-map.js'; +import type { ResolutionContext } from './model/resolution-context.js'; +import { TIER_CONFIDENCE } from './model/resolution-context.js'; /** - * Determine whether a heritage.extends capture is actually an IMPLEMENTS relationship. - * Uses the symbol table first (authoritative — Tier 1); falls back to provider-defined - * heuristics for external symbols not present in the graph: - * - interfaceNamePattern: matched against parent name (e.g., /^I[A-Z]/ for C#/Java) - * - heritageDefaultEdge: 'IMPLEMENTS' causes all unresolved parents to map to IMPLEMENTS - * - All others: default EXTENDS + * Derive the heritage-resolution strategy for a language from its + * `LanguageProvider`. This is the production wiring that `buildHeritageMap` + * and the standalone `resolveExtendsType` call site use — the model layer + * itself stays unaware of the provider registry. */ -/** Exported for implementor-map construction (C#/Java: `extends` rows in base_list may be interfaces). */ -export const resolveExtendsType = ( - parentName: string, - currentFilePath: string, - ctx: ResolutionContext, - language: SupportedLanguages, -): { type: 'EXTENDS' | 'IMPLEMENTS'; idPrefix: string } => { - const resolved = ctx.resolve(parentName, currentFilePath); - if (resolved && resolved.candidates.length > 0) { - const isInterface = resolved.candidates[0].type === 'Interface'; - return isInterface - ? { type: 'IMPLEMENTS', idPrefix: 'Interface' } - : { type: 'EXTENDS', idPrefix: 'Class' }; - } - // Unresolved symbol — fall back to provider-defined heuristics - const provider = getProvider(language); - if (provider.interfaceNamePattern?.test(parentName)) { - return { type: 'IMPLEMENTS', idPrefix: 'Interface' }; - } - if (provider.heritageDefaultEdge === 'IMPLEMENTS') { - return { type: 'IMPLEMENTS', idPrefix: 'Interface' }; - } - return { type: 'EXTENDS', idPrefix: 'Class' }; +export const getHeritageStrategyForLanguage: HeritageStrategyLookup = ( + lang: SupportedLanguages, +): HeritageResolutionStrategy => { + const provider = getProvider(lang); + return { + interfaceNamePattern: provider.interfaceNamePattern, + defaultEdge: provider.heritageDefaultEdge ?? 'EXTENDS', + }; }; /** @@ -180,7 +167,7 @@ export const processHeritage = async ( parentClassName, file.path, ctx, - language, + getHeritageStrategyForLanguage(language), ); const child = resolveHeritageId( @@ -296,7 +283,7 @@ export const processHeritageFromExtracted = async ( h.parentName, h.filePath, ctx, - fileLanguage, + getHeritageStrategyForLanguage(fileLanguage), ); const child = resolveHeritageId( diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 3c52fa7a6..3dff47094 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -12,7 +12,11 @@ import type { ExtractedImport } from './workers/parse-worker.js'; import { getTreeSitterBufferSize } from './constants.js'; import { loadImportConfigs } from './language-config.js'; import { buildSuffixIndex } from './import-resolvers/utils.js'; -import type { ResolutionContext, ModuleAliasMap } from './resolution-context.js'; +import type { + ResolutionContext, + ModuleAliasMap, + NamedImportMap, +} from './model/resolution-context.js'; import type { ImportResult, ResolveCtx, @@ -61,30 +65,6 @@ function wireImplicitImports( // Avoids expanding every Go package import into N individual ImportMap edges. export type PackageMap = Map>; -// Type: Map> -// Tracks which specific names a file imports from which sources (TS/Python only). -// Used to tighten Tier 2a resolution: `import { User } from './models'` -// means only `User` (not `Repo`) is visible from models.ts via this import. -// Stores both the resolved source path and the original exported name so that -// aliased imports (`import { User as U }`) can resolve U → User in the source file. -export interface NamedImportBinding { - sourcePath: string; - exportedName: string; -} -export type NamedImportMap = Map>; - -/** - * Check if a file path is directly inside a package directory identified by its suffix. - * Used by the symbol resolver for Go and C# directory-level import matching. - */ -export function isFileInPackageDir(filePath: string, dirSuffix: string): boolean { - // Prepend '/' so paths like "internal/auth/service.go" match suffix "/internal/auth/" - const normalized = '/' + filePath.replace(/\\/g, '/'); - if (!normalized.includes(dirSuffix)) return false; - const afterDir = normalized.substring(normalized.indexOf(dirSuffix) + dirSuffix.length); - return !afterDir.includes('/'); -} - // ImportResolutionContext is defined in ./import-resolvers/types.ts — re-exported here for consumers. export function buildImportResolutionContext(allPaths: string[]): ImportResolutionContext { diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 38997cb0d..141ba59af 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -9,7 +9,7 @@ * so adding a language to the enum without creating a provider is a compiler error. */ -import type { SupportedLanguages } from 'gitnexus-shared'; +import type { SupportedLanguages, MroStrategy } from 'gitnexus-shared'; import type { LanguageTypeConfig } from './type-extractors/types.js'; import type { CallRouter } from './call-routing.js'; import type { ClassExtractor } from './class-types.js'; @@ -26,13 +26,10 @@ import type { NodeLabel } from 'gitnexus-shared'; export type CaptureMap = Record; // ── Strategy tag types ───────────────────────────────────────────────────── -/** MRO strategy for multiple inheritance resolution. */ -export type MroStrategy = - | 'first-wins' - | 'c3' - | 'leftmost-base' - | 'implements-split' - | 'qualified-syntax'; +// NOTE: `MroStrategy` is defined in `gitnexus-shared` and re-exported above +// so `core/ingestion/model/resolve.ts` can consume it without importing from +// this file (which would pull in the full language-registry dependency graph). + /** How a language handles imports — determines wildcard synthesis behavior. */ export type ImportSemantics = 'named' | 'wildcard' | 'namespace'; diff --git a/gitnexus/src/core/ingestion/model/field-registry.ts b/gitnexus/src/core/ingestion/model/field-registry.ts new file mode 100644 index 000000000..45fe5c86a --- /dev/null +++ b/gitnexus/src/core/ingestion/model/field-registry.ts @@ -0,0 +1,53 @@ +/** + * Field Registry + * + * Owner-scoped field/property index extracted from SymbolTable. + * Stores Property symbols keyed by `ownerNodeId\0fieldName` for O(1) lookup. + */ + +import type { SymbolDefinition } from './symbol-table.js'; + +// --------------------------------------------------------------------------- +// Public read-only interface +// --------------------------------------------------------------------------- + +export interface FieldRegistry { + /** Look up a field/property by its owning class nodeId and field name. */ + lookupFieldByOwner(ownerNodeId: string, fieldName: string): SymbolDefinition | undefined; +} + +// --------------------------------------------------------------------------- +// Mutable interface (used internally by SymbolTable.add / clear) +// --------------------------------------------------------------------------- + +export interface MutableFieldRegistry extends FieldRegistry { + /** Register a field/property under its owner. */ + register(ownerNodeId: string, fieldName: string, def: SymbolDefinition): void; + /** Clear all entries. */ + clear(): void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export const createFieldRegistry = (): MutableFieldRegistry => { + const fieldByOwner = new Map(); + + const lookupFieldByOwner = ( + ownerNodeId: string, + fieldName: string, + ): SymbolDefinition | undefined => { + return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`); + }; + + const register = (ownerNodeId: string, fieldName: string, def: SymbolDefinition): void => { + fieldByOwner.set(`${ownerNodeId}\0${fieldName}`, def); + }; + + const clear = (): void => { + fieldByOwner.clear(); + }; + + return { lookupFieldByOwner, register, clear }; +}; diff --git a/gitnexus/src/core/ingestion/heritage-map.ts b/gitnexus/src/core/ingestion/model/heritage-map.ts similarity index 60% rename from gitnexus/src/core/ingestion/heritage-map.ts rename to gitnexus/src/core/ingestion/model/heritage-map.ts index 46d0c2120..16ecf3305 100644 --- a/gitnexus/src/core/ingestion/heritage-map.ts +++ b/gitnexus/src/core/ingestion/model/heritage-map.ts @@ -7,16 +7,78 @@ * resolves type names to nodeIds via `lookupClassByName`, NOT graph-edge * queries. * - * Combines two previously separate concerns: + * Combines two concerns: * 1. **Parent/ancestor lookup** (MRO-aware method resolution) * 2. **Implementor lookup** (interface dispatch — which files contain * classes implementing a given interface) */ -import type { ExtractedHeritage } from './workers/parse-worker.js'; import type { ResolutionContext } from './resolution-context.js'; -import { getLanguageFromFilename } from 'gitnexus-shared'; -import { resolveExtendsType } from './heritage-processor.js'; +import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared'; + +// --------------------------------------------------------------------------- +// ExtractedHeritage — the shape produced by the parse worker / heritage +// extractor. Defined here so `model/` has no upward imports; consumers +// import this type from the model module. +// --------------------------------------------------------------------------- + +export interface ExtractedHeritage { + filePath: string; + className: string; + parentName: string; + /** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */ + kind: string; +} + +// --------------------------------------------------------------------------- +// Heritage resolution strategy (the per-language knobs that drive +// `resolveExtendsType` below). Pulled out as an explicit strategy object so +// the model layer depends on a plain data shape rather than on the language +// provider registry. +// --------------------------------------------------------------------------- + +export interface HeritageResolutionStrategy { + /** If set and the parent name matches, force IMPLEMENTS even when the + * symbol is unresolved (e.g. `/^I[A-Z]/` for C# / Java). */ + readonly interfaceNamePattern?: RegExp; + /** Fallback edge for unresolved parents when the name pattern doesn't + * match (Swift uses 'IMPLEMENTS' for protocol conformance). */ + readonly defaultEdge: 'EXTENDS' | 'IMPLEMENTS'; +} + +/** Callback used by `buildHeritageMap` to look up the resolution strategy + * for a given language. Injected by callers so the model module doesn't + * depend on `../languages/index.js`. */ +export type HeritageStrategyLookup = (lang: SupportedLanguages) => HeritageResolutionStrategy; + +/** + * Determine whether a heritage.extends capture is actually an IMPLEMENTS + * relationship. Consults the symbol table first (authoritative — Tier 1 / + * Tier 2 resolution); falls back to the injected {@link HeritageResolutionStrategy} + * heuristics for external symbols not present in the graph. + */ +export const resolveExtendsType = ( + parentName: string, + currentFilePath: string, + ctx: ResolutionContext, + strategy: HeritageResolutionStrategy, +): { type: 'EXTENDS' | 'IMPLEMENTS'; idPrefix: string } => { + const resolved = ctx.resolve(parentName, currentFilePath); + if (resolved && resolved.candidates.length > 0) { + const isInterface = resolved.candidates[0].type === 'Interface'; + return isInterface + ? { type: 'IMPLEMENTS', idPrefix: 'Interface' } + : { type: 'EXTENDS', idPrefix: 'Class' }; + } + // Unresolved symbol — fall back to strategy heuristics. + if (strategy.interfaceNamePattern?.test(parentName)) { + return { type: 'IMPLEMENTS', idPrefix: 'Interface' }; + } + if (strategy.defaultEdge === 'IMPLEMENTS') { + return { type: 'IMPLEMENTS', idPrefix: 'Interface' }; + } + return { type: 'EXTENDS', idPrefix: 'Class' }; +}; // --------------------------------------------------------------------------- // Public types @@ -41,6 +103,12 @@ export interface HeritageMap { /** Shared empty set returned when no implementors are found. */ const EMPTY_SET: ReadonlySet = new Set(); +/** Default strategy used when `buildHeritageMap` is called without an + * explicit `getHeritageStrategy` callback — the fallback for a language + * whose provider sets no interface-name pattern and no non-default + * `heritageDefaultEdge`. */ +const DEFAULT_HERITAGE_STRATEGY: HeritageResolutionStrategy = { defaultEdge: 'EXTENDS' }; + // --------------------------------------------------------------------------- // Builder // --------------------------------------------------------------------------- @@ -49,18 +117,18 @@ const EMPTY_SET: ReadonlySet = new Set(); * Build a HeritageMap from accumulated ExtractedHeritage records. * * Resolves class/interface/struct/trait names to nodeIds via - * `ctx.symbols.lookupClassByName`. When a name resolves to multiple + * `ctx.model.types.lookupClassByName`. When a name resolves to multiple * candidates, all are recorded (partial-class / cross-file scenario). * Unresolvable names are silently skipped — a missing parent is better * than a wrong edge. * * Also builds the implementor index (interface name → implementing file - * paths) that was previously maintained by `buildImplementorMap` in - * call-processor.ts. + * paths) used by interface-dispatch in call resolution. */ export const buildHeritageMap = ( heritage: readonly ExtractedHeritage[], ctx: ResolutionContext, + getHeritageStrategy?: HeritageStrategyLookup, ): HeritageMap => { // childNodeId → Set (Set to deduplicate cross-chunk duplicates) const directParents = new Map>(); @@ -70,8 +138,8 @@ export const buildHeritageMap = ( for (const h of heritage) { // ── Parent lookup (nodeId-based) ──────────────────────────────── - const childDefs = ctx.symbols.lookupClassByName(h.className); - const parentDefs = ctx.symbols.lookupClassByName(h.parentName); + const childDefs = ctx.model.types.lookupClassByName(h.className); + const parentDefs = ctx.model.types.lookupClassByName(h.parentName); if (childDefs.length > 0 && parentDefs.length > 0) { for (const child of childDefs) { @@ -99,16 +167,15 @@ export const buildHeritageMap = ( // // Known limitation: `getImplementorFiles` is keyed by interface **name** // (string), so two interfaces with the same unqualified name in different - // packages (e.g. `pkgA.IRepository` vs `pkgB.IRepository`) collide. This - // matches the behavior of the prior standalone `ImplementorMap` and is - // not a regression introduced by this consolidation. + // packages (e.g. `pkgA.IRepository` vs `pkgB.IRepository`) collide. let isImpl = false; if (h.kind === 'implements') { isImpl = true; } else if (h.kind === 'extends') { const lang = getLanguageFromFilename(h.filePath); if (lang) { - const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, lang); + const strategy = getHeritageStrategy?.(lang) ?? DEFAULT_HERITAGE_STRATEGY; + const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, strategy); isImpl = type === 'IMPLEMENTS'; } } diff --git a/gitnexus/src/core/ingestion/model/index.ts b/gitnexus/src/core/ingestion/model/index.ts new file mode 100644 index 000000000..27f939171 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/index.ts @@ -0,0 +1,88 @@ +/** + * Semantic Model — public module surface. + * + * Barrel re-export for the `model/` module. Consumers outside `model/` + * should import from this file rather than reaching into individual + * registry files. + * + * The model is owner-scoped type/method/field knowledge layered above + * `SymbolTable`. File-indexed and name-keyed callable lookups stay in + * `SymbolTable` by design. + */ + +// Unified semantic model (factory + interfaces). SemanticModel is the +// top-level container and owns the file/callable SymbolTable as a +// nested `symbols` field. +export { + type SemanticModel, + type MutableSemanticModel, + createSemanticModel, +} from './semantic-model.js'; + +// SymbolTable is exclusively owned by SemanticModel. Re-exported here +// for the rare caller that needs the file/callable interface in +// isolation (e.g. tests). +export { + type SymbolTableReader, + type SymbolTableWriter, + createSymbolTable, +} from './symbol-table.js'; + +// Type registry (classes, structs, interfaces, enums, records, impls) +export { + type TypeRegistry, + type MutableTypeRegistry, + createTypeRegistry, +} from './type-registry.js'; + +// Method registry (owner-scoped methods with arity-aware overload lookup) +export { + type MethodRegistry, + type MutableMethodRegistry, + createMethodRegistry, +} from './method-registry.js'; + +// Field registry (owner-scoped fields/properties) +export { + type FieldRegistry, + type MutableFieldRegistry, + createFieldRegistry, +} from './field-registry.js'; + +// MRO-aware method resolution (C3, first-wins, leftmost-base, implements-split, +// qualified-syntax). Pure function that depends only on the model + HeritageMap. +// `MroStrategy` itself lives in `gitnexus-shared`; re-exported here for +// consumers that reach model behavior through the barrel. +export { lookupMethodByOwnerWithMRO } from './resolve.js'; + +// Named-import types and package-dir helper. Re-exported so barrel +// consumers don't need to reach into a specific model file. +export { + type NamedImportBinding, + type NamedImportMap, + isFileInPackageDir, +} from './resolution-context.js'; + +// Heritage types. `buildHeritageMap` + `resolveExtendsType` are exported +// directly from `heritage-map.ts` and are not re-surfaced here to keep +// the barrel narrow. +export { + type ExtractedHeritage, + type HeritageResolutionStrategy, + type HeritageStrategyLookup, +} from './heritage-map.js'; + +// Behavior-grouped dispatch table for SymbolTable.add() routing. +// See registration-table.ts module JSDoc for the behavior group taxonomy +// and "how to add a new NodeLabel" checklist. +// NOTE: createRegistrationTable, RegistrationHook, and RegistrationTableDeps +// are deliberately NOT re-exported here — they are factory internals of +// SemanticModel and should only be imported directly from registration-table.js +// by semantic-model.ts and the registration-table.test.ts file. +export { + CALLABLE_ONLY_LABELS, + INERT_LABELS, + DISPATCH_LABELS, + ALL_NODE_LABELS, + type LabelBehavior, +} from './registration-table.js'; diff --git a/gitnexus/src/core/ingestion/model/method-registry.ts b/gitnexus/src/core/ingestion/model/method-registry.ts new file mode 100644 index 000000000..be28e5782 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/method-registry.ts @@ -0,0 +1,204 @@ +/** + * Method Registry + * + * Owner-scoped method index extracted from SymbolTable. + * Stores Method/Constructor/Function-with-ownerId symbols keyed by + * `ownerNodeId\0methodName` for O(1) lookup. Supports overloads + * (array values) and arity-based filtering. + */ + +import type { SymbolDefinition } from './symbol-table.js'; + +// --------------------------------------------------------------------------- +// Public read-only interface +// --------------------------------------------------------------------------- + +export interface MethodRegistry { + /** + * Look up a method by owner class + name, optionally filtered by arity. + * + * When `argCount` is provided, overloads whose parameter count doesn't + * accommodate the call's argument count are filtered out before the + * returnType dedup runs. This lets D0 (`resolveMemberCall`) disambiguate + * arity-differing overloads (e.g. C++ `greet()` vs `greet(string)`) that + * would otherwise collide on the shared `ownerId + methodName` key. + * + * Same-arity, same-returnType overloads (e.g. `save(int)` vs `save(String)`, + * both returning `void`) still collapse to the first match — callers must + * gate D0 on overload concern before invoking this function for that case. + */ + lookupMethodByOwner( + ownerNodeId: string, + methodName: string, + argCount?: number, + ): SymbolDefinition | undefined; + + /** + * Flat-by-name lookup across all owners. Returns every method registered + * with the given unqualified name, in registration order, accumulated + * across owners and overloads. + * + * Required by Tier 3 global resolution: Method and Constructor do not + * land in `SymbolTable.callableByName`, so Tier 3 reaches them through + * this flat index instead. Returns `[]` on miss — never `undefined` — + * so callers can concatenate without null checks. + * + * Reference identity: each returned def is the same object reference + * stored under `lookupMethodByOwner`, so a method symbol occupies one + * allocation reachable from two indexes. + */ + lookupMethodByName(name: string): readonly SymbolDefinition[]; + + /** + * True iff at least one registered def has `type === 'Function'` — i.e., + * a Python/Rust/Kotlin class method emitted by the worker as + * `Function + ownerId` rather than as a strict `Method` label. Such defs + * are double-indexed: they land in `SymbolTable.callableByName` (via the + * Function callable-index gate) AND in this registry (via the + * dispatch-key normalization in `wrappedAdd`). Tier 3 resolution must + * then dedup the two indexes by nodeId. + * + * When this flag is false, the callable and method indexes are + * guaranteed disjoint and Tier 3 can skip the dedup pass entirely. + * The flag is monotonic (false→true once, never back) for the lifetime + * of the MethodRegistry. + */ + readonly hasFunctionMethods: boolean; +} + +// --------------------------------------------------------------------------- +// Mutable interface (used internally by SymbolTable.add / clear) +// --------------------------------------------------------------------------- + +export interface MutableMethodRegistry extends MethodRegistry { + /** Register a method under its owner. Supports multiple overloads. */ + register(ownerNodeId: string, methodName: string, def: SymbolDefinition): void; + /** Clear all entries. */ + clear(): void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export const createMethodRegistry = (): MutableMethodRegistry => { + const methodByOwner = new Map(); + // Secondary flat-by-name index. Values are the SAME SymbolDefinition + // references stored under `methodByOwner` — no copy, just a second key. + // Populated in lockstep by `register()` and emptied by `clear()`. + const methodsByName = new Map(); + const EMPTY: readonly SymbolDefinition[] = Object.freeze([]); + // Set once when a Function+ownerId def lands here, powers the Tier 3 + // dedup fast-path. Monotonic: never unset except on `clear()`. + let hasFunctionMethodsFlag = false; + + const lookupMethodByOwner = ( + ownerNodeId: string, + methodName: string, + argCount?: number, + ): SymbolDefinition | undefined => { + const defs = methodByOwner.get(`${ownerNodeId}\0${methodName}`); + if (!defs || defs.length === 0) return undefined; + + // Arity narrowing: when an argCount is provided and there are multiple + // overloads, keep only those whose parameterCount can accommodate the + // call. This resolves arity-differing overloads (e.g. C++ `greet()` vs + // `greet(string)`) that share the same `ownerId + methodName` key. + // + // Candidates with `parameterCount === undefined` (extractor didn't + // populate the count — typically variadic or unknown) are retained + // conservatively so that legitimate variadic matches still resolve. + // + // Streaming loop avoids allocating a filtered array on the common + // "arity selects 0 or 1 match" path. We scan once, count arity + // matches, and only materialize a narrowed array if at least one + // match was found and at least one non-match exists. If arity rules + // out every candidate, fall back to the unfiltered set so the + // caller's fuzzy path still has something to work with. + let pool: readonly SymbolDefinition[] = defs; + if (argCount !== undefined && defs.length > 1) { + let matchedCount = 0; + let rejectedCount = 0; + for (const d of defs) { + if (d.parameterCount === undefined) { + matchedCount++; + continue; + } + const min = d.requiredParameterCount ?? d.parameterCount; + if (argCount >= min && argCount <= d.parameterCount) matchedCount++; + else rejectedCount++; + } + // Only narrow when the filter actually discriminates: at least one + // match AND at least one rejection. Pure-match and pure-reject + // paths both keep the unfiltered pool (the latter because fallback + // semantics demand it). + if (matchedCount > 0 && rejectedCount > 0) { + const arityMatched: SymbolDefinition[] = []; + for (const d of defs) { + if (d.parameterCount === undefined) { + arityMatched.push(d); + continue; + } + const min = d.requiredParameterCount ?? d.parameterCount; + if (argCount >= min && argCount <= d.parameterCount) arityMatched.push(d); + } + pool = arityMatched; + } + } + + if (pool.length === 1) return pool[0]; + // Multiple overloads after arity narrowing: return first if all share + // the same defined returnType (safe for chain resolution), undefined if + // return types differ (truly ambiguous — can't determine which overload). + const firstReturnType = pool[0].returnType; + if (firstReturnType === undefined) return undefined; + for (let i = 1; i < pool.length; i++) { + if (pool[i].returnType !== firstReturnType) return undefined; + } + return pool[0]; + }; + + const lookupMethodByName = (name: string): readonly SymbolDefinition[] => { + return methodsByName.get(name) ?? EMPTY; + }; + + const register = (ownerNodeId: string, methodName: string, def: SymbolDefinition): void => { + const key = `${ownerNodeId}\0${methodName}`; + const existing = methodByOwner.get(key); + if (existing) { + existing.push(def); + } else { + methodByOwner.set(key, [def]); + } + const byName = methodsByName.get(methodName); + if (byName) { + byName.push(def); + } else { + methodsByName.set(methodName, [def]); + } + // A `Function`-typed def reaching MethodRegistry means the worker + // emitted a Python/Rust/Kotlin class method as `Function + ownerId`. + // It was already written into `SymbolTable.callableByName` by the + // upstream Function callable-index gate, so the two indexes are no + // longer disjoint for this registry's lifetime — Tier 3 must dedup. + if (!hasFunctionMethodsFlag && def.type === 'Function') { + hasFunctionMethodsFlag = true; + } + }; + + const clear = (): void => { + methodByOwner.clear(); + methodsByName.clear(); + hasFunctionMethodsFlag = false; + }; + + return { + lookupMethodByOwner, + lookupMethodByName, + register, + clear, + get hasFunctionMethods() { + return hasFunctionMethodsFlag; + }, + }; +}; diff --git a/gitnexus/src/core/ingestion/model/registration-table.ts b/gitnexus/src/core/ingestion/model/registration-table.ts new file mode 100644 index 000000000..a5bb14f21 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/registration-table.ts @@ -0,0 +1,333 @@ +/** + * Registration Dispatch Table + * + * Behavior-grouped O(1) dispatch table for routing `SymbolTable.add()` + * registrations into the semantic registries. Replaces the cascading + * `if/else` ladder in `symbol-table.ts` with a `Map` + * whose entries point to closure-captured hooks. + * + * ## Ownership diagram + * + * SemanticModel + * ├── types (TypeRegistry) ← classLikeHook / implHook write here + * ├── methods (MethodRegistry) ← methodHook writes here + * ├── fields (FieldRegistry) ← propertyHook writes here + * └── symbols (SymbolTable) ← owns fileIndex + callableByName, + * calls dispatch() in add() + * + * ## Behavior groups (5 hooks, 13 table entries) + * + * | Group | NodeLabel values | Hook | Skip callable? | + * |---------------|---------------------------------------------------|--------------|----------------| + * | class-like | Class, Struct, Interface, Enum, Record, Trait | classLikeHook | no | + * | method-like | Method, Constructor | methodHook | no | + * | property | Property | propertyHook | YES | + * | impl-block | Impl | implHook | no | + * | callable-only | Function, Macro, Delegate | (no entry) | no | + * + * Every other `NodeLabel` is "inert" — reached by `fileIndex` only. No + * specialized registry, no callable index append. + * + * ## How to add a new NodeLabel + * + * 1. Add the variant to the `NodeLabel` union in `gitnexus-shared/src/graph/types.ts`. + * 2. Decide which behavior group it belongs to by asking "which lookups must + * return this symbol?" (not "what language feature is it?"). A new Swift + * `Extension` is class-like if you want owner-scoped method lookup on it; + * a new Kotlin `Object` is class-like for the same reason. + * 3. Either: + * - Add a table entry here pointing at one of the existing hooks, OR + * - Add it to `CALLABLE_ONLY_LABELS` if it is a free callable, OR + * - Add it to `INERT_LABELS` if it's metadata-only (File, Folder, Decorator, + * etc.) — never queried via owner/class lookups. + * 4. If none of the above fit — the new kind needs a brand-new registry — + * design the registry first in `model/`, then add a new hook closure + * and table entries. Update `DISPATCH_LABELS` / the exhaustiveness guard + * accordingly. + * + * The runtime exhaustiveness guard in `symbol-table.ts` will warn if a + * `NodeLabel` is missing from all three sets. + */ + +import type { NodeLabel } from 'gitnexus-shared'; +import type { SymbolDefinition, ClassLikeLabel, FreeCallableLabel } from './symbol-table.js'; +import { FREE_CALLABLE_TYPES } from './symbol-table.js'; +import type { MutableTypeRegistry } from './type-registry.js'; +import type { MutableMethodRegistry } from './method-registry.js'; +import type { MutableFieldRegistry } from './field-registry.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Registration hook — a pure side-effectful function closed over a + * specific registry. Performs the specialized registry write into the + * appropriate owner-scoped registry for one NodeLabel. + * + * Closure capture is the isolation mechanism: `propertyHook` literally + * cannot call `types.registerClass` because its closure does not hold + * a reference to `types`. This is the runtime half of the principle of + * least authority — the compile-time half is enforced by TypeScript. + * + * The callable-index gate lives inside `SymbolTable.add()` via the + * `FREE_CALLABLE_TYPES` allowlist — the dispatch table does not + * participate in that decision. + */ +export type RegistrationHook = (name: string, def: SymbolDefinition) => void; + +/** + * Dependencies required to build the dispatch table. Matches the shape + * that `createSemanticModel()` passes into `createRegistrationTable()`. + */ +export interface RegistrationTableDeps { + readonly types: MutableTypeRegistry; + readonly methods: MutableMethodRegistry; + readonly fields: MutableFieldRegistry; +} + +// --------------------------------------------------------------------------- +// Single source of truth: NodeLabel → behavior category +// --------------------------------------------------------------------------- + +/** + * Behavior category for a NodeLabel during ingestion. Determines which + * registry (if any) receives the symbol write during `SymbolTable.add()`: + * + * - `dispatch` — owner-scoped registry write via the dispatch table + * (Class/Struct/Interface/Enum/Record/Trait → types.registerClass, + * Method/Constructor → methods.register, + * Property → fields.register, + * Impl → types.registerImpl) + * - `callable-only` — no specialized registry; symbol appears in + * `callableByName` via `SymbolTable.add()`'s + * FREE_CALLABLE_TYPES gate (Function/Macro/Delegate) + * - `inert` — no registry, no callable index; file-index only + * (metadata / structural nodes like Project, Module, + * Import, Decorator, etc.) + * + * `Function` has a twist: `Function`-with-`ownerId` (Python `def` in a + * class body, Rust trait method, Kotlin companion method) is pre-normalized + * to `Method` in `createSemanticModel`'s `wrappedAdd` before dispatch lookup, + * so only free functions actually flow through the callable-only path. + */ +export type LabelBehavior = 'dispatch' | 'callable-only' | 'inert'; + +/** + * **Single source of truth** for NodeLabel classification. Every NodeLabel + * has exactly one behavior category — enforced at compile time by the + * `as const satisfies Record` combo: + * + * - **Completeness** — `Record` requires every + * NodeLabel to be a key. Missing a label fails to compile with + * "Property 'X' is missing in type ..." naming the drifted label. + * - **No extras** — `satisfies` performs excess-property checking on + * object literals, so a non-NodeLabel string key fails to compile. + * - **No duplicates** — object keys are unique by construction. A label + * cannot be classified into two categories by accident. + * - **Valid values** — `LabelBehavior` is a narrow union, so a typo in + * the category name fails to compile. + * + * Adding a new NodeLabel to `gitnexus-shared`: TypeScript will flag this + * file as incomplete. Add the new label with its behavior category and + * the three `*_LABELS` Sets + `ALL_NODE_LABELS` array below are derived + * automatically — no separate list to update, no runtime drift detection + * needed. + * + * NOTE: `Type` and `CodeElement` are inert wrappers for language features + * that don't yet have a dedicated registry (typedefs, synthesized dynamic + * calls). If future work needs owner-scoped lookup for them, change their + * category to `'dispatch'` and add a hook in `createRegistrationTable`. + * Do not special-case them inside `SymbolTable.add()`. + */ +const LABEL_BEHAVIOR = { + // dispatch — owner-scoped registry writes + Class: 'dispatch', + Struct: 'dispatch', + Interface: 'dispatch', + Enum: 'dispatch', + Record: 'dispatch', + Trait: 'dispatch', + Method: 'dispatch', + Constructor: 'dispatch', + Property: 'dispatch', + Impl: 'dispatch', + + // callable-only — file index + callableByName, no owner scope + Function: 'callable-only', + Macro: 'callable-only', + Delegate: 'callable-only', + + // inert — file index only + Project: 'inert', + Package: 'inert', + Module: 'inert', + Folder: 'inert', + File: 'inert', + Variable: 'inert', + Decorator: 'inert', + Import: 'inert', + Type: 'inert', + CodeElement: 'inert', + Community: 'inert', + Process: 'inert', + Typedef: 'inert', + Union: 'inert', + Namespace: 'inert', + TypeAlias: 'inert', + Const: 'inert', + Static: 'inert', + Annotation: 'inert', + Template: 'inert', + Section: 'inert', + Route: 'inert', + Tool: 'inert', +} as const satisfies Record & + // Cross-invariant 1 — every class-like label (participates in + // qualifiedName fallback in `SymbolTable.add()`) MUST be classified as + // 'dispatch'. Adding a label to `CLASS_TYPES_TUPLE` without classifying + // it as 'dispatch' fails with a type error naming the drifted label. + Record & + // Cross-invariant 2 — every free-callable label (gate in + // `SymbolTable.add()` via `FREE_CALLABLE_TYPES`) MUST be classified as + // 'callable-only'. Adding a label to `FREE_CALLABLE_TUPLE` without + // classifying it as 'callable-only' fails with a type error naming the + // drifted label. + Record; + +// --------------------------------------------------------------------------- +// Derived runtime collections — all keyed off LABEL_BEHAVIOR +// --------------------------------------------------------------------------- + +/** + * All known NodeLabels, derived from the keys of `LABEL_BEHAVIOR`. The + * `satisfies Record` bijection above proves + * that `Object.keys(LABEL_BEHAVIOR)` is exactly the NodeLabel set — + * the cast to `NodeLabel[]` is sound, not a type-system bypass. + * + * Consumers (e.g., the semantic-model barrel re-export for tests) can + * rely on this list being complete by construction. No runtime drift + * check is needed or possible — the type system is the proof. + */ +export const ALL_NODE_LABELS: readonly NodeLabel[] = Object.keys(LABEL_BEHAVIOR) as NodeLabel[]; + +const labelsWithBehavior = (behavior: LabelBehavior): NodeLabel[] => + ALL_NODE_LABELS.filter((label) => LABEL_BEHAVIOR[label] === behavior); + +/** + * NodeLabel values that are free callables — appear in `callableByName` + * but have no owner-scoped specialized registry. Alias of + * {@link FREE_CALLABLE_TYPES} exported here for taxonomy-test use. The + * compile-time cross-invariant on `LABEL_BEHAVIOR` above guarantees the + * alias and the LABEL_BEHAVIOR `callable-only` classification cannot + * drift. + */ +export const CALLABLE_ONLY_LABELS: ReadonlySet = FREE_CALLABLE_TYPES; + +/** + * NodeLabel values that touch only the file index — no specialized + * registry, no callable index. + */ +export const INERT_LABELS: ReadonlySet = new Set(labelsWithBehavior('inert')); + +/** + * NodeLabel values that have a dispatch table entry. `createRegistrationTable` + * below must provide a hook for exactly this set — the test file's behavior- + * group tests and the integration tests pin the hook↔label correspondence. + */ +export const DISPATCH_LABELS: ReadonlySet = new Set(labelsWithBehavior('dispatch')); + +/** + * Type-level extraction of every label classified as `'dispatch'` in + * {@link LABEL_BEHAVIOR}. Used by {@link createRegistrationTable} as the + * key set of its internal object literal, so the `satisfies + * Record` check fails at build time if + * a dispatch-classified label is missing a hook, or a hook is wired to + * a non-dispatch label. This closes the last compile-time gap between + * `LABEL_BEHAVIOR` and the dispatch table. + */ +type DispatchLabel = { + [K in NodeLabel]: (typeof LABEL_BEHAVIOR)[K] extends 'dispatch' ? K : never; +}[NodeLabel]; + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Build the dispatch table. Must be called once per `createSymbolTable` + * invocation so each hook closes over that SymbolTable's injected + * registries. Reusing a single module-level instance would cause hooks + * to write into the wrong SemanticModel. + */ +export const createRegistrationTable = ( + deps: RegistrationTableDeps, +): Map => { + const { types, methods, fields } = deps; + + // Hook 1: class-like — Class, Struct, Interface, Enum, Record, Trait. + // Shared reference — six table entries point at this one closure. + const classLikeHook: RegistrationHook = (name, def) => { + const qualifiedKey = def.qualifiedName ?? name; + types.registerClass(name, qualifiedKey, def); + }; + + // Hook 2: method-like — Method, Constructor. Silently skipped if the + // caller did not provide an ownerId (Property without ownerId is + // treated the same way). + const methodHook: RegistrationHook = (name, def) => { + if (def.ownerId) { + methods.register(def.ownerId, name, def); + } + }; + + // Hook 3: property — Property. Silently skipped without ownerId. + // Property is not in `FREE_CALLABLE_TYPES`, so `SymbolTable.add()` already + // excludes it from `callableByName`; common property names like + // `id` / `name` / `type` never pollute the callable index. + const propertyHook: RegistrationHook = (name, def) => { + if (def.ownerId) { + fields.register(def.ownerId, name, def); + } + }; + + // Hook 4: impl-block — Rust `impl` blocks. Kept separate from classLikeHook + // because heritage resolution must not treat Impls as class candidates + // (an Impl is not a parent type, it's an ancillary dispatch table). + const implHook: RegistrationHook = (name, def) => { + types.registerImpl(name, def); + }; + + // Single source of truth for the label → hook mapping. The + // `satisfies Record` intersection + // fails at build time if (a) any label classified as 'dispatch' in + // `LABEL_BEHAVIOR` is missing here, or (b) any key here is not + // classified as 'dispatch'. This is the compile-time twin of the + // runtime taxonomy — no drift possible. + const dispatchByLabel = { + // class-like — six labels share the single `classLikeHook` closure, + // kept in lockstep with `CLASS_TYPES_TUPLE` via the + // `Record` cross-invariant on + // `LABEL_BEHAVIOR`. + Class: classLikeHook, + Struct: classLikeHook, + Interface: classLikeHook, + Enum: classLikeHook, + Record: classLikeHook, + Trait: classLikeHook, + // method-like — routed via dispatch-key normalization in + // `wrappedAdd` so Function+ownerId also reaches `methodHook`. + Method: methodHook, + Constructor: methodHook, + // property — callable-index exclusion is enforced by + // `SymbolTable.add()` (Property is not in `FREE_CALLABLE_TYPES`). + Property: propertyHook, + // impl-block — Rust `impl` blocks. Separate from classLikeHook because + // heritage resolution must not treat Impls as class candidates. + Impl: implHook, + } as const satisfies Record; + + return new Map( + Object.entries(dispatchByLabel) as [NodeLabel, RegistrationHook][], + ); +}; diff --git a/gitnexus/src/core/ingestion/resolution-context.ts b/gitnexus/src/core/ingestion/model/resolution-context.ts similarity index 55% rename from gitnexus/src/core/ingestion/resolution-context.ts rename to gitnexus/src/core/ingestion/model/resolution-context.ts index 06b2c89be..b56524b36 100644 --- a/gitnexus/src/core/ingestion/resolution-context.ts +++ b/gitnexus/src/core/ingestion/model/resolution-context.ts @@ -1,9 +1,7 @@ /** * Resolution Context * - * Single implementation of tiered name resolution. Replaces the duplicated - * tier-selection logic previously split between symbol-resolver.ts and - * call-processor.ts. + * Single implementation of tiered name resolution. * * Resolution tiers (highest confidence first): * 1. Same file (lookupExactAll — authoritative) @@ -20,11 +18,112 @@ * (three O(1) index lookups with a narrow, type-specific result set). */ -import type { SymbolTable, SymbolDefinition } from './symbol-table.js'; -import { createSymbolTable } from './symbol-table.js'; -import type { NamedImportMap } from './import-processor.js'; -import { isFileInPackageDir } from './import-processor.js'; -import { walkBindingChain } from './named-binding-processor.js'; +import type { SymbolDefinition, SymbolTableReader } from './symbol-table.js'; +import type { MutableSemanticModel } from './semantic-model.js'; +import { createSemanticModel } from './semantic-model.js'; + +// --------------------------------------------------------------------------- +// Named-import types — describe how a file imports specific names from a +// source file. Consumed by the Tier 2a-named binding-chain walker below. +// --------------------------------------------------------------------------- + +/** + * A single named binding in a source file (e.g. `import { User as U }`). + * Stores both the resolved source path and the original exported name so + * that aliased imports can resolve U → User in the source file. + */ +export interface NamedImportBinding { + sourcePath: string; + exportedName: string; +} + +/** + * Map>. + * + * Tracks which specific names a file imports from which sources (TS / Python + * / Rust / Java-static / ...). Used to tighten Tier 2a resolution: + * `import { User } from './models'` means only `User` (not `Repo`) is + * visible from models.ts via this import. + */ +export type NamedImportMap = Map>; + +/** + * Check if a file path is directly inside a package directory identified by + * its suffix. Used by Tier 2b package-scoped resolution (Go / C#). + */ +export function isFileInPackageDir(filePath: string, dirSuffix: string): boolean { + // Prepend '/' so paths like "internal/auth/service.go" match suffix "/internal/auth/" + const normalized = '/' + filePath.replace(/\\/g, '/'); + if (!normalized.includes(dirSuffix)) return false; + const afterDir = normalized.substring(normalized.indexOf(dirSuffix) + dirSuffix.length); + return !afterDir.includes('/'); +} + +/** Maximum re-export hops walkBindingChain will follow before giving up. + * A hard cap is needed to defend against pathological cycles that slip + * past the `visited` Set (e.g. a binding chain whose key is equal by + * string value but visits distinct modules). Five hops covers the + * common TypeScript monorepo pattern (component → pkg/index → + * packages/index → root/index → types/index). Chains longer than this + * fall through to Tier 2a-import / Tier 2b / Tier 3 resolution, which + * is a silent false-negative that the caller may or may not recover + * from. If a real repo hits this limit, raise it — there is no + * correctness reason to keep it at exactly 5. */ +const MAX_BINDING_CHAIN_DEPTH = 5; + +/** + * Walk a named-binding re-export chain through NamedImportMap. + * + * When file A imports { User } from B, and B re-exports { User } from C, + * the NamedImportMap for A points to B, but B has no User definition. + * This function follows the chain: A → B → C until a definition is found. + * + * Returns the definitions found at the end of the chain, or null if the + * chain breaks (missing binding, circular reference, or + * {@link MAX_BINDING_CHAIN_DEPTH} exceeded). Internal to + * resolution-context — not exported from the model barrel. + */ +function walkBindingChain( + name: string, + currentFilePath: string, + symbolTable: SymbolTableReader, + namedImportMap: NamedImportMap, +): readonly SymbolDefinition[] | null { + // Fast exit: most files have no named imports at all. Skip the Set + // allocation + loop entry on the common empty-binding path so resolve() + // stays allocation-free for the typical call site. + const firstBindings = namedImportMap.get(currentFilePath); + if (!firstBindings) return null; + const firstBinding = firstBindings.get(name); + if (!firstBinding) return null; + + let lookupFile = currentFilePath; + let lookupName = name; + const visited = new Set(); + + for (let depth = 0; depth < MAX_BINDING_CHAIN_DEPTH; depth++) { + const bindings = depth === 0 ? firstBindings : namedImportMap.get(lookupFile); + if (!bindings) return null; + + const binding = depth === 0 ? firstBinding : bindings.get(lookupName); + if (!binding) return null; + + const key = `${binding.sourcePath}:${binding.exportedName}`; + if (visited.has(key)) return null; // circular + visited.add(key); + + const targetName = binding.exportedName; + const resolvedDefs = symbolTable.lookupExactAll(binding.sourcePath, targetName); + + if (resolvedDefs.length > 0) return resolvedDefs; + + // No definition in source file → follow re-export chain + lookupFile = binding.sourcePath; + lookupName = targetName; + } + + return null; +} /** Resolution tier for tracking, logging, and test assertions. */ export type ResolutionTier = 'same-file' | 'import-scoped' | 'global'; @@ -59,8 +158,13 @@ export interface ResolutionContext { resolve(name: string, fromFile: string): TieredCandidates | null; // --- Data access (for pipeline wiring, not resolution) --- - /** Symbol table — used by parsing-processor to populate symbols. */ - readonly symbols: SymbolTable; + /** Semantic model — the top-level container for types, methods, fields, + * and the nested file/callable SymbolTable. Typed as + * {@link MutableSemanticModel} because `ResolutionContext` is the + * lifecycle owner — the pipeline registers symbols through it during + * the fan-out phase. Resolvers that only query should annotate their + * own fields as {@link SemanticModel} to drop write access. */ + readonly model: MutableSemanticModel; /** Raw maps — used by import-processor to populate import data. */ readonly importMap: ImportMap; readonly packageMap: PackageMap; @@ -86,7 +190,8 @@ export interface ResolutionContext { } export const createResolutionContext = (): ResolutionContext => { - const symbols = createSymbolTable(); + const model = createSemanticModel(); + const symbols = model.symbols; const importMap: ImportMap = new Map(); const packageMap: PackageMap = new Map(); const namedImportMap: NamedImportMap = new Map(); @@ -194,27 +299,75 @@ export const createResolutionContext = (): ResolutionContext => { // Tier 3: Global — targeted O(1) index lookups for each symbol category. // Class-like symbols (Class, Struct, Interface, Enum, Record, Trait) are // covered by lookupClassByName; Rust impl blocks by lookupImplByName - // (separate to avoid polluting heritage resolution); callables (Function, - // Method, Constructor, Macro, Delegate) by lookupCallableByName. - // The three indexes cover disjoint symbol types so no dedup is needed. - // Consumers must check candidates.length and refuse ambiguous matches. + // (separate to avoid polluting heritage resolution); free callables + // (Function, Macro, Delegate) by lookupCallableByName; owner-scoped + // methods and constructors by `model.methods.lookupMethodByName`. + // + // FREE_CALLABLE_TYPES excludes Method/Constructor, so strictly-labeled + // methods are disjoint between the two indexes. + // + // Partial-state caveat: Python/Rust/Kotlin class methods are emitted + // as Function + ownerId — `rawSymbols.add` routes them through both + // the Function callable index AND, via the dispatch-key normalization + // in `wrappedAdd`, the method registry. The same `SymbolDefinition` + // reference lands in both `callableDefs` and `methodDefs`, so the + // Set-based dedup below is required. // // Known exclusion: TypeAlias, Const, and Variable are NOT reachable at - // Tier 3 — they don't belong to any of the three indexes. In practice - // they were never useful as Tier 3 candidates: TypeAlias is not a call - // target, Const/Variable are resolved via import or same-file tiers. - // If a future language needs them at Tier 3, add a dedicated index. - // Macro (C/C++) and Delegate (C#) ARE included in the callable index + // Tier 3 — they don't belong to any of the indexes. TypeAlias is not + // a call target; Const/Variable are resolved via import or same-file + // tiers. Macro (C/C++) and Delegate (C#) stay in the callable index // since call-processor.ts treats them as callable targets. - const classDefs = symbols.lookupClassByName(name); - const implDefs = symbols.lookupImplByName(name); + const classDefs = model.types.lookupClassByName(name); + const implDefs = model.types.lookupImplByName(name); const callableDefs = symbols.lookupCallableByName(name); + const methodDefs = model.methods.lookupMethodByName(name); - if (classDefs.length === 0 && implDefs.length === 0 && callableDefs.length === 0) { + if ( + classDefs.length === 0 && + implDefs.length === 0 && + callableDefs.length === 0 && + methodDefs.length === 0 + ) { tierMiss++; return null; } - const globalDefs = [...classDefs, ...implDefs, ...callableDefs]; + + // Fast path: if no `Function + ownerId` class method was ever + // registered into the method registry (the only source of + // cross-index duplication), the callable and method indexes are + // guaranteed disjoint and we can concat without dedup. + if (!model.methods.hasFunctionMethods) { + const globalDefs: SymbolDefinition[] = [ + ...classDefs, + ...implDefs, + ...callableDefs, + ...methodDefs, + ]; + tierGlobal++; + return { candidates: globalDefs, tier: 'global' }; + } + + // Slow path: dedup by nodeId because the same SymbolDefinition + // reference can land in both `callableDefs` (via the Function + // callable-index gate) and `methodDefs` (via the dispatch-key + // normalization routing Function+ownerId into MethodRegistry). + // Dedup covers all four index reads so any nodeId overlap (even + // theoretical ones between classDefs/implDefs) is caught. + const globalDefs: SymbolDefinition[] = []; + const seen = new Set(); + const pushUnique = (pool: readonly SymbolDefinition[]): void => { + for (const def of pool) { + if (seen.has(def.nodeId)) continue; + seen.add(def.nodeId); + globalDefs.push(def); + } + }; + pushUnique(classDefs); + pushUnique(implDefs); + pushUnique(callableDefs); + pushUnique(methodDefs); + tierGlobal++; return { candidates: globalDefs, tier: 'global' }; }; @@ -271,7 +424,7 @@ export const createResolutionContext = (): ResolutionContext => { }); const clear = (): void => { - symbols.clear(); + model.clear(); importMap.clear(); packageMap.clear(); namedImportMap.clear(); @@ -288,7 +441,7 @@ export const createResolutionContext = (): ResolutionContext => { return { resolve, - symbols, + model, importMap, packageMap, namedImportMap, diff --git a/gitnexus/src/core/ingestion/model/resolve.ts b/gitnexus/src/core/ingestion/model/resolve.ts new file mode 100644 index 000000000..106630667 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/resolve.ts @@ -0,0 +1,284 @@ +/** + * Deterministic Resolution Functions + * + * Pure functions that resolve methods across the inheritance hierarchy + * using only the SemanticModel registries and HeritageMap — NO dependency + * on resolution-context.ts (circular dependency risk). + */ + +import type { SymbolDefinition } from './symbol-table.js'; +import type { SemanticModel } from './semantic-model.js'; +import type { HeritageMap } from './heritage-map.js'; +import type { MroStrategy } from 'gitnexus-shared'; + +// --------------------------------------------------------------------------- +// MRO primitives. +// +// `c3Linearize` and its BFS helper `gatherAncestors` live here so the model +// layer stays a pure leaf — mro-processor.ts (graph-level MRO emission) +// imports `c3Linearize` from this file. +// --------------------------------------------------------------------------- + +/** + * Gather all ancestor IDs in BFS / topological order. + * Returns the linearized list of ancestor IDs (excluding the class itself). + */ +function gatherAncestors(classId: string, parentMap: Map): string[] { + const visited = new Set(); + const order: string[] = []; + const queue: string[] = [...(parentMap.get(classId) ?? [])]; + + while (queue.length > 0) { + const id = queue.shift()!; + if (visited.has(id)) continue; + visited.add(id); + order.push(id); + const grandparents = parentMap.get(id); + if (grandparents) { + for (const gp of grandparents) { + if (!visited.has(gp)) queue.push(gp); + } + } + } + + return order; +} + +/** + * Compute C3 linearization for a class given a parentMap. + * Returns an array of ancestor IDs in C3 order (excluding the class itself), + * or null if linearization fails (inconsistent or cyclic hierarchy). + * + * Used internally by `lookupMethodByOwnerWithMRO` for the Python MRO + * strategy and re-exported for mro-processor.ts (graph-level MRO emission). + */ +export function c3Linearize( + classId: string, + parentMap: Map, + cache: Map, + inProgress?: Set, +): string[] | null { + if (cache.has(classId)) return cache.get(classId)!; + + // Cycle detection: if we're already computing this class, the hierarchy is cyclic + const visiting = inProgress ?? new Set(); + if (visiting.has(classId)) { + cache.set(classId, null); + return null; + } + visiting.add(classId); + + const directParents = parentMap.get(classId); + if (!directParents || directParents.length === 0) { + visiting.delete(classId); + cache.set(classId, []); + return []; + } + + // Compute linearization for each parent first + const parentLinearizations: string[][] = []; + for (const pid of directParents) { + const pLin = c3Linearize(pid, parentMap, cache, visiting); + if (pLin === null) { + visiting.delete(classId); + cache.set(classId, null); + return null; + } + parentLinearizations.push([pid, ...pLin]); + } + + // Add the direct parents list as the final sequence + const sequences = [...parentLinearizations, [...directParents]]; + const result: string[] = []; + + while (sequences.some((s) => s.length > 0)) { + // Find a good head: one that doesn't appear in the tail of any other sequence + let head: string | null = null; + for (const seq of sequences) { + if (seq.length === 0) continue; + const candidate = seq[0]; + const inTail = sequences.some( + (other) => other.length > 1 && other.indexOf(candidate, 1) !== -1, + ); + if (!inTail) { + head = candidate; + break; + } + } + + if (head === null) { + // Inconsistent hierarchy + visiting.delete(classId); + cache.set(classId, null); + return null; + } + + result.push(head); + + // Remove the chosen head from all sequences + for (const seq of sequences) { + if (seq.length > 0 && seq[0] === head) { + seq.shift(); + } + } + } + + visiting.delete(classId); + cache.set(classId, result); + return result; +} + +// `gatherAncestors` is exported so mro-processor.ts can reuse the same +// BFS traversal for graph-level MRO emission. +export { gatherAncestors }; + +// --------------------------------------------------------------------------- +// C3 linearization cache (per HeritageMap, auto-drained via WeakMap) +// --------------------------------------------------------------------------- + +/** + * Per-HeritageMap cache of C3 linearization results keyed by owner nodeId. + * + * HeritageMap instances are immutable after construction, so C3 output is + * stable for the lifetime of a HeritageMap. WeakMap lets the cache auto-drain + * when the HeritageMap is garbage collected (end of ingestion run), so we + * never need to manually invalidate it. + * + * `null` is a sentinel for "C3 failed for this owner" (cyclic or inconsistent + * hierarchy) so we don't re-run the expensive linearization repeatedly. + */ +const c3LinearizationCache = new WeakMap>(); + +const getCachedC3Linearization = ( + ownerNodeId: string, + heritageMap: HeritageMap, +): readonly string[] | null => { + let perHmCache = c3LinearizationCache.get(heritageMap); + if (!perHmCache) { + perHmCache = new Map(); + c3LinearizationCache.set(heritageMap, perHmCache); + } + const cached = perHmCache.get(ownerNodeId); + if (cached !== undefined) return cached; + const parentMap = buildParentMapFromHeritage(ownerNodeId, heritageMap); + const result = c3Linearize(ownerNodeId, parentMap, new Map()) ?? null; + perHmCache.set(ownerNodeId, result); + return result; +}; + +// --------------------------------------------------------------------------- +// Heritage → parentMap conversion +// --------------------------------------------------------------------------- + +/** + * Build a parentMap from HeritageMap for use with c3Linearize. + * Traverses the parent chain starting from startNodeId, collecting all + * parent→children relationships into a Map. + * + * Uses a head-pointer BFS (queue[head++]) instead of Array.shift() to avoid + * O(n) per-dequeue re-indexing. For wide/shallow hierarchies common in + * large Java/C# codebases this keeps the walk linear in ancestor count. + */ +const buildParentMapFromHeritage = ( + startNodeId: string, + heritageMap: HeritageMap, +): Map => { + const parentMap = new Map(); + const visited = new Set(); + const queue: string[] = [startNodeId]; + let head = 0; + + while (head < queue.length) { + const nodeId = queue[head++]!; + if (visited.has(nodeId)) continue; + visited.add(nodeId); + const parents = heritageMap.getParents(nodeId); + if (parents.length > 0) { + parentMap.set(nodeId, parents); + for (const p of parents) { + if (!visited.has(p)) queue.push(p); + } + } + } + + return parentMap; +}; + +// --------------------------------------------------------------------------- +// MRO-aware method lookup +// --------------------------------------------------------------------------- + +/** + * Look up a method on an owner class, walking the parent chain via HeritageMap + * when the method isn't found on the direct owner. + * + * Respects the 5 per-language MRO strategies: + * - `first-wins`: BFS ancestor walk, first match wins (default) + * - `leftmost-base`: BFS ancestor walk, leftmost base in declaration order wins (C++); + * HeritageMap preserves insertion order matching source declaration, + * so BFS order is equivalent to leftmost-base semantics + * - `c3`: C3-linearized ancestor order, first match wins (Python) + * - `implements-split`: BFS ancestor walk, first match wins (Java/C#) — + * full ambiguity detection for multiple interface defaults + * is handled by computeMRO at graph level + * - `qualified-syntax`: No auto-resolution (Rust) — returns undefined + * + * Uses the `c3Linearize` defined in this file (also consumed by + * mro-processor.ts for graph-level MRO emission) for the `c3` strategy. + * + * Depends only on {@link SemanticModel} + {@link HeritageMap} + an + * {@link MroStrategy} literal — NO dependency on SymbolTable, the language + * registry, or resolution-context, which keeps the `model/` module free of + * cross-layer imports. Callers derive the strategy from their language + * provider before invoking this function. + * + * @internal This is the low-level MRO walker. Exported so call-processor's + * higher-level resolvers (and unit tests) can invoke it directly. Callers + * outside `core/ingestion/` should use the higher-level resolvers in + * call-processor.ts instead of depending on this function. + */ +export const lookupMethodByOwnerWithMRO = ( + ownerNodeId: string, + methodName: string, + heritageMap: HeritageMap, + model: SemanticModel, + strategy: MroStrategy, + argCount?: number, +): SymbolDefinition | undefined => { + // Direct lookup first (child override — no walk needed). + // argCount is threaded through so arity-differing overloads on the direct + // owner can be disambiguated before the MRO walk starts. + const direct = model.methods.lookupMethodByOwner(ownerNodeId, methodName, argCount); + if (direct) return direct; + + // Rust: requires qualified syntax (::method), no auto-resolution + if (strategy === 'qualified-syntax') return undefined; + + // Determine ancestor walk order based on MRO strategy. + // readonly to accept the cached (frozen) c3 linearization without copying. + let ancestors: readonly string[]; + if (strategy === 'c3') { + // C3 linearization (memoized per HeritageMap + // so repeated calls for the same owner within an ingestion run reuse the + // linearization instead of rebuilding the parent map and re-running C3). + // c3Linearize returns ancestors only (excludes the owner itself), + // matching heritageMap.getAncestors() semantics. + const c3Result = getCachedC3Linearization(ownerNodeId, heritageMap); + // Fall back to BFS order if C3 fails (cyclic or inconsistent hierarchy). + // Note: BFS order may not preserve Python MRO semantics in these edge + // cases, but cyclic/inconsistent hierarchies are invalid in Python anyway. + ancestors = c3Result ?? heritageMap.getAncestors(ownerNodeId); + } else { + // first-wins, leftmost-base, implements-split: BFS order via HeritageMap + ancestors = heritageMap.getAncestors(ownerNodeId); + } + + // Walk ancestors in MRO order — first match wins. + // argCount narrows overloaded ancestors the same way as the direct lookup. + for (const ancestorId of ancestors) { + const method = model.methods.lookupMethodByOwner(ancestorId, methodName, argCount); + if (method) return method; + } + + return undefined; +}; diff --git a/gitnexus/src/core/ingestion/model/semantic-model.ts b/gitnexus/src/core/ingestion/model/semantic-model.ts new file mode 100644 index 000000000..9a822f8be --- /dev/null +++ b/gitnexus/src/core/ingestion/model/semantic-model.ts @@ -0,0 +1,193 @@ +/** + * Semantic Model + * + * Top-level orchestrator for all resolution-time data. Owns: + * + * - Three owner-scoped registries (types, methods, fields) + * - A nested SymbolTable (file + callable name indexes) wrapped so + * that `add()` fans out into the registries via the dispatch table + * + * ## DAG direction + * + * gitnexus-shared (NodeLabel) — leaf + * ↑ + * symbol-table.ts — pure file/callable index + * ↑ + * model/type-registry / method-registry / field-registry + * ↑ + * model/registration-table.ts — dispatch table factory + * ↑ + * model/semantic-model.ts — THIS FILE (orchestrator) + * ↑ + * resolve.ts, call-processor.ts, resolution-context.ts, ... + * + * `symbol-table.ts` is a leaf — it never imports from `./model/`. This + * file (semantic-model.ts) is the ONLY place where SymbolTable and the + * owner-scoped registries are composed. Upstream consumers pass around + * the `SemanticModel` interface and reach into `.symbols` for file-scoped + * operations or `.types` / `.methods` / `.fields` for owner-scoped ones. + * + * ## Fan-out via wrapped add() + * + * `createSemanticModel()` creates a pure SymbolTable, creates the three + * registries, builds a dispatch table via `createRegistrationTable`, and + * exposes a SymbolTable-shaped façade whose `add()`: + * + * 1. Calls `rawSymbols.add()` — writes the fileIndex + callable index + * and returns the fully-built `SymbolDefinition`. + * 2. Runs pre-dispatch normalization (`Function`-with-`ownerId` routes + * as `Method`). + * 3. Looks up the dispatch table and invokes the hook, which writes to + * the appropriate owner-scoped registry. + * + * The wrapper is the only place where the two layers are combined. A + * direct `createSymbolTable()` caller (e.g. an isolated unit test) gets + * the pure, registry-free behavior — no surprises, no hidden side + * effects. + */ + +import type { NodeLabel } from 'gitnexus-shared'; +import type { TypeRegistry, MutableTypeRegistry } from './type-registry.js'; +import type { MethodRegistry, MutableMethodRegistry } from './method-registry.js'; +import type { FieldRegistry, MutableFieldRegistry } from './field-registry.js'; +import { createTypeRegistry } from './type-registry.js'; +import { createMethodRegistry } from './method-registry.js'; +import { createFieldRegistry } from './field-registry.js'; +import type { + SymbolTableReader, + SymbolTableWriter, + SymbolDefinition, + AddMetadata, +} from './symbol-table.js'; +import { createSymbolTable } from './symbol-table.js'; +import { createRegistrationTable } from './registration-table.js'; + +// --------------------------------------------------------------------------- +// Public read-only interface +// --------------------------------------------------------------------------- + +/** + * Aggregated read-only view of the semantic registries plus the nested + * file/callable SymbolTable. + * + * `symbols` is typed as {@link SymbolTableReader} — consumers can query + * symbols but cannot register new ones or trigger a reset. Callers that + * need to register symbols or reset state must hold a + * {@link MutableSemanticModel} reference instead, which widens + * `symbols` back to {@link SymbolTableWriter} and adds `clear()` on the + * model itself. + * + * This segregation is the runtime half of the principle of least + * authority: a resolver that receives `SemanticModel` physically cannot + * mutate the index, so it cannot desync the leaf from the owner-scoped + * registries even accidentally. + */ +export interface SemanticModel { + readonly types: TypeRegistry; + readonly methods: MethodRegistry; + readonly fields: FieldRegistry; + readonly symbols: SymbolTableReader; +} + +// --------------------------------------------------------------------------- +// Mutable interface +// --------------------------------------------------------------------------- + +/** Mutable variant — exposes the MutableX registries, a Writer-typed + * `symbols` facade, and a full-cascade reset. This is the interface + * held by the lifecycle owner (pipeline, resolution-context); resolvers + * that only query should hold the narrower {@link SemanticModel}. */ +export interface MutableSemanticModel extends SemanticModel { + readonly types: MutableTypeRegistry; + readonly methods: MutableMethodRegistry; + readonly fields: MutableFieldRegistry; + readonly symbols: SymbolTableWriter; + /** Clear all registries AND the nested SymbolTable. */ + clear(): void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- +// +// NodeLabel taxonomy drift detection lives in `registration-table.ts` as a +// pure compile-time check — the `LABEL_BEHAVIOR` map is +// `Record` with `as const satisfies`, which proves +// coverage, uniqueness, and no-extra-keys at build time. No runtime guard +// is needed because drift is structurally impossible in the source. + +export const createSemanticModel = (): MutableSemanticModel => { + // 1. Create the pure, registry-unaware SymbolTable leaf. + // rawSymbols is the only handle in the codebase whose type (the + // internal createSymbolTable return) includes `.clear()`. cascadeClear + // below reaches it here; no external caller receives this variable. + const rawSymbols = createSymbolTable(); + + // 2. Create the three owner-scoped registries. + const types = createTypeRegistry(); + const methods = createMethodRegistry(); + const fields = createFieldRegistry(); + + // 3. Build the dispatch table, closed over THIS instance's registries. + const dispatchTable = createRegistrationTable({ types, methods, fields }); + + // 4. Wrap rawSymbols so `add()` fans out into the registries via the + // dispatch table. See module JSDoc for the three-step contract. + const wrappedAdd = ( + filePath: string, + name: string, + nodeId: string, + type: NodeLabel, + metadata?: AddMetadata, + ): SymbolDefinition => { + const def = rawSymbols.add(filePath, name, nodeId, type, metadata); + + // Function-with-ownerId (Python `def` in a class body, Rust trait + // method, Kotlin companion method) routes as Method. Keeps the + // dispatch table single-purpose. + const dispatchKey: NodeLabel = + type === 'Function' && metadata?.ownerId !== undefined ? 'Method' : type; + + const hook = dispatchTable.get(dispatchKey); + if (hook) { + hook(name, def); + } + + return def; + }; + + // Cascade clear: single source of truth for "reset the entire model". + // Wired into both `model.clear()` AND `model.symbols.clear()` so that a + // caller holding only a SymbolTable reference can't leave the + // owner-scoped registries populated while the file/callable indexes go + // empty (the phantom-resolution failure mode). + const cascadeClear = (): void => { + types.clear(); + methods.clear(); + fields.clear(); + rawSymbols.clear(); + }; + + // Writer-typed facade: exposes reads + add, but NO `clear` field. + // Callers holding a `SemanticModel.symbols` reference cannot desync + // the leaf indexes from the owner-scoped registries. Consumers that + // only query should widen their annotation to SymbolTableReader for + // least-authority clarity. + const symbols: SymbolTableWriter = { + add: wrappedAdd, + lookupExact: rawSymbols.lookupExact, + lookupExactFull: rawSymbols.lookupExactFull, + lookupExactAll: rawSymbols.lookupExactAll, + lookupCallableByName: rawSymbols.lookupCallableByName, + getFiles: rawSymbols.getFiles, + getStats: rawSymbols.getStats, + }; + + return { + types, + methods, + fields, + symbols, + clear: cascadeClear, + }; +}; diff --git a/gitnexus/src/core/ingestion/model/symbol-table.ts b/gitnexus/src/core/ingestion/model/symbol-table.ts new file mode 100644 index 000000000..046998778 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/symbol-table.ts @@ -0,0 +1,381 @@ +/** + * Symbol Table — file-indexed + callable-name symbol storage. + * + * This module is a PURE LEAF in the ingestion DAG. It owns two orthogonal + * O(1) indexes: + * + * 1. fileIndex — Map> + * for same-file lookups (Tier 1 resolution) + * 2. callableByName — Map + * for name-keyed callable lookups (Tier 3 widen) + * + * SymbolTable deliberately knows NOTHING about the owner-scoped registries + * (types, methods, fields) that sit above it in the DAG. Those registries + * live in `model/` and depend on SymbolTable, not the other way around. + * {@link createSemanticModel} composes this pure SymbolTable with the + * registries and wraps `add()` to fan out registrations into both layers. + * + * DAG direction (strictly enforced): + * + * gitnexus-shared (NodeLabel) — leaf type + * ↑ + * symbol-table.ts — THIS FILE (pure storage) + * ↑ + * model/type-registry.ts, method-registry.ts, field-registry.ts + * ↑ + * model/registration-table.ts — dispatch table factory + * ↑ + * model/semantic-model.ts — orchestrator, wraps add() + * ↑ + * model/resolve.ts, call-processor.ts, resolution-context.ts, ... + * + * No arrow ever points downward from this file. If you are tempted to + * import from `./model/` here, you are going the wrong way — move the + * logic up the DAG instead. + */ + +import type { NodeLabel } from 'gitnexus-shared'; + +/** + * Class-like NodeLabels — used for qualifiedName fallback inside + * `SymbolTable.add()` and (via import into `model/registration-table.ts`) + * as the single source of truth for which labels route to classHook + * in the dispatch table. + * + * Exported as a `readonly` tuple so that `typeof CLASS_TYPES_TUPLE[number]` + * yields a precise literal union (`ClassLikeLabel`). The model layer + * imports this tuple and uses `Record` in a + * `satisfies` intersection to enforce at COMPILE TIME that every label + * listed here is also classified as dispatch in `LABEL_BEHAVIOR`. Adding + * a new class-like label to this tuple without updating `LABEL_BEHAVIOR` + * fails TypeScript. + * + * Traits are class-like for heritage resolution: PHP `use Trait;`, Rust + * `impl Trait for Struct`, and Scala traits all contribute methods to the + * hierarchy of their using/implementing type. + */ +export const CLASS_TYPES_TUPLE = [ + 'Class', + 'Struct', + 'Interface', + 'Enum', + 'Record', + 'Trait', +] as const satisfies readonly NodeLabel[]; + +export type ClassLikeLabel = (typeof CLASS_TYPES_TUPLE)[number]; + +export const CLASS_TYPES: ReadonlySet = new Set(CLASS_TYPES_TUPLE); + +/** Free-callable labels — single source of truth for "callables that have + * NO owner scope". Methods and constructors are owner-scoped and live in + * `MethodRegistry` — Tier 3 reaches them via + * `model.methods.lookupMethodByName`. See `resolution-context.ts` Tier 3 + * for how both indexes are consulted together. + * + * Exported as a `readonly` tuple so that `typeof FREE_CALLABLE_TUPLE[number]` + * yields a precise literal union (`FreeCallableLabel`). `registration-table.ts` + * imports this type and uses `Record` in + * a `satisfies` intersection to enforce at COMPILE TIME that every label + * listed here is also classified as `callable-only` in `LABEL_BEHAVIOR`. + * Adding a label to this tuple without updating `LABEL_BEHAVIOR` fails + * TypeScript. + * + * Partial-state caveat: Python/Rust/Kotlin class methods are emitted by + * the worker as `Function` + `ownerId` (not `Method`), so they still land + * here via the `Function` entry. Collapsing those three languages onto the + * `Method` label is pending a `def.type` preservation decision. + */ +export const FREE_CALLABLE_TUPLE = [ + 'Function', + 'Macro', // C/C++ + 'Delegate', // C# +] as const satisfies readonly NodeLabel[]; + +export type FreeCallableLabel = (typeof FREE_CALLABLE_TUPLE)[number]; + +export const FREE_CALLABLE_TYPES: ReadonlySet = new Set(FREE_CALLABLE_TUPLE); + +/** Symbol types that can be the TARGET of a call in the resolver's kind + * filter — superset of {@link FREE_CALLABLE_TYPES} that also admits + * owner-scoped methods and constructors pulled in from `MethodRegistry`. + * + * Why the split: `FREE_CALLABLE_TYPES` now has a narrow meaning (free + * callables indexed in `callableByName`), but call resolution still + * needs to accept Method and Constructor candidates once they have been + * unioned in from `model.methods.lookupMethodByName`. The resolver uses + * this constant for kind filtering in + * `filterCallableCandidates` / `countCallableCandidates`. + */ +export const CALL_TARGET_TYPES: ReadonlySet = new Set([ + ...FREE_CALLABLE_TYPES, + 'Method', + 'Constructor', +]); + +export interface SymbolDefinition { + nodeId: string; + filePath: string; + type: NodeLabel; + /** Canonical dot-separated qualified type name for class-like symbols + * (e.g. `App.Models.User`). Falls back to the simple symbol name when no + * package/namespace/module scope exists or no explicit qualified metadata is provided. */ + qualifiedName?: string; + parameterCount?: number; + /** Number of required (non-optional, non-default) parameters. + * Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */ + requiredParameterCount?: number; + /** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']). + * Populated when parameter types are resolvable from AST (any typed language). */ + parameterTypes?: string[]; + /** Raw return type text extracted from AST (e.g. 'User', 'Promise') */ + returnType?: string; + /** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List') */ + declaredType?: string; + /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ + ownerId?: string; +} + +/** + * Optional metadata accepted by {@link SymbolTable.add}. Kept as a separate + * type alias so callers and wrappers can share the same shape. + */ +export interface AddMetadata { + parameterCount?: number; + requiredParameterCount?: number; + parameterTypes?: string[]; + returnType?: string; + declaredType?: string; + ownerId?: string; + qualifiedName?: string; +} + +/** + * Pure read-only view over the file and callable indexes. Does NOT + * include `add()` or `clear()`. + * + * Used by consumers that only query symbols (resolvers, type-env, field + * extractors). The interface is strictly observational — holding a + * `SymbolTableReader` cannot mutate the table in any way. + * + * For consumers that also need to register symbols, use + * {@link SymbolTableWriter}, which extends this interface with `add()`. + * Neither interface exposes `clear()` — that capability lives on the + * internal factory return type and is reachable only inside + * `SemanticModel` via `rawSymbols`. + * + * Segregating the observer contract from the mutation contract means + * callers holding only a Reader can never desync the model. + */ +export interface SymbolTableReader { + /** + * High Confidence: Look for a symbol specifically inside a file. + * Returns the Node ID if found. + */ + lookupExact: (filePath: string, name: string) => string | undefined; + + /** + * High Confidence: Look for a symbol in a specific file, returning full definition. + * Returns first matching definition — use lookupExactAll for overloaded methods. + */ + lookupExactFull: (filePath: string, name: string) => SymbolDefinition | undefined; + + /** + * High Confidence: Look for ALL symbols with this name in a specific file. + * Returns all definitions, including overloaded methods with the same name. + * The returned array is a view into the live internal index — callers + * MUST NOT mutate it. Use `readonly` to enforce this at the type level. + */ + lookupExactAll: (filePath: string, name: string) => readonly SymbolDefinition[]; + + /** + * Look up callable symbols (Function, Macro, Delegate) by name. + * O(1) via dedicated eagerly-populated index keyed by symbol name. + * Returned array is a view into the live index — do not mutate. + */ + lookupCallableByName: (name: string) => readonly SymbolDefinition[]; + + /** + * Iterate all indexed file paths. + * Used by Tier 2b (package-scoped) resolution to walk files matching a + * package directory suffix without a global name scan. + */ + getFiles: () => IterableIterator; + + /** + * Debugging: See how many files are tracked. + */ + getStats: () => { + fileCount: number; + }; +} + +/** + * Writer view — reads + symbol registration. Does NOT include `clear()`. + * + * `MutableSemanticModel.symbols` is typed as this interface, so the + * lifecycle owner can register symbols and query them. Full-model + * resets flow through `model.clear()`. + * + * The cascading `clear()` capability lives exclusively on the internal + * factory return type ({@link createSymbolTable}) — a private handle + * held only by `SemanticModel` via `rawSymbols`. + */ +export interface SymbolTableWriter extends SymbolTableReader { + /** + * Register a symbol in the file and (if callable) name-keyed indexes. + * + * Returns the constructed {@link SymbolDefinition} so higher-layer + * wrappers (e.g. `createSemanticModel`) can reuse it without rebuilding + * the def. This keeps the fan-out in one allocation. + */ + add: ( + filePath: string, + name: string, + nodeId: string, + type: NodeLabel, + metadata?: AddMetadata, + ) => SymbolDefinition; +} + +/** + * Internal return type for {@link createSymbolTable} — extends the + * writer with `clear()`. This capability is intentionally NOT exported + * as a named interface; consumers should hold a `SymbolTableReader` or + * `SymbolTableWriter` instead. + * + * `SemanticModel`'s constructor is the only caller of `createSymbolTable`, + * and it retains the returned handle as the private `rawSymbols` + * reference so `cascadeClear` can reach `clear()`. Every other consumer + * receives the narrower `SymbolTableWriter` facade on `model.symbols`. + */ +interface InternalSymbolTable extends SymbolTableWriter { + /** + * Cleanup memory. Clears only the file and callable indexes owned here — + * owner-scoped registries are cleared by their respective owners via + * `model.clear()`. + */ + clear: () => void; +} + +export const createSymbolTable = (): InternalSymbolTable => { + // 1. File-Specific Index — stores full SymbolDefinition(s) for O(1) lookup. + // Structure: FilePath -> (SymbolName -> SymbolDefinition[]) + // Array allows overloaded methods (same name, different signatures) to coexist. + const fileIndex = new Map>(); + + // 2. Eagerly-populated Callable Index — maintained on add(). + // Structure: SymbolName -> [Callable Definitions] + // Only Function, Method, Constructor, Macro, Delegate symbols are indexed. + const callableByName = new Map(); + + const add = ( + filePath: string, + name: string, + nodeId: string, + type: NodeLabel, + metadata?: AddMetadata, + ): SymbolDefinition => { + const qualifiedName = CLASS_TYPES.has(type) + ? (metadata?.qualifiedName ?? name) + : metadata?.qualifiedName; + const def: SymbolDefinition = { + nodeId, + filePath, + type, + ...(qualifiedName !== undefined ? { qualifiedName } : {}), + ...(metadata?.parameterCount !== undefined + ? { parameterCount: metadata.parameterCount } + : {}), + ...(metadata?.requiredParameterCount !== undefined + ? { requiredParameterCount: metadata.requiredParameterCount } + : {}), + ...(metadata?.parameterTypes !== undefined + ? { parameterTypes: metadata.parameterTypes } + : {}), + ...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}), + ...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}), + ...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}), + }; + + // A. File Index — unconditional. + if (!fileIndex.has(filePath)) { + fileIndex.set(filePath, new Map()); + } + const fileMap = fileIndex.get(filePath)!; + if (!fileMap.has(name)) { + fileMap.set(name, [def]); + } else { + fileMap.get(name)!.push(def); + } + + // B. Callable Index — gated by FREE_CALLABLE_TYPES. + // Note: Property is NOT in FREE_CALLABLE_TYPES, so it never lands here. + // This is the single source of truth for callable-index membership; + // the higher-layer dispatch table only decides owner-scoped routing. + // + // Fallback: `Method` or `Constructor` without an `ownerId` is an + // extractor contract violation (AST-degraded parse, or a buggy + // language extractor). The owner-scoped dispatch hook silently + // skips such defs because it has no owner to key them under, so + // without this fallback they would be invisible at Tier 3 global + // resolution. Route them through `callableByName` so they remain + // reachable by name — matching pre-dispatch-table behavior. + const isOrphanedOwnerScoped = + (type === 'Method' || type === 'Constructor') && metadata?.ownerId === undefined; + if (FREE_CALLABLE_TYPES.has(type) || isOrphanedOwnerScoped) { + const existing = callableByName.get(name); + if (existing) { + existing.push(def); + } else { + callableByName.set(name, [def]); + } + } + + return def; + }; + + const lookupExact = (filePath: string, name: string): string | undefined => { + const defs = fileIndex.get(filePath)?.get(name); + return defs?.[0]?.nodeId; + }; + + const lookupExactFull = (filePath: string, name: string): SymbolDefinition | undefined => { + const defs = fileIndex.get(filePath)?.get(name); + return defs?.[0]; + }; + + const lookupExactAll = (filePath: string, name: string): SymbolDefinition[] => { + return fileIndex.get(filePath)?.get(name) ?? []; + }; + + const lookupCallableByName = (name: string): SymbolDefinition[] => { + return callableByName.get(name) ?? []; + }; + + /** Returns a live iterator over all indexed file paths (fileIndex.keys()). + * The iterator is invalidated if add() changes fileIndex.size during + * iteration (ES2015 Map spec). Safe in the current pipeline because all + * symbols are added before resolution begins. */ + const getFiles = (): IterableIterator => fileIndex.keys(); + + const getStats = () => ({ + fileCount: fileIndex.size, + }); + + const clear = () => { + fileIndex.clear(); + callableByName.clear(); + }; + + return { + add, + lookupExact, + lookupExactFull, + lookupExactAll, + lookupCallableByName, + getFiles, + getStats, + clear, + }; +}; diff --git a/gitnexus/src/core/ingestion/model/type-registry.ts b/gitnexus/src/core/ingestion/model/type-registry.ts new file mode 100644 index 000000000..95d52dbc1 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/type-registry.ts @@ -0,0 +1,113 @@ +/** + * Type Registry + * + * Class/struct/interface index extracted from SymbolTable. + * Eagerly-populated indexes keyed by symbol name and qualified name. + * Also includes a separate index for Rust Impl blocks. + */ + +import type { SymbolDefinition } from './symbol-table.js'; + +// --------------------------------------------------------------------------- +// Public read-only interface +// --------------------------------------------------------------------------- + +export interface TypeRegistry { + /** + * Look up class-like definitions (Class, Struct, Interface, Enum, Record, Trait) + * by simple name. Returns all matching definitions across files + * (e.g. partial classes). Returned array is a view into the live + * internal index — do not mutate. + */ + lookupClassByName(name: string): readonly SymbolDefinition[]; + + /** + * Look up class-like definitions by canonical qualified name. + * Qualified names are normalized to dot-separated scope segments across languages, + * e.g. `App.Models.User`, `com.example.User`, or `Admin.User`. + * Returned array is a view into the live index — do not mutate. + */ + lookupClassByQualifiedName(qualifiedName: string): readonly SymbolDefinition[]; + + /** + * Look up Impl nodes by name. Used by Tier 3 resolution to include Rust + * impl blocks alongside class-like candidates. + * Returned array is a view into the live index — do not mutate. + */ + lookupImplByName(name: string): readonly SymbolDefinition[]; +} + +// --------------------------------------------------------------------------- +// Mutable interface (used internally by SymbolTable.add / clear) +// --------------------------------------------------------------------------- + +export interface MutableTypeRegistry extends TypeRegistry { + /** Register a class-like type by name and qualified name. */ + registerClass(name: string, qualifiedName: string, def: SymbolDefinition): void; + /** Register a Rust Impl block by name. */ + registerImpl(name: string, def: SymbolDefinition): void; + /** Clear all entries. */ + clear(): void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export const createTypeRegistry = (): MutableTypeRegistry => { + const classByName = new Map(); + const classByQualifiedName = new Map(); + const implByName = new Map(); + + const lookupClassByName = (name: string): SymbolDefinition[] => { + return classByName.get(name) ?? []; + }; + + const lookupClassByQualifiedName = (qualifiedName: string): SymbolDefinition[] => { + return classByQualifiedName.get(qualifiedName) ?? []; + }; + + const lookupImplByName = (name: string): SymbolDefinition[] => { + return implByName.get(name) ?? []; + }; + + const registerClass = (name: string, qualifiedName: string, def: SymbolDefinition): void => { + const existing = classByName.get(name); + if (existing) { + existing.push(def); + } else { + classByName.set(name, [def]); + } + + const qualifiedMatches = classByQualifiedName.get(qualifiedName); + if (qualifiedMatches) { + qualifiedMatches.push(def); + } else { + classByQualifiedName.set(qualifiedName, [def]); + } + }; + + const registerImpl = (name: string, def: SymbolDefinition): void => { + const existing = implByName.get(name); + if (existing) { + existing.push(def); + } else { + implByName.set(name, [def]); + } + }; + + const clear = (): void => { + classByName.clear(); + classByQualifiedName.clear(); + implByName.clear(); + }; + + return { + lookupClassByName, + lookupClassByQualifiedName, + lookupImplByName, + registerClass, + registerImpl, + clear, + }; +}; diff --git a/gitnexus/src/core/ingestion/mro-processor.ts b/gitnexus/src/core/ingestion/mro-processor.ts index e44fbd3e7..f73da20fc 100644 --- a/gitnexus/src/core/ingestion/mro-processor.ts +++ b/gitnexus/src/core/ingestion/mro-processor.ts @@ -23,6 +23,7 @@ import { KnowledgeGraph } from '../graph/types.js'; import { generateId } from '../../lib/utils.js'; import { SupportedLanguages } from 'gitnexus-shared'; import { getProvider } from './languages/index.js'; +import { c3Linearize, gatherAncestors } from './model/resolve.js'; // --------------------------------------------------------------------------- // Public types @@ -93,115 +94,9 @@ function buildAdjacency(graph: KnowledgeGraph) { return { parentMap, methodMap, parentEdgeType }; } -/** - * Gather all ancestor IDs in BFS / topological order. - * Returns the linearized list of ancestor IDs (excluding the class itself). - */ -function gatherAncestors(classId: string, parentMap: Map): string[] { - const visited = new Set(); - const order: string[] = []; - const queue: string[] = [...(parentMap.get(classId) ?? [])]; - - while (queue.length > 0) { - const id = queue.shift()!; - if (visited.has(id)) continue; - visited.add(id); - order.push(id); - const grandparents = parentMap.get(id); - if (grandparents) { - for (const gp of grandparents) { - if (!visited.has(gp)) queue.push(gp); - } - } - } - - return order; -} - -// --------------------------------------------------------------------------- -// C3 linearization (Python MRO) -// --------------------------------------------------------------------------- - -/** - * Compute C3 linearization for a class given a parentMap. - * Returns an array of ancestor IDs in C3 order (excluding the class itself), - * or null if linearization fails (inconsistent or cyclic hierarchy). - */ -export function c3Linearize( - classId: string, - parentMap: Map, - cache: Map, - inProgress?: Set, -): string[] | null { - if (cache.has(classId)) return cache.get(classId)!; - - // Cycle detection: if we're already computing this class, the hierarchy is cyclic - const visiting = inProgress ?? new Set(); - if (visiting.has(classId)) { - cache.set(classId, null); - return null; - } - visiting.add(classId); - - const directParents = parentMap.get(classId); - if (!directParents || directParents.length === 0) { - visiting.delete(classId); - cache.set(classId, []); - return []; - } - - // Compute linearization for each parent first - const parentLinearizations: string[][] = []; - for (const pid of directParents) { - const pLin = c3Linearize(pid, parentMap, cache, visiting); - if (pLin === null) { - visiting.delete(classId); - cache.set(classId, null); - return null; - } - parentLinearizations.push([pid, ...pLin]); - } - - // Add the direct parents list as the final sequence - const sequences = [...parentLinearizations, [...directParents]]; - const result: string[] = []; - - while (sequences.some((s) => s.length > 0)) { - // Find a good head: one that doesn't appear in the tail of any other sequence - let head: string | null = null; - for (const seq of sequences) { - if (seq.length === 0) continue; - const candidate = seq[0]; - const inTail = sequences.some( - (other) => other.length > 1 && other.indexOf(candidate, 1) !== -1, - ); - if (!inTail) { - head = candidate; - break; - } - } - - if (head === null) { - // Inconsistent hierarchy - visiting.delete(classId); - cache.set(classId, null); - return null; - } - - result.push(head); - - // Remove the chosen head from all sequences - for (const seq of sequences) { - if (seq.length > 0 && seq[0] === head) { - seq.shift(); - } - } - } - - visiting.delete(classId); - cache.set(classId, result); - return result; -} +// `gatherAncestors` and `c3Linearize` live in `./model/resolve.ts` and +// are imported at the top of this file for internal use by `computeMRO` +// and the method-override edge emitter. // --------------------------------------------------------------------------- // Language-specific resolution diff --git a/gitnexus/src/core/ingestion/named-binding-processor.ts b/gitnexus/src/core/ingestion/named-binding-processor.ts deleted file mode 100644 index 4340bf9e4..000000000 --- a/gitnexus/src/core/ingestion/named-binding-processor.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { SymbolTable, SymbolDefinition } from './symbol-table.js'; -import type { NamedImportMap } from './import-processor.js'; - -/** - * Walk a named-binding re-export chain through NamedImportMap. - * - * When file A imports { User } from B, and B re-exports { User } from C, - * the NamedImportMap for A points to B, but B has no User definition. - * This function follows the chain: A→B→C until a definition is found. - * - * Returns the definitions found at the end of the chain, or null if the - * chain breaks (missing binding, circular reference, or depth exceeded). - * Max depth 5 to prevent infinite loops. - */ -export function walkBindingChain( - name: string, - currentFilePath: string, - symbolTable: SymbolTable, - namedImportMap: NamedImportMap, -): SymbolDefinition[] | null { - let lookupFile = currentFilePath; - let lookupName = name; - const visited = new Set(); - - for (let depth = 0; depth < 5; depth++) { - const bindings = namedImportMap.get(lookupFile); - if (!bindings) return null; - - const binding = bindings.get(lookupName); - if (!binding) return null; - - const key = `${binding.sourcePath}:${binding.exportedName}`; - if (visited.has(key)) return null; // circular - visited.add(key); - - const targetName = binding.exportedName; - const resolvedDefs = symbolTable.lookupExactAll(binding.sourcePath, targetName); - - if (resolvedDefs.length > 0) return resolvedDefs; - - // No definition in source file → follow re-export chain - lookupFile = binding.sourcePath; - lookupName = targetName; - } - - return null; -} diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index f37f1a52f..9cf8394fe 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -4,7 +4,9 @@ import Parser from 'tree-sitter'; import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js'; import { getProvider } from './languages/index.js'; import { generateId } from '../../lib/utils.js'; -import type { SymbolTable } from './symbol-table.js'; +import type { SymbolTableReader, SymbolTableWriter } from './model/symbol-table.js'; +// SymbolTableReader is used for the FieldExtractorContext stub; the +// parsing functions themselves need Writer because they call .add(). import { ASTCache } from './ast-cache.js'; import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; import { extractVueScript, isVueSetupTopLevel } from './vue-sfc-extractor.js'; @@ -36,7 +38,6 @@ import type { ExtractedImport, ExtractedCall, ExtractedAssignment, - ExtractedHeritage, ExtractedRoute, ExtractedFetchCall, ExtractedDecoratorRoute, @@ -45,6 +46,7 @@ import type { FileScopeBindings, ExtractedORMQuery, } from './workers/parse-worker.js'; +import type { ExtractedHeritage } from './model/heritage-map.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js'; export type FileProgressCallback = (current: number, total: number, filePath: string) => void; @@ -70,7 +72,7 @@ export interface WorkerExtractedData { const processParsingWithWorkers = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], - symbolTable: SymbolTable, + symbolTable: SymbolTableWriter, astCache: ASTCache, workerPool: WorkerPool, onFileProgress?: FileProgressCallback, @@ -250,13 +252,19 @@ function seqFindEnclosingClassNode(node: SyntaxNode): SyntaxNode | null { return null; } -/** Minimal no-op SymbolTable stub for FieldExtractorContext (sequential path has a real - * SymbolTable, but it's incomplete at this stage — use the stub for safety). */ -const NOOP_SYMBOL_TABLE_SEQ = { - lookupExactAll: () => [], +/** Minimal no-op SymbolTable stub for FieldExtractorContext (sequential + * path has a real SymbolTable, but it's incomplete at this stage — use + * the stub for safety). Implements the full {@link SymbolTableReader} + * surface so future extractor additions don't silently fall off an + * `as unknown as` cast. */ +const NOOP_SYMBOL_TABLE_SEQ: SymbolTableReader = { lookupExact: () => undefined, lookupExactFull: () => undefined, -} as unknown as SymbolTable; + lookupExactAll: () => [], + lookupCallableByName: () => [], + getFiles: () => [][Symbol.iterator](), + getStats: () => ({ fileCount: 0 }), +}; function seqGetFieldInfo( classNode: SyntaxNode, @@ -278,7 +286,7 @@ function seqGetFieldInfo( const processParsingSequential = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], - symbolTable: SymbolTable, + symbolTable: SymbolTableWriter, astCache: ASTCache, onFileProgress?: FileProgressCallback, ) => { @@ -650,7 +658,7 @@ const processParsingSequential = async ( export const processParsing = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], - symbolTable: SymbolTable, + symbolTable: SymbolTableWriter, astCache: ASTCache, onFileProgress?: FileProgressCallback, workerPool?: WorkerPool, diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 42c630289..c3776d50a 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -27,7 +27,7 @@ import { type ExportedTypeMap, buildExportedTypeMapFromGraph, } from './call-processor.js'; -import { buildHeritageMap } from './heritage-map.js'; +import { buildHeritageMap } from './model/heritage-map.js'; import { nextjsFileToRouteURL, normalizeFetchURL } from './route-extractors/nextjs.js'; import { expoFileToRouteURL } from './route-extractors/expo.js'; import { phpFileToRouteURL } from './route-extractors/php.js'; @@ -47,21 +47,22 @@ import type { ExtractedCall, ExtractedDecoratorRoute, ExtractedFetchCall, - ExtractedHeritage, ExtractedORMQuery, ExtractedRoute, ExtractedToolDef, FileConstructorBindings, } from './workers/parse-worker.js'; +import type { ExtractedHeritage } from './model/heritage-map.js'; import { processHeritage, processHeritageFromExtracted, extractExtractedHeritageFromFiles, + getHeritageStrategyForLanguage, } from './heritage-processor.js'; import { computeMRO } from './mro-processor.js'; import { processCommunities } from './community-processor.js'; import { processProcesses } from './process-processor.js'; -import { createResolutionContext } from './resolution-context.js'; +import { createResolutionContext } from './model/resolution-context.js'; import { createASTCache } from './ast-cache.js'; import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared'; import { PipelineResult } from '../../types/pipeline.js'; @@ -334,7 +335,7 @@ async function runCrossFileBindingPropagation( // For the worker path, buildTypeEnv runs inside workers without SymbolTable, // so exported bindings must be collected from graph + SymbolTable in main thread. if (exportedTypeMap.size === 0 && graph.nodeCount > 0) { - const graphExports = buildExportedTypeMapFromGraph(graph, ctx.symbols); + const graphExports = buildExportedTypeMapFromGraph(graph, ctx.model.symbols); for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports); } @@ -361,7 +362,7 @@ async function runCrossFileBindingPropagation( filesWithGaps++; break; } - const def = ctx.symbols.lookupExactFull(binding.sourcePath, binding.exportedName); + const def = ctx.model.symbols.lookupExactFull(binding.sourcePath, binding.exportedName); if (def?.returnType) { filesWithGaps++; break; @@ -413,11 +414,15 @@ async function runCrossFileBindingPropagation( } } - const importedReturns = buildImportedReturnTypes(filePath, ctx.namedImportMap, ctx.symbols); + const importedReturns = buildImportedReturnTypes( + filePath, + ctx.namedImportMap, + ctx.model.symbols, + ); const importedRawReturns = buildImportedRawReturnTypes( filePath, ctx.namedImportMap, - ctx.symbols, + ctx.model.symbols, ); if (seeded.size === 0 && importedReturns.size === 0) continue; if (!allPathSet.has(filePath)) continue; @@ -657,7 +662,7 @@ async function runChunkedParseAndResolve( allORMQueries: ExtractedORMQuery[]; bindingAccumulator: BindingAccumulator; }> { - const symbolTable = ctx.symbols; + const symbolTable = ctx.model.symbols; const parseableScanned = scannedFiles.filter((f) => { const lang = getLanguageFromFilename(f.path); @@ -978,7 +983,9 @@ async function runChunkedParseAndResolve( // Build unified HeritageMap (parent lookup + implementor index) after all chunks. const fullWorkerHeritageMap = - deferredWorkerHeritage.length > 0 ? buildHeritageMap(deferredWorkerHeritage, ctx) : undefined; + deferredWorkerHeritage.length > 0 + ? buildHeritageMap(deferredWorkerHeritage, ctx, getHeritageStrategyForLanguage) + : undefined; if (deferredWorkerCalls.length > 0) { await processCallsFromExtracted( @@ -1058,7 +1065,9 @@ async function runChunkedParseAndResolve( } // Build unified HeritageMap from all sequential heritage (parent lookup + implementor index). const sequentialHeritageMap = - allSequentialHeritage.length > 0 ? buildHeritageMap(allSequentialHeritage, ctx) : undefined; + allSequentialHeritage.length > 0 + ? buildHeritageMap(allSequentialHeritage, ctx, getHeritageStrategyForLanguage) + : undefined; // Pass 2: Process calls, heritage edges, fetch calls, and ORM queries per chunk. // Reuse the file contents cached in Pass 1 instead of re-reading from disk. diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts deleted file mode 100644 index eb7a62079..000000000 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ /dev/null @@ -1,439 +0,0 @@ -import type { NodeLabel } from 'gitnexus-shared'; - -export const CLASS_TYPES = new Set([ - 'Class', - 'Struct', - 'Interface', - 'Enum', - 'Record', - // Traits are class-like for heritage resolution: PHP `use Trait;`, Rust - // `impl Trait for Struct`, and Scala traits all contribute methods to the - // hierarchy of their using/implementing type. Including Trait here lets - // buildHeritageMap resolve `h.parentName` to a Trait nodeId so the MRO - // walker can visit the trait and find its methods. - 'Trait', -]); - -/** Callable symbol types indexed in callableByName for Tier 3 resolution - * and D2 widen in call-processor.ts. Single source of truth — do not - * duplicate this set elsewhere. */ -export const CALLABLE_TYPES = new Set([ - 'Function', - 'Method', - 'Constructor', - 'Macro', // C/C++ - 'Delegate', // C# -]); - -export interface SymbolDefinition { - nodeId: string; - filePath: string; - type: NodeLabel; - /** Canonical dot-separated qualified type name for class-like symbols - * (e.g. `App.Models.User`). Falls back to the simple symbol name when no - * package/namespace/module scope exists or no explicit qualified metadata is provided. */ - qualifiedName?: string; - parameterCount?: number; - /** Number of required (non-optional, non-default) parameters. - * Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */ - requiredParameterCount?: number; - /** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']). - * Populated when parameter types are resolvable from AST (any typed language). - * Used for disambiguation in overloading languages (Java, Kotlin, C#, C++). */ - parameterTypes?: string[]; - /** Raw return type text extracted from AST (e.g. 'User', 'Promise') */ - returnType?: string; - /** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List') */ - declaredType?: string; - /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ - ownerId?: string; -} - -export interface SymbolTable { - /** - * Register a new symbol definition - */ - add: ( - filePath: string, - name: string, - nodeId: string, - type: NodeLabel, - metadata?: { - parameterCount?: number; - requiredParameterCount?: number; - parameterTypes?: string[]; - returnType?: string; - declaredType?: string; - ownerId?: string; - qualifiedName?: string; - }, - ) => void; - - /** - * High Confidence: Look for a symbol specifically inside a file - * Returns the Node ID if found - */ - lookupExact: (filePath: string, name: string) => string | undefined; - - /** - * High Confidence: Look for a symbol in a specific file, returning full definition. - * Includes type information needed for heritage resolution (Class vs Interface). - * Returns first matching definition — use lookupExactAll for overloaded methods. - */ - lookupExactFull: (filePath: string, name: string) => SymbolDefinition | undefined; - - /** - * High Confidence: Look for ALL symbols with this name in a specific file. - * Returns all definitions, including overloaded methods with the same name. - * Used by resolution-context to pass all same-file overloads to candidate filtering. - */ - lookupExactAll: (filePath: string, name: string) => SymbolDefinition[]; - - /** - * Look up callable symbols (Function, Method, Constructor, Macro, Delegate) by name. - * O(1) via dedicated eagerly-populated index keyed by symbol name. - * Used by Tier 3 resolution and ReturnTypeLookup to resolve callee → return type. - */ - lookupCallableByName: (name: string) => SymbolDefinition[]; - - /** - * Look up a field/property by its owning class nodeId and field name. - * O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0fieldName`. - * Returns undefined when no matching property exists or the owner is ambiguous. - */ - lookupFieldByOwner: (ownerNodeId: string, fieldName: string) => SymbolDefinition | undefined; - - /** - * Look up a method by its owning class nodeId and method name. - * O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0methodName`. - * For overloaded methods (same owner + name): returns the first match when all - * overloads share the same returnType, undefined when return types differ (ambiguous). - * Used by walkMixedChain for deterministic cross-class chain resolution. - */ - /** - * Lookup a method by owner class + name, optionally filtered by arity. - * - * When `argCount` is provided, overloads whose parameter count doesn't - * accommodate the call's argument count are filtered out before the - * returnType dedup runs. This lets D0 (`resolveMemberCall`) disambiguate - * arity-differing overloads (e.g. C++ `greet()` vs `greet(string)`) that - * would otherwise collide on the shared `ownerId + methodName` key. - * - * Same-arity, same-returnType overloads (e.g. `save(int)` vs `save(String)`, - * both returning `void`) still collapse to the first match — callers must - * gate D0 on overload concern before invoking this function for that case. - */ - lookupMethodByOwner: ( - ownerNodeId: string, - methodName: string, - argCount?: number, - ) => SymbolDefinition | undefined; - - /** - * Look up class-like definitions (Class, Struct, Interface, Enum, Record) by name. - * O(1) via dedicated eagerly-populated index keyed by symbol name. - * Returns all matching definitions across files (e.g. partial classes). - * Used by Phase 1 semantic-model tasks to replace filtered global lookups. - */ - lookupClassByName: (name: string) => SymbolDefinition[]; - - /** - * Look up class-like definitions by canonical qualified name. - * Qualified names are normalized to dot-separated scope segments across languages, - * e.g. `App.Models.User`, `com.example.User`, or `Admin.User`. - * Top-level class-like symbols with no explicit scope are indexed under their simple name. - */ - lookupClassByQualifiedName: (qualifiedName: string) => SymbolDefinition[]; - - /** - * Look up Impl nodes by name. - * O(1) via dedicated eagerly-populated index keyed by symbol name. - * Used by Tier 3 resolution to include Rust impl blocks alongside - * class-like candidates so method lookups on `impl User { fn save() }` work - * correctly (Rust methods are indexed under the Impl nodeId, not the Struct). - */ - lookupImplByName: (name: string) => SymbolDefinition[]; - - /** - * Iterate all indexed file paths. - * Used by Tier 2b (package-scoped) resolution to walk files matching a - * package directory suffix without a global name scan. - */ - getFiles: () => IterableIterator; - - /** - * Debugging: See how many symbols are tracked - */ - getStats: () => { - fileCount: number; - }; - - /** - * Cleanup memory - */ - clear: () => void; -} - -export const createSymbolTable = (): SymbolTable => { - // 1. File-Specific Index — stores full SymbolDefinition(s) for O(1) lookup. - // Structure: FilePath -> (SymbolName -> SymbolDefinition[]) - // Array allows overloaded methods (same name, different signatures) to coexist. - const fileIndex = new Map>(); - - // 2. Eagerly-populated Callable Index — maintained on add(). - // Structure: SymbolName -> [Callable Definitions] - // Only Function, Method, Constructor, Macro, Delegate symbols are indexed. - const callableByName = new Map(); - - // 3. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName". - // Only Property symbols with ownerId and declaredType are indexed. - const fieldByOwner = new Map(); - - // 4. Eagerly-populated Method Index — keyed by "ownerNodeId\0methodName". - // Method symbols with ownerId are indexed. Supports overloads (array values). - const methodByOwner = new Map(); - - // 5. Eagerly-populated Class-type Index — keyed by symbol name. - // Only Class, Struct, Interface, Enum, Record symbols are indexed. - const classByName = new Map(); - const classByQualifiedName = new Map(); - - // 6. Eagerly-populated Impl Index — keyed by symbol name. - // Rust impl blocks (type 'Impl') are stored here to keep them out of - // classByName (which drives heritage resolution) while still being - // reachable from Tier 3 resolution for method lookup. - const implByName = new Map(); - - // Use the module-level CALLABLE_TYPES constant (exported for call-processor.ts). - - const add = ( - filePath: string, - name: string, - nodeId: string, - type: NodeLabel, - metadata?: { - parameterCount?: number; - requiredParameterCount?: number; - parameterTypes?: string[]; - returnType?: string; - declaredType?: string; - ownerId?: string; - qualifiedName?: string; - }, - ) => { - const qualifiedName = CLASS_TYPES.has(type) - ? (metadata?.qualifiedName ?? name) - : metadata?.qualifiedName; - const def: SymbolDefinition = { - nodeId, - filePath, - type, - ...(qualifiedName !== undefined ? { qualifiedName } : {}), - ...(metadata?.parameterCount !== undefined - ? { parameterCount: metadata.parameterCount } - : {}), - ...(metadata?.requiredParameterCount !== undefined - ? { requiredParameterCount: metadata.requiredParameterCount } - : {}), - ...(metadata?.parameterTypes !== undefined - ? { parameterTypes: metadata.parameterTypes } - : {}), - ...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}), - ...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}), - ...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}), - }; - - // A. Add to File Index (shared reference — zero additional memory) - if (!fileIndex.has(filePath)) { - fileIndex.set(filePath, new Map()); - } - const fileMap = fileIndex.get(filePath)!; - if (!fileMap.has(name)) { - fileMap.set(name, [def]); - } else { - fileMap.get(name)!.push(def); - } - - // B. Properties go to fieldByOwner index only — skip other indexes to prevent - // namespace pollution for common names like 'id', 'name', 'type'. - // Index ALL properties (even without declaredType) so write-access tracking - // can resolve field ownership for dynamically-typed languages (Ruby, JS). - if (type === 'Property' && metadata?.ownerId) { - fieldByOwner.set(`${metadata.ownerId}\0${name}`, def); - // Still add to fileIndex above (for lookupExact), but skip other indexes - return; - } - - // C. Methods, constructors, and ownerId-bound Functions go to - // methodByOwner index. - // - // Some language extractors emit class methods as `Function` with an - // `ownerId` — notably Python (`def method(self):` inside a class body), - // Rust trait methods, and Kotlin object/companion methods. Treating - // `Function` with ownerId the same as `Method` here makes D0 - // (`resolveMemberCall`) work uniformly across all supported languages - // instead of silently falling through to D1-D4 widening. - if ((type === 'Method' || type === 'Constructor' || type === 'Function') && metadata?.ownerId) { - const key = `${metadata.ownerId}\0${name}`; - const existing = methodByOwner.get(key); - if (existing) { - existing.push(def); - } else { - methodByOwner.set(key, [def]); - } - } - - // C2. Class-like types go to classByName index. - if (CLASS_TYPES.has(type)) { - const existing = classByName.get(name); - if (existing) { - existing.push(def); - } else { - classByName.set(name, [def]); - } - - const qualifiedKey = qualifiedName ?? name; - const qualifiedMatches = classByQualifiedName.get(qualifiedKey); - if (qualifiedMatches) { - qualifiedMatches.push(def); - } else { - classByQualifiedName.set(qualifiedKey, [def]); - } - } - - // C3. Rust Impl blocks go to implByName (separate from classByName to avoid - // polluting heritage resolution with Impl nodes as parent candidates). - if (type === 'Impl') { - const existing = implByName.get(name); - if (existing) { - existing.push(def); - } else { - implByName.set(name, [def]); - } - } - - // D. Eagerly maintain callable index (like classByName, implByName). - if (CALLABLE_TYPES.has(type)) { - const existing = callableByName.get(name); - if (existing) { - existing.push(def); - } else { - callableByName.set(name, [def]); - } - } - }; - - const lookupExact = (filePath: string, name: string): string | undefined => { - const defs = fileIndex.get(filePath)?.get(name); - return defs?.[0]?.nodeId; - }; - - const lookupExactFull = (filePath: string, name: string): SymbolDefinition | undefined => { - const defs = fileIndex.get(filePath)?.get(name); - return defs?.[0]; - }; - - const lookupExactAll = (filePath: string, name: string): SymbolDefinition[] => { - return fileIndex.get(filePath)?.get(name) ?? []; - }; - - const lookupCallableByName = (name: string): SymbolDefinition[] => { - return callableByName.get(name) ?? []; - }; - - const lookupFieldByOwner = ( - ownerNodeId: string, - fieldName: string, - ): SymbolDefinition | undefined => { - return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`); - }; - - const lookupMethodByOwner = ( - ownerNodeId: string, - methodName: string, - argCount?: number, - ): SymbolDefinition | undefined => { - const defs = methodByOwner.get(`${ownerNodeId}\0${methodName}`); - if (!defs || defs.length === 0) return undefined; - - // Arity narrowing: when an argCount is provided and there are multiple - // overloads, keep only those whose parameterCount can accommodate the - // call. This resolves arity-differing overloads (e.g. C++ `greet()` vs - // `greet(string)`) that share the same `ownerId + methodName` key. - // - // Candidates with `parameterCount === undefined` (extractor didn't - // populate the count — typically variadic or unknown) are retained - // conservatively so that legitimate variadic matches still resolve. - let pool = defs; - if (argCount !== undefined && defs.length > 1) { - const arityMatched = defs.filter((d) => { - if (d.parameterCount === undefined) return true; - const min = d.requiredParameterCount ?? d.parameterCount; - return argCount >= min && argCount <= d.parameterCount; - }); - // Only adopt the arity-narrowed pool when it found matches; if arity - // rules out every candidate, fall back to the unfiltered set so the - // caller's fuzzy path still has something to work with. - if (arityMatched.length > 0) pool = arityMatched; - } - - if (pool.length === 1) return pool[0]; - // Multiple overloads after arity narrowing: return first if all share - // the same defined returnType (safe for chain resolution), undefined if - // return types differ (truly ambiguous — can't determine which overload). - const firstReturnType = pool[0].returnType; - if (firstReturnType === undefined) return undefined; - for (let i = 1; i < pool.length; i++) { - if (pool[i].returnType !== firstReturnType) return undefined; - } - return pool[0]; - }; - - const lookupClassByName = (name: string): SymbolDefinition[] => { - return classByName.get(name) ?? []; - }; - - const lookupClassByQualifiedName = (qualifiedName: string): SymbolDefinition[] => { - return classByQualifiedName.get(qualifiedName) ?? []; - }; - - const lookupImplByName = (name: string): SymbolDefinition[] => { - return implByName.get(name) ?? []; - }; - - /** Returns a live iterator over all indexed file paths (fileIndex.keys()). - * The iterator is invalidated if add() changes fileIndex.size during - * iteration (ES2015 Map spec). Safe in the current pipeline because all - * symbols are added before resolution begins. */ - const getFiles = (): IterableIterator => fileIndex.keys(); - - const getStats = () => ({ - fileCount: fileIndex.size, - }); - - const clear = () => { - fileIndex.clear(); - callableByName.clear(); - fieldByOwner.clear(); - methodByOwner.clear(); - classByName.clear(); - classByQualifiedName.clear(); - implByName.clear(); - }; - - return { - add, - lookupExact, - lookupExactFull, - lookupExactAll, - lookupCallableByName, - lookupFieldByOwner, - lookupMethodByOwner, - lookupClassByName, - lookupClassByQualifiedName, - lookupImplByName, - getFiles, - getStats, - clear, - }; -}; diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index c8eba5819..368de762b 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -21,7 +21,7 @@ import { stripNullable, extractReturnTypeName, } from './type-extractors/shared.js'; -import type { SymbolTable } from './symbol-table.js'; +import type { SemanticModel } from './model/semantic-model.js'; import type { NodeLabel } from 'gitnexus-shared'; /** @@ -416,11 +416,8 @@ const findEnclosingScopeKey = ( * Only `.has()` is exposed — the SymbolTable doesn't support iteration. * Results are memoized to avoid redundant class-index scans across declarations. */ -const createClassNameLookup = ( - localNames: Set, - symbolTable?: SymbolTable, -): ClassNameLookup => { - if (!symbolTable) return localNames; +const createClassNameLookup = (localNames: Set, model?: SemanticModel): ClassNameLookup => { + if (!model) return localNames; const memo = new Map(); return { @@ -428,7 +425,7 @@ const createClassNameLookup = ( if (localNames.has(name)) return true; const cached = memo.get(name); if (cached !== undefined) return cached; - const result = symbolTable + const result = model.types .lookupClassByName(name) .some((def) => def.type === 'Class' || def.type === 'Enum' || def.type === 'Struct'); memo.set(name, result); @@ -481,20 +478,20 @@ const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface']); type ClassDefRef = { nodeId: string; type: string; filePath: string }; const lookupClassDefsByName = ( - symbolTable: SymbolTable, + model: SemanticModel, name: string, allowedTypes: ReadonlySet = CLASS_LIKE_TYPES, -): ClassDefRef[] => symbolTable.lookupClassByName(name).filter((d) => allowedTypes.has(d.type)); +): ClassDefRef[] => model.types.lookupClassByName(name).filter((d) => allowedTypes.has(d.type)); /** Memoize class definition lookups during fixpoint iteration. * SymbolTable is immutable during type resolution, so results never change. * Eliminates redundant array allocations + filter scans across iterations. */ -const createClassDefCache = (symbolTable?: SymbolTable) => { +const createClassDefCache = (model?: SemanticModel) => { const cache = new Map(); return (typeName: string) => { let result = cache.get(typeName); if (result === undefined) { - result = symbolTable ? lookupClassDefsByName(symbolTable, typeName) : []; + result = model ? lookupClassDefsByName(model, typeName) : []; cache.set(typeName, result); } return result; @@ -615,22 +612,22 @@ const resolveFieldType = ( receiver: string, field: string, scopeEnv: ReadonlyMap, - symbolTable?: SymbolTable, + model?: SemanticModel, getClassDefs?: (typeName: string) => ClassDefRef[], parentMap?: ReadonlyMap, ): string | undefined => { - if (!symbolTable) return undefined; + if (!model) return undefined; const receiverType = scopeEnv.get(receiver); if (!receiverType) return undefined; - const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name)); + const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(model, name)); const classDefs = lookup(receiverType); if (classDefs.length !== 1) return undefined; // Direct lookup first - const fieldDef = symbolTable.lookupFieldByOwner(classDefs[0].nodeId, field); + const fieldDef = model.fields.lookupFieldByOwner(classDefs[0].nodeId, field); if (fieldDef?.declaredType) return extractReturnTypeName(fieldDef.declaredType); // MRO parent chain walking on miss const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => { - const f = symbolTable.lookupFieldByOwner(nodeId, field); + const f = model.fields.lookupFieldByOwner(nodeId, field); return f?.declaredType ? extractReturnTypeName(f.declaredType) : undefined; }); return inherited; @@ -644,30 +641,30 @@ const resolveMethodReturnType = ( receiver: string, method: string, scopeEnv: ReadonlyMap, - symbolTable?: SymbolTable, + model?: SemanticModel, getClassDefs?: (typeName: string) => ClassDefRef[], parentMap?: ReadonlyMap, ): string | undefined => { - if (!symbolTable) return undefined; + if (!model) return undefined; let receiverType = scopeEnv.get(receiver); // When substituteThisReceiver replaced $this/self with the enclosing class name, // the receiver IS the type — look it up directly as a class name. if (!receiverType) { - const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name)); + const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(model, name)); if (lookup(receiver).length > 0) receiverType = receiver; } if (!receiverType) return undefined; - const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name)); + const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(model, name)); const classDefs = lookup(receiverType); if (classDefs.length === 0) return undefined; // Direct lookup first const directMethodLookups = classDefs.map((d) => ({ classDef: d, - methodDef: symbolTable.lookupMethodByOwner(d.nodeId, method), + methodDef: model.methods.lookupMethodByOwner(d.nodeId, method), })); const hasAmbiguousDirectLookup = directMethodLookups.some(({ classDef, methodDef }) => { if (methodDef) return false; - return symbolTable + return model.symbols .lookupExactAll(classDef.filePath, method) .some((d) => d.ownerId === classDef.nodeId); }); @@ -681,7 +678,7 @@ const resolveMethodReturnType = ( // MRO parent chain walking on miss if (methods.length === 0) { const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => { - const parentMethod = symbolTable.lookupMethodByOwner(nodeId, method); + const parentMethod = model.methods.lookupMethodByOwner(nodeId, method); if (!parentMethod?.returnType) return undefined; return extractReturnTypeName(parentMethod.returnType); }); @@ -707,11 +704,11 @@ const resolveFixpointBindings = ( pendingItems: Array<{ scope: string } & PendingAssignment>, env: TypeEnv, returnTypeLookup: ReturnTypeLookup, - symbolTable?: SymbolTable, + model?: SemanticModel, parentMap?: ReadonlyMap, ): void => { if (pendingItems.length === 0) return; - const getClassDefs = createClassDefCache(symbolTable); + const getClassDefs = createClassDefCache(model); const resolved = new Set(); for (let iter = 0; iter < MAX_FIXPOINT_ITERATIONS; iter++) { let changed = false; @@ -740,7 +737,7 @@ const resolveFixpointBindings = ( item.receiver, item.field, scopeEnv, - symbolTable, + model, getClassDefs, parentMap, ); @@ -750,7 +747,7 @@ const resolveFixpointBindings = ( item.receiver, item.method, scopeEnv, - symbolTable, + model, getClassDefs, parentMap, ); @@ -785,7 +782,7 @@ const resolveFixpointBindings = ( * Uses an options object to allow future extensions without positional parameter sprawl. */ export interface BuildTypeEnvOptions { - symbolTable?: SymbolTable; + model?: SemanticModel; parentMap?: ReadonlyMap; /** Pre-resolved bindings from upstream files (Phase 14). * Seeded into FILE_SCOPE after walk() for names with no local binding. @@ -837,7 +834,7 @@ export const buildTypeEnv = ( enclosingClassNameCache.clear(); enclosingParentClassNameCache.clear(); - const symbolTable = options?.symbolTable; + const model = options?.model; const parentMap = options?.parentMap; const extractFuncNameHook = options?.extractFunctionName; const env: TypeEnv = new Map(); @@ -848,7 +845,7 @@ export const buildTypeEnv = ( // e.g., `Animal a = new Dog()` → constructorTypeMap.set('func@42\0a', 'Dog') const constructorTypeMap = new Map(); const localClassNames = new Set(); - const classNames = createClassNameLookup(localClassNames, symbolTable); + const classNames = createClassNameLookup(localClassNames, model); const provider = getProvider(language); const config = provider.typeConfig; const bindings: ConstructorBinding[] = []; @@ -856,29 +853,47 @@ export const buildTypeEnv = ( // Build ReturnTypeLookup: SymbolTable is authoritative when it has an unambiguous match. // Cross-file importedReturnTypes are consulted ONLY when SymbolTable has 0 matches. // Ambiguous (2+) → undefined, no cross-file fallback (conservative, local-first principle). + // Post-A4 Unit 4: callableByName no longer holds Method/Constructor, so + // for-loop binding inference must also consult methodsByName to find + // return types on class methods (e.g. `user.getItems()` iteration). + // Take `model` as an explicit argument so the non-null precondition + // is visible at the type level. Callers must enter these via an + // `if (model)` guard on their side and pass the narrowed reference. + const getCallableUnionCount = (m: SemanticModel, callee: string): number => { + return ( + m.symbols.lookupCallableByName(callee).length + m.methods.lookupMethodByName(callee).length + ); + }; + const getFirstCallable = (m: SemanticModel, callee: string) => { + const free = m.symbols.lookupCallableByName(callee); + if (free.length > 0) return free[0]; + const methods = m.methods.lookupMethodByName(callee); + return methods.length > 0 ? methods[0] : undefined; + }; + const returnTypeLookup: ReturnTypeLookup = { lookupReturnType(callee: string): string | undefined { // SymbolTable is authoritative when it has an unambiguous match - if (symbolTable) { + if (model) { if (provider.isBuiltInName(callee)) return undefined; - const callables = symbolTable.lookupCallableByName(callee); - if (callables.length === 1) { - const rawReturn = callables[0].returnType; + const count = getCallableUnionCount(model, callee); + if (count === 1) { + const rawReturn = getFirstCallable(model, callee)?.returnType; if (rawReturn) return extractReturnTypeName(rawReturn); } // Ambiguous (2+) → return undefined (conservative, no cross-file fallback) - if (callables.length > 1) return undefined; + if (count > 1) return undefined; } // No match (0 results or no symbolTable) → fall back to cross-file return options?.importedReturnTypes?.get(callee); }, lookupRawReturnType(callee: string): string | undefined { - if (symbolTable) { + if (model) { if (provider.isBuiltInName(callee)) return undefined; - const callables = symbolTable.lookupCallableByName(callee); - if (callables.length === 1) return callables[0].returnType; + const count = getCallableUnionCount(model, callee); + if (count === 1) return getFirstCallable(model, callee)?.returnType; // Ambiguous (2+) → return undefined (conservative, no cross-file fallback) - if (callables.length > 1) return undefined; + if (count > 1) return undefined; } // Cross-file fallback uses importedRawReturnTypes (raw declared types, e.g., 'User[]') // NOT importedReturnTypes (which contains processed/simple types via extractReturnTypeName) @@ -1229,7 +1244,7 @@ export const buildTypeEnv = ( seedImportedBindings(env, options.importedBindings); } - resolveFixpointBindings(pendingItems, env, returnTypeLookup, symbolTable, parentMap); + resolveFixpointBindings(pendingItems, env, returnTypeLookup, model, parentMap); // Post-fixpoint for-loop replay (Phase 10 / ex-9B loop-fixpoint bridge): // For-loop nodes whose iterables were unresolved at walk-time may now be @@ -1256,7 +1271,7 @@ export const buildTypeEnv = ( return scopeEnv && !scopeEnv.has(item.lhs); }); if (unresolvedBefore.length > 0) { - resolveFixpointBindings(unresolvedBefore, env, returnTypeLookup, symbolTable); + resolveFixpointBindings(unresolvedBefore, env, returnTypeLookup, model); } } diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index f229b4cad..32dd32aec 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -15,7 +15,8 @@ import { createRequire } from 'node:module'; import { SupportedLanguages } from 'gitnexus-shared'; import { getProvider } from '../languages/index.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from '../constants.js'; -import type { SymbolTable } from '../symbol-table.js'; +import type { SymbolTableReader } from '../model/symbol-table.js'; +import type { ExtractedHeritage } from '../model/heritage-map.js'; /** Language grammar type accepted by Parser.setLanguage(). */ type TreeSitterLanguage = Parameters[0]; @@ -181,13 +182,8 @@ export interface ExtractedAssignment { receiverTypeName?: string; } -export interface ExtractedHeritage { - filePath: string; - className: string; - parentName: string; - /** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */ - kind: string; -} +// `ExtractedHeritage` now lives in `../model/heritage-map.ts` and is +// re-exported at the top of this file. export interface ExtractedRoute { filePath: string; @@ -459,14 +455,20 @@ function findClassNodeByQualifiedName(node: SyntaxNode): SyntaxNode | null { /** * Minimal no-op SymbolTable stub for FieldExtractorContext in the worker. - * Field extraction only uses symbolTable.lookupExactAll for optional type resolution — - * returning [] causes the extractor to use the raw type string, which is fine for us. + * Field extraction only uses symbolTable.lookupExactAll for optional type + * resolution — returning [] causes the extractor to use the raw type + * string, which is fine for us. Every other method is a no-op so the + * stub remains safe if a future FieldExtractor consults it through the + * full {@link SymbolTableReader} surface. */ -const NOOP_SYMBOL_TABLE = { - lookupExactAll: () => [], +const NOOP_SYMBOL_TABLE: SymbolTableReader = { lookupExact: () => undefined, lookupExactFull: () => undefined, -} as unknown as SymbolTable; + lookupExactAll: () => [], + lookupCallableByName: () => [], + getFiles: () => [][Symbol.iterator](), + getStats: () => ({ fileCount: 0 }), +}; /** * Get (or extract and cache) field info for a class node. diff --git a/gitnexus/test/integration/ignore-and-skip-e2e.test.ts b/gitnexus/test/integration/ignore-and-skip-e2e.test.ts index e244a094c..da5c49042 100644 --- a/gitnexus/test/integration/ignore-and-skip-e2e.test.ts +++ b/gitnexus/test/integration/ignore-and-skip-e2e.test.ts @@ -8,7 +8,7 @@ import { } from '../../src/core/ingestion/filesystem-walker.js'; import { processParsing } from '../../src/core/ingestion/parsing-processor.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js'; import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js'; import { SupportedLanguages } from '../../src/config/supported-languages.js'; diff --git a/gitnexus/test/integration/qualified-class-lookups.test.ts b/gitnexus/test/integration/qualified-class-lookups.test.ts index 1d6fa4fc6..eb628b73a 100644 --- a/gitnexus/test/integration/qualified-class-lookups.test.ts +++ b/gitnexus/test/integration/qualified-class-lookups.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it } from 'vitest'; import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { processParsing } from '../../src/core/ingestion/parsing-processor.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; describe('qualified class lookups', () => { it('derives canonical dot-separated names from namespaces, packages, and modules', async () => { const graph = createKnowledgeGraph(); - const symbolTable = createSymbolTable(); + const model = createSemanticModel(); + // model.symbols is the SymbolTable leaf that processParsing writes into. + // Fan-out writes still reach model.types / model.methods / model.fields + // via SemanticModel's wrappedAdd — this alias is purely for convenience + // at call sites that want the SymbolTable-shaped interface. + const symbolTable = model.symbols; const astCache = createASTCache(); await processParsing( @@ -34,33 +39,38 @@ describe('qualified class lookups', () => { astCache, ); - const userMatches = symbolTable.lookupClassByName('User'); + const userMatches = model.types.lookupClassByName('User'); expect(userMatches).toHaveLength(3); expect(userMatches.map((match) => match.qualifiedName).sort()).toEqual( ['Admin.User', 'Data.Auth.User', 'Services.Auth.User'].sort(), ); - const servicesUser = symbolTable.lookupClassByQualifiedName('Services.Auth.User'); + const servicesUser = model.types.lookupClassByQualifiedName('Services.Auth.User'); expect(servicesUser).toHaveLength(1); expect(servicesUser[0].filePath).toBe('src/Services/User.cs'); expect(servicesUser[0].qualifiedName).toBe('Services.Auth.User'); - const dataUser = symbolTable.lookupClassByQualifiedName('Data.Auth.User'); + const dataUser = model.types.lookupClassByQualifiedName('Data.Auth.User'); expect(dataUser).toHaveLength(1); expect(dataUser[0].filePath).toBe('src/Data/User.cs'); - const javaConfig = symbolTable.lookupClassByQualifiedName('com.example.models.Config'); + const javaConfig = model.types.lookupClassByQualifiedName('com.example.models.Config'); expect(javaConfig).toHaveLength(1); expect(javaConfig[0].qualifiedName).toBe('com.example.models.Config'); - const rubyUser = symbolTable.lookupClassByQualifiedName('Admin.User'); + const rubyUser = model.types.lookupClassByQualifiedName('Admin.User'); expect(rubyUser).toHaveLength(1); expect(rubyUser[0].qualifiedName).toBe('Admin.User'); }); it('falls back to the simple name for top-level class-like symbols', async () => { const graph = createKnowledgeGraph(); - const symbolTable = createSymbolTable(); + const model = createSemanticModel(); + // model.symbols is the SymbolTable leaf that processParsing writes into. + // Fan-out writes still reach model.types / model.methods / model.fields + // via SemanticModel's wrappedAdd — this alias is purely for convenience + // at call sites that want the SymbolTable-shaped interface. + const symbolTable = model.symbols; const astCache = createASTCache(); await processParsing( @@ -70,11 +80,11 @@ describe('qualified class lookups', () => { astCache, ); - const simpleMatches = symbolTable.lookupClassByName('User'); + const simpleMatches = model.types.lookupClassByName('User'); expect(simpleMatches).toHaveLength(1); expect(simpleMatches[0].qualifiedName).toBe('User'); - const matches = symbolTable.lookupClassByQualifiedName('User'); + const matches = model.types.lookupClassByQualifiedName('User'); expect(matches).toHaveLength(1); expect(matches[0].qualifiedName).toBe('User'); }); diff --git a/gitnexus/test/unit/call-form.test.ts b/gitnexus/test/unit/call-form.test.ts index 17e42897e..01b707c13 100644 --- a/gitnexus/test/unit/call-form.test.ts +++ b/gitnexus/test/unit/call-form.test.ts @@ -4,7 +4,7 @@ import { extractReceiverName, } from '../../src/core/ingestion/utils/call-analysis.js'; import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js'; import Parser from 'tree-sitter'; import TypeScript from 'tree-sitter-typescript'; import Python from 'tree-sitter-python'; @@ -452,9 +452,13 @@ describe('ownerId on SymbolDefinition', () => { expect(def!.ownerId).toBeUndefined(); }); - it('propagates ownerId through lookupCallableByName', () => { + it('propagates ownerId through a free Function registration', () => { + // Post-A4 Unit 4, Method is no longer in FREE_CALLABLE_TYPES so this test + // exercises ownerId propagation through the free-callable index using + // a Function label. Method-with-ownerId propagation is covered via + // methodsByName in method-registry.test.ts. const st = createSymbolTable(); - st.add('src/foo.ts', 'save', 'Method:src/foo.ts:save', 'Method', { + st.add('src/foo.ts', 'save', 'Function:src/foo.ts:save', 'Function', { ownerId: 'Class:src/foo.ts:User', }); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index e69250ceb..4bbc5d4b0 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -7,22 +7,22 @@ import { extractConsumerAccessedKeys, processNextjsFetchRoutes, } from '../../src/core/ingestion/call-processor.js'; -import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js'; +import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js'; import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { extractReturnTypeName } from '../../src/core/ingestion/type-extractors/shared.js'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/resolution-context.js'; +} from '../../src/core/ingestion/model/resolution-context.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js'; import type { ExtractedAssignment, ExtractedCall, ExtractedFetchCall, - ExtractedHeritage, FileConstructorBindings, } from '../../src/core/ingestion/workers/parse-worker.js'; +import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js'; describe('processCallsFromExtracted', () => { let graph: ReturnType; @@ -34,7 +34,7 @@ describe('processCallsFromExtracted', () => { }); it('creates CALLS relationship for same-file resolution', async () => { - ctx.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function'); + ctx.model.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function'); const calls: ExtractedCall[] = [ { @@ -55,7 +55,7 @@ describe('processCallsFromExtracted', () => { }); it('creates CALLS relationship for import-resolved resolution', async () => { - ctx.symbols.add('src/utils.ts', 'format', 'Function:src/utils.ts:format', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'format', 'Function:src/utils.ts:format', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const calls: ExtractedCall[] = [ @@ -75,7 +75,12 @@ describe('processCallsFromExtracted', () => { }); it('resolves unique global symbol with moderate confidence', async () => { - ctx.symbols.add('src/other.ts', 'uniqueFunc', 'Function:src/other.ts:uniqueFunc', 'Function'); + ctx.model.symbols.add( + 'src/other.ts', + 'uniqueFunc', + 'Function:src/other.ts:uniqueFunc', + 'Function', + ); const calls: ExtractedCall[] = [ { @@ -94,8 +99,8 @@ describe('processCallsFromExtracted', () => { }); it('refuses ambiguous global symbols — no CALLS edge created', async () => { - ctx.symbols.add('src/a.ts', 'render', 'Function:src/a.ts:render', 'Function'); - ctx.symbols.add('src/b.ts', 'render', 'Function:src/b.ts:render', 'Function'); + ctx.model.symbols.add('src/a.ts', 'render', 'Function:src/a.ts:render', 'Function'); + ctx.model.symbols.add('src/b.ts', 'render', 'Function:src/b.ts:render', 'Function'); const calls: ExtractedCall[] = [ { @@ -125,7 +130,7 @@ describe('processCallsFromExtracted', () => { }); it('refuses non-callable symbols even when the name resolves', async () => { - ctx.symbols.add('src/index.ts', 'Widget', 'Class:src/index.ts:Widget', 'Class'); + ctx.model.symbols.add('src/index.ts', 'Widget', 'Class:src/index.ts:Widget', 'Class'); const calls: ExtractedCall[] = [ { @@ -140,7 +145,7 @@ describe('processCallsFromExtracted', () => { }); it('refuses CALLS edges to Interface symbols', async () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/types.ts', 'Serializable', 'Interface:src/types.ts:Serializable', @@ -161,7 +166,7 @@ describe('processCallsFromExtracted', () => { }); it('refuses CALLS edges to Enum symbols', async () => { - ctx.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); + ctx.model.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); ctx.importMap.set('src/index.ts', new Set(['src/status.ts'])); const calls: ExtractedCall[] = [ @@ -177,8 +182,8 @@ describe('processCallsFromExtracted', () => { }); it('prefers same-file over import-resolved', async () => { - ctx.symbols.add('src/index.ts', 'render', 'Function:src/index.ts:render', 'Function'); - ctx.symbols.add('src/utils.ts', 'render', 'Function:src/utils.ts:render', 'Function'); + ctx.model.symbols.add('src/index.ts', 'render', 'Function:src/index.ts:render', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'render', 'Function:src/utils.ts:render', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const calls: ExtractedCall[] = [ @@ -198,8 +203,8 @@ describe('processCallsFromExtracted', () => { }); it('handles multiple calls from the same file', async () => { - ctx.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); - ctx.symbols.add('src/index.ts', 'bar', 'Function:src/index.ts:bar', 'Function'); + ctx.model.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); + ctx.model.symbols.add('src/index.ts', 'bar', 'Function:src/index.ts:bar', 'Function'); const calls: ExtractedCall[] = [ { filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' }, @@ -211,10 +216,10 @@ describe('processCallsFromExtracted', () => { }); it('uses arity to disambiguate import-scoped callable candidates', async () => { - ctx.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { + ctx.model.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 0, }); - ctx.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { + ctx.model.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1, }); ctx.importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts'])); @@ -237,10 +242,10 @@ describe('processCallsFromExtracted', () => { }); it('refuses ambiguous call targets when arity does not produce a unique match', async () => { - ctx.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { + ctx.model.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 1, }); - ctx.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { + ctx.model.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1, }); ctx.importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts'])); @@ -259,7 +264,7 @@ describe('processCallsFromExtracted', () => { }); it('calls progress callback', async () => { - ctx.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); + ctx.model.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); const calls: ExtractedCall[] = [ { filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' }, @@ -279,7 +284,7 @@ describe('processCallsFromExtracted', () => { // ---- Constructor-aware resolution (Phase 2) ---- it('resolves constructor call to Class when no Constructor node exists', async () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [ @@ -300,10 +305,16 @@ describe('processCallsFromExtracted', () => { }); it('resolves constructor call to Constructor node over Class node', async () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User', 'Constructor', { - parameterCount: 1, - }); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add( + 'src/models.ts', + 'User', + 'Constructor:src/models.ts:User', + 'Constructor', + { + parameterCount: 1, + }, + ); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [ @@ -324,7 +335,7 @@ describe('processCallsFromExtracted', () => { }); it('refuses Class target without callForm=constructor (existing behavior)', async () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [ @@ -342,7 +353,7 @@ describe('processCallsFromExtracted', () => { }); it('constructor call falls back to callable types when no Constructor/Class found', async () => { - ctx.symbols.add('src/utils.ts', 'Widget', 'Function:src/utils.ts:Widget', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'Widget', 'Function:src/utils.ts:Widget', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const calls: ExtractedCall[] = [ @@ -362,12 +373,24 @@ describe('processCallsFromExtracted', () => { }); it('constructor arity filtering narrows overloaded constructors', async () => { - ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(0)', 'Constructor', { - parameterCount: 0, - }); - ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(2)', 'Constructor', { - parameterCount: 2, - }); + ctx.model.symbols.add( + 'src/models.ts', + 'User', + 'Constructor:src/models.ts:User(0)', + 'Constructor', + { + parameterCount: 0, + }, + ); + ctx.model.symbols.add( + 'src/models.ts', + 'User', + 'Constructor:src/models.ts:User(2)', + 'Constructor', + { + parameterCount: 2, + }, + ); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [ @@ -388,10 +411,10 @@ describe('processCallsFromExtracted', () => { }); it('cannot discriminate same-arity overloads by parameter type (known limitation)', async () => { - ctx.symbols.add('src/UserDao.ts', 'save', 'Function:src/UserDao.ts:save', 'Function', { + ctx.model.symbols.add('src/UserDao.ts', 'save', 'Function:src/UserDao.ts:save', 'Function', { parameterCount: 1, }); - ctx.symbols.add('src/RepoDao.ts', 'save', 'Function:src/RepoDao.ts:save', 'Function', { + ctx.model.symbols.add('src/RepoDao.ts', 'save', 'Function:src/RepoDao.ts:save', 'Function', { parameterCount: 1, }); ctx.importMap.set('src/index.ts', new Set(['src/UserDao.ts', 'src/RepoDao.ts'])); @@ -414,11 +437,11 @@ describe('processCallsFromExtracted', () => { it('return type inference: binds variable to return type of callee', async () => { // getUser() returns User, and User has a save() method - ctx.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function', { returnType: 'User', }); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts', 'src/models.ts'])); @@ -450,11 +473,11 @@ describe('processCallsFromExtracted', () => { }); it('return type inference: unwraps Promise to User', async () => { - ctx.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function', { + ctx.model.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function', { returnType: 'Promise', }); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/index.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -484,9 +507,15 @@ describe('processCallsFromExtracted', () => { }); it('return type inference: skips when return type is primitive', async () => { - ctx.symbols.add('src/utils.ts', 'getCount', 'Function:src/utils.ts:getCount', 'Function', { - returnType: 'number', - }); + ctx.model.symbols.add( + 'src/utils.ts', + 'getCount', + 'Function:src/utils.ts:getCount', + 'Function', + { + returnType: 'number', + }, + ); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const constructorBindings: FileConstructorBindings[] = [ @@ -514,10 +543,10 @@ describe('processCallsFromExtracted', () => { }); it('return type inference: skips ambiguous callees (multiple definitions)', async () => { - ctx.symbols.add('src/a.ts', 'getData', 'Function:src/a.ts:getData', 'Function', { + ctx.model.symbols.add('src/a.ts', 'getData', 'Function:src/a.ts:getData', 'Function', { returnType: 'User', }); - ctx.symbols.add('src/b.ts', 'getData', 'Function:src/b.ts:getData', 'Function', { + ctx.model.symbols.add('src/b.ts', 'getData', 'Function:src/b.ts:getData', 'Function', { returnType: 'Repo', }); @@ -547,8 +576,8 @@ describe('processCallsFromExtracted', () => { it('return type inference: prefers constructor binding over return type', async () => { // If the callee IS a class, constructor binding wins (existing behavior) - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); @@ -583,11 +612,11 @@ describe('processCallsFromExtracted', () => { // getUser is in the SymbolTable but WITHOUT a returnType (e.g., inferred return type // that the structure processor did not capture). The BindingAccumulator for // src/api.ts has getUser → User as a file-scope binding. - ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { + ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { // No returnType provided — simulates a structure-processor gap }); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -635,11 +664,11 @@ describe('processCallsFromExtracted', () => { it('Phase 9: BindingAccumulator fallback — SymbolTable return type takes precedence', async () => { // When the SymbolTable DOES have a returnType, the accumulator should not override it. - ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { + ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { returnType: 'User', }); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -688,8 +717,8 @@ describe('processCallsFromExtracted', () => { it('Phase 9: BindingAccumulator fallback — skips when callee not in namedImportMap', async () => { // Callee is not tracked in namedImportMap (e.g. a local function), so accumulator // lookup is skipped. No CALLS edge expected since there is no binding source. - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); // No namedImportMap entry for getUser @@ -708,8 +737,8 @@ describe('processCallsFromExtracted', () => { // but also exists on multiple types so fuzzy lookup is ambiguous without a // receiver type. Add a second owner so that unconstrained fuzzy lookup won't // match unambiguously. - ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); - ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { ownerId: 'Class:src/other.ts:OtherClass', }); @@ -741,9 +770,9 @@ describe('processCallsFromExtracted', () => { it('Phase 9: BindingAccumulator fallback — unwraps Promise type from accumulator', async () => { // Accumulator stores raw type with Promise wrapper — extractReturnTypeName should unwrap it. - ctx.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function'); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -790,7 +819,7 @@ describe('processCallsFromExtracted', () => { it('Phase 9: BindingAccumulator fallback — skips primitive types from accumulator', async () => { // Accumulator stores a primitive type — should not create a CALLS edge. - ctx.symbols.add('src/api.ts', 'getCount', 'Function:src/api.ts:getCount', 'Function'); + ctx.model.symbols.add('src/api.ts', 'getCount', 'Function:src/api.ts:getCount', 'Function'); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts'])); ctx.namedImportMap.set( 'src/consumer.ts', @@ -834,9 +863,9 @@ describe('processCallsFromExtracted', () => { it('Phase 9: BindingAccumulator fallback — handles aliased import (localName ≠ exportedName)', async () => { // import { getUser as fetchUser } from './api' — namedImportMap maps localName to exportedName - ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -889,16 +918,21 @@ describe('processCallsFromExtracted', () => { // The local definition has no returnType annotation. The accumulator has // getUser → User from api.ts. The fallback must NOT fire because the // same-file definition is authoritative (tier: 'same-file'). - ctx.symbols.add('src/consumer.ts', 'getUser', 'Function:src/consumer.ts:getUser', 'Function'); - ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + ctx.model.symbols.add( + 'src/consumer.ts', + 'getUser', + 'Function:src/consumer.ts:getUser', + 'Function', + ); + ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); // Place User and save in non-imported files so import-scoped member-call resolution // can't resolve save without a receiver type. - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); - ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); - ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { ownerId: 'Class:src/other.ts:OtherClass', }); // Only import api.ts — NOT models.ts, so save can't be found via import scope. @@ -952,19 +986,19 @@ describe('processCallsFromExtracted', () => { // x has no receiver type at all, and save is ambiguous (two owners) → 0 edges. // Either way, no CALLS edge. But we verify the accumulator's wrong type did NOT leak // by checking that no ACCESSES edge to BadType is created. - ctx.symbols.add('src/api-v1.ts', 'getUser', 'Function:src/api-v1.ts:getUser', 'Function'); - ctx.symbols.add('src/api-v2.ts', 'getUser', 'Function:src/api-v2.ts:getUser', 'Function'); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/api-v1.ts', 'getUser', 'Function:src/api-v1.ts:getUser', 'Function'); + ctx.model.symbols.add('src/api-v2.ts', 'getUser', 'Function:src/api-v2.ts:getUser', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); - ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); - ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { ownerId: 'Class:src/other.ts:OtherClass', }); // BadType has no methods — if the accumulator wrongly types x as BadType, // the receiver type is set but save won't resolve at all. - ctx.symbols.add('src/bad.ts', 'BadType', 'Class:src/bad.ts:BadType', 'Class'); + ctx.model.symbols.add('src/bad.ts', 'BadType', 'Class:src/bad.ts:BadType', 'Class'); ctx.importMap.set( 'src/consumer.ts', new Set(['src/api-v1.ts', 'src/api-v2.ts', 'src/models.ts']), @@ -1017,8 +1051,8 @@ describe('processCallsFromExtracted', () => { it('Phase 9 tier gating: no callable candidates but named import — fallback fires', async () => { // getUser is not in the SymbolTable at all (e.g. definition not parsed). // namedImportMap has the import, accumulator has the type. Fallback should fire. - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -1067,14 +1101,19 @@ describe('processCallsFromExtracted', () => { // consumer.ts has a local getUser() without returnType annotation. // No import of getUser exists. The accumulator has getUser → User from api.ts. // Tier is 'same-file' so fallback must NOT fire. - ctx.symbols.add('src/consumer.ts', 'getUser', 'Function:src/consumer.ts:getUser', 'Function'); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add( + 'src/consumer.ts', + 'getUser', + 'Function:src/consumer.ts:getUser', + 'Function', + ); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); // Add a second 'save' so fuzzy lookup is ambiguous without receiver type - ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); - ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { ownerId: 'Class:src/other.ts:OtherClass', }); @@ -1120,12 +1159,23 @@ describe('processCallsFromExtracted', () => { // User.save@100 and Repo.save@200 are two methods named "save" in different classes. // Each has a local variable "db" pointing to a different type. // Without @startIndex in the key, the second binding would overwrite the first. - ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class'); - ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); - ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { - ownerId: 'Class:src/db/Database.ts:Database', - }); - ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { + ctx.model.symbols.add( + 'src/db/Database.ts', + 'Database', + 'Class:src/db/Database.ts:Database', + 'Class', + ); + ctx.model.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); + ctx.model.symbols.add( + 'src/db/Database.ts', + 'query', + 'Method:src/db/Database.ts:query', + 'Method', + { + ownerId: 'Class:src/db/Database.ts:Database', + }, + ); + ctx.model.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { ownerId: 'Class:src/db/Cache.ts:Cache', }); ctx.importMap.set('src/models/User.ts', new Set(['src/db/Database.ts'])); @@ -1178,10 +1228,21 @@ describe('processCallsFromExtracted', () => { it('receiverKey collision: same scope funcName + same varName + same type resolves (non-ambiguous)', async () => { // Two save@* scopes both bind "db" to the same type — not ambiguous, should resolve. - ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class'); - ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { - ownerId: 'Class:src/db/Database.ts:Database', - }); + ctx.model.symbols.add( + 'src/db/Database.ts', + 'Database', + 'Class:src/db/Database.ts:Database', + 'Class', + ); + ctx.model.symbols.add( + 'src/db/Database.ts', + 'query', + 'Method:src/db/Database.ts:query', + 'Method', + { + ownerId: 'Class:src/db/Database.ts:Database', + }, + ); ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts'])); const constructorBindings: FileConstructorBindings[] = [ @@ -1213,12 +1274,23 @@ describe('processCallsFromExtracted', () => { it('receiverKey collision: same scope funcName + same varName + different types → ambiguous, no CALLS edge', async () => { // Two save@* scopes in the same file bind "db" to different types — truly ambiguous. - ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class'); - ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); - ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { - ownerId: 'Class:src/db/Database.ts:Database', - }); - ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { + ctx.model.symbols.add( + 'src/db/Database.ts', + 'Database', + 'Class:src/db/Database.ts:Database', + 'Class', + ); + ctx.model.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); + ctx.model.symbols.add( + 'src/db/Database.ts', + 'query', + 'Method:src/db/Database.ts:query', + 'Method', + { + ownerId: 'Class:src/db/Database.ts:Database', + }, + ); + ctx.model.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { ownerId: 'Class:src/db/Cache.ts:Cache', }); ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts', 'src/db/Cache.ts'])); @@ -1251,9 +1323,9 @@ describe('processCallsFromExtracted', () => { }); it('scope-aware bindings: same varName in different functions resolves to correct type', async () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'Repo', 'Class:src/models.ts:Repo', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Function:src/models.ts:save', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'Repo', 'Class:src/models.ts:Repo', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Function:src/models.ts:save', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const constructorBindings: FileConstructorBindings[] = [ @@ -1312,12 +1384,16 @@ describe('processCalls — Phase P class lookup fallback', () => { const dogId = 'Class:models/Dog.java:Dog'; const fetchBallId = 'Method:models/Dog.java:fetchBall'; - ctx.symbols.add(contractFile, 'Pet', petId, 'Interface'); - ctx.symbols.add(dogFile, 'Dog', dogId, 'Class'); - ctx.symbols.add(dogFile, 'fetchBall', fetchBallId, 'Method', { ownerId: dogId }); + ctx.model.symbols.add(contractFile, 'Pet', petId, 'Interface'); + ctx.model.symbols.add(dogFile, 'Dog', dogId, 'Class'); + ctx.model.symbols.add(dogFile, 'fetchBall', fetchBallId, 'Method', { ownerId: dogId }); ctx.importMap.set(appFile, new Set([contractFile, dogFile])); - const classLookupSpy = vi.spyOn(ctx.symbols, 'lookupClassByName'); + // SM-20 wire-up: resolveMemberCall's constructor-override branch queries + // the model directly (ctx.model.types.lookupClassByName), not the + // legacy SymbolTable wrapper. Spy on the model method to preserve the + // test's intent: verify which class names are looked up during override. + const classLookupSpy = vi.spyOn(ctx.model.types, 'lookupClassByName'); await processCalls( graph, @@ -1358,16 +1434,26 @@ class App { const otherDogFile = 'models/OtherDog.java'; const petId = 'Interface:models/Pet.java:Pet'; - ctx.symbols.add(contractFile, 'Pet', petId, 'Interface'); - ctx.symbols.add(dogFile, 'fetchBall', 'Method:models/Dog.java:fetchBall', 'Method', { + ctx.model.symbols.add(contractFile, 'Pet', petId, 'Interface'); + ctx.model.symbols.add(dogFile, 'fetchBall', 'Method:models/Dog.java:fetchBall', 'Method', { ownerId: 'Class:models/Dog.java:Dog', }); - ctx.symbols.add(otherDogFile, 'fetchBall', 'Method:models/OtherDog.java:fetchBall', 'Method', { - ownerId: 'Class:models/OtherDog.java:OtherDog', - }); + ctx.model.symbols.add( + otherDogFile, + 'fetchBall', + 'Method:models/OtherDog.java:fetchBall', + 'Method', + { + ownerId: 'Class:models/OtherDog.java:OtherDog', + }, + ); ctx.importMap.set(appFile, new Set([contractFile, dogFile, otherDogFile])); - const classLookupSpy = vi.spyOn(ctx.symbols, 'lookupClassByName'); + // SM-20 wire-up: resolveMemberCall's constructor-override branch queries + // the model directly (ctx.model.types.lookupClassByName), not the + // legacy SymbolTable wrapper. Spy on the model method to preserve the + // test's intent: verify which class names are looked up during override. + const classLookupSpy = vi.spyOn(ctx.model.types, 'lookupClassByName'); await processCalls( graph, @@ -2061,10 +2147,12 @@ describe('processCallsFromExtracted — interface dispatch', () => { const implAExecuteId = 'Method:impl/A.java:execute'; const implBExecuteId = 'Method:impl/B.java:execute'; - ctx.symbols.add(ifaceFile, 'Action', actionIfaceId, 'Interface'); - ctx.symbols.add(ifaceFile, 'execute', ifaceExecuteId, 'Method', { ownerId: actionIfaceId }); - ctx.symbols.add(implA, 'execute', implAExecuteId, 'Method'); - ctx.symbols.add(implB, 'execute', implBExecuteId, 'Method'); + ctx.model.symbols.add(ifaceFile, 'Action', actionIfaceId, 'Interface'); + ctx.model.symbols.add(ifaceFile, 'execute', ifaceExecuteId, 'Method', { + ownerId: actionIfaceId, + }); + ctx.model.symbols.add(implA, 'execute', implAExecuteId, 'Method'); + ctx.model.symbols.add(implB, 'execute', implBExecuteId, 'Method'); ctx.importMap.set(runnerFile, new Set([ifaceFile])); graph.addNode({ @@ -2100,8 +2188,8 @@ describe('processCallsFromExtracted — interface dispatch', () => { { filePath: 'impl/B.java', className: 'B', parentName: 'Action', kind: 'implements' }, ]; // Need class symbols for heritage map to resolve implementors - ctx.symbols.add('impl/A.java', 'A', 'Class:impl/A.java:A', 'Class'); - ctx.symbols.add('impl/B.java', 'B', 'Class:impl/B.java:B', 'Class'); + ctx.model.symbols.add('impl/A.java', 'A', 'Class:impl/A.java:A', 'Class'); + ctx.model.symbols.add('impl/B.java', 'B', 'Class:impl/B.java:B', 'Class'); const heritageMap = buildHeritageMap(heritage, ctx); const calls: ExtractedCall[] = [ @@ -2153,9 +2241,9 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const childId = 'class:models/Child.java:Child'; const parentMethodId = 'method:models/Parent.java:parentMethod'; - ctx.symbols.add(parentFile, 'Parent', parentId, 'Class'); - ctx.symbols.add(childFile, 'Child', childId, 'Class'); - ctx.symbols.add(parentFile, 'parentMethod', parentMethodId, 'Method', { + ctx.model.symbols.add(parentFile, 'Parent', parentId, 'Class'); + ctx.model.symbols.add(childFile, 'Child', childId, 'Class'); + ctx.model.symbols.add(parentFile, 'parentMethod', parentMethodId, 'Method', { ownerId: parentId, returnType: 'String', }); @@ -2211,26 +2299,28 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { }); it('D0 miss: heritageMap provided but method not in MRO chain falls through to D1-D4', async () => { - // Setup: Class Obj has a method `doWork` that is findable via tiered - // resolution (import-scoped lookup), but intentionally NOT registered in - // methodByOwner (no `ownerId` property). heritageMap is provided but has - // no ancestry entry for class:Obj. Expected flow: - // D0: lookupMethodByOwner(classId, 'doWork') → undefined - // heritageMap.getAncestors(classId) → [] - // lookupMethodByOwnerWithMRO returns undefined → D0 miss - // D1-D4: receiver type resolves to Obj; D2 widens via lookupCallableByName; - // D3 file-filter picks the only candidate in Obj's file. + // Setup: Class Obj exists in the same file as a `doWork` Method. The + // Method is registered under a DIFFERENT ownerId (`class:OtherOwner`) + // so lookupMethodByOwner('class:Obj', 'doWork') misses on the direct + // lookup. heritageMap is empty for class:Obj, so MRO walk yields no + // parents. Expected flow: + // D0: lookupMethodByOwner + MRO walk both miss → D0 fallthrough + // D1-D4: receiver type resolves to Obj; D3 file-filter picks the + // `doWork` candidate via its co-located file path. // Guarantees D0 miss does not swallow the call — D1-D4 still runs. const classFile = 'src/models/Obj.java'; const appFile = 'src/services/App.java'; const classId = 'class:models/Obj.java:Obj'; const doWorkId = 'method:models/Obj.java:doWork'; - ctx.symbols.add(classFile, 'Obj', classId, 'Class'); - // Intentionally omit ownerId so methodByOwner has no entry — forces D0 miss. - ctx.symbols.add(classFile, 'doWork', doWorkId, 'Method', { + ctx.model.symbols.add(classFile, 'Obj', classId, 'Class'); + // Post-A4: Method+ownerId routes through methodsByName. Using a + // different ownerId than the receiver type forces the direct + // lookupMethodByOwner miss that the test exercises. + ctx.model.symbols.add(classFile, 'doWork', doWorkId, 'Method', { returnType: 'void', parameterCount: 0, + ownerId: 'class:models/Obj.java:OtherOwner', }); ctx.importMap.set(appFile, new Set([classFile])); @@ -2321,15 +2411,15 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const methodIntId = 'method:models/Obj.java:method(int)'; const methodStringId = 'method:models/Obj.java:method(String)'; - ctx.symbols.add(classFile, 'Obj', classId, 'Class'); + ctx.model.symbols.add(classFile, 'Obj', classId, 'Class'); // int overload added FIRST so lookupMethodByOwner would return it. - ctx.symbols.add(classFile, 'method', methodIntId, 'Method', { + ctx.model.symbols.add(classFile, 'method', methodIntId, 'Method', { ownerId: classId, returnType: 'String', parameterCount: 1, parameterTypes: ['int'], }); - ctx.symbols.add(classFile, 'method', methodStringId, 'Method', { + ctx.model.symbols.add(classFile, 'method', methodStringId, 'Method', { ownerId: classId, returnType: 'String', parameterCount: 1, @@ -2384,16 +2474,16 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const methodIntId = 'method:models/Obj.java:method(int)'; const methodStringId = 'method:models/Obj.java:method(String)'; - ctx.symbols.add(classFile, 'Obj', classId, 'Class'); + ctx.model.symbols.add(classFile, 'Obj', classId, 'Class'); // int overload added FIRST — without the guard this would be returned by // lookupMethodByOwner's same-return-type fast path. - ctx.symbols.add(classFile, 'method', methodIntId, 'Method', { + ctx.model.symbols.add(classFile, 'method', methodIntId, 'Method', { ownerId: classId, returnType: 'String', parameterCount: 1, parameterTypes: ['int'], }); - ctx.symbols.add(classFile, 'method', methodStringId, 'Method', { + ctx.model.symbols.add(classFile, 'method', methodStringId, 'Method', { ownerId: classId, returnType: 'String', parameterCount: 1, @@ -2439,13 +2529,13 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const authSaveId = 'method:auth_mod.py:save'; const userSaveId = 'method:user_mod.py:save'; - ctx.symbols.add(authModFile, 'User', authUserId, 'Class'); - ctx.symbols.add(userModFile, 'User', userUserId, 'Class'); - ctx.symbols.add(authModFile, 'save', authSaveId, 'Method', { + ctx.model.symbols.add(authModFile, 'User', authUserId, 'Class'); + ctx.model.symbols.add(userModFile, 'User', userUserId, 'Class'); + ctx.model.symbols.add(authModFile, 'save', authSaveId, 'Method', { ownerId: authUserId, returnType: 'bool', }); - ctx.symbols.add(userModFile, 'save', userSaveId, 'Method', { + ctx.model.symbols.add(userModFile, 'save', userSaveId, 'Method', { ownerId: userUserId, returnType: 'bool', }); @@ -2509,13 +2599,13 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const authSaveId = 'method:src/auth_mod.py:save'; const userSaveId = 'method:src/user_mod.py:save'; - ctx.symbols.add(authModFile, 'User', authUserId, 'Class'); - ctx.symbols.add(userModFile, 'User', userUserId, 'Class'); - ctx.symbols.add(authModFile, 'save', authSaveId, 'Method', { + ctx.model.symbols.add(authModFile, 'User', authUserId, 'Class'); + ctx.model.symbols.add(userModFile, 'User', userUserId, 'Class'); + ctx.model.symbols.add(authModFile, 'save', authSaveId, 'Method', { ownerId: authUserId, returnType: 'bool', }); - ctx.symbols.add(userModFile, 'save', userSaveId, 'Method', { + ctx.model.symbols.add(userModFile, 'save', userSaveId, 'Method', { ownerId: userUserId, returnType: 'bool', }); @@ -2566,13 +2656,13 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const modelsSaveId = 'method:src/models.py:User:save'; const authSaveId = 'method:src/auth.py:Widget:save'; - ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); - ctx.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); - ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { + ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.model.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); + ctx.model.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { ownerId: modelsUserId, returnType: 'None', }); - ctx.symbols.add(authFile, 'save', authSaveId, 'Method', { + ctx.model.symbols.add(authFile, 'save', authSaveId, 'Method', { ownerId: authWidgetId, returnType: 'None', }); @@ -2616,10 +2706,10 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const authWidgetId = 'class:src/auth.py:Widget'; const authSaveId = 'method:src/auth.py:Widget:save'; - ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); - ctx.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); + ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.model.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); // NO save on User — deliberately absent to force null-route. - ctx.symbols.add(authFile, 'save', authSaveId, 'Method', { + ctx.model.symbols.add(authFile, 'save', authSaveId, 'Method', { ownerId: authWidgetId, returnType: 'None', }); @@ -2658,8 +2748,8 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const modelsUserId = 'class:src/models.py:User'; const modelsSaveId = 'method:src/models.py:User:save'; - ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); - ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { + ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.model.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { ownerId: modelsUserId, returnType: 'None', }); @@ -2697,8 +2787,8 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const modelsUserId = 'class:src/models.py:User'; const modelsSaveId = 'method:src/models.py:User:save'; - ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); - ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { + ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.model.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { ownerId: modelsUserId, returnType: 'None', }); @@ -2740,14 +2830,14 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const userCtorId = 'Constructor:src/models/User.ts:User(string)'; const repoCtorId = 'Constructor:src/models/Repo.ts:User(number)'; - ctx.symbols.add(userFile, 'User', userClassId, 'Class'); - ctx.symbols.add(repoFile, 'User', repoClassId, 'Class'); - ctx.symbols.add(userFile, 'User', userCtorId, 'Constructor', { + ctx.model.symbols.add(userFile, 'User', userClassId, 'Class'); + ctx.model.symbols.add(repoFile, 'User', repoClassId, 'Class'); + ctx.model.symbols.add(userFile, 'User', userCtorId, 'Constructor', { ownerId: userClassId, parameterCount: 1, parameterTypes: ['string'], }); - ctx.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { + ctx.model.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { ownerId: repoClassId, parameterCount: 1, parameterTypes: ['number'], @@ -2784,15 +2874,15 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const userCtorId = 'Constructor:src/models/User.ts:User(string)'; const repoCtorId = 'Constructor:src/models/Repo.ts:User(string)'; - ctx.symbols.add(userFile, 'User', userClassId, 'Class'); - ctx.symbols.add(repoFile, 'User', repoClassId, 'Class'); + ctx.model.symbols.add(userFile, 'User', userClassId, 'Class'); + ctx.model.symbols.add(repoFile, 'User', repoClassId, 'Class'); // Both constructors take `string` — genuinely ambiguous. - ctx.symbols.add(userFile, 'User', userCtorId, 'Constructor', { + ctx.model.symbols.add(userFile, 'User', userCtorId, 'Constructor', { ownerId: userClassId, parameterCount: 1, parameterTypes: ['string'], }); - ctx.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { + ctx.model.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { ownerId: repoClassId, parameterCount: 1, parameterTypes: ['string'], @@ -2833,11 +2923,17 @@ describe('processAssignmentsFromExtracted', () => { // carries getUser → User from the source file. The constructor binding // binds x = getUser(). The assignment x.address = value should produce // an ACCESSES write edge to User.address via the accumulator fallback. - ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'address', 'Property:src/models.ts:address', 'Property', { - ownerId: 'Class:src/models.ts:User', - }); + ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add( + 'src/models.ts', + 'address', + 'Property:src/models.ts:address', + 'Property', + { + ownerId: 'Class:src/models.ts:User', + }, + ); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); ctx.namedImportMap.set( 'src/consumer.ts', @@ -2889,9 +2985,9 @@ describe('D2 widen path: lookupCallableByName via module alias', () => { // pointing to auth.py. login() is defined only in auth.py (not imported // by consumer.py). The D2 widen path should find login via the global // callable index filtered to the aliased module file. - ctx.symbols.add('src/auth.py', 'login', 'Function:src/auth.py:login', 'Function'); + ctx.model.symbols.add('src/auth.py', 'login', 'Function:src/auth.py:login', 'Function'); // Consumer has a same-file function that shadows 'login' at Tier 1 - ctx.symbols.add('src/consumer.py', 'login', 'Function:src/consumer.py:login', 'Function'); + ctx.model.symbols.add('src/consumer.py', 'login', 'Function:src/consumer.py:login', 'Function'); // Module alias: consumer.py → auth → src/auth.py ctx.moduleAliasMap.set('src/consumer.py', new Map([['auth', 'src/auth.py']])); diff --git a/gitnexus/test/unit/field-extraction.test.ts b/gitnexus/test/unit/field-extraction.test.ts index 751618146..2795f5833 100644 --- a/gitnexus/test/unit/field-extraction.test.ts +++ b/gitnexus/test/unit/field-extraction.test.ts @@ -8,7 +8,7 @@ import { cppConfig } from '../../src/core/ingestion/field-extractors/configs/c-c import { rubyConfig } from '../../src/core/ingestion/field-extractors/configs/ruby.js'; import type { FieldExtractorContext } from '../../src/core/ingestion/field-types.js'; import type { TypeEnvironment } from '../../src/core/ingestion/type-env.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js'; import Parser from 'tree-sitter'; import TypeScript from 'tree-sitter-typescript'; import Python from 'tree-sitter-python'; @@ -26,7 +26,13 @@ const parse = (code: string) => { return parser.parse(code); }; -// Mock context for tests +// Mock context for tests. symbolTable comes from createSemanticModel().symbols +// (the facade) rather than createSymbolTable() (the raw leaf) — this mirrors +// production, where FieldExtractorContext always receives the SemanticModel- +// wrapped facade so any .add() write dispatches through the owner-scoped +// registries. No current field extractor calls symbolTable.add(), but +// matching the production shape prevents silent drift if a future extractor +// starts registering dynamically-discovered properties. const createMockContext = (): FieldExtractorContext => ({ typeEnv: { lookup: () => undefined, @@ -35,7 +41,7 @@ const createMockContext = (): FieldExtractorContext => ({ allScopes: () => new Map(), constructorTypeMap: new Map(), } as TypeEnvironment, - symbolTable: createSymbolTable(), + symbolTable: createSemanticModel().symbols, filePath: 'test.ts', language: SupportedLanguages.TypeScript, }); diff --git a/gitnexus/test/unit/heritage-map.test.ts b/gitnexus/test/unit/heritage-map.test.ts index b4a6b1c4f..1b206bfe5 100644 --- a/gitnexus/test/unit/heritage-map.test.ts +++ b/gitnexus/test/unit/heritage-map.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js'; +import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/resolution-context.js'; -import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js'; +} from '../../src/core/ingestion/model/resolution-context.js'; +import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js'; +import { getHeritageStrategyForLanguage } from '../../src/core/ingestion/heritage-processor.js'; describe('buildHeritageMap', () => { let ctx: ResolutionContext; @@ -17,8 +18,8 @@ describe('buildHeritageMap', () => { describe('getParents', () => { it('returns direct parents for a single extends relationship', () => { - ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/child.ts', className: 'Child', parentName: 'Parent', kind: 'extends' }, @@ -29,8 +30,8 @@ describe('buildHeritageMap', () => { }); it('returns direct parents for implements relationship', () => { - ctx.symbols.add('src/service.ts', 'Service', 'class:Service', 'Class'); - ctx.symbols.add('src/iface.ts', 'IService', 'iface:IService', 'Interface'); + ctx.model.symbols.add('src/service.ts', 'Service', 'class:Service', 'Class'); + ctx.model.symbols.add('src/iface.ts', 'IService', 'iface:IService', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -46,8 +47,8 @@ describe('buildHeritageMap', () => { }); it('returns direct parents for trait-impl relationship', () => { - ctx.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct'); - ctx.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface'); + ctx.model.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct'); + ctx.model.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -63,9 +64,14 @@ describe('buildHeritageMap', () => { }); it('returns multiple parents when class extends and implements', () => { - ctx.symbols.add('src/admin.ts', 'Admin', 'class:Admin', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/serializable.ts', 'Serializable', 'iface:Serializable', 'Interface'); + ctx.model.symbols.add('src/admin.ts', 'Admin', 'class:Admin', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add( + 'src/serializable.ts', + 'Serializable', + 'iface:Serializable', + 'Interface', + ); const heritage: ExtractedHeritage[] = [ { filePath: 'src/admin.ts', className: 'Admin', parentName: 'User', kind: 'extends' }, @@ -90,7 +96,7 @@ describe('buildHeritageMap', () => { }); it('skips heritage records where child class is not in symbol table', () => { - ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -107,7 +113,7 @@ describe('buildHeritageMap', () => { }); it('skips heritage records where parent class is not in symbol table', () => { - ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -123,7 +129,7 @@ describe('buildHeritageMap', () => { }); it('skips self-references', () => { - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/a.ts', className: 'A', parentName: 'A', kind: 'extends' }, @@ -134,8 +140,8 @@ describe('buildHeritageMap', () => { }); it('deduplicates cross-chunk duplicates', () => { - ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/child.ts', className: 'Child', parentName: 'Parent', kind: 'extends' }, @@ -151,9 +157,9 @@ describe('buildHeritageMap', () => { describe('getAncestors', () => { it('returns full ancestor chain for multi-level inheritance', () => { - ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/c.ts', className: 'C', parentName: 'B', kind: 'extends' }, @@ -173,10 +179,10 @@ describe('buildHeritageMap', () => { // B C // \ / // D - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/d.ts', className: 'D', parentName: 'B', kind: 'extends' }, @@ -194,8 +200,8 @@ describe('buildHeritageMap', () => { }); it('protects against cycles', () => { - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/a.ts', className: 'A', parentName: 'B', kind: 'extends' }, @@ -212,9 +218,9 @@ describe('buildHeritageMap', () => { }); it('protects against multi-node cycles (A→B→C→A)', () => { - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); // A → B → C → A (3-node cycle) const heritage: ExtractedHeritage[] = [ @@ -232,7 +238,7 @@ describe('buildHeritageMap', () => { }); it('returns empty array for node with no parents', () => { - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); const map = buildHeritageMap([], ctx); expect(map.getAncestors('class:A')).toEqual([]); @@ -249,9 +255,9 @@ describe('buildHeritageMap', () => { for (let i = 0; i < 40; i++) { const childName = `Level${i}`; const parentName = `Level${i + 1}`; - ctx.symbols.add(`src/${childName}.ts`, childName, `class:${childName}`, 'Class'); + ctx.model.symbols.add(`src/${childName}.ts`, childName, `class:${childName}`, 'Class'); if (i === 39) { - ctx.symbols.add(`src/${parentName}.ts`, parentName, `class:${parentName}`, 'Class'); + ctx.model.symbols.add(`src/${parentName}.ts`, parentName, `class:${parentName}`, 'Class'); } heritage.push({ filePath: `src/${childName}.ts`, @@ -289,9 +295,9 @@ describe('buildHeritageMap', () => { describe('getImplementorFiles', () => { it('records direct implements edges per interface name', () => { - ctx.symbols.add('a.java', 'C', 'class:C', 'Class'); - ctx.symbols.add('b.java', 'D', 'class:D', 'Class'); - ctx.symbols.add('iface.java', 'Runnable', 'iface:Runnable', 'Interface'); + ctx.model.symbols.add('a.java', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('b.java', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('iface.java', 'Runnable', 'iface:Runnable', 'Interface'); const heritage: ExtractedHeritage[] = [ { filePath: 'a.java', className: 'C', parentName: 'Runnable', kind: 'implements' }, @@ -302,9 +308,9 @@ describe('buildHeritageMap', () => { }); it('only records implementors for interface parents, not class parents', () => { - ctx.symbols.add('a.java', 'C', 'class:C', 'Class'); - ctx.symbols.add('base.java', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('iface.java', 'I', 'iface:I', 'Interface'); + ctx.model.symbols.add('a.java', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('base.java', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('iface.java', 'I', 'iface:I', 'Interface'); const heritage: ExtractedHeritage[] = [ { filePath: 'a.java', className: 'C', parentName: 'Base', kind: 'extends' }, @@ -326,7 +332,7 @@ describe('buildHeritageMap', () => { // Only the child class is registered; the parent interface has no symbol. // resolveExtendsType must fall through to the provider heuristic and // classify `IDisposable` as IMPLEMENTS. - ctx.symbols.add('src/Service.cs', 'Service', 'class:Service', 'Class'); + ctx.model.symbols.add('src/Service.cs', 'Service', 'class:Service', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -336,14 +342,14 @@ describe('buildHeritageMap', () => { kind: 'extends', }, ]; - const map = buildHeritageMap(heritage, ctx); + const map = buildHeritageMap(heritage, ctx, getHeritageStrategyForLanguage); expect(map.getImplementorFiles('IDisposable')).toEqual(new Set(['src/Service.cs'])); }); it('records Swift extends→IMPLEMENTS via heritageDefaultEdge when parent is unresolved', () => { // Swift provider has heritageDefaultEdge: 'IMPLEMENTS'. // Unresolved parents should default to IMPLEMENTS (protocol conformance). - ctx.symbols.add('src/MyView.swift', 'MyView', 'class:MyView', 'Class'); + ctx.model.symbols.add('src/MyView.swift', 'MyView', 'class:MyView', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -353,7 +359,7 @@ describe('buildHeritageMap', () => { kind: 'extends', }, ]; - const map = buildHeritageMap(heritage, ctx); + const map = buildHeritageMap(heritage, ctx, getHeritageStrategyForLanguage); expect(map.getImplementorFiles('SomeProtocol')).toEqual(new Set(['src/MyView.swift'])); }); @@ -361,8 +367,8 @@ describe('buildHeritageMap', () => { // Java/C# path: when ctx.resolve finds a matching symbol whose type is // Interface, resolveExtendsType returns IMPLEMENTS via the symbol lookup // (not the interfaceNamePattern fallback). - ctx.symbols.add('src/Impl.java', 'Impl', 'class:Impl', 'Class'); - ctx.symbols.add('src/MyContract.java', 'MyContract', 'iface:MyContract', 'Interface'); + ctx.model.symbols.add('src/Impl.java', 'Impl', 'class:Impl', 'Class'); + ctx.model.symbols.add('src/MyContract.java', 'MyContract', 'iface:MyContract', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -377,8 +383,8 @@ describe('buildHeritageMap', () => { }); it('records Kotlin implements edges', () => { - ctx.symbols.add('src/Impl.kt', 'Impl', 'class:Impl', 'Class'); - ctx.symbols.add('src/Iface.kt', 'Iface', 'iface:Iface', 'Interface'); + ctx.model.symbols.add('src/Impl.kt', 'Impl', 'class:Impl', 'Class'); + ctx.model.symbols.add('src/Iface.kt', 'Iface', 'iface:Iface', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -393,8 +399,8 @@ describe('buildHeritageMap', () => { }); it('records TypeScript implements edges', () => { - ctx.symbols.add('src/Service.ts', 'UserService', 'class:UserService', 'Class'); - ctx.symbols.add('src/IService.ts', 'IUserService', 'iface:IUserService', 'Interface'); + ctx.model.symbols.add('src/Service.ts', 'UserService', 'class:UserService', 'Class'); + ctx.model.symbols.add('src/IService.ts', 'IUserService', 'iface:IUserService', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -409,8 +415,8 @@ describe('buildHeritageMap', () => { }); it('records PHP implements edges', () => { - ctx.symbols.add('src/Impl.php', 'Impl', 'class:Impl', 'Class'); - ctx.symbols.add('src/Iface.php', 'Iface', 'iface:Iface', 'Interface'); + ctx.model.symbols.add('src/Impl.php', 'Impl', 'class:Impl', 'Class'); + ctx.model.symbols.add('src/Iface.php', 'Iface', 'iface:Iface', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -427,8 +433,8 @@ describe('buildHeritageMap', () => { it('does not record Rust trait-impl entries in the implementor index', () => { // Documented limitation: trait-impl is intentionally not added to the // implementor index — interface dispatch does not traverse trait objects. - ctx.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct'); - ctx.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface'); + ctx.model.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct'); + ctx.model.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -445,9 +451,9 @@ describe('buildHeritageMap', () => { }); it('heritage merged across chunks matches single-pass (chunk-order invariant)', () => { - ctx.symbols.add('a.java', 'A', 'class:A', 'Class'); - ctx.symbols.add('b.java', 'B', 'class:B', 'Class'); - ctx.symbols.add('iface.java', 'Iface', 'iface:Iface', 'Interface'); + ctx.model.symbols.add('a.java', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('b.java', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('iface.java', 'Iface', 'iface:Iface', 'Interface'); const chunk1: ExtractedHeritage[] = [ { filePath: 'a.java', className: 'A', parentName: 'Iface', kind: 'implements' }, @@ -464,10 +470,10 @@ describe('buildHeritageMap', () => { describe('chunk-order invariant', () => { it('produces same result regardless of heritage record order', () => { - ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); - ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); const heritage1: ExtractedHeritage[] = [ { filePath: 'src/d.ts', className: 'D', parentName: 'C', kind: 'extends' }, diff --git a/gitnexus/test/unit/heritage-processor.test.ts b/gitnexus/test/unit/heritage-processor.test.ts index 9c007e268..e4b4be211 100644 --- a/gitnexus/test/unit/heritage-processor.test.ts +++ b/gitnexus/test/unit/heritage-processor.test.ts @@ -4,8 +4,8 @@ import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/resolution-context.js'; -import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js'; +} from '../../src/core/ingestion/model/resolution-context.js'; +import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js'; describe('processHeritageFromExtracted', () => { let graph: ReturnType; @@ -18,8 +18,8 @@ describe('processHeritageFromExtracted', () => { describe('extends', () => { it('creates EXTENDS relationship between classes', async () => { - ctx.symbols.add('src/admin.ts', 'AdminUser', 'Class:src/admin.ts:AdminUser', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); + ctx.model.symbols.add('src/admin.ts', 'AdminUser', 'Class:src/admin.ts:AdminUser', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -58,7 +58,7 @@ describe('processHeritageFromExtracted', () => { }); it('skips self-inheritance', async () => { - ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -76,8 +76,13 @@ describe('processHeritageFromExtracted', () => { describe('implements', () => { it('creates IMPLEMENTS relationship', async () => { - ctx.symbols.add('src/service.ts', 'UserService', 'Class:src/service.ts:UserService', 'Class'); - ctx.symbols.add( + ctx.model.symbols.add( + 'src/service.ts', + 'UserService', + 'Class:src/service.ts:UserService', + 'Class', + ); + ctx.model.symbols.add( 'src/interfaces.ts', 'IService', 'Interface:src/interfaces.ts:IService', @@ -103,8 +108,8 @@ describe('processHeritageFromExtracted', () => { describe('trait-impl (Rust)', () => { it('creates IMPLEMENTS relationship for trait impl', async () => { - ctx.symbols.add('src/point.rs', 'Point', 'Struct:src/point.rs:Point', 'Struct'); - ctx.symbols.add('src/display.rs', 'Display', 'Trait:src/display.rs:Display', 'Trait'); + ctx.model.symbols.add('src/point.rs', 'Point', 'Struct:src/point.rs:Point', 'Struct'); + ctx.model.symbols.add('src/display.rs', 'Display', 'Trait:src/display.rs:Display', 'Trait'); const heritage: ExtractedHeritage[] = [ { @@ -125,8 +130,13 @@ describe('processHeritageFromExtracted', () => { describe('C# interface resolution from extends captures', () => { it('emits IMPLEMENTS when parent is an Interface in symbol table', async () => { - ctx.symbols.add('src/Service.cs', 'UserService', 'Class:src/Service.cs:UserService', 'Class'); - ctx.symbols.add( + ctx.model.symbols.add( + 'src/Service.cs', + 'UserService', + 'Class:src/Service.cs:UserService', + 'Class', + ); + ctx.model.symbols.add( 'src/IService.cs', 'IService', 'Interface:src/IService.cs:IService', @@ -153,8 +163,8 @@ describe('processHeritageFromExtracted', () => { }); it('emits EXTENDS when parent is a Class in symbol table', async () => { - ctx.symbols.add('src/Admin.cs', 'AdminUser', 'Class:src/Admin.cs:AdminUser', 'Class'); - ctx.symbols.add('src/User.cs', 'User', 'Class:src/User.cs:User', 'Class'); + ctx.model.symbols.add('src/Admin.cs', 'AdminUser', 'Class:src/Admin.cs:AdminUser', 'Class'); + ctx.model.symbols.add('src/User.cs', 'User', 'Class:src/User.cs:User', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -246,15 +256,20 @@ describe('processHeritageFromExtracted', () => { }); it('handles mixed class + interface base_list from C#', async () => { - ctx.symbols.add('src/Repo.cs', 'UserRepo', 'Class:src/Repo.cs:UserRepo', 'Class'); - ctx.symbols.add('src/Base.cs', 'BaseRepository', 'Class:src/Base.cs:BaseRepository', 'Class'); - ctx.symbols.add( + ctx.model.symbols.add('src/Repo.cs', 'UserRepo', 'Class:src/Repo.cs:UserRepo', 'Class'); + ctx.model.symbols.add( + 'src/Base.cs', + 'BaseRepository', + 'Class:src/Base.cs:BaseRepository', + 'Class', + ); + ctx.model.symbols.add( 'src/IRepo.cs', 'IRepository', 'Interface:src/IRepo.cs:IRepository', 'Interface', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/IDisp.cs', 'IDisposable', 'Interface:src/IDisp.cs:IDisposable', @@ -314,7 +329,7 @@ describe('processHeritageFromExtracted', () => { it('still uses symbol table authoritatively for Swift (Tier 1 takes precedence)', async () => { // When the parent is in the symbol table as a Class, EXTENDS wins even in Swift - ctx.symbols.add('src/Animal.swift', 'Animal', 'Class:src/Animal.swift:Animal', 'Class'); + ctx.model.symbols.add('src/Animal.swift', 'Animal', 'Class:src/Animal.swift:Animal', 'Class'); const heritage: ExtractedHeritage[] = [ { diff --git a/gitnexus/test/unit/import-processor.test.ts b/gitnexus/test/unit/import-processor.test.ts index eb5fe5c19..0396be2cb 100644 --- a/gitnexus/test/unit/import-processor.test.ts +++ b/gitnexus/test/unit/import-processor.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { buildImportResolutionContext } from '../../src/core/ingestion/import-processor.js'; import type { ImportResolutionContext } from '../../src/core/ingestion/import-resolvers/types.js'; -import { createResolutionContext } from '../../src/core/ingestion/resolution-context.js'; +import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js'; describe('ResolutionContext.importMap', () => { it('creates an empty Map', () => { diff --git a/gitnexus/test/unit/model/field-registry.test.ts b/gitnexus/test/unit/model/field-registry.test.ts new file mode 100644 index 000000000..4c6e093d7 --- /dev/null +++ b/gitnexus/test/unit/model/field-registry.test.ts @@ -0,0 +1,74 @@ +/** + * Unit tests for FieldRegistry (SM-20). + * + * FieldRegistry is the simplest of the three owner-scoped registries — + * one flat Map keyed on `ownerNodeId\0fieldName`. These tests pin the + * basic register/lookup/clear contract and the owner-scope isolation. + */ + +import { describe, it, expect } from 'vitest'; +import { createFieldRegistry } from '../../../src/core/ingestion/model/field-registry.js'; +import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; +import { makeDef as makeBaseDef } from './helpers.js'; + +const makeDef = (overrides: Partial = {}): SymbolDefinition => + makeBaseDef({ nodeId: 'prop:test', type: 'Property', ...overrides }); + +describe('FieldRegistry', () => { + it('lookupFieldByOwner returns undefined when the registry is empty', () => { + const reg = createFieldRegistry(); + expect(reg.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + }); + + it('register + lookup round-trips the exact def reference', () => { + const reg = createFieldRegistry(); + const def = makeDef({ nodeId: 'prop:User.name', declaredType: 'string' }); + + reg.register('class:User', 'name', def); + + expect(reg.lookupFieldByOwner('class:User', 'name')).toBe(def); + }); + + it('isolates fields by ownerNodeId — same field name on two classes does not collide', () => { + const reg = createFieldRegistry(); + const userName = makeDef({ nodeId: 'prop:User.name' }); + const orderName = makeDef({ nodeId: 'prop:Order.name' }); + + reg.register('class:User', 'name', userName); + reg.register('class:Order', 'name', orderName); + + expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name'); + expect(reg.lookupFieldByOwner('class:Order', 'name')?.nodeId).toBe('prop:Order.name'); + }); + + it('last-wins on duplicate (ownerNodeId, fieldName) — registry is flat, not an overload list', () => { + const reg = createFieldRegistry(); + const first = makeDef({ nodeId: 'prop:User.name#first' }); + const second = makeDef({ nodeId: 'prop:User.name#second' }); + + reg.register('class:User', 'name', first); + reg.register('class:User', 'name', second); + + expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name#second'); + }); + + it('clear() empties the registry', () => { + const reg = createFieldRegistry(); + reg.register('class:User', 'name', makeDef()); + reg.register('class:Order', 'total', makeDef()); + + reg.clear(); + + expect(reg.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(reg.lookupFieldByOwner('class:Order', 'total')).toBeUndefined(); + }); + + it('allows re-registration after clear', () => { + const reg = createFieldRegistry(); + reg.register('class:User', 'name', makeDef({ nodeId: 'prop:first' })); + reg.clear(); + reg.register('class:User', 'name', makeDef({ nodeId: 'prop:second' })); + + expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:second'); + }); +}); diff --git a/gitnexus/test/unit/model/helpers.ts b/gitnexus/test/unit/model/helpers.ts new file mode 100644 index 000000000..04856f8d3 --- /dev/null +++ b/gitnexus/test/unit/model/helpers.ts @@ -0,0 +1,27 @@ +/** + * Shared test helpers for the model/ unit tests. + * + * Keep this file minimal — just the factory functions that every + * registry/table test needs. Anything domain-specific belongs in the + * test file that uses it. + */ + +import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; + +/** + * Build a {@link SymbolDefinition} with sensible defaults. Every field + * is overridable. Defaults produce a Method-typed def so the caller + * only has to override for other shapes. + */ +export const makeDef = (overrides: Partial = {}): SymbolDefinition => ({ + nodeId: 'def:test', + filePath: 'src/test.ts', + type: 'Method', + ...overrides, +}); + +/** + * Alias for {@link makeDef} kept for readability in method-registry + * tests where "makeMethod" reads more naturally at the call site. + */ +export const makeMethod = makeDef; diff --git a/gitnexus/test/unit/model/method-registry.test.ts b/gitnexus/test/unit/model/method-registry.test.ts new file mode 100644 index 000000000..bdb534202 --- /dev/null +++ b/gitnexus/test/unit/model/method-registry.test.ts @@ -0,0 +1,374 @@ +/** + * Unit tests for MethodRegistry (SM-20). + * + * MethodRegistry is the most complex of the three owner-scoped registries + * because it supports C++/Java/C# overloads. Lookup does two layers of + * narrowing after the primary `ownerNodeId + methodName` key match: + * + * 1. Arity filter: when `argCount` is provided and there are multiple + * overloads, keep only those whose parameterCount range can match. + * Variadic candidates (`parameterCount === undefined`) are retained. + * If arity excludes EVERY candidate, fall back to the full pool so + * fuzzy resolution still has something to work with (the "arity + * fallback" branch — flagged as an untested branch by the testing + * reviewer). + * + * 2. Return-type dedup: among the remaining candidates, if every def + * shares the same defined returnType, return the first. If return + * types differ, return undefined (truly ambiguous). + */ + +import { describe, it, expect } from 'vitest'; +import { createMethodRegistry } from '../../../src/core/ingestion/model/method-registry.js'; +import { makeMethod } from './helpers.js'; + +describe('MethodRegistry — basic lookup', () => { + it('returns undefined when the registry is empty', () => { + const reg = createMethodRegistry(); + expect(reg.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + }); + + it('register + lookup round-trips the def reference', () => { + const reg = createMethodRegistry(); + const def = makeMethod({ nodeId: 'method:User.save' }); + + reg.register('class:User', 'save', def); + + expect(reg.lookupMethodByOwner('class:User', 'save')).toBe(def); + }); + + it('isolates methods by ownerNodeId — same method name on two classes does not collide', () => { + const reg = createMethodRegistry(); + const userSave = makeMethod({ nodeId: 'method:User.save' }); + const orderSave = makeMethod({ nodeId: 'method:Order.save' }); + + reg.register('class:User', 'save', userSave); + reg.register('class:Order', 'save', orderSave); + + expect(reg.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('method:User.save'); + expect(reg.lookupMethodByOwner('class:Order', 'save')?.nodeId).toBe('method:Order.save'); + }); +}); + +describe('MethodRegistry — arity narrowing', () => { + it('narrows overloads by parameterCount when argCount is provided', () => { + const reg = createMethodRegistry(); + const greetEmpty = makeMethod({ nodeId: 'method:greet#0', parameterCount: 0 }); + const greetString = makeMethod({ + nodeId: 'method:greet#1', + parameterCount: 1, + returnType: 'void', + }); + + reg.register('class:User', 'greet', greetEmpty); + reg.register('class:User', 'greet', greetString); + + // argCount 0 matches only the 0-arg overload + expect(reg.lookupMethodByOwner('class:User', 'greet', 0)?.nodeId).toBe('method:greet#0'); + // argCount 1 matches only the 1-arg overload + expect(reg.lookupMethodByOwner('class:User', 'greet', 1)?.nodeId).toBe('method:greet#1'); + }); + + it('arity fallback — when no overload matches argCount, returns from the full pool (testing reviewer T-01)', () => { + // This is the explicit arity-fallback branch flagged as untested. + // Without the fallback, `save(1)` / `save(2)` with argCount=3 would + // return undefined. With the fallback, it returns one of them so the + // caller's fuzzy resolution path can still make progress. + const reg = createMethodRegistry(); + const save1 = makeMethod({ nodeId: 'method:save#1', parameterCount: 1, returnType: 'void' }); + const save2 = makeMethod({ nodeId: 'method:save#2', parameterCount: 2, returnType: 'void' }); + + reg.register('class:User', 'save', save1); + reg.register('class:User', 'save', save2); + + // argCount 3 matches neither; fallback returns one of them (first + // wins because both share the same returnType 'void'). + const result = reg.lookupMethodByOwner('class:User', 'save', 3); + expect(result).toBeDefined(); + expect(result?.nodeId).toBe('method:save#1'); + }); + + it('requiredParameterCount range — argCount between required and total is accepted (testing reviewer T-02)', () => { + // Default parameters: `bar(a, b=1, c=2)` has requiredParameterCount: 1, + // parameterCount: 3. Calls with argCount 1, 2, and 3 must all match. + const reg = createMethodRegistry(); + const bar = makeMethod({ + nodeId: 'method:bar', + parameterCount: 3, + requiredParameterCount: 1, + returnType: 'int', + }); + // Add a second overload so arity filtering engages (defs.length > 1). + const barOther = makeMethod({ + nodeId: 'method:bar#other', + parameterCount: 5, + requiredParameterCount: 5, + returnType: 'int', + }); + + reg.register('class:Calc', 'bar', bar); + reg.register('class:Calc', 'bar', barOther); + + expect(reg.lookupMethodByOwner('class:Calc', 'bar', 1)?.nodeId).toBe('method:bar'); + expect(reg.lookupMethodByOwner('class:Calc', 'bar', 2)?.nodeId).toBe('method:bar'); + expect(reg.lookupMethodByOwner('class:Calc', 'bar', 3)?.nodeId).toBe('method:bar'); + // argCount 5 matches the second overload only + expect(reg.lookupMethodByOwner('class:Calc', 'bar', 5)?.nodeId).toBe('method:bar#other'); + }); + + it('variadic fallback — defs with parameterCount=undefined are retained during arity narrowing', () => { + const reg = createMethodRegistry(); + const fixed = makeMethod({ + nodeId: 'method:print#fixed', + parameterCount: 1, + returnType: 'void', + }); + const variadic = makeMethod({ + nodeId: 'method:print#variadic', + parameterCount: undefined, + returnType: 'void', + }); + + reg.register('class:Logger', 'print', fixed); + reg.register('class:Logger', 'print', variadic); + + // argCount 5 excludes fixed (5 > parameterCount 1) but retains + // variadic (parameterCount=undefined bypasses the range check). + // Result: variadic is the only surviving candidate. + const result = reg.lookupMethodByOwner('class:Logger', 'print', 5); + expect(result?.nodeId).toBe('method:print#variadic'); + }); + + it('variadic + matching fixed — argCount in fixed range keeps both, first wins on shared returnType', () => { + const reg = createMethodRegistry(); + const fixed = makeMethod({ + nodeId: 'method:print#fixed', + parameterCount: 2, + returnType: 'void', + }); + const variadic = makeMethod({ + nodeId: 'method:print#variadic', + parameterCount: undefined, + returnType: 'void', + }); + + reg.register('class:Logger', 'print', fixed); + reg.register('class:Logger', 'print', variadic); + + // argCount 2 satisfies fixed's range AND keeps variadic. + // Both share returnType 'void', so first-registered wins. + const result = reg.lookupMethodByOwner('class:Logger', 'print', 2); + expect(result?.nodeId).toBe('method:print#fixed'); + }); +}); + +describe('MethodRegistry — return-type dedup', () => { + it('returns first when all overloads share the same returnType', () => { + const reg = createMethodRegistry(); + const a = makeMethod({ nodeId: 'method:a', parameterCount: 1, returnType: 'int' }); + const b = makeMethod({ nodeId: 'method:b', parameterCount: 1, returnType: 'int' }); + + reg.register('class:X', 'compute', a); + reg.register('class:X', 'compute', b); + + // Two overloads with same arity & same returnType → first wins + expect(reg.lookupMethodByOwner('class:X', 'compute', 1)?.nodeId).toBe('method:a'); + }); + + it('returns undefined when overloads differ in returnType (truly ambiguous)', () => { + const reg = createMethodRegistry(); + const intVersion = makeMethod({ + nodeId: 'method:int', + parameterCount: 1, + returnType: 'int', + }); + const stringVersion = makeMethod({ + nodeId: 'method:string', + parameterCount: 1, + returnType: 'string', + }); + + reg.register('class:X', 'compute', intVersion); + reg.register('class:X', 'compute', stringVersion); + + // Same arity, different returnType → undefined (ambiguous) + expect(reg.lookupMethodByOwner('class:X', 'compute', 1)).toBeUndefined(); + }); + + it('returns undefined when firstReturnType is itself undefined', () => { + const reg = createMethodRegistry(); + const a = makeMethod({ nodeId: 'method:a', parameterCount: 1, returnType: undefined }); + const b = makeMethod({ nodeId: 'method:b', parameterCount: 1, returnType: 'int' }); + + reg.register('class:X', 'compute', a); + reg.register('class:X', 'compute', b); + + // First def has no declared returnType → bail out as undefined + expect(reg.lookupMethodByOwner('class:X', 'compute', 1)).toBeUndefined(); + }); + + it('single-overload methods skip the dedup path', () => { + const reg = createMethodRegistry(); + reg.register( + 'class:X', + 'only', + makeMethod({ nodeId: 'method:only', parameterCount: 1, returnType: undefined }), + ); + + // Only one candidate → returned directly regardless of returnType + expect(reg.lookupMethodByOwner('class:X', 'only', 1)?.nodeId).toBe('method:only'); + }); +}); + +describe('MethodRegistry — clear()', () => { + it('empties the registry', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod()); + reg.register('class:Order', 'update', makeMethod()); + + reg.clear(); + + expect(reg.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(reg.lookupMethodByOwner('class:Order', 'update')).toBeUndefined(); + }); + + it('allows re-registration after clear', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:first' })); + reg.clear(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:second' })); + + expect(reg.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('method:second'); + }); +}); + +// --------------------------------------------------------------------------- +// lookupMethodByName — flat-by-name secondary index (A4 / plan 006) +// --------------------------------------------------------------------------- + +describe('MethodRegistry — lookupMethodByName', () => { + it('returns an empty array when no method with that name is registered', () => { + const reg = createMethodRegistry(); + expect(reg.lookupMethodByName('save')).toEqual([]); + }); + + it('returns a singleton array after one registration', () => { + const reg = createMethodRegistry(); + const def = makeMethod({ nodeId: 'method:User.save' }); + + reg.register('class:User', 'save', def); + + const result = reg.lookupMethodByName('save'); + expect(result).toHaveLength(1); + expect(result[0]).toBe(def); + }); + + it('accumulates homonym registrations across different owners in order', () => { + const reg = createMethodRegistry(); + const userSave = makeMethod({ nodeId: 'method:User.save' }); + const orderSave = makeMethod({ nodeId: 'method:Order.save' }); + + reg.register('class:User', 'save', userSave); + reg.register('class:Order', 'save', orderSave); + + const result = reg.lookupMethodByName('save'); + expect(result).toHaveLength(2); + expect(result).toEqual([userSave, orderSave]); + }); + + it('accumulates overloads under the same owner', () => { + const reg = createMethodRegistry(); + const overload1 = makeMethod({ nodeId: 'method:User.save#0', parameterCount: 0 }); + const overload2 = makeMethod({ nodeId: 'method:User.save#1', parameterCount: 1 }); + + reg.register('class:User', 'save', overload1); + reg.register('class:User', 'save', overload2); + + const result = reg.lookupMethodByName('save'); + expect(result).toHaveLength(2); + expect(result).toEqual([overload1, overload2]); + }); + + it('returns an empty array after clear()', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:old' })); + + reg.clear(); + + expect(reg.lookupMethodByName('save')).toEqual([]); + }); + + it('re-registering after clear only returns post-clear defs', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:old' })); + reg.clear(); + const fresh = makeMethod({ nodeId: 'method:fresh' }); + reg.register('class:User', 'save', fresh); + + const result = reg.lookupMethodByName('save'); + expect(result).toHaveLength(1); + expect(result[0]).toBe(fresh); + }); + + it('returns the same SymbolDefinition reference as lookupMethodByOwner (dual-index identity)', () => { + const reg = createMethodRegistry(); + const def = makeMethod({ nodeId: 'method:User.save' }); + + reg.register('class:User', 'save', def); + + const byOwner = reg.lookupMethodByOwner('class:User', 'save'); + const byName = reg.lookupMethodByName('save'); + + expect(byName).toHaveLength(1); + expect(Object.is(byName[0], byOwner)).toBe(true); + }); + + it('does not return methods with different names', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:User.save' })); + reg.register('class:User', 'load', makeMethod({ nodeId: 'method:User.load' })); + + expect(reg.lookupMethodByName('save')).toHaveLength(1); + expect(reg.lookupMethodByName('load')).toHaveLength(1); + expect(reg.lookupMethodByName('missing')).toEqual([]); + }); +}); + +describe('hasFunctionMethods flag', () => { + it('is false for a fresh registry', () => { + const reg = createMethodRegistry(); + expect(reg.hasFunctionMethods).toBe(false); + }); + + it('stays false after registering only strict-Method defs', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:User.save', type: 'Method' })); + reg.register( + 'class:User', + 'load', + makeMethod({ nodeId: 'method:User.load', type: 'Constructor' }), + ); + expect(reg.hasFunctionMethods).toBe(false); + }); + + it('flips to true when a Function-typed def (Python/Rust/Kotlin class method) is registered', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'greet', makeMethod({ nodeId: 'fn:User.greet', type: 'Function' })); + expect(reg.hasFunctionMethods).toBe(true); + }); + + it('stays true after further strict-Method registrations', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'greet', makeMethod({ nodeId: 'fn:User.greet', type: 'Function' })); + reg.register('class:Dog', 'bark', makeMethod({ nodeId: 'method:Dog.bark', type: 'Method' })); + expect(reg.hasFunctionMethods).toBe(true); + }); + + it('resets to false after clear()', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'greet', makeMethod({ nodeId: 'fn:User.greet', type: 'Function' })); + expect(reg.hasFunctionMethods).toBe(true); + reg.clear(); + expect(reg.hasFunctionMethods).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/model/registration-table.test.ts b/gitnexus/test/unit/model/registration-table.test.ts new file mode 100644 index 000000000..067b7fc20 --- /dev/null +++ b/gitnexus/test/unit/model/registration-table.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect } from 'vitest'; +import { + createRegistrationTable, + CALLABLE_ONLY_LABELS, + INERT_LABELS, + DISPATCH_LABELS, +} from '../../../src/core/ingestion/model/registration-table.js'; +import { createTypeRegistry } from '../../../src/core/ingestion/model/type-registry.js'; +import { createMethodRegistry } from '../../../src/core/ingestion/model/method-registry.js'; +import { createFieldRegistry } from '../../../src/core/ingestion/model/field-registry.js'; +import { ALL_NODE_LABELS } from '../../../src/core/ingestion/model/index.js'; +import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; +import { makeDef as makeBaseDef } from './helpers.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeDeps = () => ({ + types: createTypeRegistry(), + methods: createMethodRegistry(), + fields: createFieldRegistry(), +}); + +const makeDef = (overrides: Partial = {}): SymbolDefinition => + makeBaseDef({ nodeId: 'node:test', type: 'Class', ...overrides }); + +// --------------------------------------------------------------------------- +// Basic factory + table shape +// --------------------------------------------------------------------------- + +describe('createRegistrationTable', () => { + it('returns a Map with one entry per DISPATCH_LABELS value', () => { + const table = createRegistrationTable(makeDeps()); + expect(table.size).toBe(DISPATCH_LABELS.size); + for (const label of DISPATCH_LABELS) { + expect(table.has(label)).toBe(true); + } + }); + + it('every DISPATCH_LABELS entry maps to a hook function', () => { + const table = createRegistrationTable(makeDeps()); + for (const [, hook] of table) { + expect(typeof hook).toBe('function'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Kind taxonomy exhaustiveness +// --------------------------------------------------------------------------- + +describe('NodeLabel taxonomy coverage', () => { + // ALL_NODE_LABELS is imported from model/index.ts (re-exported from + // semantic-model.ts) so that the production list and the test list + // cannot drift. If the shared NodeLabel union gains a new member, add + // it to the single list in semantic-model.ts AND to one of the + // registration-table allowlists in the same commit. + + it('every NodeLabel appears in exactly one of DISPATCH / CALLABLE_ONLY / INERT', () => { + for (const label of ALL_NODE_LABELS) { + const inDispatch = DISPATCH_LABELS.has(label); + const inCallableOnly = CALLABLE_ONLY_LABELS.has(label); + const inInert = INERT_LABELS.has(label); + const count = Number(inDispatch) + Number(inCallableOnly) + Number(inInert); + expect(count, `label ${label} must be in exactly one category`).toBe(1); + } + }); + + it('CALLABLE_ONLY_LABELS includes Function, Macro, Delegate', () => { + expect(CALLABLE_ONLY_LABELS.has('Function')).toBe(true); + expect(CALLABLE_ONLY_LABELS.has('Macro')).toBe(true); + expect(CALLABLE_ONLY_LABELS.has('Delegate')).toBe(true); + }); + + it('DISPATCH_LABELS includes all 10 routed kinds', () => { + const expected = [ + 'Class', + 'Struct', + 'Interface', + 'Enum', + 'Record', + 'Trait', + 'Method', + 'Constructor', + 'Property', + 'Impl', + ] as const; + for (const label of expected) { + expect(DISPATCH_LABELS.has(label)).toBe(true); + } + expect(DISPATCH_LABELS.size).toBe(expected.length); + }); + + it('INERT_LABELS includes metadata-only node kinds', () => { + expect(INERT_LABELS.has('File')).toBe(true); + expect(INERT_LABELS.has('Folder')).toBe(true); + expect(INERT_LABELS.has('Namespace')).toBe(true); + expect(INERT_LABELS.has('Variable')).toBe(true); + expect(INERT_LABELS.has('Import')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Behavior group coverage — every label in a behavior group routes to the +// group's registry write, regardless of how hooks are implemented (shared +// closure, per-label closure, etc.). These tests survive an internal +// refactor to per-label closures for tracing/metrics — unlike +// reference-equality assertions on the hook functions themselves. +// --------------------------------------------------------------------------- + +describe('class-like behavior group — all 6 labels route to types.registerClass', () => { + const CLASS_LIKE_LABELS = ['Class', 'Struct', 'Interface', 'Enum', 'Record', 'Trait'] as const; + + for (const label of CLASS_LIKE_LABELS) { + it(`${label} writes to types.registerClass`, () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ + nodeId: `${label.toLowerCase()}:User`, + type: label, + qualifiedName: `app.User`, + }); + table.get(label)!('User', def); + expect(deps.types.lookupClassByName('User')).toHaveLength(1); + }); + } +}); + +describe('method-like behavior group — Method and Constructor route to methods.register', () => { + for (const label of ['Method', 'Constructor'] as const) { + it(`${label} writes to methods.register when ownerId is set`, () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ + nodeId: `${label.toLowerCase()}:save`, + type: label, + ownerId: 'class:User', + }); + table.get(label)!('save', def); + expect(deps.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe( + `${label.toLowerCase()}:save`, + ); + }); + } +}); + +describe('behavior group isolation', () => { + it('class-like hooks never touch methods or fields', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ + nodeId: 'class:User', + type: 'Class', + ownerId: 'unrelated', + }); + table.get('Class')!('User', def); + // No method or field registered — class hook is isolated to types. + expect(deps.methods.lookupMethodByOwner('unrelated', 'User')).toBeUndefined(); + expect(deps.fields.lookupFieldByOwner('unrelated', 'User')).toBeUndefined(); + }); + + it('Impl hooks write to types.registerImpl, never to types.registerClass', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'impl:User', type: 'Impl' }); + table.get('Impl')!('User', def); + expect(deps.types.lookupImplByName('User')).toHaveLength(1); + expect(deps.types.lookupClassByName('User')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end hook behavior with real registries +// --------------------------------------------------------------------------- + +describe('hook behavior (real registries, no mocks)', () => { + it('classLikeHook writes to types.registerClass', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'class:User', type: 'Class', qualifiedName: 'app.User' }); + table.get('Class')!('User', def); + expect(deps.types.lookupClassByName('User')).toHaveLength(1); + expect(deps.types.lookupClassByQualifiedName('app.User')).toHaveLength(1); + }); + + it('classLikeHook falls back to the simple name when qualifiedName is absent', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'class:User', type: 'Class' }); + table.get('Class')!('User', def); + expect(deps.types.lookupClassByQualifiedName('User')).toHaveLength(1); + }); + + it('methodHook writes to methods.register when ownerId is set', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ + nodeId: 'mtd:save', + type: 'Method', + ownerId: 'class:User', + }); + table.get('Method')!('save', def); + expect(deps.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('mtd:save'); + }); + + it('methodHook silently skips registration when ownerId is missing', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'mtd:free', type: 'Method' }); + table.get('Method')!('free', def); + expect(deps.methods.lookupMethodByOwner('', 'free')).toBeUndefined(); + }); + + it('propertyHook writes to fields.register when ownerId is set', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ + nodeId: 'prop:name', + type: 'Property', + ownerId: 'class:User', + declaredType: 'string', + }); + table.get('Property')!('name', def); + expect(deps.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:name'); + }); + + it('propertyHook silently skips registration when ownerId is missing', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'prop:orphan', type: 'Property' }); + table.get('Property')!('orphan', def); + expect(deps.fields.lookupFieldByOwner('', 'orphan')).toBeUndefined(); + }); + + it('implHook writes to types.registerImpl, NOT types.registerClass', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'impl:User', type: 'Impl' }); + table.get('Impl')!('User', def); + expect(deps.types.lookupImplByName('User')).toHaveLength(1); + // Critical: Impl must not pollute classByName — heritage resolution + // would otherwise treat an Impl as a parent type candidate. + expect(deps.types.lookupClassByName('User')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Factory-per-instance isolation +// --------------------------------------------------------------------------- + +describe('factory-per-instance isolation', () => { + it('two independent tables write to their own registries only', () => { + const depsA = makeDeps(); + const depsB = makeDeps(); + const tableA = createRegistrationTable(depsA); + const tableB = createRegistrationTable(depsB); + + tableA.get('Class')!('UserA', makeDef({ nodeId: 'class:UserA', type: 'Class' })); + tableB.get('Class')!('UserB', makeDef({ nodeId: 'class:UserB', type: 'Class' })); + + expect(depsA.types.lookupClassByName('UserA')).toHaveLength(1); + expect(depsA.types.lookupClassByName('UserB')).toHaveLength(0); + expect(depsB.types.lookupClassByName('UserB')).toHaveLength(1); + expect(depsB.types.lookupClassByName('UserA')).toHaveLength(0); + }); +}); diff --git a/gitnexus/test/unit/model/resolution-context.test.ts b/gitnexus/test/unit/model/resolution-context.test.ts new file mode 100644 index 000000000..aae0a5073 --- /dev/null +++ b/gitnexus/test/unit/model/resolution-context.test.ts @@ -0,0 +1,173 @@ +/** + * Unit tests for `ResolutionContext.resolve()` — the tiered name + * resolution that backs call-processor's Tier 1 / 2a-named / 2a / 2b / 3 + * pipeline. These tests pin invariants that TypeScript cannot prove at + * build time: tier precedence, cross-index dedup, and the + * walkBindingChain cycle/depth guards. + */ + +import { describe, it, expect } from 'vitest'; +import { createResolutionContext } from '../../../src/core/ingestion/model/resolution-context.js'; + +describe('ResolutionContext.resolve() — tier precedence', () => { + it('Tier 2a-named binding chain takes precedence over Tier 2a import-scoped', () => { + // Setup: A imports { User as U } from B. B defines both User (the + // real one) and U (an unrelated same-name symbol). A resolve('U') in + // file A must prefer the aliased binding chain (U → User in B), + // NOT the raw Tier 2a lookup that would find B's own 'U'. + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/b.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/b.ts', 'U', 'class:U_decoy', 'Class'); + + // Register the import A → B and the aliased binding A.U → B.User. + ctx.importMap.set('src/a.ts', new Set(['src/b.ts'])); + const aliasBindings = new Map(); + aliasBindings.set('U', { sourcePath: 'src/b.ts', exportedName: 'User' }); + ctx.namedImportMap.set('src/a.ts', aliasBindings); + + const result = ctx.resolve('U', 'src/a.ts'); + expect(result).not.toBeNull(); + expect(result!.tier).toBe('import-scoped'); + // The named-binding chain resolves U → User, not U → U_decoy. + expect(result!.candidates.map((c) => c.nodeId)).toEqual(['class:User']); + }); + + it('Tier 1 (same-file) beats Tier 2a even when an aliased import exists', () => { + // Belt-and-suspenders check: if the caller's own file has a matching + // symbol, it wins — aliased bindings only fire when Tier 1 misses. + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/a.ts', 'U', 'fn:local:U', 'Function'); + ctx.model.symbols.add('src/b.ts', 'User', 'class:User', 'Class'); + + const aliasBindings = new Map(); + aliasBindings.set('U', { sourcePath: 'src/b.ts', exportedName: 'User' }); + ctx.namedImportMap.set('src/a.ts', aliasBindings); + + const result = ctx.resolve('U', 'src/a.ts'); + expect(result!.tier).toBe('same-file'); + expect(result!.candidates[0].nodeId).toBe('fn:local:U'); + }); +}); + +describe('ResolutionContext.resolve() — Tier 3 dedup for Function+ownerId', () => { + it('Python/Rust/Kotlin class methods emitted as Function+ownerId land in only one Tier 3 result', () => { + // Simulate the Python worker path: a class method is emitted with + // type='Function' and ownerId set. `rawSymbols.add` lands it in + // callableByName (via the Function callable-index gate) AND + // `wrappedAdd` normalizes the dispatch key to 'Method' so it also + // lands in methodRegistry. The same SymbolDefinition reference is + // reachable via two Tier 3 lookups. + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/user.py', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.py', 'greet', 'fn:User.greet', 'Function', { + ownerId: 'class:User', + returnType: 'str', + }); + + // Sanity check the setup: the same def is in both indexes. + expect(ctx.model.symbols.lookupCallableByName('greet')).toHaveLength(1); + expect(ctx.model.methods.lookupMethodByName('greet')).toHaveLength(1); + expect(ctx.model.methods.hasFunctionMethods).toBe(true); + + // Resolve a free 'greet' call from an unrelated file — Tier 1 / 2a / + // 2b all miss, so Tier 3 fires. The dedup pass must collapse the + // two index hits into a single candidate. + const result = ctx.resolve('greet', 'src/caller.py'); + expect(result).not.toBeNull(); + expect(result!.tier).toBe('global'); + expect(result!.candidates).toHaveLength(1); + expect(result!.candidates[0].nodeId).toBe('fn:User.greet'); + }); + + it('Tier 3 fast path fires when no Function+ownerId was ever registered', () => { + // Pure TypeScript-style: methods are emitted as strict Method labels, + // so callableByName and methodRegistry are disjoint and the dedup + // fast path can concat without a Set allocation. + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'greet', 'method:User.greet', 'Method', { + ownerId: 'class:User', + returnType: 'string', + }); + ctx.model.symbols.add('src/utils.ts', 'greet', 'fn:utils.greet', 'Function'); + + expect(ctx.model.methods.hasFunctionMethods).toBe(false); + + // Tier 3 for 'greet' from an unrelated file returns both the free + // function and the class method; neither overlaps so no dedup. + const result = ctx.resolve('greet', 'src/caller.ts'); + expect(result).not.toBeNull(); + expect(result!.tier).toBe('global'); + expect(result!.candidates.map((c) => c.nodeId).sort()).toEqual([ + 'fn:utils.greet', + 'method:User.greet', + ]); + }); +}); + +describe('ResolutionContext.resolve() — walkBindingChain guards', () => { + it('circular re-export returns null (cycle detection fires)', () => { + // A imports { X } from B, B re-exports { X } from A. + // walkBindingChain must detect the cycle via the visited Set and + // return null instead of looping until depth exceeded. + const ctx = createResolutionContext(); + // Intentionally leave X undefined in both files — the walker only + // follows re-export edges, not definitions. + const aBindings = new Map(); + aBindings.set('X', { sourcePath: 'src/b.ts', exportedName: 'X' }); + ctx.namedImportMap.set('src/a.ts', aBindings); + const bBindings = new Map(); + bBindings.set('X', { sourcePath: 'src/a.ts', exportedName: 'X' }); + ctx.namedImportMap.set('src/b.ts', bBindings); + + const result = ctx.resolve('X', 'src/a.ts'); + // No definition anywhere in the chain → Tier 2a-named returns null, + // nothing else matches, overall result is null. + expect(result).toBeNull(); + }); + + it('chain deeper than MAX_BINDING_CHAIN_DEPTH drops the named-binding path', () => { + // Build a six-hop re-export chain where every hop just forwards the + // binding. walkBindingChain iterates 5 times and hits the depth cap + // before the sixth hop, returning null. No other tier can resolve + // 'X' either (no X is registered anywhere), so the overall + // `ctx.resolve` call returns null. + const ctx = createResolutionContext(); + const chain = [ + 'src/a.ts', + 'src/b.ts', + 'src/c.ts', + 'src/d.ts', + 'src/e.ts', + 'src/f.ts', + 'src/g.ts', + ]; + for (let i = 0; i < chain.length - 1; i++) { + const bindings = new Map(); + bindings.set('X', { sourcePath: chain[i + 1], exportedName: 'X' }); + ctx.namedImportMap.set(chain[i], bindings); + } + // No symbol registered in any file — the chain walk is the only + // possible resolution path, and the depth cap silently kills it. + const result = ctx.resolve('X', 'src/a.ts'); + expect(result).toBeNull(); + }); + + it('chain of exactly five hops resolves successfully at the boundary', () => { + // Five hops from A is exactly MAX_BINDING_CHAIN_DEPTH — the final + // lookup on the fifth hop must succeed. + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/e.ts', 'X', 'class:X', 'Class'); + const chain = ['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts', 'src/e.ts']; + for (let i = 0; i < chain.length - 1; i++) { + const bindings = new Map(); + bindings.set('X', { sourcePath: chain[i + 1], exportedName: 'X' }); + ctx.namedImportMap.set(chain[i], bindings); + } + + const result = ctx.resolve('X', 'src/a.ts'); + expect(result).not.toBeNull(); + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates[0].nodeId).toBe('class:X'); + }); +}); diff --git a/gitnexus/test/unit/model/semantic-model.test.ts b/gitnexus/test/unit/model/semantic-model.test.ts new file mode 100644 index 000000000..1e86ee5d3 --- /dev/null +++ b/gitnexus/test/unit/model/semantic-model.test.ts @@ -0,0 +1,124 @@ +/** + * Unit tests for SemanticModel factory and lifecycle. + * + * Focused on behaviors that are NOT covered by the transitive + * ingestion-pipeline tests in symbol-table.test.ts: + * + * 1. model.clear() must cascade to all four stores (types, methods, + * fields, rawSymbols). Post-A2 (plan 006 Unit 7), this is the only + * path that resets the leaf AND the registries. External consumers + * hold a SymbolTableReader which has no `clear()` method, so the + * phantom-resolution failure mode is statically impossible. + * + * 2. createSemanticModel() must construct successfully against the + * real ALL_NODE_LABELS and current registration-table allowlists. + * A failure here means the dev-time exhaustiveness guard is + * flagging real drift that needs a registration-table fix. + */ + +import { describe, it, expect } from 'vitest'; +import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; + +describe('createSemanticModel', () => { + it('constructs successfully — no drift between ALL_NODE_LABELS and the registration-table allowlists', () => { + expect(() => createSemanticModel()).not.toThrow(); + }); +}); + +describe('model.clear() cascade (A2 / Unit 7)', () => { + it('clears the type registry', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + + expect(model.types.lookupClassByName('User')).toHaveLength(1); + + model.clear(); + + expect(model.types.lookupClassByName('User')).toHaveLength(0); + }); + + it('clears the field registry', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + declaredType: 'string', + }); + + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeDefined(); + + model.clear(); + + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + }); + + it('clears the method registry', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'greet', 'method:User.greet', 'Method', { + ownerId: 'class:User', + }); + + expect(model.methods.lookupMethodByOwner('class:User', 'greet')).toBeDefined(); + + model.clear(); + + expect(model.methods.lookupMethodByOwner('class:User', 'greet')).toBeUndefined(); + }); + + it('clears the file and callable indexes', () => { + const model = createSemanticModel(); + model.symbols.add('src/utils.ts', 'format', 'fn:format', 'Function'); + + expect(model.symbols.lookupCallableByName('format')).toHaveLength(1); + expect(Array.from(model.symbols.getFiles())).toContain('src/utils.ts'); + + model.clear(); + + expect(model.symbols.lookupCallableByName('format')).toHaveLength(0); + expect(Array.from(model.symbols.getFiles())).not.toContain('src/utils.ts'); + }); + + it('is idempotent — calling twice leaves every store empty', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + }); + + model.clear(); + model.clear(); + + expect(model.types.lookupClassByName('User')).toHaveLength(0); + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(model.symbols.lookupCallableByName('User')).toHaveLength(0); + }); + + it('post-A2: model.symbols exposes no clear() method', () => { + // Static guarantee enforced by the SymbolTableReader interface — this + // runtime assertion documents the contract. + const model = createSemanticModel(); + expect('clear' in model.symbols).toBe(false); + }); +}); + +describe('model.clear() cascade', () => { + it('clears every store — types, methods, fields, symbols', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + }); + model.symbols.add('src/user.ts', 'greet', 'method:User.greet', 'Method', { + ownerId: 'class:User', + }); + + model.clear(); + + expect(model.types.lookupClassByName('User')).toHaveLength(0); + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:User', 'greet')).toBeUndefined(); + expect(model.symbols.lookupCallableByName('User')).toHaveLength(0); + expect(Array.from(model.symbols.getFiles())).not.toContain('src/user.ts'); + }); +}); diff --git a/gitnexus/test/unit/model/type-registry.test.ts b/gitnexus/test/unit/model/type-registry.test.ts new file mode 100644 index 000000000..8f0eee838 --- /dev/null +++ b/gitnexus/test/unit/model/type-registry.test.ts @@ -0,0 +1,146 @@ +/** + * Unit tests for TypeRegistry (SM-20). + * + * TypeRegistry owns three indexes: classByName (simple name → defs), + * classByQualifiedName (FQN → defs), and implByName (Rust impl blocks). + * All three use array values to support homonym classes across files + * (e.g. two `User` classes in different packages) and Rust's multiple + * impl blocks per type. + */ + +import { describe, it, expect } from 'vitest'; +import { createTypeRegistry } from '../../../src/core/ingestion/model/type-registry.js'; +import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; +import { makeDef as makeBaseDef } from './helpers.js'; + +const makeDef = (overrides: Partial = {}): SymbolDefinition => + makeBaseDef({ nodeId: 'class:test', type: 'Class', ...overrides }); + +describe('TypeRegistry — classByName lookup', () => { + it('returns an empty array when the class is not registered', () => { + const reg = createTypeRegistry(); + expect(reg.lookupClassByName('Nonexistent')).toEqual([]); + }); + + it('returns the def reference after register', () => { + const reg = createTypeRegistry(); + const def = makeDef({ nodeId: 'class:User' }); + + reg.registerClass('User', 'app.User', def); + + expect(reg.lookupClassByName('User')).toEqual([def]); + }); + + it('accumulates homonym classes across files — second register appends, does not clobber', () => { + const reg = createTypeRegistry(); + const userApp = makeDef({ nodeId: 'class:app.User', filePath: 'src/app/user.ts' }); + const userAdmin = makeDef({ nodeId: 'class:admin.User', filePath: 'src/admin/user.ts' }); + + reg.registerClass('User', 'app.User', userApp); + reg.registerClass('User', 'admin.User', userAdmin); + + const result = reg.lookupClassByName('User'); + expect(result).toHaveLength(2); + expect(result.map((d) => d.nodeId)).toEqual(['class:app.User', 'class:admin.User']); + }); +}); + +describe('TypeRegistry — classByQualifiedName lookup', () => { + it('returns empty when the FQN is not registered', () => { + const reg = createTypeRegistry(); + expect(reg.lookupClassByQualifiedName('app.User')).toEqual([]); + }); + + it('returns the def after register', () => { + const reg = createTypeRegistry(); + const def = makeDef({ nodeId: 'class:app.User' }); + + reg.registerClass('User', 'app.User', def); + + expect(reg.lookupClassByQualifiedName('app.User')).toEqual([def]); + }); + + it('disambiguates homonym classes — same simple name, different FQNs resolve independently', () => { + const reg = createTypeRegistry(); + const userApp = makeDef({ nodeId: 'class:app.User' }); + const userAdmin = makeDef({ nodeId: 'class:admin.User' }); + + reg.registerClass('User', 'app.User', userApp); + reg.registerClass('User', 'admin.User', userAdmin); + + // Simple name returns both; qualified lookups split cleanly. + expect(reg.lookupClassByName('User')).toHaveLength(2); + expect(reg.lookupClassByQualifiedName('app.User')).toEqual([userApp]); + expect(reg.lookupClassByQualifiedName('admin.User')).toEqual([userAdmin]); + }); + + it('partial classes — two defs with the same FQN accumulate in both indexes', () => { + // C#-style partial classes: same simple and qualified name in different + // files. Both classByName and classByQualifiedName should return both. + const reg = createTypeRegistry(); + const partialA = makeDef({ nodeId: 'class:User#a', filePath: 'src/User.Core.cs' }); + const partialB = makeDef({ nodeId: 'class:User#b', filePath: 'src/User.Api.cs' }); + + reg.registerClass('User', 'app.User', partialA); + reg.registerClass('User', 'app.User', partialB); + + expect(reg.lookupClassByName('User')).toHaveLength(2); + expect(reg.lookupClassByQualifiedName('app.User')).toHaveLength(2); + }); +}); + +describe('TypeRegistry — implByName (Rust impl blocks)', () => { + it('returns empty when no impls registered', () => { + const reg = createTypeRegistry(); + expect(reg.lookupImplByName('User')).toEqual([]); + }); + + it('registerImpl stores Rust impl blocks separately from classes', () => { + const reg = createTypeRegistry(); + const userClass = makeDef({ nodeId: 'class:User', type: 'Struct' }); + const userImpl = makeDef({ nodeId: 'impl:User', type: 'Impl' }); + + reg.registerClass('User', 'crate::User', userClass); + reg.registerImpl('User', userImpl); + + expect(reg.lookupClassByName('User')).toEqual([userClass]); + expect(reg.lookupImplByName('User')).toEqual([userImpl]); + }); + + it('accumulates multiple impl blocks for the same type (Rust allows several)', () => { + const reg = createTypeRegistry(); + const implA = makeDef({ nodeId: 'impl:User#inherent', type: 'Impl' }); + const implB = makeDef({ nodeId: 'impl:User#Display', type: 'Impl' }); + + reg.registerImpl('User', implA); + reg.registerImpl('User', implB); + + const impls = reg.lookupImplByName('User'); + expect(impls).toHaveLength(2); + expect(impls.map((d) => d.nodeId)).toEqual(['impl:User#inherent', 'impl:User#Display']); + }); +}); + +describe('TypeRegistry — clear()', () => { + it('empties all three indexes', () => { + const reg = createTypeRegistry(); + reg.registerClass('User', 'app.User', makeDef()); + reg.registerImpl('User', makeDef({ type: 'Impl' })); + + reg.clear(); + + expect(reg.lookupClassByName('User')).toEqual([]); + expect(reg.lookupClassByQualifiedName('app.User')).toEqual([]); + expect(reg.lookupImplByName('User')).toEqual([]); + }); + + it('allows re-registration after clear', () => { + const reg = createTypeRegistry(); + reg.registerClass('User', 'app.User', makeDef({ nodeId: 'class:first' })); + reg.clear(); + reg.registerClass('User', 'app.User', makeDef({ nodeId: 'class:second' })); + + expect(reg.lookupClassByName('User')).toHaveLength(1); + expect(reg.lookupClassByName('User')[0].nodeId).toBe('class:second'); + }); +}); diff --git a/gitnexus/test/unit/sequential-language-availability.test.ts b/gitnexus/test/unit/sequential-language-availability.test.ts index 5b84f6702..3d804f93e 100644 --- a/gitnexus/test/unit/sequential-language-availability.test.ts +++ b/gitnexus/test/unit/sequential-language-availability.test.ts @@ -14,7 +14,7 @@ import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { processImports } from '../../src/core/ingestion/import-processor.js'; import { processCalls } from '../../src/core/ingestion/call-processor.js'; import { processHeritage } from '../../src/core/ingestion/heritage-processor.js'; -import { createResolutionContext } from '../../src/core/ingestion/resolution-context.js'; +import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js'; import * as parserLoader from '../../src/core/tree-sitter/parser-loader.js'; describe('sequential native parser availability', () => { diff --git a/gitnexus/test/unit/symbol-resolver.test.ts b/gitnexus/test/unit/symbol-resolver.test.ts index 67797e61f..dee9cdecc 100644 --- a/gitnexus/test/unit/symbol-resolver.test.ts +++ b/gitnexus/test/unit/symbol-resolver.test.ts @@ -2,9 +2,10 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/resolution-context.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; -import { isFileInPackageDir } from '../../src/core/ingestion/import-processor.js'; +} from '../../src/core/ingestion/model/resolution-context.js'; +import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js'; +import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js'; +import { isFileInPackageDir } from '../../src/core/ingestion/model/resolution-context.js'; /** Helper: resolve to single best definition (refuses ambiguous global) */ const resolveOne = (ctx: ResolutionContext, name: string, fromFile: string) => { @@ -35,7 +36,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { describe('Tier 1: Same-file resolution', () => { it('resolves symbol defined in the same file', () => { - ctx.symbols.add('src/models/user.ts', 'User', 'Class:src/models/user.ts:User', 'Class'); + ctx.model.symbols.add('src/models/user.ts', 'User', 'Class:src/models/user.ts:User', 'Class'); const result = resolveOne(ctx, 'User', 'src/models/user.ts'); @@ -46,8 +47,8 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('prefers same-file over imported definition', () => { - ctx.symbols.add('src/local.ts', 'Config', 'Class:src/local.ts:Config', 'Class'); - ctx.symbols.add('src/shared.ts', 'Config', 'Class:src/shared.ts:Config', 'Class'); + ctx.model.symbols.add('src/local.ts', 'Config', 'Class:src/local.ts:Config', 'Class'); + ctx.model.symbols.add('src/shared.ts', 'Config', 'Class:src/shared.ts:Config', 'Class'); ctx.importMap.set('src/local.ts', new Set(['src/shared.ts'])); const result = resolveOne(ctx, 'Config', 'src/local.ts'); @@ -59,7 +60,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { describe('Tier 2: Import-scoped resolution', () => { it('resolves symbol from an imported file', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/services/auth.ts', 'AuthService', 'Class:src/services/auth.ts:AuthService', @@ -75,13 +76,13 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('prefers imported definition over non-imported with same name', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/services/logger.ts', 'Logger', 'Class:src/services/logger.ts:Logger', 'Class', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/testing/mock-logger.ts', 'Logger', 'Class:src/testing/mock-logger.ts:Logger', @@ -96,7 +97,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('handles file with no imports — unique global falls through', () => { - ctx.symbols.add('src/utils.ts', 'Helper', 'Class:src/utils.ts:Helper', 'Class'); + ctx.model.symbols.add('src/utils.ts', 'Helper', 'Class:src/utils.ts:Helper', 'Class'); const result = resolveOne(ctx, 'Helper', 'src/app.ts'); @@ -107,7 +108,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { describe('Tier 3: Global resolution', () => { it('resolves unique global when not in imports', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/external/base.ts', 'BaseModel', 'Class:src/external/base.ts:BaseModel', @@ -122,8 +123,8 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('refuses ambiguous global — returns null when multiple candidates exist', () => { - ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); - ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); const result = resolveOne(ctx, 'Config', 'src/other.ts'); @@ -131,8 +132,8 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('ctx.resolve returns all candidates at global tier (consumers decide)', () => { - ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); - ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); const tiered = ctx.resolve('Config', 'src/other.ts'); @@ -156,7 +157,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { describe('type preservation', () => { it('preserves Interface type for heritage resolution', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/interfaces.ts', 'ILogger', 'Interface:src/interfaces.ts:ILogger', @@ -170,7 +171,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('preserves Class type for heritage resolution', () => { - ctx.symbols.add('src/base.ts', 'BaseService', 'Class:src/base.ts:BaseService', 'Class'); + ctx.model.symbols.add('src/base.ts', 'BaseService', 'Class:src/base.ts:BaseService', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/base.ts'])); const result = resolveOne(ctx, 'BaseService', 'src/app.ts'); @@ -181,13 +182,13 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { describe('heritage-specific scenarios', () => { it('resolves C# interface vs class ambiguity via imports', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/logging/ilogger.cs', 'ILogger', 'Interface:src/logging/ilogger.cs:ILogger', 'Interface', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/testing/ilogger.cs', 'ILogger', 'Class:src/testing/ilogger.cs:ILogger', @@ -202,13 +203,13 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('resolves parent class from imported file for extends', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/api/controller.ts', 'UserController', 'Class:src/api/controller.ts:UserController', 'Class', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/base/controller.ts', 'BaseController', 'Class:src/base/controller.ts:BaseController', @@ -231,7 +232,7 @@ describe('ResolutionContext.resolve — tier metadata', () => { }); it('returns same-file tier for Tier 1 match', () => { - ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); const result = resolveInternal(ctx, 'Foo', 'src/a.ts'); @@ -242,8 +243,8 @@ describe('ResolutionContext.resolve — tier metadata', () => { }); it('returns import-scoped tier for Tier 2 match', () => { - ctx.symbols.add('src/logger.ts', 'Logger', 'Class:src/logger.ts:Logger', 'Class'); - ctx.symbols.add('src/mock.ts', 'Logger', 'Class:src/mock.ts:Logger', 'Class'); + ctx.model.symbols.add('src/logger.ts', 'Logger', 'Class:src/logger.ts:Logger', 'Class'); + ctx.model.symbols.add('src/mock.ts', 'Logger', 'Class:src/mock.ts:Logger', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/logger.ts'])); const result = resolveInternal(ctx, 'Logger', 'src/app.ts'); @@ -253,7 +254,7 @@ describe('ResolutionContext.resolve — tier metadata', () => { }); it('returns global tier for Tier 3 match', () => { - ctx.symbols.add('src/only.ts', 'Singleton', 'Class:src/only.ts:Singleton', 'Class'); + ctx.model.symbols.add('src/only.ts', 'Singleton', 'Class:src/only.ts:Singleton', 'Class'); const result = resolveInternal(ctx, 'Singleton', 'src/other.ts'); @@ -263,8 +264,8 @@ describe('ResolutionContext.resolve — tier metadata', () => { }); it('returns null for ambiguous global — refuses to guess', () => { - ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); - ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); const result = resolveInternal(ctx, 'Config', 'src/other.ts'); @@ -277,8 +278,8 @@ describe('ResolutionContext.resolve — tier metadata', () => { }); it('Tier 1 wins over Tier 2 — same-file takes priority', () => { - ctx.symbols.add('src/app.ts', 'Util', 'Function:src/app.ts:Util', 'Function'); - ctx.symbols.add('src/lib.ts', 'Util', 'Function:src/lib.ts:Util', 'Function'); + ctx.model.symbols.add('src/app.ts', 'Util', 'Function:src/app.ts:Util', 'Function'); + ctx.model.symbols.add('src/lib.ts', 'Util', 'Function:src/lib.ts:Util', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/lib.ts'])); const result = resolveInternal(ctx, 'Util', 'src/app.ts'); @@ -296,13 +297,13 @@ describe('negative tests — ambiguous refusal per language family', () => { }); it('TS/JS: two Logger definitions with no import → returns null', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/services/logger.ts', 'Logger', 'Class:src/services/logger.ts:Logger', 'Class', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/testing/logger.ts', 'Logger', 'Class:src/testing/logger.ts:Logger', @@ -314,13 +315,13 @@ describe('negative tests — ambiguous refusal per language family', () => { }); it('Java: same-named class in different packages, no import → returns null', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'com/example/models/User.java', 'User', 'Class:com/example/models/User.java:User', 'Class', ); - ctx.symbols.add( + ctx.model.symbols.add( 'com/example/dto/User.java', 'User', 'Class:com/example/dto/User.java:User', @@ -332,8 +333,8 @@ describe('negative tests — ambiguous refusal per language family', () => { }); it('C/C++: type defined in transitively-included header → returns null (not reachable via direct import)', () => { - ctx.symbols.add('src/c.h', 'Widget', 'Struct:src/c.h:Widget', 'Struct'); - ctx.symbols.add('src/d.h', 'Widget', 'Struct:src/d.h:Widget', 'Struct'); + ctx.model.symbols.add('src/c.h', 'Widget', 'Struct:src/c.h:Widget', 'Struct'); + ctx.model.symbols.add('src/d.h', 'Widget', 'Struct:src/d.h:Widget', 'Struct'); ctx.importMap.set('src/a.c', new Set(['src/b.h'])); const result = resolveOne(ctx, 'Widget', 'src/a.c'); @@ -341,13 +342,13 @@ describe('negative tests — ambiguous refusal per language family', () => { }); it('C#: two IService interfaces in different namespaces, no import → returns null', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/Services/IService.cs', 'IService', 'Interface:src/Services/IService.cs:IService', 'Interface', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/Testing/IService.cs', 'IService', 'Interface:src/Testing/IService.cs:IService', @@ -367,13 +368,13 @@ describe('heritage false-positive guard', () => { }); it('null from resolve prevents false edge — generateId fallback produces synthetic ID, not wrong match', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/api/base.ts', 'BaseController', 'Class:src/api/base.ts:BaseController', 'Class', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/testing/base.ts', 'BaseController', 'Class:src/testing/base.ts:BaseController', @@ -390,6 +391,14 @@ describe('heritage false-positive guard', () => { }); }); +// These two describe blocks (`lookupExactFull` and `SM-16: SymbolTable.getFiles()`) +// intentionally use `createSymbolTable()` directly instead of going through +// `createSemanticModel()`. The behaviors under test belong to the pure DAG +// leaf — file/callable indexes, getFiles iterator — and do not involve the +// owner-scoped registries. Testing them on the bare leaf keeps the unit +// isolated. Do not migrate these blocks to createSemanticModel() "for +// consistency" — that would add unused registry setup and weaken the +// isolation property. describe('lookupExactFull', () => { it('returns full SymbolDefinition for same-file lookup via O(1) direct storage', () => { const symbolTable = createSymbolTable(); @@ -476,7 +485,7 @@ describe('Tier 2b: PackageMap resolution (Go)', () => { }); it('resolves symbol via PackageMap when not in ImportMap', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'HandleLogin', 'Function:internal/auth/handler.go:HandleLogin', @@ -492,7 +501,7 @@ describe('Tier 2b: PackageMap resolution (Go)', () => { }); it('does not resolve symbol from wrong package', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/db/connection.go', 'Connect', 'Function:internal/db/connection.go:Connect', @@ -508,13 +517,13 @@ describe('Tier 2b: PackageMap resolution (Go)', () => { }); it('Tier 2a (ImportMap) takes precedence over Tier 2b (PackageMap)', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'Validate', 'Function:internal/auth/handler.go:Validate', 'Function', ); - ctx.symbols.add( + ctx.model.symbols.add( 'internal/db/validator.go', 'Validate', 'Function:internal/db/validator.go:Validate', @@ -532,13 +541,13 @@ describe('Tier 2b: PackageMap resolution (Go)', () => { }); it('resolves both symbols in same imported package', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'Run', 'Function:internal/auth/handler.go:Run', 'Function', ); - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/worker.go', 'Run', 'Function:internal/auth/worker.go:Run', @@ -554,13 +563,18 @@ describe('Tier 2b: PackageMap resolution (Go)', () => { }); it('returns global without packageMap when ambiguous', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'X', 'Function:internal/auth/handler.go:X', 'Function', ); - ctx.symbols.add('internal/db/handler.go', 'X', 'Function:internal/db/handler.go:X', 'Function'); + ctx.model.symbols.add( + 'internal/db/handler.go', + 'X', + 'Function:internal/db/handler.go:X', + 'Function', + ); const result = resolveInternal(ctx, 'X', 'cmd/main.go'); @@ -577,7 +591,7 @@ describe('per-file cache', () => { }); it('caches results per file', () => { - ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); ctx.enableCache('src/a.ts'); const r1 = ctx.resolve('Foo', 'src/a.ts'); @@ -591,7 +605,7 @@ describe('per-file cache', () => { }); it('resolve works without cache enabled', () => { - ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); const result = ctx.resolve('Foo', 'src/a.ts'); @@ -601,7 +615,7 @@ describe('per-file cache', () => { }); it('cache does not leak across files', () => { - ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); ctx.enableCache('src/a.ts'); ctx.resolve('Foo', 'src/a.ts'); // cached for a.ts @@ -627,8 +641,8 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { }); it('collects definitions from all imported files', () => { - ctx.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class'); - ctx.symbols.add('src/b.ts', 'Widget', 'Class:src/b.ts:Widget', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Widget', 'Class:src/b.ts:Widget', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts'])); const result = ctx.resolve('Widget', 'src/app.ts'); @@ -640,8 +654,8 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { }); it('skips files with no matching symbol — no false positives', () => { - ctx.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class'); - ctx.symbols.add('src/b.ts', 'Button', 'Class:src/b.ts:Button', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Button', 'Class:src/b.ts:Button', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts'])); const result = ctx.resolve('Widget', 'src/app.ts'); @@ -652,8 +666,8 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { it('returns all overloads from a single imported file', () => { // Same-name method overloads in one file - ctx.symbols.add('src/math.ts', 'add', 'fn:math:add:0', 'Function', { parameterCount: 1 }); - ctx.symbols.add('src/math.ts', 'add', 'fn:math:add:2', 'Function', { parameterCount: 2 }); + ctx.model.symbols.add('src/math.ts', 'add', 'fn:math:add:0', 'Function', { parameterCount: 1 }); + ctx.model.symbols.add('src/math.ts', 'add', 'fn:math:add:2', 'Function', { parameterCount: 2 }); ctx.importMap.set('src/app.ts', new Set(['src/math.ts'])); const result = ctx.resolve('add', 'src/app.ts'); @@ -663,7 +677,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { }); it('Java: resolves class from import via lookupExactAll per file', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'com/example/models/User.java', 'User', 'Class:com/example/models/User.java:User', @@ -681,7 +695,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { }); it('Python: resolves function from imported module file', () => { - ctx.symbols.add('models.py', 'User', 'Class:models.py:User', 'Class'); + ctx.model.symbols.add('models.py', 'User', 'Class:models.py:User', 'Class'); ctx.importMap.set('app.py', new Set(['models.py'])); const result = ctx.resolve('User', 'app.py'); @@ -691,7 +705,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { }); it('C#: resolves interface from imported file', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/Services/IService.cs', 'IService', 'Interface:src/Services/IService.cs:IService', @@ -707,7 +721,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { it('TypeScript: resolves re-exported class via named binding chain', () => { // index.ts re-exports User from models.ts - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.namedImportMap.set( 'src/index.ts', new Map([['User', { sourcePath: 'src/models.ts', exportedName: 'User' }]]), @@ -733,13 +747,13 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => { }); it('Go: resolves symbol in package dir via file iteration', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'Authenticate', 'Function:internal/auth/handler.go:Authenticate', 'Function', ); - ctx.symbols.add( + ctx.model.symbols.add( 'internal/db/repo.go', 'Authenticate', 'Function:internal/db/repo.go:Authenticate', @@ -755,8 +769,13 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => { }); it('C#: resolves class from namespace directory', () => { - ctx.symbols.add('MyApp/Models/User.cs', 'User', 'Class:MyApp/Models/User.cs:User', 'Class'); - ctx.symbols.add('MyApp/Other/User.cs', 'User', 'Class:MyApp/Other/User.cs:User', 'Class'); + ctx.model.symbols.add( + 'MyApp/Models/User.cs', + 'User', + 'Class:MyApp/Models/User.cs:User', + 'Class', + ); + ctx.model.symbols.add('MyApp/Other/User.cs', 'User', 'Class:MyApp/Other/User.cs:User', 'Class'); ctx.packageMap.set('MyApp/Controllers/UserController.cs', new Set(['/MyApp/Models/'])); const result = ctx.resolve('User', 'MyApp/Controllers/UserController.cs'); @@ -767,13 +786,13 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => { }); it('Tier 2a (ImportMap) still takes precedence over Tier 2b (PackageMap)', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'Validate', 'Function:internal/auth/handler.go:Validate', 'Function', ); - ctx.symbols.add( + ctx.model.symbols.add( 'internal/db/validator.go', 'Validate', 'Function:internal/db/validator.go:Validate', @@ -797,7 +816,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('returns class-like symbol (Class) at global tier', () => { - ctx.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); const result = ctx.resolve('User', 'src/app.ts'); @@ -806,7 +825,12 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('returns callable symbol (Function) at global tier', () => { - ctx.symbols.add('src/utils.ts', 'parseDate', 'Function:src/utils.ts:parseDate', 'Function'); + ctx.model.symbols.add( + 'src/utils.ts', + 'parseDate', + 'Function:src/utils.ts:parseDate', + 'Function', + ); const result = ctx.resolve('parseDate', 'src/app.ts'); @@ -815,8 +839,8 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('returns both Class and Function with the same name at global tier', () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/factories.ts', 'User', 'Function:src/factories.ts:User', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/factories.ts', 'User', 'Function:src/factories.ts:User', 'Function'); const result = ctx.resolve('User', 'src/app.ts'); @@ -827,8 +851,8 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Rust: returns Impl node at global tier (needed for method resolution)', () => { - ctx.symbols.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct'); - ctx.symbols.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl'); + ctx.model.symbols.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct'); + ctx.model.symbols.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl'); const result = ctx.resolve('User', 'src/main.rs'); @@ -839,22 +863,24 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Rust: Impl is separate from Class-like types — does not affect heritage (lookupClassByName)', () => { - const table = createSymbolTable(); - table.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct'); - table.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl'); + // SM-23 DAG: registry lookups go through SemanticModel; SymbolTable + // is a pure leaf with no registry knowledge. + const model = createSemanticModel(); + model.symbols.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct'); + model.symbols.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl'); // lookupClassByName excludes Impl (preserves heritage resolution correctness) - const classDefs = table.lookupClassByName('User'); + const classDefs = model.types.lookupClassByName('User'); expect(classDefs.map((d) => d.type)).toEqual(['Struct']); // lookupImplByName returns only Impl nodes - const implDefs = table.lookupImplByName('User'); + const implDefs = model.types.lookupImplByName('User'); expect(implDefs.map((d) => d.type)).toEqual(['Impl']); }); it('ambiguous global returns all candidates (consumers decide)', () => { - ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); - ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); const result = ctx.resolve('Config', 'src/other.ts'); @@ -862,13 +888,30 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup expect(result!.candidates.length).toBe(2); }); + it('A4 intermediate: Method reachable via both callable and method indexes dedups to one Tier 3 candidate', () => { + // A method with an owner lands in callableByName (because Method is + // still in FREE_CALLABLE_TYPES during the Unit 3 intermediate state) AND in + // methodsByName (because A4 Unit 2 dual-indexes every method + // registration). Tier 3 must dedup by nodeId so consumers see each + // method exactly once. + ctx.model.symbols.add('src/user.ts', 'save', 'Method:src/user.ts:User.save', 'Method', { + ownerId: 'Class:src/user.ts:User', + }); + + const result = ctx.resolve('save', 'src/app.ts'); + + expect(result!.tier).toBe('global'); + const nodeIds = result!.candidates.map((c) => c.nodeId); + expect(nodeIds).toEqual(['Method:src/user.ts:User.save']); + }); + it('returns null when no symbol exists at any tier', () => { const result = ctx.resolve('NonExistent', 'src/app.ts'); expect(result).toBeNull(); }); it('TypeScript: resolves Enum at global tier', () => { - ctx.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); + ctx.model.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); const result = ctx.resolve('Status', 'src/app.ts'); @@ -877,7 +920,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Kotlin: resolves data class (Record) at global tier', () => { - ctx.symbols.add('src/User.kt', 'User', 'Record:src/User.kt:User', 'Record'); + ctx.model.symbols.add('src/User.kt', 'User', 'Record:src/User.kt:User', 'Record'); const result = ctx.resolve('User', 'src/Main.kt'); @@ -886,7 +929,12 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('PHP: resolves Trait at global tier', () => { - ctx.symbols.add('src/Loggable.php', 'Loggable', 'Trait:src/Loggable.php:Loggable', 'Trait'); + ctx.model.symbols.add( + 'src/Loggable.php', + 'Loggable', + 'Trait:src/Loggable.php:Loggable', + 'Trait', + ); const result = ctx.resolve('Loggable', 'src/App.php'); @@ -895,7 +943,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Java: resolves Interface at global tier', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'com/example/IService.java', 'IService', 'Interface:com/example/IService.java:IService', @@ -909,7 +957,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Go: resolves Struct at global tier', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/model/user.go', 'User', 'Struct:internal/model/user.go:User', @@ -954,7 +1002,7 @@ describe('SM-16: SymbolTable.getFiles()', () => { describe('SM-16: walkBindingChain — no allDefs parameter', () => { it('resolves non-aliased import via lookupExactAll at depth=0', () => { const ctx = createResolutionContext(); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.namedImportMap.set( 'src/app.ts', new Map([['User', { sourcePath: 'src/models.ts', exportedName: 'User' }]]), @@ -968,7 +1016,7 @@ describe('SM-16: walkBindingChain — no allDefs parameter', () => { it('resolves aliased import (U → User) via chain walk', () => { const ctx = createResolutionContext(); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.namedImportMap.set( 'src/app.ts', new Map([['U', { sourcePath: 'src/models.ts', exportedName: 'User' }]]), @@ -982,7 +1030,7 @@ describe('SM-16: walkBindingChain — no allDefs parameter', () => { it('follows re-export chain A → B → C', () => { const ctx = createResolutionContext(); - ctx.symbols.add('src/models.ts', 'Widget', 'Class:src/models.ts:Widget', 'Class'); + ctx.model.symbols.add('src/models.ts', 'Widget', 'Class:src/models.ts:Widget', 'Class'); // B re-exports Widget from C ctx.namedImportMap.set( 'src/index.ts', @@ -1011,26 +1059,31 @@ describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () => }); it('TypeAlias is not reachable at Tier 3', () => { - ctx.symbols.add('src/types.ts', 'Handler', 'TypeAlias:src/types.ts:Handler', 'TypeAlias'); + ctx.model.symbols.add('src/types.ts', 'Handler', 'TypeAlias:src/types.ts:Handler', 'TypeAlias'); const result = ctx.resolve('Handler', 'src/app.ts'); expect(result).toBeNull(); }); it('Const is not reachable at Tier 3', () => { - ctx.symbols.add('src/config.ts', 'MAX_RETRIES', 'Const:src/config.ts:MAX_RETRIES', 'Const'); + ctx.model.symbols.add( + 'src/config.ts', + 'MAX_RETRIES', + 'Const:src/config.ts:MAX_RETRIES', + 'Const', + ); const result = ctx.resolve('MAX_RETRIES', 'src/app.ts'); expect(result).toBeNull(); }); it('Variable is not reachable at Tier 3', () => { - ctx.symbols.add('src/state.ts', 'counter', 'Variable:src/state.ts:counter', 'Variable'); + ctx.model.symbols.add('src/state.ts', 'counter', 'Variable:src/state.ts:counter', 'Variable'); const result = ctx.resolve('counter', 'src/app.ts'); expect(result).toBeNull(); }); it('Class-like and callable ARE reachable at Tier 3 (control)', () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function'); const classResult = ctx.resolve('User', 'src/app.ts'); expect(classResult).not.toBeNull(); @@ -1042,7 +1095,7 @@ describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () => }); it('Macro (C/C++) is reachable at Tier 3 via callable index', () => { - ctx.symbols.add('src/macros.h', 'ASSERT', 'Macro:src/macros.h:ASSERT', 'Macro'); + ctx.model.symbols.add('src/macros.h', 'ASSERT', 'Macro:src/macros.h:ASSERT', 'Macro'); const result = ctx.resolve('ASSERT', 'src/main.c'); expect(result).not.toBeNull(); expect(result!.tier).toBe('global'); @@ -1050,7 +1103,7 @@ describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () => }); it('Delegate (C#) is reachable at Tier 3 via callable index', () => { - ctx.symbols.add('src/Events.cs', 'OnClick', 'Delegate:src/Events.cs:OnClick', 'Delegate'); + ctx.model.symbols.add('src/Events.cs', 'OnClick', 'Delegate:src/Events.cs:OnClick', 'Delegate'); const result = ctx.resolve('OnClick', 'src/App.cs'); expect(result).not.toBeNull(); expect(result!.tier).toBe('global'); @@ -1064,7 +1117,7 @@ describe('SM-16: Tier 2b — packageDirIndex picks up symbols added after clear( it('resolves newly added symbol after clear() resets the index', () => { const ctx = createResolutionContext(); // Initial setup: one symbol in package dir - ctx.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct'); + ctx.model.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct'); ctx.packageMap.set('cmd/main.go', new Set(['/pkg/models/'])); // Prime the packageDirIndex via a Tier 2b resolution @@ -1075,8 +1128,13 @@ describe('SM-16: Tier 2b — packageDirIndex picks up symbols added after clear( ctx.clear(); // Re-add symbols with a NEW file in the package dir - ctx.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct'); - ctx.symbols.add('pkg/models/order.go', 'Order', 'Struct:pkg/models/order.go:Order', 'Struct'); + ctx.model.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct'); + ctx.model.symbols.add( + 'pkg/models/order.go', + 'Order', + 'Struct:pkg/models/order.go:Order', + 'Struct', + ); ctx.packageMap.set('cmd/main.go', new Set(['/pkg/models/'])); // The new symbol must be visible — packageDirIndex was invalidated by clear() @@ -1092,8 +1150,8 @@ describe('SM-16: Tier 2b — packageDirIndex picks up symbols added after clear( describe('SM-16: Tier 2b — Rust package-scoped resolution', () => { it('resolves struct in package dir via Tier 2b', () => { const ctx = createResolutionContext(); - ctx.symbols.add('src/models/user.rs', 'User', 'Struct:src/models/user.rs:User', 'Struct'); - ctx.symbols.add('src/other/user.rs', 'User', 'Struct:src/other/user.rs:User', 'Struct'); + ctx.model.symbols.add('src/models/user.rs', 'User', 'Struct:src/models/user.rs:User', 'Struct'); + ctx.model.symbols.add('src/other/user.rs', 'User', 'Struct:src/other/user.rs:User', 'Struct'); ctx.packageMap.set('src/main.rs', new Set(['/src/models/'])); const result = ctx.resolve('User', 'src/main.rs'); @@ -1106,8 +1164,18 @@ describe('SM-16: Tier 2b — Rust package-scoped resolution', () => { describe('SM-16: Tier 2b — Kotlin package-scoped resolution', () => { it('resolves class in package dir via Tier 2b', () => { const ctx = createResolutionContext(); - ctx.symbols.add('com/app/models/User.kt', 'User', 'Class:com/app/models/User.kt:User', 'Class'); - ctx.symbols.add('com/app/other/User.kt', 'User', 'Class:com/app/other/User.kt:User', 'Class'); + ctx.model.symbols.add( + 'com/app/models/User.kt', + 'User', + 'Class:com/app/models/User.kt:User', + 'Class', + ); + ctx.model.symbols.add( + 'com/app/other/User.kt', + 'User', + 'Class:com/app/other/User.kt:User', + 'Class', + ); ctx.packageMap.set('com/app/Main.kt', new Set(['/com/app/models/'])); const result = ctx.resolve('User', 'com/app/Main.kt'); @@ -1120,8 +1188,8 @@ describe('SM-16: Tier 2b — Kotlin package-scoped resolution', () => { describe('SM-16: Tier 2b — PHP namespace directory resolution', () => { it('resolves class in namespace dir via Tier 2b', () => { const ctx = createResolutionContext(); - ctx.symbols.add('app/Models/User.php', 'User', 'Class:app/Models/User.php:User', 'Class'); - ctx.symbols.add('app/Other/User.php', 'User', 'Class:app/Other/User.php:User', 'Class'); + ctx.model.symbols.add('app/Models/User.php', 'User', 'Class:app/Models/User.php:User', 'Class'); + ctx.model.symbols.add('app/Other/User.php', 'User', 'Class:app/Other/User.php:User', 'Class'); ctx.packageMap.set('app/Controllers/UserController.php', new Set(['/app/Models/'])); const result = ctx.resolve('User', 'app/Controllers/UserController.php'); diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index 0f12851d6..1b8490fce 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -1,11 +1,24 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createSymbolTable, type SymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { SymbolTableWriter } from '../../src/core/ingestion/model/symbol-table.js'; +import { + createSemanticModel, + type MutableSemanticModel, +} from '../../src/core/ingestion/model/semantic-model.js'; describe('SymbolTable', () => { - let table: SymbolTable; + // SM-23 DAG: SymbolTable is now a pure leaf with no registry knowledge. + // Tests that exercise owner-scoped lookups (lookupClassByName, + // lookupMethodByOwner, lookupFieldByOwner, lookupClassByQualifiedName, + // lookupImplByName) must go through SemanticModel which composes + // SymbolTable with the registries. We build a model and alias + // `table = model.symbols` so the 200+ file/callable test cases keep + // their existing call sites unchanged. + let model: MutableSemanticModel; + let table: SymbolTableWriter; beforeEach(() => { - table = createSymbolTable(); + model = createSemanticModel(); + table = model.symbols; }); describe('add', () => { @@ -151,7 +164,7 @@ describe('SymbolTable', () => { // No declaredType → still indexed in fieldByOwner (for write-access tracking // in dynamically-typed languages like Ruby/JS), but excluded from callable index expect(table.lookupCallableByName('name')).toEqual([]); - expect(table.lookupFieldByOwner('class:User', 'name')).toEqual({ + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toEqual({ nodeId: 'prop:name', filePath: 'src/models.ts', type: 'Property', @@ -159,9 +172,12 @@ describe('SymbolTable', () => { }); }); - it('non-Property callable types are in callable index', () => { + it('post-A4: Method with ownerId lands in methodsByName, not callableByName', () => { + // Plan 006 Unit 4 shrank FREE_CALLABLE_TYPES to free callables only. + // Method registrations now flow through the method registry. table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:User' }); - expect(table.lookupCallableByName('save')).toHaveLength(1); + expect(table.lookupCallableByName('save')).toHaveLength(0); + expect(model.methods.lookupMethodByName('save')).toHaveLength(1); }); }); @@ -169,9 +185,9 @@ describe('SymbolTable', () => { it('adding a Function makes it available in callable index', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function', { returnType: 'void' }); expect(table.lookupCallableByName('foo')).toHaveLength(1); - // Add another callable - table.add('src/a.ts', 'bar', 'func:bar', 'Method'); - expect(table.lookupCallableByName('bar')).toHaveLength(1); + // Free Macro is a callable (C/C++ preprocessor macro). + table.add('src/macros.h', 'BAR', 'macro:BAR', 'Macro'); + expect(table.lookupCallableByName('BAR')).toHaveLength(1); }); it('adding a Property does NOT add it to callable index', () => { @@ -204,6 +220,43 @@ describe('SymbolTable', () => { expect(table.lookupCallableByName('OnClick')).toHaveLength(1); expect(table.lookupCallableByName('OnClick')[0].type).toBe('Delegate'); }); + + it('Method WITHOUT ownerId falls back to the callable index', () => { + // Orphaned Method (extractor contract violation / degraded AST). + // The dispatch hook silently skips it because it has no owner to + // key under; the callable-index fallback keeps it reachable at + // Tier 3 global resolution. + table.add('src/a.ts', 'orphan', 'method:orphan', 'Method'); + expect(table.lookupCallableByName('orphan')).toHaveLength(1); + expect(table.lookupCallableByName('orphan')[0].type).toBe('Method'); + }); + + it('Constructor WITHOUT ownerId falls back to the callable index', () => { + table.add('src/a.ts', 'Orphan', 'ctor:Orphan', 'Constructor'); + expect(table.lookupCallableByName('Orphan')).toHaveLength(1); + expect(table.lookupCallableByName('Orphan')[0].type).toBe('Constructor'); + }); + + it('Method WITH ownerId does NOT land in the callable index (goes to MethodRegistry instead)', () => { + table.add('src/user.ts', 'greet', 'method:User.greet', 'Method', { + ownerId: 'class:User', + }); + expect(table.lookupCallableByName('greet')).toHaveLength(0); + }); + + it('Constructor WITH ownerId does NOT land in the callable index', () => { + table.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { + ownerId: 'class:User', + }); + expect(table.lookupCallableByName('User')).toHaveLength(0); + }); + + it('Property WITHOUT ownerId still does NOT fall back to the callable index', () => { + // Property fallback would pollute common names like `id` / `name` / + // `type` — kept disjoint from the Method/Constructor fallback. + table.add('src/a.ts', 'orphanField', 'prop:orphan', 'Property'); + expect(table.lookupCallableByName('orphanField')).toHaveLength(0); + }); }); describe('lookupFieldByOwner', () => { @@ -212,7 +265,7 @@ describe('SymbolTable', () => { declaredType: 'Address', ownerId: 'class:User', }); - const def = table.lookupFieldByOwner('class:User', 'address'); + const def = model.fields.lookupFieldByOwner('class:User', 'address'); expect(def).toBeDefined(); expect(def!.declaredType).toBe('Address'); expect(def!.nodeId).toBe('prop:address'); @@ -223,7 +276,7 @@ describe('SymbolTable', () => { declaredType: 'Address', ownerId: 'class:User', }); - expect(table.lookupFieldByOwner('class:Unknown', 'address')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:Unknown', 'address')).toBeUndefined(); }); it('returns undefined for unknown field name', () => { @@ -231,16 +284,16 @@ describe('SymbolTable', () => { declaredType: 'Address', ownerId: 'class:User', }); - expect(table.lookupFieldByOwner('class:User', 'email')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:User', 'email')).toBeUndefined(); }); it('returns undefined for empty table', () => { - expect(table.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); }); it('indexes Property without declaredType (for dynamic language write-access)', () => { table.add('src/models.ts', 'name', 'prop:name', 'Property', { ownerId: 'class:User' }); - expect(table.lookupFieldByOwner('class:User', 'name')).toEqual({ + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toEqual({ nodeId: 'prop:name', filePath: 'src/models.ts', type: 'Property', @@ -257,8 +310,8 @@ describe('SymbolTable', () => { declaredType: 'RepoName', ownerId: 'class:Repo', }); - expect(table.lookupFieldByOwner('class:User', 'name')!.declaredType).toBe('string'); - expect(table.lookupFieldByOwner('class:Repo', 'name')!.declaredType).toBe('RepoName'); + expect(model.fields.lookupFieldByOwner('class:User', 'name')!.declaredType).toBe('string'); + expect(model.fields.lookupFieldByOwner('class:Repo', 'name')!.declaredType).toBe('RepoName'); }); }); @@ -268,7 +321,7 @@ describe('SymbolTable', () => { returnType: 'Address', ownerId: 'class:User', }); - const def = table.lookupMethodByOwner('class:User', 'getAddress'); + const def = model.methods.lookupMethodByOwner('class:User', 'getAddress'); expect(def).toBeDefined(); expect(def!.returnType).toBe('Address'); expect(def!.nodeId).toBe('method:getAddress'); @@ -283,8 +336,10 @@ describe('SymbolTable', () => { returnType: 'String', ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:User', 'getAddress')!.returnType).toBe('Address'); - expect(table.lookupMethodByOwner('class:User', 'getName')!.returnType).toBe('String'); + expect(model.methods.lookupMethodByOwner('class:User', 'getAddress')!.returnType).toBe( + 'Address', + ); + expect(model.methods.lookupMethodByOwner('class:User', 'getName')!.returnType).toBe('String'); }); it('distinguishes methods by owner', () => { @@ -296,8 +351,10 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:Address', }); - expect(table.lookupMethodByOwner('class:User', 'save')!.nodeId).toBe('method:user:save'); - expect(table.lookupMethodByOwner('class:Address', 'save')!.nodeId).toBe( + expect(model.methods.lookupMethodByOwner('class:User', 'save')!.nodeId).toBe( + 'method:user:save', + ); + expect(model.methods.lookupMethodByOwner('class:Address', 'save')!.nodeId).toBe( 'method:address:save', ); }); @@ -307,7 +364,7 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:Unknown', 'save')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:Unknown', 'save')).toBeUndefined(); }); it('returns undefined for unknown method name', () => { @@ -315,18 +372,24 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:User', 'delete')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:User', 'delete')).toBeUndefined(); }); it('returns undefined for empty table', () => { - expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); }); - it('does NOT index Method without ownerId', () => { + it('Method without ownerId is not in MethodRegistry but falls back to callable index', () => { + // methodHook silently skips Method-without-ownerId (methods.register + // requires an owner). The orphan-owner-scoped fallback in + // `SymbolTable.add()` routes such defs through `callableByName` so + // Tier 3 global resolution can still find them. table.add('src/utils.ts', 'helper', 'method:helper', 'Method'); - expect(table.lookupMethodByOwner('', 'helper')).toBeUndefined(); - // But it should still be in lookupCallableByName + expect(model.methods.lookupMethodByOwner('', 'helper')).toBeUndefined(); + expect(model.methods.lookupMethodByName('helper')).toHaveLength(0); expect(table.lookupCallableByName('helper')).toHaveLength(1); + expect(table.lookupCallableByName('helper')[0].type).toBe('Method'); + expect(table.lookupExact('src/utils.ts', 'helper')).toBe('method:helper'); }); it('returns first match for overloads with same returnType (unambiguous)', () => { @@ -340,7 +403,7 @@ describe('SymbolTable', () => { returnType: 'User', ownerId: 'class:UserRepo', }); - const def = table.lookupMethodByOwner('class:UserRepo', 'find'); + const def = model.methods.lookupMethodByOwner('class:UserRepo', 'find'); expect(def).toBeDefined(); expect(def!.nodeId).toBe('method:find:1'); expect(def!.returnType).toBe('User'); @@ -355,7 +418,7 @@ describe('SymbolTable', () => { parameterCount: 2, ownerId: 'class:Handler', }); - expect(table.lookupMethodByOwner('class:Handler', 'process')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:Handler', 'process')).toBeUndefined(); }); it('indexes Constructor in methodByOwner', () => { @@ -363,15 +426,17 @@ describe('SymbolTable', () => { parameterCount: 0, ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:User', 'User')).toEqual({ + expect(model.methods.lookupMethodByOwner('class:User', 'User')).toEqual({ nodeId: 'ctor:User', filePath: 'src/models.ts', type: 'Constructor', parameterCount: 0, ownerId: 'class:User', }); - // But it should be in lookupCallableByName - expect(table.lookupCallableByName('User')).toHaveLength(1); + // Post-A4 Unit 4: Constructor no longer lands in callableByName. + // It is reachable via methodsByName instead. + expect(table.lookupCallableByName('User')).toHaveLength(0); + expect(model.methods.lookupMethodByName('User')).toHaveLength(1); }); it('returns undefined for overloads with different returnTypes (ambiguous)', () => { @@ -385,15 +450,17 @@ describe('SymbolTable', () => { returnType: 'Number', ownerId: 'class:Converter', }); - expect(table.lookupMethodByOwner('class:Converter', 'convert')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:Converter', 'convert')).toBeUndefined(); }); - it('Method with ownerId is still available via lookupCallableByName', () => { + it('post-A4: Method with ownerId is reachable via methodsByName, not callableByName', () => { table.add('src/models.ts', 'save', 'method:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); - expect(table.lookupCallableByName('save')).toHaveLength(1); + expect(table.lookupCallableByName('save')).toHaveLength(0); + expect(model.methods.lookupMethodByName('save')).toHaveLength(1); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeDefined(); }); it('after clear(), lookupMethodByOwner returns undefined', () => { @@ -401,22 +468,26 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:User', 'save')).toBeDefined(); - table.clear(); - expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeDefined(); + model.clear(); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); }); }); describe('lookupCallableByName', () => { - it('returns only callable types (Function, Method, Constructor)', () => { + it('post-A4: returns only free callables (Function/Macro/Delegate)', () => { + // Post-Unit 4, FREE_CALLABLE_TYPES = {Function, Macro, Delegate}. + // Method and Constructor flow through the method registry instead. table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - table.add('src/a.ts', 'bar', 'method:bar', 'Method'); - table.add('src/a.ts', 'Baz', 'ctor:Baz', 'Constructor'); + table.add('src/a.ts', 'bar', 'method:bar', 'Method', { ownerId: 'class:X' }); + table.add('src/a.ts', 'Baz', 'ctor:Baz', 'Constructor', { ownerId: 'class:Baz' }); table.add('src/a.ts', 'User', 'class:User', 'Class'); table.add('src/a.ts', 'IUser', 'iface:IUser', 'Interface'); expect(table.lookupCallableByName('foo')).toHaveLength(1); - expect(table.lookupCallableByName('bar')).toHaveLength(1); - expect(table.lookupCallableByName('Baz')).toHaveLength(1); + expect(table.lookupCallableByName('bar')).toEqual([]); + expect(table.lookupCallableByName('Baz')).toEqual([]); + expect(model.methods.lookupMethodByName('bar')).toHaveLength(1); + expect(model.methods.lookupMethodByName('Baz')).toHaveLength(1); expect(table.lookupCallableByName('User')).toEqual([]); expect(table.lookupCallableByName('IUser')).toEqual([]); }); @@ -456,20 +527,20 @@ describe('SymbolTable', () => { ownerId: 'class:User', }); table.add('src/models.ts', 'User', 'class:User', 'Class'); - table.clear(); + model.clear(); expect(table.getStats()).toEqual({ fileCount: 0, }); expect(table.lookupExact('src/a.ts', 'foo')).toBeUndefined(); - expect(table.lookupFieldByOwner('class:User', 'address')).toBeUndefined(); - expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:User', 'address')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); expect(table.lookupCallableByName('foo')).toEqual([]); - expect(table.lookupClassByName('User')).toEqual([]); + expect(model.types.lookupClassByName('User')).toEqual([]); }); it('allows re-adding after clear', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - table.clear(); + model.clear(); table.add('src/b.ts', 'bar', 'func:bar', 'Function'); expect(table.getStats()).toEqual({ fileCount: 1, @@ -480,7 +551,7 @@ describe('SymbolTable', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); // Verify callable is found expect(table.lookupCallableByName('foo')).toHaveLength(1); - table.clear(); + model.clear(); // After clear the callable index must be gone — empty table returns nothing expect(table.lookupCallableByName('foo')).toEqual([]); // Re-adding and looking up works correctly @@ -501,7 +572,7 @@ describe('SymbolTable', () => { expect(def!.ownerId).toBeUndefined(); }); - it('stores only ownerId on a Method (non-Property) — still in callable index', () => { + it('stores only ownerId on a Method — reachable via methodsByName (post-A4)', () => { table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:Repo' }); const def = table.lookupExactFull('src/models.ts', 'save'); expect(def).toBeDefined(); @@ -509,8 +580,10 @@ describe('SymbolTable', () => { expect(def!.parameterCount).toBeUndefined(); expect(def!.returnType).toBeUndefined(); expect(def!.declaredType).toBeUndefined(); - // Non-Property with ownerId must still appear in callable index - expect(table.lookupCallableByName('save')).toHaveLength(1); + // Post-A4 Unit 4: owner-scoped Method lives in methodsByName, + // not callableByName. + expect(table.lookupCallableByName('save')).toHaveLength(0); + expect(model.methods.lookupMethodByName('save')).toHaveLength(1); }); it('stores declaredType alone (no ownerId) — symbol in file index', () => { @@ -575,23 +648,27 @@ describe('SymbolTable', () => { expect(second[0].nodeId).toBe('func:fetch'); }); - it('includes newly added Method', () => { + it('post-A4: newly added Method is reachable via methodsByName, not callableByName', () => { table.add('src/a.ts', 'alpha', 'func:alpha', 'Function'); expect(table.lookupCallableByName('alpha')).toHaveLength(1); expect(table.lookupCallableByName('beta')).toEqual([]); - // Add a Method - table.add('src/a.ts', 'beta', 'method:beta', 'Method'); - const result = table.lookupCallableByName('beta'); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('Method'); + table.add('src/a.ts', 'beta', 'method:beta', 'Method', { ownerId: 'class:X' }); + expect(table.lookupCallableByName('beta')).toHaveLength(0); + const byName = model.methods.lookupMethodByName('beta'); + expect(byName).toHaveLength(1); + expect(byName[0].type).toBe('Method'); }); - it('includes newly added Constructor', () => { + it('post-A4: newly added Constructor is reachable via methodsByName, not callableByName', () => { table.add('src/a.ts', 'existing', 'func:existing', 'Function'); expect(table.lookupCallableByName('existing')).toHaveLength(1); - table.add('src/models.ts', 'MyClass', 'ctor:MyClass', 'Constructor'); - expect(table.lookupCallableByName('MyClass')).toHaveLength(1); - expect(table.lookupCallableByName('MyClass')[0].type).toBe('Constructor'); + table.add('src/models.ts', 'MyClass', 'ctor:MyClass', 'Constructor', { + ownerId: 'class:MyClass', + }); + expect(table.lookupCallableByName('MyClass')).toHaveLength(0); + const byName = model.methods.lookupMethodByName('MyClass'); + expect(byName).toHaveLength(1); + expect(byName[0].type).toBe('Constructor'); }); }); @@ -655,9 +732,9 @@ describe('SymbolTable', () => { declaredType: 'Date', ownerId: 'class:User', }); - expect(table.lookupFieldByOwner('class:User', 'id')!.declaredType).toBe('number'); - expect(table.lookupFieldByOwner('class:User', 'email')!.declaredType).toBe('string'); - expect(table.lookupFieldByOwner('class:User', 'createdAt')!.declaredType).toBe('Date'); + expect(model.fields.lookupFieldByOwner('class:User', 'id')!.declaredType).toBe('number'); + expect(model.fields.lookupFieldByOwner('class:User', 'email')!.declaredType).toBe('string'); + expect(model.fields.lookupFieldByOwner('class:User', 'createdAt')!.declaredType).toBe('Date'); }); it('returns the full SymbolDefinition (nodeId + filePath + type) not just declaredType', () => { @@ -665,7 +742,7 @@ describe('SymbolTable', () => { declaredType: 'number', ownerId: 'class:Player', }); - const def = table.lookupFieldByOwner('class:Player', 'score'); + const def = model.fields.lookupFieldByOwner('class:Player', 'score'); expect(def).toBeDefined(); expect(def!.nodeId).toBe('prop:score'); expect(def!.filePath).toBe('src/models.ts'); @@ -682,17 +759,17 @@ describe('SymbolTable', () => { declaredType: 'UUID', ownerId: 'class:B', }); - expect(table.lookupFieldByOwner('class:A', 'id')!.nodeId).toBe('prop:a:id'); - expect(table.lookupFieldByOwner('class:B', 'id')!.nodeId).toBe('prop:b:id'); + expect(model.fields.lookupFieldByOwner('class:A', 'id')!.nodeId).toBe('prop:a:id'); + expect(model.fields.lookupFieldByOwner('class:B', 'id')!.nodeId).toBe('prop:b:id'); // An owner whose id is the concatenation of A's ownerId + fieldName must not match - expect(table.lookupFieldByOwner('class:A\0id', '')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:A\0id', '')).toBeUndefined(); }); }); describe('lookupClassByName', () => { it('returns Class definitions by name', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - const results = table.lookupClassByName('User'); + const results = model.types.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0]).toEqual({ nodeId: 'class:User', @@ -704,28 +781,28 @@ describe('SymbolTable', () => { it('returns Struct definitions by name', () => { table.add('src/models.rs', 'Point', 'struct:Point', 'Struct'); - const results = table.lookupClassByName('Point'); + const results = model.types.lookupClassByName('Point'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Struct'); }); it('returns Interface definitions by name', () => { table.add('src/types.ts', 'Serializable', 'iface:Serializable', 'Interface'); - const results = table.lookupClassByName('Serializable'); + const results = model.types.lookupClassByName('Serializable'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Interface'); }); it('returns Enum definitions by name', () => { table.add('src/types.ts', 'Color', 'enum:Color', 'Enum'); - const results = table.lookupClassByName('Color'); + const results = model.types.lookupClassByName('Color'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Enum'); }); it('returns Record definitions by name', () => { table.add('src/models.java', 'Config', 'record:Config', 'Record'); - const results = table.lookupClassByName('Config'); + const results = model.types.lookupClassByName('Config'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Record'); }); @@ -733,7 +810,7 @@ describe('SymbolTable', () => { it('does NOT include Function with the same name', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); table.add('src/utils.ts', 'User', 'func:User', 'Function'); - const results = table.lookupClassByName('User'); + const results = model.types.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Class'); expect(results[0].nodeId).toBe('class:User'); @@ -744,10 +821,10 @@ describe('SymbolTable', () => { table.add('src/a.ts', 'Bar', 'var:Bar', 'Variable'); table.add('src/a.ts', 'Baz', 'prop:Baz', 'Property'); table.add('src/a.ts', 'Qux', 'ctor:Qux', 'Constructor'); - expect(table.lookupClassByName('Foo')).toEqual([]); - expect(table.lookupClassByName('Bar')).toEqual([]); - expect(table.lookupClassByName('Baz')).toEqual([]); - expect(table.lookupClassByName('Qux')).toEqual([]); + expect(model.types.lookupClassByName('Foo')).toEqual([]); + expect(model.types.lookupClassByName('Bar')).toEqual([]); + expect(model.types.lookupClassByName('Baz')).toEqual([]); + expect(model.types.lookupClassByName('Qux')).toEqual([]); }); it('includes Trait in the class set (PHP use, Rust impl, Scala traits)', () => { @@ -757,20 +834,20 @@ describe('SymbolTable', () => { // Struct` in Rust, etc. Added as part of PR #744 (SM-11 Codex review // fixes) after the PHP HasTimestamps trait walk gap was discovered. table.add('src/a.rs', 'Writer', 'trait:Writer', 'Trait'); - const results = table.lookupClassByName('Writer'); + const results = model.types.lookupClassByName('Writer'); expect(results).toHaveLength(1); expect(results[0].nodeId).toBe('trait:Writer'); }); it('does NOT include other type-like labels outside the allowed class set', () => { table.add('src/a.ts', 'User', 'type:User', 'Type'); - expect(table.lookupClassByName('User')).toEqual([]); + expect(model.types.lookupClassByName('User')).toEqual([]); }); it('returns multiple classes with the same name from different files', () => { table.add('src/models/user.ts', 'User', 'class:user:User', 'Class'); table.add('src/dto/user.ts', 'User', 'class:dto:User', 'Class'); - const results = table.lookupClassByName('User'); + const results = model.types.lookupClassByName('User'); expect(results).toHaveLength(2); expect(results[0].filePath).toBe('src/models/user.ts'); expect(results[1].filePath).toBe('src/dto/user.ts'); @@ -778,25 +855,25 @@ describe('SymbolTable', () => { it('returns empty array for unknown name', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - expect(table.lookupClassByName('NonExistent')).toEqual([]); + expect(model.types.lookupClassByName('NonExistent')).toEqual([]); }); it('returns empty array for empty table', () => { - expect(table.lookupClassByName('User')).toEqual([]); + expect(model.types.lookupClassByName('User')).toEqual([]); }); it('after clear(), returns empty array', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - expect(table.lookupClassByName('User')).toHaveLength(1); - table.clear(); - expect(table.lookupClassByName('User')).toEqual([]); + expect(model.types.lookupClassByName('User')).toHaveLength(1); + model.clear(); + expect(model.types.lookupClassByName('User')).toEqual([]); }); it('returns mixed class-like types with the same name', () => { // e.g. a Class and an Interface both named 'Comparable' in different files table.add('src/base.ts', 'Comparable', 'class:Comparable', 'Class'); table.add('src/types.ts', 'Comparable', 'iface:Comparable', 'Interface'); - const results = table.lookupClassByName('Comparable'); + const results = model.types.lookupClassByName('Comparable'); expect(results).toHaveLength(2); expect(results.map((r) => r.type)).toEqual(['Class', 'Interface']); }); @@ -806,7 +883,7 @@ describe('SymbolTable', () => { returnType: 'User', ownerId: 'module:models', }); - const results = table.lookupClassByName('User'); + const results = model.types.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0].ownerId).toBe('module:models'); }); @@ -814,14 +891,14 @@ describe('SymbolTable', () => { it('class-like symbols are available via lookupClassByName', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); // classByName is the dedicated index for class-like lookups - expect(table.lookupClassByName('User')).toHaveLength(1); + expect(model.types.lookupClassByName('User')).toHaveLength(1); }); it('allows re-adding after clear and returns correct results', () => { table.add('src/models.ts', 'User', 'class:User:v1', 'Class'); - table.clear(); + model.clear(); table.add('src/models.ts', 'User', 'class:User:v2', 'Class'); - const results = table.lookupClassByName('User'); + const results = model.types.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0].nodeId).toBe('class:User:v2'); }); @@ -836,8 +913,8 @@ describe('SymbolTable', () => { qualifiedName: 'Data.User', }); - expect(table.lookupClassByName('User')).toHaveLength(2); - expect(table.lookupClassByQualifiedName('Services.User')).toEqual([ + expect(model.types.lookupClassByName('User')).toHaveLength(2); + expect(model.types.lookupClassByQualifiedName('Services.User')).toEqual([ { nodeId: 'class:services:User', filePath: 'src/services/user.cs', @@ -845,14 +922,14 @@ describe('SymbolTable', () => { qualifiedName: 'Services.User', }, ]); - const dataUserMatches = table.lookupClassByQualifiedName('Data.User'); + const dataUserMatches = model.types.lookupClassByQualifiedName('Data.User'); expect(dataUserMatches).toHaveLength(1); expect(dataUserMatches[0].qualifiedName).toBe('Data.User'); }); it('falls back to the simple name when no qualified metadata is provided', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - expect(table.lookupClassByQualifiedName('User')).toEqual([ + expect(model.types.lookupClassByQualifiedName('User')).toEqual([ { nodeId: 'class:User', filePath: 'src/models.ts', @@ -866,16 +943,258 @@ describe('SymbolTable', () => { table.add('src/utils.ts', 'User', 'func:User', 'Function', { qualifiedName: 'Services.User', }); - expect(table.lookupClassByQualifiedName('Services.User')).toEqual([]); + expect(model.types.lookupClassByQualifiedName('Services.User')).toEqual([]); }); it('after clear(), returns empty array', () => { table.add('src/services/user.cs', 'User', 'class:User', 'Class', { qualifiedName: 'Services.User', }); - expect(table.lookupClassByQualifiedName('Services.User')).toHaveLength(1); - table.clear(); - expect(table.lookupClassByQualifiedName('Services.User')).toEqual([]); + expect(model.types.lookupClassByQualifiedName('Services.User')).toHaveLength(1); + model.clear(); + expect(model.types.lookupClassByQualifiedName('Services.User')).toEqual([]); + }); + }); + + describe('SemanticModel container (SM-21 inversion)', () => { + // Post-inversion, the SemanticModel is the top-level container and + // SymbolTable is a nested `symbols` subfield. These tests exercise the + // inverted access pattern directly via createSemanticModel() so the + // factory wiring is covered end-to-end: feeding the symbol table via + // its `add()` populates the parent registries (types/methods/fields). + const buildModel = (): MutableSemanticModel => createSemanticModel(); + + it('exposes types, methods, fields, and symbols subfields', () => { + const model = buildModel(); + expect(model.types).toBeDefined(); + expect(model.methods).toBeDefined(); + expect(model.fields).toBeDefined(); + expect(model.symbols).toBeDefined(); + }); + + it('feeding a Class via model.symbols.add populates model.types', () => { + const model = buildModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class', { + qualifiedName: 'app.User', + }); + expect(model.types.lookupClassByName('User')).toHaveLength(1); + expect(model.types.lookupClassByName('User')[0]!.nodeId).toBe('class:User'); + expect(model.types.lookupClassByQualifiedName('app.User')).toHaveLength(1); + }); + + it('feeding a Method with ownerId populates model.methods', () => { + const model = buildModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'save', 'mtd:User.save', 'Method', { + ownerId: 'class:User', + parameterCount: 0, + }); + expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('mtd:User.save'); + }); + + it('feeding a Property with ownerId populates model.fields', () => { + const model = buildModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + declaredType: 'string', + }); + expect(model.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name'); + }); + + it('feeding an Impl populates model.types.lookupImplByName', () => { + const model = buildModel(); + model.symbols.add('src/user.rs', 'User', 'impl:User', 'Impl'); + expect(model.types.lookupImplByName('User')).toHaveLength(1); + }); + + it('arity filtering disambiguates overloads via model.methods', () => { + const model = buildModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'greet', 'mtd:greet:0', 'Method', { + ownerId: 'class:User', + parameterCount: 0, + }); + model.symbols.add('src/user.ts', 'greet', 'mtd:greet:1', 'Method', { + ownerId: 'class:User', + parameterCount: 1, + }); + expect(model.methods.lookupMethodByOwner('class:User', 'greet', 0)?.nodeId).toBe( + 'mtd:greet:0', + ); + expect(model.methods.lookupMethodByOwner('class:User', 'greet', 1)?.nodeId).toBe( + 'mtd:greet:1', + ); + }); + + it('clear() cascades through all three registries and the nested symbol table', () => { + const model = buildModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'save', 'mtd:User.save', 'Method', { + ownerId: 'class:User', + }); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + declaredType: 'string', + }); + + // Pre-clear: every store is populated. + expect(model.types.lookupClassByName('User')).toHaveLength(1); + expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('mtd:User.save'); + expect(model.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name'); + expect(model.symbols.lookupExact('src/user.ts', 'User')).toBe('class:User'); + + model.clear(); + + // Post-clear: every store is empty — types, methods, fields, symbols. + expect(model.types.lookupClassByName('User')).toEqual([]); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(model.symbols.lookupExact('src/user.ts', 'User')).toBeUndefined(); + }); + + it('feeds Function-with-ownerId into model.methods (Python-style class method)', () => { + // Python/Rust/Kotlin extractors emit class methods as `Function` with + // ownerId. The add() branch must route these into the method registry + // so owner-scoped resolution works uniformly across languages. + const model = buildModel(); + model.symbols.add('src/user.py', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.py', 'save', 'fn:User.save', 'Function', { + ownerId: 'class:User', + }); + expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('fn:User.save'); + }); + + it('silently skips Property without ownerId (no model.fields registration)', () => { + // Properties without ownerId are kept in the file index but never + // reach the fields registry — documenting the intentional behavior. + const model = buildModel(); + model.symbols.add('src/user.ts', 'name', 'prop:orphan.name', 'Property', { + declaredType: 'string', + }); + expect(model.symbols.lookupExact('src/user.ts', 'name')).toBe('prop:orphan.name'); + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // SM-22 — dispatch table routing invariants + // ------------------------------------------------------------------------- + + describe('registration dispatch table (SM-22)', () => { + it('registering a Class hits types.registerClass exactly once and touches no other registry', () => { + const model = createSemanticModel(); + const classSpy = vi.spyOn(model.types, 'registerClass'); + const implSpy = vi.spyOn(model.types, 'registerImpl'); + const methodsSpy = vi.spyOn(model.methods, 'register'); + const fieldsSpy = vi.spyOn(model.fields, 'register'); + + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class', { + qualifiedName: 'app.User', + }); + + expect(classSpy).toHaveBeenCalledTimes(1); + expect(implSpy).not.toHaveBeenCalled(); + expect(methodsSpy).not.toHaveBeenCalled(); + expect(fieldsSpy).not.toHaveBeenCalled(); + }); + + it('registering a Property populates fields.register and DOES NOT append to callableByName', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + declaredType: 'string', + }); + + expect(model.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name'); + // Property must NOT leak into callableByName — Property is not in + // FREE_CALLABLE_TYPES, so SymbolTable.add() never appends it. + expect(model.symbols.lookupCallableByName('name')).toHaveLength(0); + }); + + it('registering a free Function populates callableByName but not methods.register', () => { + const model = createSemanticModel(); + const methodsSpy = vi.spyOn(model.methods, 'register'); + + model.symbols.add('src/utils.ts', 'format', 'fn:format', 'Function'); + + expect(model.symbols.lookupCallableByName('format')).toHaveLength(1); + expect(methodsSpy).not.toHaveBeenCalled(); + }); + + it('registering a Function-with-ownerId routes to methods.register via pre-dispatch normalization AND appears in callableByName', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.py', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.py', 'save', 'fn:User.save', 'Function', { + ownerId: 'class:User', + }); + + // Owner-scoped method lookup resolves it (Python-style class method). + expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('fn:User.save'); + // Function is in FREE_CALLABLE_TYPES, so it also appears in callableByName. + expect(model.symbols.lookupCallableByName('save')).toHaveLength(1); + }); + + it('registering an Impl populates lookupImplByName but NOT lookupClassByName', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.rs', 'User', 'impl:User', 'Impl'); + // Impl is kept separate from class-like so heritage resolution + // does not treat it as a parent type candidate. + expect(model.types.lookupImplByName('User')).toHaveLength(1); + expect(model.types.lookupClassByName('User')).toHaveLength(0); + }); + + it('registering an inert NodeLabel only populates the file index', () => { + const model = createSemanticModel(); + const classSpy = vi.spyOn(model.types, 'registerClass'); + const implSpy = vi.spyOn(model.types, 'registerImpl'); + const methodsSpy = vi.spyOn(model.methods, 'register'); + const fieldsSpy = vi.spyOn(model.fields, 'register'); + + // `Variable` is in INERT_LABELS — no specialized registry, no + // callable index (it's not in FREE_CALLABLE_TYPES). + model.symbols.add('src/main.ts', 'CONFIG', 'var:CONFIG', 'Variable'); + + expect(model.symbols.lookupExact('src/main.ts', 'CONFIG')).toBe('var:CONFIG'); + expect(classSpy).not.toHaveBeenCalled(); + expect(implSpy).not.toHaveBeenCalled(); + expect(methodsSpy).not.toHaveBeenCalled(); + expect(fieldsSpy).not.toHaveBeenCalled(); + expect(model.symbols.lookupCallableByName('CONFIG')).toHaveLength(0); + }); + + it('Method-without-ownerId skips methods.register and falls back to the callable index', () => { + const model = createSemanticModel(); + const methodsSpy = vi.spyOn(model.methods, 'register'); + + model.symbols.add('src/orphan.ts', 'orphan', 'mtd:orphan', 'Method'); + + // File index still populated. + expect(model.symbols.lookupExact('src/orphan.ts', 'orphan')).toBe('mtd:orphan'); + // Method registry NOT populated (no ownerId to key under) — the + // dispatch hook silently skips. + expect(methodsSpy).not.toHaveBeenCalled(); + expect(model.methods.lookupMethodByName('orphan')).toHaveLength(0); + // Callable-index fallback: an orphaned Method/Constructor is an + // extractor contract violation (AST-degraded parse), but we keep + // it reachable at Tier 3 global resolution by routing it through + // `callableByName`. Matches pre-dispatch-table behavior. + expect(model.symbols.lookupCallableByName('orphan')).toHaveLength(1); + expect(model.symbols.lookupCallableByName('orphan')[0].type).toBe('Method'); + }); + + it('exhaustiveness guard does not fire for the current NodeLabel taxonomy', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Fresh SymbolTable — triggers the guard at construction. + createSemanticModel(); + // No warnings about missing NodeLabels — every label is accounted + // for in one of the three allowlists. + const mismatchWarnings = warnSpy.mock.calls.filter((args) => + String(args[0]).startsWith('[SymbolTable] NodeLabel '), + ); + expect(mismatchWarnings).toHaveLength(0); + warnSpy.mockRestore(); }); }); }); @@ -884,14 +1203,13 @@ describe('SymbolTable', () => { // lookupMethodByOwnerWithMRO — MRO-aware method resolution via HeritageMap // --------------------------------------------------------------------------- -import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js'; -import { lookupMethodByOwnerWithMRO } from '../../src/core/ingestion/call-processor.js'; +import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js'; +import { lookupMethodByOwnerWithMRO } from '../../src/core/ingestion/model/index.js'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/resolution-context.js'; -import { SupportedLanguages } from 'gitnexus-shared'; -import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js'; +} from '../../src/core/ingestion/model/resolution-context.js'; +import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js'; describe('lookupMethodByOwnerWithMRO', () => { let ctx: ResolutionContext; @@ -901,12 +1219,18 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('child.parentMethod() resolves to Parent#parentMethod via MRO walk', () => { - ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); - ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.java', 'parentMethod', 'method:Parent:parentMethod', 'Method', { - returnType: 'String', - ownerId: 'class:Parent', - }); + ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add( + 'src/parent.java', + 'parentMethod', + 'method:Parent:parentMethod', + 'Method', + { + returnType: 'String', + ownerId: 'class:Parent', + }, + ); const heritage: ExtractedHeritage[] = [ { filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' }, @@ -917,8 +1241,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'parentMethod', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Parent:parentMethod'); @@ -926,13 +1250,13 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('child override returns child version (direct hit, no walk)', () => { - ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); - ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.java', 'save', 'method:Parent:save', 'Method', { + ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.java', 'save', 'method:Parent:save', 'Method', { returnType: 'void', ownerId: 'class:Parent', }); - ctx.symbols.add('src/child.java', 'save', 'method:Child:save', 'Method', { + ctx.model.symbols.add('src/child.java', 'save', 'method:Child:save', 'Method', { returnType: 'void', ownerId: 'class:Child', }); @@ -946,18 +1270,18 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'save', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Child:save'); }); it('3-level inheritance: grandchild → child → parent, method on parent found', () => { - ctx.symbols.add('src/a.java', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.java', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.java', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/a.java', 'greet', 'method:A:greet', 'Method', { + ctx.model.symbols.add('src/a.java', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.java', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.java', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/a.java', 'greet', 'method:A:greet', 'Method', { returnType: 'Greeting', ownerId: 'class:A', }); @@ -972,8 +1296,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:C', 'greet', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:A:greet'); @@ -981,15 +1305,15 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('diamond pattern: first-wins strategy returns first ancestor match in BFS order', () => { - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); - ctx.symbols.add('src/b.ts', 'foo', 'method:B:foo', 'Method', { + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('src/b.ts', 'foo', 'method:B:foo', 'Method', { returnType: 'String', ownerId: 'class:B', }); - ctx.symbols.add('src/c.ts', 'foo', 'method:C:foo', 'Method', { + ctx.model.symbols.add('src/c.ts', 'foo', 'method:C:foo', 'Method', { returnType: 'String', ownerId: 'class:C', }); @@ -1003,27 +1327,21 @@ describe('lookupMethodByOwnerWithMRO', () => { const map = buildHeritageMap(heritage, ctx); // TypeScript uses 'first-wins' — B is first parent, so B.foo wins - const result = lookupMethodByOwnerWithMRO( - 'class:D', - 'foo', - map, - ctx.symbols, - SupportedLanguages.TypeScript, - ); + const result = lookupMethodByOwnerWithMRO('class:D', 'foo', map, ctx.model, 'first-wins'); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:B:foo'); }); it('diamond pattern: c3 strategy uses C3 linearization order', () => { - ctx.symbols.add('src/a.py', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.py', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.py', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/d.py', 'D', 'class:D', 'Class'); - ctx.symbols.add('src/b.py', 'foo', 'method:B:foo', 'Method', { + ctx.model.symbols.add('src/a.py', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.py', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.py', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/d.py', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('src/b.py', 'foo', 'method:B:foo', 'Method', { returnType: 'str', ownerId: 'class:B', }); - ctx.symbols.add('src/c.py', 'foo', 'method:C:foo', 'Method', { + ctx.model.symbols.add('src/c.py', 'foo', 'method:C:foo', 'Method', { returnType: 'str', ownerId: 'class:C', }); @@ -1037,22 +1355,41 @@ describe('lookupMethodByOwnerWithMRO', () => { const map = buildHeritageMap(heritage, ctx); // Python uses 'c3' — C3 linearization for D(B,C): [B, C, A] - const result = lookupMethodByOwnerWithMRO( - 'class:D', - 'foo', - map, - ctx.symbols, - SupportedLanguages.Python, - ); + const result = lookupMethodByOwnerWithMRO('class:D', 'foo', map, ctx.model, 'c3'); expect(result).toBeDefined(); // C3 linearization resolves to B before C in this hierarchy expect(result!.nodeId).toBe('method:B:foo'); }); + it('c3 (Python): cyclic hierarchy falls back to BFS ancestor order', () => { + // Build a legitimately cyclic heritage: A extends B, B extends A. + // c3Linearize returns null for this case (inconsistent linearization). + // The MRO walker must then fall back to heritageMap.getAncestors() + // (BFS order) instead of silently returning undefined. + ctx.model.symbols.add('src/a.py', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.py', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/b.py', 'foo', 'method:B:foo', 'Method', { + returnType: 'void', + ownerId: 'class:B', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/a.py', className: 'A', parentName: 'B', kind: 'extends' }, + { filePath: 'src/b.py', className: 'B', parentName: 'A', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + // Even with a cyclic hierarchy, BFS via heritageMap.getAncestors() + // walks A → B and finds `foo` on B. The method lookup must succeed. + const result = lookupMethodByOwnerWithMRO('class:A', 'foo', map, ctx.model, 'c3'); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:B:foo'); + }); + it('qualified-syntax (Rust): returns undefined for inherited methods', () => { - ctx.symbols.add('src/parent.rs', 'Parent', 'class:Parent', 'Class'); - ctx.symbols.add('src/child.rs', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.rs', 'process', 'method:Parent:process', 'Method', { + ctx.model.symbols.add('src/parent.rs', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.rs', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.rs', 'process', 'method:Parent:process', 'Method', { returnType: 'void', ownerId: 'class:Parent', }); @@ -1066,16 +1403,16 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'process', map, - ctx.symbols, - SupportedLanguages.Rust, + ctx.model, + 'qualified-syntax', ); // Rust requires qualified syntax — no auto-resolution expect(result).toBeUndefined(); }); it('method not on any ancestor returns undefined', () => { - ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); - ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' }, @@ -1086,17 +1423,17 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'nonExistent', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeUndefined(); }); it('leftmost-base (C++): walks ancestors in BFS order', () => { - ctx.symbols.add('src/a.cpp', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.cpp', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.cpp', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', { + ctx.model.symbols.add('src/a.cpp', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.cpp', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.cpp', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', { returnType: 'void', ownerId: 'class:A', }); @@ -1107,22 +1444,16 @@ describe('lookupMethodByOwnerWithMRO', () => { ]; const map = buildHeritageMap(heritage, ctx); - const result = lookupMethodByOwnerWithMRO( - 'class:C', - 'render', - map, - ctx.symbols, - SupportedLanguages.CPlusPlus, - ); + const result = lookupMethodByOwnerWithMRO('class:C', 'render', map, ctx.model, 'leftmost-base'); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:A:render'); }); it('implements-split (Java): walks ancestors to find inherited method', () => { - ctx.symbols.add('src/base.java', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('src/iface.java', 'IRepo', 'iface:IRepo', 'Interface'); - ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/base.java', 'save', 'method:Base:save', 'Method', { + ctx.model.symbols.add('src/base.java', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('src/iface.java', 'IRepo', 'iface:IRepo', 'Interface'); + ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/base.java', 'save', 'method:Base:save', 'Method', { returnType: 'void', ownerId: 'class:Base', }); @@ -1142,8 +1473,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'save', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:save'); @@ -1156,14 +1487,14 @@ describe('lookupMethodByOwnerWithMRO', () => { // level. lookupMethodByOwnerWithMRO itself uses BFS order and returns // the first match — this test pins that contract so a future regression // that starts returning undefined (or flips the order) fails loudly. - ctx.symbols.add('src/I1.java', 'I1', 'iface:I1', 'Interface'); - ctx.symbols.add('src/I2.java', 'I2', 'iface:I2', 'Interface'); - ctx.symbols.add('src/C.java', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/I1.java', 'handle', 'method:I1:handle', 'Method', { + ctx.model.symbols.add('src/I1.java', 'I1', 'iface:I1', 'Interface'); + ctx.model.symbols.add('src/I2.java', 'I2', 'iface:I2', 'Interface'); + ctx.model.symbols.add('src/C.java', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/I1.java', 'handle', 'method:I1:handle', 'Method', { returnType: 'void', ownerId: 'iface:I1', }); - ctx.symbols.add('src/I2.java', 'handle', 'method:I2:handle', 'Method', { + ctx.model.symbols.add('src/I2.java', 'handle', 'method:I2:handle', 'Method', { returnType: 'void', ownerId: 'iface:I2', }); @@ -1179,8 +1510,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:C', 'handle', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); // BFS first-wins — I1 was declared first, so it wins. @@ -1194,14 +1525,14 @@ describe('lookupMethodByOwnerWithMRO', () => { // Base before IFoo — class wins. Documents the current BFS-level // behavior; the strict Java "class always wins" rule is enforced at // the mro-processor graph pass. - ctx.symbols.add('src/Base.java', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('src/IFoo.java', 'IFoo', 'iface:IFoo', 'Interface'); - ctx.symbols.add('src/Child.java', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/Base.java', 'handle', 'method:Base:handle', 'Method', { + ctx.model.symbols.add('src/Base.java', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('src/IFoo.java', 'IFoo', 'iface:IFoo', 'Interface'); + ctx.model.symbols.add('src/Child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/Base.java', 'handle', 'method:Base:handle', 'Method', { returnType: 'void', ownerId: 'class:Base', }); - ctx.symbols.add('src/IFoo.java', 'handle', 'method:IFoo:handle', 'Method', { + ctx.model.symbols.add('src/IFoo.java', 'handle', 'method:IFoo:handle', 'Method', { returnType: 'void', ownerId: 'iface:IFoo', }); @@ -1216,17 +1547,17 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'handle', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:handle'); }); it('implements-split (Kotlin): walks ancestors to find inherited method', () => { - ctx.symbols.add('src/base.kt', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('src/child.kt', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/base.kt', 'handle', 'method:Base:handle', 'Method', { + ctx.model.symbols.add('src/base.kt', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('src/child.kt', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/base.kt', 'handle', 'method:Base:handle', 'Method', { returnType: 'Unit', ownerId: 'class:Base', }); @@ -1240,17 +1571,17 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'handle', map, - ctx.symbols, - SupportedLanguages.Kotlin, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:handle'); }); it('implements-split (C#): walks ancestors to find inherited method', () => { - ctx.symbols.add('src/Base.cs', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('src/Child.cs', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/Base.cs', 'Execute', 'method:Base:Execute', 'Method', { + ctx.model.symbols.add('src/Base.cs', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('src/Child.cs', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/Base.cs', 'Execute', 'method:Base:Execute', 'Method', { returnType: 'void', ownerId: 'class:Base', }); @@ -1264,8 +1595,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'Execute', map, - ctx.symbols, - SupportedLanguages.CSharp, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:Execute'); @@ -1274,9 +1605,9 @@ describe('lookupMethodByOwnerWithMRO', () => { it('first-wins (JavaScript): walks ancestors to find inherited method', () => { // JavaScript provider is wired separately from TypeScript — this guards // the provider wiring independent of the TS path. - ctx.symbols.add('src/animal.js', 'Animal', 'class:Animal', 'Class'); - ctx.symbols.add('src/dog.js', 'Dog', 'class:Dog', 'Class'); - ctx.symbols.add('src/animal.js', 'speak', 'method:Animal:speak', 'Method', { + ctx.model.symbols.add('src/animal.js', 'Animal', 'class:Animal', 'Class'); + ctx.model.symbols.add('src/dog.js', 'Dog', 'class:Dog', 'Class'); + ctx.model.symbols.add('src/animal.js', 'speak', 'method:Animal:speak', 'Method', { returnType: 'string', ownerId: 'class:Animal', }); @@ -1286,13 +1617,7 @@ describe('lookupMethodByOwnerWithMRO', () => { ]; const map = buildHeritageMap(heritage, ctx); - const result = lookupMethodByOwnerWithMRO( - 'class:Dog', - 'speak', - map, - ctx.symbols, - SupportedLanguages.JavaScript, - ); + const result = lookupMethodByOwnerWithMRO('class:Dog', 'speak', map, ctx.model, 'first-wins'); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Animal:speak'); }); @@ -1301,19 +1626,19 @@ describe('lookupMethodByOwnerWithMRO', () => { // Diamond: D extends B, C; B extends A; C extends A. // Both B and C define render(). leftmost-base must return B#render (first // branch in declaration order), not A#render or C#render. - ctx.symbols.add('src/a.cpp', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.cpp', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.cpp', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/d.cpp', 'D', 'class:D', 'Class'); - ctx.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', { + ctx.model.symbols.add('src/a.cpp', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.cpp', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.cpp', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/d.cpp', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', { returnType: 'void', ownerId: 'class:A', }); - ctx.symbols.add('src/b.cpp', 'render', 'method:B:render', 'Method', { + ctx.model.symbols.add('src/b.cpp', 'render', 'method:B:render', 'Method', { returnType: 'void', ownerId: 'class:B', }); - ctx.symbols.add('src/c.cpp', 'render', 'method:C:render', 'Method', { + ctx.model.symbols.add('src/c.cpp', 'render', 'method:C:render', 'Method', { returnType: 'void', ownerId: 'class:C', }); @@ -1327,13 +1652,7 @@ describe('lookupMethodByOwnerWithMRO', () => { ]; const map = buildHeritageMap(heritage, ctx); - const result = lookupMethodByOwnerWithMRO( - 'class:D', - 'render', - map, - ctx.symbols, - SupportedLanguages.CPlusPlus, - ); + const result = lookupMethodByOwnerWithMRO('class:D', 'render', map, ctx.model, 'leftmost-base'); expect(result).toBeDefined(); // BFS via HeritageMap visits B before C (insertion order), so leftmost // branch wins — matches C++ leftmost-base semantics for non-virtual base. @@ -1341,8 +1660,8 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('returns direct method on owner without walking (no heritage needed)', () => { - ctx.symbols.add('src/user.java', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.java', 'getName', 'method:User:getName', 'Method', { + ctx.model.symbols.add('src/user.java', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.java', 'getName', 'method:User:getName', 'Method', { returnType: 'String', ownerId: 'class:User', }); @@ -1353,8 +1672,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:User', 'getName', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:User:getName'); @@ -1380,8 +1699,8 @@ describe('resolveMemberCall', () => { }); it('resolves direct method on owner type', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1396,9 +1715,9 @@ describe('resolveMemberCall', () => { }); it('resolves inherited method via MRO walk', () => { - ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); - ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.java', 'validate', 'method:Parent:validate', 'Method', { + ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.java', 'validate', 'method:Parent:validate', 'Method', { returnType: 'boolean', ownerId: 'class:Parent', }); @@ -1422,7 +1741,7 @@ describe('resolveMemberCall', () => { }); it('returns null for unknown method on known owner', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); const result = resolveMemberCall('User', 'nonExistentMethod', 'src/app.ts', ctx); @@ -1430,8 +1749,8 @@ describe('resolveMemberCall', () => { }); it('returns result with correct confidence tier for same-file resolution', () => { - ctx.symbols.add('src/app.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/app.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/app.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/app.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1444,8 +1763,8 @@ describe('resolveMemberCall', () => { }); it('returns result with import-scoped tier for cross-file resolution', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1459,10 +1778,10 @@ describe('resolveMemberCall', () => { }); it('resolves with heritage map across C3 MRO chain (Python)', () => { - ctx.symbols.add('src/a.py', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.py', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.py', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/a.py', 'foo', 'method:A:foo', 'Method', { + ctx.model.symbols.add('src/a.py', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.py', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.py', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/a.py', 'foo', 'method:A:foo', 'Method', { returnType: 'str', ownerId: 'class:A', }); @@ -1491,8 +1810,8 @@ describe('resolveMemberCall', () => { // have used the tier of resolving "save" globally; new behaviour uses the // tier of resolving "User". Both happen to yield import-scoped here — // the test locks that the reported tier tracks the class lookup. - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1513,9 +1832,9 @@ describe('resolveMemberCall', () => { it('Rust: returns null for trait-inherited method (qualified-syntax MRO)', () => { // Trait Writer defines `save`. Struct User has an impl_item but NO save // method of its own — save is only available via trait. - ctx.symbols.add('src/writer.rs', 'Writer', 'trait:Writer', 'Trait'); - ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); - ctx.symbols.add('src/writer.rs', 'save', 'method:Writer:save', 'Method', { + ctx.model.symbols.add('src/writer.rs', 'Writer', 'trait:Writer', 'Trait'); + ctx.model.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); + ctx.model.symbols.add('src/writer.rs', 'save', 'method:Writer:save', 'Method', { returnType: 'bool', ownerId: 'trait:Writer', }); @@ -1537,8 +1856,8 @@ describe('resolveMemberCall', () => { // Positive control: a method defined directly on User (not via trait) // resolves normally — demonstrates the null in the previous test is // specifically due to the trait-inheritance path, not a broken fixture. - ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); - ctx.symbols.add('src/user.rs', 'name', 'method:User:name', 'Method', { + ctx.model.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); + ctx.model.symbols.add('src/user.rs', 'name', 'method:User:name', 'Method', { returnType: 'String', ownerId: 'struct:User', }); @@ -1562,13 +1881,13 @@ describe('resolveMemberCall', () => { it('disambiguates homonym classes: only one owns the method', () => { // Two classes both named `User` — one in auth.py (has `save`), one in // legacy.py (has `archive` but no `save`). Both are imported from app.py. - ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); - ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { + ctx.model.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); + ctx.model.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { returnType: 'None', ownerId: 'class:auth:User', }); - ctx.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class'); - ctx.symbols.add('src/legacy.py', 'archive', 'method:legacy:User:archive', 'Method', { + ctx.model.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class'); + ctx.model.symbols.add('src/legacy.py', 'archive', 'method:legacy:User:archive', 'Method', { returnType: 'None', ownerId: 'class:legacy:User', }); @@ -1589,13 +1908,13 @@ describe('resolveMemberCall', () => { // Both homonym Users define a `save` method — resolveMemberCall refuses // to pick one. The caller (resolveCallTarget) falls through to D1-D4 which // may or may not be able to narrow further. - ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); - ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { + ctx.model.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); + ctx.model.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { returnType: 'None', ownerId: 'class:auth:User', }); - ctx.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class'); - ctx.symbols.add('src/legacy.py', 'save', 'method:legacy:User:save', 'Method', { + ctx.model.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class'); + ctx.model.symbols.add('src/legacy.py', 'save', 'method:legacy:User:save', 'Method', { returnType: 'None', ownerId: 'class:legacy:User', }); @@ -1609,13 +1928,13 @@ describe('resolveMemberCall', () => { // Two homonym `User` classes in different files, both extending a common // `BaseUser` that owns `save`. Direct lookup on either User misses; MRO // walks both find BaseUser.save. Dedup by nodeId yields a single result. - ctx.symbols.add('src/base.ts', 'BaseUser', 'class:BaseUser', 'Class'); - ctx.symbols.add('src/base.ts', 'save', 'method:BaseUser:save', 'Method', { + ctx.model.symbols.add('src/base.ts', 'BaseUser', 'class:BaseUser', 'Class'); + ctx.model.symbols.add('src/base.ts', 'save', 'method:BaseUser:save', 'Method', { returnType: 'void', ownerId: 'class:BaseUser', }); - ctx.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class'); - ctx.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class'); + ctx.model.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class'); + ctx.model.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/base.ts', 'src/a.ts', 'src/b.ts'])); const heritage: ExtractedHeritage[] = [ @@ -1639,11 +1958,11 @@ describe('resolveMemberCall', () => { // // Both A and B inherit `method` from Base. Derived extends (A, B). // Leftmost-base strategy walks A's chain first → finds Base::method. - ctx.symbols.add('src/base.h', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('src/a.h', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.h', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/derived.h', 'Derived', 'class:Derived', 'Class'); - ctx.symbols.add('src/base.h', 'method', 'method:Base:method', 'Method', { + ctx.model.symbols.add('src/base.h', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('src/a.h', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.h', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/derived.h', 'Derived', 'class:Derived', 'Class'); + ctx.model.symbols.add('src/base.h', 'method', 'method:Base:method', 'Method', { returnType: 'int', ownerId: 'class:Base', }); @@ -1677,10 +1996,10 @@ describe('resolveMemberCall', () => { // C# uses implements-split MRO: class base chain walked first, then // interfaces. Here IService declares Save which is implemented by the // base class BaseService — MyService inherits Save through the class. - ctx.symbols.add('src/iservice.cs', 'IService', 'interface:IService', 'Interface'); - ctx.symbols.add('src/base.cs', 'BaseService', 'class:BaseService', 'Class'); - ctx.symbols.add('src/my.cs', 'MyService', 'class:MyService', 'Class'); - ctx.symbols.add('src/base.cs', 'Save', 'method:BaseService:Save', 'Method', { + ctx.model.symbols.add('src/iservice.cs', 'IService', 'interface:IService', 'Interface'); + ctx.model.symbols.add('src/base.cs', 'BaseService', 'class:BaseService', 'Class'); + ctx.model.symbols.add('src/my.cs', 'MyService', 'class:MyService', 'Class'); + ctx.model.symbols.add('src/base.cs', 'Save', 'method:BaseService:Save', 'Method', { returnType: 'void', ownerId: 'class:BaseService', }); @@ -1708,9 +2027,9 @@ describe('resolveMemberCall', () => { // Kotlin shares the implements-split MRO strategy with Java/C#. A class // inheriting from an interface that provides a default method should // resolve `obj.method()` to the interface's implementation. - ctx.symbols.add('src/validator.kt', 'Validator', 'interface:Validator', 'Interface'); - ctx.symbols.add('src/user.kt', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/validator.kt', 'validate', 'method:Validator:validate', 'Method', { + ctx.model.symbols.add('src/validator.kt', 'Validator', 'interface:Validator', 'Interface'); + ctx.model.symbols.add('src/user.kt', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/validator.kt', 'validate', 'method:Validator:validate', 'Method', { returnType: 'Boolean', ownerId: 'interface:Validator', }); @@ -1765,13 +2084,13 @@ describe('resolveCallTarget thin dispatcher (SM-19)', () => { // type-file verification guard requires the alias target file to be // among the receiver type's defining files before alias narrowing is // considered a valid signal. - ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); - ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { + ctx.model.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); + ctx.model.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { returnType: 'None', ownerId: 'class:auth:User', }); - ctx.symbols.add('src/other.py', 'User', 'class:other:User', 'Class'); - ctx.symbols.add('src/other.py', 'save', 'method:other:User:save', 'Method', { + ctx.model.symbols.add('src/other.py', 'User', 'class:other:User', 'Class'); + ctx.model.symbols.add('src/other.py', 'save', 'method:other:User:save', 'Method', { returnType: 'None', ownerId: 'class:other:User', }); @@ -1797,8 +2116,8 @@ describe('resolveCallTarget thin dispatcher (SM-19)', () => { it('overloadHints ignored for member calls — resolveMemberCall resolves directly', () => { // With the thin dispatcher, overloadHints are not passed to resolveMemberCall // (it does not accept them). Single-candidate member calls still resolve. - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1825,8 +2144,8 @@ describe('resolveCallTarget thin dispatcher (SM-19)', () => { // Analogous to the overloadHints case: thin dispatcher delegates to // resolveMemberCall which resolves the single candidate without needing // argument-type disambiguation. - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1863,8 +2182,8 @@ describe('resolveStaticCall', () => { }); it('resolves constructor with ownerId via lookupMethodByOwner', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { returnType: 'User', ownerId: 'class:User', }); @@ -1877,7 +2196,7 @@ describe('resolveStaticCall', () => { }); it('returns class node when no constructor exists', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); const result = resolveStaticCall('User', 'src/app.ts', ctx); @@ -1887,7 +2206,7 @@ describe('resolveStaticCall', () => { }); it('returns null for non-class symbol', () => { - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); const result = resolveStaticCall('helper', 'src/app.ts', ctx); @@ -1902,8 +2221,8 @@ describe('resolveStaticCall', () => { }); it('returns null when Constructor nodes lack ownerId', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { parameterCount: 1, }); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); @@ -1917,13 +2236,13 @@ describe('resolveStaticCall', () => { }); it('disambiguates constructor by arity', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', { parameterCount: 0, returnType: 'User', ownerId: 'class:User', }); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', { parameterCount: 2, returnType: 'User', ownerId: 'class:User', @@ -1937,7 +2256,7 @@ describe('resolveStaticCall', () => { }); it('returns correct confidence tier for import-scoped class', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); const result = resolveStaticCall('User', 'src/app.ts', ctx); @@ -1948,7 +2267,7 @@ describe('resolveStaticCall', () => { }); it('returns correct confidence tier for same-file class', () => { - ctx.symbols.add('src/app.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/app.ts', 'User', 'class:User', 'Class'); const result = resolveStaticCall('User', 'src/app.ts', ctx); @@ -1958,8 +2277,8 @@ describe('resolveStaticCall', () => { }); it('returns null for ambiguous homonym classes without constructor', () => { - ctx.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class'); - ctx.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class'); + ctx.model.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class'); + ctx.model.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts'])); const result = resolveStaticCall('User', 'src/app.ts', ctx); @@ -1969,7 +2288,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget for constructor callForm', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); const result = _resolveCallTargetForTesting( @@ -1986,7 +2305,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget for free-form call targeting a class (Swift/Kotlin)', () => { - ctx.symbols.add('src/user.swift', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.swift', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.swift', new Set(['src/user.swift'])); const result = _resolveCallTargetForTesting( @@ -2003,8 +2322,8 @@ describe('resolveStaticCall', () => { }); it('reuses the pre-computed tiered result instead of calling ctx.resolve twice', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { returnType: 'User', ownerId: 'class:User', }); @@ -2030,8 +2349,8 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget for Java constructor call (new User())', () => { - ctx.symbols.add('src/User.java', 'User', 'class:java:User', 'Class'); - ctx.symbols.add('src/User.java', 'User', 'ctor:java:User', 'Constructor', { + ctx.model.symbols.add('src/User.java', 'User', 'class:java:User', 'Class'); + ctx.model.symbols.add('src/User.java', 'User', 'ctor:java:User', 'Constructor', { returnType: 'User', ownerId: 'class:java:User', }); @@ -2052,7 +2371,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget for Python free-form constructor (User())', () => { - ctx.symbols.add('models/user.py', 'User', 'class:py:User', 'Class'); + ctx.model.symbols.add('models/user.py', 'User', 'class:py:User', 'Class'); ctx.importMap.set('app.py', new Set(['models/user.py'])); const result = _resolveCallTargetForTesting( @@ -2069,7 +2388,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget for Kotlin free-form constructor (User())', () => { - ctx.symbols.add('src/User.kt', 'User', 'class:kt:User', 'Class'); + ctx.model.symbols.add('src/User.kt', 'User', 'class:kt:User', 'Class'); ctx.importMap.set('src/App.kt', new Set(['src/User.kt'])); const result = _resolveCallTargetForTesting( @@ -2093,7 +2412,7 @@ describe('resolveStaticCall', () => { // ------------------------------------------------------------------------- it('returns a Struct node when no constructor exists (positive regression guard)', () => { - ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); + ctx.model.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); ctx.importMap.set('src/app.rs', new Set(['src/user.rs'])); const result = resolveStaticCall('User', 'src/app.rs', ctx); @@ -2103,7 +2422,7 @@ describe('resolveStaticCall', () => { }); it('returns a Record node when no constructor exists (positive regression guard)', () => { - ctx.symbols.add('src/User.cs', 'User', 'record:User', 'Record'); + ctx.model.symbols.add('src/User.cs', 'User', 'record:User', 'Record'); ctx.importMap.set('src/App.cs', new Set(['src/User.cs'])); const result = resolveStaticCall('User', 'src/App.cs', ctx); @@ -2115,7 +2434,7 @@ describe('resolveStaticCall', () => { it('null-routes when the sole candidate is an Interface (Java/C#/TS)', () => { // Constructor-shaped call on an interface name — not legal source, but // the resolver must refuse to emit a CALLS edge to a non-instantiable node. - ctx.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface'); + ctx.model.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface'); ctx.importMap.set('src/app.java', new Set(['src/validator.java'])); const result = resolveStaticCall('IValidator', 'src/app.java', ctx); @@ -2125,7 +2444,7 @@ describe('resolveStaticCall', () => { it('null-routes when the sole candidate is a Trait (PHP/Rust/Scala)', () => { // PHP `HasTimestamps` trait — not instantiable via constructor syntax. - ctx.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); + ctx.model.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); ctx.importMap.set('src/model.php', new Set(['src/timestamps.php'])); const result = resolveStaticCall('HasTimestamps', 'src/model.php', ctx); @@ -2134,7 +2453,7 @@ describe('resolveStaticCall', () => { }); it('null-routes when the sole candidate is a Rust Trait (Display)', () => { - ctx.symbols.add('src/fmt.rs', 'Display', 'trait:rs:Display', 'Trait'); + ctx.model.symbols.add('src/fmt.rs', 'Display', 'trait:rs:Display', 'Trait'); ctx.importMap.set('src/app.rs', new Set(['src/fmt.rs'])); const result = resolveStaticCall('Display', 'src/app.rs', ctx); @@ -2146,8 +2465,8 @@ describe('resolveStaticCall', () => { // Rust `impl User { ... }` alongside `struct User { ... }` in the same file. // Same-file tier returns both via lookupExactAll, both pass CLASS_LIKE_TYPES, // but the instantiability filter must strip the Impl so the Struct wins. - ctx.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct'); - ctx.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); + ctx.model.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct'); + ctx.model.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); const result = resolveStaticCall('User', 'src/user.rs', ctx); @@ -2158,7 +2477,7 @@ describe('resolveStaticCall', () => { it('null-routes when the sole candidate is a Rust Impl block (no Struct present)', () => { // Pathological extractor output: only the Impl survives tier resolution. // The instantiability filter must reject it rather than emit a wrong edge. - ctx.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); + ctx.model.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); const result = resolveStaticCall('User', 'src/user.rs', ctx); @@ -2171,9 +2490,9 @@ describe('resolveStaticCall', () => { // extractor still resolves correctly. The Struct is also present so that // step-1's lookupClassByName pre-check succeeds (Impl alone isn't in the // classByName index). - ctx.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct'); - ctx.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); - ctx.symbols.add('src/user.rs', 'User', 'ctor:rs:User', 'Constructor', { + ctx.model.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct'); + ctx.model.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); + ctx.model.symbols.add('src/user.rs', 'User', 'ctor:rs:User', 'Constructor', { returnType: 'User', ownerId: 'impl:rs:User', }); @@ -2186,7 +2505,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget and null-routes Interface constructor-shaped calls', () => { - ctx.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface'); + ctx.model.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface'); ctx.importMap.set('src/app.java', new Set(['src/validator.java'])); const result = _resolveCallTargetForTesting( @@ -2204,7 +2523,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget and null-routes Trait free-form calls', () => { - ctx.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); + ctx.model.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); ctx.importMap.set('src/model.php', new Set(['src/timestamps.php'])); const result = _resolveCallTargetForTesting( @@ -2225,7 +2544,7 @@ describe('resolveStaticCall', () => { // so S0 was bypassed and Record free-form calls fell through to the // constructor-form retry path. This test would have silently passed with // the old (wasteful) code path — with the fix, S0 resolves it directly. - ctx.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record'); + ctx.model.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record'); ctx.importMap.set('src/App.cs', new Set(['src/User.cs'])); const result = _resolveCallTargetForTesting( @@ -2245,13 +2564,13 @@ describe('resolveStaticCall', () => { // Regression guard: if call.argCount were ever dropped at the S0 call // site, the 2-arg constructor would resolve to the 0-arg overload (or // return null via ambiguity). This test fails in either case. - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', { parameterCount: 0, returnType: 'User', ownerId: 'class:User', }); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', { parameterCount: 2, returnType: 'User', ownerId: 'class:User', @@ -2285,7 +2604,7 @@ describe('resolveFreeCall', () => { }); it('resolves a free function call via import-scoped resolution', () => { - ctx.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); const result = resolveFreeCall('doStuff', 'src/app.ts', ctx); @@ -2297,7 +2616,7 @@ describe('resolveFreeCall', () => { }); it('resolves a free function call via same-file resolution', () => { - ctx.symbols.add('src/app.ts', 'helper', 'func:helper', 'Function'); + ctx.model.symbols.add('src/app.ts', 'helper', 'func:helper', 'Function'); const result = resolveFreeCall('helper', 'src/app.ts', ctx); @@ -2313,8 +2632,8 @@ describe('resolveFreeCall', () => { }); it('returns null for ambiguous free function calls (multiple candidates)', () => { - ctx.symbols.add('src/a.ts', 'doStuff', 'func:a:doStuff', 'Function'); - ctx.symbols.add('src/b.ts', 'doStuff', 'func:b:doStuff', 'Function'); + ctx.model.symbols.add('src/a.ts', 'doStuff', 'func:a:doStuff', 'Function'); + ctx.model.symbols.add('src/b.ts', 'doStuff', 'func:b:doStuff', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts'])); const result = resolveFreeCall('doStuff', 'src/app.ts', ctx); @@ -2323,7 +2642,7 @@ describe('resolveFreeCall', () => { }); it('delegates to resolveStaticCall for free-form class targets (Swift/Kotlin)', () => { - ctx.symbols.add('src/user.swift', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.swift', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.swift', new Set(['src/user.swift'])); const result = resolveFreeCall('User', 'src/app.swift', ctx); @@ -2333,7 +2652,7 @@ describe('resolveFreeCall', () => { }); it('delegates to resolveStaticCall for Record free-form targets (C#/Kotlin)', () => { - ctx.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record'); + ctx.model.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record'); ctx.importMap.set('src/App.cs', new Set(['src/User.cs'])); const result = resolveFreeCall('User', 'src/App.cs', ctx); @@ -2343,7 +2662,7 @@ describe('resolveFreeCall', () => { }); it('null-routes Trait free-form calls via resolveStaticCall', () => { - ctx.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); + ctx.model.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); ctx.importMap.set('src/model.php', new Set(['src/timestamps.php'])); const result = resolveFreeCall('HasTimestamps', 'src/model.php', ctx); @@ -2352,7 +2671,7 @@ describe('resolveFreeCall', () => { }); it('uses tieredOverride when provided', () => { - ctx.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); const tiered = ctx.resolve('doStuff', 'src/app.ts'); @@ -2374,7 +2693,7 @@ describe('resolveFreeCall', () => { }); it('routes through resolveCallTarget for free-form calls', () => { - ctx.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); const result = _resolveCallTargetForTesting( @@ -2401,7 +2720,7 @@ describe('resolveFreeCall', () => { // file-extension branching; these guard the dispatch chain per language. it('resolves a Go free function (doStuff())', () => { - ctx.symbols.add('src/helper.go', 'doStuff', 'func:go:doStuff', 'Function'); + ctx.model.symbols.add('src/helper.go', 'doStuff', 'func:go:doStuff', 'Function'); ctx.importMap.set('src/main.go', new Set(['src/helper.go'])); const result = _resolveCallTargetForTesting( @@ -2415,7 +2734,7 @@ describe('resolveFreeCall', () => { }); it('resolves a Python free function (def helper(): ... helper())', () => { - ctx.symbols.add('helpers.py', 'helper', 'func:py:helper', 'Function'); + ctx.model.symbols.add('helpers.py', 'helper', 'func:py:helper', 'Function'); ctx.importMap.set('app.py', new Set(['helpers.py'])); const result = _resolveCallTargetForTesting( @@ -2429,7 +2748,7 @@ describe('resolveFreeCall', () => { }); it('resolves a Rust free function outside any impl block (free_fn())', () => { - ctx.symbols.add('src/helpers.rs', 'free_fn', 'func:rs:free_fn', 'Function'); + ctx.model.symbols.add('src/helpers.rs', 'free_fn', 'func:rs:free_fn', 'Function'); ctx.importMap.set('src/main.rs', new Set(['src/helpers.rs'])); const result = _resolveCallTargetForTesting( @@ -2447,7 +2766,7 @@ describe('resolveFreeCall', () => { // indexing the function directly in its declaring file. The test guards // the dispatch chain for .java files, not the extractor's handling of // static imports specifically. - ctx.symbols.add('src/Utils.java', 'doStuff', 'func:java:doStuff', 'Function'); + ctx.model.symbols.add('src/Utils.java', 'doStuff', 'func:java:doStuff', 'Function'); ctx.importMap.set('src/App.java', new Set(['src/Utils.java'])); const result = _resolveCallTargetForTesting( @@ -2461,7 +2780,7 @@ describe('resolveFreeCall', () => { }); it('resolves a JavaScript module-level function (moduleFn())', () => { - ctx.symbols.add('src/helpers.js', 'moduleFn', 'func:js:moduleFn', 'Function'); + ctx.model.symbols.add('src/helpers.js', 'moduleFn', 'func:js:moduleFn', 'Function'); ctx.importMap.set('src/app.js', new Set(['src/helpers.js'])); const result = _resolveCallTargetForTesting( @@ -2478,10 +2797,10 @@ describe('resolveFreeCall', () => { // differing only in parameter count. it('narrows overloaded free functions by argCount (2-arg overload selected)', () => { - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { parameterCount: 0, }); - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', { parameterCount: 2, }); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); @@ -2497,10 +2816,10 @@ describe('resolveFreeCall', () => { }); it('narrows overloaded free functions by argCount (0-arg overload selected)', () => { - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { parameterCount: 0, }); - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', { parameterCount: 2, }); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); @@ -2520,7 +2839,7 @@ describe('resolveFreeCall', () => { // so a silent tier-table refactor surfaces here. it('resolves a globally-visible free function via Tier 3 with global confidence', () => { - ctx.symbols.add('lib/global.ts', 'helper', 'func:global:helper', 'Function'); + ctx.model.symbols.add('lib/global.ts', 'helper', 'func:global:helper', 'Function'); // No importMap entry — must fall through to Tier 3 (global). const result = _resolveCallTargetForTesting( @@ -2544,11 +2863,11 @@ describe('resolveFreeCall', () => { // preComputedArgTypes at the disambiguation site. it('disambiguates overloads via preComputedArgTypes (String overload matched)', () => { - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { parameterCount: 1, parameterTypes: ['String'], }); - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { parameterCount: 1, parameterTypes: ['Int'], }); @@ -2566,11 +2885,11 @@ describe('resolveFreeCall', () => { }); it('disambiguates overloads via preComputedArgTypes (Int overload matched)', () => { - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { parameterCount: 1, parameterTypes: ['String'], }); - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { parameterCount: 1, parameterTypes: ['Int'], }); @@ -2600,7 +2919,7 @@ describe('resolveFreeCall', () => { // will need to be updated alongside that work — that is the correct signal. it('null-routes Enum free-form calls (Color() — no instantiable fallback)', () => { - ctx.symbols.add('src/color.ts', 'Color', 'enum:Color', 'Enum'); + ctx.model.symbols.add('src/color.ts', 'Color', 'enum:Color', 'Enum'); ctx.importMap.set('src/app.ts', new Set(['src/color.ts'])); const result = _resolveCallTargetForTesting( @@ -2631,8 +2950,13 @@ describe('resolveFreeCall', () => { it('dedupes Swift extension candidates by shortest file path (free-form retry path)', () => { // Two same-name Class entries, different path lengths. - ctx.symbols.add('src/User.swift', 'User', 'class:User:primary', 'Class'); - ctx.symbols.add('src/Extensions/UserExtensions.swift', 'User', 'class:User:extension', 'Class'); + ctx.model.symbols.add('src/User.swift', 'User', 'class:User:primary', 'Class'); + ctx.model.symbols.add( + 'src/Extensions/UserExtensions.swift', + 'User', + 'class:User:extension', + 'Class', + ); ctx.importMap.set( 'src/App.swift', new Set(['src/User.swift', 'src/Extensions/UserExtensions.swift']), @@ -2673,8 +2997,8 @@ describe('resolveFreeCall', () => { // 'constructor' form, which — per CONSTRUCTOR_TARGET_TYPES — prefers // the Constructor node over the Class node. // 4. Single survivor → returned as the call target. - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User:ownerless', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:ownerless', 'Constructor', { parameterCount: 0, // No ownerId — this is the pathological extractor output the retry path // exists to handle. @@ -2699,7 +3023,7 @@ describe('resolveFreeCall', () => { // language coverage table in PR #756 review flagged this as uncovered; // this test exercises the `.php` dispatch path for free calls. Matches // the shape of the existing Go/Python/Rust/Java/JS language tests above. - ctx.symbols.add('src/helpers.php', 'helper', 'func:php:helper', 'Function'); + ctx.model.symbols.add('src/helpers.php', 'helper', 'func:php:helper', 'Function'); ctx.importMap.set('src/app.php', new Set(['src/helpers.php'])); const result = _resolveCallTargetForTesting( diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index ce771b857..27f449f91 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; import { buildTypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js'; import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js'; +import { type SymbolDefinition } from '../../src/core/ingestion/model/symbol-table.js'; import { - createSymbolTable, - type SymbolDefinition, - type SymbolTable, -} from '../../src/core/ingestion/symbol-table.js'; + createSemanticModel, + type SemanticModel, +} from '../../src/core/ingestion/model/semantic-model.js'; import { stripNullable, extractSimpleTypeName, @@ -78,25 +78,6 @@ function flatSize(typeEnv: TypeEnvironment): number { return count; } -const createMockSymbolTable = (overrides: Partial = {}): SymbolTable => ({ - add: () => {}, - lookupExact: () => undefined, - lookupExactFull: () => undefined, - lookupExactAll: () => [], - lookupCallableByName: () => [], - lookupFieldByOwner: () => undefined, - lookupMethodByOwner: () => undefined, - lookupClassByName: () => [], - lookupClassByQualifiedName: () => [], - lookupImplByName: () => [], - getFiles: () => [][Symbol.iterator](), - getStats: () => ({ - fileCount: 0, - }), - clear: () => {}, - ...overrides, -}); - const createClassDef = ( name: string, type: SymbolDefinition['type'] = 'Class', @@ -1191,29 +1172,44 @@ class RepoService { }); describe('destructured call results', () => { - // Minimal mock SymbolTable for call-result return type lookup + // Minimal mock SemanticModel for call-result return type lookup + // (SM-21 inversion — buildTypeEnv takes a SemanticModel via `model:`). const makeSymbolTable = (callables: Array<{ name: string; returnType?: string }>) => ({ - lookupCallableByName: (name: string) => - callables - .filter((c) => c.name === name) - .map((c) => ({ - nodeId: 'n1', - filePath: 'src.ts', - type: 'Function' as const, - returnType: c.returnType, - })), - lookupClassByName: () => [], - lookupExact: () => undefined, - lookupExactFull: () => undefined, - add: () => {}, - getStats: () => ({ fileCount: 0 }), - clear: () => {}, + types: { + lookupClassByName: () => [], + lookupClassByQualifiedName: () => [], + lookupImplByName: () => [], + }, + methods: { + lookupMethodByOwner: () => undefined, + lookupMethodByName: () => [], + }, + fields: { + lookupFieldByOwner: () => undefined, + }, + symbols: { + add: () => {}, + lookupExact: () => undefined, + lookupExactFull: () => undefined, + lookupExactAll: () => [], + lookupCallableByName: (name: string) => + callables + .filter((c) => c.name === name) + .map((c) => ({ + nodeId: 'n1', + filePath: 'src.ts', + type: 'Function' as const, + returnType: c.returnType, + })), + getFiles: () => [][Symbol.iterator](), + getStats: () => ({ fileCount: 0 }), + }, }); it('emits callResult + fieldAccess items for const { x } = fn()', () => { const symbolTable = makeSymbolTable([{ name: 'getUser', returnType: 'User' }]); const tree = parse('const { name } = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable: symbolTable as any }); + const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable }); // callResult resolves __destr_getUser_N → User // fieldAccess resolves name via User's properties (no Property nodes in mock → undefined) // But the callResult itself IS emitted — verify constructorBindings is still empty @@ -1226,14 +1222,14 @@ class RepoService { 'async function f() { const { data } = await fetchData(); }', TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable: symbolTable as any }); + const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable }); expect(typeEnv.constructorBindings).toEqual([]); }); it('gracefully handles no return type (composable without annotation)', () => { const symbolTable = makeSymbolTable([{ name: 'useUserRole' }]); // no returnType const tree = parse('const { isMaker } = useUserRole();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable: symbolTable as any }); + const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable }); // No return type → callResult unresolved → fieldAccess unresolved expect(flatGet(typeEnv, 'isMaker')).toBeUndefined(); }); @@ -2045,17 +2041,10 @@ class RepoService { `, Kotlin, ); - // User is NOT defined in this file, but SymbolTable knows it's a Class - const mockSymbolTable = { - lookupClassByName: (name: string) => - name === 'User' ? [{ nodeId: 'n1', filePath: 'models.kt', type: 'Class' }] : [], - lookupExact: () => undefined, - lookupExactFull: () => undefined, - add: () => {}, - getStats: () => ({ fileCount: 0 }), - clear: () => {}, - }; - const typeEnv = buildTypeEnv(tree, 'kotlin', { symbolTable: mockSymbolTable as any }); + // User is NOT defined in this file, but SemanticModel knows it's a Class + const model = createSemanticModel(); + model.symbols.add('models.kt', 'User', 'n1', 'Class'); + const typeEnv = buildTypeEnv(tree, 'kotlin', { model }); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -2068,17 +2057,8 @@ class RepoService { `, Kotlin, ); - const mockSymbolTable = { - lookupClassByName: () => [], - lookupCallableByName: () => [], - lookupFieldByOwner: () => undefined, - lookupExact: () => undefined, - lookupExactFull: () => undefined, - add: () => {}, - getStats: () => ({ fileCount: 0 }), - clear: () => {}, - }; - const typeEnv = buildTypeEnv(tree, 'kotlin', { symbolTable: mockSymbolTable as any }); + const model = createSemanticModel(); + const typeEnv = buildTypeEnv(tree, 'kotlin', { model }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2144,10 +2124,15 @@ def main(): }); describe('lookupClassByName regression coverage', () => { - const makeClassLookupTable = (classDefs: Record) => - createMockSymbolTable({ - lookupClassByName: (name: string) => classDefs[name] ?? [], - }); + const makeClassLookupTable = ( + classDefs: Record, + ): SemanticModel => { + const model = createSemanticModel(); + vi.spyOn(model.types, 'lookupClassByName').mockImplementation( + (name: string) => classDefs[name] ?? [], + ); + return model; + }; it('Python cross-file constructor inference uses lookupClassByName', () => { const tree = parse( @@ -2158,7 +2143,7 @@ def main(): Python, ); const typeEnv = buildTypeEnv(tree, 'python', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.py')], }), }); @@ -2174,7 +2159,7 @@ def main(): Python, ); const typeEnv = buildTypeEnv(tree, 'python', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2188,7 +2173,7 @@ def main(): Python, ); const typeEnv = buildTypeEnv(tree, 'python', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.py')], }), }); @@ -2205,7 +2190,7 @@ void run() { CPP, ); const typeEnv = buildTypeEnv(tree, 'cpp', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.h')], }), }); @@ -2222,7 +2207,7 @@ void run() { CPP, ); const typeEnv = buildTypeEnv(tree, 'cpp', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2237,7 +2222,7 @@ end Ruby, ); const typeEnv = buildTypeEnv(tree, 'ruby', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models/user.rb')], }), }); @@ -2254,7 +2239,7 @@ end Ruby, ); const typeEnv = buildTypeEnv(tree, 'ruby', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ UserService: [createClassDef('UserService', 'Class', 'models/user_service.rb')], }), }); @@ -2271,7 +2256,7 @@ end Ruby, ); const typeEnv = buildTypeEnv(tree, 'ruby', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2286,7 +2271,7 @@ void run() { `, ); const typeEnv = buildTypeEnv(tree, 'dart', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.dart')], }), }); @@ -2302,7 +2287,7 @@ void run() { `, ); const typeEnv = buildTypeEnv(tree, 'dart', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.dart')], }), }); @@ -2318,7 +2303,7 @@ void run() { `, ); const typeEnv = buildTypeEnv(tree, 'dart', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2334,7 +2319,7 @@ fn run() { Rust, ); const typeEnv = buildTypeEnv(tree, 'rust', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ UserService: [createClassDef('UserService', 'Struct', 'models.rs')], }), }); @@ -2351,7 +2336,7 @@ fn run() { Rust, ); const typeEnv = buildTypeEnv(tree, 'rust', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'value')).toBeUndefined(); }); @@ -2366,7 +2351,7 @@ func run() { `, ); const typeEnv = buildTypeEnv(tree, 'swift', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'Models/User.swift')], }), }); @@ -2382,7 +2367,7 @@ func run() { `, ); const typeEnv = buildTypeEnv(tree, 'swift', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'Models/User.swift')], }), }); @@ -2398,7 +2383,7 @@ func run() { `, ); const typeEnv = buildTypeEnv(tree, 'swift', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2413,20 +2398,13 @@ function process(user: User) { `, TypeScript.typescript, ); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => - name === 'User' ? [createClassDef('User', 'Class', 'models.ts')] : [], - lookupFieldByOwner: (ownerNodeId: string, fieldName: string) => - ownerNodeId === 'class:User' && fieldName === 'address' - ? { - nodeId: 'prop:User:address', - filePath: 'models.ts', - type: 'Property' as const, - declaredType: 'Address', - } - : undefined, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'User', 'class:User', 'Class'); + model.symbols.add('models.ts', 'address', 'prop:User:address', 'Property', { + ownerId: 'class:User', + declaredType: 'Address', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'addr')).toBe('Address'); }); @@ -2439,10 +2417,8 @@ function process(user: User) { `, TypeScript.typescript, ); - const symbolTable = createMockSymbolTable({ - lookupClassByName: () => [], - }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + const model = createSemanticModel(); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'addr')).toBeUndefined(); }); @@ -2455,23 +2431,14 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => - name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [], - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:Repo' && methodName === 'getProfile' - ? { - nodeId: 'method:Repo:getProfile', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Profile', - } - : undefined, - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile', 'Method', { + ownerId: 'class:Repo', + returnType: 'Profile', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2485,27 +2452,16 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => { - if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; - if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; - return []; - }, - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' - ? { - nodeId: 'method:BaseRepo:getProfile', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - } - : undefined, - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile', 'Method', { + ownerId: 'class:BaseRepo', + returnType: 'Profile', }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable, + model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); @@ -2521,32 +2477,15 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => - name === 'Repo' - ? [ - createClassDef('Repo', 'Class', 'models-a.ts'), - { - ...createClassDef('Repo', 'Class', 'models-b.ts'), - nodeId: 'class:Repo:partial', - }, - ] - : [], - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:Repo:partial' && methodName === 'getProfile' - ? { - nodeId: 'method:Repo:getProfile', - filePath: 'models-b.ts', - type: 'Method', - ownerId: 'class:Repo:partial', - returnType: 'Profile', - } - : undefined, - lookupExactAll: () => [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models-a.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('models-b.ts', 'Repo', 'class:Repo:partial', 'Class'); + model.symbols.add('models-b.ts', 'getProfile', 'method:Repo:getProfile', 'Method', { + ownerId: 'class:Repo:partial', + returnType: 'Profile', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2560,33 +2499,17 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => { - if (name === 'Repo') { - return [ - createClassDef('Repo', 'Class', 'models-a.ts'), - { ...createClassDef('Repo', 'Class', 'models-b.ts'), nodeId: 'class:Repo:partial' }, - ]; - } - if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; - return []; - }, - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' - ? { - nodeId: 'method:BaseRepo:getProfile', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - } - : undefined, - lookupExactAll: () => [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models-a.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('models-b.ts', 'Repo', 'class:Repo:partial', 'Class'); + model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile', 'Method', { + ownerId: 'class:BaseRepo', + returnType: 'Profile', }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable, + model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); @@ -2602,44 +2525,19 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => - name === 'Repo' - ? [ - createClassDef('Repo', 'Class', 'models-a.ts'), - { - ...createClassDef('Repo', 'Class', 'models-b.ts'), - nodeId: 'class:Repo:partial', - }, - ] - : [], - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => { - if (methodName !== 'getProfile') return undefined; - if (ownerNodeId === 'class:Repo') { - return { - nodeId: 'method:Repo:getProfile#a', - filePath: 'models-a.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Profile', - }; - } - if (ownerNodeId === 'class:Repo:partial') { - return { - nodeId: 'method:Repo:getProfile#b', - filePath: 'models-b.ts', - type: 'Method', - ownerId: 'class:Repo:partial', - returnType: 'Profile', - }; - } - return undefined; - }, - lookupExactAll: () => [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models-a.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('models-b.ts', 'Repo', 'class:Repo:partial', 'Class'); + model.symbols.add('models-a.ts', 'getProfile', 'method:Repo:getProfile#a', 'Method', { + ownerId: 'class:Repo', + returnType: 'Profile', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + model.symbols.add('models-b.ts', 'getProfile', 'method:Repo:getProfile#b', 'Method', { + ownerId: 'class:Repo:partial', + returnType: 'Profile', + }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2653,42 +2551,18 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => - name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [], - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:Repo' && methodName === 'getProfile' - ? { - nodeId: 'method:Repo:getProfile#1', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Profile', - } - : undefined, - lookupExactAll: (filePath: string, name: string) => - filePath === 'models.ts' && name === 'getProfile' - ? [ - { - nodeId: 'method:Repo:getProfile#1', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Profile', - }, - { - nodeId: 'method:Repo:getProfile#2', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Profile', - }, - ] - : [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#1', 'Method', { + ownerId: 'class:Repo', + returnType: 'Profile', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#2', 'Method', { + ownerId: 'class:Repo', + returnType: 'Profile', + }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2702,46 +2576,24 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => { - if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; - if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; - return []; - }, - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' - ? { - nodeId: 'method:BaseRepo:getProfile', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - } - : undefined, - lookupExactAll: (filePath: string, name: string) => - filePath === 'models.ts' && name === 'getProfile' - ? [ - { - nodeId: 'method:Repo:getProfile#1', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'User', - }, - { - nodeId: 'method:Repo:getProfile#2', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Admin', - }, - ] - : [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile', 'Method', { + ownerId: 'class:BaseRepo', + returnType: 'Profile', }); + model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#1', 'Method', { + ownerId: 'class:Repo', + returnType: 'User', + }); + model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#2', 'Method', { + ownerId: 'class:Repo', + returnType: 'Admin', + }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable, + model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); @@ -2757,46 +2609,20 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => { - if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; - if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; - return []; - }, - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' - ? { - nodeId: 'method:BaseRepo:getProfile#1', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - } - : undefined, - lookupExactAll: (filePath: string, name: string) => - filePath === 'base.ts' && name === 'getProfile' - ? [ - { - nodeId: 'method:BaseRepo:getProfile#1', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - }, - { - nodeId: 'method:BaseRepo:getProfile#2', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - }, - ] - : [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', { + ownerId: 'class:BaseRepo', + returnType: 'Profile', }); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', { + ownerId: 'class:BaseRepo', + returnType: 'Profile', + }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable, + model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); @@ -2812,22 +2638,24 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const symbolTable = createSymbolTable(); - symbolTable.add('models.ts', 'Repo', 'class:Repo', 'Class'); - symbolTable.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); - symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', { + // SM-21: construct a real SemanticModel and feed it via + // model.symbols.add so the nested registries are populated. + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', { ownerId: 'class:BaseRepo', parameterCount: 1, returnType: 'User', }); - symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', { + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', { ownerId: 'class:BaseRepo', parameterCount: 2, returnType: 'Admin', }); - const lookupCallableByName = vi.spyOn(symbolTable, 'lookupCallableByName'); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable, + model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); @@ -5740,24 +5568,20 @@ function process() { }); describe('importedReturnTypes (Phase 14 E3)', () => { - // Minimal mock SymbolTable that returns a known callable - const makeSymbolTable = (callables: Array<{ name: string; returnType?: string }>) => ({ - lookupCallableByName: (name: string) => - callables - .filter((c) => c.name === name) - .map((c) => ({ - nodeId: 'n1', - filePath: 'src.ts', - type: 'Function' as const, - returnType: c.returnType, - })), - lookupClassByName: () => [], - lookupExact: () => undefined, - lookupExactFull: () => undefined, - add: () => {}, - getStats: () => ({ fileCount: 0 }), - clear: () => {}, - }); + // Minimal real SemanticModel populated via model.symbols.add so that + // lookupCallableByName returns Function symbols with the requested + // return types. + const makeSymbolTable = ( + callables: Array<{ name: string; returnType?: string }>, + ): SemanticModel => { + const model = createSemanticModel(); + callables.forEach((c, idx) => { + model.symbols.add('src.ts', c.name, `n${idx}`, 'Function', { + returnType: c.returnType, + }); + }); + return model; + }; it('SymbolTable has unambiguous match → uses it, ignores cross-file', () => { // SymbolTable knows getConfig() returns Config (SymbolType) @@ -5765,7 +5589,7 @@ function process() { const symbolTable = makeSymbolTable([{ name: 'getConfig', returnType: 'Config' }]); const tree = parse('const c = getConfig();', TypeScript.typescript); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable: symbolTable as any, + model: symbolTable, importedReturnTypes: new Map([['getConfig', 'WrongType']]), }); // SymbolTable result (Config) wins over cross-file fallback (WrongType) @@ -5777,7 +5601,7 @@ function process() { const symbolTable = makeSymbolTable([]); const tree = parse('const c = getConfig();', TypeScript.typescript); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable: symbolTable as any, + model: symbolTable, importedReturnTypes: new Map([['getConfig', 'Config']]), }); // Cross-file fallback provides Config @@ -5792,7 +5616,7 @@ function process() { ]); const tree = parse('const r = process();', TypeScript.typescript); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable: symbolTable as any, + model: symbolTable, importedReturnTypes: new Map([['process', 'User']]), }); // Ambiguous → conservative → no binding produced @@ -5812,7 +5636,7 @@ function process() { const symbolTable = makeSymbolTable([{ name: 'getUser', returnType: 'User' }]); const tree = parse('const u = getUser();', TypeScript.typescript); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable: symbolTable as any, + model: symbolTable, importedReturnTypes: new Map([['getUser', 'CrossFileUser']]), }); // SymbolTable result (User) wins From b10d25bbca531bfbc1bd7497031737be780d9fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sun, 12 Apr 2026 12:31:21 +0100 Subject: [PATCH 11/15] =?UTF-8?q?chore:=20release=20v1.6.0=20=E2=80=94=20u?= =?UTF-8?q?pdate=20CHANGELOG=20and=20package-lock=20(#798)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gitnexus/CHANGELOG.md | 59 ++++++++++++++++++++++++++++++++++++++ gitnexus/package-lock.json | 5 ++-- gitnexus/package.json | 2 +- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index 081eb9b26..26126a9e7 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -2,6 +2,65 @@ All notable changes to GitNexus will be documented in this file. +## [1.6.0] - 2026-04-12 + +### Added +- **SemanticModel architecture refactor (SM-8 through SM-19)** — extracted registries into `model/` module with ISP-compliant interfaces: TypeRegistry, MethodRegistry, FieldRegistry, RegistrationTable, ResolutionContext (#786) + - HeritageMap built from accumulated `ExtractedHeritage[]` for MRO-aware resolution (#739) + - `lookupMethodByOwnerWithMRO` using HeritageMap for cross-class method dispatch (#740) + - MRO fast path before D2 fuzzy widening in call resolution (#741) + - BindingAccumulator for cross-file return type propagation (#743, #763) + - Restructured `resolveUncached` replacing `lookupFuzzy` data source for all tiers (#764) + - Deleted `lookupFuzzy`, `lookupFuzzyCallable`, `globalIndex`, `callableIndex` — replaced with structured lookups (#769) + - Deleted `resolveCallTarget` god-method — replaced with thin dispatcher delegating to `resolveMemberCall` (#744), `resolveStaticCall` (#754), `resolveFreeCall` (#756) (#770) +- **Service group infrastructure** — service boundary detection, contract extractors, sync pipeline, CLI/MCP tools, monorepo fixture; bridge.lbug storage and contract matching expansion (#795) +- **C# interface-to-interface heritage** capture (#789) +- **Vue SFC support** with destructured call result tracking (#604) +- **Java method reference** resolution — `obj::method` as call sites (#622) +- **C/C++ MethodExtractor** config with pure virtual detection (#617) +- **MethodExtractor configs** for Python, PHP, Swift, Dart, Rust, Ruby (#624) +- **METHOD_IMPLEMENTS edges** with overload disambiguation and MethodExtractor unification (#642) +- **Same-arity overload disambiguation** via type-hash suffix (#658) +- **`GITNEXUS_HOME` env var** to customize global directory (#746) +- **Verbose analyze output** prints skipped large file paths (#745) +- **Class name lookup index** for O(1) qualified lookups (#707, #716) +- **`lookupMethodByOwner` index** for O(1) cross-class chain resolution (#665) +- **Fuzzy lookup counters** for performance visibility (#708) + +### Fixed +- **Stack overflow on large PHP files** — iterative AST traversal (#783) +- **Large repository graph loading** failure (#732) +- **Windows multi-repo switching** — false 404 errors and stale repo context (#633) +- **`detect_changes` diff mapping** — map diff hunks to symbol line ranges (#779) +- **HTTP client vs Express route detection** and Spring interface attribution (#780) +- **VECTOR extension** not loaded during DB init for semantic search (#782) +- **tree-sitter-swift** postinstall patch for macOS ARM64 (#788) +- **tree-sitter-c** peer dependency conflict pinned (#723) +- **Constructor indexing** in methodByOwner (#694, #753) +- **Named binding processor** — `lookupExact` replaced with `lookupExactAll` (#755) +- **`.gitnexusignore` negation patterns** now respected (#654) +- **MCP setup** prefers global gitnexus binary over npx (#653) +- **CORS rejection** returns clean error instead of 500 (#646) +- **Array.push stack overflow** — replaced spread with loop (#650) +- **MCP stdout silencing** prevents embedder/pool-adapter conflicts (#645) +- **Web heartbeat** — graceful reconnection replaces aggressive disconnect (#643) +- **Web repo scoping** — backend calls scoped to active repo (#644) +- **OpenCode config path** and FTS extension load order (#781) +- **OnboardingGuide** dev-mode serve command corrected (#725) +- **Security issues** and critical bugs from code review (#709) + +### Changed +- Replaced class-type fuzzy lookups with structured indices in type-env (#733, #734, #736) +- Extracted `CLASS_LIKE_TYPES` constant (#693) + +## [1.5.3] - 2026-04-01 + +### Added +- **TypeScript/JavaScript MethodExtractor** config (#588) + +### Fixed +- **Wiki Azure OpenAI** compat and HTML viewer script injection (#618) + ## [1.5.2] - 2026-04-01 ### Fixed diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 3736d0c46..ee80faecf 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,13 @@ { "name": "gitnexus", - "version": "1.5.3", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.5.3", + "version": "1.6.0", + "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@huggingface/transformers": "^3.0.0", diff --git a/gitnexus/package.json b/gitnexus/package.json index 871524702..864f28101 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.5.3", + "version": "1.6.0", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", From 4d4756fe869bea86d5f10a652bbccd800a830cdc Mon Sep 17 00:00:00 2001 From: ivkond Date: Mon, 13 Apr 2026 10:49:30 +0300 Subject: [PATCH 12/15] feat(group): extractor expansion + manifest extractor (2/4 of #606 split) (#796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(group): extractor expansion + manifest extractor Part 2 of 4 in the split of #606 (ticket: #792). Follows #795 (bridge.lbug storage foundation, already merged), but this PR has no code-level dependency on #795 — it only imports types and the ContractExtractor interface that existed on upstream main before either PR. It could have been reviewed in parallel with #795. ## What changed Expands the 3 existing contract extractors with substantially more language/framework coverage, and adds a new `manifest-extractor` that resolves `group.yaml`-declared cross-links against the per-repo graph via exact-name lookups. ### New file (228 LOC) - `gitnexus/src/core/group/extractors/manifest-extractor.ts` — exact graph lookup for `group.yaml`-declared cross-links. HTTP paths are canonicalized before Route.name matching; gRPC is resolved by service/method name (NO `.proto`-filename fallback); topic and lib use exact-name match. Falls back to a synthetic `manifest::::` uid when the graph has no matching symbol, so cross-impact traversal still has a stable anchor for the contract. ### Modified extractors (+958 LOC prod) - `extractors/grpc-extractor.ts` (+522) — `.proto` parser with comment and string-literal sanitization (braces inside strings no longer truncate service bodies); package/service/method canonical IDs; server/client detection across Go (`grpc.NewServer`, `RegisterXxxServer`, `XxxGrpc.XxxImplBase`), Java (`@GrpcService`, `BlockingStub`), Python (`servicer_to_server`, `XxxStub`), and TypeScript/Node (`@GrpcMethod`, `ClientGrpc`, `loadPackageDefinition`). - `extractors/http-route-extractor.ts` (+174) — Go gin/echo/stdlib `HandleFunc`, NestJS `@Controller`+`@Get`/etc, Python FastAPI decorators, Java Spring `@RequestMapping`/`@GetMapping`, restTemplate / WebClient / OkHttp consumers. - `extractors/topic-extractor.ts` (+98) — sarama `ProducerMessage{}` struct literal detection (replaces a constructor-anchored regex that missed topics inside producer loops), kafka-go Writer/Reader, Python NATS (`await nc.subscribe`/`await nc.publish`), JetStream helpers. ### Modified and new tests (+1264 LOC) - `grpc-extractor.test.ts` (+539) — full coverage of the new proto parser (strings-with-braces regression, comments-with-braces regression), per-language server/client detection - `http-route-extractor.test.ts` (+240) — per-framework route extraction + normalization edge cases - `topic-extractor.test.ts` (+177) — the sarama in-loop regression, JetStream, Python NATS, kafka-go Writer/Reader - `manifest-extractor.test.ts` (+308 NEW) — HTTP path normalization, gRPC exact lookup with proto-fallback regression, lib and topic exact matching, synthetic-uid fallback behavior ### Self-review fixes folded in Carried forward from the #606 self-review (commit `d15b8cb`): - **HIGH #1** — `manifest-extractor.resolveSymbol` was too fuzzy. Previously used `CONTAINS` on route/name fields plus an unconditional `filePath ENDS WITH '.proto'` fallback for gRPC. Consequences: `/orders` matched `/suborders`, and any repo with any `.proto` file returned a random proto symbol for a gRPC manifest entry. Replaced with exact equality + deterministic `ORDER BY` + synthetic-uid fallback for unresolved manifests. Regression tests included. - **MED #3** — gRPC proto parser brace-depth counting now sanitizes strings and comments first (`stripProtoCommentsAndStrings`). A valid proto with `option deprecated_reason = "use NewService { instead"` used to have its service body closed early by the `"{"` inside the literal, silently dropping methods after the offending string. Regression tests for both string-with-brace and comment-with-brace cases. - **MED #4** — sarama Kafka regex changed from `sarama.NewSyncProducer[\s\S]{0,300}?Topic:` (anchored on constructor, caught only first topic in a loop) to `sarama.ProducerMessage{...Topic:}` (matches every struct literal directly). Regression test with a for-loop that constructs multiple `ProducerMessage`s. - **MED #7** — `manifest-extractor.resolveSymbol` no longer has a silent `catch { /* fall through */ }`. Errors from the graph executor are logged via `console.warn` with link type, contract name, repo key, and error message before falling through to the synthetic-uid path. ## Why Reviewer focus here is pure regex / parser correctness — no storage, no Cypher queries, no algorithmic changes to the cross-link algorithm. Separating this from the bridge foundation PR (#795) meant reviewers could stay in a single mental mode (parsing logic) instead of context-switching between DDL, Cypher, and regex. ## How to verify - `cd gitnexus && npx tsc --noEmit` - `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts --pool=forks` - `cd gitnexus && npx vitest run test/unit/group/http-route-extractor.test.ts --pool=forks` - `cd gitnexus && npx vitest run test/unit/group/topic-extractor.test.ts --pool=forks` - `cd gitnexus && npx vitest run test/unit/group/manifest-extractor.test.ts --pool=forks` Local pre-push: typecheck clean, all 99 extractor unit tests pass (grpc 43, http 18, topic 30, manifest 8). ## Risk / rollback **Low.** Extractors have no user-facing surface in this PR — they produce `ExtractedContract[]` that is consumed by `sync.ts` in the next split (#793). No existing behavior changes for users who don't run a `group sync`. Rollback = `git revert` of the merge commit; the modifications to `grpc-extractor.ts` / `http-route-extractor.ts` / `topic-extractor.ts` revert to the pre-PR versions that still work (they're subsets of the new functionality). ## Scope discipline (per GUARDRAILS.md) - Only the 8 files above are touched; no drive-by refactors - No CI/release/security config changes - No secrets or machine-specific paths - Content lifted from #606 (CI 11/11 green on `d15b8cb`) ## Dependencies - **Base:** `main` (upstream already includes #795 as `1ff324c`) - **Blocks:** sync pipeline (#793) and the cross-impact feature (#794) - **Tracker issue:** #792 - **Parent PR:** #606 Co-authored-by: Claude * refactor(group): migrate topic-extractor from regex to tree-sitter queries Addresses @magyargergo's feedback on #796 that regex-based lookups should use tree-sitter nodes instead, and that the top-level extractors must NOT carry language dependencies. This is phase 1 of a multi-step migration — topic-extractor first because its patterns are the most uniform (16 "call/annotation with first-arg string literal" variants), which makes it a clean proof of the approach before grpc-extractor and http-route-extractor get the same treatment. ## Architecture: language-agnostic orchestrator + per-language plugins The top-level extractor is a thin orchestrator that never imports a tree-sitter grammar or a query string. Per-language knowledge lives in a new `topic-patterns/` folder with one file per language plus a registry that maps file extensions to compiled plugins: ``` src/core/group/extractors/ ├── tree-sitter-scanner.ts # shared, language-agnostic scanning utilities ├── topic-extractor.ts # thin orchestrator (no grammar imports) └── topic-patterns/ ├── types.ts # TopicMeta, Broker ├── index.ts # registry: extension → compiled provider ├── java.ts # tree-sitter-java + JAVA_TOPIC_PROVIDER ├── go.ts # tree-sitter-go + GO_TOPIC_PROVIDER ├── python.ts # tree-sitter-python + PYTHON_TOPIC_PROVIDER └── node.ts # tree-sitter-javascript + tree-sitter-typescript # → JAVASCRIPT_/TYPESCRIPT_/TSX_TOPIC_PROVIDER ``` **Shared scanner (`tree-sitter-scanner.ts`)** — defines `PatternSpec`, `LanguagePatterns`, `CompiledPatterns` and the `scanFile(parser, plugin, content)` helper. Plugins compile their queries eagerly at module load via `compilePatterns()`, so a broken pattern fails loudly at import time instead of silently at scan time. `unquoteLiteral()` handles single/double/template quotes, Python triple-quoted strings, and Go raw backtick strings. **Per-language plugins** own: - the tree-sitter grammar import (this is the ONLY place in `src/core/group/` where tree-sitter grammars are imported), - the query S-expressions, - the `TopicMeta` payload (role, broker, confidence, symbolName) that the orchestrator receives back on every match. Each plugin uses a `@value` capture name to bind the topic literal node. The JavaScript and TypeScript grammars share AST node names for every construct we query, so `node.ts` defines the pattern sources once and compiles them against `JavaScript`, `TypeScript.typescript`, and `TypeScript.tsx` — exporting three providers because `Parser.Query` objects are NOT portable across grammar instances. **Registry (`topic-patterns/index.ts`)** — maps `.java` → Java provider, `.go` → Go, `.py` → Python, `.js`/`.jsx` → JS, `.ts` → TS, `.tsx` → TSX. Also exports `TOPIC_SCAN_GLOB` so adding a new language is a single file-level edit (drop `topic-patterns/.ts`, import + register it here — zero edits required in `topic-extractor.ts`). **Orchestrator (`topic-extractor.ts`)** — ~110 lines, no grammar or query imports. Per file: `getProviderForFile(rel)` → `scanFile(parser, provider, content)` → `unquoteLiteral(valueText)` → `makeContract(...)`. Reuses one `Parser` instance across files; the scanner calls `setLanguage` per plugin. ## Why this is better than regex 1. **Comments and strings are respected for free.** The old regex would match `// kafkaTemplate.send("fake.topic")` as a real producer; tree-sitter never visits comments or string literals as code nodes, so false positives from commented-out code are eliminated. 2. **Struct/object literal patterns are structural, not textual.** `sarama.ProducerMessage{Topic: "..."}` no longer needs a 300-char lookahead (which was a known cross-match bug partly mitigated by a loop regression test in the self-review). The new query matches a specific `composite_literal` with a specific `qualified_type` and `keyed_element` — exactly one struct literal per match. 3. **No order-of-operations fragility.** Regex for `channel.publish` vs `channel.consume` was independent and file-wide; the AST scopes matches to the specific `call_expression`. 4. **Language-agnostic extension.** Adding Ruby, Rust, or C# topic detection later means dropping one file in `topic-patterns/` — no changes to shared scanner or orchestrator, and no tree-sitter imports leak into top-level code. ## Per-file fault tolerance - Malformed files that tree-sitter can't parse are silently skipped (`parser.parse` is wrapped by `scanFile`). The ingestion pipeline already logs unparseable files at index time. - A syntactically invalid query is caught at `compilePatterns` time, not scan time — broken plugins fail loudly at import. - Per-pattern `matches()` failures are swallowed so one broken query in a plugin doesn't block the rest. ## Tests All 30 existing `topic-extractor.test.ts` tests pass **without any changes to the test file** — they were written as input/output contract tests (given this source file, expect these `ExtractedContract` objects) and that contract is unchanged. Regression coverage includes: - Kafka: Java `@KafkaListener` + `kafkaTemplate.send`; Node `producer.send` + `consumer.subscribe`; Go sarama producer/consumer (sync and async); kafka-go Writer/Reader; Python `KafkaConsumer` + `producer.send/produce` - RabbitMQ: Java `@RabbitListener` + `rabbitTemplate.convertAndSend`; Node `channel.consume/publish/sendToQueue`; Python `basic_consume/ basic_publish` with keyword args - NATS: Go and Node `nc.Subscribe/Publish`; Go and Node JetStream `js.Subscribe/Publish`; Python `await nc.subscribe/publish` Including the regression test for the sarama `ProducerMessage` in-loop case — the AST-based query captures every literal in the file independently, not just the first one after `NewSyncProducer`. ## Neighbor regression check - `topic-extractor.test.ts` — 30/30 pass (rewritten extractor) - `http-route-extractor.test.ts` — 18/18 pass (untouched) - `grpc-extractor.test.ts` — 43/43 pass (untouched) - `manifest-extractor.test.ts` — 8/8 pass (untouched) - Full `npx tsc --noEmit` clean ## Scope discipline (per GUARDRAILS.md) - Only files under `src/core/group/extractors/` are touched; no changes to other extractors, tests, MCP surface, or pipeline.ts. - No CI/release/security config changes, no secrets. - New tree-sitter imports all reference grammars that are already installed as dependencies (`tree-sitter`, `tree-sitter-javascript`, `tree-sitter-typescript`, `tree-sitter-python`, `tree-sitter-java`, `tree-sitter-go` — all in `package.json` for the existing pipeline). ## Phase 2 / phase 3 plan - **Phase 2 (next commit):** rewrite `http-route-extractor.ts` Strategy B (regex fallback) on the same plugin pattern. Graph-assisted Strategy A stays as-is (already uses pipeline-built tree-sitter data via `HANDLES_ROUTE` Cypher queries). - **Phase 3 (commit after):** rewrite `grpc-extractor.ts` for Java / Go / Python / TypeScript detection. `.proto` files are the one outstanding question — there is no `tree-sitter-proto` grammar installed; the in-tree string-sanitizing parser stays as a pragmatic exception with a comment, alternative being to add `tree-sitter-proto` as a dep (open for the maintainer). Co-authored-by: Claude * refactor(group): migrate http-route-extractor Strategy B to tree-sitter plugins Phase 2 of the extractor refactor requested by @magyargergo on #796. Same architecture as the phase 1 topic-extractor rewrite: a thin, language-agnostic orchestrator plus per-language plugins that own tree-sitter grammars and query sources. The top-level extractor file no longer imports any tree-sitter grammar or query string. ## Architecture ``` src/core/group/extractors/ ├── tree-sitter-scanner.ts # shared, language-agnostic primitives ├── http-route-extractor.ts # thin orchestrator (no grammar imports) └── http-patterns/ ├── types.ts # HttpDetection, HttpLanguagePlugin, HttpRole ├── index.ts # registry: ext → plugin + HTTP_SCAN_GLOB ├── java.ts # tree-sitter-java: Spring + RestTemplate/WebClient/OkHttp ├── go.ts # tree-sitter-go: gin/echo/HandleFunc + http/resty consumers ├── python.ts # tree-sitter-python: FastAPI + requests ├── php.ts # tree-sitter-php: Laravel Route::get/... └── node.ts # tree-sitter-javascript + tree-sitter-typescript: # NestJS controllers, Express, fetch, axios ``` **Shared scanner (`tree-sitter-scanner.ts`)** — generalised from phase 1: - `ScanMatch.captures` is now a full `CaptureMap` (every named capture the query binds, not just a single `@value`). Topic extractor updated to read `match.captures.value` accordingly. - New `runCompiledPatterns(plugin, tree)` helper lets plugins run multiple query bundles against the same pre-parsed tree. This is needed for HTTP plugins that combine a class-prefix query with a method-route query (Spring, NestJS). - `scanFile` becomes a thin wrapper over `parser.parse + runCompiledPatterns`. **HTTP plugin shape** — unlike topic plugins, HTTP plugins expose a `scan(tree)` function rather than a flat pattern list. This reflects HTTP's more complex extraction: each detection needs method + path + handler name, and framework patterns like Spring `@RequestMapping` / NestJS `@Controller` require cross-referencing a class-level prefix with method-level annotations. Plugins internally use `compilePatterns` + `runCompiledPatterns` and walk the AST to resolve the class/method relationships. **Per-framework coverage:** - **Java (`java.ts`)** - Spring: `@RequestMapping("/api/v2")` class prefix + `@(Get|Post|Put| Delete|Patch)Mapping("/sub")` method routes, joined via the enclosing `class_declaration` node id. - `RestTemplate.getForObject/postForEntity/put/delete/patchForObject` → method derived from API name. - `WebClient.method(HttpMethod.X, "/path")` → method from `HttpMethod.X` capture. - `new Request.Builder().url("/path")` → OkHttp consumer. - **Go (`go.ts`)** - gin / echo / chi frameworks: `\w+.GET("/path", handler)` captures upper-case verb + handler identifier. - `net/http.HandleFunc("/path", handler)` → provider (default GET). - `http.Get/Post/Head` consumer, `http.NewRequest("METHOD", ...)`, resty `client.R().Get/Post/...`. - **Python (`python.ts`)** - `@app.get("/path")` FastAPI decorators. - `requests.get/post/...` and `requests.request("METHOD", "url")`. - **PHP (`php.ts`)** - Laravel `Route::get/post/.../patch('/path', ...)` via `scoped_call_expression`. Uses `PHP.php_only` to match the existing ingestion pipeline's grammar selection. - **Node (`node.ts`) — JS + TS + TSX** - Pattern sources defined once, compiled against three grammar variants (`JavaScript`, `TypeScript.typescript`, `TypeScript.tsx`) because `Parser.Query` objects are not portable across grammars. Exports three plugins sharing the same `scan` logic. - NestJS: `@Controller('prefix')` decorators are siblings of the class in `export_statement` / `program`; `@Get(':id')` decorators are siblings of the method in `class_body`. The plugin walks decorator → next named sibling to find the decorated class / method, then combines the class prefix with the method path. Only emits NestJS detections when the enclosing class has a real `@Controller` decorator — prevents false positives from generic classes that happen to use `@Get` from another library. - Express: `(router|app).('/path', ...)`. - `fetch(url)` (default GET) + `fetch(url, { method: 'X' })` (uses two queries + a SyntaxNode-id dedupe set so URL literals aren't double-emitted by the options variant). - `axios.get/post/...`. ## Orchestrator changes `http-route-extractor.ts` drops every `scanXxxProviders` / `scanXxxConsumers` regex method and replaces them with a single source-scan loop that delegates to `getPluginForFile(rel).scan(tree)`. The orchestrator still owns: - **Path normalization** (`normalizeHttpPath`, `normalizeConsumerPath`) — language-agnostic string processing shared by both strategies. - **Graph-assisted Strategy A** (`HANDLES_ROUTE` / `FETCHES` / `CONTAINS` Cypher queries) — unchanged in spirit. The only regex helpers it used (`inferMethodFromFileScan`, `pickJavaHandlerName`) are now replaced by a lookup against the plugin's detections for the same file: for each route row, find the detection whose normalized path matches, and pull the HTTP method + handler name from it. - **Per-file parse cache** — the orchestrator parses each relevant file at most once per `extract()` call. Both the graph-assisted enrichment loop and the source-scan fallback share the same `cachedDetections` map, so we never run the plugin twice for the same file. ## Why this is better than the regex version 1. **Comments and strings for free.** The old regex would match `// router.get('/fake')` as a real Express route; tree-sitter never visits string/comment nodes. 2. **Structural controller-prefix.** Spring and NestJS class-prefix joining is now scoped to the enclosing class via `class_declaration` node ids, eliminating file-wide state that broke when a file had multiple controllers. 3. **Precise NestJS disambiguation.** The plugin only emits a NestJS detection when the enclosing class has a real `@Controller` decorator — the old regex would fire on any `@Get(...)` in the file regardless of surrounding context. 4. **Language-agnostic extension.** Adding Ruby / Rust / Kotlin HTTP detection later means dropping one file in `http-patterns/` — no changes to the shared scanner, the orchestrator, or the Strategy A Cypher queries. ## Tests - `http-route-extractor.test.ts` — **18/18 pass** (tests unchanged; they're contract-style input/output tests and the contract shape is unchanged). Covers Spring class prefix, Express, gin/echo, stdlib HandleFunc, NestJS, Laravel, FastAPI for providers and fetch/axios/python-requests/rest-template/webClient/okhttp/go-stdlib/ resty for consumers, plus graph-first Strategy A for both. - `topic-extractor.test.ts` — **30/30 pass** after the `captures.value` API migration. - `grpc-extractor.test.ts` — 43/43 pass (untouched; phase 3). - `manifest-extractor.test.ts` — 8/8 pass (untouched). - `service.test.ts`, `sync.test.ts`, `storage.test.ts` — 41/41 pass. - `npx tsc -p tsconfig.json --noEmit` clean. ## Scope discipline (per GUARDRAILS.md) - Only files under `src/core/group/extractors/` are touched. - No changes to pipeline.ts, MCP surface, ingestion, or tests. - No CI / release / security / secrets changes. - Tree-sitter grammars imported by plugins (`tree-sitter-java`, `tree-sitter-go`, `tree-sitter-python`, `tree-sitter-php`, `tree-sitter-javascript`, `tree-sitter-typescript`) are all already in `package.json` for the existing ingestion pipeline. ## Phase 3 plan - **grpc-extractor** gets the same treatment: plugin-per-language under `grpc-patterns/` for Java / Go / Python / TS detection. `.proto` files remain an open question — no `tree-sitter-proto` grammar is installed, so the in-tree string-sanitizing parser from PR #796's self-review stays as a pragmatic exception unless the maintainer wants us to add `tree-sitter-proto` as a new dep. Co-authored-by: Claude * refactor(group): migrate grpc-extractor source scans to tree-sitter plugins Phase 3 (final) of the extractor refactor requested by @magyargergo on #796. Same architecture as phase 1 (topic) and phase 2 (http): thin language-agnostic orchestrator + per-language plugins that own tree-sitter grammars and query sources. With this commit the top-level extractors under `src/core/group/extractors/` import ZERO tree-sitter grammars and ZERO query strings — every grammar import lives in a `*-patterns/.ts` plugin file, and the orchestrators go through the registry indirection. ## Architecture ``` src/core/group/extractors/ ├── tree-sitter-scanner.ts # shared primitives (unchanged) ├── grpc-extractor.ts # orchestrator (only `.proto` parser left) └── grpc-patterns/ ├── types.ts # GrpcDetection, GrpcLanguagePlugin, GrpcRole ├── index.ts # registry: ext → plugin + GRPC_SCAN_GLOB ├── go.ts # tree-sitter-go: RegisterXxxServer, Unimplemented, NewXxxClient ├── java.ts # tree-sitter-java: @GrpcService + XxxImplBase + newBlockingStub ├── python.ts # tree-sitter-python: add_XxxServicer_to_server + XxxStub └── node.ts # tree-sitter-javascript + tree-sitter-typescript: # @GrpcMethod, @GrpcClient field type, # .getService('Svc'), new XxxServiceClient, # loadPackageDefinition dynamic constructors ``` ## Per-language coverage **Go (`go.ts`)** - Provider: `\w+.RegisterXxxServer(...)` via `call_expression → selector_expression → field_identifier` + JS regex filter `^Register(\w+)Server$`. - Provider: `pb.UnimplementedXxxServer` embedded in a struct via `struct_type → field_declaration_list → field_declaration → qualified_type → type_identifier` + JS filter. - Consumer: `\w+.NewXxxClient(...)` via the same call_expression query + JS filter `^New(\w+)Client$`. **Java (`java.ts`)** - Provider: `class X extends YyyGrpc.YyyImplBase` — two queries handle the scoped and plain forms. `scoped_type_identifier`'s children are positional (no `scope:`/`name:` fields), so the query matches the two `type_identifier` children by position. - `#match? @inner "ImplBase$"` restricts matches at query time. - Whether the class has `@GrpcService` or not controls only the `source` metadata label — the plugin walks the class_declaration's `modifiers` child in JS to detect the marker_annotation. - Consumer: `YyyGrpc.newStub(ch)` / `newBlockingStub(ch)` via a `method_invocation` query with `#match? @method "^new(Blocking)?Stub$"`, service name extracted via `^(\w+)Grpc$` on the object identifier. **Python (`python.ts`)** - Single call-expression query covers both bare identifier and `obj.method` attribute forms: `(call function: [(identifier) @fn (attribute attribute: (identifier) @fn)])`. - Plugin filters `@fn.text` against two JS regexes: `^add_(\w+)Servicer_to_server$` (provider) and `^(\w+)Stub$` (consumer), with a reserved-names ignore list for the Stub case (Mock / Test / Fake / Stub). **Node — JavaScript + TypeScript + TSX (`node.ts`)** - Pattern sources defined once, compiled three times (one per grammar) because `Parser.Query` objects are not portable across grammars. Exports three `GrpcLanguagePlugin`s sharing the same `scan`. - `@GrpcMethod('Service', 'Method')`: decorator query captures the two string literals. Confidence is hard-coded 0.8 regardless of proto map resolution (matches the original regex version's behaviour). - `@GrpcClient(...) field: XxxServiceClient`: decorator query captures the decorator node, plugin walks up to find the enclosing `public_field_definition` (decorators on fields are CHILDREN of the field definition in tree-sitter-typescript, not siblings) and reads its first `type_annotation → type_identifier`, then runs the `^(\w+Service)Client$` JS filter. - `client.getService('AuthService')`: call-expression query on `member_expression.property = "getService"` + string literal arg. - `new XxxServiceClient(...)`: `new_expression` with a bare identifier constructor, filtered by `^(\w+Service)Client$` so generic `new AuthClient(...)` (missing the `Service` infix) does NOT falsely register as a consumer. Preserves the regression test `test_extract_ts_non_service_client_constructor_is_ignored`. - `loadPackageDefinition` dynamic loader: gated on `tree.rootNode.text.includes('loadPackageDefinition')`. When set, `new foo.bar.Xxx(...)` qualified constructors with a capitalised property name register as consumers. ## Orchestrator changes `grpc-extractor.ts` loses every `scanGoProviders` / `scanJavaProviders` / ... helper and replaces them with a single source-scan loop that: 1. Parses each file with the plugin's grammar (one shared `Parser` instance across all files, `setLanguage` called per plugin). 2. Calls `plugin.scan(tree)` to get `GrpcDetection[]`. 3. Converts each detection to an `ExtractedContract` via the private `detectionToContract` helper, which: - Looks the short service name up in the proto map (filled by the `.proto` parser). - Picks confidence = `confidenceWithProto` if resolved, else `confidenceWithoutProto`. - Builds a method-level contract id (`grpc::pkg.Svc/Method`) when the detection carries a `methodName` (TS `@GrpcMethod` only), otherwise a service-level id (`grpc::pkg.Svc/*`). Everything else — the `.proto` parser, `buildProtoContext`, `buildProtoMap`, `resolveProtoConflict`, `serviceContractId`, `stripProtoCommentsAndStrings`, `extractServiceBlocks`, the dedupe function — stays exactly as before. The `.proto` parser is kept as a pragmatic exception to the "no regex in extractors" rule because no `tree-sitter-proto` grammar is installed in the repo; a comment at the top of the file explains this and flags the maintainer option of adding `tree-sitter-proto` as a dependency. ## Why this is better than the regex version 1. **Comments and strings are respected for free.** Matched node types are only code constructs, never text inside comments or string literals. 2. **No false positives on partial names.** The old `(\w+?)Grpc`-style regexes would cross-match unrelated identifiers; structural queries restrict matches to the exact AST shape (`scoped_type_identifier → type_identifier` pairs, `method_invocation → identifier` etc.). 3. **NestJS `@GrpcClient` is structural, not regex-based.** The old regex required a specific textual layout (`@GrpcClient(...) private readonly foo!: XxxServiceClient`); the plugin now walks the AST, so modifier order / optional modifiers / multi-line formatting don't break it. 4. **Language-agnostic extension.** Adding Kotlin / Rust / C# gRPC detection later is a one-file edit in `grpc-patterns/index.ts` — no touches to the shared scanner, the orchestrator, or the proto parser. ## Tests - `grpc-extractor.test.ts` — **43/43 pass** (tests unchanged; the contract shape is identical). Covers .proto parsing (including the brace-inside-string regression), Go provider/consumer, Java @GrpcService / plain ImplBase provider + newBlockingStub consumer, Python servicer + stub, TS @GrpcMethod + @GrpcClient + .getService + new XxxServiceClient + loadPackageDefinition + the `AuthClient` vs `AuthServiceClient` discrimination, dedupe across multiple patterns in one file, proto-aware confidence, and the inherited-package resolution for split proto definitions. - `topic-extractor.test.ts` — 30/30 pass. - `http-route-extractor.test.ts` — 18/18 pass. - `manifest-extractor.test.ts` — 8/8 pass. - `service.test.ts`, `sync.test.ts`, `storage.test.ts` — 41/41 pass. - `npx tsc -p tsconfig.json --noEmit` clean. ## Scope discipline (per GUARDRAILS.md) - Only files under `src/core/group/extractors/` are touched. - No pipeline.ts, MCP surface, ingestion, CI / release / security, or test changes. - New tree-sitter grammar imports (`tree-sitter-go`, `tree-sitter-java`, `tree-sitter-python`, `tree-sitter-javascript`, `tree-sitter-typescript`) are all already installed for the ingestion pipeline. ## End of phase series This commit completes the three-phase extractor refactor: - **Phase 1** (`ea06d11`): topic-extractor → `topic-patterns/` - **Phase 2** (`b6015f6`): http-route-extractor → `http-patterns/` - **Phase 3** (this commit): grpc-extractor → `grpc-patterns/` Every remaining regex-based extractor helper under the `src/core/group/ extractors/` directory is either (a) language-agnostic string processing (path normalization, dedupe keys) or (b) the `.proto` parser, which is documented as an explicit exception. Co-authored-by: Claude * feat(group): add tree-sitter-proto for .proto file parsing Addresses @magyargergo's suggestion on #796 to replace the manual string-sanitizing .proto parser with a tree-sitter grammar. - **Vendored `tree-sitter-proto`** in `vendor/tree-sitter-proto/`. Grammar source from [coder3101/tree-sitter-proto](https://github.com/coder3101/tree-sitter-proto) (latest `grammar.js`), parser.c regenerated with `tree-sitter-cli 0.24` to produce ABI version 14 — compatible with the project's `tree-sitter 0.25` runtime (which supports ABI ≤ 14). Added as `optionalDependency` with `file:./vendor/tree-sitter-proto`. - **New `grpc-patterns/proto.ts` plugin** — uses the same `compilePatterns` + `runCompiledPatterns` infrastructure as every other plugin. Two queries: - `(package (full_ident) @pkg)` — package declaration - `(service (service_name) @service_name (rpc (rpc_name) @rpc_name))` — one match per (service, rpc) pair - **Graceful fallback** — `tree-sitter-proto` is an optional dependency. If it fails to install (platform incompatibility) or fails the runtime smoke-test (`setLanguage` + `parse` on a trivial proto), `PROTO_GRPC_PLUGIN` stays `null` and the orchestrator uses the existing manual parser. The smoke-test catches the `SyntaxNode` TDZ error that occurs in vitest's fork-based test runner. - **Orchestrator updated** — when `hasProtoPlugin` is true, `.proto` files are handled by the plugin loop (they're included in `GRPC_SCAN_GLOB`), and the manual `parseProtoFile` loop is skipped. `buildProtoContext` still runs to build the proto map for cross-referencing source-file detections. 1. **No manual comment/string stripping.** The old parser needed `stripProtoCommentsAndStrings` (110 lines) to avoid counting braces inside comments and string literals. tree-sitter handles this natively. 2. **No brace-depth tracking.** `extractServiceBlocks` used a manual depth counter to find service boundaries. tree-sitter's AST gives us `service` → `service_name` + `rpc` → `rpc_name` directly. 3. **Performance.** tree-sitter's C-based parser is faster than character-by-character JS scanning + regex on large proto files. - `grpc-extractor.test.ts` — **43/43 pass** (unchanged) - All other extractor tests — 99/99 pass - `npx tsc -p tsconfig.json --noEmit` clean Co-authored-by: Claude * chore: add .gitignore for vendored tree-sitter-proto build artifacts https://claude.ai/code/session_01SFUCxgKMMQ8EgRHYw91xPU * fix: correct .gitignore paths for vendored tree-sitter-proto Patterns should be relative to the .gitignore file's directory. https://claude.ai/code/session_01SFUCxgKMMQ8EgRHYw91xPU * refactor(group): address Copilot review feedback on #796 Six fixes suggested by the Copilot AI review: 1. **`normalizeHttpPath` root-path edge case** — stripping trailing slashes on the input `/` produced an empty string, yielding malformed contract ids like `http::GET::`. Now preserves `/` for the root handler/fetch case. 2. **Dedupe `scanFiles` call** — `extract()` was globbing the source-scan file list twice (once for the provider fallback, once for the consumer fallback). Moved to a single lazy call that memoizes the result for the rest of the method. 3. **HTTP `scanFiles` now ignores `**/vendor/**`** — every other extractor's glob already ignored vendored sources; the HTTP one didn't. Fixed for consistency. 4. **`loadPackageDefinition` check is now structural** — was calling `tree.rootNode.text.includes('loadPackageDefinition')` which forces materialization of the entire file text from the parse tree (expensive on large files). Replaced with a dedicated compiled query on `(call_expression function: [(identifier) | (member_expression)])` so the check stays in the AST domain. 5. **`grpc-extractor.ts` header docstring updated** — still claimed ".proto parsing is not tree-sitter-based because no grammar is installed". Now describes the actual behaviour: tree-sitter when `tree-sitter-proto` is available (optionalDependency), manual fallback otherwise. 6. **Eliminated the double proto file parse on the fallback path** — `buildProtoContext` already globs + parses every `.proto` file to build `servicesByName`. On the `!hasProtoPlugin` branch the extractor was globbing + parsing again via the now-removed `parseProtoFile` helper. The fallback branch now iterates the map that `buildProtoContext` already produced to emit provider contracts directly — single pass per proto file. ## Tests - `topic-extractor.test.ts` — 30/30 pass - `http-route-extractor.test.ts` — 18/18 pass - `grpc-extractor.test.ts` — 43/43 pass - `manifest-extractor.test.ts` — 8/8 pass - `npx tsc -p tsconfig.json --noEmit` clean Co-authored-by: Claude * refactor(group): address Claude review feedback (bugs + dedup + hygiene) on #796 Follows up `2f28bfc` with the remaining items from the Claude AI review: ## Bugs **Bug 2 — Label-unaware Cypher queries in `resolveSymbol`.** The manifest-extractor's lookup queries were `MATCH (n) WHERE n.name = $x` with no label filter, so a topic/service/package name could silently match any node type (File, Variable, Import, Folder, …). Added label filters: - `topic` → `(n:Function|Method|Class|Interface)` (topics are best-effort symbol-name matches against listener/publisher symbols) - `grpc` method → `(n:Function|Method)` - `grpc` service → `(n:Class|Interface)` - `lib` → `(n:Package|Module)` All 8 manifest-extractor tests still pass (mock executor is label-agnostic, but the production LadybugDB graph now gets correctly scoped queries). **Bug 8 — Tautological `!handlerName` condition.** `http-route-extractor.ts:extractProvidersGraph` had `let handlerName = null; if (!method || !handlerName) { ... }` — the `!handlerName` clause was always true since there was no intervening assignment. Simplified to always run the plugin-scan lookup (we need the handler name even when `methodFromRouteReason` already resolved the method). ## Clean code / dedup **Design 7 — `readSafe` was copy-pasted in all three orchestrators.** Extracted to `extractors/fs-utils.ts` as the single source of truth for the path-traversal guard. Dropped the three local copies and the now-unused `fs`/`path` imports from topic-extractor. **Style 10 — Language-specific `_test.go` skip in the topic orchestrator.** Was `if (rel.endsWith('_test.go')) continue;` inside the language- agnostic extraction loop. Pushed into the glob's ignore list (`'**/*_test.go'`) alongside the existing `node_modules`, `vendor`, `dist`, `build` entries, with a comment explaining that other languages' test file conventions either live in separate directories (Python `tests/`, Java `src/test/`) or are already covered by the existing ignores. ## Already addressed in `2f28bfc` (mentioned again in Claude review) - Bug 3: `normalizeHttpPath('/')` returns `''` — fixed - Bug 4: double glob + double parse of `.proto` — fixed - Bug 5: `scanFiles` called twice in HTTP — fixed - Bug 6: missing `**/vendor/**` in HTTP glob — fixed - Design 9 partially: `tree.rootNode.text.includes('loadPackageDefinition')` replaced with a dedicated structural query ## Deferred - Bug 1 (`http::*::path` vs `http::GET::path` matching) — out of scope; sync.ts matching logic lands in #793, manifest extractor already emits correct synthetic uids for unresolved HTTP contracts. - Design 9 full (change plugin `scan(tree)` → `scan(tree, source)`) — the only real use case (`loadPackageDefinition` gate) is already fixed via a structural query, so the interface change would be cosmetic churn without a concrete consumer. ## Tests - `topic-extractor.test.ts` — 30/30 pass - `http-route-extractor.test.ts` — 18/18 pass - `grpc-extractor.test.ts` — 43/43 pass - `manifest-extractor.test.ts` — 8/8 pass - `npx tsc -p tsconfig.json --noEmit` clean Co-authored-by: Claude * docs+fix(group): address remaining Claude review items + add pipeline flow chart ## Fixes **Remaining 🔴 — HTTP contract id wildcard format.** Documented the `http::*::` format as an intentional wildcard for manifest links that omit the HTTP method, alongside the explicit-method form (`GET::/path` → `http::GET::/path`). The docblock on `buildContractId` now states both forms, notes that wildcard-aware matching is the responsibility of the sync / cross-impact layer (#793), and recommends the explicit-method form whenever the author knows the method (it round-trips through exact equality without needing wildcard logic downstream). Tests unchanged — the wildcard format is what they've always asserted. **Minor 1 — stale comment at `manifest-extractor.ts:124-126`.** The comment claimed "creates a contract with an empty symbolUid/ref" but the code switched to `manifestSymbolUid(repo, contractId)` a few commits back. Updated to describe the actual synthetic-uid fallback semantics and the cross-impact path that relies on both sides of the join deriving the same uid. **Minor 2 — exhaustiveness guard on `buildContractId`.** The `switch(type)` covered all five current `ContractType` variants but silently returned `undefined` if a new variant was added. Added a `default: const _exhaustive: never = type; throw new Error(...)` clause so the build fails loudly on an unhandled variant. **Minor 3 — `tree.rootNode.text` in `grpc-patterns/node.ts`.** Already fixed in `2f28bfc` via a dedicated structural query (`LOAD_PACKAGE_DEFINITION_SPEC`). No action needed. ## New: pipeline flow chart (per @magyargergo's request) Added `src/core/group/PIPELINE.md` with four Mermaid diagrams: 1. **High-level overview** — `group.yaml` → extractors + manifest → contract matching → `bridge.lbug` → `runGroupImpact`. 2. **Per-repo extractor two-strategy shape** — graph-assisted Strategy A vs. source-scan Strategy B. 3. **Plugin architecture** — orchestrator → registry → per-language `*-patterns/.ts` → `tree-sitter-scanner.ts` → `ExtractedContract`. 4. **Manifest extraction** — label-scoped `resolveSymbol` with the synthetic-uid fallback. 5. **Cross-impact query (#606)** — local impact → bridge join → cross-repo fan-out. Each diagram is annotated with which PRs own which stage (this PR: extractors + manifest; #795: bridge storage; #606: cross-impact runtime) and points at the concrete files/functions involved. ## Tests - 99/99 extractor tests pass - `npx tsc -p tsconfig.json --noEmit` clean Co-authored-by: Claude --------- Co-authored-by: Claude --- gitnexus/package-lock.json | 28 + gitnexus/package.json | 1 + gitnexus/src/core/group/PIPELINE.md | 139 + .../src/core/group/extractors/fs-utils.ts | 23 + .../core/group/extractors/grpc-extractor.ts | 626 +- .../core/group/extractors/grpc-patterns/go.ts | 109 + .../group/extractors/grpc-patterns/index.ts | 53 + .../group/extractors/grpc-patterns/java.ts | 179 + .../group/extractors/grpc-patterns/node.ts | 314 + .../group/extractors/grpc-patterns/proto.ts | 147 + .../group/extractors/grpc-patterns/python.ts | 77 + .../group/extractors/grpc-patterns/types.ts | 54 + .../core/group/extractors/http-patterns/go.ts | 224 + .../group/extractors/http-patterns/index.ts | 50 + .../group/extractors/http-patterns/java.ts | 267 + .../group/extractors/http-patterns/node.ts | 373 + .../group/extractors/http-patterns/php.ts | 79 + .../group/extractors/http-patterns/python.ts | 142 + .../group/extractors/http-patterns/types.ts | 65 + .../group/extractors/http-route-extractor.ts | 486 +- .../group/extractors/manifest-extractor.ts | 268 + .../core/group/extractors/topic-extractor.ts | 283 +- .../group/extractors/topic-patterns/go.ts | 123 + .../group/extractors/topic-patterns/index.ts | 49 + .../group/extractors/topic-patterns/java.ts | 83 + .../group/extractors/topic-patterns/node.ts | 165 + .../group/extractors/topic-patterns/python.ts | 119 + .../group/extractors/topic-patterns/types.ts | 27 + .../group/extractors/tree-sitter-scanner.ts | 193 + .../test/unit/group/grpc-extractor.test.ts | 539 +- .../unit/group/http-route-extractor.test.ts | 240 +- .../unit/group/manifest-extractor.test.ts | 308 + .../test/unit/group/topic-extractor.test.ts | 177 +- gitnexus/vendor/tree-sitter-proto/.gitignore | 3 + gitnexus/vendor/tree-sitter-proto/binding.gyp | 30 + .../bindings/node/binding.cc | 20 + .../bindings/node/index.d.ts | 28 + .../tree-sitter-proto/bindings/node/index.js | 7 + .../vendor/tree-sitter-proto/package.json | 18 + .../tree-sitter-proto/src/node-types.json | 1145 ++ .../vendor/tree-sitter-proto/src/parser.c | 10149 ++++++++++++++++ .../tree-sitter-proto/src/tree_sitter/alloc.h | 54 + .../tree-sitter-proto/src/tree_sitter/array.h | 291 + .../src/tree_sitter/parser.h | 266 + 44 files changed, 17186 insertions(+), 835 deletions(-) create mode 100644 gitnexus/src/core/group/PIPELINE.md create mode 100644 gitnexus/src/core/group/extractors/fs-utils.ts create mode 100644 gitnexus/src/core/group/extractors/grpc-patterns/go.ts create mode 100644 gitnexus/src/core/group/extractors/grpc-patterns/index.ts create mode 100644 gitnexus/src/core/group/extractors/grpc-patterns/java.ts create mode 100644 gitnexus/src/core/group/extractors/grpc-patterns/node.ts create mode 100644 gitnexus/src/core/group/extractors/grpc-patterns/proto.ts create mode 100644 gitnexus/src/core/group/extractors/grpc-patterns/python.ts create mode 100644 gitnexus/src/core/group/extractors/grpc-patterns/types.ts create mode 100644 gitnexus/src/core/group/extractors/http-patterns/go.ts create mode 100644 gitnexus/src/core/group/extractors/http-patterns/index.ts create mode 100644 gitnexus/src/core/group/extractors/http-patterns/java.ts create mode 100644 gitnexus/src/core/group/extractors/http-patterns/node.ts create mode 100644 gitnexus/src/core/group/extractors/http-patterns/php.ts create mode 100644 gitnexus/src/core/group/extractors/http-patterns/python.ts create mode 100644 gitnexus/src/core/group/extractors/http-patterns/types.ts create mode 100644 gitnexus/src/core/group/extractors/manifest-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/topic-patterns/go.ts create mode 100644 gitnexus/src/core/group/extractors/topic-patterns/index.ts create mode 100644 gitnexus/src/core/group/extractors/topic-patterns/java.ts create mode 100644 gitnexus/src/core/group/extractors/topic-patterns/node.ts create mode 100644 gitnexus/src/core/group/extractors/topic-patterns/python.ts create mode 100644 gitnexus/src/core/group/extractors/topic-patterns/types.ts create mode 100644 gitnexus/src/core/group/extractors/tree-sitter-scanner.ts create mode 100644 gitnexus/test/unit/group/manifest-extractor.test.ts create mode 100644 gitnexus/vendor/tree-sitter-proto/.gitignore create mode 100644 gitnexus/vendor/tree-sitter-proto/binding.gyp create mode 100644 gitnexus/vendor/tree-sitter-proto/bindings/node/binding.cc create mode 100644 gitnexus/vendor/tree-sitter-proto/bindings/node/index.d.ts create mode 100644 gitnexus/vendor/tree-sitter-proto/bindings/node/index.js create mode 100644 gitnexus/vendor/tree-sitter-proto/package.json create mode 100644 gitnexus/vendor/tree-sitter-proto/src/node-types.json create mode 100644 gitnexus/vendor/tree-sitter-proto/src/parser.c create mode 100644 gitnexus/vendor/tree-sitter-proto/src/tree_sitter/alloc.h create mode 100644 gitnexus/vendor/tree-sitter-proto/src/tree_sitter/array.h create mode 100644 gitnexus/vendor/tree-sitter-proto/src/tree_sitter/parser.h diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index ee80faecf..a746292e8 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -65,6 +65,7 @@ "optionalDependencies": { "tree-sitter-dart": "github:UserNobody14/tree-sitter-dart#80e23c07b64494f7e21090bb3450223ef0b192f4", "tree-sitter-kotlin": "^0.3.8", + "tree-sitter-proto": "file:./vendor/tree-sitter-proto", "tree-sitter-swift": "^0.6.0" } }, @@ -5296,6 +5297,10 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/tree-sitter-proto": { + "resolved": "vendor/tree-sitter-proto", + "link": true + }, "node_modules/tree-sitter-python": { "version": "0.23.4", "resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.23.4.tgz", @@ -5877,6 +5882,29 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "vendor/tree-sitter-proto": { + "version": "0.4.1", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": ">=0.21.0" + } + }, + "vendor/tree-sitter-proto/node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } } } } diff --git a/gitnexus/package.json b/gitnexus/package.json index 864f28101..435f9b325 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -87,6 +87,7 @@ "optionalDependencies": { "tree-sitter-dart": "github:UserNobody14/tree-sitter-dart#80e23c07b64494f7e21090bb3450223ef0b192f4", "tree-sitter-kotlin": "^0.3.8", + "tree-sitter-proto": "file:./vendor/tree-sitter-proto", "tree-sitter-swift": "^0.6.0" }, "devDependencies": { diff --git a/gitnexus/src/core/group/PIPELINE.md b/gitnexus/src/core/group/PIPELINE.md new file mode 100644 index 000000000..7730b48e7 --- /dev/null +++ b/gitnexus/src/core/group/PIPELINE.md @@ -0,0 +1,139 @@ +# Group Analysis Pipeline + +Flow chart of the cross-repo contract extraction + matching pipeline. +This covers what runs **inside this PR** (extractors + manifest) and +the downstream handoff to the bridge storage (PR #795) and +cross-impact query (PR #606). + +## High-level overview + +```mermaid +flowchart TD + A[group.yaml] --> B[GroupConfig parser] + B --> C{For each repo
in group} + C --> D[Per-repo LadybugDB
indexed by main pipeline] + + D --> E1[TopicExtractor] + D --> E2[HttpRouteExtractor] + D --> E3[GrpcExtractor] + + E1 --> F[ExtractedContract array
per repo] + E2 --> F + E3 --> F + + B --> M[ManifestExtractor] + M --> G[Manifest contracts
+ cross-links] + + F --> H[Contract matching
exact + wildcard] + G --> H + + H --> I[(bridge.lbug
#795)] + + I --> J[runGroupImpact
#606] + J --> K[CrossRepoImpact] +``` + +## Per-repo extractor pipeline + +Each extractor under `src/core/group/extractors/` follows the same +two-strategy shape: + +```mermaid +flowchart TD + R[RepoHandle + CypherExecutor
for this repo] --> S{Graph-assisted
Strategy A
available?} + + S -->|yes| A1[Cypher query against
per-repo LadybugDB] + A1 --> A2{non-empty
result?} + A2 -->|yes| OUT[ExtractedContract array] + A2 -->|no| B1 + + S -->|no| B1[Source-scan Strategy B] + B1 --> B2[glob repo source files] + B2 --> B3{ext in registry?} + B3 -->|yes| B4[Per-language plugin
scan parsed tree] + B3 -->|no| SKIP[skip file] + B4 --> OUT + + SKIP --> B2 +``` + +**Strategy A** (graph-assisted) uses Cypher over edges already produced +by the main ingestion pipeline: +- HTTP: `HANDLES_ROUTE` / `FETCHES` edges from `(File)-[]->(Route)` +- topic: none (pipeline doesn't yet produce topic nodes — Strategy B only) +- gRPC: none (Strategy B + proto map only) + +**Strategy B** (source-scan) is 100% tree-sitter based after this PR. +Each `*-patterns/.ts` plugin owns its grammar + S-expression +queries; the top-level orchestrator imports neither. + +## Plugin architecture + +```mermaid +flowchart LR + O[Orchestrator
topic|http|grpc-extractor.ts] --> REG[REGISTRY
*-patterns/index.ts] + REG --> P1[java.ts
tree-sitter-java] + REG --> P2[go.ts
tree-sitter-go] + REG --> P3[python.ts
tree-sitter-python] + REG --> P4[node.ts
JS + TS + TSX] + REG --> P5[php.ts
tree-sitter-php
HTTP only] + REG --> P6[proto.ts
tree-sitter-proto
gRPC only, optional] + + P1 --> SCAN[tree-sitter-scanner.ts
compilePatterns + runCompiledPatterns] + P2 --> SCAN + P3 --> SCAN + P4 --> SCAN + P5 --> SCAN + P6 --> SCAN + + SCAN --> DET[Detection objects
TopicMeta / HttpDetection / GrpcDetection] + DET --> O + O --> CT[ExtractedContract array] +``` + +The orchestrator never imports a grammar. Adding a new language / +framework = drop one file in `*-patterns/`, register it in +`index.ts`. No orchestrator edits required. + +## Manifest extraction + +```mermaid +flowchart TD + Y[group.yaml links] --> ME[ManifestExtractor] + ME --> LOOP{for each link} + LOOP --> RES[resolveSymbol
label-scoped Cypher] + RES --> OK{found?} + OK -->|yes| REF[real symbol uid + ref] + OK -->|no| SYN[synthetic uid
manifest::repo::cid] + + REF --> EMIT[emit provider + consumer
Contract objects
+ CrossLink] + SYN --> EMIT + + EMIT --> BRIDGE[(bridge.lbug
#795)] +``` + +Label-scoped queries in `resolveSymbol` keep accidental cross-matches +out: +- `topic` → `(n:Function|Method|Class|Interface)` +- `grpc` method → `(n:Function|Method)`, service → `(n:Class|Interface)` +- `lib` → `(n:Package|Module)` + +## Cross-impact query (PR #606) + +```mermaid +flowchart TD + U[User changes symbol S
in repo R] --> LI[Local impact engine
per-repo uid expansion] + LI --> IDS[Affected uid set] + + IDS --> BR[Bridge query
MATCH Contract WHERE uid IN ids] + BR --> CL[CrossLink traversal] + CL --> OTHER[Matching contract in
other repo] + + OTHER --> FE[Fan-out impact
to consuming repo] + FE --> OUT[CrossRepoImpact
per affected repo] +``` + +The bridge stores every extracted contract keyed by `symbolUid`. +Manifest-sourced contracts use the synthetic uid form so both sides +of the `(local impact) ↔ (bridge query)` join derive the same uid +without coordinating through any shared state. diff --git a/gitnexus/src/core/group/extractors/fs-utils.ts b/gitnexus/src/core/group/extractors/fs-utils.ts new file mode 100644 index 000000000..384f63203 --- /dev/null +++ b/gitnexus/src/core/group/extractors/fs-utils.ts @@ -0,0 +1,23 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * Safely read a file inside a repo, rejecting any path that escapes + * `repoPath` via `..` traversal or absolute segments. Returns `null` if + * the path is outside the repo or the file can't be read. + * + * Used by every source-scan extractor under this directory. Kept as a + * single shared implementation so the path-traversal guard (security- + * sensitive) lives in exactly one place. + */ +export function readSafe(repoPath: string, rel: string): string | null { + const abs = path.resolve(repoPath, rel); + const base = path.resolve(repoPath); + const relToBase = path.relative(base, abs); + if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null; + try { + return fs.readFileSync(abs, 'utf-8'); + } catch { + return null; + } +} diff --git a/gitnexus/src/core/group/extractors/grpc-extractor.ts b/gitnexus/src/core/group/extractors/grpc-extractor.ts index b4cefadc5..c6af9138a 100644 --- a/gitnexus/src/core/group/extractors/grpc-extractor.ts +++ b/gitnexus/src/core/group/extractors/grpc-extractor.ts @@ -1,20 +1,38 @@ -import * as fs from 'node:fs'; import * as path from 'node:path'; import { glob } from 'glob'; +import Parser from 'tree-sitter'; import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; import type { ExtractedContract, RepoHandle } from '../types.js'; +import { readSafe } from './fs-utils.js'; +import { + GRPC_SCAN_GLOB, + getPluginForFile, + hasProtoPlugin, + type GrpcDetection, +} from './grpc-patterns/index.js'; -function readSafe(repoPath: string, rel: string): string | null { - const abs = path.resolve(repoPath, rel); - const base = path.resolve(repoPath); - const relToBase = path.relative(base, abs); - if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null; - try { - return fs.readFileSync(abs, 'utf-8'); - } catch { - return null; - } -} +/** + * Language-agnostic orchestrator for gRPC (provider + consumer) contract + * extraction. + * + * Two parts: + * + * 1. **`.proto` parsing** — tree-sitter when `tree-sitter-proto` is + * installed (optionalDependency vendored in `vendor/tree-sitter-proto/`), + * via the `.proto` entry in `grpc-patterns/` and `hasProtoPlugin`. + * When the grammar isn't available (platform incompatibility, native + * build failure) the orchestrator falls back to the in-process + * string-sanitizing parser defined below (`stripProtoCommentsAndStrings` + * + `extractServiceBlocks`). The fallback preserves offsets so any + * downstream regex scans run against a sanitized copy without + * affecting line numbers of the original. + * + * 2. **Source-scan providers / consumers** — delegated to per-language + * plugins in `./grpc-patterns/`. The orchestrator imports NO + * tree-sitter grammars or query strings — each plugin owns its own. + */ + +// ─── .proto fallback parser (used only when tree-sitter-proto is absent) ─── function contractId(pkg: string, service: string, method: string): string { const prefix = pkg ? `${pkg}.${service}` : service; @@ -25,20 +43,110 @@ function serviceOnlyContractId(serviceName: string): string { return `grpc::${serviceName}/*`; } +/** + * Replace all .proto comments and string literals with spaces, preserving the + * original length and character offsets of the input. This lets downstream + * regex / brace-depth parsers run on a "sanitized" copy without having to + * understand proto syntax, while any RegExp.exec/index-based lookups that + * were already positional against `content` continue to work against the + * original string. + * + * Supported comment forms: `// line comment`, `/* block comment * /`. + * Supported strings: double-quoted ("…") and single-quoted ('…') with `\` + * escape handling. Raw/unterminated strings are not supported — we stop + * on a line break for line-style comments and on EOF for unterminated + * strings/blocks, which matches how most real proto files parse. + */ +function stripProtoCommentsAndStrings(content: string): string { + const out = new Array(content.length); + let i = 0; + while (i < content.length) { + const ch = content[i]; + const next = content[i + 1]; + + // Line comment: // ... \n + if (ch === '/' && next === '/') { + out[i] = ' '; + out[i + 1] = ' '; + i += 2; + while (i < content.length && content[i] !== '\n') { + out[i] = content[i] === '\r' ? '\r' : ' '; + i++; + } + continue; + } + + // Block comment: /* ... */ + if (ch === '/' && next === '*') { + out[i] = ' '; + out[i + 1] = ' '; + i += 2; + while (i < content.length) { + if (content[i] === '*' && content[i + 1] === '/') { + out[i] = ' '; + out[i + 1] = ' '; + i += 2; + break; + } + // Preserve newlines so line numbers stay stable for downstream code. + out[i] = content[i] === '\n' || content[i] === '\r' ? content[i] : ' '; + i++; + } + continue; + } + + // String literal: "..." or '...' + if (ch === '"' || ch === "'") { + const quote = ch; + out[i] = ' '; // replace opening quote + i++; + while (i < content.length) { + const c = content[i]; + if (c === '\\' && i + 1 < content.length) { + // Skip escaped pair (e.g. \" \n \\) + out[i] = ' '; + out[i + 1] = ' '; + i += 2; + continue; + } + if (c === quote) { + out[i] = ' '; + i++; + break; + } + // Preserve newlines; proto technically disallows unescaped newlines + // inside strings, but real files occasionally have them. + out[i] = c === '\n' || c === '\r' ? c : ' '; + i++; + } + continue; + } + + out[i] = ch; + i++; + } + return out.join(''); +} + function extractServiceBlocks(content: string): Array<{ name: string; body: string }> { const results: Array<{ name: string; body: string }> = []; - // v1: brace-depth only — braces inside comments or string literals are not filtered (see spec Fix 2) + // Sanitize comments and string literals so braces inside them don't + // throw off the depth counter. The sanitized copy has the same length + // and offsets as the original, so we use it ONLY to scan for service + // headers and braces; the service body we return is sliced from the + // ORIGINAL content to preserve exact source text for downstream use. + const sanitized = stripProtoCommentsAndStrings(content); const headerRe = /service\s+(\w+)\s*\{/g; let headerMatch: RegExpExecArray | null; - while ((headerMatch = headerRe.exec(content)) !== null) { + while ((headerMatch = headerRe.exec(sanitized)) !== null) { const serviceName = headerMatch[1]; const bodyStart = headerMatch.index + headerMatch[0].length; let depth = 1; let pos = bodyStart; - while (pos < content.length && depth > 0) { - const ch = content[pos]; + while (pos < sanitized.length && depth > 0) { + const ch = sanitized[pos]; if (ch === '{') depth++; else if (ch === '}') depth--; pos++; @@ -75,6 +183,165 @@ function makeContract( }; } +export interface ProtoServiceInfo { + package: string; + serviceName: string; + methods: string[]; + protoPath: string; +} + +function normalizeProtoPath(rel: string): string { + return rel.replace(/\\/g, '/'); +} + +function extractProtoImports(content: string): string[] { + const imports: string[] = []; + const re = /^\s*import\s+"([^"]+)"\s*;/gm; + let match: RegExpExecArray | null; + while ((match = re.exec(content)) !== null) { + imports.push(match[1]); + } + return imports; +} + +function longestSharedSegmentRun(aPath: string, bPath: string): number { + const a = aPath.split('/').filter(Boolean); + const b = bPath.split('/').filter(Boolean); + let best = 0; + + for (let i = 0; i < a.length; i++) { + for (let j = 0; j < b.length; j++) { + let run = 0; + while (a[i + run] && b[j + run] && a[i + run] === b[j + run]) { + run++; + } + if (run > best) best = run; + } + } + + return best; +} + +async function buildProtoContext(repoPath: string): Promise<{ + packagesByProto: Map; + servicesByName: Map; +}> { + const servicesByName = new Map(); + const protoFiles = await glob('**/*.proto', { + cwd: repoPath, + absolute: false, + nodir: true, + ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'], + }); + const contents = new Map(); + + for (const rel of protoFiles) { + const content = readSafe(repoPath, rel); + if (!content) continue; + contents.set(normalizeProtoPath(rel), content); + } + + const packagesByProto = new Map(); + + const resolvePackage = (protoPath: string, seen = new Set()): string => { + if (packagesByProto.has(protoPath)) return packagesByProto.get(protoPath) ?? ''; + if (seen.has(protoPath)) return ''; + + const content = contents.get(protoPath); + if (!content) return ''; + + seen.add(protoPath); + const pkgMatch = content.match(/^\s*package\s+([\w.]+)\s*;/m); + if (pkgMatch?.[1]) { + packagesByProto.set(protoPath, pkgMatch[1]); + return pkgMatch[1]; + } + + for (const importPath of extractProtoImports(content)) { + const normalizedImport = normalizeProtoPath(importPath); + const candidates = [ + normalizeProtoPath( + path.posix.normalize(path.posix.join(path.posix.dirname(protoPath), normalizedImport)), + ), + normalizedImport, + ]; + for (const candidate of candidates) { + if (!contents.has(candidate)) continue; + const inheritedPackage = resolvePackage(candidate, seen); + if (inheritedPackage) { + packagesByProto.set(protoPath, inheritedPackage); + return inheritedPackage; + } + } + } + + packagesByProto.set(protoPath, ''); + return ''; + }; + + for (const rel of protoFiles) { + const normalizedRel = normalizeProtoPath(rel); + const content = contents.get(normalizedRel); + if (!content) continue; + const pkg = resolvePackage(normalizedRel); + + const serviceBlocks = extractServiceBlocks(content); + for (const block of serviceBlocks) { + const rpcRe = /rpc\s+(\w+)\s*\(/g; + const methods: string[] = []; + let m: RegExpExecArray | null; + while ((m = rpcRe.exec(block.body)) !== null) { + methods.push(m[1]); + } + const info: ProtoServiceInfo = { + package: pkg, + serviceName: block.name, + methods, + protoPath: normalizedRel, + }; + const existing = servicesByName.get(block.name) ?? []; + existing.push(info); + servicesByName.set(block.name, existing); + } + } + + return { packagesByProto, servicesByName }; +} + +export async function buildProtoMap(repoPath: string): Promise> { + const { servicesByName } = await buildProtoContext(repoPath); + return servicesByName; +} + +export function resolveProtoConflict( + _serviceName: string, + sourceFilePath: string, + candidates: ProtoServiceInfo[], +): ProtoServiceInfo | null { + if (candidates.length === 0) return null; + if (candidates.length === 1) return candidates[0]; + + const sourceDir = normalizeProtoPath(path.dirname(sourceFilePath)); + let best = candidates[0]; + let bestScore = -1; + for (const c of candidates) { + const protoDir = normalizeProtoPath(path.dirname(c.protoPath)); + const sharedRun = longestSharedSegmentRun(sourceDir, protoDir); + if (sharedRun > bestScore) { + bestScore = sharedRun; + best = c; + } + } + return best; +} + +export function serviceContractId(pkg: string, serviceName: string): string { + const prefix = pkg ? `${pkg}.${serviceName}` : serviceName; + return `grpc::${prefix}/*`; +} + +// ─── Orchestrator ──────────────────────────────────────────────────── + export class GrpcExtractor implements ContractExtractor { type = 'grpc' as const; @@ -88,270 +355,111 @@ export class GrpcExtractor implements ContractExtractor { _repo: RepoHandle, ): Promise { const out: ExtractedContract[] = []; + const protoContext = await buildProtoContext(repoPath); + const protoMap = protoContext.servicesByName; - // Proto files — definitive provider source - const protoFiles = await glob('**/*.proto', { - cwd: repoPath, - ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'], - nodir: true, - }); - for (const rel of protoFiles) { - const content = readSafe(repoPath, rel); - if (content) out.push(...this.parseProtoFile(content, rel)); + // ─── Proto files — definitive provider source ───────────────── + // When tree-sitter-proto is available, .proto files are handled by + // the plugin loop below (they're in GRPC_SCAN_GLOB). Otherwise + // emit provider contracts directly from the proto map that + // `buildProtoContext` already built — no second glob / parse pass. + if (!hasProtoPlugin) { + for (const infos of protoMap.values()) { + for (const info of infos) { + for (const methodName of info.methods) { + const cid = contractId(info.package, info.serviceName, methodName); + out.push( + makeContract( + cid, + 'provider', + info.protoPath, + `${info.serviceName}.${methodName}`, + 0.85, + { + package: info.package, + service: info.serviceName, + method: methodName, + source: 'proto', + }, + ), + ); + } + } + } } - // Source files — server/client detection - const sourceFiles = await glob('**/*.{go,java,py,ts,tsx,js,jsx}', { + // ─── Source files (+ .proto when plugin available) ──────────── + const sourceFiles = await glob(GRPC_SCAN_GLOB, { cwd: repoPath, ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'], nodir: true, }); + + const parser = new Parser(); for (const rel of sourceFiles) { + const plugin = getPluginForFile(rel); + if (!plugin) continue; const content = readSafe(repoPath, rel); if (!content) continue; - const ext = path.extname(rel).toLowerCase(); - - if (ext === '.go') { - out.push(...this.scanGoProviders(content, rel)); - out.push(...this.scanGoConsumers(content, rel)); - } else if (ext === '.java') { - out.push(...this.scanJavaProviders(content, rel)); - out.push(...this.scanJavaConsumers(content, rel)); - } else if (ext === '.py') { - out.push(...this.scanPythonProviders(content, rel)); - out.push(...this.scanPythonConsumers(content, rel)); - } else if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) { - out.push(...this.scanTsProviders(content, rel)); + let detections: GrpcDetection[] = []; + try { + parser.setLanguage(plugin.language); + const tree = parser.parse(content); + detections = plugin.scan(tree); + } catch { + continue; + } + for (const d of detections) { + out.push(this.detectionToContract(d, rel, protoMap)); } } return this.dedupe(out); } - private parseProtoFile(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - - const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m); - const pkg = pkgMatch ? pkgMatch[1] : ''; - - for (const { name: serviceName, body } of extractServiceBlocks(content)) { - const rpcRe = /rpc\s+(\w+)\s*\(/g; - let rpcMatch: RegExpExecArray | null; - while ((rpcMatch = rpcRe.exec(body)) !== null) { - const methodName = rpcMatch[1]; - const cid = contractId(pkg, serviceName, methodName); - out.push( - makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.85, { - package: pkg, - service: serviceName, - method: methodName, - source: 'proto', - }), - ); - } - } - - return out; - } - - private scanGoProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - - // pb.RegisterXxxServer( - const registerRe = /\w+\.Register(\w+)Server\s*\(/g; - let m: RegExpExecArray | null; - while ((m = registerRe.exec(content)) !== null) { - const serviceName = m[1]; - out.push( - makeContract( - serviceOnlyContractId(serviceName), - 'provider', - filePath, - `Register${serviceName}Server`, - 0.8, - { service: serviceName, source: 'go_register' }, - ), - ); - } - - // pb.UnimplementedXxxServer - const unimplRe = /\w+\.Unimplemented(\w+)Server\b/g; - while ((m = unimplRe.exec(content)) !== null) { - const serviceName = m[1]; - out.push( - makeContract( - serviceOnlyContractId(serviceName), - 'provider', - filePath, - `Unimplemented${serviceName}Server`, - 0.8, - { service: serviceName, source: 'go_unimplemented' }, - ), - ); - } - - return out; - } - - private scanGoConsumers(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - const re = /\w+\.New(\w+)Client\s*\(/g; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const serviceName = m[1]; - out.push( - makeContract( - serviceOnlyContractId(serviceName), - 'consumer', - filePath, - `New${serviceName}Client`, - 0.7, - { service: serviceName, source: 'go_client' }, - ), - ); - } - return out; - } - - private scanJavaProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - - // @GrpcService - if (content.includes('@GrpcService')) { - const implBaseRe = /extends\s+(\w+)Grpc\.(\w+)ImplBase/; - const m = content.match(implBaseRe); - if (m) { - out.push( - makeContract(serviceOnlyContractId(m[1]), 'provider', filePath, m[2], 0.8, { - service: m[1], - source: 'java_grpc_service', - }), - ); - } else { - // Try extracting service name from class name - const classRe = - /class\s+(\w*?)(?:Grpc)?(?:Service)?\s+extends\s+(\w+)(?:Grpc\.(\w+))?ImplBase/; - const cm = content.match(classRe); - if (cm) { - const svcName = cm[2].replace(/Grpc$/, ''); - out.push( - makeContract(serviceOnlyContractId(svcName), 'provider', filePath, cm[1], 0.8, { - service: svcName, - source: 'java_grpc_service', - }), - ); - } - } - } - - // extends XxxImplBase (without @GrpcService) - if (!content.includes('@GrpcService')) { - const implRe = /extends\s+(\w+?)(?:Grpc\.(\w+))?ImplBase/; - const m = content.match(implRe); - if (m) { - const svcName = m[2] || m[1].replace(/Grpc$/, ''); - out.push( - makeContract(serviceOnlyContractId(svcName), 'provider', filePath, svcName, 0.8, { - service: svcName, - source: 'java_impl_base', - }), - ); - } - } - - return out; - } - - private scanJavaConsumers(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - // XxxGrpc.newBlockingStub( or XxxGrpc.newStub( - const re = /(\w+)Grpc\.new(?:Blocking)?Stub\s*\(/g; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const serviceName = m[1]; - out.push( - makeContract( - serviceOnlyContractId(serviceName), - 'consumer', - filePath, - `${serviceName}Stub`, - 0.7, - { service: serviceName, source: 'java_stub' }, - ), - ); - } - return out; - } - - private scanPythonProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - // add_XxxServicer_to_server( - const re = /add_(\w+?)Servicer_to_server\s*\(/g; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const serviceName = m[1]; - out.push( - makeContract( - serviceOnlyContractId(serviceName), - 'provider', - filePath, - `add_${serviceName}Servicer_to_server`, - 0.8, - { service: serviceName, source: 'python_servicer' }, - ), - ); - } - return out; - } - - private scanPythonConsumers(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - // XxxStub( - const re = /(\w+)Stub\s*\(/g; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const name = m[1]; - // Filter out common false positives - if (['Mock', 'Test', 'Fake', 'Stub'].includes(name)) continue; - out.push( - makeContract(serviceOnlyContractId(name), 'consumer', filePath, `${name}Stub`, 0.7, { - service: name, - source: 'python_stub', - }), - ); - } - return out; - } - - private scanTsProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - // @GrpcMethod('ServiceName', 'MethodName') - const re = /@GrpcMethod\s*\(\s*['"](\w+)['"]\s*,\s*['"](\w+)['"]\s*\)/g; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const serviceName = m[1]; - const methodName = m[2]; - const cid = contractId('', serviceName, methodName); - out.push( - makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.8, { - service: serviceName, - method: methodName, - source: 'ts_grpc_method', - }), - ); - } - return out; + /** + * Convert a plugin `GrpcDetection` into a concrete `ExtractedContract` + * by resolving the short service name against the proto map, building + * either a service-level (`grpc::pkg.Svc/*`) or method-level + * (`grpc::pkg.Svc/Method`) contract id, and selecting confidence + * based on whether the proto map had an entry. + */ + private detectionToContract( + d: GrpcDetection, + filePath: string, + protoMap: Map, + ): ExtractedContract { + const candidates = protoMap.get(d.serviceName); + const proto = resolveProtoConflict(d.serviceName, filePath, candidates ?? []); + const pkg = proto?.package ?? ''; + const cid = d.methodName + ? contractId(pkg, d.serviceName, d.methodName) + : proto + ? serviceContractId(pkg, d.serviceName) + : serviceOnlyContractId(d.serviceName); + const confidence = proto ? d.confidenceWithProto : d.confidenceWithoutProto; + const meta: Record = { + service: d.serviceName, + source: d.source, + }; + if (d.methodName) meta.method = d.methodName; + return makeContract(cid, d.role, filePath, d.symbolName, confidence, meta); } private dedupe(items: ExtractedContract[]): ExtractedContract[] { - const seen = new Set(); - const out: ExtractedContract[] = []; + const byKey = new Map(); for (const c of items) { const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`; - if (seen.has(k)) continue; - seen.add(k); - out.push(c); + const existing = byKey.get(k); + if ( + !existing || + c.confidence > existing.confidence || + (c.confidence === existing.confidence && + String(c.meta.source) < String(existing.meta.source)) + ) { + byKey.set(k, c); + } } - return out; + return Array.from(byKey.values()); } } diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/go.ts b/gitnexus/src/core/group/extractors/grpc-patterns/go.ts new file mode 100644 index 000000000..b1abbaeb7 --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-patterns/go.ts @@ -0,0 +1,109 @@ +import Go from 'tree-sitter-go'; +import { + compilePatterns, + runCompiledPatterns, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { GrpcDetection, GrpcLanguagePlugin } from './types.js'; + +/** + * Go gRPC plugin. Detects: + * - Provider: `pb.RegisterXxxServer(...)` calls + * - Provider: `pb.UnimplementedXxxServer` embedded in a struct + * - Consumer: `pb.NewXxxClient(conn)` calls + */ + +const REGISTER_RE = /^Register(\w+)Server$/; +const UNIMPLEMENTED_RE = /^Unimplemented(\w+)Server$/; +const NEW_CLIENT_RE = /^New(\w+)Client$/; + +// Any `xxx.(...)` call — plugin filters the field identifier text. +const SELECTOR_CALL_PATTERNS = compilePatterns({ + name: 'go-grpc-selector-call', + language: Go, + patterns: [ + { + meta: {}, + query: ` + (call_expression + function: (selector_expression + field: (field_identifier) @fn)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// Any `qualified_type` used as a struct field — for `pb.UnimplementedXxxServer`. +const STRUCT_EMBEDDING_PATTERNS = compilePatterns({ + name: 'go-grpc-struct-embedding', + language: Go, + patterns: [ + { + meta: {}, + query: ` + (struct_type + (field_declaration_list + (field_declaration + type: (qualified_type + name: (type_identifier) @field_type)))) + `, + }, + ], +} satisfies LanguagePatterns>); + +export const GO_GRPC_PLUGIN: GrpcLanguagePlugin = { + name: 'go-grpc', + language: Go, + scan(tree) { + const out: GrpcDetection[] = []; + + for (const match of runCompiledPatterns(SELECTOR_CALL_PATTERNS, tree)) { + const fnNode = match.captures.fn; + if (!fnNode) continue; + const fnText = fnNode.text; + + const registerMatch = REGISTER_RE.exec(fnText); + if (registerMatch) { + out.push({ + role: 'provider', + serviceName: registerMatch[1], + symbolName: fnText, + source: 'go_register', + confidenceWithProto: 0.8, + confidenceWithoutProto: 0.65, + }); + continue; + } + + const newClientMatch = NEW_CLIENT_RE.exec(fnText); + if (newClientMatch) { + out.push({ + role: 'consumer', + serviceName: newClientMatch[1], + symbolName: fnText, + source: 'go_client', + confidenceWithProto: 0.75, + confidenceWithoutProto: 0.55, + }); + continue; + } + } + + for (const match of runCompiledPatterns(STRUCT_EMBEDDING_PATTERNS, tree)) { + const fieldNode = match.captures.field_type; + if (!fieldNode) continue; + const unimpl = UNIMPLEMENTED_RE.exec(fieldNode.text); + if (!unimpl) continue; + out.push({ + role: 'provider', + serviceName: unimpl[1], + symbolName: fieldNode.text, + source: 'go_unimplemented', + confidenceWithProto: 0.8, + confidenceWithoutProto: 0.65, + }); + } + + return out; + }, +}; diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/index.ts b/gitnexus/src/core/group/extractors/grpc-patterns/index.ts new file mode 100644 index 000000000..617c14beb --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-patterns/index.ts @@ -0,0 +1,53 @@ +import * as path from 'node:path'; +import type { GrpcLanguagePlugin } from './types.js'; +import { GO_GRPC_PLUGIN } from './go.js'; +import { JAVA_GRPC_PLUGIN } from './java.js'; +import { PYTHON_GRPC_PLUGIN } from './python.js'; +import { JAVASCRIPT_GRPC_PLUGIN, TYPESCRIPT_GRPC_PLUGIN, TSX_GRPC_PLUGIN } from './node.js'; +import { PROTO_GRPC_PLUGIN } from './proto.js'; + +export type { GrpcDetection, GrpcLanguagePlugin, GrpcRole } from './types.js'; +export { PROTO_GRPC_PLUGIN, extractPackageFromTree } from './proto.js'; + +/** + * File-extension → gRPC language plugin registry. Mirrors the shape + * of `http-patterns/index.ts` and `topic-patterns/index.ts`. + * + * `.proto` files are registered only when `tree-sitter-proto` is + * available (it's an optionalDependency). When absent, the orchestrator + * falls back to the built-in manual proto parser. + */ +const REGISTRY: Record = { + '.go': GO_GRPC_PLUGIN, + '.java': JAVA_GRPC_PLUGIN, + '.py': PYTHON_GRPC_PLUGIN, + '.js': JAVASCRIPT_GRPC_PLUGIN, + '.jsx': JAVASCRIPT_GRPC_PLUGIN, + '.ts': TYPESCRIPT_GRPC_PLUGIN, + '.tsx': TSX_GRPC_PLUGIN, + ...(PROTO_GRPC_PLUGIN ? { '.proto': PROTO_GRPC_PLUGIN } : {}), +}; + +/** + * Glob for source files worth scanning for gRPC server/client patterns. + * Includes `.proto` when the grammar is available. + */ +export const GRPC_SCAN_GLOB = PROTO_GRPC_PLUGIN + ? '**/*.{go,java,py,ts,tsx,js,jsx,proto}' + : '**/*.{go,java,py,ts,tsx,js,jsx}'; + +/** + * Whether the tree-sitter proto plugin is available. The orchestrator + * uses this to decide between the tree-sitter path and the fallback + * manual parser for `.proto` files. + */ +export const hasProtoPlugin = PROTO_GRPC_PLUGIN !== null; + +/** + * Return the gRPC plugin registered for the given file's extension, + * or `undefined` if the extension is not registered. + */ +export function getPluginForFile(rel: string): GrpcLanguagePlugin | undefined { + const ext = path.extname(rel).toLowerCase(); + return REGISTRY[ext]; +} diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/java.ts b/gitnexus/src/core/group/extractors/grpc-patterns/java.ts new file mode 100644 index 000000000..bf1cf4816 --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-patterns/java.ts @@ -0,0 +1,179 @@ +import Parser from 'tree-sitter'; +import Java from 'tree-sitter-java'; +import { + compilePatterns, + runCompiledPatterns, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { GrpcDetection, GrpcLanguagePlugin } from './types.js'; + +/** + * Java gRPC plugin. Detects: + * - Provider: classes extending `XxxServiceGrpc.XxxServiceImplBase` + * (with or without a `@GrpcService` annotation; the annotation + * only affects confidence labelling in the original regex version + * — here we emit a single detection per class and pick the source + * label based on whether the annotation is present). + * - Consumer: `XxxServiceGrpc.newBlockingStub(ch)` / + * `XxxServiceGrpc.newStub(ch)` calls. + */ + +const IMPL_BASE_RE = /^(\w+)ImplBase$/; +const GRPC_SUFFIX_RE = /^(\w+)Grpc$/; + +// Classes extending `ScopedType.ScopedType` where the inner name ends +// in ImplBase. Covers `XxxServiceGrpc.XxxServiceImplBase`. +// Note: tree-sitter-java's `scoped_type_identifier` exposes its two +// segments as positional `type_identifier` children, NOT as named +// `scope:`/`name:` fields. We match positionally here and rely on the +// grammar's left-to-right ordering: first child = outer, second = inner. +const SCOPED_IMPL_BASE_PATTERNS = compilePatterns({ + name: 'java-grpc-scoped-impl-base', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (class_declaration + name: (identifier) @class_name + superclass: (superclass + (scoped_type_identifier + (type_identifier) @outer + (type_identifier) @inner (#match? @inner "ImplBase$")))) @class + `, + }, + ], +} satisfies LanguagePatterns>); + +// Classes extending a simple `XxxImplBase` identifier (no scope). +const PLAIN_IMPL_BASE_PATTERNS = compilePatterns({ + name: 'java-grpc-plain-impl-base', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (class_declaration + name: (identifier) @class_name + superclass: (superclass + (type_identifier) @plain_type (#match? @plain_type "ImplBase$"))) @class + `, + }, + ], +} satisfies LanguagePatterns>); + +// gRPC stub factories: `XxxGrpc.newStub(ch)` / `XxxGrpc.newBlockingStub(ch)`. +const STUB_PATTERNS = compilePatterns({ + name: 'java-grpc-stub', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (method_invocation + object: (identifier) @grpc_cls + name: (identifier) @method (#match? @method "^new(Blocking)?Stub$")) + `, + }, + ], +} satisfies LanguagePatterns>); + +/** + * Check whether a `class_declaration` node has a `@GrpcService` + * annotation in its modifiers list. In tree-sitter-java, class-level + * annotations live under `(class_declaration (modifiers (marker_annotation|annotation)))`. + */ +function hasGrpcServiceAnnotation(classNode: Parser.SyntaxNode): boolean { + for (let i = 0; i < classNode.namedChildCount; i++) { + const child = classNode.namedChild(i); + if (!child || child.type !== 'modifiers') continue; + for (let j = 0; j < child.namedChildCount; j++) { + const mod = child.namedChild(j); + if (!mod) continue; + if (mod.type !== 'marker_annotation' && mod.type !== 'annotation') continue; + const nameNode = mod.childForFieldName('name'); + if (nameNode?.text === 'GrpcService') return true; + } + } + return false; +} + +/** + * Given the inner type_identifier text like `AuthServiceImplBase`, + * return the service name (`AuthService`), or null if the text + * doesn't end in `ImplBase`. + */ +function extractServiceFromImplBase(text: string): string | null { + const m = IMPL_BASE_RE.exec(text); + if (!m) return null; + // Strip a trailing `Grpc` on the service name too — the original + // regex replaces `Grpc$` on the extracted prefix. + return m[1].replace(/Grpc$/, ''); +} + +export const JAVA_GRPC_PLUGIN: GrpcLanguagePlugin = { + name: 'java-grpc', + language: Java, + scan(tree) { + const out: GrpcDetection[] = []; + const emittedClassIds = new Set(); + + // ─── Providers: scoped form (`...Grpc.XxxImplBase`) ───────────── + for (const match of runCompiledPatterns(SCOPED_IMPL_BASE_PATTERNS, tree)) { + const classNode = match.captures.class; + const innerNode = match.captures.inner; + if (!classNode || !innerNode) continue; + const serviceName = extractServiceFromImplBase(innerNode.text); + if (!serviceName) continue; + emittedClassIds.add(classNode.id); + const annotated = hasGrpcServiceAnnotation(classNode); + out.push({ + role: 'provider', + serviceName, + symbolName: serviceName, + source: annotated ? 'java_grpc_service' : 'java_impl_base', + confidenceWithProto: 0.8, + confidenceWithoutProto: 0.65, + }); + } + + // ─── Providers: plain form (`XxxImplBase`) ────────────────────── + for (const match of runCompiledPatterns(PLAIN_IMPL_BASE_PATTERNS, tree)) { + const classNode = match.captures.class; + const plainNode = match.captures.plain_type; + if (!classNode || !plainNode) continue; + if (emittedClassIds.has(classNode.id)) continue; + const serviceName = extractServiceFromImplBase(plainNode.text); + if (!serviceName) continue; + emittedClassIds.add(classNode.id); + const annotated = hasGrpcServiceAnnotation(classNode); + out.push({ + role: 'provider', + serviceName, + symbolName: serviceName, + source: annotated ? 'java_grpc_service' : 'java_impl_base', + confidenceWithProto: 0.8, + confidenceWithoutProto: 0.65, + }); + } + + // ─── Consumers: `XxxGrpc.newBlockingStub(...)` / `newStub(...)` ─ + for (const match of runCompiledPatterns(STUB_PATTERNS, tree)) { + const grpcClsNode = match.captures.grpc_cls; + if (!grpcClsNode) continue; + const grpcMatch = GRPC_SUFFIX_RE.exec(grpcClsNode.text); + if (!grpcMatch) continue; + const serviceName = grpcMatch[1]; + out.push({ + role: 'consumer', + serviceName, + symbolName: `${serviceName}Stub`, + source: 'java_stub', + confidenceWithProto: 0.75, + confidenceWithoutProto: 0.55, + }); + } + + return out; + }, +}; diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/node.ts b/gitnexus/src/core/group/extractors/grpc-patterns/node.ts new file mode 100644 index 000000000..033962206 --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-patterns/node.ts @@ -0,0 +1,314 @@ +import Parser from 'tree-sitter'; +import JavaScript from 'tree-sitter-javascript'; +import TypeScript from 'tree-sitter-typescript'; +import { + compilePatterns, + runCompiledPatterns, + unquoteLiteral, + type CompiledPatterns, + type LanguagePatterns, + type PatternSpec, +} from '../tree-sitter-scanner.js'; +import type { GrpcDetection, GrpcLanguagePlugin } from './types.js'; + +/** + * Node.js / TypeScript gRPC plugin family. Detects: + * - Provider: NestJS `@GrpcMethod('Service', 'Method')` decorators + * - Consumer: NestJS `@GrpcClient(...) readonly x!: XxxServiceClient` + * - Consumer: `client.getService('AuthService')` + * - Consumer: `new XxxServiceClient(...)` (generated client constructor) + * - Consumer: `new foo.bar.Xxx(...)` when the file uses + * `loadPackageDefinition` (gRPC dynamic proto loader) + * + * As with the HTTP `node.ts`, pattern sources are defined once and + * compiled against three grammar variants (JS / TS / TSX) because + * `Parser.Query` is not portable across grammar objects. + */ + +const SERVICE_CLIENT_RE = /^(\w+Service)Client$/; +const CAPITALIZED_SERVICE_RE = /^[A-Z]\w+$/; + +// @GrpcMethod('Service', 'Method') +const GRPC_METHOD_SPEC: PatternSpec> = { + meta: {}, + query: ` + (decorator + (call_expression + function: (identifier) @dec (#eq? @dec "GrpcMethod") + arguments: (arguments + . [(string) (template_string)] @service + . [(string) (template_string)] @method))) + `, +}; + +// @GrpcClient(...) standalone decorator — the plugin walks to the next +// sibling (a field definition) to read its type annotation. +const GRPC_CLIENT_SPEC: PatternSpec> = { + meta: {}, + query: ` + (decorator + (call_expression + function: (identifier) @dec (#eq? @dec "GrpcClient"))) @grpc_client_decorator + `, +}; + +// `.getService('AuthService')` / `.getService('AuthService')` +const GET_SERVICE_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (member_expression + property: (property_identifier) @method (#eq? @method "getService")) + arguments: (arguments . [(string) (template_string)] @service)) + `, +}; + +// `new XxxServiceClient(...)` — bare identifier constructor. +const NEW_SIMPLE_CTOR_SPEC: PatternSpec> = { + meta: {}, + query: ` + (new_expression + constructor: (identifier) @ctor) + `, +}; + +// `new foo.bar.XxxService(...)` — qualified constructor. +const NEW_QUALIFIED_CTOR_SPEC: PatternSpec> = { + meta: {}, + query: ` + (new_expression + constructor: (member_expression + property: (property_identifier) @ctor)) + `, +}; + +// Detect whether the file uses `loadPackageDefinition` (gRPC dynamic +// proto loader). Matches either a bare call or an `obj.loadPackageDefinition(...)` +// call. Plugin gates the qualified-constructor consumer on this — +// structural check avoids materializing `tree.rootNode.text` for every file. +const LOAD_PACKAGE_DEFINITION_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: [ + (identifier) @fn (#eq? @fn "loadPackageDefinition") + (member_expression property: (property_identifier) @fn (#eq? @fn "loadPackageDefinition")) + ]) + `, +}; + +interface NodeGrpcPatternBundle { + grpcMethod: CompiledPatterns>; + grpcClient: CompiledPatterns>; + getService: CompiledPatterns>; + newSimpleCtor: CompiledPatterns>; + newQualifiedCtor: CompiledPatterns>; + loadPackageDefinition: CompiledPatterns>; +} + +function compileBundle(language: unknown, name: string): NodeGrpcPatternBundle { + const mk = (spec: PatternSpec>, suffix: string) => + compilePatterns({ + name: `${name}-${suffix}`, + language, + patterns: [spec], + } satisfies LanguagePatterns>); + return { + grpcMethod: mk(GRPC_METHOD_SPEC, 'grpc-method'), + grpcClient: mk(GRPC_CLIENT_SPEC, 'grpc-client'), + getService: mk(GET_SERVICE_SPEC, 'get-service'), + newSimpleCtor: mk(NEW_SIMPLE_CTOR_SPEC, 'new-simple-ctor'), + newQualifiedCtor: mk(NEW_QUALIFIED_CTOR_SPEC, 'new-qualified-ctor'), + loadPackageDefinition: mk(LOAD_PACKAGE_DEFINITION_SPEC, 'load-package-definition'), + }; +} + +const JAVASCRIPT_BUNDLE = compileBundle(JavaScript, 'javascript-grpc'); +const TYPESCRIPT_BUNDLE = compileBundle(TypeScript.typescript, 'typescript-grpc'); +const TSX_BUNDLE = compileBundle(TypeScript.tsx, 'tsx-grpc'); + +/** + * Given a `@GrpcClient(...)` decorator node, find the type annotation + * text of the field it decorates (e.g. `AuthServiceClient`). + * + * In tree-sitter-typescript, decorators on class fields can appear in + * two configurations: + * - As a CHILD of `public_field_definition` alongside the field's + * type annotation (the common case for NestJS `@GrpcClient`). + * - As a SIBLING of the field in `class_body` (for method + * decorators, but kept for resilience against grammar variants). + * We walk the parent container and search for a type annotation. + */ +function resolveGrpcClientFieldType(decoratorNode: Parser.SyntaxNode): string | null { + const parent = decoratorNode.parent; + if (!parent) return null; + + // Case 1: decorator is a child of the field definition — search + // the parent itself (which is the field definition) for a + // type_annotation child. + if (parent.type === 'public_field_definition' || parent.type.endsWith('field_definition')) { + return findFirstTypeAnnotationText(parent); + } + + // Case 2: decorator is a sibling of the field in a class_body — walk + // forward through subsequent siblings until we find a node containing + // a type annotation. + for (let i = 0; i < parent.namedChildCount; i++) { + const child = parent.namedChild(i); + if (child && child.id === decoratorNode.id) { + for (let j = i + 1; j < parent.namedChildCount; j++) { + const next = parent.namedChild(j); + if (!next) continue; + if (next.type === 'decorator') continue; + const typeText = findFirstTypeAnnotationText(next); + if (typeText) return typeText; + return null; + } + return null; + } + } + return null; +} + +/** + * Recursively search `node` for the first `type_annotation` child and + * return the text of its inner `type_identifier`, or null. Handles + * both `public_field_definition` and its variants. + */ +function findFirstTypeAnnotationText(node: Parser.SyntaxNode): string | null { + if (node.type === 'type_annotation') { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (child.type === 'type_identifier') return child.text; + } + return null; + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + const found = findFirstTypeAnnotationText(child); + if (found) return found; + } + return null; +} + +function scanBundle(bundle: NodeGrpcPatternBundle, tree: Parser.Tree): GrpcDetection[] { + const out: GrpcDetection[] = []; + + // ─── Provider: @GrpcMethod('Service', 'Method') ────────────────── + for (const match of runCompiledPatterns(bundle.grpcMethod, tree)) { + const svcNode = match.captures.service; + const methodNode = match.captures.method; + if (!svcNode || !methodNode) continue; + const svc = unquoteLiteral(svcNode.text); + const mth = unquoteLiteral(methodNode.text); + if (!svc || !mth) continue; + out.push({ + role: 'provider', + serviceName: svc, + symbolName: `${svc}.${mth}`, + source: 'ts_grpc_method', + methodName: mth, + // @GrpcMethod hard-coded confidence 0.8 in the original code + // regardless of whether the proto map has a match. + confidenceWithProto: 0.8, + confidenceWithoutProto: 0.8, + }); + } + + // ─── Consumer: @GrpcClient() field with XxxServiceClient type ──── + for (const match of runCompiledPatterns(bundle.grpcClient, tree)) { + const decoratorNode = match.captures.grpc_client_decorator; + if (!decoratorNode) continue; + const typeText = resolveGrpcClientFieldType(decoratorNode); + if (!typeText) continue; + const svcMatch = SERVICE_CLIENT_RE.exec(typeText); + if (!svcMatch) continue; + const serviceName = svcMatch[1]; + out.push({ + role: 'consumer', + serviceName, + symbolName: `${serviceName}Client`, + source: 'ts_grpc_client_decorator', + confidenceWithProto: 0.75, + confidenceWithoutProto: 0.55, + }); + } + + // ─── Consumer: client.getService('Service') ─────────────────── + for (const match of runCompiledPatterns(bundle.getService, tree)) { + const svcNode = match.captures.service; + if (!svcNode) continue; + const svc = unquoteLiteral(svcNode.text); + if (!svc) continue; + out.push({ + role: 'consumer', + serviceName: svc, + symbolName: `${svc}Client`, + source: 'ts_client_grpc_get_service', + confidenceWithProto: 0.75, + confidenceWithoutProto: 0.55, + }); + } + + // ─── Consumer: new XxxServiceClient(...) ───────────────────────── + for (const match of runCompiledPatterns(bundle.newSimpleCtor, tree)) { + const ctorNode = match.captures.ctor; + if (!ctorNode) continue; + const svcMatch = SERVICE_CLIENT_RE.exec(ctorNode.text); + if (!svcMatch) continue; + const serviceName = svcMatch[1]; + out.push({ + role: 'consumer', + serviceName, + symbolName: `${serviceName}Client`, + source: 'ts_generated_client', + confidenceWithProto: 0.75, + confidenceWithoutProto: 0.55, + }); + } + + // ─── Consumer: loadPackageDefinition dynamic proto loader ──────── + // Only emit when the file uses loadPackageDefinition, otherwise a + // generic `new foo.bar.Something()` in unrelated code would falsely + // register as a gRPC consumer. Check structurally via a dedicated + // query — avoids materializing `tree.rootNode.text` for the whole + // file (expensive on large files). + const usesLoadPackage = runCompiledPatterns(bundle.loadPackageDefinition, tree).length > 0; + if (usesLoadPackage) { + for (const match of runCompiledPatterns(bundle.newQualifiedCtor, tree)) { + const ctorNode = match.captures.ctor; + if (!ctorNode) continue; + if (!CAPITALIZED_SERVICE_RE.test(ctorNode.text)) continue; + out.push({ + role: 'consumer', + serviceName: ctorNode.text, + symbolName: `${ctorNode.text}Client`, + source: 'ts_load_package_definition', + confidenceWithProto: 0.75, + confidenceWithoutProto: 0.55, + }); + } + } + + return out; +} + +export const JAVASCRIPT_GRPC_PLUGIN: GrpcLanguagePlugin = { + name: 'javascript-grpc', + language: JavaScript, + scan: (tree) => scanBundle(JAVASCRIPT_BUNDLE, tree), +}; + +export const TYPESCRIPT_GRPC_PLUGIN: GrpcLanguagePlugin = { + name: 'typescript-grpc', + language: TypeScript.typescript, + scan: (tree) => scanBundle(TYPESCRIPT_BUNDLE, tree), +}; + +export const TSX_GRPC_PLUGIN: GrpcLanguagePlugin = { + name: 'tsx-grpc', + language: TypeScript.tsx, + scan: (tree) => scanBundle(TSX_BUNDLE, tree), +}; diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/proto.ts b/gitnexus/src/core/group/extractors/grpc-patterns/proto.ts new file mode 100644 index 000000000..69b446e55 --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-patterns/proto.ts @@ -0,0 +1,147 @@ +import { createRequire } from 'node:module'; +import { + compilePatterns, + runCompiledPatterns, + type CompiledPatterns, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { GrpcDetection, GrpcLanguagePlugin } from './types.js'; + +/** + * Protobuf (.proto) tree-sitter plugin for gRPC contract extraction. + * + * Uses `tree-sitter-proto` (coder3101/tree-sitter-proto) as an + * optionalDependency — if the grammar is not installed (e.g. native + * compilation failed on an unusual platform), the plugin exports + * `null` and the orchestrator falls back to the existing manual + * string-sanitizing parser. + * + * The grammar is vendored in `vendor/tree-sitter-proto/` with + * parser.c regenerated against tree-sitter-cli 0.24 (ABI version 14) + * so it is compatible with the project's tree-sitter 0.25 runtime. + */ + +const _require = createRequire(import.meta.url); +let ProtoGrammar: unknown = null; +try { + ProtoGrammar = _require('tree-sitter-proto'); +} catch { + // Grammar not installed — PROTO_GRPC_PLUGIN will be null. +} + +let PACKAGE_PATTERNS: CompiledPatterns> | null = null; +let SERVICE_PATTERNS: CompiledPatterns> | null = null; + +if (ProtoGrammar) { + try { + // Validate that the grammar actually loads end-to-end: compile queries + // AND parse + walk a trivial proto file. tree-sitter's internal + // `initializeLanguageNodeClasses` can fail with a TDZ error in some + // test runners (vitest forks) when SyntaxNode isn't fully initialized + // yet. Catching that here ensures `PROTO_GRPC_PLUGIN` stays null and + // the orchestrator falls back to the manual parser. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const _Parser = _require('tree-sitter') as any; + // Smoke-test: parse + setLanguage to verify the grammar is + // end-to-end compatible with this tree-sitter runtime. + const _testParser = new _Parser(); + _testParser.setLanguage(ProtoGrammar); + _testParser.parse('service X { rpc Y (R) returns (R); }'); + + PACKAGE_PATTERNS = compilePatterns({ + name: 'proto-package', + language: ProtoGrammar, + patterns: [ + { + meta: {}, + query: `(package (full_ident) @pkg)`, + }, + ], + } satisfies LanguagePatterns>); + + SERVICE_PATTERNS = compilePatterns({ + name: 'proto-service', + language: ProtoGrammar, + patterns: [ + { + meta: {}, + query: ` + (service + (service_name) @service_name + (rpc + (rpc_name) @rpc_name)) + `, + }, + ], + } satisfies LanguagePatterns>); + } catch { + // Compilation failed (grammar ABI mismatch?) — fall back to null. + PACKAGE_PATTERNS = null; + SERVICE_PATTERNS = null; + ProtoGrammar = null; + } +} + +function buildPlugin(): GrpcLanguagePlugin | null { + if (!ProtoGrammar || !PACKAGE_PATTERNS || !SERVICE_PATTERNS) return null; + const pkgPatterns = PACKAGE_PATTERNS; + const svcPatterns = SERVICE_PATTERNS; + + return { + name: 'proto-grpc', + language: ProtoGrammar, + scan(tree) { + const out: GrpcDetection[] = []; + + // Extract `package` declaration (first match wins). + let pkg = ''; + for (const match of runCompiledPatterns(pkgPatterns, tree)) { + const pkgNode = match.captures.pkg; + if (pkgNode) { + pkg = pkgNode.text; + break; + } + } + + // Extract `service → rpc` pairs. The query returns one match per + // (service, rpc) combination thanks to the nested structure. + for (const match of runCompiledPatterns(svcPatterns, tree)) { + const serviceNode = match.captures.service_name; + const rpcNode = match.captures.rpc_name; + if (!serviceNode || !rpcNode) continue; + const serviceName = serviceNode.text; + const methodName = rpcNode.text; + out.push({ + role: 'provider', + serviceName, + symbolName: `${serviceName}.${methodName}`, + source: 'proto', + methodName, + // Proto definitions are the canonical source of truth — always + // high confidence regardless of cross-referencing. + confidenceWithProto: 0.85, + confidenceWithoutProto: 0.85, + }); + } + + return out; + }, + }; +} + +/** + * The proto plugin, or `null` if tree-sitter-proto is not available. + * The orchestrator checks this at import time and decides whether to + * use the tree-sitter path or the fallback manual parser. + */ +export const PROTO_GRPC_PLUGIN: GrpcLanguagePlugin | null = buildPlugin(); + +/** The package declaration text from a proto file's tree. */ +export function extractPackageFromTree(tree: import('tree-sitter').Tree): string { + if (!PACKAGE_PATTERNS) return ''; + for (const match of runCompiledPatterns(PACKAGE_PATTERNS, tree)) { + const pkgNode = match.captures.pkg; + if (pkgNode) return pkgNode.text; + } + return ''; +} diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/python.ts b/gitnexus/src/core/group/extractors/grpc-patterns/python.ts new file mode 100644 index 000000000..a19896c1f --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-patterns/python.ts @@ -0,0 +1,77 @@ +import Python from 'tree-sitter-python'; +import { + compilePatterns, + runCompiledPatterns, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { GrpcDetection, GrpcLanguagePlugin } from './types.js'; + +/** + * Python gRPC plugin. Detects: + * - Provider: `add_XxxServicer_to_server(...)` calls (bare identifier + * or qualified attribute form `auth_pb2_grpc.add_XxxServicer_to_server`) + * - Consumer: `XxxStub(channel)` calls (bare or `auth_pb2_grpc.XxxStub`) + */ + +const ADD_SERVICER_RE = /^add_(\w+)Servicer_to_server$/; +const STUB_RE = /^(\w+)Stub$/; +/** Reserved names that would produce garbage service names. */ +const STUB_IGNORE = new Set(['Mock', 'Test', 'Fake', 'Stub']); + +// Any call whose target is either a bare identifier or an attribute +// access (`obj.method`). The plugin filters the function name in JS. +const CALL_PATTERNS = compilePatterns({ + name: 'python-grpc-call', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (call + function: [ + (identifier) @fn + (attribute attribute: (identifier) @fn) + ]) + `, + }, + ], +} satisfies LanguagePatterns>); + +export const PYTHON_GRPC_PLUGIN: GrpcLanguagePlugin = { + name: 'python-grpc', + language: Python, + scan(tree) { + const out: GrpcDetection[] = []; + for (const match of runCompiledPatterns(CALL_PATTERNS, tree)) { + const fnNode = match.captures.fn; + if (!fnNode) continue; + const fnText = fnNode.text; + + const addServicer = ADD_SERVICER_RE.exec(fnText); + if (addServicer) { + out.push({ + role: 'provider', + serviceName: addServicer[1], + symbolName: fnText, + source: 'python_servicer', + confidenceWithProto: 0.8, + confidenceWithoutProto: 0.65, + }); + continue; + } + + const stubMatch = STUB_RE.exec(fnText); + if (stubMatch && !STUB_IGNORE.has(stubMatch[1])) { + out.push({ + role: 'consumer', + serviceName: stubMatch[1], + symbolName: fnText, + source: 'python_stub', + confidenceWithProto: 0.75, + confidenceWithoutProto: 0.55, + }); + } + } + return out; + }, +}; diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/types.ts b/gitnexus/src/core/group/extractors/grpc-patterns/types.ts new file mode 100644 index 000000000..606d9629b --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-patterns/types.ts @@ -0,0 +1,54 @@ +import type Parser from 'tree-sitter'; + +/** + * Shared types for the grpc-extractor language plugins. + * + * Each plugin lives in its own file (java.ts, go.ts, ...) and owns the + * tree-sitter grammar import + query sources. The top-level + * `grpc-extractor.ts` orchestrator only knows about this type module + * and the plugin registry (`./index.ts`). It MUST NOT import any + * grammar or query text directly. + */ + +export type GrpcRole = 'provider' | 'consumer'; + +/** + * One raw gRPC detection produced by a plugin's `scan()` function. The + * orchestrator uses the proto map to resolve the full package-qualified + * contract id and choose a confidence based on whether the proto was + * found. + * + * Most patterns produce service-level detections; `TS @GrpcMethod` is + * the only pattern that captures an explicit `methodName`, producing + * a method-level contract (`grpc::pkg.Service/Method`). + */ +export interface GrpcDetection { + role: GrpcRole; + /** Short service name, e.g. `"AuthService"`. */ + serviceName: string; + /** Symbol name emitted into the contract's symbolRef. */ + symbolName: string; + /** Metadata source label (goes into `meta.source`). */ + source: string; + /** Explicit method name; set only by TS `@GrpcMethod`. */ + methodName?: string; + /** Confidence when the proto map resolves the service. */ + confidenceWithProto: number; + /** Confidence when the proto map has no entry. */ + confidenceWithoutProto: number; +} + +/** + * One language-scoped gRPC plugin. Plugins own the tree-sitter grammar + * and a `scan(tree)` function that returns zero or more + * `GrpcDetection`s. The plugin is free to run multiple compiled query + * bundles and walk the AST to cross-reference captures. + * + * `language` is typed `unknown` for the same reason as in + * `tree-sitter-scanner.ts`. + */ +export interface GrpcLanguagePlugin { + name: string; + language: unknown; + scan(tree: Parser.Tree): GrpcDetection[]; +} diff --git a/gitnexus/src/core/group/extractors/http-patterns/go.ts b/gitnexus/src/core/group/extractors/http-patterns/go.ts new file mode 100644 index 000000000..afbfaad56 --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/go.ts @@ -0,0 +1,224 @@ +import Go from 'tree-sitter-go'; +import { + compilePatterns, + runCompiledPatterns, + unquoteLiteral, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { HttpDetection, HttpLanguagePlugin } from './types.js'; + +/** + * Go HTTP plugin. Handles: + * - gin / echo / chi framework routing — `r.GET("/path", handler)` + * - net/http stdlib — `http.HandleFunc("/path", handler)` + * - net/http consumer — `http.Get(...)`, `http.NewRequest("METHOD", ...)` + * - resty consumer — `client.R().Delete("/path")` + */ + +// ─── Provider: framework routing ────────────────────────────────────── +// Matches `\w+\.GET(...)` etc. (gin, echo, chi all share this shape). +// Captures the HTTP method (field name), path literal, and handler +// identifier passed as the second argument. +const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({ + name: 'go-framework-route', + language: Go, + patterns: [ + { + meta: {}, + query: ` + (call_expression + function: (selector_expression + field: (field_identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE|PATCH)$")) + arguments: (argument_list + (interpreted_string_literal) @path + (identifier) @handler)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Provider: net/http `http.HandleFunc("/p", handler)` ───────────── +const HANDLE_FUNC_PATTERNS = compilePatterns({ + name: 'go-handle-func', + language: Go, + patterns: [ + { + meta: {}, + query: ` + (call_expression + function: (selector_expression + operand: (identifier) @pkg (#eq? @pkg "http") + field: (field_identifier) @fn (#eq? @fn "HandleFunc")) + arguments: (argument_list + (interpreted_string_literal) @path + (identifier) @handler)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: net/http stdlib Get / Post / Head ───────────────────── +const HTTP_CLIENT_METHOD_TO_HTTP: Record = { + Get: 'GET', + Post: 'POST', + Head: 'GET', // HEAD has no body semantics we care about — treat as GET for contract matching +}; + +const HTTP_CLIENT_PATTERNS = compilePatterns({ + name: 'go-http-client', + language: Go, + patterns: [ + { + meta: {}, + query: ` + (call_expression + function: (selector_expression + operand: (identifier) @pkg (#eq? @pkg "http") + field: (field_identifier) @fn (#match? @fn "^(Get|Post|Head)$")) + arguments: (argument_list . (interpreted_string_literal) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: net/http `http.NewRequest("METHOD", "/path", ...)` ──── +const NEW_REQUEST_PATTERNS = compilePatterns({ + name: 'go-new-request', + language: Go, + patterns: [ + { + meta: {}, + query: ` + (call_expression + function: (selector_expression + operand: (identifier) @pkg (#eq? @pkg "http") + field: (field_identifier) @fn (#eq? @fn "NewRequest")) + arguments: (argument_list + . + (interpreted_string_literal) @http_method + (interpreted_string_literal) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: resty `client.R().Delete("/path")` ───────────────────── +// Matches any chained call whose receiver is `something.R()` and whose +// method name is an HTTP verb. This is how go-resty's fluent API looks. +const RESTY_PATTERNS = compilePatterns({ + name: 'go-resty', + language: Go, + patterns: [ + { + meta: {}, + query: ` + (call_expression + function: (selector_expression + operand: (call_expression + function: (selector_expression + field: (field_identifier) @r (#eq? @r "R"))) + field: (field_identifier) @http_method (#match? @http_method "^(Get|Post|Put|Delete|Patch)$")) + arguments: (argument_list . (interpreted_string_literal) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { + name: 'go-http', + language: Go, + scan(tree) { + const out: HttpDetection[] = []; + + // Framework providers: r.GET/POST/... with handler identifier + for (const match of runCompiledPatterns(FRAMEWORK_ROUTE_PATTERNS, tree)) { + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + const handlerNode = match.captures.handler; + if (!methodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'provider', + framework: 'go-framework', + method: methodNode.text.toUpperCase(), + path, + name: handlerNode?.text ?? null, + confidence: 0.8, + }); + } + + // net/http HandleFunc: default method GET + for (const match of runCompiledPatterns(HANDLE_FUNC_PATTERNS, tree)) { + const pathNode = match.captures.path; + const handlerNode = match.captures.handler; + if (!pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'provider', + framework: 'go-stdlib', + method: 'GET', + path, + name: handlerNode?.text ?? null, + confidence: 0.8, + }); + } + + // net/http client: http.Get/Post/Head + for (const match of runCompiledPatterns(HTTP_CLIENT_PATTERNS, tree)) { + const fnNode = match.captures.fn; + const pathNode = match.captures.path; + if (!fnNode || !pathNode) continue; + const httpMethod = HTTP_CLIENT_METHOD_TO_HTTP[fnNode.text]; + if (!httpMethod) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'go-stdlib', + method: httpMethod, + path, + name: null, + confidence: 0.7, + }); + } + + // net/http NewRequest + for (const match of runCompiledPatterns(NEW_REQUEST_PATTERNS, tree)) { + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const method = unquoteLiteral(methodNode.text); + const path = unquoteLiteral(pathNode.text); + if (method === null || path === null) continue; + out.push({ + role: 'consumer', + framework: 'go-stdlib', + method: method.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + // resty + for (const match of runCompiledPatterns(RESTY_PATTERNS, tree)) { + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'go-resty', + method: methodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + return out; + }, +}; diff --git a/gitnexus/src/core/group/extractors/http-patterns/index.ts b/gitnexus/src/core/group/extractors/http-patterns/index.ts new file mode 100644 index 000000000..e33d32a79 --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/index.ts @@ -0,0 +1,50 @@ +import * as path from 'node:path'; +import type { HttpLanguagePlugin } from './types.js'; +import { JAVA_HTTP_PLUGIN } from './java.js'; +import { GO_HTTP_PLUGIN } from './go.js'; +import { PYTHON_HTTP_PLUGIN } from './python.js'; +import { PHP_HTTP_PLUGIN } from './php.js'; +import { JAVASCRIPT_HTTP_PLUGIN, TYPESCRIPT_HTTP_PLUGIN, TSX_HTTP_PLUGIN } from './node.js'; + +export type { HttpDetection, HttpLanguagePlugin, HttpRole } from './types.js'; + +/** + * File-extension → HTTP language plugin registry. The top-level + * orchestrator (`http-route-extractor.ts`) looks up the plugin for each + * file it visits and delegates the tree-sitter scanning to the plugin. + * + * Keys are lowercase extensions including the leading dot. To add a + * new language, drop a `http-patterns/.ts` that exports a + * `HttpLanguagePlugin`, import it here and register the extension(s). + * No edits to `http-route-extractor.ts` are required. + */ +const REGISTRY: Record = { + '.java': JAVA_HTTP_PLUGIN, + '.go': GO_HTTP_PLUGIN, + '.py': PYTHON_HTTP_PLUGIN, + '.php': PHP_HTTP_PLUGIN, + '.js': JAVASCRIPT_HTTP_PLUGIN, + '.jsx': JAVASCRIPT_HTTP_PLUGIN, + '.ts': TYPESCRIPT_HTTP_PLUGIN, + '.tsx': TSX_HTTP_PLUGIN, +}; + +/** + * Glob for files worth scanning for HTTP routes. Kept alongside the + * registry so adding a new language widens the glob in one edit. + * + * `.vue` / `.svelte` files are intentionally omitted for the source-scan + * path — they need their own grammar-aware extraction and the existing + * regex fallback for them was never very accurate. The graph-assisted + * Strategy A still handles them via the ingestion pipeline. + */ +export const HTTP_SCAN_GLOB = '**/*.{ts,tsx,js,jsx,java,go,py,php}'; + +/** + * Return the HTTP plugin registered for the given file's extension, + * or `undefined` if the extension is not registered. + */ +export function getPluginForFile(rel: string): HttpLanguagePlugin | undefined { + const ext = path.extname(rel).toLowerCase(); + return REGISTRY[ext]; +} diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts new file mode 100644 index 000000000..484f74fb2 --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -0,0 +1,267 @@ +import Parser from 'tree-sitter'; +import Java from 'tree-sitter-java'; +import { + compilePatterns, + runCompiledPatterns, + unquoteLiteral, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { HttpDetection, HttpLanguagePlugin } from './types.js'; + +/** + * Java HTTP plugin. Handles: + * - Spring `@RequestMapping` class prefixes + `@(Get|Post|...)Mapping` method annotations + * - Spring `RestTemplate.getForObject/...`, `WebClient.method(HttpMethod.X, ...)` + * - OkHttp `new Request.Builder().url("...")` + * + * The plugin runs two pattern bundles: one to collect class-level + * `@RequestMapping` prefixes keyed by the enclosing class node, and a + * second to match method-level annotations. The `scan` function walks + * up from each matched annotation to find its enclosing class and + * combines the prefix with the method path. + */ + +const METHOD_ANNOTATION_TO_HTTP: Record = { + GetMapping: 'GET', + PostMapping: 'POST', + PutMapping: 'PUT', + DeleteMapping: 'DELETE', + PatchMapping: 'PATCH', +}; + +// ─── Provider: Spring class-level @RequestMapping prefix ────────────── +const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({ + name: 'java-spring-class-prefix', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "RequestMapping") + arguments: (annotation_argument_list (string_literal) @prefix)))) @class + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Provider: Spring @(Get|Post|...)Mapping method annotations ─────── +const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({ + name: 'java-spring-method-route', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (method_declaration + (modifiers + (annotation + name: (identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$") + arguments: (annotation_argument_list (string_literal) @path))) + name: (identifier) @method_name) @method + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: Spring RestTemplate (object-named + method-named) ────── +// RestTemplate.getForObject / getForEntity → GET +// RestTemplate.postForObject / postForEntity → POST +// RestTemplate.put → PUT +// RestTemplate.delete → DELETE +// RestTemplate.patchForObject → PATCH +const REST_TEMPLATE_TO_HTTP: Record = { + getForObject: 'GET', + getForEntity: 'GET', + postForObject: 'POST', + postForEntity: 'POST', + put: 'PUT', + delete: 'DELETE', + patchForObject: 'PATCH', +}; + +interface RestTemplateMeta { + framework: 'spring-rest-template'; +} + +const REST_TEMPLATE_PATTERNS = compilePatterns({ + name: 'java-rest-template', + language: Java, + patterns: [ + { + meta: { framework: 'spring-rest-template' }, + query: ` + (method_invocation + object: (identifier) @obj (#eq? @obj "restTemplate") + name: (identifier) @method + arguments: (argument_list . (string_literal) @path)) + `, + }, + ], +} satisfies LanguagePatterns); + +// ─── Consumer: Spring WebClient — webClient.method(HttpMethod.X, "path") ─ +const WEB_CLIENT_PATTERNS = compilePatterns({ + name: 'java-web-client', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (method_invocation + object: (identifier) @obj (#eq? @obj "webClient") + name: (identifier) @method (#eq? @method "method") + arguments: (argument_list + (field_access + object: (identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod") + field: (identifier) @http_method) + (string_literal) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: OkHttp `new Request.Builder().url("path")` ───────────── +// Note: `Request.Builder` is a `scoped_type_identifier` whose text includes +// the dot, so `#eq?` against the literal string matches cleanly (no need +// to escape a regex dot). +const OK_HTTP_PATTERNS = compilePatterns({ + name: 'java-okhttp', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (method_invocation + object: (object_creation_expression + type: (scoped_type_identifier) @type (#eq? @type "Request.Builder")) + name: (identifier) @method (#eq? @method "url") + arguments: (argument_list . (string_literal) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +/** + * Find the nearest enclosing class_declaration ancestor for a node, or + * null if the node is top-level. Tree-sitter's SyntaxNode.parent walks + * one level at a time. + */ +function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + let cur: Parser.SyntaxNode | null = node.parent; + while (cur) { + if (cur.type === 'class_declaration') return cur; + cur = cur.parent; + } + return null; +} + +/** + * Join a class-level prefix and a method-level path into a single URL + * path. Mirrors the semantics of the original regex implementation: + * strip trailing slashes on the prefix, then ensure a single slash + * between prefix and method path. + */ +function joinPath(prefix: string, methodPath: string): string { + const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, ''); + const cleanSub = methodPath.replace(/^\/+/, ''); + if (!cleanPrefix) return `/${cleanSub}`; + return `/${cleanPrefix}/${cleanSub}`; +} + +export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { + name: 'java-http', + language: Java, + scan(tree) { + const out: HttpDetection[] = []; + + // ─── Providers: Spring class prefix + method annotations ──────── + const prefixByClassId = new Map(); + for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) { + const prefixNode = match.captures.prefix; + const classNode = match.captures.class; + if (!prefixNode || !classNode) continue; + const prefix = unquoteLiteral(prefixNode.text); + if (prefix !== null) prefixByClassId.set(classNode.id, prefix); + } + + for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { + const annNode = match.captures.ann; + const pathNode = match.captures.path; + const nameNode = match.captures.method_name; + const methodNode = match.captures.method; + if (!annNode || !pathNode || !methodNode) continue; + const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text]; + if (!httpMethod) continue; + const rawPath = unquoteLiteral(pathNode.text); + if (rawPath === null) continue; + const enclosingClass = findEnclosingClass(methodNode); + const prefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : ''; + const fullPath = joinPath(prefix, rawPath); + out.push({ + role: 'provider', + framework: 'spring', + method: httpMethod, + path: fullPath, + name: nameNode?.text ?? null, + confidence: 0.8, + }); + } + + // ─── Consumers: RestTemplate ──────────────────────────────────── + for (const match of runCompiledPatterns(REST_TEMPLATE_PATTERNS, tree)) { + const methodNode = match.captures.method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const httpMethod = REST_TEMPLATE_TO_HTTP[methodNode.text]; + if (!httpMethod) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'spring-rest-template', + method: httpMethod, + path, + name: null, + confidence: 0.7, + }); + } + + // ─── Consumers: WebClient.method(HttpMethod.X, "path") ────────── + for (const match of runCompiledPatterns(WEB_CLIENT_PATTERNS, tree)) { + const httpMethodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!httpMethodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'spring-web-client', + method: httpMethodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + // ─── Consumers: OkHttp Request.Builder().url("path") ──────────── + for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) { + const pathNode = match.captures.path; + if (!pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'okhttp', + method: 'GET', + path, + name: null, + confidence: 0.7, + }); + } + + return out; + }, +}; diff --git a/gitnexus/src/core/group/extractors/http-patterns/node.ts b/gitnexus/src/core/group/extractors/http-patterns/node.ts new file mode 100644 index 000000000..587f48e8c --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/node.ts @@ -0,0 +1,373 @@ +import Parser from 'tree-sitter'; +import JavaScript from 'tree-sitter-javascript'; +import TypeScript from 'tree-sitter-typescript'; +import { + compilePatterns, + runCompiledPatterns, + unquoteLiteral, + type CompiledPatterns, + type LanguagePatterns, + type PatternSpec, +} from '../tree-sitter-scanner.js'; +import type { HttpDetection, HttpLanguagePlugin } from './types.js'; + +/** + * Node.js / TypeScript HTTP plugin family. Handles: + * - NestJS `@Controller('prefix')` classes with `@Get(':id')` methods + * - Express `router.get(...)` / `app.post(...)` providers + * - `fetch(url)` / `fetch(url, { method: 'POST' })` consumers + * - `axios.get(url)` / `axios.delete(url)` consumers + * + * Because the JavaScript and TypeScript tree-sitter grammars share + * node type names for every construct we query, pattern sources are + * defined once and compiled against each grammar variant. The plugin + * exports three `HttpLanguagePlugin`s (JS, TS, TSX) that share the + * same `scan` function but bind to different grammars. + */ + +// ─── Provider: NestJS — class-level @Controller('prefix') ──────────── +// In tree-sitter-typescript decorators are NOT children of +// class_declaration / method_definition — they're siblings in the +// surrounding class_body / program node. We therefore match the +// decorator standalone and walk to its related class/method in JS. +const NEST_CONTROLLER_SPEC: PatternSpec> = { + meta: {}, + query: ` + (decorator + (call_expression + function: (identifier) @dec (#eq? @dec "Controller") + arguments: (arguments . [(string) (template_string)] @prefix))) @ctrl_decorator + `, +}; + +// ─── Provider: NestJS — method-level @Get/@Post/... decorators ─────── +// Matches either `@Get('path')` or `@Get()`. The `@path` capture is +// optional — when the first argument isn't a string, the plugin falls +// back to '/' for the method-level path. +const NEST_METHOD_SPEC: PatternSpec> = { + meta: {}, + query: ` + (decorator + (call_expression + function: (identifier) @dec (#match? @dec "^(Get|Post|Put|Delete|Patch)$") + arguments: (arguments) @args)) @method_decorator + `, +}; + +// ─── Provider: Express — router.get/app.post/... ───────────────────── +const EXPRESS_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#match? @obj "^(router|app)$") + property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$")) + arguments: (arguments . [(string) (template_string)] @path)) + `, +}; + +// ─── Consumer: fetch(url) with NO options ───────────────────────────── +const FETCH_NO_OPTIONS_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (identifier) @fn (#eq? @fn "fetch") + arguments: (arguments . [(string) (template_string)] @path .)) + `, +}; + +// ─── Consumer: fetch(url, { method: 'X', ... }) ────────────────────── +const FETCH_WITH_OPTIONS_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (identifier) @fn (#eq? @fn "fetch") + arguments: (arguments + . [(string) (template_string)] @path + (object + (pair + key: (property_identifier) @key (#eq? @key "method") + value: (string) @http_method)))) + `, +}; + +// ─── Consumer: axios.get/post/... ──────────────────────────────────── +const AXIOS_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "axios") + property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$")) + arguments: (arguments . [(string) (template_string)] @path)) + `, +}; + +interface NodePatternBundle { + controller: CompiledPatterns>; + methodDecorator: CompiledPatterns>; + express: CompiledPatterns>; + fetchNoOptions: CompiledPatterns>; + fetchWithOptions: CompiledPatterns>; + axios: CompiledPatterns>; +} + +function compileBundle(language: unknown, name: string): NodePatternBundle { + const mk = (spec: PatternSpec>, suffix: string) => + compilePatterns({ + name: `${name}-${suffix}`, + language, + patterns: [spec], + } satisfies LanguagePatterns>); + return { + controller: mk(NEST_CONTROLLER_SPEC, 'nest-controller'), + methodDecorator: mk(NEST_METHOD_SPEC, 'nest-method-decorator'), + express: mk(EXPRESS_SPEC, 'express'), + fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'), + fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'), + axios: mk(AXIOS_SPEC, 'axios'), + }; +} + +const JAVASCRIPT_BUNDLE = compileBundle(JavaScript, 'javascript-http'); +const TYPESCRIPT_BUNDLE = compileBundle(TypeScript.typescript, 'typescript-http'); +const TSX_BUNDLE = compileBundle(TypeScript.tsx, 'tsx-http'); + +const NEST_DECORATOR_TO_HTTP: Record = { + Get: 'GET', + Post: 'POST', + Put: 'PUT', + Delete: 'DELETE', + Patch: 'PATCH', +}; + +/** + * Find the nearest enclosing class_declaration for a node, or null. + */ +function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + let cur: Parser.SyntaxNode | null = node.parent; + while (cur) { + if (cur.type === 'class_declaration') return cur; + cur = cur.parent; + } + return null; +} + +function joinPath(prefix: string, sub: string): string { + const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, ''); + const cleanSub = sub.replace(/^\/+/, ''); + if (!cleanPrefix) return `/${cleanSub}`; + return `/${cleanPrefix}/${cleanSub}`; +} + +/** + * For a standalone `decorator` node (child of class_body / program), + * find the related `class_declaration` node that it decorates. In + * tree-sitter-typescript the decorator is placed before the class + * declaration as a sibling (when decorating a class) or inside the + * class_body before a method_definition (when decorating a method); + * we walk the parent chain until we find the enclosing class. + */ +function findDecoratedClass(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null { + const parent = decoratorNode.parent; + if (!parent) return null; + // Case 1: decorator is a sibling of the class_declaration at program / + // export_statement level. Walk forward through siblings until we find + // the class_declaration this decorator belongs to. + for (let i = 0; i < parent.namedChildCount; i++) { + const child = parent.namedChild(i); + if (child && child.id === decoratorNode.id) { + for (let j = i + 1; j < parent.namedChildCount; j++) { + const next = parent.namedChild(j); + if (!next) continue; + if (next.type === 'decorator') continue; // adjacent decorators stack + if (next.type === 'class_declaration') return next; + if (next.type === 'export_statement') { + // `export class Foo { ... }` wraps the declaration. + for (let k = 0; k < next.namedChildCount; k++) { + const inner = next.namedChild(k); + if (inner?.type === 'class_declaration') return inner; + } + } + break; + } + break; + } + } + // Case 2: decorator is inside a class_body (decorating a method) — + // walk up to the enclosing class_declaration. + return findEnclosingClass(decoratorNode); +} + +/** + * For a method-level decorator node (child of class_body before a + * method_definition), find the method_definition it decorates. + */ +function findDecoratedMethod(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null { + const parent = decoratorNode.parent; + if (!parent || parent.type !== 'class_body') return null; + for (let i = 0; i < parent.namedChildCount; i++) { + const child = parent.namedChild(i); + if (child && child.id === decoratorNode.id) { + for (let j = i + 1; j < parent.namedChildCount; j++) { + const next = parent.namedChild(j); + if (!next) continue; + if (next.type === 'decorator') continue; + if (next.type === 'method_definition') return next; + return null; + } + return null; + } + } + return null; +} + +function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection[] { + const out: HttpDetection[] = []; + + // NestJS: collect `@Controller('prefix')` class decorators, keyed by + // the `class_declaration` they decorate. + const prefixByClassId = new Map(); + for (const match of runCompiledPatterns(bundle.controller, tree)) { + const prefixNode = match.captures.prefix; + const decoratorNode = match.captures.ctrl_decorator; + if (!prefixNode || !decoratorNode) continue; + const prefix = unquoteLiteral(prefixNode.text); + if (prefix === null) continue; + const classNode = findDecoratedClass(decoratorNode); + if (!classNode) continue; + prefixByClassId.set(classNode.id, prefix); + } + + // NestJS: method-level @Get/@Post/... decorators. The decorator's + // arguments list may be empty (`@Get()`), a string (`@Get('path')`), + // or something else (which we skip). + for (const match of runCompiledPatterns(bundle.methodDecorator, tree)) { + const decNode = match.captures.dec; + const argsNode = match.captures.args; + const decoratorNode = match.captures.method_decorator; + if (!decNode || !argsNode || !decoratorNode) continue; + const httpMethod = NEST_DECORATOR_TO_HTTP[decNode.text]; + if (!httpMethod) continue; + const methodNode = findDecoratedMethod(decoratorNode); + if (!methodNode) continue; + const enclosingClass = findEnclosingClass(methodNode); + // Only emit NestJS detections when the class actually has a + // @Controller decorator — without it, the match is almost certainly + // something else (e.g. an unrelated library using similar names). + if (!enclosingClass || !prefixByClassId.has(enclosingClass.id)) continue; + const prefix = prefixByClassId.get(enclosingClass.id) ?? ''; + + let rawPath = '/'; + const firstArg = argsNode.namedChild(0); + if (firstArg && (firstArg.type === 'string' || firstArg.type === 'template_string')) { + const unquoted = unquoteLiteral(firstArg.text); + if (unquoted !== null) rawPath = unquoted; + } + + // Get the method name from the decorated method_definition. + const methodNameNode = methodNode.childForFieldName('name'); + const name = methodNameNode?.text ?? null; + + out.push({ + role: 'provider', + framework: 'nest', + method: httpMethod, + path: joinPath(prefix, rawPath), + name, + confidence: 0.8, + }); + } + + // Express: router/app.(...) + for (const match of runCompiledPatterns(bundle.express, tree)) { + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'provider', + framework: 'express', + method: methodNode.text.toUpperCase(), + path, + name: 'handler', + confidence: 0.8, + }); + } + + // Consumer: fetch with options { method: 'X' } + const fetchSeen = new Set(); + for (const match of runCompiledPatterns(bundle.fetchWithOptions, tree)) { + const pathNode = match.captures.path; + const methodNode = match.captures.http_method; + if (!pathNode || !methodNode) continue; + const path = unquoteLiteral(pathNode.text); + const method = unquoteLiteral(methodNode.text); + if (path === null || method === null) continue; + fetchSeen.add(pathNode.id); + out.push({ + role: 'consumer', + framework: 'fetch', + method: method.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + // Consumer: plain fetch(path) — default GET. Skip path nodes we already + // matched with the options variant so we don't double-emit. + for (const match of runCompiledPatterns(bundle.fetchNoOptions, tree)) { + const pathNode = match.captures.path; + if (!pathNode) continue; + if (fetchSeen.has(pathNode.id)) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'fetch', + method: 'GET', + path, + name: null, + confidence: 0.7, + }); + } + + // Consumer: axios.(url) + for (const match of runCompiledPatterns(bundle.axios, tree)) { + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'axios', + method: methodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + return out; +} + +export const JAVASCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = { + name: 'javascript-http', + language: JavaScript, + scan: (tree) => scanBundle(JAVASCRIPT_BUNDLE, tree), +}; + +export const TYPESCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = { + name: 'typescript-http', + language: TypeScript.typescript, + scan: (tree) => scanBundle(TYPESCRIPT_BUNDLE, tree), +}; + +export const TSX_HTTP_PLUGIN: HttpLanguagePlugin = { + name: 'tsx-http', + language: TypeScript.tsx, + scan: (tree) => scanBundle(TSX_BUNDLE, tree), +}; diff --git a/gitnexus/src/core/group/extractors/http-patterns/php.ts b/gitnexus/src/core/group/extractors/http-patterns/php.ts new file mode 100644 index 000000000..ae91c141b --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/php.ts @@ -0,0 +1,79 @@ +import PHP from 'tree-sitter-php'; +import { + compilePatterns, + runCompiledPatterns, + unquoteLiteral, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { HttpDetection, HttpLanguagePlugin } from './types.js'; + +/** + * PHP HTTP plugin — Laravel `Route::get/post/...` declarations. + * + * The pipeline already uses `PHP.php_only` for ingesting plain `.php` + * files (see `core/tree-sitter/parser-loader.ts`), and we do the same + * here so Laravel route files are parsed with the right grammar dialect. + */ + +const LARAVEL_PATTERNS = compilePatterns({ + name: 'php-laravel', + language: PHP.php_only, + patterns: [ + { + meta: {}, + query: ` + (scoped_call_expression + scope: (name) @scope (#eq? @scope "Route") + name: (name) @method (#match? @method "^(get|post|put|delete|patch)$") + arguments: (arguments . (argument (string) @path))) + `, + }, + ], +} satisfies LanguagePatterns>); + +/** + * Extract the inner text of a PHP `string` node. The tree-sitter-php + * grammar wraps single / double-quoted literals differently depending + * on content; we try both the raw `text` (with quotes) through + * `unquoteLiteral`, and a fallback via the `string_value` / `string_content` + * child nodes. + */ +function phpStringText(node: import('tree-sitter').SyntaxNode): string | null { + // Most single-quoted strings expose their inner content through the + // full node text (including quotes), which unquoteLiteral strips. + const direct = unquoteLiteral(node.text); + if (direct !== null && direct !== node.text) return direct; + // Fall back to child string_content / string_value node if present. + for (const child of node.children) { + if (child.type === 'string_content' || child.type === 'string_value') { + return child.text; + } + } + return direct; +} + +export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = { + name: 'php-http', + language: PHP.php_only, + scan(tree) { + const out: HttpDetection[] = []; + + for (const match of runCompiledPatterns(LARAVEL_PATTERNS, tree)) { + const methodNode = match.captures.method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const path = phpStringText(pathNode); + if (path === null) continue; + out.push({ + role: 'provider', + framework: 'laravel', + method: methodNode.text.toUpperCase(), + path, + name: 'route', + confidence: 0.8, + }); + } + + return out; + }, +}; diff --git a/gitnexus/src/core/group/extractors/http-patterns/python.ts b/gitnexus/src/core/group/extractors/http-patterns/python.ts new file mode 100644 index 000000000..27ddf6633 --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/python.ts @@ -0,0 +1,142 @@ +import Python from 'tree-sitter-python'; +import { + compilePatterns, + runCompiledPatterns, + unquoteLiteral, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { HttpDetection, HttpLanguagePlugin } from './types.js'; + +/** + * Python HTTP plugin. Handles: + * - FastAPI `@app.get("/path")` provider decorators + * - `requests.get/post/...("url")` consumer calls + * - Generic `requests.request("METHOD", "url")` consumer calls + */ + +const FASTAPI_VERBS: Record = { + get: 'GET', + post: 'POST', + put: 'PUT', + delete: 'DELETE', + patch: 'PATCH', +}; + +// ─── Provider: FastAPI @app.get/... ────────────────────────────────── +const FASTAPI_PATTERNS = compilePatterns({ + name: 'python-fastapi', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (decorator + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "app") + attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$")) + arguments: (argument_list . (string) @path))) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: requests.get/post/... ────────────────────────────────── +const REQUESTS_VERB_PATTERNS = compilePatterns({ + name: 'python-requests-verb', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "requests") + attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$")) + arguments: (argument_list . (string) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: requests.request("METHOD", "url") ───────────────────── +const REQUESTS_GENERIC_PATTERNS = compilePatterns({ + name: 'python-requests-generic', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "requests") + attribute: (identifier) @method (#eq? @method "request")) + arguments: (argument_list . (string) @http_method (string) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { + name: 'python-http', + language: Python, + scan(tree) { + const out: HttpDetection[] = []; + + // Providers: FastAPI + for (const match of runCompiledPatterns(FASTAPI_PATTERNS, tree)) { + const methodNode = match.captures.method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const httpMethod = FASTAPI_VERBS[methodNode.text]; + if (!httpMethod) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'provider', + framework: 'fastapi', + method: httpMethod, + path, + name: null, + confidence: 0.8, + }); + } + + // Consumers: requests. + for (const match of runCompiledPatterns(REQUESTS_VERB_PATTERNS, tree)) { + const methodNode = match.captures.method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'python-requests', + method: methodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + // Consumers: requests.request("METHOD", "url") + for (const match of runCompiledPatterns(REQUESTS_GENERIC_PATTERNS, tree)) { + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const methodRaw = unquoteLiteral(methodNode.text); + const path = unquoteLiteral(pathNode.text); + if (methodRaw === null || path === null) continue; + out.push({ + role: 'consumer', + framework: 'python-requests', + method: methodRaw.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + return out; + }, +}; diff --git a/gitnexus/src/core/group/extractors/http-patterns/types.ts b/gitnexus/src/core/group/extractors/http-patterns/types.ts new file mode 100644 index 000000000..6df0ede28 --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/types.ts @@ -0,0 +1,65 @@ +import type Parser from 'tree-sitter'; + +/** + * Shared types for the http-route-extractor language plugins. + * + * Each plugin lives in its own file (java.ts, node.ts, ...) and owns + * the tree-sitter grammar import + queries. The top-level + * `http-route-extractor.ts` orchestrator only knows about this type + * module and the plugin registry (`./index.ts`). It MUST NOT import + * any grammar or query text directly — language-specific knowledge + * belongs in the plugins. + */ + +export type HttpRole = 'provider' | 'consumer'; + +/** + * One raw HTTP detection produced by a plugin's `scan()` function. The + * orchestrator converts this into a full `ExtractedContract` by running + * path normalization and building the contract id. + * + * `path` is the raw literal string as it appeared in source (with + * `${...}` template placeholders still in place); the orchestrator + * runs the appropriate normalizer for provider vs. consumer paths. + */ +export interface HttpDetection { + role: HttpRole; + /** Short framework label, e.g. `'spring'`, `'nest'`, `'express'`. */ + framework: string; + /** HTTP method in upper case (`'GET'`, `'POST'`, ...). */ + method: string; + /** Raw path literal as seen in source (template placeholders intact). */ + path: string; + /** + * Symbol name of the handler (for providers) or calling function + * (for consumers) when the plugin can determine it structurally. + * Null when no good candidate is available. + */ + name: string | null; + /** Confidence in (0, 1]. Source-scan plugins typically use 0.7–0.8. */ + confidence: number; +} + +/** + * One language-scoped HTTP plugin. The plugin owns the tree-sitter + * grammar and the `scan` function that translates a parsed tree into + * zero or more `HttpDetection`s. Plugins are free to run multiple + * compiled pattern bundles internally (see the shared scanner's + * `runCompiledPatterns` helper). + * + * `language` is typed as `unknown` for the same reason as + * `LanguagePatterns.language` in `tree-sitter-scanner.ts` — the + * grammar modules export different shapes. + */ +export interface HttpLanguagePlugin { + /** Human-readable plugin name for diagnostics. */ + name: string; + /** tree-sitter grammar object (passed to the shared parser). */ + language: unknown; + /** + * Scan a parsed tree and return zero or more HTTP detections. Plugins + * must not throw — they should swallow per-match errors so a single + * malformed construct does not abort the whole file. + */ + scan(tree: Parser.Tree): HttpDetection[]; +} diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index ebb4c668d..0b07090e1 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -1,8 +1,34 @@ -import * as fs from 'node:fs'; import * as path from 'node:path'; import { glob } from 'glob'; +import Parser from 'tree-sitter'; import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; import type { ExtractedContract, RepoHandle } from '../types.js'; +import { readSafe } from './fs-utils.js'; +import { getPluginForFile, HTTP_SCAN_GLOB, type HttpDetection } from './http-patterns/index.js'; + +/** + * Language-agnostic orchestrator for HTTP route (provider + consumer) + * contract extraction. Two strategies, in order of preference per role: + * + * 1. **Graph-assisted (Strategy A)** — if a per-repo LadybugDB executor + * is available, read `HANDLES_ROUTE` / `FETCHES` Cypher edges that + * the ingestion pipeline already produced via tree-sitter. This is + * the preferred path because the graph has richer symbol metadata + * (real uids, class/method structure, etc.). + * + * 2. **Source-scan fallback (Strategy B)** — parse files directly with + * the per-language plugin registry in `./http-patterns/`. Used when + * the graph has no routes/fetches for this repo (e.g. a repo that + * hasn't been indexed yet, or whose indexer doesn't know the + * framework). Each plugin owns its tree-sitter grammar and query + * sources — this orchestrator imports NO grammars or query strings. + * + * Adding a new language for Strategy B is a one-file edit in + * `http-patterns/index.ts`: register a new `HttpLanguagePlugin` and + * widen `HTTP_SCAN_GLOB` if needed. + */ + +// ─── Graph-assisted queries ────────────────────────────────────────── const HANDLES_ROUTE_QUERY = ` MATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route) @@ -23,14 +49,56 @@ WHERE sym.startLine IS NOT NULL RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath, labels(sym) AS labels ORDER BY sym.startLine`; +// ─── Path normalization (shared between provider / consumer paths) ── + +/** + * Canonicalize a provider-side HTTP path for contract-id generation: + * - strip query string + * - lower-case + * - drop trailing slash + * - collapse `:id`, `{id}`, `[id]` path params into a single `{param}` + */ export function normalizeHttpPath(p: string): string { let s = p.trim().split('?')[0].toLowerCase().replace(/\/+$/, ''); s = s.replace(/:\w+/g, '{param}'); s = s.replace(/\{[^}]+\}/g, '{param}'); s = s.replace(/\[[^\]]+\]/g, '{param}'); - return s; + // Preserve root: after stripping trailing slashes, the root "/" + // collapses to "" which would produce malformed contract ids like + // `http::GET::`. Restore a single slash for the root case. + return s === '' ? '/' : s; } +/** + * Consumer-side normalization is more aggressive: + * - template literals (`${x}`) → `{param}` + * - strip protocol + host if the URL is absolute + * - numeric segments → `{param}` (so `/api/orders/42` → `/api/orders/{param}`) + */ +function normalizeConsumerPath(url: string): string { + const templated = url.replace(/\$\{[^}]+\}/g, '{param}').trim(); + let pathOnly = templated; + if (/^https?:\/\//i.test(templated)) { + try { + pathOnly = new URL(templated).pathname; + } catch { + pathOnly = templated.replace(/^https?:\/\/[^/]+/i, ''); + } + } + const normalized = normalizeHttpPath(pathOnly || '/'); + const segments = normalized + .split('/') + .filter(Boolean) + .map((segment) => (/^\d+$/.test(segment) ? '{param}' : segment)); + return `/${segments.join('/')}`.replace(/\/+$/, '') || '/'; +} + +function contractIdFor(method: string, pathNorm: string): string { + return `http::${method.toUpperCase()}::${pathNorm}`; +} + +// ─── Graph row helpers ─────────────────────────────────────────────── + function methodFromRouteReason(reason: string): string | null { const r = reason || ''; if (/GetMapping|decorator-Get/i.test(r)) return 'GET'; @@ -41,50 +109,6 @@ function methodFromRouteReason(reason: string): string | null { return null; } -function contractIdFor(method: string, pathNorm: string): string { - return `http::${method.toUpperCase()}::${pathNorm}`; -} - -function readSafe(repoPath: string, rel: string): string | null { - const abs = path.resolve(repoPath, rel); - const base = path.resolve(repoPath); - const relToBase = path.relative(base, abs); - if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null; - try { - return fs.readFileSync(abs, 'utf-8'); - } catch { - return null; - } -} - -function pickJavaHandlerName( - content: string, - routePath: string, - httpMethod: string, -): string | null { - const tail = routePath.split('/').filter(Boolean).pop() || ''; - const mapNames: Record = { - GET: 'GetMapping', - POST: 'PostMapping', - PUT: 'PutMapping', - DELETE: 'DeleteMapping', - PATCH: 'PatchMapping', - }; - const ann = mapNames[httpMethod] || 'GetMapping'; - const lines = content.split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (!line.includes(`@${ann}`)) continue; - if (!line.includes(`"${tail}"`) && !line.includes(`'${tail}'`) && tail && !line.includes(tail)) - continue; - for (let j = i + 1; j < Math.min(i + 8, lines.length); j++) { - const m = lines[j].match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/); - if (m) return m[1]; - } - } - return null; -} - function pickSymbolUid( rows: Record[], preferredName: string | null, @@ -114,6 +138,8 @@ function pickSymbolUid( }; } +// ─── Orchestrator ──────────────────────────────────────────────────── + export class HttpRouteExtractor implements ContractExtractor { type = 'http' as const; @@ -124,20 +150,76 @@ export class HttpRouteExtractor implements ContractExtractor { async extract( dbExecutor: CypherExecutor | null, repoPath: string, - repo: RepoHandle, + _repo: RepoHandle, ): Promise { - const graphP = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, repoPath) : []; - const providers = graphP.length > 0 ? graphP : await this.extractProvidersSourceScan(repoPath); + // Parse each file at most once and reuse the plugin results across + // both graph-assisted enrichment and source-scan emission. + const parser = new Parser(); + const cachedDetections = new Map(); + const getDetections = (rel: string): HttpDetection[] => { + const cached = cachedDetections.get(rel); + if (cached) return cached; + const plugin = getPluginForFile(rel); + if (!plugin) { + cachedDetections.set(rel, []); + return []; + } + const content = readSafe(repoPath, rel); + if (!content) { + cachedDetections.set(rel, []); + return []; + } + try { + parser.setLanguage(plugin.language); + const tree = parser.parse(content); + const detections = plugin.scan(tree); + cachedDetections.set(rel, detections); + return detections; + } catch { + cachedDetections.set(rel, []); + return []; + } + }; - const graphC = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, repoPath) : []; - const consumers = graphC.length > 0 ? graphC : await this.extractConsumersSourceScan(repoPath); + // Glob the source-scan file list at most once per extract() — + // both provider and consumer fallback paths share the same list. + let scannedFiles: string[] | null = null; + const getScannedFiles = async (): Promise => { + if (scannedFiles) return scannedFiles; + scannedFiles = await this.scanFiles(repoPath); + return scannedFiles; + }; + + const graphProviders = + dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, getDetections) : []; + const providers = + graphProviders.length > 0 + ? graphProviders + : this.extractProvidersSourceScan(await getScannedFiles(), getDetections); + + const graphConsumers = + dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : []; + const consumers = + graphConsumers.length > 0 + ? graphConsumers + : this.extractConsumersSourceScan(await getScannedFiles(), getDetections); return [...providers, ...consumers]; } + private async scanFiles(repoPath: string): Promise { + return glob(HTTP_SCAN_GLOB, { + cwd: repoPath, + ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/vendor/**'], + nodir: true, + }); + } + + // ─── Graph-assisted providers ────────────────────────────────────── + private async extractProvidersGraph( db: CypherExecutor, - repoPath: string, + getDetections: (rel: string) => HttpDetection[], ): Promise { const out: ExtractedContract[] = []; let rows: Record[]; @@ -152,16 +234,26 @@ export class HttpRouteExtractor implements ContractExtractor { const routePath = String(row.routePath ?? ''); const routeSource = String(row.routeSource ?? row.routeReason ?? ''); let method = methodFromRouteReason(routeSource); - const content = readSafe(repoPath, filePath); - if (!method && content) { - method = this.inferMethodFromFileScan(content, routePath, 'provider'); + + // Look up handler name (and backfill method if missing) from the + // plugin's scan of the handler file. This replaces the old + // regex-based `inferMethodFromFileScan` and `pickJavaHandlerName` + // helpers — tree-sitter gives both pieces of information + // structurally. Always run the lookup: even when method is set by + // `methodFromRouteReason`, we still need the handler name. + const detections = filePath ? getDetections(filePath) : []; + const providerDetections = detections.filter((d) => d.role === 'provider'); + let handlerName: string | null = null; + const normalizedRoute = normalizeHttpPath(routePath); + const match = providerDetections.find((d) => normalizeHttpPath(d.path) === normalizedRoute); + if (match) { + if (!method) method = match.method; + handlerName = match.name; } if (!method) method = 'GET'; const pathNorm = normalizeHttpPath(routePath); const cid = contractIdFor(method, pathNorm); - const handlerName = - content && routePath ? pickJavaHandlerName(content, routePath, method) : null; let symbolUid = ''; let symbolName = path.basename(filePath) || 'handler'; @@ -201,157 +293,44 @@ export class HttpRouteExtractor implements ContractExtractor { return out; } - private inferMethodFromFileScan( - content: string, - routePath: string, - _role: string, - ): string | null { - const tail = routePath.split('/').filter(Boolean).pop() || ''; - for (const m of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const) { - const mapNames: Record = { - GET: 'GetMapping', - POST: 'PostMapping', - PUT: 'PutMapping', - DELETE: 'DeleteMapping', - PATCH: 'PatchMapping', - }; - if ( - content.includes(`@${mapNames[m]}`) && - (content.includes(tail) || routePath.includes(tail)) - ) { - return m; - } - } - return null; - } + // ─── Source-scan providers ───────────────────────────────────────── - private async extractProvidersSourceScan(repoPath: string): Promise { - const files = await glob('**/*.{ts,tsx,js,jsx,java,vue,svelte,php,py}', { - cwd: repoPath, - ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'], - nodir: true, - }); + private extractProvidersSourceScan( + files: string[], + getDetections: (rel: string) => HttpDetection[], + ): ExtractedContract[] { const out: ExtractedContract[] = []; for (const rel of files) { - const content = readSafe(repoPath, rel); - if (!content) continue; - out.push(...this.scanSpringProviders(content, rel)); - out.push(...this.scanExpressProviders(content, rel)); - out.push(...this.scanLaravelProviders(content, rel)); - out.push(...this.scanFastApiProviders(content, rel)); + const detections = getDetections(rel); + for (const d of detections) { + if (d.role !== 'provider') continue; + const pathNorm = normalizeHttpPath(d.path); + out.push({ + contractId: contractIdFor(d.method, pathNorm), + type: 'http', + role: 'provider', + symbolUid: '', + symbolRef: { filePath: rel, name: d.name ?? 'handler' }, + symbolName: d.name ?? 'handler', + confidence: d.confidence, + meta: { + method: d.method, + path: pathNorm, + pathSegments: pathNorm.split('/').filter(Boolean), + extractionStrategy: 'source_scan', + framework: d.framework, + }, + }); + } } return this.dedupeContracts(out); } - private dedupeContracts(items: ExtractedContract[]): ExtractedContract[] { - const seen = new Set(); - const out: ExtractedContract[] = []; - for (const c of items) { - const k = `${c.contractId}|${c.symbolRef.filePath}|${c.symbolRef.name}`; - if (seen.has(k)) continue; - seen.add(k); - out.push(c); - } - return out; - } - - private scanSpringProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - - // Skip Feign/client interfaces — annotated methods in interfaces are - // consumers (Feign, JAX-RS proxies), not provider endpoints. - // Anchored to line start (with optional access modifier) so we do not - // match "interface" inside comments or string literals. - if ( - /^\s*(?:public\s+)?interface\s+\w+/m.test(content) && - !/@(?:Rest)?Controller\b/.test(content) - ) { - return out; - } - - let classPrefix = ''; - const classRm = content.match(/@RequestMapping\s*\(\s*"([^"]+)"/); - if (classRm) classPrefix = classRm[1].replace(/\/+$/, ''); - - const re = /@(Get|Post|Put|Delete|Patch)Mapping\s*\(\s*"([^"]+)"/gi; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const method = m[1].toUpperCase(); - let p = m[2]; - if (classPrefix) p = `${classPrefix}/${p.replace(/^\//, '')}`; - const pathNorm = normalizeHttpPath(p); - const sub = content.slice(m.index); - const nameM = sub.match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/); - const name = nameM ? nameM[1] : m[0]; - out.push(this.makeProvider(filePath, method, pathNorm, name, 0.8)); - } - return out; - } - - private scanExpressProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - const re = /(?:router|app)\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const method = m[1].toUpperCase(); - const pathNorm = normalizeHttpPath(m[2]); - out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8)); - } - return out; - } - - private scanLaravelProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - const re = /Route::(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const method = m[1].toUpperCase(); - const pathNorm = normalizeHttpPath(m[2]); - out.push(this.makeProvider(filePath, method, pathNorm, 'route', 0.8)); - } - return out; - } - - private scanFastApiProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - const re = /@app\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const method = m[1].toUpperCase(); - const pathNorm = normalizeHttpPath(m[2]); - out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8)); - } - return out; - } - - private makeProvider( - filePath: string, - method: string, - pathNorm: string, - name: string, - confidence: number, - ): ExtractedContract { - const cid = contractIdFor(method, pathNorm); - return { - contractId: cid, - type: 'http', - role: 'provider', - symbolUid: '', - symbolRef: { filePath, name }, - symbolName: name, - confidence, - meta: { - method, - path: pathNorm, - pathSegments: pathNorm.split('/').filter(Boolean), - extractionStrategy: 'source_scan', - }, - }; - } + // ─── Graph-assisted consumers ────────────────────────────────────── private async extractConsumersGraph( db: CypherExecutor, - repoPath: string, + getDetections: (rel: string) => HttpDetection[], ): Promise { const out: ExtractedContract[] = []; let rows: Record[]; @@ -365,11 +344,14 @@ export class HttpRouteExtractor implements ContractExtractor { const routePath = String(row.routePath ?? ''); const pathNorm = normalizeHttpPath(routePath); let method = 'GET'; - const content = readSafe(repoPath, filePath); - if (content) { - const inferred = this.inferFetchMethod(content, pathNorm); - if (inferred) method = inferred; - } + // Prefer the plugin's detected method if we can find a matching + // fetch/axios call in the same file. + const detections = filePath ? getDetections(filePath) : []; + const inferred = detections.find( + (d) => d.role === 'consumer' && normalizeConsumerPath(d.path) === pathNorm, + ); + if (inferred) method = inferred.method; + const cid = contractIdFor(method, pathNorm); let symbolUid = ''; let symbolName = 'fetch'; @@ -407,81 +389,47 @@ export class HttpRouteExtractor implements ContractExtractor { return out; } - private inferFetchMethod(content: string, pathNorm: string): string | null { - const esc = pathNorm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const fetchRe = new RegExp( - `fetch\\s*\\(\\s*['"\`]([^'"\`]*${esc}[^'"\`]*)['"\`]\\s*,\\s*\\{[^}]*method:\\s*['"](\\w+)['"]`, - 'i', - ); - const m = content.match(fetchRe); - if (m) return m[2].toUpperCase(); - return null; - } + // ─── Source-scan consumers ───────────────────────────────────────── - private async extractConsumersSourceScan(repoPath: string): Promise { - const files = await glob('**/*.{ts,tsx,js,jsx,vue,svelte}', { - cwd: repoPath, - ignore: ['**/node_modules/**', '**/.git/**'], - nodir: true, - }); + private extractConsumersSourceScan( + files: string[], + getDetections: (rel: string) => HttpDetection[], + ): ExtractedContract[] { const out: ExtractedContract[] = []; for (const rel of files) { - const content = readSafe(repoPath, rel); - if (!content) continue; - out.push(...this.scanFetchConsumers(content, rel)); - out.push(...this.scanAxiosConsumers(content, rel)); + const detections = getDetections(rel); + for (const d of detections) { + if (d.role !== 'consumer') continue; + const pathNorm = normalizeConsumerPath(d.path); + out.push({ + contractId: contractIdFor(d.method, pathNorm), + type: 'http', + role: 'consumer', + symbolUid: '', + symbolRef: { filePath: rel, name: 'fetch' }, + symbolName: 'fetch', + confidence: d.confidence, + meta: { + method: d.method, + path: pathNorm, + extractionStrategy: 'source_scan', + framework: d.framework, + }, + }); + } } return this.dedupeContracts(out); } - private scanFetchConsumers(content: string, filePath: string): ExtractedContract[] { + private dedupeContracts(items: ExtractedContract[]): ExtractedContract[] { + const seen = new Set(); const out: ExtractedContract[] = []; - const re = - /fetch\s*\(\s*['"`]([^'"`]+)['"`](?:\s*,\s*\{[^}]*method:\s*['"](\w+)['"][^}]*\})?\s*\)/gi; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const pathNorm = normalizeHttpPath(this.templateToPattern(m[1])); - const method = (m[2] || 'GET').toUpperCase(); - out.push(this.makeConsumer(filePath, method, pathNorm, 0.7)); + for (const c of items) { + const k = `${c.contractId}|${c.symbolRef.filePath}|${c.symbolRef.name}`; + if (seen.has(k)) continue; + seen.add(k); + out.push(c); } return out; } - - private templateToPattern(url: string): string { - return url.replace(/\$\{[^}]+\}/g, '{param}'); - } - - private scanAxiosConsumers(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - const re = /axios\.(get|post|put|delete|patch)\s*\(\s*[`'"]([^`'"]+)[`'"]/gi; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const method = m[1].toUpperCase(); - const pathNorm = normalizeHttpPath(this.templateToPattern(m[2])); - out.push(this.makeConsumer(filePath, method, pathNorm, 0.7)); - } - return out; - } - - private makeConsumer( - filePath: string, - method: string, - pathNorm: string, - confidence: number, - ): ExtractedContract { - return { - contractId: contractIdFor(method, pathNorm), - type: 'http', - role: 'consumer', - symbolUid: '', - symbolRef: { filePath, name: 'fetch' }, - symbolName: 'fetch', - confidence, - meta: { - method, - path: pathNorm, - extractionStrategy: 'source_scan', - }, - }; - } } diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts new file mode 100644 index 000000000..29c8f9b21 --- /dev/null +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -0,0 +1,268 @@ +import type { ContractType, CrossLink, GroupManifestLink, StoredContract } from '../types.js'; +import type { CypherExecutor } from '../contract-extractor.js'; + +export interface ManifestExtractResult { + contracts: StoredContract[]; + crossLinks: CrossLink[]; +} + +/** + * Canonicalize an HTTP path for matching against Route.name in the graph. + * Mirrors core/ingestion/pipeline.ts ensureSlash semantics: + * - Ensures a leading slash. + * - Strips trailing slashes (except the root "/"). + * - Normalizes consecutive slashes. + * - Does NOT lowercase (route matching is case-sensitive). + */ +function normalizeRoutePath(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return '/'; + const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + const collapsed = withLeading.replace(/\/+/g, '/'); + if (collapsed === '/') return '/'; + return collapsed.replace(/\/+$/, ''); +} + +/** + * Stable synthetic symbolUid for a manifest-declared contract whose target + * symbol could not be resolved against the per-repo graph (resolveSymbol + * returned null). Two reasons we don't leave the uid empty: + * + * 1. The bridge stores Contract nodes keyed in part by symbolUid; an empty + * uid means downstream Cypher queries that anchor on `provider.symbolUid` + * can't tell two different unresolved manifest contracts apart. + * 2. The cross-impact bridge query in cross-impact.ts joins local impact + * results to bridge contracts via `WHERE provider.symbolUid IN $localUids`. + * If the local impact engine produces a deterministic identifier for the + * unresolved target, it must agree with the value the bridge stored. A + * synthetic uid keyed off (repo, contractId) is the only thing both sides + * can derive without knowing about each other. + * + * Format: `manifest::::`. Stable across syncs, scoped to a + * single repo within a group, and never collides with real indexer uids + * (which never start with `manifest::`). + */ +export function manifestSymbolUid(repo: string, contractId: string): string { + return `manifest::${repo}::${contractId}`; +} + +export class ManifestExtractor { + async extractFromManifest( + links: GroupManifestLink[], + dbExecutors?: Map, + ): Promise { + const contracts: StoredContract[] = []; + const crossLinks: CrossLink[] = []; + + for (const link of links) { + const contractId = this.buildContractId(link.type, link.contract); + + const providerRepo = link.role === 'provider' ? link.from : link.to; + const consumerRepo = link.role === 'provider' ? link.to : link.from; + + const providerSymbol = await this.resolveSymbol(providerRepo, link, dbExecutors); + const consumerSymbol = await this.resolveSymbol(consumerRepo, link, dbExecutors); + const providerRef = providerSymbol || { filePath: '', name: link.contract }; + const consumerRef = consumerSymbol || { filePath: '', name: link.contract }; + // When the resolver finds a real graph symbol we keep its uid, otherwise + // fall back to the deterministic synthetic uid (see manifestSymbolUid). + const providerUid = providerSymbol?.uid || manifestSymbolUid(providerRepo, contractId); + const consumerUid = consumerSymbol?.uid || manifestSymbolUid(consumerRepo, contractId); + + contracts.push({ + contractId, + type: link.type, + role: 'provider', + symbolUid: providerUid, + symbolRef: providerRef, + symbolName: link.contract, + confidence: 1.0, + meta: { source: 'manifest' }, + repo: providerRepo, + }); + + contracts.push({ + contractId, + type: link.type, + role: 'consumer', + symbolUid: consumerUid, + symbolRef: consumerRef, + symbolName: link.contract, + confidence: 1.0, + meta: { source: 'manifest' }, + repo: consumerRepo, + }); + + crossLinks.push({ + from: { repo: consumerRepo, symbolUid: consumerUid, symbolRef: consumerRef }, + to: { repo: providerRepo, symbolUid: providerUid, symbolRef: providerRef }, + type: link.type, + contractId, + matchType: 'manifest', + confidence: 1.0, + }); + } + + return { contracts, crossLinks }; + } + + private async resolveSymbol( + repoPathKey: string, + link: GroupManifestLink, + dbExecutors?: Map, + ): Promise<{ filePath: string; name: string; uid: string } | null> { + const executor = dbExecutors?.get(repoPathKey); + if (!executor) return null; + + // NOTE: All lookups use EXACT equality on the relevant name field and + // deterministic ORDER BY before LIMIT 1. Previous versions used CONTAINS + // for fuzzy matching (plus an unconditional ".proto" fallback for gRPC) + // which produced silent false positives: e.g. manifest "/orders" would + // match "/suborders", and a gRPC manifest entry in a repo with any + // .proto file would attach to a random proto symbol. + // + // If resolveSymbol returns null, the extractor falls back to a + // deterministic synthetic uid via `manifestSymbolUid(repo, contractId)` + // (see the function's docstring for why synthetic rather than empty). + // Cross-impact still works: the bridge query joins on the synthetic + // uid, and the local impact engine derives the same uid for the + // unresolved symbol — name-based hints are the additional safety net. + try { + let rows: Record[]; + if (link.type === 'http') { + // Route.name is the canonicalized URL path (see + // core/ingestion/pipeline.ts ensureSlash + generateId('Route', ...)). + // Normalize the manifest contract the same way so a user-written + // "/api/orders" matches "api/orders" in the graph. + const normalized = normalizeRoutePath(link.contract); + rows = await executor( + `MATCH (handler)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route) + WHERE route.name = $normalized + RETURN handler.id AS uid, handler.name AS name, handler.filePath AS filePath + ORDER BY handler.filePath ASC + LIMIT 1`, + { normalized }, + ); + } else if (link.type === 'topic') { + // Topic names aren't a first-class NodeLabel in the graph — + // topics are referenced by function/method symbols (Kafka + // listeners, publishers). Restrict to symbol-like labels to + // avoid cross-matching Files/Variables/Imports that happen to + // share the topic name. + rows = await executor( + `MATCH (n:Function|Method|Class|Interface) WHERE n.name = $contract + RETURN n.id AS uid, n.name AS name, n.filePath AS filePath + ORDER BY n.filePath ASC + LIMIT 1`, + { contract: link.contract }, + ); + } else if (link.type === 'grpc') { + // Contract is "Service/Method" or just "Service" (or package.Service + // variants). Prefer matching by method name when present, otherwise + // by service name. NO .proto path fallback — that's guaranteed to + // return a wrong symbol in any repo with more than one proto file. + // Label filters scope lookups: methods → Function|Method, services + // → Class|Interface (no label match = no silent wrong hits on + // File/Variable nodes that happen to share the name). + const parts = link.contract.split('/'); + const serviceName = parts[0]?.trim() ?? ''; + const methodName = parts[1]?.trim() ?? ''; + if (methodName) { + rows = await executor( + `MATCH (n:Function|Method) WHERE n.name = $methodName + RETURN n.id AS uid, n.name AS name, n.filePath AS filePath + ORDER BY n.filePath ASC + LIMIT 1`, + { methodName }, + ); + } else if (serviceName) { + rows = await executor( + `MATCH (n:Class|Interface) WHERE n.name = $serviceName + RETURN n.id AS uid, n.name AS name, n.filePath AS filePath + ORDER BY n.filePath ASC + LIMIT 1`, + { serviceName }, + ); + } else { + rows = []; + } + } else if (link.type === 'lib') { + // Only exact match on the symbol's name. Previous fallback to + // CONTAINS on n.filePath would promote "react" to "react-native" + // or "@types/react" — silent wrong attribution. Restrict to + // package-level labels so we don't return arbitrary symbols + // named after a library. + rows = await executor( + `MATCH (n:Package|Module) WHERE n.name = $contract + RETURN n.id AS uid, n.name AS name, n.filePath AS filePath + ORDER BY n.filePath ASC + LIMIT 1`, + { contract: link.contract }, + ); + } else { + return null; + } + if (rows.length > 0) { + return { + filePath: rows[0].filePath as string, + name: rows[0].name as string, + uid: String(rows[0].uid ?? ''), + }; + } + } catch (err) { + // Log but don't throw: a broken graph query in one repo shouldn't + // fail the whole manifest extraction. Unresolved contracts still + // get a synthetic symbolUid below, so cross-impact can proceed. + const message = err instanceof Error ? err.message : String(err); + console.warn( + `[manifest-extractor] resolveSymbol failed for ${link.type}:${link.contract} ` + + `in ${repoPathKey}: ${message}`, + ); + } + return null; + } + + /** + * Build a canonical contract id for a manifest link. + * + * HTTP is the only type with two valid forms: + * - Explicit method: `"GET::/api/orders"` → `"http::GET::/api/orders"` + * (matches exactly against `HttpRouteExtractor` provider/consumer + * contracts, which are also keyed by `http::::`). + * - Method-agnostic: `"/api/orders"` → `"http::*::/api/orders"` + * — the `*` is a wildcard and is intended to match any concrete + * HTTP method on that path. Wildcard-aware matching is the + * responsibility of the sync / cross-impact layer (see #793); + * downstream code should treat `http::*::` as matching + * every `http::::` for the same path. + * + * Recommend the explicit-method form in group.yaml whenever the + * manifest author knows the method — it round-trips through exact + * equality matching without requiring wildcard logic downstream. + * + * NOTE on exhaustiveness: the switch covers every current + * `ContractType` variant and falls through to a `never` assertion so + * TypeScript fails the build if a new variant is added without a + * corresponding case. + */ + private buildContractId(type: ContractType, contract: string): string { + switch (type) { + case 'http': { + if (/^[A-Za-z]+::/.test(contract)) return `http::${contract}`; + return `http::*::${contract}`; + } + case 'grpc': + return `grpc::${contract}`; + case 'topic': + return `topic::${contract}`; + case 'lib': + return `lib::${contract}`; + case 'custom': + return `custom::${contract}`; + default: { + const _exhaustive: never = type; + throw new Error(`Unhandled ContractType: ${String(_exhaustive)}`); + } + } + } +} diff --git a/gitnexus/src/core/group/extractors/topic-extractor.ts b/gitnexus/src/core/group/extractors/topic-extractor.ts index c27b419bb..1fbccac8a 100644 --- a/gitnexus/src/core/group/extractors/topic-extractor.ts +++ b/gitnexus/src/core/group/extractors/topic-extractor.ts @@ -1,214 +1,49 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; import { glob } from 'glob'; +import Parser from 'tree-sitter'; import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; import type { ExtractedContract, RepoHandle } from '../types.js'; +import { readSafe } from './fs-utils.js'; +import { scanFile, unquoteLiteral } from './tree-sitter-scanner.js'; +import { + TOPIC_SCAN_GLOB, + getProviderForFile, + type Broker, + type TopicMeta, +} from './topic-patterns/index.js'; -type Broker = 'kafka' | 'rabbitmq' | 'nats'; +/** + * Language-agnostic orchestrator for topic (message broker) contract + * extraction. All grammar-specific knowledge lives in `topic-patterns/*` + * — this file must not import any tree-sitter grammar directly. + * + * Flow per file: + * 1. `getProviderForFile(rel)` → compiled plugin (or `undefined` if the + * file's extension isn't registered, in which case we skip it). + * 2. `scanFile(parser, provider, content)` → list of `{meta, valueText}` + * pairs, one per matched literal. + * 3. `unquoteLiteral(valueText)` → the raw topic string. + * 4. `makeContract(topic, meta, relPath)` → `ExtractedContract`. + * + * Adding a new language is a one-file edit in `topic-patterns/index.ts`. + */ -function readSafe(repoPath: string, rel: string): string | null { - const abs = path.resolve(repoPath, rel); - const base = path.resolve(repoPath); - const relToBase = path.relative(base, abs); - if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null; - try { - return fs.readFileSync(abs, 'utf-8'); - } catch { - return null; - } -} - -function makeContract( - topicName: string, - role: 'provider' | 'consumer', - filePath: string, - symbolName: string, - confidence: number, - broker: Broker, -): ExtractedContract { +function makeContract(topicName: string, meta: TopicMeta, filePath: string): ExtractedContract { return { contractId: `topic::${topicName}`, type: 'topic', - role, + role: meta.role, symbolUid: '', - symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: symbolName }, - symbolName, - confidence, + symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: meta.symbolName }, + symbolName: meta.symbolName, + confidence: meta.confidence, meta: { - broker, + broker: meta.broker satisfies Broker, topicName, - extractionStrategy: 'source_scan', + extractionStrategy: 'tree_sitter', }, }; } -interface PatternDef { - regex: RegExp; - role: 'provider' | 'consumer'; - broker: Broker; - confidence: number; - topicGroup: number; - symbolName: string; -} - -// --- Kafka patterns --- -const KAFKA_PATTERNS: PatternDef[] = [ - // Java: @KafkaListener(topics = "xxx") - { - regex: /@KafkaListener\s*\(\s*topics\s*=\s*"([^"]+)"/g, - role: 'consumer', - broker: 'kafka', - confidence: 0.8, - topicGroup: 1, - symbolName: 'kafkaListener', - }, - // Java: kafkaTemplate.send("xxx" - { - regex: /kafkaTemplate\.send\s*\(\s*"([^"]+)"/gi, - role: 'provider', - broker: 'kafka', - confidence: 0.8, - topicGroup: 1, - symbolName: 'kafkaTemplate.send', - }, - // Node: producer.send({ topic: 'xxx' - { - regex: /producer\.send\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g, - role: 'provider', - broker: 'kafka', - confidence: 0.8, - topicGroup: 1, - symbolName: 'producer.send', - }, - // Node: consumer.subscribe({ topic: 'xxx' - { - regex: /consumer\.subscribe\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g, - role: 'consumer', - broker: 'kafka', - confidence: 0.8, - topicGroup: 1, - symbolName: 'consumer.subscribe', - }, - // Go: consumer.ConsumePartition("xxx" - { - regex: /\.ConsumePartition\s*\(\s*"([^"]+)"/g, - role: 'consumer', - broker: 'kafka', - confidence: 0.7, - topicGroup: 1, - symbolName: 'ConsumePartition', - }, - // Python: KafkaConsumer('xxx' - { - regex: /KafkaConsumer\s*\(\s*['"]([^'"]+)['"]/g, - role: 'consumer', - broker: 'kafka', - confidence: 0.7, - topicGroup: 1, - symbolName: 'KafkaConsumer', - }, - // Python: producer.send('xxx' or producer.produce('xxx' - { - regex: /producer\.(?:send|produce)\s*\(\s*['"]([^'"]+)['"]/g, - role: 'provider', - broker: 'kafka', - confidence: 0.7, - topicGroup: 1, - symbolName: 'producer.send', - }, -]; - -// --- RabbitMQ patterns --- -const RABBITMQ_PATTERNS: PatternDef[] = [ - // Java: @RabbitListener(queues = "xxx") - { - regex: /@RabbitListener\s*\(\s*queues\s*=\s*"([^"]+)"/g, - role: 'consumer', - broker: 'rabbitmq', - confidence: 0.8, - topicGroup: 1, - symbolName: 'rabbitListener', - }, - // Java: rabbitTemplate.convertAndSend("xxx" - { - regex: /rabbitTemplate\.convertAndSend\s*\(\s*"([^"]+)"/gi, - role: 'provider', - broker: 'rabbitmq', - confidence: 0.8, - topicGroup: 1, - symbolName: 'rabbitTemplate.convertAndSend', - }, - // Node: channel.consume("xxx" - { - regex: /channel\.consume\s*\(\s*"([^"]+)"/g, - role: 'consumer', - broker: 'rabbitmq', - confidence: 0.8, - topicGroup: 1, - symbolName: 'channel.consume', - }, - // Node: channel.publish("xxx" - { - regex: /channel\.publish\s*\(\s*"([^"]+)"/g, - role: 'provider', - broker: 'rabbitmq', - confidence: 0.8, - topicGroup: 1, - symbolName: 'channel.publish', - }, - // Node: channel.sendToQueue("xxx" - { - regex: /channel\.sendToQueue\s*\(\s*"([^"]+)"/g, - role: 'provider', - broker: 'rabbitmq', - confidence: 0.8, - topicGroup: 1, - symbolName: 'channel.sendToQueue', - }, - // Python: channel.basic_consume(queue='xxx' - { - regex: /channel\.basic_consume\s*\(\s*queue\s*=\s*['"]([^'"]+)['"]/g, - role: 'consumer', - broker: 'rabbitmq', - confidence: 0.7, - topicGroup: 1, - symbolName: 'basic_consume', - }, - // Python: channel.basic_publish(exchange='xxx' - { - regex: /channel\.basic_publish\s*\([^)]*exchange\s*=\s*['"]([^'"]+)['"]/g, - role: 'provider', - broker: 'rabbitmq', - confidence: 0.7, - topicGroup: 1, - symbolName: 'basic_publish', - }, -]; - -// --- NATS patterns --- -const NATS_PATTERNS: PatternDef[] = [ - // Go/Node: nc.Subscribe("xxx" or nc.subscribe("xxx" - { - regex: /nc\.(?:S|s)ubscribe\s*\(\s*"([^"]+)"/g, - role: 'consumer', - broker: 'nats', - confidence: 0.8, - topicGroup: 1, - symbolName: 'nc.Subscribe', - }, - // Go/Node: nc.Publish("xxx" or nc.publish("xxx" - { - regex: /nc\.(?:P|p)ublish\s*\(\s*"([^"]+)"/g, - role: 'provider', - broker: 'nats', - confidence: 0.8, - topicGroup: 1, - symbolName: 'nc.Publish', - }, -]; - -const ALL_PATTERNS: PatternDef[] = [...KAFKA_PATTERNS, ...RABBITMQ_PATTERNS, ...NATS_PATTERNS]; - export class TopicExtractor implements ContractExtractor { type = 'topic' as const; @@ -221,46 +56,48 @@ export class TopicExtractor implements ContractExtractor { repoPath: string, _repo: RepoHandle, ): Promise { - const files = await glob('**/*.{ts,tsx,js,jsx,java,go,py}', { + const files = await glob(TOPIC_SCAN_GLOB, { cwd: repoPath, - ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'], + ignore: [ + '**/node_modules/**', + '**/.git/**', + '**/vendor/**', + '**/dist/**', + '**/build/**', + // Language-level test file conventions. Go test files + // `*_test.go` live next to source; other languages either use + // separate test directories (Python's `tests/`, Java's + // `src/test/`) or are already covered by the dist/build ignores. + // Pushed to the glob level so the orchestrator stays + // language-agnostic. + '**/*_test.go', + ], nodir: true, }); + // One parser reused across files; the scanner calls `setLanguage` per + // file based on which plugin the registry returns. + const parser = new Parser(); const out: ExtractedContract[] = []; + for (const rel of files) { + const provider = getProviderForFile(rel); + if (!provider) continue; + const content = readSafe(repoPath, rel); if (!content) continue; - out.push(...this.scanFile(content, rel)); - } - return this.dedupe(out); - } - - private scanFile(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - - for (const pattern of ALL_PATTERNS) { - // Reset regex state for each file - const re = new RegExp(pattern.regex.source, pattern.regex.flags); - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const topicName = m[pattern.topicGroup]; + const matches = scanFile(parser, provider, content); + for (const match of matches) { + const valueNode = match.captures.value; + if (!valueNode) continue; + const topicName = unquoteLiteral(valueNode.text); if (!topicName) continue; - out.push( - makeContract( - topicName, - pattern.role, - filePath, - pattern.symbolName, - pattern.confidence, - pattern.broker, - ), - ); + out.push(makeContract(topicName, match.meta, rel)); } } - return out; + return this.dedupe(out); } private dedupe(items: ExtractedContract[]): ExtractedContract[] { diff --git a/gitnexus/src/core/group/extractors/topic-patterns/go.ts b/gitnexus/src/core/group/extractors/topic-patterns/go.ts new file mode 100644 index 000000000..df3bab095 --- /dev/null +++ b/gitnexus/src/core/group/extractors/topic-patterns/go.ts @@ -0,0 +1,123 @@ +import Go from 'tree-sitter-go'; +import { compilePatterns, type LanguagePatterns } from '../tree-sitter-scanner.js'; +import type { TopicMeta } from './types.js'; + +/** + * Go topic extraction patterns. + * + * Detects Sarama, segmentio/kafka-go and nats.go producer/consumer APIs: + * - `X.ConsumePartition("topic", ...)` + * - `sarama.ProducerMessage{Topic: "xxx"}` + * - `kafka.Writer{Topic: "xxx"}` / `kafka.WriterConfig{Topic: ...}` + * - `kafka.Reader{Topic: "xxx"}` / `kafka.ReaderConfig{Topic: ...}` + * - `nc.Subscribe("topic", ...)` / `js.Subscribe("topic", ...)` + * - `nc.Publish("topic", ...)` / `js.Publish("topic", ...)` + * + * Every query MUST bind `@value` to the topic literal node. + */ +const GO_TOPIC_SPEC: LanguagePatterns = { + name: 'go-topic', + language: Go, + patterns: [ + { + meta: { + role: 'consumer', + broker: 'kafka', + confidence: 0.7, + symbolName: 'ConsumePartition', + }, + query: ` + (call_expression + function: (selector_expression + field: (field_identifier) @method (#eq? @method "ConsumePartition")) + arguments: (argument_list . (interpreted_string_literal) @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'kafka', + confidence: 0.75, + symbolName: 'sarama.ProducerMessage', + }, + query: ` + (composite_literal + type: (qualified_type + package: (package_identifier) @pkg (#eq? @pkg "sarama") + name: (type_identifier) @ty (#eq? @ty "ProducerMessage")) + body: (literal_value + (keyed_element + (literal_element (identifier) @field (#eq? @field "Topic")) + (literal_element (interpreted_string_literal) @value)))) + `, + }, + { + meta: { + role: 'provider', + broker: 'kafka', + confidence: 0.75, + symbolName: 'kafka.Writer', + }, + query: ` + (composite_literal + type: (qualified_type + package: (package_identifier) @pkg (#eq? @pkg "kafka") + name: (type_identifier) @ty (#match? @ty "^(Writer|WriterConfig)$")) + body: (literal_value + (keyed_element + (literal_element (identifier) @field (#eq? @field "Topic")) + (literal_element (interpreted_string_literal) @value)))) + `, + }, + { + meta: { + role: 'consumer', + broker: 'kafka', + confidence: 0.75, + symbolName: 'kafka.Reader', + }, + query: ` + (composite_literal + type: (qualified_type + package: (package_identifier) @pkg (#eq? @pkg "kafka") + name: (type_identifier) @ty (#match? @ty "^(Reader|ReaderConfig)$")) + body: (literal_value + (keyed_element + (literal_element (identifier) @field (#eq? @field "Topic")) + (literal_element (interpreted_string_literal) @value)))) + `, + }, + { + meta: { + role: 'consumer', + broker: 'nats', + confidence: 0.8, + symbolName: 'nc.Subscribe', + }, + query: ` + (call_expression + function: (selector_expression + operand: (identifier) @obj (#match? @obj "^(nc|js)$") + field: (field_identifier) @method (#match? @method "^[Ss]ubscribe$")) + arguments: (argument_list . (interpreted_string_literal) @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'nats', + confidence: 0.8, + symbolName: 'nc.Publish', + }, + query: ` + (call_expression + function: (selector_expression + operand: (identifier) @obj (#match? @obj "^(nc|js)$") + field: (field_identifier) @method (#match? @method "^[Pp]ublish$")) + arguments: (argument_list . (interpreted_string_literal) @value)) + `, + }, + ], +}; + +export const GO_TOPIC_PROVIDER = compilePatterns(GO_TOPIC_SPEC); diff --git a/gitnexus/src/core/group/extractors/topic-patterns/index.ts b/gitnexus/src/core/group/extractors/topic-patterns/index.ts new file mode 100644 index 000000000..b6e1b8c1f --- /dev/null +++ b/gitnexus/src/core/group/extractors/topic-patterns/index.ts @@ -0,0 +1,49 @@ +import * as path from 'node:path'; +import type { CompiledPatterns } from '../tree-sitter-scanner.js'; +import type { TopicMeta } from './types.js'; +import { JAVA_TOPIC_PROVIDER } from './java.js'; +import { GO_TOPIC_PROVIDER } from './go.js'; +import { PYTHON_TOPIC_PROVIDER } from './python.js'; +import { + JAVASCRIPT_TOPIC_PROVIDER, + TYPESCRIPT_TOPIC_PROVIDER, + TSX_TOPIC_PROVIDER, +} from './node.js'; + +export type { TopicMeta, Broker } from './types.js'; + +/** + * File-extension → compiled-plugin registry for topic extraction. The + * top-level orchestrator (`topic-extractor.ts`) looks up the plugin for + * each file it visits and delegates the scanning to `tree-sitter-scanner`. + * + * Keys are lowercase extensions including the leading dot. To add a new + * language, drop a `topic-patterns/.ts` that exports a compiled + * provider, import it here and register the extension(s). No edits to + * `topic-extractor.ts` are required. + */ +const REGISTRY: Record> = { + '.java': JAVA_TOPIC_PROVIDER, + '.go': GO_TOPIC_PROVIDER, + '.py': PYTHON_TOPIC_PROVIDER, + '.js': JAVASCRIPT_TOPIC_PROVIDER, + '.jsx': JAVASCRIPT_TOPIC_PROVIDER, + '.ts': TYPESCRIPT_TOPIC_PROVIDER, + '.tsx': TSX_TOPIC_PROVIDER, +}; + +/** + * Glob pattern for files worth scanning. Kept here so adding a new + * language to the registry also widens the glob automatically via a + * single edit. + */ +export const TOPIC_SCAN_GLOB = '**/*.{ts,tsx,js,jsx,java,go,py}'; + +/** + * Return the compiled provider registered for the given file's + * extension, or `undefined` if the extension is not registered. + */ +export function getProviderForFile(rel: string): CompiledPatterns | undefined { + const ext = path.extname(rel).toLowerCase(); + return REGISTRY[ext]; +} diff --git a/gitnexus/src/core/group/extractors/topic-patterns/java.ts b/gitnexus/src/core/group/extractors/topic-patterns/java.ts new file mode 100644 index 000000000..d126f25ce --- /dev/null +++ b/gitnexus/src/core/group/extractors/topic-patterns/java.ts @@ -0,0 +1,83 @@ +import Java from 'tree-sitter-java'; +import { compilePatterns, type LanguagePatterns } from '../tree-sitter-scanner.js'; +import type { TopicMeta } from './types.js'; + +/** + * Java topic extraction patterns. + * + * Detects Kafka and RabbitMQ (Spring conventions) producer/consumer APIs: + * - `@KafkaListener(topics = "xxx")` + * - `@RabbitListener(queues = "xxx")` + * - `kafkaTemplate.send("xxx", ...)` + * - `rabbitTemplate.convertAndSend("xxx", ...)` + * + * Every query MUST bind `@value` to the topic literal node. + */ +const JAVA_TOPIC_SPEC: LanguagePatterns = { + name: 'java-topic', + language: Java, + patterns: [ + { + meta: { + role: 'consumer', + broker: 'kafka', + confidence: 0.8, + symbolName: 'kafkaListener', + }, + query: ` + (annotation + name: (identifier) @name (#eq? @name "KafkaListener") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#eq? @key "topics") + value: (string_literal) @value))) + `, + }, + { + meta: { + role: 'consumer', + broker: 'rabbitmq', + confidence: 0.8, + symbolName: 'rabbitListener', + }, + query: ` + (annotation + name: (identifier) @name (#eq? @name "RabbitListener") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#eq? @key "queues") + value: (string_literal) @value))) + `, + }, + { + meta: { + role: 'provider', + broker: 'kafka', + confidence: 0.8, + symbolName: 'kafkaTemplate.send', + }, + query: ` + (method_invocation + object: (identifier) @obj (#eq? @obj "kafkaTemplate") + name: (identifier) @method (#eq? @method "send") + arguments: (argument_list . (string_literal) @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'rabbitmq', + confidence: 0.8, + symbolName: 'rabbitTemplate.convertAndSend', + }, + query: ` + (method_invocation + object: (identifier) @obj (#eq? @obj "rabbitTemplate") + name: (identifier) @method (#eq? @method "convertAndSend") + arguments: (argument_list . (string_literal) @value)) + `, + }, + ], +}; + +export const JAVA_TOPIC_PROVIDER = compilePatterns(JAVA_TOPIC_SPEC); diff --git a/gitnexus/src/core/group/extractors/topic-patterns/node.ts b/gitnexus/src/core/group/extractors/topic-patterns/node.ts new file mode 100644 index 000000000..68f3a4ef8 --- /dev/null +++ b/gitnexus/src/core/group/extractors/topic-patterns/node.ts @@ -0,0 +1,165 @@ +import JavaScript from 'tree-sitter-javascript'; +import TypeScript from 'tree-sitter-typescript'; +import { + compilePatterns, + type LanguagePatterns, + type PatternSpec, +} from '../tree-sitter-scanner.js'; +import type { TopicMeta } from './types.js'; + +/** + * Node.js / TypeScript topic extraction patterns. + * + * Detects kafkajs, amqplib (RabbitMQ), and nats.js producer/consumer APIs: + * - `producer.send({ topic: 'xxx', ... })` (kafkajs) + * - `consumer.subscribe({ topic: 'xxx', ... })` (kafkajs) + * - `channel.consume("queue", ...)` / `channel.publish(...)` / `channel.sendToQueue(...)` + * - `nc.subscribe("topic")` / `js.subscribe("topic")` + * - `nc.publish("topic", ...)` / `js.publish("topic", ...)` + * + * The JavaScript and TypeScript tree-sitter grammars share node type + * names for every construct we query here, so the pattern sources are + * defined once and compiled against each grammar variant. We export three + * providers because Parser.Query objects are NOT portable across grammar + * instances — `.js` files use the JavaScript grammar, `.ts` uses + * TypeScript.typescript, and `.tsx` uses TypeScript.tsx. + * + * Every query MUST bind `@value` to the topic literal node. + */ +const NODE_TOPIC_PATTERNS: PatternSpec[] = [ + { + meta: { + role: 'provider', + broker: 'kafka', + confidence: 0.8, + symbolName: 'producer.send', + }, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "producer") + property: (property_identifier) @prop (#eq? @prop "send")) + arguments: (arguments + (object + (pair + key: (property_identifier) @key (#eq? @key "topic") + value: [(string) (template_string)] @value)))) + `, + }, + { + meta: { + role: 'consumer', + broker: 'kafka', + confidence: 0.8, + symbolName: 'consumer.subscribe', + }, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "consumer") + property: (property_identifier) @prop (#eq? @prop "subscribe")) + arguments: (arguments + (object + (pair + key: (property_identifier) @key (#eq? @key "topic") + value: [(string) (template_string)] @value)))) + `, + }, + { + meta: { + role: 'consumer', + broker: 'rabbitmq', + confidence: 0.8, + symbolName: 'channel.consume', + }, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "channel") + property: (property_identifier) @prop (#eq? @prop "consume")) + arguments: (arguments . [(string) (template_string)] @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'rabbitmq', + confidence: 0.8, + symbolName: 'channel.publish', + }, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "channel") + property: (property_identifier) @prop (#eq? @prop "publish")) + arguments: (arguments . [(string) (template_string)] @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'rabbitmq', + confidence: 0.8, + symbolName: 'channel.sendToQueue', + }, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "channel") + property: (property_identifier) @prop (#eq? @prop "sendToQueue")) + arguments: (arguments . [(string) (template_string)] @value)) + `, + }, + { + meta: { + role: 'consumer', + broker: 'nats', + confidence: 0.8, + symbolName: 'nc.subscribe', + }, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#match? @obj "^(nc|js)$") + property: (property_identifier) @prop (#match? @prop "^[Ss]ubscribe$")) + arguments: (arguments . [(string) (template_string)] @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'nats', + confidence: 0.8, + symbolName: 'nc.publish', + }, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#match? @obj "^(nc|js)$") + property: (property_identifier) @prop (#match? @prop "^[Pp]ublish$")) + arguments: (arguments . [(string) (template_string)] @value)) + `, + }, +]; + +const JAVASCRIPT_TOPIC_SPEC: LanguagePatterns = { + name: 'javascript-topic', + language: JavaScript, + patterns: NODE_TOPIC_PATTERNS, +}; + +const TYPESCRIPT_TOPIC_SPEC: LanguagePatterns = { + name: 'typescript-topic', + language: TypeScript.typescript, + patterns: NODE_TOPIC_PATTERNS, +}; + +const TSX_TOPIC_SPEC: LanguagePatterns = { + name: 'tsx-topic', + language: TypeScript.tsx, + patterns: NODE_TOPIC_PATTERNS, +}; + +export const JAVASCRIPT_TOPIC_PROVIDER = compilePatterns(JAVASCRIPT_TOPIC_SPEC); +export const TYPESCRIPT_TOPIC_PROVIDER = compilePatterns(TYPESCRIPT_TOPIC_SPEC); +export const TSX_TOPIC_PROVIDER = compilePatterns(TSX_TOPIC_SPEC); diff --git a/gitnexus/src/core/group/extractors/topic-patterns/python.ts b/gitnexus/src/core/group/extractors/topic-patterns/python.ts new file mode 100644 index 000000000..d84cae999 --- /dev/null +++ b/gitnexus/src/core/group/extractors/topic-patterns/python.ts @@ -0,0 +1,119 @@ +import Python from 'tree-sitter-python'; +import { compilePatterns, type LanguagePatterns } from '../tree-sitter-scanner.js'; +import type { TopicMeta } from './types.js'; + +/** + * Python topic extraction patterns. + * + * Detects kafka-python, pika (RabbitMQ), and nats-py producer/consumer APIs: + * - `KafkaConsumer('topic', ...)` + * - `producer.send('topic', ...)` / `producer.produce('topic', ...)` + * - `channel.basic_consume(queue='xxx', ...)` + * - `channel.basic_publish(exchange='xxx', ...)` + * - `await nc.subscribe('topic')` + * - `await nc.publish('topic', ...)` + * + * Every query MUST bind `@value` to the topic literal node. + */ +const PYTHON_TOPIC_SPEC: LanguagePatterns = { + name: 'python-topic', + language: Python, + patterns: [ + { + meta: { + role: 'consumer', + broker: 'kafka', + confidence: 0.7, + symbolName: 'KafkaConsumer', + }, + query: ` + (call + function: (identifier) @func (#eq? @func "KafkaConsumer") + arguments: (argument_list . (string) @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'kafka', + confidence: 0.7, + symbolName: 'producer.send', + }, + query: ` + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "producer") + attribute: (identifier) @method (#match? @method "^(send|produce)$")) + arguments: (argument_list . (string) @value)) + `, + }, + { + meta: { + role: 'consumer', + broker: 'rabbitmq', + confidence: 0.7, + symbolName: 'basic_consume', + }, + query: ` + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "channel") + attribute: (identifier) @method (#eq? @method "basic_consume")) + arguments: (argument_list + (keyword_argument + name: (identifier) @kw (#eq? @kw "queue") + value: (string) @value))) + `, + }, + { + meta: { + role: 'provider', + broker: 'rabbitmq', + confidence: 0.7, + symbolName: 'basic_publish', + }, + query: ` + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "channel") + attribute: (identifier) @method (#eq? @method "basic_publish")) + arguments: (argument_list + (keyword_argument + name: (identifier) @kw (#eq? @kw "exchange") + value: (string) @value))) + `, + }, + { + meta: { + role: 'consumer', + broker: 'nats', + confidence: 0.75, + symbolName: 'nc.subscribe', + }, + query: ` + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "nc") + attribute: (identifier) @method (#eq? @method "subscribe")) + arguments: (argument_list . (string) @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'nats', + confidence: 0.75, + symbolName: 'nc.publish', + }, + query: ` + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "nc") + attribute: (identifier) @method (#eq? @method "publish")) + arguments: (argument_list . (string) @value)) + `, + }, + ], +}; + +export const PYTHON_TOPIC_PROVIDER = compilePatterns(PYTHON_TOPIC_SPEC); diff --git a/gitnexus/src/core/group/extractors/topic-patterns/types.ts b/gitnexus/src/core/group/extractors/topic-patterns/types.ts new file mode 100644 index 000000000..3a27f21d3 --- /dev/null +++ b/gitnexus/src/core/group/extractors/topic-patterns/types.ts @@ -0,0 +1,27 @@ +/** + * Shared types for the topic-extractor language plugins. + * + * Each plugin lives in its own file (java.ts, go.ts, ...) and owns the + * tree-sitter grammar import + query sources. The top-level + * `topic-extractor.ts` orchestrator only knows about this type module and + * the plugin registry (`./index.ts`). It MUST NOT import any grammar or + * query text directly — that's the whole point of the split. + */ + +export type Broker = 'kafka' | 'rabbitmq' | 'nats'; + +/** + * Per-pattern payload every topic plugin attaches to its query. Whatever + * the pattern matches, the orchestrator receives this object verbatim + * and uses it to build an `ExtractedContract`. + * + * Plugins produce one `TopicMeta` per pattern (not per match) because a + * single query uniquely identifies its broker/role/confidence triple. + */ +export interface TopicMeta { + role: 'provider' | 'consumer'; + broker: Broker; + confidence: number; + /** Short human-readable label of the API being detected. */ + symbolName: string; +} diff --git a/gitnexus/src/core/group/extractors/tree-sitter-scanner.ts b/gitnexus/src/core/group/extractors/tree-sitter-scanner.ts new file mode 100644 index 000000000..cd50456aa --- /dev/null +++ b/gitnexus/src/core/group/extractors/tree-sitter-scanner.ts @@ -0,0 +1,193 @@ +import Parser from 'tree-sitter'; + +/** + * Shared, language-agnostic tree-sitter scanning utilities used by group + * extractors (topic, http, grpc, ...). + * + * Design goals: + * - The top-level extractors must not import any tree-sitter grammar. + * - Per-language plugins own their grammar import, their query sources, + * and the mapping from capture → meta. + * - This module provides the plumbing: compile queries once per plugin, + * parse a file with a given grammar, run all patterns, and return the + * captured `string_literal`-style nodes together with the plugin's meta. + */ + +/** + * One pattern owned by a language plugin. Each pattern owns a tree-sitter + * S-expression query. Plugins can freely choose which capture names to + * use — the scanner exposes every capture in the returned `captures` + * map and does not privilege any particular name. + * + * `TMeta` is the plugin-specific payload the orchestrator receives back + * when this pattern matches — e.g. for topic extraction it carries the + * broker name, role, confidence, symbol name. + */ +export interface PatternSpec { + /** Tree-sitter S-expression. */ + query: string; + /** Plugin-specific payload returned on every match. */ + meta: TMeta; +} + +/** + * A set of patterns owned by one language plugin, bound to a specific + * tree-sitter grammar. + * + * `language` is typed as `unknown` because tree-sitter's TypeScript + * declarations use `any` for the grammar object, and the grammar modules + * export different shapes (plain grammar vs. namespace with `typescript` + * / `tsx` members). Callers pass the concrete grammar object; this + * module forwards it to `parser.setLanguage` / `new Parser.Query`. + */ +export interface LanguagePatterns { + /** Human-readable plugin name for diagnostics. */ + name: string; + /** tree-sitter grammar object. */ + language: unknown; + /** Patterns authored against `language`. */ + patterns: PatternSpec[]; +} + +/** + * Compiled form of a `LanguagePatterns` bundle. Queries are compiled + * eagerly at module load time so a broken grammar/query pair fails + * loudly the first time the plugin is imported, instead of silently + * at scan time when no contract is produced. + */ +export interface CompiledPatterns { + name: string; + language: unknown; + patterns: CompiledPattern[]; +} + +export interface CompiledPattern { + query: Parser.Query; + meta: TMeta; +} + +/** + * Map from capture name → syntax node. Every named capture the query + * binds is exposed as an entry. If a query captures the same name more + * than once (unusual), the first occurrence wins — plugins that need + * all occurrences should use distinct capture names or fall back to + * `match.captures` array directly by iterating `query.matches()` + * themselves. + */ +export type CaptureMap = Record; + +/** + * One match returned by `scanFile` / `runCompiledPatterns`. The caller + * receives the full capture map plus the plugin meta, and is + * responsible for turning it into a domain object. + */ +export interface ScanMatch { + meta: TMeta; + captures: CaptureMap; +} + +/** + * Compile a LanguagePatterns bundle. Call this once per plugin, at + * module load time, and export the result. Throws if any pattern + * fails to compile against the grammar — that's a bug in the plugin + * author's query, not a runtime condition. + */ +export function compilePatterns(bundle: LanguagePatterns): CompiledPatterns { + const compiled: CompiledPattern[] = []; + for (const spec of bundle.patterns) { + try { + const query = new Parser.Query(bundle.language, spec.query); + compiled.push({ query, meta: spec.meta }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error( + `[tree-sitter-scanner] Failed to compile pattern in ${bundle.name}: ${message}\n` + + `Query source:\n${spec.query}`, + ); + } + } + return { name: bundle.name, language: bundle.language, patterns: compiled }; +} + +/** + * Run every compiled pattern in `plugin` against an already-parsed + * tree. Use this when a plugin needs multiple query bundles against + * the same file (e.g. one query for class-level prefixes and another + * for method-level annotations) and wants to avoid re-parsing. + */ +export function runCompiledPatterns( + plugin: CompiledPatterns, + tree: Parser.Tree, +): ScanMatch[] { + const out: ScanMatch[] = []; + for (const compiled of plugin.patterns) { + let matches: Parser.QueryMatch[]; + try { + matches = compiled.query.matches(tree.rootNode); + } catch { + continue; + } + for (const match of matches) { + const captures: CaptureMap = {}; + for (const cap of match.captures) { + if (!(cap.name in captures)) captures[cap.name] = cap.node; + } + out.push({ meta: compiled.meta, captures }); + } + } + return out; +} + +/** + * Parse `content` with the plugin's grammar and run every compiled + * pattern against the AST. Returns one `ScanMatch` per matched query + * occurrence, carrying the plugin's meta payload. + * + * Errors are swallowed at the file level (malformed file must not abort + * the whole extract). Individual pattern failures are swallowed too so + * a single unusable query doesn't block the rest of the plugin. + */ +export function scanFile( + parser: Parser, + plugin: CompiledPatterns, + content: string, +): ScanMatch[] { + let tree: Parser.Tree; + try { + parser.setLanguage(plugin.language); + tree = parser.parse(content); + } catch { + return []; + } + return runCompiledPatterns(plugin, tree); +} + +/** + * Strip enclosing quotes from a tree-sitter string literal node's text. + * Handles single / double / template quotes, Python triple-quoted strings, + * and Go raw string literals (backticks). + * + * Returns null for empty/nullish input so callers can uniformly skip + * captures whose value is missing. + */ +export function unquoteLiteral(raw: string): string | null { + if (!raw) return null; + + // Python triple-quoted + if ( + (raw.startsWith('"""') && raw.endsWith('"""')) || + (raw.startsWith("'''") && raw.endsWith("'''")) + ) { + return raw.slice(3, -3); + } + + const first = raw[0]; + const last = raw[raw.length - 1]; + if ((first === '"' || first === "'" || first === '`') && last === first && raw.length >= 2) { + return raw.slice(1, -1); + } + + // Some grammars expose the string content without quotes already (e.g. + // Python `string_content` child). Return as-is. + return raw; +} diff --git a/gitnexus/test/unit/group/grpc-extractor.test.ts b/gitnexus/test/unit/group/grpc-extractor.test.ts index b4fc63b5c..82d79cbd6 100644 --- a/gitnexus/test/unit/group/grpc-extractor.test.ts +++ b/gitnexus/test/unit/group/grpc-extractor.test.ts @@ -1,17 +1,23 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; +import fsp from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; -import { GrpcExtractor } from '../../../src/core/group/extractors/grpc-extractor.js'; +import { + GrpcExtractor, + buildProtoMap, + resolveProtoConflict, + serviceContractId, +} from '../../../src/core/group/extractors/grpc-extractor.js'; +import type { ProtoServiceInfo } from '../../../src/core/group/extractors/grpc-extractor.js'; import type { RepoHandle } from '../../../src/core/group/types.js'; describe('GrpcExtractor', () => { let tmpDir: string; let extractor: GrpcExtractor; - beforeEach(() => { - tmpDir = path.join(os.tmpdir(), `gitnexus-grpc-${Date.now()}`); - fs.mkdirSync(tmpDir, { recursive: true }); + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-grpc-')); extractor = new GrpcExtractor(); }); @@ -205,6 +211,66 @@ service IncompleteService { // The old regex would find partial match; the new parser should skip it expect(providers).toHaveLength(0); }); + + it('test_extract_proto_ignores_braces_inside_string_literals', async () => { + // Regression for a known parser limitation: braces inside string + // literals used to be counted as real service-body braces, which + // would terminate the service early and drop methods after the + // offending string. + writeFile( + 'api/strings.proto', + `syntax = "proto3"; +package strings; + +service TrickyService { + rpc First (Req) returns (Res) { + option (google.api.http).additional_bindings = { + post: "/v1/first"; + }; + } + // Previously the "{" inside this literal would close the service body. + option deprecated_reason = "use NewService { instead"; + rpc Second (Req) returns (Res); + rpc Third (Req) returns (Res); +} +`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const protoProviders = contracts.filter( + (c) => c.role === 'provider' && c.symbolRef.filePath === 'api/strings.proto', + ); + // All three methods must be extracted even though a string literal + // contains an unbalanced "{". + expect(protoProviders.map((c) => c.symbolName).sort()).toEqual([ + 'TrickyService.First', + 'TrickyService.Second', + 'TrickyService.Third', + ]); + }); + + it('test_extract_proto_ignores_braces_inside_comments', async () => { + writeFile( + 'api/commented.proto', + `syntax = "proto3"; +package commented; + +service Svc { + // TODO: move { or } from this comment — parser used to count them + /* A block comment with { unbalanced braces } */ + rpc Alpha (Req) returns (Res); + // }} end of the method block (in comment) + rpc Beta (Req) returns (Res); +} +`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const protoProviders = contracts.filter( + (c) => c.role === 'provider' && c.symbolRef.filePath === 'api/commented.proto', + ); + expect(protoProviders.map((c) => c.symbolName).sort()).toEqual(['Svc.Alpha', 'Svc.Beta']); + }); }); describe('Go server detection', () => { @@ -228,7 +294,7 @@ func main() { expect(providers.length).toBeGreaterThanOrEqual(1); expect(providers[0].contractId).toContain('grpc::'); expect(providers[0].contractId).toContain('AuthService'); - expect(providers[0].confidence).toBe(0.8); + expect(providers[0].confidence).toBe(0.65); }); it('test_extract_go_unimplemented_server_returns_provider', async () => { @@ -267,7 +333,7 @@ func NewAuthClient(conn *grpc.ClientConn) pb.AuthServiceClient { expect(consumers.length).toBeGreaterThanOrEqual(1); expect(consumers[0].contractId).toContain('AuthService'); - expect(consumers[0].confidence).toBe(0.7); + expect(consumers[0].confidence).toBe(0.55); }); }); @@ -287,7 +353,7 @@ public class AuthGrpcService extends AuthServiceGrpc.AuthServiceImplBase { expect(providers.length).toBeGreaterThanOrEqual(1); expect(providers[0].contractId).toContain('AuthService'); - expect(providers[0].confidence).toBe(0.8); + expect(providers[0].confidence).toBe(0.65); }); it('test_extract_java_blocking_stub_returns_consumer', async () => { @@ -306,7 +372,7 @@ public class AuthGrpcService extends AuthServiceGrpc.AuthServiceImplBase { expect(consumers.length).toBeGreaterThanOrEqual(1); expect(consumers[0].contractId).toContain('AuthService'); - expect(consumers[0].confidence).toBe(0.7); + expect(consumers[0].confidence).toBe(0.55); }); }); @@ -328,7 +394,7 @@ def serve(): expect(providers.length).toBeGreaterThanOrEqual(1); expect(providers[0].contractId).toContain('AuthService'); - expect(providers[0].confidence).toBe(0.8); + expect(providers[0].confidence).toBe(0.65); }); it('test_extract_python_stub_returns_consumer', async () => { @@ -346,7 +412,7 @@ stub = auth_pb2_grpc.AuthServiceStub(channel)`, expect(consumers.length).toBeGreaterThanOrEqual(1); expect(consumers[0].contractId).toContain('AuthService'); - expect(consumers[0].confidence).toBe(0.7); + expect(consumers[0].confidence).toBe(0.55); }); }); @@ -372,6 +438,165 @@ export class AuthController { expect(providers[0].contractId).toContain('Login'); expect(providers[0].confidence).toBe(0.8); }); + + it('test_extract_ts_grpc_client_decorator_returns_consumer', async () => { + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth.v1; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + writeFile( + 'src/auth.client.ts', + `import { GrpcClient } from '@nestjs/microservices'; +import type { AuthServiceClient } from './generated/auth'; + +export class AuthGateway { + @GrpcClient({ package: 'auth.v1', protoPath: 'proto/auth.proto' }) + private readonly authClient!: AuthServiceClient; +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*'); + }); + + it('test_extract_ts_getService_without_decorator_returns_consumer', async () => { + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth.v1; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + writeFile( + 'src/auth.client.ts', + `import type { ClientGrpc } from '@nestjs/microservices'; + +export function createAuthClient(client: ClientGrpc) { + return client.getService('AuthService'); +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*'); + }); + + it('test_extract_ts_generated_client_constructor_returns_consumer', async () => { + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth.v1; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + writeFile( + 'src/auth.client.ts', + `import { credentials } from '@grpc/grpc-js'; +import { AuthServiceClient } from './generated/auth'; + +export const authClient = new AuthServiceClient('localhost:50051', credentials.createInsecure());`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*'); + }); + + it('test_extract_ts_non_service_client_constructor_is_ignored', async () => { + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth.v1; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + writeFile( + 'src/auth.client.ts', + `import { AuthClient } from './generated/auth'; + +export const authClient = new AuthClient('localhost:50051');`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(0); + }); + + it('test_extract_ts_loadPackageDefinition_constructor_returns_consumer', async () => { + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth.v1; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + writeFile( + 'src/auth.client.ts', + `import * as grpc from '@grpc/grpc-js'; +import * as protoLoader from '@grpc/proto-loader'; + +const definition = protoLoader.loadSync('proto/auth.proto'); +const authProto = grpc.loadPackageDefinition(definition) as any; +export const authClient = new authProto.auth.v1.AuthService( + 'localhost:50051', + grpc.credentials.createInsecure(), +);`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*'); + }); + + it('test_extract_ts_duplicate_consumer_patterns_in_one_file_dedupes_deterministically', async () => { + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth.v1; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + writeFile( + 'src/auth.client.ts', + `import * as grpc from '@grpc/grpc-js'; +import type { ClientGrpc } from '@nestjs/microservices'; +import { AuthServiceClient } from './generated/auth'; + +export class AuthGateway { + constructor(private readonly client: ClientGrpc) {} + + connect() { + this.client.getService('AuthService'); + return new AuthServiceClient('localhost:50051', grpc.credentials.createInsecure()); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*'); + }); }); describe('edge cases', () => { @@ -389,3 +614,297 @@ export class AuthController { }); }); }); + +describe('buildProtoMap', () => { + let tmpDir: string; + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'proto-test-')); + }); + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + it('test_buildProtoMap_single_proto_parses_package_service_methods', async () => { + const protoContent = ` +syntax = "proto3"; +package com.example; + +service UserService { + rpc GetUser (GetUserRequest) returns (GetUserResponse); + rpc ListUsers (ListUsersRequest) returns (ListUsersResponse); +}`; + await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true }); + await fsp.writeFile(path.join(tmpDir, 'proto', 'user.proto'), protoContent); + + const map = await buildProtoMap(tmpDir); + expect(map.has('UserService')).toBe(true); + const entries = map.get('UserService')!; + expect(entries).toHaveLength(1); + expect(entries[0].package).toBe('com.example'); + expect(entries[0].serviceName).toBe('UserService'); + expect(entries[0].methods).toEqual(['GetUser', 'ListUsers']); + expect(entries[0].protoPath).toBe('proto/user.proto'); + }); + + it('test_buildProtoMap_no_package_declaration', async () => { + const protoContent = ` +syntax = "proto3"; +service Foo { rpc Bar (Req) returns (Res); }`; + await fsp.writeFile(path.join(tmpDir, 'foo.proto'), protoContent); + + const map = await buildProtoMap(tmpDir); + const entries = map.get('Foo')!; + expect(entries[0].package).toBe(''); + }); + + it('test_buildProtoMap_no_protos_returns_empty', async () => { + const map = await buildProtoMap(tmpDir); + expect(map.size).toBe(0); + }); + + it('test_buildProtoMap_conflicting_names', async () => { + await fsp.mkdir(path.join(tmpDir, 'a'), { recursive: true }); + await fsp.mkdir(path.join(tmpDir, 'b'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'a', 'svc.proto'), + 'package pkg.a;\nservice Svc { rpc Do (R) returns (R); }', + ); + await fsp.writeFile( + path.join(tmpDir, 'b', 'svc.proto'), + 'package pkg.b;\nservice Svc { rpc Do (R) returns (R); }', + ); + + const map = await buildProtoMap(tmpDir); + expect(map.get('Svc')).toHaveLength(2); + }); + + it('test_buildProtoMap_imported_package_is_inherited_for_split_service_definition', async () => { + await fsp.mkdir(path.join(tmpDir, 'proto', 'shared'), { recursive: true }); + await fsp.mkdir(path.join(tmpDir, 'proto', 'services'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'shared', 'package.proto'), + 'package auth.v1;\nmessage LoginRequest {}', + ); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'services', 'auth.proto'), + 'import "../shared/package.proto";\nservice AuthService { rpc Login (LoginRequest) returns (LoginRequest); }', + ); + + const map = await buildProtoMap(tmpDir); + const entries = map.get('AuthService')!; + + expect(entries).toHaveLength(1); + expect(entries[0].package).toBe('auth.v1'); + }); +}); + +describe('resolveProtoConflict', () => { + const makeInfo = (pkg: string, protoPath: string): ProtoServiceInfo => ({ + package: pkg, + serviceName: 'Svc', + methods: ['Do'], + protoPath, + }); + + it('test_single_candidate_returns_it', () => { + const result = resolveProtoConflict('Svc', 'src/main.go', [makeInfo('pkg', 'proto/svc.proto')]); + expect(result?.package).toBe('pkg'); + }); + + it('test_multiple_candidates_picks_closest_directory', () => { + const candidates = [ + makeInfo('far', 'other/dir/svc.proto'), + makeInfo('close', 'src/proto/svc.proto'), + ]; + const result = resolveProtoConflict('Svc', 'src/server.go', candidates); + expect(result?.package).toBe('close'); + }); + + it('test_centralized_proto_layout_prefers_shared_path_segments_over_prefix_only', () => { + const candidates = [ + makeInfo('billing', 'proto/services/billing/svc.proto'), + makeInfo('auth', 'proto/services/auth/svc.proto'), + ]; + const result = resolveProtoConflict('Svc', 'services/auth/src/server.ts', candidates); + expect(result?.package).toBe('auth'); + }); + + it('test_no_candidates_returns_null', () => { + expect(resolveProtoConflict('Svc', 'src/main.go', [])).toBeNull(); + }); +}); + +describe('serviceContractId', () => { + it('test_with_package', () => { + expect(serviceContractId('com.example', 'UserService')).toBe('grpc::com.example.UserService/*'); + }); + + it('test_without_package', () => { + expect(serviceContractId('', 'UserService')).toBe('grpc::UserService/*'); + }); +}); + +describe('proto-aware source scanners', () => { + let tmpDir: string; + let extractor: GrpcExtractor; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'scanner-test-')); + extractor = new GrpcExtractor(); + }); + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + const makeRepo = (repoPath: string): RepoHandle => ({ + id: 'test-repo', + path: '', + repoPath, + storagePath: '', + }); + + it('test_go_provider_with_proto_uses_canonical_service_id', async () => { + await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'user.proto'), + 'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }', + ); + await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'src', 'server.go'), + 'package main\nfunc init() { pb.RegisterUserServiceServer(srv, &impl{}) }', + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + const goProvider = contracts.find((c) => c.meta.source === 'go_register'); + expect(goProvider).toBeDefined(); + expect(goProvider!.contractId).toBe('grpc::com.example.UserService/*'); + expect(goProvider!.confidence).toBe(0.8); + }); + + it('test_go_provider_without_proto_reduced_confidence', async () => { + await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'src', 'server.go'), + 'package main\nfunc init() { pb.RegisterFooServer(srv, &impl{}) }', + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + const goProvider = contracts.find((c) => c.meta.source === 'go_register'); + expect(goProvider).toBeDefined(); + expect(goProvider!.contractId).toBe('grpc::Foo/*'); + expect(goProvider!.confidence).toBe(0.65); + }); + + it('test_go_consumer_with_proto_uses_canonical_service_id', async () => { + await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'user.proto'), + 'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }', + ); + await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'src', 'client.go'), + 'package main\nfunc init() { client := pb.NewUserServiceClient(conn) }', + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + const goConsumer = contracts.find((c) => c.meta.source === 'go_client'); + expect(goConsumer).toBeDefined(); + expect(goConsumer!.contractId).toBe('grpc::com.example.UserService/*'); + expect(goConsumer!.confidence).toBe(0.75); + }); + + it('test_java_provider_with_proto_uses_canonical_service_id', async () => { + await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'user.proto'), + 'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }', + ); + await fsp.mkdir(path.join(tmpDir, 'src', 'main', 'java'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'src', 'main', 'java', 'UserGrpcService.java'), + `@GrpcService +public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase { + @Override + public void getUser(GetUserRequest req, StreamObserver obs) {} +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + const javaProvider = contracts.find((c) => c.meta.source === 'java_grpc_service'); + expect(javaProvider).toBeDefined(); + expect(javaProvider!.contractId).toBe('grpc::com.example.UserService/*'); + expect(javaProvider!.confidence).toBe(0.8); + }); + + it('test_python_consumer_with_proto_uses_canonical_service_id', async () => { + await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'user.proto'), + 'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }', + ); + await fsp.writeFile( + path.join(tmpDir, 'client.py'), + `import grpc +channel = grpc.insecure_channel('localhost:50051') +stub = UserServiceStub(channel)`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + const pyConsumer = contracts.find((c) => c.meta.source === 'python_stub'); + expect(pyConsumer).toBeDefined(); + expect(pyConsumer!.contractId).toBe('grpc::com.example.UserService/*'); + expect(pyConsumer!.confidence).toBe(0.75); + }); + + it('test_ts_provider_with_proto_adds_package', async () => { + await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'user.proto'), + 'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }', + ); + await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'src', 'controller.ts'), + "@GrpcMethod('UserService', 'GetUser')\nasync getUser() {}", + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + const tsProvider = contracts.find((c) => c.meta.source === 'ts_grpc_method'); + expect(tsProvider).toBeDefined(); + expect(tsProvider!.contractId).toBe('grpc::com.example.UserService/GetUser'); + expect(tsProvider!.confidence).toBe(0.8); + }); + + it('test_proto_provider_inherits_package_from_imported_definition', async () => { + await fsp.mkdir(path.join(tmpDir, 'proto', 'shared'), { recursive: true }); + await fsp.mkdir(path.join(tmpDir, 'proto', 'services'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'shared', 'package.proto'), + 'package auth.v1;\nmessage LoginRequest {}', + ); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'services', 'auth.proto'), + `syntax = "proto3"; +import "../shared/package.proto"; +service AuthService { + rpc Login (LoginRequest) returns (LoginRequest); +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + const protoProvider = contracts.find( + (c) => c.symbolRef.filePath === 'proto/services/auth.proto', + ); + expect(protoProvider).toBeDefined(); + expect(protoProvider!.contractId).toBe('grpc::auth.v1.AuthService/Login'); + }); +}); diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index d4c0db3eb..653b4952c 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -157,6 +157,89 @@ export default router; providers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'), ).toBeDefined(); }); + + it('extracts Go Gin and Echo route registrations', async () => { + const dir = path.join(tmpDir, 'go-frameworks'); + fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'cmd', 'server.go'), + ` +package main + +func createOrder(c *gin.Context) {} +func listOrders(c echo.Context) error { return nil } + +func main() { + r := gin.Default() + r.POST("/api/orders/:id", createOrder) + + e := echo.New() + e.GET("/api/orders", listOrders) +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const ginRoute = providers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'); + expect(ginRoute).toBeDefined(); + expect(ginRoute?.symbolName).toBe('createOrder'); + + const echoRoute = providers.find((c) => c.contractId === 'http::GET::/api/orders'); + expect(echoRoute).toBeDefined(); + expect(echoRoute?.symbolName).toBe('listOrders'); + }); + + it('extracts stdlib HandleFunc providers', async () => { + const dir = path.join(tmpDir, 'go-stdlib-provider'); + fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'cmd', 'server.go'), + ` +package main + +func healthHandler(w http.ResponseWriter, r *http.Request) {} + +func main() { + http.HandleFunc("/api/health", healthHandler) +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const healthRoute = providers.find((c) => c.contractId === 'http::GET::/api/health'); + expect(healthRoute).toBeDefined(); + expect(healthRoute?.symbolName).toBe('healthHandler'); + }); + + it('extracts NestJS controller decorators', async () => { + const dir = path.join(tmpDir, 'nestjs'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'orders.controller.ts'), + ` +import { Controller, Patch } from '@nestjs/common'; + +@Controller('orders') +export class OrdersController { + @Patch(':id') + updateOrder() { + return {}; + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const patchRoute = providers.find((c) => c.contractId === 'http::PATCH::/orders/{param}'); + expect(patchRoute).toBeDefined(); + expect(patchRoute?.symbolName).toBe('updateOrder'); + }); }); describe('consumer extraction — fetch patterns', () => { @@ -206,6 +289,91 @@ export const deleteUser = (id: string) => axios.delete(\`/api/users/\${id}\`); consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'), ).toBeDefined(); }); + + it('extracts Python requests calls', async () => { + const dir = path.join(tmpDir, 'python-consumer'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'client.py'), + ` +import requests + +def create_order(): + return requests.post("https://svc.local/api/orders/42", json={"id": 42}) +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'), + ).toBeDefined(); + }); + + it('extracts Java RestTemplate, WebClient and OkHttp calls', async () => { + const dir = path.join(tmpDir, 'java-consumer'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'ApiClient.java'), + ` +import org.springframework.http.HttpMethod; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; +import okhttp3.Request; + +class ApiClient { + void run(RestTemplate restTemplate, WebClient webClient) { + restTemplate.getForObject("/api/users/{id}", String.class, 42); + webClient.method(HttpMethod.PATCH, "/api/users/42"); + new Request.Builder().url("/api/orders/42").build(); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), + ).toBeDefined(); + }); + + it('extracts Go stdlib and resty calls', async () => { + const dir = path.join(tmpDir, 'go-consumer'); + fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'cmd', 'client.go'), + ` +package main + +import ( + "net/http" + + "github.com/go-resty/resty/v2" +) + +func main() { + http.Get("/api/health") + client := resty.New() + client.R().Delete("/api/orders/42") +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::GET::/api/health')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'), + ).toBeDefined(); + }); }); describe('provider extraction — Laravel', () => { @@ -326,78 +494,6 @@ async def create_user(user: UserCreate): }); }); - describe('interface regex anchoring', () => { - it('skips Feign client interfaces (no @Controller)', async () => { - const dir = path.join(tmpDir, 'feign-skip'); - fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(dir, 'src/UserClient.java'), - ` -package com.example; -@FeignClient(name = "user-service") -public interface UserClient { - @GetMapping("/users") - List getUsers(); -} -`, - ); - const contracts = await extractor.extract(null, dir, makeRepo(dir)); - expect(contracts.filter((c) => c.role === 'provider')).toHaveLength(0); - }); - - it('does NOT skip when @RestController is present', async () => { - const dir = path.join(tmpDir, 'ctrl-iface'); - fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(dir, 'src/UserController.java'), - ` -@RestController -@RequestMapping("/api") -public class UserController { - @GetMapping("/users") - public List list() { return null; } -} -`, - ); - const contracts = await extractor.extract(null, dir, makeRepo(dir)); - expect(contracts.filter((c) => c.role === 'provider').length).toBeGreaterThanOrEqual(1); - }); - - it('does NOT false-positive on interface in comments', async () => { - const dir = path.join(tmpDir, 'iface-comment'); - fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(dir, 'src/Api.java'), - ` -// implements the interface UserApi -public class Api { - @GetMapping("/health") - public String health() { return "ok"; } -} -`, - ); - const contracts = await extractor.extract(null, dir, makeRepo(dir)); - expect(contracts.filter((c) => c.role === 'provider').length).toBeGreaterThanOrEqual(1); - }); - - it('does NOT false-positive on interface in a string', async () => { - const dir = path.join(tmpDir, 'iface-str'); - fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(dir, 'src/Svc.java'), - ` -public class Svc { - String desc = "implements interface Foo"; - @GetMapping("/status") - public String status() { return desc; } -} -`, - ); - const contracts = await extractor.extract(null, dir, makeRepo(dir)); - expect(contracts.filter((c) => c.role === 'provider').length).toBeGreaterThanOrEqual(1); - }); - }); - describe('path normalization', () => { it('strips trailing slash', async () => { const dir = path.join(tmpDir, 'trailing'); diff --git a/gitnexus/test/unit/group/manifest-extractor.test.ts b/gitnexus/test/unit/group/manifest-extractor.test.ts new file mode 100644 index 000000000..c2c67a33a --- /dev/null +++ b/gitnexus/test/unit/group/manifest-extractor.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect } from 'vitest'; +import { ManifestExtractor } from '../../../src/core/group/extractors/manifest-extractor.js'; +import type { GroupManifestLink } from '../../../src/core/group/types.js'; + +describe('ManifestExtractor', () => { + const extractor = new ManifestExtractor(); + + it('creates provider + consumer contracts and a cross-link for each manifest link', async () => { + const links: GroupManifestLink[] = [ + { + from: 'hr/payroll/backend', + to: 'hr/hiring/backend', + type: 'topic', + contract: 'employee.hired', + role: 'provider', + }, + ]; + + const result = await extractor.extractFromManifest(links); + + expect(result.contracts).toHaveLength(2); + + const provider = result.contracts.find((c) => c.role === 'provider'); + expect(provider).toBeDefined(); + expect(provider!.contractId).toBe('topic::employee.hired'); + expect(provider!.type).toBe('topic'); + expect(provider!.confidence).toBe(1.0); + + const consumer = result.contracts.find((c) => c.role === 'consumer'); + expect(consumer).toBeDefined(); + expect(consumer!.contractId).toBe('topic::employee.hired'); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('manifest'); + expect(result.crossLinks[0].confidence).toBe(1.0); + expect(result.crossLinks[0].from.repo).toBe('hr/hiring/backend'); + expect(result.crossLinks[0].to.repo).toBe('hr/payroll/backend'); + }); + + it('handles role: consumer (from-repo is consumer)', async () => { + const links: GroupManifestLink[] = [ + { + from: 'sales/admin/bff', + to: 'sales/crm/backend', + type: 'http', + contract: '/api/v2/leads/*', + role: 'consumer', + }, + ]; + + const result = await extractor.extractFromManifest(links); + + const provider = result.contracts.find((c) => c.role === 'provider'); + const consumer = result.contracts.find((c) => c.role === 'consumer'); + + expect(consumer!.contractId).toBe('http::*::/api/v2/leads/*'); + expect(provider!.contractId).toBe('http::*::/api/v2/leads/*'); + + expect(result.crossLinks[0].from.repo).toBe('sales/admin/bff'); + expect(result.crossLinks[0].to.repo).toBe('sales/crm/backend'); + }); + + it('resolves grpc manifest provider by exact method name (no .proto fallback)', async () => { + const links: GroupManifestLink[] = [ + { + from: 'platform/orders', + to: 'platform/auth', + type: 'grpc', + contract: 'auth.AuthService/Login', + role: 'consumer', + }, + ]; + + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'platform/auth', + async (_cypher, params) => { + // Exact match on method name. + if (params?.methodName === 'Login') { + return [ + { + uid: 'uid-auth-login', + name: 'Login', + filePath: 'src/auth.proto', + }, + ]; + } + return []; + }, + ], + [ + 'platform/orders', + async (_cypher, params) => { + // No symbol with the exact method name — resolve returns null and + // the consumer contract gets an empty symbolUid, falling back to + // name-based hint at cross-impact time. + if (params?.methodName === 'Login') return []; + return []; + }, + ], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + + const provider = result.contracts.find((c) => c.role === 'provider'); + const consumer = result.contracts.find((c) => c.role === 'consumer'); + + // Provider resolved to the concrete proto symbol. + expect(provider?.symbolUid).toBe('uid-auth-login'); + expect(provider?.symbolRef.filePath).toBe('src/auth.proto'); + + // Consumer falls back to a deterministic synthetic uid + name-based ref. + // The synthetic uid lets the bridge cross-impact query anchor on it + // even when the indexer doesn't expose a matching symbol. + expect(consumer?.symbolUid).toBe('manifest::platform/orders::grpc::auth.AuthService/Login'); + expect(consumer?.symbolRef.name).toBe('auth.AuthService/Login'); + + expect(result.crossLinks[0].to.symbolRef.filePath).toBe('src/auth.proto'); + expect(result.crossLinks[0].from.symbolUid).toBe( + 'manifest::platform/orders::grpc::auth.AuthService/Login', + ); + }); + + it('does NOT resolve grpc manifest to an arbitrary .proto file', async () => { + // Regression test for a previous bug: the extractor had an unconditional + // `OR n.filePath ENDS WITH '.proto'` fallback that returned the first + // proto symbol in the repo, regardless of whether it matched the contract. + const links: GroupManifestLink[] = [ + { + from: 'platform/orders', + to: 'platform/auth', + type: 'grpc', + contract: 'auth.AuthService/Login', + role: 'consumer', + }, + ]; + + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'platform/auth', + // Executor returns matches for ANY query (simulates the old buggy + // fallback that returned a random .proto file). The new code must + // only accept a hit when the method/service name matches exactly. + async (_cypher, params) => { + if (params?.methodName === 'Login' || params?.serviceName === 'auth.AuthService') { + return [ + { + uid: 'uid-correct-login', + name: 'Login', + filePath: 'src/auth.proto', + }, + ]; + } + return []; + }, + ], + ['platform/orders', async () => []], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + const provider = result.contracts.find((c) => c.role === 'provider'); + // Must resolve to the correct symbol (not a random proto one). + expect(provider?.symbolUid).toBe('uid-correct-login'); + }); + + it('resolves lib manifest links by exact name only', async () => { + const links: GroupManifestLink[] = [ + { + from: 'platform/web', + to: 'platform/shared-lib', + type: 'lib', + contract: '@platform/contracts', + role: 'consumer', + }, + ]; + + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'platform/shared-lib', + async (_cypher, params) => { + if (params?.contract !== '@platform/contracts') return []; + return [ + { + uid: 'uid-lib', + name: '@platform/contracts', + filePath: 'src/index.ts', + }, + ]; + }, + ], + [ + 'platform/web', + async (_cypher, params) => { + if (params?.contract !== '@platform/contracts') return []; + return []; + }, + ], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + + const provider = result.contracts.find((c) => c.role === 'provider'); + const consumer = result.contracts.find((c) => c.role === 'consumer'); + + expect(provider?.symbolUid).toBe('uid-lib'); + // Consumer doesn't have a symbol named exactly '@platform/contracts' — + // exact matching returns null, falling back to the synthetic manifest uid. + expect(consumer?.symbolUid).toBe('manifest::platform/web::lib::@platform/contracts'); + }); + + it('does NOT resolve lib manifest via CONTAINS on name', async () => { + // Regression test: previous CONTAINS fallback would match "react" to + // "react-native" or "@types/react". Exact matching must reject both. + const links: GroupManifestLink[] = [ + { + from: 'web', + to: 'packages/ui', + type: 'lib', + contract: 'react', + role: 'consumer', + }, + ]; + + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'packages/ui', + async (_cypher, params) => { + // Executor is called with contract='react'. Only exact matches + // should come back; return only wrong candidates to verify the + // Cypher uses `=` not `CONTAINS`. + if (params?.contract === 'react') { + // Simulated DB returns nothing because it has only "react-native" + // and "@types/react" — neither is an exact match for "react". + return []; + } + return []; + }, + ], + ['web', async () => []], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + const provider = result.contracts.find((c) => c.role === 'provider'); + // No exact match → synthetic manifest uid, not a wrong real one. + expect(provider?.symbolUid).toBe('manifest::packages/ui::lib::react'); + }); + + it('normalizes http contract path for exact Route.name match', async () => { + // Manifest may be written as "/api/orders/" or "api/orders"; both should + // match the canonical "/api/orders" stored in the graph. + const variants = ['/api/orders', '/api/orders/', 'api/orders', '//api//orders']; + for (const raw of variants) { + const links: GroupManifestLink[] = [ + { + from: 'gateway', + to: 'orders-svc', + type: 'http', + contract: raw, + role: 'consumer', + }, + ]; + + let seenParam: string | undefined; + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'orders-svc', + async (_cypher, params) => { + seenParam = params?.normalized as string; + return [ + { + uid: 'uid-orders-list', + name: 'listOrders', + filePath: 'src/orders.ts', + }, + ]; + }, + ], + ['gateway', async () => []], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + expect(seenParam).toBe('/api/orders'); + const provider = result.contracts.find((c) => c.role === 'provider'); + expect(provider?.symbolUid).toBe('uid-orders-list'); + } + }); + + it('returns empty for no links', async () => { + const result = await extractor.extractFromManifest([]); + expect(result.contracts).toHaveLength(0); + expect(result.crossLinks).toHaveLength(0); + }); +}); diff --git a/gitnexus/test/unit/group/topic-extractor.test.ts b/gitnexus/test/unit/group/topic-extractor.test.ts index c6a1161a0..bf821de63 100644 --- a/gitnexus/test/unit/group/topic-extractor.test.ts +++ b/gitnexus/test/unit/group/topic-extractor.test.ts @@ -75,8 +75,7 @@ public void handleUserCreated(ConsumerRecord record) { it('test_extract_kafkajs_subscribe_returns_consumer', async () => { writeFile( 'src/consumer.ts', - `await consumer.subscribe({ topic: 'order.placed', fromBeginning: true }); -await consumer.run({ eachMessage: async ({ message }) => {} });`, + `await consumer.subscribe({ topic: 'order.placed', fromBeginning: true });`, ); const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); @@ -101,6 +100,23 @@ await consumer.run({ eachMessage: async ({ message }) => {} });`, }); }); + describe('KafkaJS consumer run', () => { + it('test_extract_kafkajs_consumer_run_eachmessage_returns_consumer', async () => { + writeFile( + 'src/consumer.ts', + `await consumer.subscribe({ topic: 'user.logged-in' }); +await consumer.run({ eachMessage: async () => {} });`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::user.logged-in'); + expect(consumers[0].meta.broker).toBe('kafka'); + }); + }); + describe('RabbitMQ — Java', () => { it('test_extract_rabbit_listener_returns_consumer', async () => { writeFile( @@ -174,6 +190,62 @@ public void processOrder(OrderMessage msg) {}`, }); }); + describe('JetStream', () => { + it('test_extract_jetstream_publish_returns_provider', async () => { + writeFile('src/stream.go', `js.Publish("orders.created", payload)`); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::orders.created'); + expect(producers[0].meta.broker).toBe('nats'); + }); + + it('test_extract_jetstream_subscribe_returns_consumer', async () => { + writeFile('src/stream.go', `js.Subscribe("orders.created", handler)`); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::orders.created'); + expect(consumers[0].meta.broker).toBe('nats'); + }); + }); + + describe('Python NATS', () => { + it('test_extract_python_nats_subscribe_returns_consumer', async () => { + writeFile( + 'src/subscriber.py', + `nc = await nats.connect() +await nc.subscribe("orders.created", cb=handler)`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::orders.created'); + expect(consumers[0].meta.broker).toBe('nats'); + }); + + it('test_extract_python_nats_publish_returns_provider', async () => { + writeFile( + 'src/publisher.py', + `nc = await nats.connect() +await nc.publish("orders.created", payload)`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::orders.created'); + expect(producers[0].meta.broker).toBe('nats'); + }); + }); + describe('NATS', () => { it('test_extract_nats_subscribe_go_returns_consumer', async () => { writeFile( @@ -248,6 +320,96 @@ partConsumer, _ := consumer.ConsumePartition("inventory.update", 0, sarama.Offse expect(consumers[0].contractId).toBe('topic::inventory.update'); expect(consumers[0].meta.broker).toBe('kafka'); }); + + it('test_extract_sarama_sync_producer_returns_provider', async () => { + writeFile( + 'internal/publisher.go', + `package publisher +producer, _ := sarama.NewSyncProducer(brokers, cfg) +producer.SendMessage(&sarama.ProducerMessage{Topic: "inventory.update"})`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::inventory.update'); + expect(producers[0].meta.broker).toBe('kafka'); + }); + + it('test_extract_sarama_async_producer_returns_provider', async () => { + writeFile( + 'internal/publisher.go', + `package publisher +producer, _ := sarama.NewAsyncProducer(brokers, cfg) +producer.Input() <- &sarama.ProducerMessage{Topic: "inventory.update"}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::inventory.update'); + expect(producers[0].meta.broker).toBe('kafka'); + }); + + it('test_extract_sarama_producer_in_loop_captures_all_topics', async () => { + // Regression: a for loop that constructs multiple ProducerMessage + // literals inside a single NewSyncProducer scope. The previous + // regex anchored on NewSyncProducer and captured only the first + // Topic within 300 chars, silently dropping the rest. + writeFile( + 'internal/multi-publisher.go', + `package publisher + +func publishAll(producer sarama.SyncProducer, items []Item) error { + _, _ = sarama.NewSyncProducer(brokers, cfg) + for _, item := range items { + msg1 := &sarama.ProducerMessage{Topic: "order.created"} + msg2 := &sarama.ProducerMessage{Topic: "order.shipped"} + _ = msg1 + _ = msg2 + } + return nil +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + const topics = producers.map((c) => c.contractId).sort(); + // Both topics must appear (exact set match to catch any duplicates). + expect(topics).toEqual(['topic::order.created', 'topic::order.shipped']); + }); + + it('test_extract_kafka_go_writer_returns_provider', async () => { + writeFile( + 'internal/writer.go', + `package publisher +writer := &kafka.Writer{Topic: "inventory.update"}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::inventory.update'); + expect(producers[0].meta.broker).toBe('kafka'); + }); + + it('test_extract_kafka_go_reader_returns_consumer', async () => { + writeFile( + 'internal/reader.go', + `package consumer +reader := kafka.NewReader(kafka.ReaderConfig{Topic: "inventory.update"})`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::inventory.update'); + expect(consumers[0].meta.broker).toBe('kafka'); + }); }); describe('Kafka — Python', () => { @@ -309,5 +471,16 @@ await consumer.subscribe({ topic: 'order.placed' });`, expect(producers).toHaveLength(2); expect(consumers).toHaveLength(1); }); + + it('test_extract_ignores_go_test_files', async () => { + writeFile( + 'src/orders_test.go', + `consumer.ConsumePartition("fake-topic", 0, sarama.OffsetNewest)`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(contracts).toEqual([]); + }); }); }); diff --git a/gitnexus/vendor/tree-sitter-proto/.gitignore b/gitnexus/vendor/tree-sitter-proto/.gitignore new file mode 100644 index 000000000..009351ab8 --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/.gitignore @@ -0,0 +1,3 @@ +build/ +node_modules/ +package-lock.json diff --git a/gitnexus/vendor/tree-sitter-proto/binding.gyp b/gitnexus/vendor/tree-sitter-proto/binding.gyp new file mode 100644 index 000000000..53ec4feb8 --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/binding.gyp @@ -0,0 +1,30 @@ +{ + "targets": [ + { + "target_name": "tree_sitter_proto_binding", + "dependencies": [ + " + +typedef struct TSLanguage TSLanguage; + +extern "C" TSLanguage *tree_sitter_proto(); + +// "tree-sitter", "language" hashed with BLAKE2 +const napi_type_tag LANGUAGE_TYPE_TAG = { + 0x8AF2E5212AD58ABF, 0xD5006CAD83ABBA16 +}; + +Napi::Object Init(Napi::Env env, Napi::Object exports) { + exports["name"] = Napi::String::New(env, "proto"); + auto language = Napi::External::New(env, tree_sitter_proto()); + language.TypeTag(&LANGUAGE_TYPE_TAG); + exports["language"] = language; + return exports; +} + +NODE_API_MODULE(tree_sitter_proto_binding, Init) diff --git a/gitnexus/vendor/tree-sitter-proto/bindings/node/index.d.ts b/gitnexus/vendor/tree-sitter-proto/bindings/node/index.d.ts new file mode 100644 index 000000000..efe259eed --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/bindings/node/index.d.ts @@ -0,0 +1,28 @@ +type BaseNode = { + type: string; + named: boolean; +}; + +type ChildNode = { + multiple: boolean; + required: boolean; + types: BaseNode[]; +}; + +type NodeInfo = + | (BaseNode & { + subtypes: BaseNode[]; + }) + | (BaseNode & { + fields: { [name: string]: ChildNode }; + children: ChildNode[]; + }); + +type Language = { + name: string; + language: unknown; + nodeTypeInfo: NodeInfo[]; +}; + +declare const language: Language; +export = language; diff --git a/gitnexus/vendor/tree-sitter-proto/bindings/node/index.js b/gitnexus/vendor/tree-sitter-proto/bindings/node/index.js new file mode 100644 index 000000000..6657bcf42 --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/bindings/node/index.js @@ -0,0 +1,7 @@ +const root = require("path").join(__dirname, "..", ".."); + +module.exports = require("node-gyp-build")(root); + +try { + module.exports.nodeTypeInfo = require("../../src/node-types.json"); +} catch (_) {} diff --git a/gitnexus/vendor/tree-sitter-proto/package.json b/gitnexus/vendor/tree-sitter-proto/package.json new file mode 100644 index 000000000..387f3d9bb --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/package.json @@ -0,0 +1,18 @@ +{ + "name": "tree-sitter-proto", + "version": "0.4.1", + "description": "tree-sitter grammar for protobuf — ABI 14 build from coder3101/tree-sitter-proto latest grammar.js, compatible with tree-sitter 0.25", + "repository": "https://github.com/coder3101/tree-sitter-proto", + "license": "MIT", + "main": "bindings/node", + "scripts": { + "install": "node-gyp-build" + }, + "peerDependencies": { + "tree-sitter": ">=0.21.0" + }, + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + } +} diff --git a/gitnexus/vendor/tree-sitter-proto/src/node-types.json b/gitnexus/vendor/tree-sitter-proto/src/node-types.json new file mode 100644 index 000000000..63f8942a6 --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/src/node-types.json @@ -0,0 +1,1145 @@ +[ + { + "type": "block_lit", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "constant", + "named": true + }, + { + "type": "full_ident", + "named": true + }, + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "bool", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "false", + "named": true + }, + { + "type": "true", + "named": true + } + ] + } + }, + { + "type": "constant", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "block_lit", + "named": true + }, + { + "type": "bool", + "named": true + }, + { + "type": "float_lit", + "named": true + }, + { + "type": "full_ident", + "named": true + }, + { + "type": "int_lit", + "named": true + }, + { + "type": "string", + "named": true + } + ] + } + }, + { + "type": "edition", + "named": true, + "fields": { + "year": { + "multiple": false, + "required": true, + "types": [ + { + "type": "string", + "named": true + } + ] + } + } + }, + { + "type": "empty_statement", + "named": true, + "fields": {} + }, + { + "type": "enum", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "enum_body", + "named": true + }, + { + "type": "enum_name", + "named": true + } + ] + } + }, + { + "type": "enum_body", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "empty_statement", + "named": true + }, + { + "type": "enum_field", + "named": true + }, + { + "type": "option", + "named": true + }, + { + "type": "reserved", + "named": true + } + ] + } + }, + { + "type": "enum_field", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "enum_value_option", + "named": true + }, + { + "type": "identifier", + "named": true + }, + { + "type": "int_lit", + "named": true + } + ] + } + }, + { + "type": "enum_name", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "enum_value_option", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "constant", + "named": true + }, + { + "type": "full_ident", + "named": true + }, + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "extend", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "full_ident", + "named": true + }, + { + "type": "message_body", + "named": true + } + ] + } + }, + { + "type": "extensions", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "ranges", + "named": true + } + ] + } + }, + { + "type": "field", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "field_number", + "named": true + }, + { + "type": "field_options", + "named": true + }, + { + "type": "identifier", + "named": true + }, + { + "type": "type", + "named": true + } + ] + } + }, + { + "type": "field_number", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "int_lit", + "named": true + } + ] + } + }, + { + "type": "field_option", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "constant", + "named": true + }, + { + "type": "full_ident", + "named": true + }, + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "field_options", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "field_option", + "named": true + } + ] + } + }, + { + "type": "full_ident", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "group", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "field_number", + "named": true + }, + { + "type": "message_body", + "named": true + }, + { + "type": "message_name", + "named": true + } + ] + } + }, + { + "type": "import", + "named": true, + "fields": { + "path": { + "multiple": false, + "required": true, + "types": [ + { + "type": "string", + "named": true + } + ] + } + } + }, + { + "type": "int_lit", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "decimal_lit", + "named": true + }, + { + "type": "hex_lit", + "named": true + }, + { + "type": "octal_lit", + "named": true + } + ] + } + }, + { + "type": "key_type", + "named": true, + "fields": {} + }, + { + "type": "map_field", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "field_number", + "named": true + }, + { + "type": "field_options", + "named": true + }, + { + "type": "identifier", + "named": true + }, + { + "type": "key_type", + "named": true + }, + { + "type": "type", + "named": true + } + ] + } + }, + { + "type": "message", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "message_body", + "named": true + }, + { + "type": "message_name", + "named": true + } + ] + } + }, + { + "type": "message_body", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "empty_statement", + "named": true + }, + { + "type": "enum", + "named": true + }, + { + "type": "extend", + "named": true + }, + { + "type": "extensions", + "named": true + }, + { + "type": "field", + "named": true + }, + { + "type": "group", + "named": true + }, + { + "type": "map_field", + "named": true + }, + { + "type": "message", + "named": true + }, + { + "type": "oneof", + "named": true + }, + { + "type": "option", + "named": true + }, + { + "type": "reserved", + "named": true + } + ] + } + }, + { + "type": "message_name", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "message_or_enum_type", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "oneof", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "empty_statement", + "named": true + }, + { + "type": "identifier", + "named": true + }, + { + "type": "oneof_field", + "named": true + }, + { + "type": "option", + "named": true + } + ] + } + }, + { + "type": "oneof_field", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "field_number", + "named": true + }, + { + "type": "field_options", + "named": true + }, + { + "type": "identifier", + "named": true + }, + { + "type": "type", + "named": true + } + ] + } + }, + { + "type": "option", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "constant", + "named": true + }, + { + "type": "full_ident", + "named": true + }, + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "package", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "full_ident", + "named": true + } + ] + } + }, + { + "type": "range", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "int_lit", + "named": true + } + ] + } + }, + { + "type": "ranges", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "range", + "named": true + } + ] + } + }, + { + "type": "reserved", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "ranges", + "named": true + }, + { + "type": "reserved_field_names", + "named": true + } + ] + } + }, + { + "type": "reserved_field_names", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "reserved_identifier", + "named": true + } + ] + } + }, + { + "type": "rpc", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "empty_statement", + "named": true + }, + { + "type": "message_or_enum_type", + "named": true + }, + { + "type": "option", + "named": true + }, + { + "type": "rpc_name", + "named": true + } + ] + } + }, + { + "type": "rpc_name", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "service", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "empty_statement", + "named": true + }, + { + "type": "option", + "named": true + }, + { + "type": "rpc", + "named": true + }, + { + "type": "service_name", + "named": true + } + ] + } + }, + { + "type": "service_name", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "source_file", + "named": true, + "root": true, + "fields": {}, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "edition", + "named": true + }, + { + "type": "empty_statement", + "named": true + }, + { + "type": "enum", + "named": true + }, + { + "type": "extend", + "named": true + }, + { + "type": "import", + "named": true + }, + { + "type": "message", + "named": true + }, + { + "type": "option", + "named": true + }, + { + "type": "package", + "named": true + }, + { + "type": "service", + "named": true + }, + { + "type": "syntax", + "named": true + } + ] + } + }, + { + "type": "string", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "escape_sequence", + "named": true + } + ] + } + }, + { + "type": "syntax", + "named": true, + "fields": {} + }, + { + "type": "type", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": false, + "types": [ + { + "type": "message_or_enum_type", + "named": true + } + ] + } + }, + { + "type": "\"", + "named": false + }, + { + "type": "\"proto2\"", + "named": false + }, + { + "type": "\"proto3\"", + "named": false + }, + { + "type": "'", + "named": false + }, + { + "type": "(", + "named": false + }, + { + "type": ")", + "named": false + }, + { + "type": "+", + "named": false + }, + { + "type": ",", + "named": false + }, + { + "type": "-", + "named": false + }, + { + "type": ".", + "named": false + }, + { + "type": ":", + "named": false + }, + { + "type": ";", + "named": false + }, + { + "type": "<", + "named": false + }, + { + "type": "=", + "named": false + }, + { + "type": ">", + "named": false + }, + { + "type": "[", + "named": false + }, + { + "type": "]", + "named": false + }, + { + "type": "bool", + "named": false + }, + { + "type": "bytes", + "named": false + }, + { + "type": "comment", + "named": true + }, + { + "type": "decimal_lit", + "named": true + }, + { + "type": "double", + "named": false + }, + { + "type": "edition", + "named": false + }, + { + "type": "enum", + "named": false + }, + { + "type": "escape_sequence", + "named": true + }, + { + "type": "export", + "named": false + }, + { + "type": "extend", + "named": false + }, + { + "type": "extensions", + "named": false + }, + { + "type": "false", + "named": true + }, + { + "type": "fixed32", + "named": false + }, + { + "type": "fixed64", + "named": false + }, + { + "type": "float", + "named": false + }, + { + "type": "float_lit", + "named": true + }, + { + "type": "group", + "named": false + }, + { + "type": "hex_lit", + "named": true + }, + { + "type": "identifier", + "named": true + }, + { + "type": "import", + "named": false + }, + { + "type": "int32", + "named": false + }, + { + "type": "int64", + "named": false + }, + { + "type": "local", + "named": false + }, + { + "type": "map", + "named": false + }, + { + "type": "max", + "named": false + }, + { + "type": "message", + "named": false + }, + { + "type": "octal_lit", + "named": true + }, + { + "type": "oneof", + "named": false + }, + { + "type": "option", + "named": false + }, + { + "type": "optional", + "named": false + }, + { + "type": "package", + "named": false + }, + { + "type": "public", + "named": false + }, + { + "type": "repeated", + "named": false + }, + { + "type": "required", + "named": false + }, + { + "type": "reserved", + "named": false + }, + { + "type": "reserved_identifier", + "named": true + }, + { + "type": "returns", + "named": false + }, + { + "type": "rpc", + "named": false + }, + { + "type": "service", + "named": false + }, + { + "type": "sfixed32", + "named": false + }, + { + "type": "sfixed64", + "named": false + }, + { + "type": "sint32", + "named": false + }, + { + "type": "sint64", + "named": false + }, + { + "type": "stream", + "named": false + }, + { + "type": "string", + "named": false + }, + { + "type": "syntax", + "named": false + }, + { + "type": "to", + "named": false + }, + { + "type": "true", + "named": true + }, + { + "type": "uint32", + "named": false + }, + { + "type": "uint64", + "named": false + }, + { + "type": "weak", + "named": false + }, + { + "type": "{", + "named": false + }, + { + "type": "}", + "named": false + } +] \ No newline at end of file diff --git a/gitnexus/vendor/tree-sitter-proto/src/parser.c b/gitnexus/vendor/tree-sitter-proto/src/parser.c new file mode 100644 index 000000000..96b661b8e --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/src/parser.c @@ -0,0 +1,10149 @@ +#include "tree_sitter/parser.h" + +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic ignored "-Wmissing-field-initializers" +#endif + +#ifdef _MSC_VER +#pragma optimize("", off) +#elif defined(__clang__) +#pragma clang optimize off +#elif defined(__GNUC__) +#pragma GCC optimize ("O0") +#endif + +#define LANGUAGE_VERSION 14 +#define STATE_COUNT 345 +#define LARGE_STATE_COUNT 2 +#define SYMBOL_COUNT 133 +#define ALIAS_COUNT 0 +#define TOKEN_COUNT 73 +#define EXTERNAL_TOKEN_COUNT 0 +#define FIELD_COUNT 2 +#define MAX_ALIAS_SEQUENCE_LENGTH 14 +#define PRODUCTION_ID_COUNT 4 + +enum ts_symbol_identifiers { + anon_sym_SEMI = 1, + anon_sym_edition = 2, + anon_sym_EQ = 3, + anon_sym_syntax = 4, + anon_sym_DQUOTEproto3_DQUOTE = 5, + anon_sym_DQUOTEproto2_DQUOTE = 6, + anon_sym_import = 7, + anon_sym_weak = 8, + anon_sym_public = 9, + anon_sym_option = 10, + anon_sym_package = 11, + anon_sym_LPAREN = 12, + anon_sym_RPAREN = 13, + anon_sym_DOT = 14, + anon_sym_export = 15, + anon_sym_local = 16, + anon_sym_enum = 17, + anon_sym_LBRACE = 18, + anon_sym_RBRACE = 19, + anon_sym_DASH = 20, + anon_sym_LBRACK = 21, + anon_sym_COMMA = 22, + anon_sym_RBRACK = 23, + anon_sym_message = 24, + anon_sym_extend = 25, + anon_sym_optional = 26, + anon_sym_required = 27, + anon_sym_repeated = 28, + anon_sym_group = 29, + anon_sym_oneof = 30, + anon_sym_map = 31, + anon_sym_LT = 32, + anon_sym_GT = 33, + anon_sym_int32 = 34, + anon_sym_int64 = 35, + anon_sym_uint32 = 36, + anon_sym_uint64 = 37, + anon_sym_sint32 = 38, + anon_sym_sint64 = 39, + anon_sym_fixed32 = 40, + anon_sym_fixed64 = 41, + anon_sym_sfixed32 = 42, + anon_sym_sfixed64 = 43, + anon_sym_bool = 44, + anon_sym_string = 45, + anon_sym_double = 46, + anon_sym_float = 47, + anon_sym_bytes = 48, + anon_sym_reserved = 49, + anon_sym_extensions = 50, + anon_sym_to = 51, + anon_sym_max = 52, + anon_sym_service = 53, + anon_sym_rpc = 54, + anon_sym_stream = 55, + anon_sym_returns = 56, + anon_sym_PLUS = 57, + anon_sym_COLON = 58, + sym_identifier = 59, + sym_reserved_identifier = 60, + sym_true = 61, + sym_false = 62, + sym_decimal_lit = 63, + sym_octal_lit = 64, + sym_hex_lit = 65, + sym_float_lit = 66, + anon_sym_DQUOTE = 67, + aux_sym_string_token1 = 68, + anon_sym_SQUOTE = 69, + aux_sym_string_token2 = 70, + sym_escape_sequence = 71, + sym_comment = 72, + sym_source_file = 73, + sym_empty_statement = 74, + sym_edition = 75, + sym_syntax = 76, + sym_import = 77, + sym_package = 78, + sym_option = 79, + sym__option_name = 80, + sym_enum = 81, + sym_enum_name = 82, + sym_enum_body = 83, + sym_enum_field = 84, + sym_enum_value_option = 85, + sym_message = 86, + sym_message_body = 87, + sym_message_name = 88, + sym_extend = 89, + sym_group = 90, + sym_field = 91, + sym_field_options = 92, + sym_field_option = 93, + sym_oneof = 94, + sym_oneof_field = 95, + sym_map_field = 96, + sym_key_type = 97, + sym_type = 98, + sym_reserved = 99, + sym_extensions = 100, + sym_ranges = 101, + sym_range = 102, + sym_reserved_field_names = 103, + sym_message_or_enum_type = 104, + sym_field_number = 105, + sym_service = 106, + sym_service_name = 107, + sym_rpc = 108, + sym_rpc_name = 109, + sym_constant = 110, + sym_block_lit = 111, + sym_full_ident = 112, + sym_bool = 113, + sym_int_lit = 114, + sym_string = 115, + aux_sym_source_file_repeat1 = 116, + aux_sym__option_name_repeat1 = 117, + aux_sym_enum_body_repeat1 = 118, + aux_sym_enum_field_repeat1 = 119, + aux_sym_message_body_repeat1 = 120, + aux_sym_field_options_repeat1 = 121, + aux_sym_oneof_repeat1 = 122, + aux_sym_ranges_repeat1 = 123, + aux_sym_reserved_field_names_repeat1 = 124, + aux_sym_message_or_enum_type_repeat1 = 125, + aux_sym_service_repeat1 = 126, + aux_sym_rpc_repeat1 = 127, + aux_sym_block_lit_repeat1 = 128, + aux_sym_block_lit_repeat2 = 129, + aux_sym_string_repeat1 = 130, + aux_sym_string_repeat2 = 131, + aux_sym_string_repeat3 = 132, +}; + +static const char * const ts_symbol_names[] = { + [ts_builtin_sym_end] = "end", + [anon_sym_SEMI] = ";", + [anon_sym_edition] = "edition", + [anon_sym_EQ] = "=", + [anon_sym_syntax] = "syntax", + [anon_sym_DQUOTEproto3_DQUOTE] = "\"proto3\"", + [anon_sym_DQUOTEproto2_DQUOTE] = "\"proto2\"", + [anon_sym_import] = "import", + [anon_sym_weak] = "weak", + [anon_sym_public] = "public", + [anon_sym_option] = "option", + [anon_sym_package] = "package", + [anon_sym_LPAREN] = "(", + [anon_sym_RPAREN] = ")", + [anon_sym_DOT] = ".", + [anon_sym_export] = "export", + [anon_sym_local] = "local", + [anon_sym_enum] = "enum", + [anon_sym_LBRACE] = "{", + [anon_sym_RBRACE] = "}", + [anon_sym_DASH] = "-", + [anon_sym_LBRACK] = "[", + [anon_sym_COMMA] = ",", + [anon_sym_RBRACK] = "]", + [anon_sym_message] = "message", + [anon_sym_extend] = "extend", + [anon_sym_optional] = "optional", + [anon_sym_required] = "required", + [anon_sym_repeated] = "repeated", + [anon_sym_group] = "group", + [anon_sym_oneof] = "oneof", + [anon_sym_map] = "map", + [anon_sym_LT] = "<", + [anon_sym_GT] = ">", + [anon_sym_int32] = "int32", + [anon_sym_int64] = "int64", + [anon_sym_uint32] = "uint32", + [anon_sym_uint64] = "uint64", + [anon_sym_sint32] = "sint32", + [anon_sym_sint64] = "sint64", + [anon_sym_fixed32] = "fixed32", + [anon_sym_fixed64] = "fixed64", + [anon_sym_sfixed32] = "sfixed32", + [anon_sym_sfixed64] = "sfixed64", + [anon_sym_bool] = "bool", + [anon_sym_string] = "string", + [anon_sym_double] = "double", + [anon_sym_float] = "float", + [anon_sym_bytes] = "bytes", + [anon_sym_reserved] = "reserved", + [anon_sym_extensions] = "extensions", + [anon_sym_to] = "to", + [anon_sym_max] = "max", + [anon_sym_service] = "service", + [anon_sym_rpc] = "rpc", + [anon_sym_stream] = "stream", + [anon_sym_returns] = "returns", + [anon_sym_PLUS] = "+", + [anon_sym_COLON] = ":", + [sym_identifier] = "identifier", + [sym_reserved_identifier] = "reserved_identifier", + [sym_true] = "true", + [sym_false] = "false", + [sym_decimal_lit] = "decimal_lit", + [sym_octal_lit] = "octal_lit", + [sym_hex_lit] = "hex_lit", + [sym_float_lit] = "float_lit", + [anon_sym_DQUOTE] = "\"", + [aux_sym_string_token1] = "string_token1", + [anon_sym_SQUOTE] = "'", + [aux_sym_string_token2] = "string_token2", + [sym_escape_sequence] = "escape_sequence", + [sym_comment] = "comment", + [sym_source_file] = "source_file", + [sym_empty_statement] = "empty_statement", + [sym_edition] = "edition", + [sym_syntax] = "syntax", + [sym_import] = "import", + [sym_package] = "package", + [sym_option] = "option", + [sym__option_name] = "_option_name", + [sym_enum] = "enum", + [sym_enum_name] = "enum_name", + [sym_enum_body] = "enum_body", + [sym_enum_field] = "enum_field", + [sym_enum_value_option] = "enum_value_option", + [sym_message] = "message", + [sym_message_body] = "message_body", + [sym_message_name] = "message_name", + [sym_extend] = "extend", + [sym_group] = "group", + [sym_field] = "field", + [sym_field_options] = "field_options", + [sym_field_option] = "field_option", + [sym_oneof] = "oneof", + [sym_oneof_field] = "oneof_field", + [sym_map_field] = "map_field", + [sym_key_type] = "key_type", + [sym_type] = "type", + [sym_reserved] = "reserved", + [sym_extensions] = "extensions", + [sym_ranges] = "ranges", + [sym_range] = "range", + [sym_reserved_field_names] = "reserved_field_names", + [sym_message_or_enum_type] = "message_or_enum_type", + [sym_field_number] = "field_number", + [sym_service] = "service", + [sym_service_name] = "service_name", + [sym_rpc] = "rpc", + [sym_rpc_name] = "rpc_name", + [sym_constant] = "constant", + [sym_block_lit] = "block_lit", + [sym_full_ident] = "full_ident", + [sym_bool] = "bool", + [sym_int_lit] = "int_lit", + [sym_string] = "string", + [aux_sym_source_file_repeat1] = "source_file_repeat1", + [aux_sym__option_name_repeat1] = "_option_name_repeat1", + [aux_sym_enum_body_repeat1] = "enum_body_repeat1", + [aux_sym_enum_field_repeat1] = "enum_field_repeat1", + [aux_sym_message_body_repeat1] = "message_body_repeat1", + [aux_sym_field_options_repeat1] = "field_options_repeat1", + [aux_sym_oneof_repeat1] = "oneof_repeat1", + [aux_sym_ranges_repeat1] = "ranges_repeat1", + [aux_sym_reserved_field_names_repeat1] = "reserved_field_names_repeat1", + [aux_sym_message_or_enum_type_repeat1] = "message_or_enum_type_repeat1", + [aux_sym_service_repeat1] = "service_repeat1", + [aux_sym_rpc_repeat1] = "rpc_repeat1", + [aux_sym_block_lit_repeat1] = "block_lit_repeat1", + [aux_sym_block_lit_repeat2] = "block_lit_repeat2", + [aux_sym_string_repeat1] = "string_repeat1", + [aux_sym_string_repeat2] = "string_repeat2", + [aux_sym_string_repeat3] = "string_repeat3", +}; + +static const TSSymbol ts_symbol_map[] = { + [ts_builtin_sym_end] = ts_builtin_sym_end, + [anon_sym_SEMI] = anon_sym_SEMI, + [anon_sym_edition] = anon_sym_edition, + [anon_sym_EQ] = anon_sym_EQ, + [anon_sym_syntax] = anon_sym_syntax, + [anon_sym_DQUOTEproto3_DQUOTE] = anon_sym_DQUOTEproto3_DQUOTE, + [anon_sym_DQUOTEproto2_DQUOTE] = anon_sym_DQUOTEproto2_DQUOTE, + [anon_sym_import] = anon_sym_import, + [anon_sym_weak] = anon_sym_weak, + [anon_sym_public] = anon_sym_public, + [anon_sym_option] = anon_sym_option, + [anon_sym_package] = anon_sym_package, + [anon_sym_LPAREN] = anon_sym_LPAREN, + [anon_sym_RPAREN] = anon_sym_RPAREN, + [anon_sym_DOT] = anon_sym_DOT, + [anon_sym_export] = anon_sym_export, + [anon_sym_local] = anon_sym_local, + [anon_sym_enum] = anon_sym_enum, + [anon_sym_LBRACE] = anon_sym_LBRACE, + [anon_sym_RBRACE] = anon_sym_RBRACE, + [anon_sym_DASH] = anon_sym_DASH, + [anon_sym_LBRACK] = anon_sym_LBRACK, + [anon_sym_COMMA] = anon_sym_COMMA, + [anon_sym_RBRACK] = anon_sym_RBRACK, + [anon_sym_message] = anon_sym_message, + [anon_sym_extend] = anon_sym_extend, + [anon_sym_optional] = anon_sym_optional, + [anon_sym_required] = anon_sym_required, + [anon_sym_repeated] = anon_sym_repeated, + [anon_sym_group] = anon_sym_group, + [anon_sym_oneof] = anon_sym_oneof, + [anon_sym_map] = anon_sym_map, + [anon_sym_LT] = anon_sym_LT, + [anon_sym_GT] = anon_sym_GT, + [anon_sym_int32] = anon_sym_int32, + [anon_sym_int64] = anon_sym_int64, + [anon_sym_uint32] = anon_sym_uint32, + [anon_sym_uint64] = anon_sym_uint64, + [anon_sym_sint32] = anon_sym_sint32, + [anon_sym_sint64] = anon_sym_sint64, + [anon_sym_fixed32] = anon_sym_fixed32, + [anon_sym_fixed64] = anon_sym_fixed64, + [anon_sym_sfixed32] = anon_sym_sfixed32, + [anon_sym_sfixed64] = anon_sym_sfixed64, + [anon_sym_bool] = anon_sym_bool, + [anon_sym_string] = anon_sym_string, + [anon_sym_double] = anon_sym_double, + [anon_sym_float] = anon_sym_float, + [anon_sym_bytes] = anon_sym_bytes, + [anon_sym_reserved] = anon_sym_reserved, + [anon_sym_extensions] = anon_sym_extensions, + [anon_sym_to] = anon_sym_to, + [anon_sym_max] = anon_sym_max, + [anon_sym_service] = anon_sym_service, + [anon_sym_rpc] = anon_sym_rpc, + [anon_sym_stream] = anon_sym_stream, + [anon_sym_returns] = anon_sym_returns, + [anon_sym_PLUS] = anon_sym_PLUS, + [anon_sym_COLON] = anon_sym_COLON, + [sym_identifier] = sym_identifier, + [sym_reserved_identifier] = sym_reserved_identifier, + [sym_true] = sym_true, + [sym_false] = sym_false, + [sym_decimal_lit] = sym_decimal_lit, + [sym_octal_lit] = sym_octal_lit, + [sym_hex_lit] = sym_hex_lit, + [sym_float_lit] = sym_float_lit, + [anon_sym_DQUOTE] = anon_sym_DQUOTE, + [aux_sym_string_token1] = aux_sym_string_token1, + [anon_sym_SQUOTE] = anon_sym_SQUOTE, + [aux_sym_string_token2] = aux_sym_string_token2, + [sym_escape_sequence] = sym_escape_sequence, + [sym_comment] = sym_comment, + [sym_source_file] = sym_source_file, + [sym_empty_statement] = sym_empty_statement, + [sym_edition] = sym_edition, + [sym_syntax] = sym_syntax, + [sym_import] = sym_import, + [sym_package] = sym_package, + [sym_option] = sym_option, + [sym__option_name] = sym__option_name, + [sym_enum] = sym_enum, + [sym_enum_name] = sym_enum_name, + [sym_enum_body] = sym_enum_body, + [sym_enum_field] = sym_enum_field, + [sym_enum_value_option] = sym_enum_value_option, + [sym_message] = sym_message, + [sym_message_body] = sym_message_body, + [sym_message_name] = sym_message_name, + [sym_extend] = sym_extend, + [sym_group] = sym_group, + [sym_field] = sym_field, + [sym_field_options] = sym_field_options, + [sym_field_option] = sym_field_option, + [sym_oneof] = sym_oneof, + [sym_oneof_field] = sym_oneof_field, + [sym_map_field] = sym_map_field, + [sym_key_type] = sym_key_type, + [sym_type] = sym_type, + [sym_reserved] = sym_reserved, + [sym_extensions] = sym_extensions, + [sym_ranges] = sym_ranges, + [sym_range] = sym_range, + [sym_reserved_field_names] = sym_reserved_field_names, + [sym_message_or_enum_type] = sym_message_or_enum_type, + [sym_field_number] = sym_field_number, + [sym_service] = sym_service, + [sym_service_name] = sym_service_name, + [sym_rpc] = sym_rpc, + [sym_rpc_name] = sym_rpc_name, + [sym_constant] = sym_constant, + [sym_block_lit] = sym_block_lit, + [sym_full_ident] = sym_full_ident, + [sym_bool] = sym_bool, + [sym_int_lit] = sym_int_lit, + [sym_string] = sym_string, + [aux_sym_source_file_repeat1] = aux_sym_source_file_repeat1, + [aux_sym__option_name_repeat1] = aux_sym__option_name_repeat1, + [aux_sym_enum_body_repeat1] = aux_sym_enum_body_repeat1, + [aux_sym_enum_field_repeat1] = aux_sym_enum_field_repeat1, + [aux_sym_message_body_repeat1] = aux_sym_message_body_repeat1, + [aux_sym_field_options_repeat1] = aux_sym_field_options_repeat1, + [aux_sym_oneof_repeat1] = aux_sym_oneof_repeat1, + [aux_sym_ranges_repeat1] = aux_sym_ranges_repeat1, + [aux_sym_reserved_field_names_repeat1] = aux_sym_reserved_field_names_repeat1, + [aux_sym_message_or_enum_type_repeat1] = aux_sym_message_or_enum_type_repeat1, + [aux_sym_service_repeat1] = aux_sym_service_repeat1, + [aux_sym_rpc_repeat1] = aux_sym_rpc_repeat1, + [aux_sym_block_lit_repeat1] = aux_sym_block_lit_repeat1, + [aux_sym_block_lit_repeat2] = aux_sym_block_lit_repeat2, + [aux_sym_string_repeat1] = aux_sym_string_repeat1, + [aux_sym_string_repeat2] = aux_sym_string_repeat2, + [aux_sym_string_repeat3] = aux_sym_string_repeat3, +}; + +static const TSSymbolMetadata ts_symbol_metadata[] = { + [ts_builtin_sym_end] = { + .visible = false, + .named = true, + }, + [anon_sym_SEMI] = { + .visible = true, + .named = false, + }, + [anon_sym_edition] = { + .visible = true, + .named = false, + }, + [anon_sym_EQ] = { + .visible = true, + .named = false, + }, + [anon_sym_syntax] = { + .visible = true, + .named = false, + }, + [anon_sym_DQUOTEproto3_DQUOTE] = { + .visible = true, + .named = false, + }, + [anon_sym_DQUOTEproto2_DQUOTE] = { + .visible = true, + .named = false, + }, + [anon_sym_import] = { + .visible = true, + .named = false, + }, + [anon_sym_weak] = { + .visible = true, + .named = false, + }, + [anon_sym_public] = { + .visible = true, + .named = false, + }, + [anon_sym_option] = { + .visible = true, + .named = false, + }, + [anon_sym_package] = { + .visible = true, + .named = false, + }, + [anon_sym_LPAREN] = { + .visible = true, + .named = false, + }, + [anon_sym_RPAREN] = { + .visible = true, + .named = false, + }, + [anon_sym_DOT] = { + .visible = true, + .named = false, + }, + [anon_sym_export] = { + .visible = true, + .named = false, + }, + [anon_sym_local] = { + .visible = true, + .named = false, + }, + [anon_sym_enum] = { + .visible = true, + .named = false, + }, + [anon_sym_LBRACE] = { + .visible = true, + .named = false, + }, + [anon_sym_RBRACE] = { + .visible = true, + .named = false, + }, + [anon_sym_DASH] = { + .visible = true, + .named = false, + }, + [anon_sym_LBRACK] = { + .visible = true, + .named = false, + }, + [anon_sym_COMMA] = { + .visible = true, + .named = false, + }, + [anon_sym_RBRACK] = { + .visible = true, + .named = false, + }, + [anon_sym_message] = { + .visible = true, + .named = false, + }, + [anon_sym_extend] = { + .visible = true, + .named = false, + }, + [anon_sym_optional] = { + .visible = true, + .named = false, + }, + [anon_sym_required] = { + .visible = true, + .named = false, + }, + [anon_sym_repeated] = { + .visible = true, + .named = false, + }, + [anon_sym_group] = { + .visible = true, + .named = false, + }, + [anon_sym_oneof] = { + .visible = true, + .named = false, + }, + [anon_sym_map] = { + .visible = true, + .named = false, + }, + [anon_sym_LT] = { + .visible = true, + .named = false, + }, + [anon_sym_GT] = { + .visible = true, + .named = false, + }, + [anon_sym_int32] = { + .visible = true, + .named = false, + }, + [anon_sym_int64] = { + .visible = true, + .named = false, + }, + [anon_sym_uint32] = { + .visible = true, + .named = false, + }, + [anon_sym_uint64] = { + .visible = true, + .named = false, + }, + [anon_sym_sint32] = { + .visible = true, + .named = false, + }, + [anon_sym_sint64] = { + .visible = true, + .named = false, + }, + [anon_sym_fixed32] = { + .visible = true, + .named = false, + }, + [anon_sym_fixed64] = { + .visible = true, + .named = false, + }, + [anon_sym_sfixed32] = { + .visible = true, + .named = false, + }, + [anon_sym_sfixed64] = { + .visible = true, + .named = false, + }, + [anon_sym_bool] = { + .visible = true, + .named = false, + }, + [anon_sym_string] = { + .visible = true, + .named = false, + }, + [anon_sym_double] = { + .visible = true, + .named = false, + }, + [anon_sym_float] = { + .visible = true, + .named = false, + }, + [anon_sym_bytes] = { + .visible = true, + .named = false, + }, + [anon_sym_reserved] = { + .visible = true, + .named = false, + }, + [anon_sym_extensions] = { + .visible = true, + .named = false, + }, + [anon_sym_to] = { + .visible = true, + .named = false, + }, + [anon_sym_max] = { + .visible = true, + .named = false, + }, + [anon_sym_service] = { + .visible = true, + .named = false, + }, + [anon_sym_rpc] = { + .visible = true, + .named = false, + }, + [anon_sym_stream] = { + .visible = true, + .named = false, + }, + [anon_sym_returns] = { + .visible = true, + .named = false, + }, + [anon_sym_PLUS] = { + .visible = true, + .named = false, + }, + [anon_sym_COLON] = { + .visible = true, + .named = false, + }, + [sym_identifier] = { + .visible = true, + .named = true, + }, + [sym_reserved_identifier] = { + .visible = true, + .named = true, + }, + [sym_true] = { + .visible = true, + .named = true, + }, + [sym_false] = { + .visible = true, + .named = true, + }, + [sym_decimal_lit] = { + .visible = true, + .named = true, + }, + [sym_octal_lit] = { + .visible = true, + .named = true, + }, + [sym_hex_lit] = { + .visible = true, + .named = true, + }, + [sym_float_lit] = { + .visible = true, + .named = true, + }, + [anon_sym_DQUOTE] = { + .visible = true, + .named = false, + }, + [aux_sym_string_token1] = { + .visible = false, + .named = false, + }, + [anon_sym_SQUOTE] = { + .visible = true, + .named = false, + }, + [aux_sym_string_token2] = { + .visible = false, + .named = false, + }, + [sym_escape_sequence] = { + .visible = true, + .named = true, + }, + [sym_comment] = { + .visible = true, + .named = true, + }, + [sym_source_file] = { + .visible = true, + .named = true, + }, + [sym_empty_statement] = { + .visible = true, + .named = true, + }, + [sym_edition] = { + .visible = true, + .named = true, + }, + [sym_syntax] = { + .visible = true, + .named = true, + }, + [sym_import] = { + .visible = true, + .named = true, + }, + [sym_package] = { + .visible = true, + .named = true, + }, + [sym_option] = { + .visible = true, + .named = true, + }, + [sym__option_name] = { + .visible = false, + .named = true, + }, + [sym_enum] = { + .visible = true, + .named = true, + }, + [sym_enum_name] = { + .visible = true, + .named = true, + }, + [sym_enum_body] = { + .visible = true, + .named = true, + }, + [sym_enum_field] = { + .visible = true, + .named = true, + }, + [sym_enum_value_option] = { + .visible = true, + .named = true, + }, + [sym_message] = { + .visible = true, + .named = true, + }, + [sym_message_body] = { + .visible = true, + .named = true, + }, + [sym_message_name] = { + .visible = true, + .named = true, + }, + [sym_extend] = { + .visible = true, + .named = true, + }, + [sym_group] = { + .visible = true, + .named = true, + }, + [sym_field] = { + .visible = true, + .named = true, + }, + [sym_field_options] = { + .visible = true, + .named = true, + }, + [sym_field_option] = { + .visible = true, + .named = true, + }, + [sym_oneof] = { + .visible = true, + .named = true, + }, + [sym_oneof_field] = { + .visible = true, + .named = true, + }, + [sym_map_field] = { + .visible = true, + .named = true, + }, + [sym_key_type] = { + .visible = true, + .named = true, + }, + [sym_type] = { + .visible = true, + .named = true, + }, + [sym_reserved] = { + .visible = true, + .named = true, + }, + [sym_extensions] = { + .visible = true, + .named = true, + }, + [sym_ranges] = { + .visible = true, + .named = true, + }, + [sym_range] = { + .visible = true, + .named = true, + }, + [sym_reserved_field_names] = { + .visible = true, + .named = true, + }, + [sym_message_or_enum_type] = { + .visible = true, + .named = true, + }, + [sym_field_number] = { + .visible = true, + .named = true, + }, + [sym_service] = { + .visible = true, + .named = true, + }, + [sym_service_name] = { + .visible = true, + .named = true, + }, + [sym_rpc] = { + .visible = true, + .named = true, + }, + [sym_rpc_name] = { + .visible = true, + .named = true, + }, + [sym_constant] = { + .visible = true, + .named = true, + }, + [sym_block_lit] = { + .visible = true, + .named = true, + }, + [sym_full_ident] = { + .visible = true, + .named = true, + }, + [sym_bool] = { + .visible = true, + .named = true, + }, + [sym_int_lit] = { + .visible = true, + .named = true, + }, + [sym_string] = { + .visible = true, + .named = true, + }, + [aux_sym_source_file_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym__option_name_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_enum_body_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_enum_field_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_message_body_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_field_options_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_oneof_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_ranges_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_reserved_field_names_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_message_or_enum_type_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_service_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_rpc_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_block_lit_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_block_lit_repeat2] = { + .visible = false, + .named = false, + }, + [aux_sym_string_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_string_repeat2] = { + .visible = false, + .named = false, + }, + [aux_sym_string_repeat3] = { + .visible = false, + .named = false, + }, +}; + +enum ts_field_identifiers { + field_path = 1, + field_year = 2, +}; + +static const char * const ts_field_names[] = { + [0] = NULL, + [field_path] = "path", + [field_year] = "year", +}; + +static const TSFieldMapSlice ts_field_map_slices[PRODUCTION_ID_COUNT] = { + [1] = {.index = 0, .length = 1}, + [2] = {.index = 1, .length = 1}, + [3] = {.index = 2, .length = 1}, +}; + +static const TSFieldMapEntry ts_field_map_entries[] = { + [0] = + {field_path, 1}, + [1] = + {field_year, 2}, + [2] = + {field_path, 2}, +}; + +static const TSSymbol ts_alias_sequences[PRODUCTION_ID_COUNT][MAX_ALIAS_SEQUENCE_LENGTH] = { + [0] = {0}, +}; + +static const uint16_t ts_non_terminal_alias_map[] = { + 0, +}; + +static const TSStateId ts_primary_state_ids[STATE_COUNT] = { + [0] = 0, + [1] = 1, + [2] = 2, + [3] = 3, + [4] = 4, + [5] = 3, + [6] = 2, + [7] = 7, + [8] = 8, + [9] = 9, + [10] = 10, + [11] = 11, + [12] = 12, + [13] = 13, + [14] = 14, + [15] = 15, + [16] = 16, + [17] = 17, + [18] = 18, + [19] = 19, + [20] = 20, + [21] = 21, + [22] = 22, + [23] = 23, + [24] = 24, + [25] = 25, + [26] = 26, + [27] = 27, + [28] = 28, + [29] = 29, + [30] = 30, + [31] = 31, + [32] = 32, + [33] = 33, + [34] = 34, + [35] = 35, + [36] = 36, + [37] = 37, + [38] = 38, + [39] = 39, + [40] = 40, + [41] = 41, + [42] = 42, + [43] = 43, + [44] = 44, + [45] = 45, + [46] = 46, + [47] = 47, + [48] = 48, + [49] = 49, + [50] = 50, + [51] = 51, + [52] = 52, + [53] = 53, + [54] = 54, + [55] = 30, + [56] = 8, + [57] = 57, + [58] = 57, + [59] = 59, + [60] = 60, + [61] = 61, + [62] = 57, + [63] = 57, + [64] = 8, + [65] = 65, + [66] = 30, + [67] = 67, + [68] = 68, + [69] = 28, + [70] = 22, + [71] = 71, + [72] = 7, + [73] = 73, + [74] = 21, + [75] = 23, + [76] = 76, + [77] = 77, + [78] = 78, + [79] = 24, + [80] = 25, + [81] = 26, + [82] = 27, + [83] = 83, + [84] = 84, + [85] = 85, + [86] = 86, + [87] = 87, + [88] = 86, + [89] = 89, + [90] = 90, + [91] = 87, + [92] = 92, + [93] = 93, + [94] = 94, + [95] = 95, + [96] = 96, + [97] = 97, + [98] = 95, + [99] = 99, + [100] = 100, + [101] = 101, + [102] = 102, + [103] = 103, + [104] = 104, + [105] = 105, + [106] = 106, + [107] = 107, + [108] = 108, + [109] = 39, + [110] = 110, + [111] = 111, + [112] = 112, + [113] = 113, + [114] = 114, + [115] = 115, + [116] = 116, + [117] = 117, + [118] = 118, + [119] = 119, + [120] = 120, + [121] = 121, + [122] = 122, + [123] = 123, + [124] = 30, + [125] = 125, + [126] = 126, + [127] = 127, + [128] = 39, + [129] = 129, + [130] = 130, + [131] = 131, + [132] = 132, + [133] = 8, + [134] = 134, + [135] = 135, + [136] = 136, + [137] = 137, + [138] = 138, + [139] = 139, + [140] = 140, + [141] = 141, + [142] = 142, + [143] = 143, + [144] = 116, + [145] = 145, + [146] = 146, + [147] = 147, + [148] = 29, + [149] = 149, + [150] = 150, + [151] = 151, + [152] = 152, + [153] = 153, + [154] = 154, + [155] = 155, + [156] = 156, + [157] = 157, + [158] = 158, + [159] = 159, + [160] = 160, + [161] = 161, + [162] = 162, + [163] = 163, + [164] = 164, + [165] = 165, + [166] = 166, + [167] = 167, + [168] = 168, + [169] = 169, + [170] = 170, + [171] = 171, + [172] = 172, + [173] = 173, + [174] = 174, + [175] = 175, + [176] = 176, + [177] = 177, + [178] = 178, + [179] = 179, + [180] = 180, + [181] = 181, + [182] = 182, + [183] = 183, + [184] = 184, + [185] = 185, + [186] = 186, + [187] = 187, + [188] = 188, + [189] = 189, + [190] = 190, + [191] = 191, + [192] = 192, + [193] = 193, + [194] = 194, + [195] = 195, + [196] = 196, + [197] = 197, + [198] = 198, + [199] = 199, + [200] = 200, + [201] = 201, + [202] = 202, + [203] = 203, + [204] = 204, + [205] = 188, + [206] = 206, + [207] = 207, + [208] = 208, + [209] = 209, + [210] = 210, + [211] = 211, + [212] = 212, + [213] = 213, + [214] = 214, + [215] = 188, + [216] = 188, + [217] = 217, + [218] = 218, + [219] = 219, + [220] = 220, + [221] = 221, + [222] = 222, + [223] = 223, + [224] = 224, + [225] = 225, + [226] = 226, + [227] = 227, + [228] = 228, + [229] = 229, + [230] = 230, + [231] = 231, + [232] = 232, + [233] = 233, + [234] = 234, + [235] = 235, + [236] = 236, + [237] = 237, + [238] = 238, + [239] = 239, + [240] = 240, + [241] = 241, + [242] = 242, + [243] = 243, + [244] = 244, + [245] = 245, + [246] = 246, + [247] = 247, + [248] = 248, + [249] = 249, + [250] = 250, + [251] = 251, + [252] = 252, + [253] = 253, + [254] = 254, + [255] = 255, + [256] = 256, + [257] = 247, + [258] = 258, + [259] = 259, + [260] = 243, + [261] = 244, + [262] = 258, + [263] = 221, + [264] = 228, + [265] = 255, + [266] = 227, + [267] = 248, + [268] = 259, + [269] = 269, + [270] = 254, + [271] = 271, + [272] = 272, + [273] = 273, + [274] = 274, + [275] = 275, + [276] = 276, + [277] = 277, + [278] = 278, + [279] = 279, + [280] = 280, + [281] = 281, + [282] = 282, + [283] = 283, + [284] = 284, + [285] = 285, + [286] = 286, + [287] = 287, + [288] = 288, + [289] = 289, + [290] = 290, + [291] = 291, + [292] = 292, + [293] = 293, + [294] = 294, + [295] = 295, + [296] = 296, + [297] = 297, + [298] = 298, + [299] = 299, + [300] = 300, + [301] = 301, + [302] = 302, + [303] = 303, + [304] = 304, + [305] = 305, + [306] = 306, + [307] = 307, + [308] = 308, + [309] = 309, + [310] = 310, + [311] = 311, + [312] = 312, + [313] = 313, + [314] = 314, + [315] = 315, + [316] = 316, + [317] = 317, + [318] = 318, + [319] = 319, + [320] = 320, + [321] = 321, + [322] = 308, + [323] = 323, + [324] = 324, + [325] = 303, + [326] = 308, + [327] = 308, + [328] = 328, + [329] = 329, + [330] = 330, + [331] = 331, + [332] = 332, + [333] = 333, + [334] = 334, + [335] = 335, + [336] = 336, + [337] = 337, + [338] = 338, + [339] = 338, + [340] = 338, + [341] = 341, + [342] = 342, + [343] = 343, + [344] = 338, +}; + +static bool ts_lex(TSLexer *lexer, TSStateId state) { + START_LEXER(); + eof = lexer->eof(lexer); + switch (state) { + case 0: + if (eof) ADVANCE(212); + ADVANCE_MAP( + '"', 450, + '\'', 457, + '(', 227, + ')', 228, + '+', 302, + ',', 241, + '-', 239, + '.', 230, + '/', 11, + '0', 442, + ':', 303, + ';', 213, + '<', 259, + '=', 215, + '>', 260, + '[', 240, + '\\', 38, + ']', 242, + 'b', 138, + 'd', 132, + 'e', 64, + 'f', 39, + 'g', 165, + 'i', 115, + 'l', 133, + 'm', 40, + 'n', 41, + 'o', 123, + 'p', 44, + 'r', 68, + 's', 76, + 't', 134, + 'u', 105, + 'w', 78, + '{', 237, + '}', 238, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(210); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(440); + END_STATE(); + case 1: + ADVANCE_MAP( + '"', 450, + '\'', 457, + '(', 227, + ')', 228, + ',', 241, + '.', 229, + '/', 11, + ';', 213, + '=', 215, + '>', 260, + '[', 240, + ']', 242, + '{', 237, + '}', 238, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(1); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 2: + ADVANCE_MAP( + '"', 450, + '\'', 457, + '+', 302, + '-', 239, + '.', 197, + '/', 11, + '0', 442, + ':', 303, + '[', 240, + ']', 242, + 'f', 324, + 'i', 380, + 'n', 325, + 't', 405, + '{', 237, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(2); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(440); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 3: + if (lookahead == '"') ADVANCE(450); + if (lookahead == '/') ADVANCE(452); + if (lookahead == '\\') ADVANCE(38); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') ADVANCE(455); + if (lookahead != 0) ADVANCE(456); + END_STATE(); + case 4: + if (lookahead == '"') ADVANCE(218); + END_STATE(); + case 5: + if (lookahead == '"') ADVANCE(217); + END_STATE(); + case 6: + if (lookahead == '"') ADVANCE(208); + if (lookahead == '\'') ADVANCE(209); + if (lookahead == '/') ADVANCE(11); + if (lookahead == '0') ADVANCE(444); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(6); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(441); + if (('A' <= lookahead && lookahead <= 'Z') || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(435); + END_STATE(); + case 7: + if (lookahead == '"') ADVANCE(434); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(7); + END_STATE(); + case 8: + ADVANCE_MAP( + '"', 153, + '.', 229, + '/', 11, + ';', 213, + 'b', 390, + 'd', 385, + 'e', 379, + 'f', 358, + 'g', 408, + 'i', 378, + 'l', 386, + 'm', 319, + 'o', 377, + 'r', 335, + 's', 355, + 'u', 364, + '}', 238, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(8); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 9: + if (lookahead == '\'') ADVANCE(457); + if (lookahead == '/') ADVANCE(459); + if (lookahead == '\\') ADVANCE(38); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') ADVANCE(462); + if (lookahead != 0) ADVANCE(463); + END_STATE(); + case 10: + if (lookahead == '\'') ADVANCE(434); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(10); + END_STATE(); + case 11: + if (lookahead == '*') ADVANCE(13); + if (lookahead == '/') ADVANCE(468); + END_STATE(); + case 12: + if (lookahead == '*') ADVANCE(12); + if (lookahead == '/') ADVANCE(467); + if (lookahead != 0) ADVANCE(13); + END_STATE(); + case 13: + if (lookahead == '*') ADVANCE(12); + if (lookahead != 0) ADVANCE(13); + END_STATE(); + case 14: + if (lookahead == '.') ADVANCE(448); + if (lookahead == 'E' || + lookahead == 'e') ADVANCE(196); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(14); + END_STATE(); + case 15: + ADVANCE_MAP( + '.', 229, + '/', 11, + ';', 213, + '[', 240, + 'b', 390, + 'd', 385, + 'f', 358, + 'i', 378, + 'o', 401, + 's', 355, + 'u', 364, + '{', 237, + '}', 238, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(15); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 16: + ADVANCE_MAP( + '.', 229, + '/', 11, + 'b', 390, + 'd', 385, + 'f', 358, + 'g', 408, + 'i', 378, + 'r', 346, + 's', 355, + 'u', 364, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(16); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 17: + ADVANCE_MAP( + '.', 229, + '/', 11, + 'b', 390, + 'd', 385, + 'f', 358, + 'g', 408, + 'i', 378, + 's', 355, + 'u', 364, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(17); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 18: + ADVANCE_MAP( + '.', 229, + '/', 11, + 'b', 390, + 'd', 385, + 'f', 358, + 'i', 378, + 's', 355, + 'u', 364, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(18); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 19: + if (lookahead == '.') ADVANCE(229); + if (lookahead == '/') ADVANCE(11); + if (lookahead == 's') ADVANCE(422); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(19); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 20: + if (lookahead == '.') ADVANCE(197); + if (lookahead == '/') ADVANCE(11); + if (lookahead == '0') ADVANCE(442); + if (lookahead == 'i') ADVANCE(124); + if (lookahead == 'n') ADVANCE(41); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(20); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(440); + END_STATE(); + case 21: + if (lookahead == '/') ADVANCE(11); + if (lookahead == ';') ADVANCE(213); + if (lookahead == 'o') ADVANCE(401); + if (lookahead == 'r') ADVANCE(351); + if (lookahead == '}') ADVANCE(238); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(21); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 22: + if (lookahead == '2') ADVANCE(261); + END_STATE(); + case 23: + if (lookahead == '2') ADVANCE(269); + END_STATE(); + case 24: + if (lookahead == '2') ADVANCE(265); + END_STATE(); + case 25: + if (lookahead == '2') ADVANCE(273); + END_STATE(); + case 26: + if (lookahead == '2') ADVANCE(277); + END_STATE(); + case 27: + if (lookahead == '2') ADVANCE(4); + if (lookahead == '3') ADVANCE(5); + END_STATE(); + case 28: + if (lookahead == '3') ADVANCE(22); + if (lookahead == '6') ADVANCE(33); + END_STATE(); + case 29: + if (lookahead == '3') ADVANCE(23); + if (lookahead == '6') ADVANCE(34); + END_STATE(); + case 30: + if (lookahead == '3') ADVANCE(24); + if (lookahead == '6') ADVANCE(35); + END_STATE(); + case 31: + if (lookahead == '3') ADVANCE(25); + if (lookahead == '6') ADVANCE(36); + END_STATE(); + case 32: + if (lookahead == '3') ADVANCE(26); + if (lookahead == '6') ADVANCE(37); + END_STATE(); + case 33: + if (lookahead == '4') ADVANCE(263); + END_STATE(); + case 34: + if (lookahead == '4') ADVANCE(271); + END_STATE(); + case 35: + if (lookahead == '4') ADVANCE(267); + END_STATE(); + case 36: + if (lookahead == '4') ADVANCE(275); + END_STATE(); + case 37: + if (lookahead == '4') ADVANCE(279); + END_STATE(); + case 38: + if (lookahead == 'U') ADVANCE(207); + if (lookahead == 'u') ADVANCE(203); + if (lookahead == 'x') ADVANCE(201); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(466); + if (lookahead != 0) ADVANCE(464); + END_STATE(); + case 39: + if (lookahead == 'a') ADVANCE(111); + if (lookahead == 'i') ADVANCE(194); + if (lookahead == 'l') ADVANCE(139); + END_STATE(); + case 40: + if (lookahead == 'a') ADVANCE(148); + if (lookahead == 'e') ADVANCE(169); + END_STATE(); + case 41: + if (lookahead == 'a') ADVANCE(118); + END_STATE(); + case 42: + if (lookahead == 'a') ADVANCE(94); + END_STATE(); + case 43: + if (lookahead == 'a') ADVANCE(193); + END_STATE(); + case 44: + if (lookahead == 'a') ADVANCE(54); + if (lookahead == 'u') ADVANCE(52); + END_STATE(); + case 45: + if (lookahead == 'a') ADVANCE(117); + END_STATE(); + case 46: + if (lookahead == 'a') ADVANCE(106); + END_STATE(); + case 47: + if (lookahead == 'a') ADVANCE(192); + if (lookahead == 'e') ADVANCE(169); + END_STATE(); + case 48: + if (lookahead == 'a') ADVANCE(172); + END_STATE(); + case 49: + if (lookahead == 'a') ADVANCE(109); + END_STATE(); + case 50: + if (lookahead == 'a') ADVANCE(179); + END_STATE(); + case 51: + if (lookahead == 'a') ADVANCE(95); + END_STATE(); + case 52: + if (lookahead == 'b') ADVANCE(112); + END_STATE(); + case 53: + if (lookahead == 'b') ADVANCE(113); + END_STATE(); + case 54: + if (lookahead == 'c') ADVANCE(107); + END_STATE(); + case 55: + if (lookahead == 'c') ADVANCE(298); + END_STATE(); + case 56: + if (lookahead == 'c') ADVANCE(221); + END_STATE(); + case 57: + if (lookahead == 'c') ADVANCE(49); + END_STATE(); + case 58: + if (lookahead == 'c') ADVANCE(75); + END_STATE(); + case 59: + if (lookahead == 'd') ADVANCE(245); + END_STATE(); + case 60: + if (lookahead == 'd') ADVANCE(245); + if (lookahead == 's') ADVANCE(101); + END_STATE(); + case 61: + if (lookahead == 'd') ADVANCE(251); + END_STATE(); + case 62: + if (lookahead == 'd') ADVANCE(249); + END_STATE(); + case 63: + if (lookahead == 'd') ADVANCE(291); + END_STATE(); + case 64: + if (lookahead == 'd') ADVANCE(103); + if (lookahead == 'n') ADVANCE(184); + if (lookahead == 'x') ADVANCE(151); + END_STATE(); + case 65: + if (lookahead == 'd') ADVANCE(103); + if (lookahead == 'n') ADVANCE(184); + if (lookahead == 'x') ADVANCE(152); + END_STATE(); + case 66: + if (lookahead == 'd') ADVANCE(31); + END_STATE(); + case 67: + if (lookahead == 'd') ADVANCE(32); + END_STATE(); + case 68: + if (lookahead == 'e') ADVANCE(154); + if (lookahead == 'p') ADVANCE(55); + END_STATE(); + case 69: + if (lookahead == 'e') ADVANCE(66); + END_STATE(); + case 70: + if (lookahead == 'e') ADVANCE(436); + END_STATE(); + case 71: + if (lookahead == 'e') ADVANCE(438); + END_STATE(); + case 72: + if (lookahead == 'e') ADVANCE(285); + END_STATE(); + case 73: + if (lookahead == 'e') ADVANCE(243); + END_STATE(); + case 74: + if (lookahead == 'e') ADVANCE(226); + END_STATE(); + case 75: + if (lookahead == 'e') ADVANCE(297); + END_STATE(); + case 76: + if (lookahead == 'e') ADVANCE(157); + if (lookahead == 'f') ADVANCE(104); + if (lookahead == 'i') ADVANCE(126); + if (lookahead == 't') ADVANCE(158); + if (lookahead == 'y') ADVANCE(127); + END_STATE(); + case 77: + if (lookahead == 'e') ADVANCE(157); + if (lookahead == 'y') ADVANCE(127); + END_STATE(); + case 78: + if (lookahead == 'e') ADVANCE(46); + END_STATE(); + case 79: + if (lookahead == 'e') ADVANCE(61); + END_STATE(); + case 80: + if (lookahead == 'e') ADVANCE(119); + END_STATE(); + case 81: + if (lookahead == 'e') ADVANCE(62); + END_STATE(); + case 82: + if (lookahead == 'e') ADVANCE(166); + END_STATE(); + case 83: + if (lookahead == 'e') ADVANCE(63); + END_STATE(); + case 84: + if (lookahead == 'e') ADVANCE(159); + END_STATE(); + case 85: + if (lookahead == 'e') ADVANCE(135); + END_STATE(); + case 86: + if (lookahead == 'e') ADVANCE(50); + END_STATE(); + case 87: + if (lookahead == 'e') ADVANCE(45); + if (lookahead == 'i') ADVANCE(125); + END_STATE(); + case 88: + if (lookahead == 'e') ADVANCE(129); + END_STATE(); + case 89: + if (lookahead == 'e') ADVANCE(67); + END_STATE(); + case 90: + if (lookahead == 'f') ADVANCE(447); + END_STATE(); + case 91: + if (lookahead == 'f') ADVANCE(447); + if (lookahead == 't') ADVANCE(28); + END_STATE(); + case 92: + if (lookahead == 'f') ADVANCE(255); + END_STATE(); + case 93: + if (lookahead == 'g') ADVANCE(283); + END_STATE(); + case 94: + if (lookahead == 'g') ADVANCE(73); + END_STATE(); + case 95: + if (lookahead == 'g') ADVANCE(74); + END_STATE(); + case 96: + if (lookahead == 'i') ADVANCE(56); + END_STATE(); + case 97: + if (lookahead == 'i') ADVANCE(58); + END_STATE(); + case 98: + if (lookahead == 'i') ADVANCE(142); + END_STATE(); + case 99: + if (lookahead == 'i') ADVANCE(164); + END_STATE(); + case 100: + if (lookahead == 'i') ADVANCE(143); + END_STATE(); + case 101: + if (lookahead == 'i') ADVANCE(145); + END_STATE(); + case 102: + if (lookahead == 'i') ADVANCE(146); + END_STATE(); + case 103: + if (lookahead == 'i') ADVANCE(181); + END_STATE(); + case 104: + if (lookahead == 'i') ADVANCE(195); + END_STATE(); + case 105: + if (lookahead == 'i') ADVANCE(131); + END_STATE(); + case 106: + if (lookahead == 'k') ADVANCE(220); + END_STATE(); + case 107: + if (lookahead == 'k') ADVANCE(51); + END_STATE(); + case 108: + if (lookahead == 'l') ADVANCE(281); + END_STATE(); + case 109: + if (lookahead == 'l') ADVANCE(233); + END_STATE(); + case 110: + if (lookahead == 'l') ADVANCE(247); + END_STATE(); + case 111: + if (lookahead == 'l') ADVANCE(171); + END_STATE(); + case 112: + if (lookahead == 'l') ADVANCE(96); + END_STATE(); + case 113: + if (lookahead == 'l') ADVANCE(72); + END_STATE(); + case 114: + if (lookahead == 'm') ADVANCE(155); + END_STATE(); + case 115: + if (lookahead == 'm') ADVANCE(155); + if (lookahead == 'n') ADVANCE(91); + END_STATE(); + case 116: + if (lookahead == 'm') ADVANCE(235); + END_STATE(); + case 117: + if (lookahead == 'm') ADVANCE(299); + END_STATE(); + case 118: + if (lookahead == 'n') ADVANCE(447); + END_STATE(); + case 119: + if (lookahead == 'n') ADVANCE(60); + END_STATE(); + case 120: + if (lookahead == 'n') ADVANCE(224); + END_STATE(); + case 121: + if (lookahead == 'n') ADVANCE(214); + END_STATE(); + case 122: + if (lookahead == 'n') ADVANCE(222); + END_STATE(); + case 123: + if (lookahead == 'n') ADVANCE(85); + if (lookahead == 'p') ADVANCE(176); + END_STATE(); + case 124: + if (lookahead == 'n') ADVANCE(90); + END_STATE(); + case 125: + if (lookahead == 'n') ADVANCE(93); + END_STATE(); + case 126: + if (lookahead == 'n') ADVANCE(180); + END_STATE(); + case 127: + if (lookahead == 'n') ADVANCE(177); + END_STATE(); + case 128: + if (lookahead == 'n') ADVANCE(167); + END_STATE(); + case 129: + if (lookahead == 'n') ADVANCE(59); + END_STATE(); + case 130: + if (lookahead == 'n') ADVANCE(168); + END_STATE(); + case 131: + if (lookahead == 'n') ADVANCE(182); + END_STATE(); + case 132: + if (lookahead == 'o') ADVANCE(189); + END_STATE(); + case 133: + if (lookahead == 'o') ADVANCE(57); + END_STATE(); + case 134: + if (lookahead == 'o') ADVANCE(295); + if (lookahead == 'r') ADVANCE(188); + END_STATE(); + case 135: + if (lookahead == 'o') ADVANCE(92); + END_STATE(); + case 136: + if (lookahead == 'o') ADVANCE(27); + END_STATE(); + case 137: + if (lookahead == 'o') ADVANCE(108); + END_STATE(); + case 138: + if (lookahead == 'o') ADVANCE(137); + if (lookahead == 'y') ADVANCE(175); + END_STATE(); + case 139: + if (lookahead == 'o') ADVANCE(48); + END_STATE(); + case 140: + if (lookahead == 'o') ADVANCE(160); + END_STATE(); + case 141: + if (lookahead == 'o') ADVANCE(185); + END_STATE(); + case 142: + if (lookahead == 'o') ADVANCE(120); + END_STATE(); + case 143: + if (lookahead == 'o') ADVANCE(121); + END_STATE(); + case 144: + if (lookahead == 'o') ADVANCE(178); + END_STATE(); + case 145: + if (lookahead == 'o') ADVANCE(130); + END_STATE(); + case 146: + if (lookahead == 'o') ADVANCE(122); + END_STATE(); + case 147: + if (lookahead == 'o') ADVANCE(162); + END_STATE(); + case 148: + if (lookahead == 'p') ADVANCE(257); + if (lookahead == 'x') ADVANCE(296); + END_STATE(); + case 149: + if (lookahead == 'p') ADVANCE(253); + END_STATE(); + case 150: + if (lookahead == 'p') ADVANCE(55); + END_STATE(); + case 151: + if (lookahead == 'p') ADVANCE(140); + if (lookahead == 't') ADVANCE(80); + END_STATE(); + case 152: + if (lookahead == 'p') ADVANCE(140); + if (lookahead == 't') ADVANCE(88); + END_STATE(); + case 153: + if (lookahead == 'p') ADVANCE(163); + END_STATE(); + case 154: + if (lookahead == 'p') ADVANCE(86); + if (lookahead == 'q') ADVANCE(187); + if (lookahead == 's') ADVANCE(84); + if (lookahead == 't') ADVANCE(186); + END_STATE(); + case 155: + if (lookahead == 'p') ADVANCE(147); + END_STATE(); + case 156: + if (lookahead == 'p') ADVANCE(183); + END_STATE(); + case 157: + if (lookahead == 'r') ADVANCE(190); + END_STATE(); + case 158: + if (lookahead == 'r') ADVANCE(87); + END_STATE(); + case 159: + if (lookahead == 'r') ADVANCE(191); + END_STATE(); + case 160: + if (lookahead == 'r') ADVANCE(173); + END_STATE(); + case 161: + if (lookahead == 'r') ADVANCE(128); + END_STATE(); + case 162: + if (lookahead == 'r') ADVANCE(174); + END_STATE(); + case 163: + if (lookahead == 'r') ADVANCE(144); + END_STATE(); + case 164: + if (lookahead == 'r') ADVANCE(81); + END_STATE(); + case 165: + if (lookahead == 'r') ADVANCE(141); + END_STATE(); + case 166: + if (lookahead == 's') ADVANCE(289); + END_STATE(); + case 167: + if (lookahead == 's') ADVANCE(301); + END_STATE(); + case 168: + if (lookahead == 's') ADVANCE(293); + END_STATE(); + case 169: + if (lookahead == 's') ADVANCE(170); + END_STATE(); + case 170: + if (lookahead == 's') ADVANCE(42); + END_STATE(); + case 171: + if (lookahead == 's') ADVANCE(71); + END_STATE(); + case 172: + if (lookahead == 't') ADVANCE(287); + END_STATE(); + case 173: + if (lookahead == 't') ADVANCE(231); + END_STATE(); + case 174: + if (lookahead == 't') ADVANCE(219); + END_STATE(); + case 175: + if (lookahead == 't') ADVANCE(82); + END_STATE(); + case 176: + if (lookahead == 't') ADVANCE(98); + END_STATE(); + case 177: + if (lookahead == 't') ADVANCE(43); + END_STATE(); + case 178: + if (lookahead == 't') ADVANCE(136); + END_STATE(); + case 179: + if (lookahead == 't') ADVANCE(79); + END_STATE(); + case 180: + if (lookahead == 't') ADVANCE(29); + END_STATE(); + case 181: + if (lookahead == 't') ADVANCE(100); + END_STATE(); + case 182: + if (lookahead == 't') ADVANCE(30); + END_STATE(); + case 183: + if (lookahead == 't') ADVANCE(102); + END_STATE(); + case 184: + if (lookahead == 'u') ADVANCE(116); + END_STATE(); + case 185: + if (lookahead == 'u') ADVANCE(149); + END_STATE(); + case 186: + if (lookahead == 'u') ADVANCE(161); + END_STATE(); + case 187: + if (lookahead == 'u') ADVANCE(99); + END_STATE(); + case 188: + if (lookahead == 'u') ADVANCE(70); + END_STATE(); + case 189: + if (lookahead == 'u') ADVANCE(53); + END_STATE(); + case 190: + if (lookahead == 'v') ADVANCE(97); + END_STATE(); + case 191: + if (lookahead == 'v') ADVANCE(83); + END_STATE(); + case 192: + if (lookahead == 'x') ADVANCE(296); + END_STATE(); + case 193: + if (lookahead == 'x') ADVANCE(216); + END_STATE(); + case 194: + if (lookahead == 'x') ADVANCE(69); + END_STATE(); + case 195: + if (lookahead == 'x') ADVANCE(89); + END_STATE(); + case 196: + if (lookahead == '+' || + lookahead == '-') ADVANCE(198); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(449); + END_STATE(); + case 197: + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(448); + END_STATE(); + case 198: + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(449); + END_STATE(); + case 199: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(464); + END_STATE(); + case 200: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(446); + END_STATE(); + case 201: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(199); + END_STATE(); + case 202: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(201); + END_STATE(); + case 203: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(202); + END_STATE(); + case 204: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(203); + END_STATE(); + case 205: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(204); + END_STATE(); + case 206: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(205); + END_STATE(); + case 207: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(206); + END_STATE(); + case 208: + if (('A' <= lookahead && lookahead <= 'Z') || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(7); + END_STATE(); + case 209: + if (('A' <= lookahead && lookahead <= 'Z') || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(10); + END_STATE(); + case 210: + if (eof) ADVANCE(212); + ADVANCE_MAP( + '"', 450, + '\'', 457, + '(', 227, + ')', 228, + '+', 302, + ',', 241, + '-', 239, + '.', 230, + '/', 11, + '0', 442, + ':', 303, + ';', 213, + '<', 259, + '=', 215, + '>', 260, + '[', 240, + ']', 242, + 'b', 138, + 'd', 132, + 'e', 64, + 'f', 39, + 'g', 165, + 'i', 115, + 'l', 133, + 'm', 40, + 'n', 41, + 'o', 123, + 'p', 44, + 'r', 68, + 's', 76, + 't', 134, + 'u', 105, + 'w', 78, + '{', 237, + '}', 238, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(210); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(440); + END_STATE(); + case 211: + if (eof) ADVANCE(212); + ADVANCE_MAP( + '"', 450, + '\'', 457, + '-', 239, + '.', 229, + '/', 11, + '0', 444, + ';', 213, + '=', 215, + 'e', 65, + 'i', 114, + 'l', 133, + 'm', 47, + 'o', 156, + 'p', 44, + 'r', 150, + 's', 77, + 'w', 78, + '}', 238, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(211); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(441); + END_STATE(); + case 212: + ACCEPT_TOKEN(ts_builtin_sym_end); + END_STATE(); + case 213: + ACCEPT_TOKEN(anon_sym_SEMI); + END_STATE(); + case 214: + ACCEPT_TOKEN(anon_sym_edition); + END_STATE(); + case 215: + ACCEPT_TOKEN(anon_sym_EQ); + END_STATE(); + case 216: + ACCEPT_TOKEN(anon_sym_syntax); + END_STATE(); + case 217: + ACCEPT_TOKEN(anon_sym_DQUOTEproto3_DQUOTE); + END_STATE(); + case 218: + ACCEPT_TOKEN(anon_sym_DQUOTEproto2_DQUOTE); + END_STATE(); + case 219: + ACCEPT_TOKEN(anon_sym_import); + END_STATE(); + case 220: + ACCEPT_TOKEN(anon_sym_weak); + END_STATE(); + case 221: + ACCEPT_TOKEN(anon_sym_public); + END_STATE(); + case 222: + ACCEPT_TOKEN(anon_sym_option); + END_STATE(); + case 223: + ACCEPT_TOKEN(anon_sym_option); + if (lookahead == 'a') ADVANCE(368); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 224: + ACCEPT_TOKEN(anon_sym_option); + if (lookahead == 'a') ADVANCE(110); + END_STATE(); + case 225: + ACCEPT_TOKEN(anon_sym_option); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 226: + ACCEPT_TOKEN(anon_sym_package); + END_STATE(); + case 227: + ACCEPT_TOKEN(anon_sym_LPAREN); + END_STATE(); + case 228: + ACCEPT_TOKEN(anon_sym_RPAREN); + END_STATE(); + case 229: + ACCEPT_TOKEN(anon_sym_DOT); + END_STATE(); + case 230: + ACCEPT_TOKEN(anon_sym_DOT); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(448); + END_STATE(); + case 231: + ACCEPT_TOKEN(anon_sym_export); + END_STATE(); + case 232: + ACCEPT_TOKEN(anon_sym_export); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 233: + ACCEPT_TOKEN(anon_sym_local); + END_STATE(); + case 234: + ACCEPT_TOKEN(anon_sym_local); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 235: + ACCEPT_TOKEN(anon_sym_enum); + END_STATE(); + case 236: + ACCEPT_TOKEN(anon_sym_enum); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 237: + ACCEPT_TOKEN(anon_sym_LBRACE); + END_STATE(); + case 238: + ACCEPT_TOKEN(anon_sym_RBRACE); + END_STATE(); + case 239: + ACCEPT_TOKEN(anon_sym_DASH); + END_STATE(); + case 240: + ACCEPT_TOKEN(anon_sym_LBRACK); + END_STATE(); + case 241: + ACCEPT_TOKEN(anon_sym_COMMA); + END_STATE(); + case 242: + ACCEPT_TOKEN(anon_sym_RBRACK); + END_STATE(); + case 243: + ACCEPT_TOKEN(anon_sym_message); + END_STATE(); + case 244: + ACCEPT_TOKEN(anon_sym_message); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 245: + ACCEPT_TOKEN(anon_sym_extend); + END_STATE(); + case 246: + ACCEPT_TOKEN(anon_sym_extend); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 247: + ACCEPT_TOKEN(anon_sym_optional); + END_STATE(); + case 248: + ACCEPT_TOKEN(anon_sym_optional); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 249: + ACCEPT_TOKEN(anon_sym_required); + END_STATE(); + case 250: + ACCEPT_TOKEN(anon_sym_required); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 251: + ACCEPT_TOKEN(anon_sym_repeated); + END_STATE(); + case 252: + ACCEPT_TOKEN(anon_sym_repeated); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 253: + ACCEPT_TOKEN(anon_sym_group); + END_STATE(); + case 254: + ACCEPT_TOKEN(anon_sym_group); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 255: + ACCEPT_TOKEN(anon_sym_oneof); + END_STATE(); + case 256: + ACCEPT_TOKEN(anon_sym_oneof); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 257: + ACCEPT_TOKEN(anon_sym_map); + END_STATE(); + case 258: + ACCEPT_TOKEN(anon_sym_map); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 259: + ACCEPT_TOKEN(anon_sym_LT); + END_STATE(); + case 260: + ACCEPT_TOKEN(anon_sym_GT); + END_STATE(); + case 261: + ACCEPT_TOKEN(anon_sym_int32); + END_STATE(); + case 262: + ACCEPT_TOKEN(anon_sym_int32); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 263: + ACCEPT_TOKEN(anon_sym_int64); + END_STATE(); + case 264: + ACCEPT_TOKEN(anon_sym_int64); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 265: + ACCEPT_TOKEN(anon_sym_uint32); + END_STATE(); + case 266: + ACCEPT_TOKEN(anon_sym_uint32); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 267: + ACCEPT_TOKEN(anon_sym_uint64); + END_STATE(); + case 268: + ACCEPT_TOKEN(anon_sym_uint64); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 269: + ACCEPT_TOKEN(anon_sym_sint32); + END_STATE(); + case 270: + ACCEPT_TOKEN(anon_sym_sint32); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 271: + ACCEPT_TOKEN(anon_sym_sint64); + END_STATE(); + case 272: + ACCEPT_TOKEN(anon_sym_sint64); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 273: + ACCEPT_TOKEN(anon_sym_fixed32); + END_STATE(); + case 274: + ACCEPT_TOKEN(anon_sym_fixed32); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 275: + ACCEPT_TOKEN(anon_sym_fixed64); + END_STATE(); + case 276: + ACCEPT_TOKEN(anon_sym_fixed64); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 277: + ACCEPT_TOKEN(anon_sym_sfixed32); + END_STATE(); + case 278: + ACCEPT_TOKEN(anon_sym_sfixed32); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 279: + ACCEPT_TOKEN(anon_sym_sfixed64); + END_STATE(); + case 280: + ACCEPT_TOKEN(anon_sym_sfixed64); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 281: + ACCEPT_TOKEN(anon_sym_bool); + END_STATE(); + case 282: + ACCEPT_TOKEN(anon_sym_bool); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 283: + ACCEPT_TOKEN(anon_sym_string); + END_STATE(); + case 284: + ACCEPT_TOKEN(anon_sym_string); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 285: + ACCEPT_TOKEN(anon_sym_double); + END_STATE(); + case 286: + ACCEPT_TOKEN(anon_sym_double); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 287: + ACCEPT_TOKEN(anon_sym_float); + END_STATE(); + case 288: + ACCEPT_TOKEN(anon_sym_float); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 289: + ACCEPT_TOKEN(anon_sym_bytes); + END_STATE(); + case 290: + ACCEPT_TOKEN(anon_sym_bytes); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 291: + ACCEPT_TOKEN(anon_sym_reserved); + END_STATE(); + case 292: + ACCEPT_TOKEN(anon_sym_reserved); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 293: + ACCEPT_TOKEN(anon_sym_extensions); + END_STATE(); + case 294: + ACCEPT_TOKEN(anon_sym_extensions); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 295: + ACCEPT_TOKEN(anon_sym_to); + END_STATE(); + case 296: + ACCEPT_TOKEN(anon_sym_max); + END_STATE(); + case 297: + ACCEPT_TOKEN(anon_sym_service); + END_STATE(); + case 298: + ACCEPT_TOKEN(anon_sym_rpc); + END_STATE(); + case 299: + ACCEPT_TOKEN(anon_sym_stream); + END_STATE(); + case 300: + ACCEPT_TOKEN(anon_sym_stream); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 301: + ACCEPT_TOKEN(anon_sym_returns); + END_STATE(); + case 302: + ACCEPT_TOKEN(anon_sym_PLUS); + END_STATE(); + case 303: + ACCEPT_TOKEN(anon_sym_COLON); + END_STATE(); + case 304: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '2') ADVANCE(262); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 305: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '2') ADVANCE(270); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 306: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '2') ADVANCE(266); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 307: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '2') ADVANCE(274); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 308: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '2') ADVANCE(278); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 309: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '3') ADVANCE(304); + if (lookahead == '6') ADVANCE(314); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 310: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '3') ADVANCE(305); + if (lookahead == '6') ADVANCE(315); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 311: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '3') ADVANCE(306); + if (lookahead == '6') ADVANCE(316); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 312: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '3') ADVANCE(307); + if (lookahead == '6') ADVANCE(317); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 313: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '3') ADVANCE(308); + if (lookahead == '6') ADVANCE(318); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 314: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '4') ADVANCE(264); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 315: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '4') ADVANCE(272); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 316: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '4') ADVANCE(268); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 317: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '4') ADVANCE(276); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 318: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '4') ADVANCE(280); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 319: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(396); + if (lookahead == 'e') ADVANCE(411); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 320: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(357); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 321: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(372); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 322: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(367); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 323: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(416); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 324: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(369); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 325: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(373); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 326: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(420); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 327: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'b') ADVANCE(370); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 328: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'c') ADVANCE(322); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 329: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'd') ADVANCE(246); + if (lookahead == 's') ADVANCE(362); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 330: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'd') ADVANCE(252); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 331: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'd') ADVANCE(250); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 332: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'd') ADVANCE(292); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 333: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'd') ADVANCE(312); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 334: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'd') ADVANCE(313); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 335: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(399); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 336: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(333); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 337: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(286); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 338: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(244); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 339: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(437); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 340: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(439); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 341: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(374); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 342: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(330); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 343: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(409); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 344: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(331); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 345: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(402); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 346: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(400); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 347: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(326); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 348: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(332); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 349: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(389); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 350: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(321); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 351: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(413); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 352: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(334); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 353: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'f') ADVANCE(433); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 354: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'f') ADVANCE(256); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 355: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'f') ADVANCE(365); + if (lookahead == 'i') ADVANCE(383); + if (lookahead == 't') ADVANCE(403); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 356: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'g') ADVANCE(284); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 357: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'g') ADVANCE(338); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 358: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(431); + if (lookahead == 'l') ADVANCE(388); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 359: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(381); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 360: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(406); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 361: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(393); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 362: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(394); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 363: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(395); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 364: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(384); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 365: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(432); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 366: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'l') ADVANCE(282); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 367: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'l') ADVANCE(234); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 368: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'l') ADVANCE(248); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 369: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'l') ADVANCE(414); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 370: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'l') ADVANCE(337); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 371: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'm') ADVANCE(236); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 372: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'm') ADVANCE(300); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 373: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(433); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 374: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(329); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 375: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(223); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 376: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(225); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 377: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(349); + if (lookahead == 'p') ADVANCE(419); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 378: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(415); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 379: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(426); + if (lookahead == 'x') ADVANCE(398); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 380: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(353); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 381: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(356); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 382: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(410); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 383: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(421); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 384: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(423); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 385: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(425); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 386: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(328); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 387: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(366); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 388: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(323); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 389: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(354); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 390: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(387); + if (lookahead == 'y') ADVANCE(418); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 391: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(404); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 392: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(427); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 393: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(375); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 394: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(382); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 395: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(376); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 396: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'p') ADVANCE(258); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 397: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'p') ADVANCE(254); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 398: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'p') ADVANCE(391); + if (lookahead == 't') ADVANCE(341); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 399: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'p') ADVANCE(347); + if (lookahead == 'q') ADVANCE(428); + if (lookahead == 's') ADVANCE(345); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 400: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'p') ADVANCE(347); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 401: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'p') ADVANCE(424); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 402: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'r') ADVANCE(430); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 403: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'r') ADVANCE(359); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 404: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'r') ADVANCE(417); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 405: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'r') ADVANCE(429); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 406: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'r') ADVANCE(344); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 407: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'r') ADVANCE(350); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 408: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'r') ADVANCE(392); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 409: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 's') ADVANCE(290); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 410: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 's') ADVANCE(294); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 411: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 's') ADVANCE(412); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 412: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 's') ADVANCE(320); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 413: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 's') ADVANCE(345); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 414: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 's') ADVANCE(340); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 415: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(309); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 416: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(288); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 417: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(232); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 418: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(343); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 419: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(361); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 420: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(342); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 421: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(310); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 422: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(407); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 423: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(311); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 424: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(363); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 425: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'u') ADVANCE(327); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 426: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'u') ADVANCE(371); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 427: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'u') ADVANCE(397); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 428: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'u') ADVANCE(360); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 429: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'u') ADVANCE(339); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 430: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'v') ADVANCE(348); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 431: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'x') ADVANCE(336); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 432: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'x') ADVANCE(352); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 433: + ACCEPT_TOKEN(sym_identifier); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 434: + ACCEPT_TOKEN(sym_reserved_identifier); + END_STATE(); + case 435: + ACCEPT_TOKEN(sym_reserved_identifier); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(435); + END_STATE(); + case 436: + ACCEPT_TOKEN(sym_true); + END_STATE(); + case 437: + ACCEPT_TOKEN(sym_true); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 438: + ACCEPT_TOKEN(sym_false); + END_STATE(); + case 439: + ACCEPT_TOKEN(sym_false); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 440: + ACCEPT_TOKEN(sym_decimal_lit); + if (lookahead == '.') ADVANCE(448); + if (lookahead == 'E' || + lookahead == 'e') ADVANCE(196); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(440); + END_STATE(); + case 441: + ACCEPT_TOKEN(sym_decimal_lit); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(441); + END_STATE(); + case 442: + ACCEPT_TOKEN(sym_octal_lit); + if (lookahead == '.') ADVANCE(448); + if (lookahead == 'E' || + lookahead == 'e') ADVANCE(196); + if (lookahead == 'X' || + lookahead == 'x') ADVANCE(200); + if (lookahead == '8' || + lookahead == '9') ADVANCE(14); + if (('0' <= lookahead && lookahead <= '7')) ADVANCE(443); + END_STATE(); + case 443: + ACCEPT_TOKEN(sym_octal_lit); + if (lookahead == '.') ADVANCE(448); + if (lookahead == 'E' || + lookahead == 'e') ADVANCE(196); + if (lookahead == '8' || + lookahead == '9') ADVANCE(14); + if (('0' <= lookahead && lookahead <= '7')) ADVANCE(443); + END_STATE(); + case 444: + ACCEPT_TOKEN(sym_octal_lit); + if (lookahead == 'X' || + lookahead == 'x') ADVANCE(200); + if (('0' <= lookahead && lookahead <= '7')) ADVANCE(445); + END_STATE(); + case 445: + ACCEPT_TOKEN(sym_octal_lit); + if (('0' <= lookahead && lookahead <= '7')) ADVANCE(445); + END_STATE(); + case 446: + ACCEPT_TOKEN(sym_hex_lit); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(446); + END_STATE(); + case 447: + ACCEPT_TOKEN(sym_float_lit); + END_STATE(); + case 448: + ACCEPT_TOKEN(sym_float_lit); + if (lookahead == 'E' || + lookahead == 'e') ADVANCE(196); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(448); + END_STATE(); + case 449: + ACCEPT_TOKEN(sym_float_lit); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(449); + END_STATE(); + case 450: + ACCEPT_TOKEN(anon_sym_DQUOTE); + END_STATE(); + case 451: + ACCEPT_TOKEN(aux_sym_string_token1); + if (lookahead == '\n') ADVANCE(456); + if (lookahead != 0 && + lookahead != '"' && + lookahead != '\\') ADVANCE(451); + END_STATE(); + case 452: + ACCEPT_TOKEN(aux_sym_string_token1); + if (lookahead == '*') ADVANCE(454); + if (lookahead == '/') ADVANCE(451); + if (lookahead != 0 && + lookahead != '"' && + lookahead != '\\') ADVANCE(456); + END_STATE(); + case 453: + ACCEPT_TOKEN(aux_sym_string_token1); + if (lookahead == '*') ADVANCE(453); + if (lookahead == '/') ADVANCE(456); + if (lookahead != 0 && + lookahead != '"' && + lookahead != '\\') ADVANCE(454); + END_STATE(); + case 454: + ACCEPT_TOKEN(aux_sym_string_token1); + if (lookahead == '*') ADVANCE(453); + if (lookahead != 0 && + lookahead != '"' && + lookahead != '\\') ADVANCE(454); + END_STATE(); + case 455: + ACCEPT_TOKEN(aux_sym_string_token1); + if (lookahead == '/') ADVANCE(452); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') ADVANCE(455); + if (lookahead != 0 && + lookahead != '"' && + lookahead != '\\') ADVANCE(456); + END_STATE(); + case 456: + ACCEPT_TOKEN(aux_sym_string_token1); + if (lookahead != 0 && + lookahead != '"' && + lookahead != '\\') ADVANCE(456); + END_STATE(); + case 457: + ACCEPT_TOKEN(anon_sym_SQUOTE); + END_STATE(); + case 458: + ACCEPT_TOKEN(aux_sym_string_token2); + if (lookahead == '\n') ADVANCE(463); + if (lookahead != 0 && + lookahead != '\'' && + lookahead != '\\') ADVANCE(458); + END_STATE(); + case 459: + ACCEPT_TOKEN(aux_sym_string_token2); + if (lookahead == '*') ADVANCE(461); + if (lookahead == '/') ADVANCE(458); + if (lookahead != 0 && + lookahead != '\'' && + lookahead != '\\') ADVANCE(463); + END_STATE(); + case 460: + ACCEPT_TOKEN(aux_sym_string_token2); + if (lookahead == '*') ADVANCE(460); + if (lookahead == '/') ADVANCE(463); + if (lookahead != 0 && + lookahead != '\'' && + lookahead != '\\') ADVANCE(461); + END_STATE(); + case 461: + ACCEPT_TOKEN(aux_sym_string_token2); + if (lookahead == '*') ADVANCE(460); + if (lookahead != 0 && + lookahead != '\'' && + lookahead != '\\') ADVANCE(461); + END_STATE(); + case 462: + ACCEPT_TOKEN(aux_sym_string_token2); + if (lookahead == '/') ADVANCE(459); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') ADVANCE(462); + if (lookahead != 0 && + lookahead != '\'' && + lookahead != '\\') ADVANCE(463); + END_STATE(); + case 463: + ACCEPT_TOKEN(aux_sym_string_token2); + if (lookahead != 0 && + lookahead != '\'' && + lookahead != '\\') ADVANCE(463); + END_STATE(); + case 464: + ACCEPT_TOKEN(sym_escape_sequence); + END_STATE(); + case 465: + ACCEPT_TOKEN(sym_escape_sequence); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(464); + END_STATE(); + case 466: + ACCEPT_TOKEN(sym_escape_sequence); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(465); + END_STATE(); + case 467: + ACCEPT_TOKEN(sym_comment); + END_STATE(); + case 468: + ACCEPT_TOKEN(sym_comment); + if (lookahead != 0 && + lookahead != '\n') ADVANCE(468); + END_STATE(); + default: + return false; + } +} + +static const TSLexMode ts_lex_modes[STATE_COUNT] = { + [0] = {.lex_state = 0}, + [1] = {.lex_state = 211}, + [2] = {.lex_state = 8}, + [3] = {.lex_state = 8}, + [4] = {.lex_state = 8}, + [5] = {.lex_state = 8}, + [6] = {.lex_state = 8}, + [7] = {.lex_state = 8}, + [8] = {.lex_state = 8}, + [9] = {.lex_state = 8}, + [10] = {.lex_state = 8}, + [11] = {.lex_state = 8}, + [12] = {.lex_state = 8}, + [13] = {.lex_state = 8}, + [14] = {.lex_state = 8}, + [15] = {.lex_state = 8}, + [16] = {.lex_state = 8}, + [17] = {.lex_state = 8}, + [18] = {.lex_state = 8}, + [19] = {.lex_state = 8}, + [20] = {.lex_state = 8}, + [21] = {.lex_state = 8}, + [22] = {.lex_state = 8}, + [23] = {.lex_state = 8}, + [24] = {.lex_state = 8}, + [25] = {.lex_state = 8}, + [26] = {.lex_state = 8}, + [27] = {.lex_state = 8}, + [28] = {.lex_state = 8}, + [29] = {.lex_state = 8}, + [30] = {.lex_state = 8}, + [31] = {.lex_state = 8}, + [32] = {.lex_state = 15}, + [33] = {.lex_state = 15}, + [34] = {.lex_state = 15}, + [35] = {.lex_state = 16}, + [36] = {.lex_state = 15}, + [37] = {.lex_state = 15}, + [38] = {.lex_state = 17}, + [39] = {.lex_state = 15}, + [40] = {.lex_state = 2}, + [41] = {.lex_state = 2}, + [42] = {.lex_state = 211}, + [43] = {.lex_state = 18}, + [44] = {.lex_state = 211}, + [45] = {.lex_state = 211}, + [46] = {.lex_state = 2}, + [47] = {.lex_state = 2}, + [48] = {.lex_state = 2}, + [49] = {.lex_state = 18}, + [50] = {.lex_state = 2}, + [51] = {.lex_state = 2}, + [52] = {.lex_state = 15}, + [53] = {.lex_state = 211}, + [54] = {.lex_state = 2}, + [55] = {.lex_state = 15}, + [56] = {.lex_state = 15}, + [57] = {.lex_state = 2}, + [58] = {.lex_state = 2}, + [59] = {.lex_state = 2}, + [60] = {.lex_state = 2}, + [61] = {.lex_state = 2}, + [62] = {.lex_state = 2}, + [63] = {.lex_state = 2}, + [64] = {.lex_state = 211}, + [65] = {.lex_state = 0}, + [66] = {.lex_state = 211}, + [67] = {.lex_state = 211}, + [68] = {.lex_state = 211}, + [69] = {.lex_state = 211}, + [70] = {.lex_state = 211}, + [71] = {.lex_state = 211}, + [72] = {.lex_state = 211}, + [73] = {.lex_state = 211}, + [74] = {.lex_state = 211}, + [75] = {.lex_state = 211}, + [76] = {.lex_state = 211}, + [77] = {.lex_state = 211}, + [78] = {.lex_state = 1}, + [79] = {.lex_state = 211}, + [80] = {.lex_state = 211}, + [81] = {.lex_state = 211}, + [82] = {.lex_state = 211}, + [83] = {.lex_state = 211}, + [84] = {.lex_state = 1}, + [85] = {.lex_state = 21}, + [86] = {.lex_state = 21}, + [87] = {.lex_state = 21}, + [88] = {.lex_state = 21}, + [89] = {.lex_state = 1}, + [90] = {.lex_state = 1}, + [91] = {.lex_state = 21}, + [92] = {.lex_state = 1}, + [93] = {.lex_state = 1}, + [94] = {.lex_state = 1}, + [95] = {.lex_state = 6}, + [96] = {.lex_state = 211}, + [97] = {.lex_state = 1}, + [98] = {.lex_state = 6}, + [99] = {.lex_state = 211}, + [100] = {.lex_state = 211}, + [101] = {.lex_state = 211}, + [102] = {.lex_state = 211}, + [103] = {.lex_state = 211}, + [104] = {.lex_state = 211}, + [105] = {.lex_state = 211}, + [106] = {.lex_state = 211}, + [107] = {.lex_state = 211}, + [108] = {.lex_state = 211}, + [109] = {.lex_state = 0}, + [110] = {.lex_state = 1}, + [111] = {.lex_state = 1}, + [112] = {.lex_state = 1}, + [113] = {.lex_state = 211}, + [114] = {.lex_state = 1}, + [115] = {.lex_state = 1}, + [116] = {.lex_state = 20}, + [117] = {.lex_state = 1}, + [118] = {.lex_state = 1}, + [119] = {.lex_state = 211}, + [120] = {.lex_state = 19}, + [121] = {.lex_state = 1}, + [122] = {.lex_state = 1}, + [123] = {.lex_state = 21}, + [124] = {.lex_state = 21}, + [125] = {.lex_state = 1}, + [126] = {.lex_state = 211}, + [127] = {.lex_state = 1}, + [128] = {.lex_state = 1}, + [129] = {.lex_state = 211}, + [130] = {.lex_state = 211}, + [131] = {.lex_state = 21}, + [132] = {.lex_state = 211}, + [133] = {.lex_state = 21}, + [134] = {.lex_state = 1}, + [135] = {.lex_state = 211}, + [136] = {.lex_state = 21}, + [137] = {.lex_state = 1}, + [138] = {.lex_state = 21}, + [139] = {.lex_state = 1}, + [140] = {.lex_state = 211}, + [141] = {.lex_state = 1}, + [142] = {.lex_state = 1}, + [143] = {.lex_state = 211}, + [144] = {.lex_state = 20}, + [145] = {.lex_state = 211}, + [146] = {.lex_state = 21}, + [147] = {.lex_state = 1}, + [148] = {.lex_state = 21}, + [149] = {.lex_state = 211}, + [150] = {.lex_state = 19}, + [151] = {.lex_state = 19}, + [152] = {.lex_state = 9}, + [153] = {.lex_state = 1}, + [154] = {.lex_state = 1}, + [155] = {.lex_state = 1}, + [156] = {.lex_state = 1}, + [157] = {.lex_state = 1}, + [158] = {.lex_state = 1}, + [159] = {.lex_state = 3}, + [160] = {.lex_state = 9}, + [161] = {.lex_state = 1}, + [162] = {.lex_state = 1}, + [163] = {.lex_state = 1}, + [164] = {.lex_state = 1}, + [165] = {.lex_state = 1}, + [166] = {.lex_state = 0}, + [167] = {.lex_state = 211}, + [168] = {.lex_state = 211}, + [169] = {.lex_state = 211}, + [170] = {.lex_state = 211}, + [171] = {.lex_state = 211}, + [172] = {.lex_state = 0}, + [173] = {.lex_state = 3}, + [174] = {.lex_state = 1}, + [175] = {.lex_state = 211}, + [176] = {.lex_state = 1}, + [177] = {.lex_state = 3}, + [178] = {.lex_state = 9}, + [179] = {.lex_state = 0}, + [180] = {.lex_state = 0}, + [181] = {.lex_state = 0}, + [182] = {.lex_state = 0}, + [183] = {.lex_state = 0}, + [184] = {.lex_state = 0}, + [185] = {.lex_state = 1}, + [186] = {.lex_state = 0}, + [187] = {.lex_state = 0}, + [188] = {.lex_state = 1}, + [189] = {.lex_state = 0}, + [190] = {.lex_state = 211}, + [191] = {.lex_state = 0}, + [192] = {.lex_state = 0}, + [193] = {.lex_state = 1}, + [194] = {.lex_state = 0}, + [195] = {.lex_state = 0}, + [196] = {.lex_state = 1}, + [197] = {.lex_state = 0}, + [198] = {.lex_state = 1}, + [199] = {.lex_state = 0}, + [200] = {.lex_state = 0}, + [201] = {.lex_state = 0}, + [202] = {.lex_state = 0}, + [203] = {.lex_state = 0}, + [204] = {.lex_state = 1}, + [205] = {.lex_state = 1}, + [206] = {.lex_state = 1}, + [207] = {.lex_state = 0}, + [208] = {.lex_state = 211}, + [209] = {.lex_state = 0}, + [210] = {.lex_state = 0}, + [211] = {.lex_state = 1}, + [212] = {.lex_state = 211}, + [213] = {.lex_state = 0}, + [214] = {.lex_state = 211}, + [215] = {.lex_state = 1}, + [216] = {.lex_state = 1}, + [217] = {.lex_state = 0}, + [218] = {.lex_state = 0}, + [219] = {.lex_state = 0}, + [220] = {.lex_state = 0}, + [221] = {.lex_state = 1}, + [222] = {.lex_state = 0}, + [223] = {.lex_state = 0}, + [224] = {.lex_state = 1}, + [225] = {.lex_state = 1}, + [226] = {.lex_state = 1}, + [227] = {.lex_state = 1}, + [228] = {.lex_state = 1}, + [229] = {.lex_state = 0}, + [230] = {.lex_state = 0}, + [231] = {.lex_state = 0}, + [232] = {.lex_state = 0}, + [233] = {.lex_state = 0}, + [234] = {.lex_state = 0}, + [235] = {.lex_state = 0}, + [236] = {.lex_state = 1}, + [237] = {.lex_state = 1}, + [238] = {.lex_state = 0}, + [239] = {.lex_state = 1}, + [240] = {.lex_state = 0}, + [241] = {.lex_state = 1}, + [242] = {.lex_state = 1}, + [243] = {.lex_state = 0}, + [244] = {.lex_state = 0}, + [245] = {.lex_state = 8}, + [246] = {.lex_state = 1}, + [247] = {.lex_state = 0}, + [248] = {.lex_state = 1}, + [249] = {.lex_state = 1}, + [250] = {.lex_state = 1}, + [251] = {.lex_state = 0}, + [252] = {.lex_state = 0}, + [253] = {.lex_state = 0}, + [254] = {.lex_state = 0}, + [255] = {.lex_state = 1}, + [256] = {.lex_state = 0}, + [257] = {.lex_state = 0}, + [258] = {.lex_state = 0}, + [259] = {.lex_state = 0}, + [260] = {.lex_state = 0}, + [261] = {.lex_state = 0}, + [262] = {.lex_state = 0}, + [263] = {.lex_state = 1}, + [264] = {.lex_state = 1}, + [265] = {.lex_state = 1}, + [266] = {.lex_state = 1}, + [267] = {.lex_state = 1}, + [268] = {.lex_state = 0}, + [269] = {.lex_state = 0}, + [270] = {.lex_state = 0}, + [271] = {.lex_state = 1}, + [272] = {.lex_state = 0}, + [273] = {.lex_state = 0}, + [274] = {.lex_state = 0}, + [275] = {.lex_state = 0}, + [276] = {.lex_state = 1}, + [277] = {.lex_state = 0}, + [278] = {.lex_state = 0}, + [279] = {.lex_state = 0}, + [280] = {.lex_state = 0}, + [281] = {.lex_state = 0}, + [282] = {.lex_state = 211}, + [283] = {.lex_state = 0}, + [284] = {.lex_state = 0}, + [285] = {.lex_state = 0}, + [286] = {.lex_state = 0}, + [287] = {.lex_state = 0}, + [288] = {.lex_state = 0}, + [289] = {.lex_state = 1}, + [290] = {.lex_state = 0}, + [291] = {.lex_state = 0}, + [292] = {.lex_state = 1}, + [293] = {.lex_state = 0}, + [294] = {.lex_state = 0}, + [295] = {.lex_state = 0}, + [296] = {.lex_state = 0}, + [297] = {.lex_state = 0}, + [298] = {.lex_state = 0}, + [299] = {.lex_state = 1}, + [300] = {.lex_state = 0}, + [301] = {.lex_state = 0}, + [302] = {.lex_state = 0}, + [303] = {.lex_state = 0}, + [304] = {.lex_state = 0}, + [305] = {.lex_state = 0}, + [306] = {.lex_state = 6}, + [307] = {.lex_state = 0}, + [308] = {.lex_state = 0}, + [309] = {.lex_state = 0}, + [310] = {.lex_state = 0}, + [311] = {.lex_state = 0}, + [312] = {.lex_state = 0}, + [313] = {.lex_state = 0}, + [314] = {.lex_state = 0}, + [315] = {.lex_state = 1}, + [316] = {.lex_state = 0}, + [317] = {.lex_state = 0}, + [318] = {.lex_state = 0}, + [319] = {.lex_state = 0}, + [320] = {.lex_state = 1}, + [321] = {.lex_state = 0}, + [322] = {.lex_state = 0}, + [323] = {.lex_state = 0}, + [324] = {.lex_state = 0}, + [325] = {.lex_state = 0}, + [326] = {.lex_state = 0}, + [327] = {.lex_state = 0}, + [328] = {.lex_state = 0}, + [329] = {.lex_state = 0}, + [330] = {.lex_state = 0}, + [331] = {.lex_state = 0}, + [332] = {.lex_state = 0}, + [333] = {.lex_state = 0}, + [334] = {.lex_state = 0}, + [335] = {.lex_state = 0}, + [336] = {.lex_state = 1}, + [337] = {.lex_state = 0}, + [338] = {.lex_state = 0}, + [339] = {.lex_state = 0}, + [340] = {.lex_state = 0}, + [341] = {.lex_state = 0}, + [342] = {.lex_state = 1}, + [343] = {.lex_state = 0}, + [344] = {.lex_state = 0}, +}; + +static const uint16_t ts_parse_table[LARGE_STATE_COUNT][SYMBOL_COUNT] = { + [0] = { + [ts_builtin_sym_end] = ACTIONS(1), + [anon_sym_SEMI] = ACTIONS(1), + [anon_sym_edition] = ACTIONS(1), + [anon_sym_EQ] = ACTIONS(1), + [anon_sym_syntax] = ACTIONS(1), + [anon_sym_import] = ACTIONS(1), + [anon_sym_weak] = ACTIONS(1), + [anon_sym_public] = ACTIONS(1), + [anon_sym_option] = ACTIONS(1), + [anon_sym_package] = ACTIONS(1), + [anon_sym_LPAREN] = ACTIONS(1), + [anon_sym_RPAREN] = ACTIONS(1), + [anon_sym_DOT] = ACTIONS(1), + [anon_sym_export] = ACTIONS(1), + [anon_sym_local] = ACTIONS(1), + [anon_sym_enum] = ACTIONS(1), + [anon_sym_LBRACE] = ACTIONS(1), + [anon_sym_RBRACE] = ACTIONS(1), + [anon_sym_DASH] = ACTIONS(1), + [anon_sym_LBRACK] = ACTIONS(1), + [anon_sym_COMMA] = ACTIONS(1), + [anon_sym_RBRACK] = ACTIONS(1), + [anon_sym_message] = ACTIONS(1), + [anon_sym_extend] = ACTIONS(1), + [anon_sym_optional] = ACTIONS(1), + [anon_sym_required] = ACTIONS(1), + [anon_sym_repeated] = ACTIONS(1), + [anon_sym_group] = ACTIONS(1), + [anon_sym_oneof] = ACTIONS(1), + [anon_sym_map] = ACTIONS(1), + [anon_sym_LT] = ACTIONS(1), + [anon_sym_GT] = ACTIONS(1), + [anon_sym_int32] = ACTIONS(1), + [anon_sym_int64] = ACTIONS(1), + [anon_sym_uint32] = ACTIONS(1), + [anon_sym_uint64] = ACTIONS(1), + [anon_sym_sint32] = ACTIONS(1), + [anon_sym_sint64] = ACTIONS(1), + [anon_sym_fixed32] = ACTIONS(1), + [anon_sym_fixed64] = ACTIONS(1), + [anon_sym_sfixed32] = ACTIONS(1), + [anon_sym_sfixed64] = ACTIONS(1), + [anon_sym_bool] = ACTIONS(1), + [anon_sym_string] = ACTIONS(1), + [anon_sym_double] = ACTIONS(1), + [anon_sym_float] = ACTIONS(1), + [anon_sym_bytes] = ACTIONS(1), + [anon_sym_reserved] = ACTIONS(1), + [anon_sym_extensions] = ACTIONS(1), + [anon_sym_to] = ACTIONS(1), + [anon_sym_max] = ACTIONS(1), + [anon_sym_service] = ACTIONS(1), + [anon_sym_rpc] = ACTIONS(1), + [anon_sym_stream] = ACTIONS(1), + [anon_sym_returns] = ACTIONS(1), + [anon_sym_PLUS] = ACTIONS(1), + [anon_sym_COLON] = ACTIONS(1), + [sym_true] = ACTIONS(1), + [sym_false] = ACTIONS(1), + [sym_decimal_lit] = ACTIONS(1), + [sym_octal_lit] = ACTIONS(1), + [sym_hex_lit] = ACTIONS(1), + [sym_float_lit] = ACTIONS(1), + [anon_sym_DQUOTE] = ACTIONS(1), + [anon_sym_SQUOTE] = ACTIONS(1), + [sym_escape_sequence] = ACTIONS(1), + [sym_comment] = ACTIONS(3), + }, + [1] = { + [sym_source_file] = STATE(319), + [sym_empty_statement] = STATE(53), + [sym_edition] = STATE(44), + [sym_syntax] = STATE(44), + [sym_import] = STATE(53), + [sym_package] = STATE(53), + [sym_option] = STATE(53), + [sym_enum] = STATE(53), + [sym_message] = STATE(53), + [sym_extend] = STATE(53), + [sym_service] = STATE(53), + [aux_sym_source_file_repeat1] = STATE(53), + [ts_builtin_sym_end] = ACTIONS(5), + [anon_sym_SEMI] = ACTIONS(7), + [anon_sym_edition] = ACTIONS(9), + [anon_sym_syntax] = ACTIONS(11), + [anon_sym_import] = ACTIONS(13), + [anon_sym_option] = ACTIONS(15), + [anon_sym_package] = ACTIONS(17), + [anon_sym_export] = ACTIONS(19), + [anon_sym_local] = ACTIONS(19), + [anon_sym_enum] = ACTIONS(21), + [anon_sym_message] = ACTIONS(23), + [anon_sym_extend] = ACTIONS(25), + [anon_sym_service] = ACTIONS(27), + [sym_comment] = ACTIONS(3), + }, +}; + +static const uint16_t ts_small_parse_table[] = { + [0] = 22, + ACTIONS(3), 1, + sym_comment, + ACTIONS(29), 1, + anon_sym_SEMI, + ACTIONS(31), 1, + anon_sym_option, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(37), 1, + anon_sym_enum, + ACTIONS(39), 1, + anon_sym_RBRACE, + ACTIONS(41), 1, + anon_sym_message, + ACTIONS(43), 1, + anon_sym_extend, + ACTIONS(47), 1, + anon_sym_repeated, + ACTIONS(49), 1, + anon_sym_group, + ACTIONS(51), 1, + anon_sym_oneof, + ACTIONS(53), 1, + anon_sym_map, + ACTIONS(57), 1, + anon_sym_reserved, + ACTIONS(59), 1, + anon_sym_extensions, + ACTIONS(61), 1, + sym_identifier, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(299), 1, + sym_type, + ACTIONS(35), 2, + anon_sym_export, + anon_sym_local, + ACTIONS(45), 2, + anon_sym_optional, + anon_sym_required, + STATE(3), 12, + sym_empty_statement, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_group, + sym_field, + sym_oneof, + sym_map_field, + sym_reserved, + sym_extensions, + aux_sym_message_body_repeat1, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [94] = 22, + ACTIONS(3), 1, + sym_comment, + ACTIONS(29), 1, + anon_sym_SEMI, + ACTIONS(31), 1, + anon_sym_option, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(37), 1, + anon_sym_enum, + ACTIONS(41), 1, + anon_sym_message, + ACTIONS(43), 1, + anon_sym_extend, + ACTIONS(47), 1, + anon_sym_repeated, + ACTIONS(49), 1, + anon_sym_group, + ACTIONS(51), 1, + anon_sym_oneof, + ACTIONS(53), 1, + anon_sym_map, + ACTIONS(57), 1, + anon_sym_reserved, + ACTIONS(59), 1, + anon_sym_extensions, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(63), 1, + anon_sym_RBRACE, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(299), 1, + sym_type, + ACTIONS(35), 2, + anon_sym_export, + anon_sym_local, + ACTIONS(45), 2, + anon_sym_optional, + anon_sym_required, + STATE(4), 12, + sym_empty_statement, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_group, + sym_field, + sym_oneof, + sym_map_field, + sym_reserved, + sym_extensions, + aux_sym_message_body_repeat1, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [188] = 22, + ACTIONS(3), 1, + sym_comment, + ACTIONS(65), 1, + anon_sym_SEMI, + ACTIONS(68), 1, + anon_sym_option, + ACTIONS(71), 1, + anon_sym_DOT, + ACTIONS(77), 1, + anon_sym_enum, + ACTIONS(80), 1, + anon_sym_RBRACE, + ACTIONS(82), 1, + anon_sym_message, + ACTIONS(85), 1, + anon_sym_extend, + ACTIONS(91), 1, + anon_sym_repeated, + ACTIONS(94), 1, + anon_sym_group, + ACTIONS(97), 1, + anon_sym_oneof, + ACTIONS(100), 1, + anon_sym_map, + ACTIONS(106), 1, + anon_sym_reserved, + ACTIONS(109), 1, + anon_sym_extensions, + ACTIONS(112), 1, + sym_identifier, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(299), 1, + sym_type, + ACTIONS(74), 2, + anon_sym_export, + anon_sym_local, + ACTIONS(88), 2, + anon_sym_optional, + anon_sym_required, + STATE(4), 12, + sym_empty_statement, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_group, + sym_field, + sym_oneof, + sym_map_field, + sym_reserved, + sym_extensions, + aux_sym_message_body_repeat1, + ACTIONS(103), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [282] = 22, + ACTIONS(3), 1, + sym_comment, + ACTIONS(29), 1, + anon_sym_SEMI, + ACTIONS(31), 1, + anon_sym_option, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(37), 1, + anon_sym_enum, + ACTIONS(41), 1, + anon_sym_message, + ACTIONS(43), 1, + anon_sym_extend, + ACTIONS(47), 1, + anon_sym_repeated, + ACTIONS(49), 1, + anon_sym_group, + ACTIONS(51), 1, + anon_sym_oneof, + ACTIONS(53), 1, + anon_sym_map, + ACTIONS(57), 1, + anon_sym_reserved, + ACTIONS(59), 1, + anon_sym_extensions, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(115), 1, + anon_sym_RBRACE, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(299), 1, + sym_type, + ACTIONS(35), 2, + anon_sym_export, + anon_sym_local, + ACTIONS(45), 2, + anon_sym_optional, + anon_sym_required, + STATE(4), 12, + sym_empty_statement, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_group, + sym_field, + sym_oneof, + sym_map_field, + sym_reserved, + sym_extensions, + aux_sym_message_body_repeat1, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [376] = 22, + ACTIONS(3), 1, + sym_comment, + ACTIONS(29), 1, + anon_sym_SEMI, + ACTIONS(31), 1, + anon_sym_option, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(37), 1, + anon_sym_enum, + ACTIONS(41), 1, + anon_sym_message, + ACTIONS(43), 1, + anon_sym_extend, + ACTIONS(47), 1, + anon_sym_repeated, + ACTIONS(49), 1, + anon_sym_group, + ACTIONS(51), 1, + anon_sym_oneof, + ACTIONS(53), 1, + anon_sym_map, + ACTIONS(57), 1, + anon_sym_reserved, + ACTIONS(59), 1, + anon_sym_extensions, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(117), 1, + anon_sym_RBRACE, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(299), 1, + sym_type, + ACTIONS(35), 2, + anon_sym_export, + anon_sym_local, + ACTIONS(45), 2, + anon_sym_optional, + anon_sym_required, + STATE(5), 12, + sym_empty_statement, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_group, + sym_field, + sym_oneof, + sym_map_field, + sym_reserved, + sym_extensions, + aux_sym_message_body_repeat1, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [470] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(119), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(121), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [511] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(123), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(125), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [552] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(127), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(129), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [593] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(131), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(133), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [634] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(135), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(137), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [675] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(139), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(141), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [716] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(143), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(145), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [757] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(147), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(149), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [798] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(151), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(153), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [839] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(155), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(157), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [880] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(159), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(161), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [921] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(163), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(165), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [962] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(167), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(169), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1003] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(171), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(173), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1044] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(175), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(177), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1085] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(179), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(181), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1126] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(183), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(185), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1167] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(187), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(189), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1208] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(191), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(193), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1249] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(195), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(197), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1290] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(199), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(201), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1331] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(203), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(205), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1372] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(207), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(209), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1413] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(211), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(213), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1454] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(215), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(217), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1495] = 11, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(219), 1, + anon_sym_SEMI, + ACTIONS(221), 1, + anon_sym_option, + ACTIONS(223), 1, + anon_sym_RBRACE, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(342), 1, + sym_type, + STATE(34), 4, + sym_empty_statement, + sym_option, + sym_oneof_field, + aux_sym_oneof_repeat1, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [1546] = 11, + ACTIONS(3), 1, + sym_comment, + ACTIONS(225), 1, + anon_sym_SEMI, + ACTIONS(228), 1, + anon_sym_option, + ACTIONS(231), 1, + anon_sym_DOT, + ACTIONS(234), 1, + anon_sym_RBRACE, + ACTIONS(239), 1, + sym_identifier, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(342), 1, + sym_type, + STATE(33), 4, + sym_empty_statement, + sym_option, + sym_oneof_field, + aux_sym_oneof_repeat1, + ACTIONS(236), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [1597] = 11, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(219), 1, + anon_sym_SEMI, + ACTIONS(221), 1, + anon_sym_option, + ACTIONS(242), 1, + anon_sym_RBRACE, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(342), 1, + sym_type, + STATE(33), 4, + sym_empty_statement, + sym_option, + sym_oneof_field, + aux_sym_oneof_repeat1, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [1648] = 9, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(244), 1, + anon_sym_repeated, + ACTIONS(246), 1, + anon_sym_group, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(276), 1, + sym_type, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [1690] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(248), 5, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_LBRACE, + anon_sym_RBRACE, + anon_sym_LBRACK, + ACTIONS(250), 17, + anon_sym_option, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + sym_identifier, + [1720] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(256), 1, + anon_sym_LBRACK, + ACTIONS(252), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(254), 17, + anon_sym_option, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + sym_identifier, + [1751] = 8, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(246), 1, + anon_sym_group, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(276), 1, + sym_type, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [1790] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(258), 4, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + anon_sym_LBRACK, + ACTIONS(260), 17, + anon_sym_option, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + sym_identifier, + [1819] = 15, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(266), 1, + anon_sym_LBRACK, + ACTIONS(268), 1, + anon_sym_COLON, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(276), 1, + sym_hex_lit, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(127), 1, + sym_constant, + ACTIONS(264), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(274), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [1872] = 15, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(276), 1, + sym_hex_lit, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(284), 1, + anon_sym_LBRACK, + ACTIONS(286), 1, + anon_sym_COLON, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(142), 1, + sym_constant, + ACTIONS(264), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(274), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [1925] = 12, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(13), 1, + anon_sym_import, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(17), 1, + anon_sym_package, + ACTIONS(21), 1, + anon_sym_enum, + ACTIONS(23), 1, + anon_sym_message, + ACTIONS(25), 1, + anon_sym_extend, + ACTIONS(27), 1, + anon_sym_service, + ACTIONS(288), 1, + ts_builtin_sym_end, + ACTIONS(19), 2, + anon_sym_export, + anon_sym_local, + STATE(45), 9, + sym_empty_statement, + sym_import, + sym_package, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_service, + aux_sym_source_file_repeat1, + [1971] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(320), 1, + sym_type, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [2007] = 12, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(13), 1, + anon_sym_import, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(17), 1, + anon_sym_package, + ACTIONS(21), 1, + anon_sym_enum, + ACTIONS(23), 1, + anon_sym_message, + ACTIONS(25), 1, + anon_sym_extend, + ACTIONS(27), 1, + anon_sym_service, + ACTIONS(290), 1, + ts_builtin_sym_end, + ACTIONS(19), 2, + anon_sym_export, + anon_sym_local, + STATE(42), 9, + sym_empty_statement, + sym_import, + sym_package, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_service, + aux_sym_source_file_repeat1, + [2053] = 12, + ACTIONS(3), 1, + sym_comment, + ACTIONS(292), 1, + ts_builtin_sym_end, + ACTIONS(294), 1, + anon_sym_SEMI, + ACTIONS(297), 1, + anon_sym_import, + ACTIONS(300), 1, + anon_sym_option, + ACTIONS(303), 1, + anon_sym_package, + ACTIONS(309), 1, + anon_sym_enum, + ACTIONS(312), 1, + anon_sym_message, + ACTIONS(315), 1, + anon_sym_extend, + ACTIONS(318), 1, + anon_sym_service, + ACTIONS(306), 2, + anon_sym_export, + anon_sym_local, + STATE(45), 9, + sym_empty_statement, + sym_import, + sym_package, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_service, + aux_sym_source_file_repeat1, + [2099] = 14, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(323), 1, + anon_sym_RBRACK, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(202), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2149] = 14, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + ACTIONS(329), 1, + anon_sym_RBRACK, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(183), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2199] = 14, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(276), 1, + sym_hex_lit, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(331), 1, + anon_sym_LBRACK, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(141), 1, + sym_constant, + ACTIONS(264), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(274), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2249] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(274), 1, + sym_type, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [2285] = 14, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + ACTIONS(333), 1, + anon_sym_RBRACK, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(197), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2335] = 14, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(276), 1, + sym_hex_lit, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(335), 1, + anon_sym_LBRACK, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(139), 1, + sym_constant, + ACTIONS(264), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(274), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2385] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(337), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(339), 17, + anon_sym_option, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + sym_identifier, + [2413] = 12, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(13), 1, + anon_sym_import, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(17), 1, + anon_sym_package, + ACTIONS(21), 1, + anon_sym_enum, + ACTIONS(23), 1, + anon_sym_message, + ACTIONS(25), 1, + anon_sym_extend, + ACTIONS(27), 1, + anon_sym_service, + ACTIONS(290), 1, + ts_builtin_sym_end, + ACTIONS(19), 2, + anon_sym_export, + anon_sym_local, + STATE(45), 9, + sym_empty_statement, + sym_import, + sym_package, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_service, + aux_sym_source_file_repeat1, + [2459] = 14, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + ACTIONS(341), 1, + anon_sym_RBRACK, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(195), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2509] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(211), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(213), 17, + anon_sym_option, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + sym_identifier, + [2537] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(123), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(125), 17, + anon_sym_option, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + sym_identifier, + [2565] = 13, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(327), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2612] = 13, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(308), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2659] = 13, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(232), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2706] = 13, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(222), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2753] = 13, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(251), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2800] = 13, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(322), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2847] = 13, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(326), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2894] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(123), 13, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_RBRACE, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + anon_sym_rpc, + [2913] = 3, + ACTIONS(3), 1, + sym_comment, + STATE(335), 1, + sym_key_type, + ACTIONS(343), 12, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + [2934] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(211), 13, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_RBRACE, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + anon_sym_rpc, + [2953] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(345), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [2970] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(347), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [2987] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(203), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3004] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(179), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3021] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(349), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3038] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(119), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3055] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(351), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3072] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(175), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3089] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(183), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3106] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(353), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3123] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(355), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3140] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(359), 1, + anon_sym_DOT, + STATE(78), 1, + aux_sym__option_name_repeat1, + ACTIONS(357), 9, + anon_sym_SEMI, + anon_sym_EQ, + anon_sym_RPAREN, + anon_sym_LBRACE, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3161] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(187), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3178] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(191), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3195] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(195), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3212] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(199), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3229] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(362), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3246] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(366), 1, + anon_sym_DOT, + STATE(78), 1, + aux_sym__option_name_repeat1, + ACTIONS(364), 8, + anon_sym_SEMI, + anon_sym_RPAREN, + anon_sym_LBRACE, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3266] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(368), 1, + anon_sym_SEMI, + ACTIONS(371), 1, + anon_sym_option, + ACTIONS(374), 1, + anon_sym_RBRACE, + ACTIONS(376), 1, + anon_sym_reserved, + ACTIONS(379), 1, + sym_identifier, + STATE(85), 5, + sym_empty_statement, + sym_option, + sym_enum_field, + sym_reserved, + aux_sym_enum_body_repeat1, + [3292] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(382), 1, + anon_sym_SEMI, + ACTIONS(384), 1, + anon_sym_option, + ACTIONS(386), 1, + anon_sym_RBRACE, + ACTIONS(388), 1, + anon_sym_reserved, + ACTIONS(390), 1, + sym_identifier, + STATE(87), 5, + sym_empty_statement, + sym_option, + sym_enum_field, + sym_reserved, + aux_sym_enum_body_repeat1, + [3318] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(382), 1, + anon_sym_SEMI, + ACTIONS(384), 1, + anon_sym_option, + ACTIONS(388), 1, + anon_sym_reserved, + ACTIONS(390), 1, + sym_identifier, + ACTIONS(392), 1, + anon_sym_RBRACE, + STATE(85), 5, + sym_empty_statement, + sym_option, + sym_enum_field, + sym_reserved, + aux_sym_enum_body_repeat1, + [3344] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(382), 1, + anon_sym_SEMI, + ACTIONS(384), 1, + anon_sym_option, + ACTIONS(388), 1, + anon_sym_reserved, + ACTIONS(390), 1, + sym_identifier, + ACTIONS(394), 1, + anon_sym_RBRACE, + STATE(91), 5, + sym_empty_statement, + sym_option, + sym_enum_field, + sym_reserved, + aux_sym_enum_body_repeat1, + [3370] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(366), 1, + anon_sym_DOT, + STATE(84), 1, + aux_sym__option_name_repeat1, + ACTIONS(396), 8, + anon_sym_SEMI, + anon_sym_RPAREN, + anon_sym_LBRACE, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3390] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(357), 10, + anon_sym_SEMI, + anon_sym_EQ, + anon_sym_RPAREN, + anon_sym_DOT, + anon_sym_LBRACE, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3406] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(382), 1, + anon_sym_SEMI, + ACTIONS(384), 1, + anon_sym_option, + ACTIONS(388), 1, + anon_sym_reserved, + ACTIONS(390), 1, + sym_identifier, + ACTIONS(398), 1, + anon_sym_RBRACE, + STATE(85), 5, + sym_empty_statement, + sym_option, + sym_enum_field, + sym_reserved, + aux_sym_enum_body_repeat1, + [3432] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(402), 1, + anon_sym_DQUOTE, + ACTIONS(405), 1, + anon_sym_SQUOTE, + STATE(92), 1, + aux_sym_string_repeat3, + ACTIONS(400), 6, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3453] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + STATE(92), 1, + aux_sym_string_repeat3, + ACTIONS(408), 6, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3474] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(400), 8, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + anon_sym_DQUOTE, + anon_sym_SQUOTE, + [3488] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + ACTIONS(410), 1, + sym_reserved_identifier, + STATE(179), 1, + sym_range, + STATE(189), 1, + sym_int_lit, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + STATE(325), 2, + sym_ranges, + sym_reserved_field_names, + [3512] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(412), 1, + anon_sym_SEMI, + ACTIONS(415), 1, + anon_sym_option, + ACTIONS(418), 1, + anon_sym_RBRACE, + ACTIONS(420), 1, + anon_sym_rpc, + STATE(96), 4, + sym_empty_statement, + sym_option, + sym_rpc, + aux_sym_service_repeat1, + [3534] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(423), 8, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + anon_sym_DQUOTE, + anon_sym_SQUOTE, + [3548] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + ACTIONS(410), 1, + sym_reserved_identifier, + STATE(179), 1, + sym_range, + STATE(189), 1, + sym_int_lit, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + STATE(303), 2, + sym_ranges, + sym_reserved_field_names, + [3572] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(425), 1, + anon_sym_RBRACE, + ACTIONS(427), 1, + anon_sym_rpc, + STATE(96), 4, + sym_empty_statement, + sym_option, + sym_rpc, + aux_sym_service_repeat1, + [3594] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(427), 1, + anon_sym_rpc, + ACTIONS(429), 1, + anon_sym_RBRACE, + STATE(99), 4, + sym_empty_statement, + sym_option, + sym_rpc, + aux_sym_service_repeat1, + [3616] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(302), 1, + sym_string, + ACTIONS(431), 3, + anon_sym_weak, + anon_sym_public, + anon_sym_option, + [3637] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(433), 1, + anon_sym_RBRACE, + STATE(104), 3, + sym_empty_statement, + sym_option, + aux_sym_rpc_repeat1, + [3655] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(435), 1, + anon_sym_RBRACE, + STATE(106), 3, + sym_empty_statement, + sym_option, + aux_sym_rpc_repeat1, + [3673] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(435), 1, + anon_sym_RBRACE, + STATE(107), 3, + sym_empty_statement, + sym_option, + aux_sym_rpc_repeat1, + [3691] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(437), 1, + anon_sym_RBRACE, + STATE(108), 3, + sym_empty_statement, + sym_option, + aux_sym_rpc_repeat1, + [3709] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(437), 1, + anon_sym_RBRACE, + STATE(107), 3, + sym_empty_statement, + sym_option, + aux_sym_rpc_repeat1, + [3727] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(439), 1, + anon_sym_SEMI, + ACTIONS(442), 1, + anon_sym_option, + ACTIONS(445), 1, + anon_sym_RBRACE, + STATE(107), 3, + sym_empty_statement, + sym_option, + aux_sym_rpc_repeat1, + [3745] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(447), 1, + anon_sym_RBRACE, + STATE(107), 3, + sym_empty_statement, + sym_option, + aux_sym_rpc_repeat1, + [3763] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(258), 6, + anon_sym_SEMI, + anon_sym_LBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + anon_sym_to, + [3775] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(449), 6, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3787] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(451), 6, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3799] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(453), 6, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3811] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(179), 1, + sym_range, + STATE(189), 1, + sym_int_lit, + STATE(286), 1, + sym_ranges, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [3831] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(455), 6, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3843] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(457), 6, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3855] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(327), 1, + sym_hex_lit, + ACTIONS(459), 1, + sym_float_lit, + STATE(111), 1, + sym_int_lit, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + [3872] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(210), 1, + sym_field_option, + STATE(305), 1, + sym_field_options, + STATE(317), 1, + sym__option_name, + [3891] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(465), 2, + anon_sym_SEMI, + anon_sym_COMMA, + ACTIONS(467), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [3904] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(189), 1, + sym_int_lit, + STATE(229), 1, + sym_range, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [3921] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(469), 1, + anon_sym_stream, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(318), 1, + sym_message_or_enum_type, + [3940] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(471), 2, + anon_sym_SEMI, + anon_sym_COMMA, + ACTIONS(473), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [3953] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(210), 1, + sym_field_option, + STATE(317), 1, + sym__option_name, + STATE(331), 1, + sym_field_options, + [3972] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(475), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(477), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [3985] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(211), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(213), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [3998] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(210), 1, + sym_field_option, + STATE(317), 1, + sym__option_name, + STATE(332), 1, + sym_field_options, + [4017] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(36), 1, + sym_int_lit, + STATE(218), 1, + sym_field_number, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4034] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(479), 2, + anon_sym_SEMI, + anon_sym_COMMA, + ACTIONS(481), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [4047] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(258), 5, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + sym_identifier, + [4058] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(36), 1, + sym_int_lit, + STATE(269), 1, + sym_field_number, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4075] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + ACTIONS(483), 1, + anon_sym_DASH, + STATE(256), 1, + sym_int_lit, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4092] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(485), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(487), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [4105] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(491), 1, + sym_octal_lit, + STATE(36), 1, + sym_int_lit, + STATE(37), 1, + sym_field_number, + ACTIONS(489), 2, + sym_decimal_lit, + sym_hex_lit, + [4122] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(123), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(125), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [4135] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(493), 2, + anon_sym_SEMI, + anon_sym_COMMA, + ACTIONS(495), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [4148] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(36), 1, + sym_int_lit, + STATE(234), 1, + sym_field_number, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4165] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(497), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(499), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [4178] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(210), 1, + sym_field_option, + STATE(298), 1, + sym_field_options, + STATE(317), 1, + sym__option_name, + [4197] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(501), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(503), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [4210] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(505), 2, + anon_sym_SEMI, + anon_sym_COMMA, + ACTIONS(507), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [4223] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(36), 1, + sym_int_lit, + STATE(253), 1, + sym_field_number, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4240] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(509), 2, + anon_sym_SEMI, + anon_sym_COMMA, + ACTIONS(511), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [4253] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(513), 2, + anon_sym_SEMI, + anon_sym_COMMA, + ACTIONS(515), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [4266] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + ACTIONS(517), 1, + anon_sym_max, + STATE(230), 1, + sym_int_lit, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4283] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(276), 1, + sym_hex_lit, + ACTIONS(459), 1, + sym_float_lit, + STATE(111), 1, + sym_int_lit, + ACTIONS(274), 2, + sym_decimal_lit, + sym_octal_lit, + [4300] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(36), 1, + sym_int_lit, + STATE(235), 1, + sym_field_number, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4317] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(519), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(521), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [4330] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(210), 1, + sym_field_option, + STATE(273), 1, + sym_field_options, + STATE(317), 1, + sym__option_name, + [4349] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(207), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(209), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [4362] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(36), 1, + sym_int_lit, + STATE(219), 1, + sym_field_number, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4379] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(523), 1, + anon_sym_stream, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(341), 1, + sym_message_or_enum_type, + [4398] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(525), 1, + anon_sym_stream, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(337), 1, + sym_message_or_enum_type, + [4417] = 4, + ACTIONS(527), 1, + anon_sym_SQUOTE, + ACTIONS(531), 1, + sym_comment, + STATE(160), 1, + aux_sym_string_repeat2, + ACTIONS(529), 2, + aux_sym_string_token2, + sym_escape_sequence, + [4431] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(533), 1, + anon_sym_RBRACE, + ACTIONS(535), 1, + anon_sym_LBRACK, + ACTIONS(537), 1, + sym_identifier, + STATE(161), 1, + aux_sym_block_lit_repeat2, + [4447] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(194), 1, + sym_enum_value_option, + STATE(310), 1, + sym__option_name, + [4463] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(539), 1, + sym_identifier, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(288), 1, + sym_message_or_enum_type, + [4479] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(201), 1, + sym_enum_value_option, + STATE(310), 1, + sym__option_name, + [4495] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(543), 1, + anon_sym_DOT, + ACTIONS(541), 3, + anon_sym_RPAREN, + anon_sym_GT, + sym_identifier, + [4507] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(223), 1, + sym_enum_value_option, + STATE(310), 1, + sym__option_name, + [4523] = 4, + ACTIONS(531), 1, + sym_comment, + ACTIONS(545), 1, + anon_sym_DQUOTE, + STATE(177), 1, + aux_sym_string_repeat1, + ACTIONS(547), 2, + aux_sym_string_token1, + sym_escape_sequence, + [4537] = 4, + ACTIONS(531), 1, + sym_comment, + ACTIONS(545), 1, + anon_sym_SQUOTE, + STATE(178), 1, + aux_sym_string_repeat2, + ACTIONS(549), 2, + aux_sym_string_token2, + sym_escape_sequence, + [4551] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(535), 1, + anon_sym_LBRACK, + ACTIONS(537), 1, + sym_identifier, + ACTIONS(551), 1, + anon_sym_RBRACE, + STATE(174), 1, + aux_sym_block_lit_repeat2, + [4567] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(233), 1, + sym_field_option, + STATE(317), 1, + sym__option_name, + [4583] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(539), 1, + sym_identifier, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(337), 1, + sym_message_or_enum_type, + [4599] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(543), 1, + anon_sym_DOT, + ACTIONS(553), 3, + anon_sym_RPAREN, + anon_sym_GT, + sym_identifier, + [4611] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(539), 1, + sym_identifier, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(278), 1, + sym_message_or_enum_type, + [4627] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(295), 1, + sym_string, + [4643] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(555), 4, + anon_sym_SEMI, + anon_sym_option, + anon_sym_RBRACE, + anon_sym_rpc, + [4653] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(557), 4, + anon_sym_SEMI, + anon_sym_option, + anon_sym_RBRACE, + anon_sym_rpc, + [4663] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(559), 4, + anon_sym_SEMI, + anon_sym_option, + anon_sym_RBRACE, + anon_sym_rpc, + [4673] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(561), 4, + anon_sym_SEMI, + anon_sym_option, + anon_sym_RBRACE, + anon_sym_rpc, + [4683] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(563), 4, + anon_sym_SEMI, + anon_sym_option, + anon_sym_RBRACE, + anon_sym_rpc, + [4693] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(284), 1, + sym_string, + [4709] = 4, + ACTIONS(527), 1, + anon_sym_DQUOTE, + ACTIONS(531), 1, + sym_comment, + STATE(159), 1, + aux_sym_string_repeat1, + ACTIONS(565), 2, + aux_sym_string_token1, + sym_escape_sequence, + [4723] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(515), 1, + anon_sym_RBRACE, + ACTIONS(567), 1, + anon_sym_LBRACK, + ACTIONS(570), 1, + sym_identifier, + STATE(174), 1, + aux_sym_block_lit_repeat2, + [4739] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(231), 1, + sym_int_lit, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4753] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(543), 1, + anon_sym_DOT, + ACTIONS(573), 3, + anon_sym_RPAREN, + anon_sym_GT, + sym_identifier, + [4765] = 4, + ACTIONS(531), 1, + sym_comment, + ACTIONS(575), 1, + anon_sym_DQUOTE, + STATE(177), 1, + aux_sym_string_repeat1, + ACTIONS(577), 2, + aux_sym_string_token1, + sym_escape_sequence, + [4779] = 4, + ACTIONS(531), 1, + sym_comment, + ACTIONS(580), 1, + anon_sym_SQUOTE, + STATE(178), 1, + aux_sym_string_repeat2, + ACTIONS(582), 2, + aux_sym_string_token2, + sym_escape_sequence, + [4793] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(585), 1, + anon_sym_SEMI, + ACTIONS(587), 1, + anon_sym_COMMA, + STATE(209), 1, + aux_sym_ranges_repeat1, + [4806] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(589), 1, + anon_sym_COMMA, + ACTIONS(591), 1, + anon_sym_RBRACK, + STATE(181), 1, + aux_sym_enum_field_repeat1, + [4819] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(593), 1, + anon_sym_COMMA, + ACTIONS(596), 1, + anon_sym_RBRACK, + STATE(181), 1, + aux_sym_enum_field_repeat1, + [4832] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(333), 1, + anon_sym_RBRACK, + ACTIONS(598), 1, + anon_sym_COMMA, + STATE(199), 1, + aux_sym_block_lit_repeat1, + [4845] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(333), 1, + anon_sym_RBRACK, + ACTIONS(598), 1, + anon_sym_COMMA, + STATE(200), 1, + aux_sym_block_lit_repeat1, + [4858] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(600), 1, + anon_sym_COMMA, + ACTIONS(602), 1, + anon_sym_RBRACK, + STATE(191), 1, + aux_sym_field_options_repeat1, + [4871] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(473), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [4880] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(604), 1, + anon_sym_SEMI, + ACTIONS(606), 1, + anon_sym_COMMA, + STATE(207), 1, + aux_sym_reserved_field_names_repeat1, + [4893] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(598), 1, + anon_sym_COMMA, + ACTIONS(608), 1, + anon_sym_RBRACK, + STATE(199), 1, + aux_sym_block_lit_repeat1, + [4906] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(340), 1, + sym__option_name, + [4919] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(612), 1, + anon_sym_to, + ACTIONS(610), 2, + anon_sym_SEMI, + anon_sym_COMMA, + [4930] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(366), 1, + anon_sym_DOT, + ACTIONS(614), 1, + anon_sym_EQ, + STATE(78), 1, + aux_sym__option_name_repeat1, + [4943] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(616), 1, + anon_sym_COMMA, + ACTIONS(619), 1, + anon_sym_RBRACK, + STATE(191), 1, + aux_sym_field_options_repeat1, + [4956] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(621), 1, + anon_sym_SEMI, + ACTIONS(623), 1, + anon_sym_COMMA, + STATE(192), 1, + aux_sym_reserved_field_names_repeat1, + [4969] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(495), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [4978] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(589), 1, + anon_sym_COMMA, + ACTIONS(626), 1, + anon_sym_RBRACK, + STATE(203), 1, + aux_sym_enum_field_repeat1, + [4991] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(598), 1, + anon_sym_COMMA, + ACTIONS(628), 1, + anon_sym_RBRACK, + STATE(187), 1, + aux_sym_block_lit_repeat1, + [5004] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(630), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [5013] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(341), 1, + anon_sym_RBRACK, + ACTIONS(598), 1, + anon_sym_COMMA, + STATE(213), 1, + aux_sym_block_lit_repeat1, + [5026] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(507), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [5035] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(632), 1, + anon_sym_COMMA, + ACTIONS(635), 1, + anon_sym_RBRACK, + STATE(199), 1, + aux_sym_block_lit_repeat1, + [5048] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(341), 1, + anon_sym_RBRACK, + ACTIONS(598), 1, + anon_sym_COMMA, + STATE(199), 1, + aux_sym_block_lit_repeat1, + [5061] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(589), 1, + anon_sym_COMMA, + ACTIONS(637), 1, + anon_sym_RBRACK, + STATE(180), 1, + aux_sym_enum_field_repeat1, + [5074] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(329), 1, + anon_sym_RBRACK, + ACTIONS(598), 1, + anon_sym_COMMA, + STATE(182), 1, + aux_sym_block_lit_repeat1, + [5087] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(589), 1, + anon_sym_COMMA, + ACTIONS(637), 1, + anon_sym_RBRACK, + STATE(181), 1, + aux_sym_enum_field_repeat1, + [5100] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(511), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [5109] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(344), 1, + sym__option_name, + [5122] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(481), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [5131] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(606), 1, + anon_sym_COMMA, + ACTIONS(639), 1, + anon_sym_SEMI, + STATE(192), 1, + aux_sym_reserved_field_names_repeat1, + [5144] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(366), 1, + anon_sym_DOT, + ACTIONS(641), 1, + anon_sym_EQ, + STATE(190), 1, + aux_sym__option_name_repeat1, + [5157] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(587), 1, + anon_sym_COMMA, + ACTIONS(643), 1, + anon_sym_SEMI, + STATE(217), 1, + aux_sym_ranges_repeat1, + [5170] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(600), 1, + anon_sym_COMMA, + ACTIONS(645), 1, + anon_sym_RBRACK, + STATE(184), 1, + aux_sym_field_options_repeat1, + [5183] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(467), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [5192] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(366), 1, + anon_sym_DOT, + ACTIONS(647), 1, + anon_sym_EQ, + STATE(78), 1, + aux_sym__option_name_repeat1, + [5205] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(598), 1, + anon_sym_COMMA, + ACTIONS(628), 1, + anon_sym_RBRACK, + STATE(199), 1, + aux_sym_block_lit_repeat1, + [5218] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(366), 1, + anon_sym_DOT, + ACTIONS(649), 1, + anon_sym_EQ, + STATE(212), 1, + aux_sym__option_name_repeat1, + [5231] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(338), 1, + sym__option_name, + [5244] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(339), 1, + sym__option_name, + [5257] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(651), 1, + anon_sym_SEMI, + ACTIONS(653), 1, + anon_sym_COMMA, + STATE(217), 1, + aux_sym_ranges_repeat1, + [5270] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(656), 1, + anon_sym_SEMI, + ACTIONS(658), 1, + anon_sym_LBRACK, + [5280] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(660), 1, + anon_sym_SEMI, + ACTIONS(662), 1, + anon_sym_LBRACK, + [5290] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(621), 2, + anon_sym_SEMI, + anon_sym_COMMA, + [5298] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(664), 1, + sym_identifier, + STATE(247), 1, + sym_enum_name, + [5308] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(666), 2, + anon_sym_COMMA, + anon_sym_RBRACK, + [5316] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(596), 2, + anon_sym_COMMA, + anon_sym_RBRACK, + [5324] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(668), 1, + sym_identifier, + STATE(311), 1, + sym_service_name, + [5334] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(670), 1, + sym_identifier, + STATE(237), 1, + aux_sym_message_or_enum_type_repeat1, + [5344] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(672), 1, + sym_identifier, + STATE(300), 1, + sym_full_ident, + [5354] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(664), 1, + sym_identifier, + STATE(243), 1, + sym_enum_name, + [5364] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(674), 1, + sym_identifier, + STATE(262), 1, + sym_message_name, + [5374] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(651), 2, + anon_sym_SEMI, + anon_sym_COMMA, + [5382] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(676), 2, + anon_sym_SEMI, + anon_sym_COMMA, + [5390] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(678), 1, + anon_sym_SEMI, + ACTIONS(680), 1, + anon_sym_LBRACK, + [5400] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(682), 2, + anon_sym_COMMA, + anon_sym_RBRACK, + [5408] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(619), 2, + anon_sym_COMMA, + anon_sym_RBRACK, + [5416] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(684), 1, + anon_sym_LBRACE, + STATE(14), 1, + sym_message_body, + [5426] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(686), 1, + anon_sym_SEMI, + ACTIONS(688), 1, + anon_sym_LBRACK, + [5436] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(674), 1, + sym_identifier, + STATE(277), 1, + sym_message_name, + [5446] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(690), 1, + sym_identifier, + STATE(246), 1, + aux_sym_message_or_enum_type_repeat1, + [5456] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(692), 1, + anon_sym_SEMI, + ACTIONS(694), 1, + anon_sym_LBRACE, + [5466] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(674), 1, + sym_identifier, + STATE(321), 1, + sym_message_name, + [5476] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(433), 1, + anon_sym_SEMI, + ACTIONS(696), 1, + anon_sym_LBRACE, + [5486] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(698), 2, + anon_sym_GT, + sym_identifier, + [5494] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(700), 1, + sym_identifier, + STATE(297), 1, + sym_rpc_name, + [5504] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(702), 1, + anon_sym_LBRACE, + STATE(79), 1, + sym_enum_body, + [5514] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(704), 1, + anon_sym_LBRACE, + STATE(80), 1, + sym_message_body, + [5524] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(706), 2, + anon_sym_DQUOTEproto3_DQUOTE, + anon_sym_DQUOTEproto2_DQUOTE, + [5532] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(708), 1, + sym_identifier, + STATE(246), 1, + aux_sym_message_or_enum_type_repeat1, + [5542] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(702), 1, + anon_sym_LBRACE, + STATE(74), 1, + sym_enum_body, + [5552] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(674), 1, + sym_identifier, + STATE(244), 1, + sym_message_name, + [5562] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(672), 1, + sym_identifier, + STATE(333), 1, + sym_full_ident, + [5572] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(672), 1, + sym_identifier, + STATE(279), 1, + sym_full_ident, + [5582] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(635), 2, + anon_sym_COMMA, + anon_sym_RBRACK, + [5590] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(711), 2, + anon_sym_EQ, + anon_sym_LBRACE, + [5598] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(684), 1, + anon_sym_LBRACE, + STATE(11), 1, + sym_message_body, + [5608] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(713), 1, + anon_sym_enum, + ACTIONS(715), 1, + anon_sym_message, + [5618] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(672), 1, + sym_identifier, + STATE(268), 1, + sym_full_ident, + [5628] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(717), 1, + anon_sym_SEMI, + ACTIONS(719), 1, + anon_sym_LBRACK, + [5638] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(721), 1, + anon_sym_LBRACE, + STATE(21), 1, + sym_enum_body, + [5648] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(684), 1, + anon_sym_LBRACE, + STATE(22), 1, + sym_message_body, + [5658] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(684), 1, + anon_sym_LBRACE, + STATE(23), 1, + sym_message_body, + [5668] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(721), 1, + anon_sym_LBRACE, + STATE(24), 1, + sym_enum_body, + [5678] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(684), 1, + anon_sym_LBRACE, + STATE(25), 1, + sym_message_body, + [5688] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(704), 1, + anon_sym_LBRACE, + STATE(70), 1, + sym_message_body, + [5698] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(664), 1, + sym_identifier, + STATE(257), 1, + sym_enum_name, + [5708] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(674), 1, + sym_identifier, + STATE(258), 1, + sym_message_name, + [5718] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(672), 1, + sym_identifier, + STATE(259), 1, + sym_full_ident, + [5728] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(664), 1, + sym_identifier, + STATE(260), 1, + sym_enum_name, + [5738] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(674), 1, + sym_identifier, + STATE(261), 1, + sym_message_name, + [5748] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(704), 1, + anon_sym_LBRACE, + STATE(75), 1, + sym_message_body, + [5758] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(723), 1, + anon_sym_SEMI, + ACTIONS(725), 1, + anon_sym_LBRACK, + [5768] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(727), 1, + anon_sym_enum, + ACTIONS(729), 1, + anon_sym_message, + [5778] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(670), 1, + sym_identifier, + STATE(246), 1, + aux_sym_message_or_enum_type_repeat1, + [5788] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(435), 1, + anon_sym_SEMI, + ACTIONS(731), 1, + anon_sym_LBRACE, + [5798] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(733), 1, + anon_sym_RBRACK, + [5805] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(735), 1, + anon_sym_GT, + [5812] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(737), 1, + anon_sym_EQ, + [5819] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(739), 1, + sym_identifier, + [5826] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(741), 1, + anon_sym_EQ, + [5833] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(743), 1, + anon_sym_RPAREN, + [5840] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(745), 1, + anon_sym_RPAREN, + [5847] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(747), 1, + anon_sym_SEMI, + [5854] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(749), 1, + anon_sym_LBRACE, + [5861] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(543), 1, + anon_sym_DOT, + [5868] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(751), 1, + anon_sym_EQ, + [5875] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(753), 1, + anon_sym_SEMI, + [5882] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(755), 1, + anon_sym_SEMI, + [5889] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(757), 1, + anon_sym_SEMI, + [5896] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(759), 1, + anon_sym_SEMI, + [5903] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(761), 1, + anon_sym_RPAREN, + [5910] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(763), 1, + sym_identifier, + [5917] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(765), 1, + anon_sym_EQ, + [5924] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(767), 1, + anon_sym_LPAREN, + [5931] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(769), 1, + sym_identifier, + [5938] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(771), 1, + anon_sym_returns, + [5945] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(773), 1, + anon_sym_LBRACE, + [5952] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(775), 1, + anon_sym_SEMI, + [5959] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(777), 1, + anon_sym_LPAREN, + [5966] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(779), 1, + anon_sym_LPAREN, + [5973] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(781), 1, + anon_sym_RBRACK, + [5980] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(783), 1, + sym_identifier, + [5987] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(785), 1, + anon_sym_RBRACK, + [5994] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(787), 1, + anon_sym_EQ, + [6001] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(789), 1, + anon_sym_SEMI, + [6008] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(791), 1, + anon_sym_SEMI, + [6015] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(793), 1, + anon_sym_SEMI, + [6022] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(795), 1, + anon_sym_RBRACK, + [6029] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(797), 1, + sym_reserved_identifier, + [6036] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(799), 1, + anon_sym_SEMI, + [6043] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(801), 1, + anon_sym_SEMI, + [6050] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(803), 1, + anon_sym_LBRACE, + [6057] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(805), 1, + anon_sym_EQ, + [6064] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(807), 1, + anon_sym_LBRACE, + [6071] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(809), 1, + anon_sym_returns, + [6078] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(811), 1, + anon_sym_SEMI, + [6085] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(813), 1, + anon_sym_LPAREN, + [6092] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(815), 1, + sym_identifier, + [6099] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(817), 1, + anon_sym_EQ, + [6106] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(819), 1, + anon_sym_EQ, + [6113] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(821), 1, + anon_sym_RPAREN, + [6120] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(823), 1, + ts_builtin_sym_end, + [6127] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(825), 1, + sym_identifier, + [6134] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(827), 1, + anon_sym_EQ, + [6141] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(829), 1, + anon_sym_SEMI, + [6148] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(831), 1, + anon_sym_EQ, + [6155] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(833), 1, + anon_sym_SEMI, + [6162] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(835), 1, + anon_sym_SEMI, + [6169] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(837), 1, + anon_sym_SEMI, + [6176] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(839), 1, + anon_sym_SEMI, + [6183] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(841), 1, + anon_sym_EQ, + [6190] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(843), 1, + anon_sym_EQ, + [6197] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(845), 1, + anon_sym_SEMI, + [6204] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(847), 1, + anon_sym_RBRACK, + [6211] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(849), 1, + anon_sym_RBRACK, + [6218] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(851), 1, + anon_sym_SEMI, + [6225] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(853), 1, + anon_sym_COMMA, + [6232] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(855), 1, + anon_sym_COMMA, + [6239] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(857), 1, + sym_identifier, + [6246] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(859), 1, + anon_sym_RPAREN, + [6253] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(861), 1, + anon_sym_EQ, + [6260] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(863), 1, + anon_sym_EQ, + [6267] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(865), 1, + anon_sym_EQ, + [6274] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(867), 1, + anon_sym_RPAREN, + [6281] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(869), 1, + sym_identifier, + [6288] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(871), 1, + anon_sym_LT, + [6295] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(873), 1, + anon_sym_EQ, +}; + +static const uint32_t ts_small_parse_table_map[] = { + [SMALL_STATE(2)] = 0, + [SMALL_STATE(3)] = 94, + [SMALL_STATE(4)] = 188, + [SMALL_STATE(5)] = 282, + [SMALL_STATE(6)] = 376, + [SMALL_STATE(7)] = 470, + [SMALL_STATE(8)] = 511, + [SMALL_STATE(9)] = 552, + [SMALL_STATE(10)] = 593, + [SMALL_STATE(11)] = 634, + [SMALL_STATE(12)] = 675, + [SMALL_STATE(13)] = 716, + [SMALL_STATE(14)] = 757, + [SMALL_STATE(15)] = 798, + [SMALL_STATE(16)] = 839, + [SMALL_STATE(17)] = 880, + [SMALL_STATE(18)] = 921, + [SMALL_STATE(19)] = 962, + [SMALL_STATE(20)] = 1003, + [SMALL_STATE(21)] = 1044, + [SMALL_STATE(22)] = 1085, + [SMALL_STATE(23)] = 1126, + [SMALL_STATE(24)] = 1167, + [SMALL_STATE(25)] = 1208, + [SMALL_STATE(26)] = 1249, + [SMALL_STATE(27)] = 1290, + [SMALL_STATE(28)] = 1331, + [SMALL_STATE(29)] = 1372, + [SMALL_STATE(30)] = 1413, + [SMALL_STATE(31)] = 1454, + [SMALL_STATE(32)] = 1495, + [SMALL_STATE(33)] = 1546, + [SMALL_STATE(34)] = 1597, + [SMALL_STATE(35)] = 1648, + [SMALL_STATE(36)] = 1690, + [SMALL_STATE(37)] = 1720, + [SMALL_STATE(38)] = 1751, + [SMALL_STATE(39)] = 1790, + [SMALL_STATE(40)] = 1819, + [SMALL_STATE(41)] = 1872, + [SMALL_STATE(42)] = 1925, + [SMALL_STATE(43)] = 1971, + [SMALL_STATE(44)] = 2007, + [SMALL_STATE(45)] = 2053, + [SMALL_STATE(46)] = 2099, + [SMALL_STATE(47)] = 2149, + [SMALL_STATE(48)] = 2199, + [SMALL_STATE(49)] = 2249, + [SMALL_STATE(50)] = 2285, + [SMALL_STATE(51)] = 2335, + [SMALL_STATE(52)] = 2385, + [SMALL_STATE(53)] = 2413, + [SMALL_STATE(54)] = 2459, + [SMALL_STATE(55)] = 2509, + [SMALL_STATE(56)] = 2537, + [SMALL_STATE(57)] = 2565, + [SMALL_STATE(58)] = 2612, + [SMALL_STATE(59)] = 2659, + [SMALL_STATE(60)] = 2706, + [SMALL_STATE(61)] = 2753, + [SMALL_STATE(62)] = 2800, + [SMALL_STATE(63)] = 2847, + [SMALL_STATE(64)] = 2894, + [SMALL_STATE(65)] = 2913, + [SMALL_STATE(66)] = 2934, + [SMALL_STATE(67)] = 2953, + [SMALL_STATE(68)] = 2970, + [SMALL_STATE(69)] = 2987, + [SMALL_STATE(70)] = 3004, + [SMALL_STATE(71)] = 3021, + [SMALL_STATE(72)] = 3038, + [SMALL_STATE(73)] = 3055, + [SMALL_STATE(74)] = 3072, + [SMALL_STATE(75)] = 3089, + [SMALL_STATE(76)] = 3106, + [SMALL_STATE(77)] = 3123, + [SMALL_STATE(78)] = 3140, + [SMALL_STATE(79)] = 3161, + [SMALL_STATE(80)] = 3178, + [SMALL_STATE(81)] = 3195, + [SMALL_STATE(82)] = 3212, + [SMALL_STATE(83)] = 3229, + [SMALL_STATE(84)] = 3246, + [SMALL_STATE(85)] = 3266, + [SMALL_STATE(86)] = 3292, + [SMALL_STATE(87)] = 3318, + [SMALL_STATE(88)] = 3344, + [SMALL_STATE(89)] = 3370, + [SMALL_STATE(90)] = 3390, + [SMALL_STATE(91)] = 3406, + [SMALL_STATE(92)] = 3432, + [SMALL_STATE(93)] = 3453, + [SMALL_STATE(94)] = 3474, + [SMALL_STATE(95)] = 3488, + [SMALL_STATE(96)] = 3512, + [SMALL_STATE(97)] = 3534, + [SMALL_STATE(98)] = 3548, + [SMALL_STATE(99)] = 3572, + [SMALL_STATE(100)] = 3594, + [SMALL_STATE(101)] = 3616, + [SMALL_STATE(102)] = 3637, + [SMALL_STATE(103)] = 3655, + [SMALL_STATE(104)] = 3673, + [SMALL_STATE(105)] = 3691, + [SMALL_STATE(106)] = 3709, + [SMALL_STATE(107)] = 3727, + [SMALL_STATE(108)] = 3745, + [SMALL_STATE(109)] = 3763, + [SMALL_STATE(110)] = 3775, + [SMALL_STATE(111)] = 3787, + [SMALL_STATE(112)] = 3799, + [SMALL_STATE(113)] = 3811, + [SMALL_STATE(114)] = 3831, + [SMALL_STATE(115)] = 3843, + [SMALL_STATE(116)] = 3855, + [SMALL_STATE(117)] = 3872, + [SMALL_STATE(118)] = 3891, + [SMALL_STATE(119)] = 3904, + [SMALL_STATE(120)] = 3921, + [SMALL_STATE(121)] = 3940, + [SMALL_STATE(122)] = 3953, + [SMALL_STATE(123)] = 3972, + [SMALL_STATE(124)] = 3985, + [SMALL_STATE(125)] = 3998, + [SMALL_STATE(126)] = 4017, + [SMALL_STATE(127)] = 4034, + [SMALL_STATE(128)] = 4047, + [SMALL_STATE(129)] = 4058, + [SMALL_STATE(130)] = 4075, + [SMALL_STATE(131)] = 4092, + [SMALL_STATE(132)] = 4105, + [SMALL_STATE(133)] = 4122, + [SMALL_STATE(134)] = 4135, + [SMALL_STATE(135)] = 4148, + [SMALL_STATE(136)] = 4165, + [SMALL_STATE(137)] = 4178, + [SMALL_STATE(138)] = 4197, + [SMALL_STATE(139)] = 4210, + [SMALL_STATE(140)] = 4223, + [SMALL_STATE(141)] = 4240, + [SMALL_STATE(142)] = 4253, + [SMALL_STATE(143)] = 4266, + [SMALL_STATE(144)] = 4283, + [SMALL_STATE(145)] = 4300, + [SMALL_STATE(146)] = 4317, + [SMALL_STATE(147)] = 4330, + [SMALL_STATE(148)] = 4349, + [SMALL_STATE(149)] = 4362, + [SMALL_STATE(150)] = 4379, + [SMALL_STATE(151)] = 4398, + [SMALL_STATE(152)] = 4417, + [SMALL_STATE(153)] = 4431, + [SMALL_STATE(154)] = 4447, + [SMALL_STATE(155)] = 4463, + [SMALL_STATE(156)] = 4479, + [SMALL_STATE(157)] = 4495, + [SMALL_STATE(158)] = 4507, + [SMALL_STATE(159)] = 4523, + [SMALL_STATE(160)] = 4537, + [SMALL_STATE(161)] = 4551, + [SMALL_STATE(162)] = 4567, + [SMALL_STATE(163)] = 4583, + [SMALL_STATE(164)] = 4599, + [SMALL_STATE(165)] = 4611, + [SMALL_STATE(166)] = 4627, + [SMALL_STATE(167)] = 4643, + [SMALL_STATE(168)] = 4653, + [SMALL_STATE(169)] = 4663, + [SMALL_STATE(170)] = 4673, + [SMALL_STATE(171)] = 4683, + [SMALL_STATE(172)] = 4693, + [SMALL_STATE(173)] = 4709, + [SMALL_STATE(174)] = 4723, + [SMALL_STATE(175)] = 4739, + [SMALL_STATE(176)] = 4753, + [SMALL_STATE(177)] = 4765, + [SMALL_STATE(178)] = 4779, + [SMALL_STATE(179)] = 4793, + [SMALL_STATE(180)] = 4806, + [SMALL_STATE(181)] = 4819, + [SMALL_STATE(182)] = 4832, + [SMALL_STATE(183)] = 4845, + [SMALL_STATE(184)] = 4858, + [SMALL_STATE(185)] = 4871, + [SMALL_STATE(186)] = 4880, + [SMALL_STATE(187)] = 4893, + [SMALL_STATE(188)] = 4906, + [SMALL_STATE(189)] = 4919, + [SMALL_STATE(190)] = 4930, + [SMALL_STATE(191)] = 4943, + [SMALL_STATE(192)] = 4956, + [SMALL_STATE(193)] = 4969, + [SMALL_STATE(194)] = 4978, + [SMALL_STATE(195)] = 4991, + [SMALL_STATE(196)] = 5004, + [SMALL_STATE(197)] = 5013, + [SMALL_STATE(198)] = 5026, + [SMALL_STATE(199)] = 5035, + [SMALL_STATE(200)] = 5048, + [SMALL_STATE(201)] = 5061, + [SMALL_STATE(202)] = 5074, + [SMALL_STATE(203)] = 5087, + [SMALL_STATE(204)] = 5100, + [SMALL_STATE(205)] = 5109, + [SMALL_STATE(206)] = 5122, + [SMALL_STATE(207)] = 5131, + [SMALL_STATE(208)] = 5144, + [SMALL_STATE(209)] = 5157, + [SMALL_STATE(210)] = 5170, + [SMALL_STATE(211)] = 5183, + [SMALL_STATE(212)] = 5192, + [SMALL_STATE(213)] = 5205, + [SMALL_STATE(214)] = 5218, + [SMALL_STATE(215)] = 5231, + [SMALL_STATE(216)] = 5244, + [SMALL_STATE(217)] = 5257, + [SMALL_STATE(218)] = 5270, + [SMALL_STATE(219)] = 5280, + [SMALL_STATE(220)] = 5290, + [SMALL_STATE(221)] = 5298, + [SMALL_STATE(222)] = 5308, + [SMALL_STATE(223)] = 5316, + [SMALL_STATE(224)] = 5324, + [SMALL_STATE(225)] = 5334, + [SMALL_STATE(226)] = 5344, + [SMALL_STATE(227)] = 5354, + [SMALL_STATE(228)] = 5364, + [SMALL_STATE(229)] = 5374, + [SMALL_STATE(230)] = 5382, + [SMALL_STATE(231)] = 5390, + [SMALL_STATE(232)] = 5400, + [SMALL_STATE(233)] = 5408, + [SMALL_STATE(234)] = 5416, + [SMALL_STATE(235)] = 5426, + [SMALL_STATE(236)] = 5436, + [SMALL_STATE(237)] = 5446, + [SMALL_STATE(238)] = 5456, + [SMALL_STATE(239)] = 5466, + [SMALL_STATE(240)] = 5476, + [SMALL_STATE(241)] = 5486, + [SMALL_STATE(242)] = 5494, + [SMALL_STATE(243)] = 5504, + [SMALL_STATE(244)] = 5514, + [SMALL_STATE(245)] = 5524, + [SMALL_STATE(246)] = 5532, + [SMALL_STATE(247)] = 5542, + [SMALL_STATE(248)] = 5552, + [SMALL_STATE(249)] = 5562, + [SMALL_STATE(250)] = 5572, + [SMALL_STATE(251)] = 5582, + [SMALL_STATE(252)] = 5590, + [SMALL_STATE(253)] = 5598, + [SMALL_STATE(254)] = 5608, + [SMALL_STATE(255)] = 5618, + [SMALL_STATE(256)] = 5628, + [SMALL_STATE(257)] = 5638, + [SMALL_STATE(258)] = 5648, + [SMALL_STATE(259)] = 5658, + [SMALL_STATE(260)] = 5668, + [SMALL_STATE(261)] = 5678, + [SMALL_STATE(262)] = 5688, + [SMALL_STATE(263)] = 5698, + [SMALL_STATE(264)] = 5708, + [SMALL_STATE(265)] = 5718, + [SMALL_STATE(266)] = 5728, + [SMALL_STATE(267)] = 5738, + [SMALL_STATE(268)] = 5748, + [SMALL_STATE(269)] = 5758, + [SMALL_STATE(270)] = 5768, + [SMALL_STATE(271)] = 5778, + [SMALL_STATE(272)] = 5788, + [SMALL_STATE(273)] = 5798, + [SMALL_STATE(274)] = 5805, + [SMALL_STATE(275)] = 5812, + [SMALL_STATE(276)] = 5819, + [SMALL_STATE(277)] = 5826, + [SMALL_STATE(278)] = 5833, + [SMALL_STATE(279)] = 5840, + [SMALL_STATE(280)] = 5847, + [SMALL_STATE(281)] = 5854, + [SMALL_STATE(282)] = 5861, + [SMALL_STATE(283)] = 5868, + [SMALL_STATE(284)] = 5875, + [SMALL_STATE(285)] = 5882, + [SMALL_STATE(286)] = 5889, + [SMALL_STATE(287)] = 5896, + [SMALL_STATE(288)] = 5903, + [SMALL_STATE(289)] = 5910, + [SMALL_STATE(290)] = 5917, + [SMALL_STATE(291)] = 5924, + [SMALL_STATE(292)] = 5931, + [SMALL_STATE(293)] = 5938, + [SMALL_STATE(294)] = 5945, + [SMALL_STATE(295)] = 5952, + [SMALL_STATE(296)] = 5959, + [SMALL_STATE(297)] = 5966, + [SMALL_STATE(298)] = 5973, + [SMALL_STATE(299)] = 5980, + [SMALL_STATE(300)] = 5987, + [SMALL_STATE(301)] = 5994, + [SMALL_STATE(302)] = 6001, + [SMALL_STATE(303)] = 6008, + [SMALL_STATE(304)] = 6015, + [SMALL_STATE(305)] = 6022, + [SMALL_STATE(306)] = 6029, + [SMALL_STATE(307)] = 6036, + [SMALL_STATE(308)] = 6043, + [SMALL_STATE(309)] = 6050, + [SMALL_STATE(310)] = 6057, + [SMALL_STATE(311)] = 6064, + [SMALL_STATE(312)] = 6071, + [SMALL_STATE(313)] = 6078, + [SMALL_STATE(314)] = 6085, + [SMALL_STATE(315)] = 6092, + [SMALL_STATE(316)] = 6099, + [SMALL_STATE(317)] = 6106, + [SMALL_STATE(318)] = 6113, + [SMALL_STATE(319)] = 6120, + [SMALL_STATE(320)] = 6127, + [SMALL_STATE(321)] = 6134, + [SMALL_STATE(322)] = 6141, + [SMALL_STATE(323)] = 6148, + [SMALL_STATE(324)] = 6155, + [SMALL_STATE(325)] = 6162, + [SMALL_STATE(326)] = 6169, + [SMALL_STATE(327)] = 6176, + [SMALL_STATE(328)] = 6183, + [SMALL_STATE(329)] = 6190, + [SMALL_STATE(330)] = 6197, + [SMALL_STATE(331)] = 6204, + [SMALL_STATE(332)] = 6211, + [SMALL_STATE(333)] = 6218, + [SMALL_STATE(334)] = 6225, + [SMALL_STATE(335)] = 6232, + [SMALL_STATE(336)] = 6239, + [SMALL_STATE(337)] = 6246, + [SMALL_STATE(338)] = 6253, + [SMALL_STATE(339)] = 6260, + [SMALL_STATE(340)] = 6267, + [SMALL_STATE(341)] = 6274, + [SMALL_STATE(342)] = 6281, + [SMALL_STATE(343)] = 6288, + [SMALL_STATE(344)] = 6295, +}; + +static const TSParseActionEntry ts_parse_actions[] = { + [0] = {.entry = {.count = 0, .reusable = false}}, + [1] = {.entry = {.count = 1, .reusable = false}}, RECOVER(), + [3] = {.entry = {.count = 1, .reusable = true}}, SHIFT_EXTRA(), + [5] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_source_file, 0, 0, 0), + [7] = {.entry = {.count = 1, .reusable = true}}, SHIFT(66), + [9] = {.entry = {.count = 1, .reusable = true}}, SHIFT(328), + [11] = {.entry = {.count = 1, .reusable = true}}, SHIFT(316), + [13] = {.entry = {.count = 1, .reusable = true}}, SHIFT(101), + [15] = {.entry = {.count = 1, .reusable = true}}, SHIFT(205), + [17] = {.entry = {.count = 1, .reusable = true}}, SHIFT(249), + [19] = {.entry = {.count = 1, .reusable = true}}, SHIFT(254), + [21] = {.entry = {.count = 1, .reusable = true}}, SHIFT(221), + [23] = {.entry = {.count = 1, .reusable = true}}, SHIFT(228), + [25] = {.entry = {.count = 1, .reusable = true}}, SHIFT(255), + [27] = {.entry = {.count = 1, .reusable = true}}, SHIFT(224), + [29] = {.entry = {.count = 1, .reusable = true}}, SHIFT(30), + [31] = {.entry = {.count = 1, .reusable = false}}, SHIFT(216), + [33] = {.entry = {.count = 1, .reusable = true}}, SHIFT(225), + [35] = {.entry = {.count = 1, .reusable = false}}, SHIFT(270), + [37] = {.entry = {.count = 1, .reusable = false}}, SHIFT(263), + [39] = {.entry = {.count = 1, .reusable = true}}, SHIFT(82), + [41] = {.entry = {.count = 1, .reusable = false}}, SHIFT(264), + [43] = {.entry = {.count = 1, .reusable = false}}, SHIFT(265), + [45] = {.entry = {.count = 1, .reusable = false}}, SHIFT(35), + [47] = {.entry = {.count = 1, .reusable = false}}, SHIFT(38), + [49] = {.entry = {.count = 1, .reusable = false}}, SHIFT(236), + [51] = {.entry = {.count = 1, .reusable = false}}, SHIFT(336), + [53] = {.entry = {.count = 1, .reusable = false}}, SHIFT(343), + [55] = {.entry = {.count = 1, .reusable = false}}, SHIFT(241), + [57] = {.entry = {.count = 1, .reusable = false}}, SHIFT(95), + [59] = {.entry = {.count = 1, .reusable = false}}, SHIFT(113), + [61] = {.entry = {.count = 1, .reusable = false}}, SHIFT(157), + [63] = {.entry = {.count = 1, .reusable = true}}, SHIFT(69), + [65] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(30), + [68] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(216), + [71] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(225), + [74] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(270), + [77] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(263), + [80] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), + [82] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(264), + [85] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(265), + [88] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(35), + [91] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(38), + [94] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(236), + [97] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(336), + [100] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(343), + [103] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(241), + [106] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(95), + [109] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(113), + [112] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(157), + [115] = {.entry = {.count = 1, .reusable = true}}, SHIFT(28), + [117] = {.entry = {.count = 1, .reusable = true}}, SHIFT(27), + [119] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_body, 3, 0, 0), + [121] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum_body, 3, 0, 0), + [123] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_option, 5, 0, 0), + [125] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_option, 5, 0, 0), + [127] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_extensions, 3, 0, 0), + [129] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_extensions, 3, 0, 0), + [131] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_oneof, 4, 0, 0), + [133] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_oneof, 4, 0, 0), + [135] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_group, 5, 0, 0), + [137] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_group, 5, 0, 0), + [139] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_oneof, 5, 0, 0), + [141] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_oneof, 5, 0, 0), + [143] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field, 5, 0, 0), + [145] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_field, 5, 0, 0), + [147] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_group, 6, 0, 0), + [149] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_group, 6, 0, 0), + [151] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field, 6, 0, 0), + [153] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_field, 6, 0, 0), + [155] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field, 7, 0, 0), + [157] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_field, 7, 0, 0), + [159] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field, 8, 0, 0), + [161] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_field, 8, 0, 0), + [163] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field, 10, 0, 0), + [165] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_field, 10, 0, 0), + [167] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_map_field, 10, 0, 0), + [169] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_map_field, 10, 0, 0), + [171] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_map_field, 13, 0, 0), + [173] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_map_field, 13, 0, 0), + [175] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum, 3, 0, 0), + [177] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum, 3, 0, 0), + [179] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message, 3, 0, 0), + [181] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_message, 3, 0, 0), + [183] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_extend, 3, 0, 0), + [185] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_extend, 3, 0, 0), + [187] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum, 4, 0, 0), + [189] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum, 4, 0, 0), + [191] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message, 4, 0, 0), + [193] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_message, 4, 0, 0), + [195] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_body, 2, 0, 0), + [197] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum_body, 2, 0, 0), + [199] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message_body, 2, 0, 0), + [201] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_message_body, 2, 0, 0), + [203] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message_body, 3, 0, 0), + [205] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_message_body, 3, 0, 0), + [207] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_reserved, 3, 0, 0), + [209] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_reserved, 3, 0, 0), + [211] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_empty_statement, 1, 0, 0), + [213] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_empty_statement, 1, 0, 0), + [215] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field, 9, 0, 0), + [217] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_field, 9, 0, 0), + [219] = {.entry = {.count = 1, .reusable = true}}, SHIFT(55), + [221] = {.entry = {.count = 1, .reusable = false}}, SHIFT(188), + [223] = {.entry = {.count = 1, .reusable = true}}, SHIFT(10), + [225] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_oneof_repeat1, 2, 0, 0), SHIFT_REPEAT(55), + [228] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_oneof_repeat1, 2, 0, 0), SHIFT_REPEAT(188), + [231] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_oneof_repeat1, 2, 0, 0), SHIFT_REPEAT(225), + [234] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_oneof_repeat1, 2, 0, 0), + [236] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_oneof_repeat1, 2, 0, 0), SHIFT_REPEAT(241), + [239] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_oneof_repeat1, 2, 0, 0), SHIFT_REPEAT(157), + [242] = {.entry = {.count = 1, .reusable = true}}, SHIFT(12), + [244] = {.entry = {.count = 1, .reusable = false}}, SHIFT(43), + [246] = {.entry = {.count = 1, .reusable = false}}, SHIFT(239), + [248] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field_number, 1, 0, 0), + [250] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_field_number, 1, 0, 0), + [252] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_oneof_field, 4, 0, 0), + [254] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_oneof_field, 4, 0, 0), + [256] = {.entry = {.count = 1, .reusable = true}}, SHIFT(125), + [258] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_int_lit, 1, 0, 0), + [260] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_int_lit, 1, 0, 0), + [262] = {.entry = {.count = 1, .reusable = true}}, SHIFT(153), + [264] = {.entry = {.count = 1, .reusable = true}}, SHIFT(144), + [266] = {.entry = {.count = 1, .reusable = true}}, SHIFT(50), + [268] = {.entry = {.count = 1, .reusable = true}}, SHIFT(51), + [270] = {.entry = {.count = 1, .reusable = false}}, SHIFT(89), + [272] = {.entry = {.count = 1, .reusable = false}}, SHIFT(110), + [274] = {.entry = {.count = 1, .reusable = false}}, SHIFT(128), + [276] = {.entry = {.count = 1, .reusable = true}}, SHIFT(128), + [278] = {.entry = {.count = 1, .reusable = false}}, SHIFT(112), + [280] = {.entry = {.count = 1, .reusable = true}}, SHIFT(173), + [282] = {.entry = {.count = 1, .reusable = true}}, SHIFT(152), + [284] = {.entry = {.count = 1, .reusable = true}}, SHIFT(46), + [286] = {.entry = {.count = 1, .reusable = true}}, SHIFT(48), + [288] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_source_file, 2, 0, 0), + [290] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_source_file, 1, 0, 0), + [292] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), + [294] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(66), + [297] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(101), + [300] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(205), + [303] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(249), + [306] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(254), + [309] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(221), + [312] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(228), + [315] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(255), + [318] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(224), + [321] = {.entry = {.count = 1, .reusable = true}}, SHIFT(116), + [323] = {.entry = {.count = 1, .reusable = true}}, SHIFT(141), + [325] = {.entry = {.count = 1, .reusable = false}}, SHIFT(109), + [327] = {.entry = {.count = 1, .reusable = true}}, SHIFT(109), + [329] = {.entry = {.count = 1, .reusable = true}}, SHIFT(127), + [331] = {.entry = {.count = 1, .reusable = true}}, SHIFT(47), + [333] = {.entry = {.count = 1, .reusable = true}}, SHIFT(139), + [335] = {.entry = {.count = 1, .reusable = true}}, SHIFT(54), + [337] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_oneof_field, 7, 0, 0), + [339] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_oneof_field, 7, 0, 0), + [341] = {.entry = {.count = 1, .reusable = true}}, SHIFT(118), + [343] = {.entry = {.count = 1, .reusable = true}}, SHIFT(334), + [345] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_edition, 4, 0, 2), + [347] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_import, 4, 0, 3), + [349] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_package, 3, 0, 0), + [351] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_service, 5, 0, 0), + [353] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_syntax, 4, 0, 0), + [355] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_import, 3, 0, 1), + [357] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym__option_name_repeat1, 2, 0, 0), + [359] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym__option_name_repeat1, 2, 0, 0), SHIFT_REPEAT(292), + [362] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_service, 4, 0, 0), + [364] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_full_ident, 2, 0, 0), + [366] = {.entry = {.count = 1, .reusable = true}}, SHIFT(292), + [368] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_enum_body_repeat1, 2, 0, 0), SHIFT_REPEAT(124), + [371] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_enum_body_repeat1, 2, 0, 0), SHIFT_REPEAT(215), + [374] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_enum_body_repeat1, 2, 0, 0), + [376] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_enum_body_repeat1, 2, 0, 0), SHIFT_REPEAT(98), + [379] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_enum_body_repeat1, 2, 0, 0), SHIFT_REPEAT(283), + [382] = {.entry = {.count = 1, .reusable = true}}, SHIFT(124), + [384] = {.entry = {.count = 1, .reusable = false}}, SHIFT(215), + [386] = {.entry = {.count = 1, .reusable = true}}, SHIFT(81), + [388] = {.entry = {.count = 1, .reusable = false}}, SHIFT(98), + [390] = {.entry = {.count = 1, .reusable = false}}, SHIFT(283), + [392] = {.entry = {.count = 1, .reusable = true}}, SHIFT(72), + [394] = {.entry = {.count = 1, .reusable = true}}, SHIFT(26), + [396] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_full_ident, 1, 0, 0), + [398] = {.entry = {.count = 1, .reusable = true}}, SHIFT(7), + [400] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_string_repeat3, 2, 0, 0), + [402] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_string_repeat3, 2, 0, 0), SHIFT_REPEAT(173), + [405] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_string_repeat3, 2, 0, 0), SHIFT_REPEAT(152), + [408] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_string, 1, 0, 0), + [410] = {.entry = {.count = 1, .reusable = true}}, SHIFT(186), + [412] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_service_repeat1, 2, 0, 0), SHIFT_REPEAT(66), + [415] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_service_repeat1, 2, 0, 0), SHIFT_REPEAT(205), + [418] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_service_repeat1, 2, 0, 0), + [420] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_service_repeat1, 2, 0, 0), SHIFT_REPEAT(242), + [423] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_string_repeat3, 3, 0, 0), + [425] = {.entry = {.count = 1, .reusable = true}}, SHIFT(73), + [427] = {.entry = {.count = 1, .reusable = true}}, SHIFT(242), + [429] = {.entry = {.count = 1, .reusable = true}}, SHIFT(83), + [431] = {.entry = {.count = 1, .reusable = true}}, SHIFT(172), + [433] = {.entry = {.count = 1, .reusable = true}}, SHIFT(168), + [435] = {.entry = {.count = 1, .reusable = true}}, SHIFT(169), + [437] = {.entry = {.count = 1, .reusable = true}}, SHIFT(170), + [439] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_rpc_repeat1, 2, 0, 0), SHIFT_REPEAT(66), + [442] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_rpc_repeat1, 2, 0, 0), SHIFT_REPEAT(205), + [445] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_rpc_repeat1, 2, 0, 0), + [447] = {.entry = {.count = 1, .reusable = true}}, SHIFT(171), + [449] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_bool, 1, 0, 0), + [451] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_constant, 2, 0, 0), + [453] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_constant, 1, 0, 0), + [455] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_block_lit, 2, 0, 0), + [457] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_block_lit, 3, 0, 0), + [459] = {.entry = {.count = 1, .reusable = true}}, SHIFT(111), + [461] = {.entry = {.count = 1, .reusable = true}}, SHIFT(250), + [463] = {.entry = {.count = 1, .reusable = true}}, SHIFT(208), + [465] = {.entry = {.count = 1, .reusable = true}}, SHIFT(185), + [467] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 6, 0, 0), + [469] = {.entry = {.count = 1, .reusable = false}}, SHIFT(163), + [471] = {.entry = {.count = 1, .reusable = true}}, SHIFT(193), + [473] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 7, 0, 0), + [475] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_field, 8, 0, 0), + [477] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum_field, 8, 0, 0), + [479] = {.entry = {.count = 1, .reusable = true}}, SHIFT(198), + [481] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 4, 0, 0), + [483] = {.entry = {.count = 1, .reusable = true}}, SHIFT(175), + [485] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_field, 4, 0, 0), + [487] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum_field, 4, 0, 0), + [489] = {.entry = {.count = 1, .reusable = true}}, SHIFT(39), + [491] = {.entry = {.count = 1, .reusable = false}}, SHIFT(39), + [493] = {.entry = {.count = 1, .reusable = true}}, SHIFT(196), + [495] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 8, 0, 0), + [497] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_field, 9, 0, 0), + [499] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum_field, 9, 0, 0), + [501] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_field, 7, 0, 0), + [503] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum_field, 7, 0, 0), + [505] = {.entry = {.count = 1, .reusable = true}}, SHIFT(211), + [507] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 5, 0, 0), + [509] = {.entry = {.count = 1, .reusable = true}}, SHIFT(206), + [511] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 3, 0, 0), + [513] = {.entry = {.count = 1, .reusable = true}}, SHIFT(204), + [515] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 2, 0, 0), + [517] = {.entry = {.count = 1, .reusable = true}}, SHIFT(230), + [519] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_field, 5, 0, 0), + [521] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum_field, 5, 0, 0), + [523] = {.entry = {.count = 1, .reusable = false}}, SHIFT(155), + [525] = {.entry = {.count = 1, .reusable = false}}, SHIFT(165), + [527] = {.entry = {.count = 1, .reusable = false}}, SHIFT(94), + [529] = {.entry = {.count = 1, .reusable = true}}, SHIFT(160), + [531] = {.entry = {.count = 1, .reusable = false}}, SHIFT_EXTRA(), + [533] = {.entry = {.count = 1, .reusable = true}}, SHIFT(114), + [535] = {.entry = {.count = 1, .reusable = true}}, SHIFT(226), + [537] = {.entry = {.count = 1, .reusable = true}}, SHIFT(41), + [539] = {.entry = {.count = 1, .reusable = true}}, SHIFT(157), + [541] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message_or_enum_type, 1, 0, 0), + [543] = {.entry = {.count = 1, .reusable = true}}, SHIFT(289), + [545] = {.entry = {.count = 1, .reusable = false}}, SHIFT(97), + [547] = {.entry = {.count = 1, .reusable = true}}, SHIFT(177), + [549] = {.entry = {.count = 1, .reusable = true}}, SHIFT(178), + [551] = {.entry = {.count = 1, .reusable = true}}, SHIFT(115), + [553] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message_or_enum_type, 2, 0, 0), + [555] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_rpc, 10, 0, 0), + [557] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_rpc, 11, 0, 0), + [559] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_rpc, 12, 0, 0), + [561] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_rpc, 13, 0, 0), + [563] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_rpc, 14, 0, 0), + [565] = {.entry = {.count = 1, .reusable = true}}, SHIFT(159), + [567] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 2, 0, 0), SHIFT_REPEAT(226), + [570] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 2, 0, 0), SHIFT_REPEAT(41), + [573] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message_or_enum_type, 3, 0, 0), + [575] = {.entry = {.count = 1, .reusable = false}}, REDUCE(aux_sym_string_repeat1, 2, 0, 0), + [577] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_string_repeat1, 2, 0, 0), SHIFT_REPEAT(177), + [580] = {.entry = {.count = 1, .reusable = false}}, REDUCE(aux_sym_string_repeat2, 2, 0, 0), + [582] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_string_repeat2, 2, 0, 0), SHIFT_REPEAT(178), + [585] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_ranges, 1, 0, 0), + [587] = {.entry = {.count = 1, .reusable = true}}, SHIFT(119), + [589] = {.entry = {.count = 1, .reusable = true}}, SHIFT(158), + [591] = {.entry = {.count = 1, .reusable = true}}, SHIFT(304), + [593] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_enum_field_repeat1, 2, 0, 0), SHIFT_REPEAT(158), + [596] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_enum_field_repeat1, 2, 0, 0), + [598] = {.entry = {.count = 1, .reusable = true}}, SHIFT(61), + [600] = {.entry = {.count = 1, .reusable = true}}, SHIFT(162), + [602] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field_options, 2, 0, 0), + [604] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_reserved_field_names, 1, 0, 0), + [606] = {.entry = {.count = 1, .reusable = true}}, SHIFT(306), + [608] = {.entry = {.count = 1, .reusable = true}}, SHIFT(134), + [610] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_range, 1, 0, 0), + [612] = {.entry = {.count = 1, .reusable = true}}, SHIFT(143), + [614] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym__option_name, 2, 0, 0), + [616] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_field_options_repeat1, 2, 0, 0), SHIFT_REPEAT(162), + [619] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_field_options_repeat1, 2, 0, 0), + [621] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_reserved_field_names_repeat1, 2, 0, 0), + [623] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_reserved_field_names_repeat1, 2, 0, 0), SHIFT_REPEAT(306), + [626] = {.entry = {.count = 1, .reusable = true}}, SHIFT(324), + [628] = {.entry = {.count = 1, .reusable = true}}, SHIFT(121), + [630] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 9, 0, 0), + [632] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat1, 2, 0, 0), SHIFT_REPEAT(61), + [635] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat1, 2, 0, 0), + [637] = {.entry = {.count = 1, .reusable = true}}, SHIFT(285), + [639] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_reserved_field_names, 2, 0, 0), + [641] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym__option_name, 1, 0, 0), + [643] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_ranges, 2, 0, 0), + [645] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field_options, 1, 0, 0), + [647] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym__option_name, 4, 0, 0), + [649] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym__option_name, 3, 0, 0), + [651] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_ranges_repeat1, 2, 0, 0), + [653] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_ranges_repeat1, 2, 0, 0), SHIFT_REPEAT(119), + [656] = {.entry = {.count = 1, .reusable = true}}, SHIFT(19), + [658] = {.entry = {.count = 1, .reusable = true}}, SHIFT(147), + [660] = {.entry = {.count = 1, .reusable = true}}, SHIFT(13), + [662] = {.entry = {.count = 1, .reusable = true}}, SHIFT(137), + [664] = {.entry = {.count = 1, .reusable = true}}, SHIFT(294), + [666] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_value_option, 3, 0, 0), + [668] = {.entry = {.count = 1, .reusable = true}}, SHIFT(309), + [670] = {.entry = {.count = 1, .reusable = true}}, SHIFT(164), + [672] = {.entry = {.count = 1, .reusable = true}}, SHIFT(89), + [674] = {.entry = {.count = 1, .reusable = true}}, SHIFT(252), + [676] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_range, 3, 0, 0), + [678] = {.entry = {.count = 1, .reusable = true}}, SHIFT(146), + [680] = {.entry = {.count = 1, .reusable = true}}, SHIFT(156), + [682] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field_option, 3, 0, 0), + [684] = {.entry = {.count = 1, .reusable = true}}, SHIFT(6), + [686] = {.entry = {.count = 1, .reusable = true}}, SHIFT(15), + [688] = {.entry = {.count = 1, .reusable = true}}, SHIFT(122), + [690] = {.entry = {.count = 1, .reusable = true}}, SHIFT(176), + [692] = {.entry = {.count = 1, .reusable = true}}, SHIFT(167), + [694] = {.entry = {.count = 1, .reusable = true}}, SHIFT(102), + [696] = {.entry = {.count = 1, .reusable = true}}, SHIFT(103), + [698] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_type, 1, 0, 0), + [700] = {.entry = {.count = 1, .reusable = true}}, SHIFT(296), + [702] = {.entry = {.count = 1, .reusable = true}}, SHIFT(86), + [704] = {.entry = {.count = 1, .reusable = true}}, SHIFT(2), + [706] = {.entry = {.count = 1, .reusable = true}}, SHIFT(313), + [708] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_message_or_enum_type_repeat1, 2, 0, 0), SHIFT_REPEAT(282), + [711] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message_name, 1, 0, 0), + [713] = {.entry = {.count = 1, .reusable = true}}, SHIFT(227), + [715] = {.entry = {.count = 1, .reusable = true}}, SHIFT(248), + [717] = {.entry = {.count = 1, .reusable = true}}, SHIFT(131), + [719] = {.entry = {.count = 1, .reusable = true}}, SHIFT(154), + [721] = {.entry = {.count = 1, .reusable = true}}, SHIFT(88), + [723] = {.entry = {.count = 1, .reusable = true}}, SHIFT(16), + [725] = {.entry = {.count = 1, .reusable = true}}, SHIFT(117), + [727] = {.entry = {.count = 1, .reusable = true}}, SHIFT(266), + [729] = {.entry = {.count = 1, .reusable = true}}, SHIFT(267), + [731] = {.entry = {.count = 1, .reusable = true}}, SHIFT(105), + [733] = {.entry = {.count = 1, .reusable = true}}, SHIFT(287), + [735] = {.entry = {.count = 1, .reusable = true}}, SHIFT(315), + [737] = {.entry = {.count = 1, .reusable = true}}, SHIFT(126), + [739] = {.entry = {.count = 1, .reusable = true}}, SHIFT(323), + [741] = {.entry = {.count = 1, .reusable = true}}, SHIFT(140), + [743] = {.entry = {.count = 1, .reusable = true}}, SHIFT(272), + [745] = {.entry = {.count = 1, .reusable = true}}, SHIFT(214), + [747] = {.entry = {.count = 1, .reusable = true}}, SHIFT(17), + [749] = {.entry = {.count = 1, .reusable = true}}, SHIFT(32), + [751] = {.entry = {.count = 1, .reusable = true}}, SHIFT(130), + [753] = {.entry = {.count = 1, .reusable = true}}, SHIFT(68), + [755] = {.entry = {.count = 1, .reusable = true}}, SHIFT(123), + [757] = {.entry = {.count = 1, .reusable = true}}, SHIFT(9), + [759] = {.entry = {.count = 1, .reusable = true}}, SHIFT(20), + [761] = {.entry = {.count = 1, .reusable = true}}, SHIFT(312), + [763] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_message_or_enum_type_repeat1, 2, 0, 0), + [765] = {.entry = {.count = 1, .reusable = true}}, SHIFT(149), + [767] = {.entry = {.count = 1, .reusable = true}}, SHIFT(151), + [769] = {.entry = {.count = 1, .reusable = true}}, SHIFT(90), + [771] = {.entry = {.count = 1, .reusable = true}}, SHIFT(314), + [773] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_name, 1, 0, 0), + [775] = {.entry = {.count = 1, .reusable = true}}, SHIFT(67), + [777] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_rpc_name, 1, 0, 0), + [779] = {.entry = {.count = 1, .reusable = true}}, SHIFT(150), + [781] = {.entry = {.count = 1, .reusable = true}}, SHIFT(280), + [783] = {.entry = {.count = 1, .reusable = true}}, SHIFT(290), + [785] = {.entry = {.count = 1, .reusable = true}}, SHIFT(40), + [787] = {.entry = {.count = 1, .reusable = true}}, SHIFT(129), + [789] = {.entry = {.count = 1, .reusable = true}}, SHIFT(77), + [791] = {.entry = {.count = 1, .reusable = true}}, SHIFT(148), + [793] = {.entry = {.count = 1, .reusable = true}}, SHIFT(136), + [795] = {.entry = {.count = 1, .reusable = true}}, SHIFT(330), + [797] = {.entry = {.count = 1, .reusable = true}}, SHIFT(220), + [799] = {.entry = {.count = 1, .reusable = true}}, SHIFT(31), + [801] = {.entry = {.count = 1, .reusable = true}}, SHIFT(64), + [803] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_service_name, 1, 0, 0), + [805] = {.entry = {.count = 1, .reusable = true}}, SHIFT(60), + [807] = {.entry = {.count = 1, .reusable = true}}, SHIFT(100), + [809] = {.entry = {.count = 1, .reusable = true}}, SHIFT(291), + [811] = {.entry = {.count = 1, .reusable = true}}, SHIFT(76), + [813] = {.entry = {.count = 1, .reusable = true}}, SHIFT(120), + [815] = {.entry = {.count = 1, .reusable = true}}, SHIFT(275), + [817] = {.entry = {.count = 1, .reusable = true}}, SHIFT(245), + [819] = {.entry = {.count = 1, .reusable = true}}, SHIFT(59), + [821] = {.entry = {.count = 1, .reusable = true}}, SHIFT(238), + [823] = {.entry = {.count = 1, .reusable = true}}, ACCEPT_INPUT(), + [825] = {.entry = {.count = 1, .reusable = true}}, SHIFT(301), + [827] = {.entry = {.count = 1, .reusable = true}}, SHIFT(135), + [829] = {.entry = {.count = 1, .reusable = true}}, SHIFT(133), + [831] = {.entry = {.count = 1, .reusable = true}}, SHIFT(145), + [833] = {.entry = {.count = 1, .reusable = true}}, SHIFT(138), + [835] = {.entry = {.count = 1, .reusable = true}}, SHIFT(29), + [837] = {.entry = {.count = 1, .reusable = true}}, SHIFT(8), + [839] = {.entry = {.count = 1, .reusable = true}}, SHIFT(56), + [841] = {.entry = {.count = 1, .reusable = true}}, SHIFT(166), + [843] = {.entry = {.count = 1, .reusable = true}}, SHIFT(132), + [845] = {.entry = {.count = 1, .reusable = true}}, SHIFT(18), + [847] = {.entry = {.count = 1, .reusable = true}}, SHIFT(307), + [849] = {.entry = {.count = 1, .reusable = true}}, SHIFT(52), + [851] = {.entry = {.count = 1, .reusable = true}}, SHIFT(71), + [853] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_key_type, 1, 0, 0), + [855] = {.entry = {.count = 1, .reusable = true}}, SHIFT(49), + [857] = {.entry = {.count = 1, .reusable = true}}, SHIFT(281), + [859] = {.entry = {.count = 1, .reusable = true}}, SHIFT(240), + [861] = {.entry = {.count = 1, .reusable = true}}, SHIFT(62), + [863] = {.entry = {.count = 1, .reusable = true}}, SHIFT(63), + [865] = {.entry = {.count = 1, .reusable = true}}, SHIFT(57), + [867] = {.entry = {.count = 1, .reusable = true}}, SHIFT(293), + [869] = {.entry = {.count = 1, .reusable = true}}, SHIFT(329), + [871] = {.entry = {.count = 1, .reusable = true}}, SHIFT(65), + [873] = {.entry = {.count = 1, .reusable = true}}, SHIFT(58), +}; + +#ifdef __cplusplus +extern "C" { +#endif +#ifdef TREE_SITTER_HIDE_SYMBOLS +#define TS_PUBLIC +#elif defined(_WIN32) +#define TS_PUBLIC __declspec(dllexport) +#else +#define TS_PUBLIC __attribute__((visibility("default"))) +#endif + +TS_PUBLIC const TSLanguage *tree_sitter_proto(void) { + static const TSLanguage language = { + .version = LANGUAGE_VERSION, + .symbol_count = SYMBOL_COUNT, + .alias_count = ALIAS_COUNT, + .token_count = TOKEN_COUNT, + .external_token_count = EXTERNAL_TOKEN_COUNT, + .state_count = STATE_COUNT, + .large_state_count = LARGE_STATE_COUNT, + .production_id_count = PRODUCTION_ID_COUNT, + .field_count = FIELD_COUNT, + .max_alias_sequence_length = MAX_ALIAS_SEQUENCE_LENGTH, + .parse_table = &ts_parse_table[0][0], + .small_parse_table = ts_small_parse_table, + .small_parse_table_map = ts_small_parse_table_map, + .parse_actions = ts_parse_actions, + .symbol_names = ts_symbol_names, + .field_names = ts_field_names, + .field_map_slices = ts_field_map_slices, + .field_map_entries = ts_field_map_entries, + .symbol_metadata = ts_symbol_metadata, + .public_symbol_map = ts_symbol_map, + .alias_map = ts_non_terminal_alias_map, + .alias_sequences = &ts_alias_sequences[0][0], + .lex_modes = ts_lex_modes, + .lex_fn = ts_lex, + .primary_state_ids = ts_primary_state_ids, + }; + return &language; +} +#ifdef __cplusplus +} +#endif diff --git a/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/alloc.h b/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/alloc.h new file mode 100644 index 000000000..1abdd1201 --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/alloc.h @@ -0,0 +1,54 @@ +#ifndef TREE_SITTER_ALLOC_H_ +#define TREE_SITTER_ALLOC_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +// Allow clients to override allocation functions +#ifdef TREE_SITTER_REUSE_ALLOCATOR + +extern void *(*ts_current_malloc)(size_t size); +extern void *(*ts_current_calloc)(size_t count, size_t size); +extern void *(*ts_current_realloc)(void *ptr, size_t size); +extern void (*ts_current_free)(void *ptr); + +#ifndef ts_malloc +#define ts_malloc ts_current_malloc +#endif +#ifndef ts_calloc +#define ts_calloc ts_current_calloc +#endif +#ifndef ts_realloc +#define ts_realloc ts_current_realloc +#endif +#ifndef ts_free +#define ts_free ts_current_free +#endif + +#else + +#ifndef ts_malloc +#define ts_malloc malloc +#endif +#ifndef ts_calloc +#define ts_calloc calloc +#endif +#ifndef ts_realloc +#define ts_realloc realloc +#endif +#ifndef ts_free +#define ts_free free +#endif + +#endif + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_ALLOC_H_ diff --git a/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/array.h b/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/array.h new file mode 100644 index 000000000..a17a574f0 --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/array.h @@ -0,0 +1,291 @@ +#ifndef TREE_SITTER_ARRAY_H_ +#define TREE_SITTER_ARRAY_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "./alloc.h" + +#include +#include +#include +#include +#include + +#ifdef _MSC_VER +#pragma warning(push) +#pragma warning(disable : 4101) +#elif defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-variable" +#endif + +#define Array(T) \ + struct { \ + T *contents; \ + uint32_t size; \ + uint32_t capacity; \ + } + +/// Initialize an array. +#define array_init(self) \ + ((self)->size = 0, (self)->capacity = 0, (self)->contents = NULL) + +/// Create an empty array. +#define array_new() \ + { NULL, 0, 0 } + +/// Get a pointer to the element at a given `index` in the array. +#define array_get(self, _index) \ + (assert((uint32_t)(_index) < (self)->size), &(self)->contents[_index]) + +/// Get a pointer to the first element in the array. +#define array_front(self) array_get(self, 0) + +/// Get a pointer to the last element in the array. +#define array_back(self) array_get(self, (self)->size - 1) + +/// Clear the array, setting its size to zero. Note that this does not free any +/// memory allocated for the array's contents. +#define array_clear(self) ((self)->size = 0) + +/// Reserve `new_capacity` elements of space in the array. If `new_capacity` is +/// less than the array's current capacity, this function has no effect. +#define array_reserve(self, new_capacity) \ + _array__reserve((Array *)(self), array_elem_size(self), new_capacity) + +/// Free any memory allocated for this array. Note that this does not free any +/// memory allocated for the array's contents. +#define array_delete(self) _array__delete((Array *)(self)) + +/// Push a new `element` onto the end of the array. +#define array_push(self, element) \ + (_array__grow((Array *)(self), 1, array_elem_size(self)), \ + (self)->contents[(self)->size++] = (element)) + +/// Increase the array's size by `count` elements. +/// New elements are zero-initialized. +#define array_grow_by(self, count) \ + do { \ + if ((count) == 0) break; \ + _array__grow((Array *)(self), count, array_elem_size(self)); \ + memset((self)->contents + (self)->size, 0, (count) * array_elem_size(self)); \ + (self)->size += (count); \ + } while (0) + +/// Append all elements from one array to the end of another. +#define array_push_all(self, other) \ + array_extend((self), (other)->size, (other)->contents) + +/// Append `count` elements to the end of the array, reading their values from the +/// `contents` pointer. +#define array_extend(self, count, contents) \ + _array__splice( \ + (Array *)(self), array_elem_size(self), (self)->size, \ + 0, count, contents \ + ) + +/// Remove `old_count` elements from the array starting at the given `index`. At +/// the same index, insert `new_count` new elements, reading their values from the +/// `new_contents` pointer. +#define array_splice(self, _index, old_count, new_count, new_contents) \ + _array__splice( \ + (Array *)(self), array_elem_size(self), _index, \ + old_count, new_count, new_contents \ + ) + +/// Insert one `element` into the array at the given `index`. +#define array_insert(self, _index, element) \ + _array__splice((Array *)(self), array_elem_size(self), _index, 0, 1, &(element)) + +/// Remove one element from the array at the given `index`. +#define array_erase(self, _index) \ + _array__erase((Array *)(self), array_elem_size(self), _index) + +/// Pop the last element off the array, returning the element by value. +#define array_pop(self) ((self)->contents[--(self)->size]) + +/// Assign the contents of one array to another, reallocating if necessary. +#define array_assign(self, other) \ + _array__assign((Array *)(self), (const Array *)(other), array_elem_size(self)) + +/// Swap one array with another +#define array_swap(self, other) \ + _array__swap((Array *)(self), (Array *)(other)) + +/// Get the size of the array contents +#define array_elem_size(self) (sizeof *(self)->contents) + +/// Search a sorted array for a given `needle` value, using the given `compare` +/// callback to determine the order. +/// +/// If an existing element is found to be equal to `needle`, then the `index` +/// out-parameter is set to the existing value's index, and the `exists` +/// out-parameter is set to true. Otherwise, `index` is set to an index where +/// `needle` should be inserted in order to preserve the sorting, and `exists` +/// is set to false. +#define array_search_sorted_with(self, compare, needle, _index, _exists) \ + _array__search_sorted(self, 0, compare, , needle, _index, _exists) + +/// Search a sorted array for a given `needle` value, using integer comparisons +/// of a given struct field (specified with a leading dot) to determine the order. +/// +/// See also `array_search_sorted_with`. +#define array_search_sorted_by(self, field, needle, _index, _exists) \ + _array__search_sorted(self, 0, _compare_int, field, needle, _index, _exists) + +/// Insert a given `value` into a sorted array, using the given `compare` +/// callback to determine the order. +#define array_insert_sorted_with(self, compare, value) \ + do { \ + unsigned _index, _exists; \ + array_search_sorted_with(self, compare, &(value), &_index, &_exists); \ + if (!_exists) array_insert(self, _index, value); \ + } while (0) + +/// Insert a given `value` into a sorted array, using integer comparisons of +/// a given struct field (specified with a leading dot) to determine the order. +/// +/// See also `array_search_sorted_by`. +#define array_insert_sorted_by(self, field, value) \ + do { \ + unsigned _index, _exists; \ + array_search_sorted_by(self, field, (value) field, &_index, &_exists); \ + if (!_exists) array_insert(self, _index, value); \ + } while (0) + +// Private + +typedef Array(void) Array; + +/// This is not what you're looking for, see `array_delete`. +static inline void _array__delete(Array *self) { + if (self->contents) { + ts_free(self->contents); + self->contents = NULL; + self->size = 0; + self->capacity = 0; + } +} + +/// This is not what you're looking for, see `array_erase`. +static inline void _array__erase(Array *self, size_t element_size, + uint32_t index) { + assert(index < self->size); + char *contents = (char *)self->contents; + memmove(contents + index * element_size, contents + (index + 1) * element_size, + (self->size - index - 1) * element_size); + self->size--; +} + +/// This is not what you're looking for, see `array_reserve`. +static inline void _array__reserve(Array *self, size_t element_size, uint32_t new_capacity) { + if (new_capacity > self->capacity) { + if (self->contents) { + self->contents = ts_realloc(self->contents, new_capacity * element_size); + } else { + self->contents = ts_malloc(new_capacity * element_size); + } + self->capacity = new_capacity; + } +} + +/// This is not what you're looking for, see `array_assign`. +static inline void _array__assign(Array *self, const Array *other, size_t element_size) { + _array__reserve(self, element_size, other->size); + self->size = other->size; + memcpy(self->contents, other->contents, self->size * element_size); +} + +/// This is not what you're looking for, see `array_swap`. +static inline void _array__swap(Array *self, Array *other) { + Array swap = *other; + *other = *self; + *self = swap; +} + +/// This is not what you're looking for, see `array_push` or `array_grow_by`. +static inline void _array__grow(Array *self, uint32_t count, size_t element_size) { + uint32_t new_size = self->size + count; + if (new_size > self->capacity) { + uint32_t new_capacity = self->capacity * 2; + if (new_capacity < 8) new_capacity = 8; + if (new_capacity < new_size) new_capacity = new_size; + _array__reserve(self, element_size, new_capacity); + } +} + +/// This is not what you're looking for, see `array_splice`. +static inline void _array__splice(Array *self, size_t element_size, + uint32_t index, uint32_t old_count, + uint32_t new_count, const void *elements) { + uint32_t new_size = self->size + new_count - old_count; + uint32_t old_end = index + old_count; + uint32_t new_end = index + new_count; + assert(old_end <= self->size); + + _array__reserve(self, element_size, new_size); + + char *contents = (char *)self->contents; + if (self->size > old_end) { + memmove( + contents + new_end * element_size, + contents + old_end * element_size, + (self->size - old_end) * element_size + ); + } + if (new_count > 0) { + if (elements) { + memcpy( + (contents + index * element_size), + elements, + new_count * element_size + ); + } else { + memset( + (contents + index * element_size), + 0, + new_count * element_size + ); + } + } + self->size += new_count - old_count; +} + +/// A binary search routine, based on Rust's `std::slice::binary_search_by`. +/// This is not what you're looking for, see `array_search_sorted_with` or `array_search_sorted_by`. +#define _array__search_sorted(self, start, compare, suffix, needle, _index, _exists) \ + do { \ + *(_index) = start; \ + *(_exists) = false; \ + uint32_t size = (self)->size - *(_index); \ + if (size == 0) break; \ + int comparison; \ + while (size > 1) { \ + uint32_t half_size = size / 2; \ + uint32_t mid_index = *(_index) + half_size; \ + comparison = compare(&((self)->contents[mid_index] suffix), (needle)); \ + if (comparison <= 0) *(_index) = mid_index; \ + size -= half_size; \ + } \ + comparison = compare(&((self)->contents[*(_index)] suffix), (needle)); \ + if (comparison == 0) *(_exists) = true; \ + else if (comparison < 0) *(_index) += 1; \ + } while (0) + +/// Helper macro for the `_sorted_by` routines below. This takes the left (existing) +/// parameter by reference in order to work with the generic sorting function above. +#define _compare_int(a, b) ((int)*(a) - (int)(b)) + +#ifdef _MSC_VER +#pragma warning(pop) +#elif defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic pop +#endif + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_ARRAY_H_ diff --git a/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/parser.h b/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/parser.h new file mode 100644 index 000000000..799f599bd --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/parser.h @@ -0,0 +1,266 @@ +#ifndef TREE_SITTER_PARSER_H_ +#define TREE_SITTER_PARSER_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +#define ts_builtin_sym_error ((TSSymbol)-1) +#define ts_builtin_sym_end 0 +#define TREE_SITTER_SERIALIZATION_BUFFER_SIZE 1024 + +#ifndef TREE_SITTER_API_H_ +typedef uint16_t TSStateId; +typedef uint16_t TSSymbol; +typedef uint16_t TSFieldId; +typedef struct TSLanguage TSLanguage; +#endif + +typedef struct { + TSFieldId field_id; + uint8_t child_index; + bool inherited; +} TSFieldMapEntry; + +typedef struct { + uint16_t index; + uint16_t length; +} TSFieldMapSlice; + +typedef struct { + bool visible; + bool named; + bool supertype; +} TSSymbolMetadata; + +typedef struct TSLexer TSLexer; + +struct TSLexer { + int32_t lookahead; + TSSymbol result_symbol; + void (*advance)(TSLexer *, bool); + void (*mark_end)(TSLexer *); + uint32_t (*get_column)(TSLexer *); + bool (*is_at_included_range_start)(const TSLexer *); + bool (*eof)(const TSLexer *); + void (*log)(const TSLexer *, const char *, ...); +}; + +typedef enum { + TSParseActionTypeShift, + TSParseActionTypeReduce, + TSParseActionTypeAccept, + TSParseActionTypeRecover, +} TSParseActionType; + +typedef union { + struct { + uint8_t type; + TSStateId state; + bool extra; + bool repetition; + } shift; + struct { + uint8_t type; + uint8_t child_count; + TSSymbol symbol; + int16_t dynamic_precedence; + uint16_t production_id; + } reduce; + uint8_t type; +} TSParseAction; + +typedef struct { + uint16_t lex_state; + uint16_t external_lex_state; +} TSLexMode; + +typedef union { + TSParseAction action; + struct { + uint8_t count; + bool reusable; + } entry; +} TSParseActionEntry; + +typedef struct { + int32_t start; + int32_t end; +} TSCharacterRange; + +struct TSLanguage { + uint32_t version; + uint32_t symbol_count; + uint32_t alias_count; + uint32_t token_count; + uint32_t external_token_count; + uint32_t state_count; + uint32_t large_state_count; + uint32_t production_id_count; + uint32_t field_count; + uint16_t max_alias_sequence_length; + const uint16_t *parse_table; + const uint16_t *small_parse_table; + const uint32_t *small_parse_table_map; + const TSParseActionEntry *parse_actions; + const char * const *symbol_names; + const char * const *field_names; + const TSFieldMapSlice *field_map_slices; + const TSFieldMapEntry *field_map_entries; + const TSSymbolMetadata *symbol_metadata; + const TSSymbol *public_symbol_map; + const uint16_t *alias_map; + const TSSymbol *alias_sequences; + const TSLexMode *lex_modes; + bool (*lex_fn)(TSLexer *, TSStateId); + bool (*keyword_lex_fn)(TSLexer *, TSStateId); + TSSymbol keyword_capture_token; + struct { + const bool *states; + const TSSymbol *symbol_map; + void *(*create)(void); + void (*destroy)(void *); + bool (*scan)(void *, TSLexer *, const bool *symbol_whitelist); + unsigned (*serialize)(void *, char *); + void (*deserialize)(void *, const char *, unsigned); + } external_scanner; + const TSStateId *primary_state_ids; +}; + +static inline bool set_contains(TSCharacterRange *ranges, uint32_t len, int32_t lookahead) { + uint32_t index = 0; + uint32_t size = len - index; + while (size > 1) { + uint32_t half_size = size / 2; + uint32_t mid_index = index + half_size; + TSCharacterRange *range = &ranges[mid_index]; + if (lookahead >= range->start && lookahead <= range->end) { + return true; + } else if (lookahead > range->end) { + index = mid_index; + } + size -= half_size; + } + TSCharacterRange *range = &ranges[index]; + return (lookahead >= range->start && lookahead <= range->end); +} + +/* + * Lexer Macros + */ + +#ifdef _MSC_VER +#define UNUSED __pragma(warning(suppress : 4101)) +#else +#define UNUSED __attribute__((unused)) +#endif + +#define START_LEXER() \ + bool result = false; \ + bool skip = false; \ + UNUSED \ + bool eof = false; \ + int32_t lookahead; \ + goto start; \ + next_state: \ + lexer->advance(lexer, skip); \ + start: \ + skip = false; \ + lookahead = lexer->lookahead; + +#define ADVANCE(state_value) \ + { \ + state = state_value; \ + goto next_state; \ + } + +#define ADVANCE_MAP(...) \ + { \ + static const uint16_t map[] = { __VA_ARGS__ }; \ + for (uint32_t i = 0; i < sizeof(map) / sizeof(map[0]); i += 2) { \ + if (map[i] == lookahead) { \ + state = map[i + 1]; \ + goto next_state; \ + } \ + } \ + } + +#define SKIP(state_value) \ + { \ + skip = true; \ + state = state_value; \ + goto next_state; \ + } + +#define ACCEPT_TOKEN(symbol_value) \ + result = true; \ + lexer->result_symbol = symbol_value; \ + lexer->mark_end(lexer); + +#define END_STATE() return result; + +/* + * Parse Table Macros + */ + +#define SMALL_STATE(id) ((id) - LARGE_STATE_COUNT) + +#define STATE(id) id + +#define ACTIONS(id) id + +#define SHIFT(state_value) \ + {{ \ + .shift = { \ + .type = TSParseActionTypeShift, \ + .state = (state_value) \ + } \ + }} + +#define SHIFT_REPEAT(state_value) \ + {{ \ + .shift = { \ + .type = TSParseActionTypeShift, \ + .state = (state_value), \ + .repetition = true \ + } \ + }} + +#define SHIFT_EXTRA() \ + {{ \ + .shift = { \ + .type = TSParseActionTypeShift, \ + .extra = true \ + } \ + }} + +#define REDUCE(symbol_name, children, precedence, prod_id) \ + {{ \ + .reduce = { \ + .type = TSParseActionTypeReduce, \ + .symbol = symbol_name, \ + .child_count = children, \ + .dynamic_precedence = precedence, \ + .production_id = prod_id \ + }, \ + }} + +#define RECOVER() \ + {{ \ + .type = TSParseActionTypeRecover \ + }} + +#define ACCEPT_INPUT() \ + {{ \ + .type = TSParseActionTypeAccept \ + }} + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_PARSER_H_ From 79e1d933fa3ade75c295b32752c544b3882d84d1 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:16:42 +0100 Subject: [PATCH 13/15] fix: resolve generic TypeScript awaited function calls missing from call graph (#804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * fix: resolve generic TypeScript function callers missed by impact analysis When a generic function call is combined with `await` (e.g. `await fn(args)`), tree-sitter-typescript parses it as a `call_expression` whose `function` field is an `await_expression` rather than a bare `identifier`. The existing queries only matched `call_expression { function: identifier }`, so these calls produced no `@call.name` capture and were silently dropped from the call graph. Fix: add two new tree-sitter query patterns to `TYPESCRIPT_QUERIES` that handle: 1. `await fn(args)` — awaited generic free call 2. `await obj.fn(args)` — awaited generic member call Both patterns require the `(type_arguments)` child to be present (which is what causes tree-sitter to parse the `function` field as an `await_expression`). Non-generic awaited calls (`await fn(args)`) are unaffected: tree-sitter parses them as `await_expression { call_expression { identifier } }`, which is still captured by the existing first pattern. Also adds a new test fixture `typescript-generic-calls` with two callers of a generic `verifyToken` function using `await` and three new integration tests. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/4cf75290-900b-4cea-8a65-2a245ff86970 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: clean up test fixture interface ordering and imports Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/4cf75290-900b-4cea-8a65-2a245ff86970 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: add coverage for awaited generic member-call form (await obj.fn()) Address review feedback: the member-call query pattern was untested. Adds service.ts (TokenService with generic verify method) and guest.ts (calls await svc.verify()) to the typescript-generic-calls fixture, plus a new integration test asserting the CALLS edge resolves. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fcbf8d99-8dbc-40ce-b2a3-60b8d63c095a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * revert: undo accidental ladybugdb version bump in package files Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fcbf8d99-8dbc-40ce-b2a3-60b8d63c095a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * style: run prettier on changed files Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/8c7d8291-74bb-4a86-ae47-7c79e2cbb57e --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- .../src/core/ingestion/tree-sitter-queries.ts | 17 ++++++++ .../typescript-generic-calls/src/admin.ts | 10 +++++ .../typescript-generic-calls/src/auth.ts | 10 +++++ .../typescript-generic-calls/src/guest.ts | 12 ++++++ .../typescript-generic-calls/src/service.ts | 7 +++ .../typescript-generic-calls/src/token.ts | 7 +++ .../integration/resolvers/typescript.test.ts | 43 +++++++++++++++++++ 7 files changed, 106 insertions(+) create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/admin.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/auth.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/guest.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/service.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/token.ts diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 7180806ae..eb96ffb4a 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -75,6 +75,23 @@ export const TYPESCRIPT_QUERIES = ` function: (member_expression property: (property_identifier) @call.name)) @call +; Generic awaited free call: await fn(args) +; tree-sitter-typescript parses "await fn(args)" as a call_expression whose +; "function" field is an await_expression (not a bare identifier), because the +; grammar resolves the ambiguity between generics and comparisons by consuming +; "await fn" as an expression before attaching as type_arguments. +(call_expression + function: (await_expression + (identifier) @call.name) + (type_arguments)) @call + +; Generic awaited member call: await obj.fn(args) +(call_expression + function: (await_expression + (member_expression + property: (property_identifier) @call.name)) + (type_arguments)) @call + ; Constructor calls: new Foo() (new_expression constructor: (identifier) @call.name) @call diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/admin.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/admin.ts new file mode 100644 index 000000000..8a5891d60 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/admin.ts @@ -0,0 +1,10 @@ +import { verifyToken, BasePayload } from './token'; + +interface AdminPayload extends BasePayload { + role: string; +} + +export async function authenticateAdmin(token: string): Promise { + const payload = await verifyToken(token, 'admin-secret'); + return payload; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/auth.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/auth.ts new file mode 100644 index 000000000..5b056d034 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/auth.ts @@ -0,0 +1,10 @@ +import { verifyToken, BasePayload } from './token'; + +interface UserPayload extends BasePayload { + userId: string; +} + +export async function authenticateUser(token: string): Promise { + const payload = await verifyToken(token, 'secret'); + return payload; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/guest.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/guest.ts new file mode 100644 index 000000000..525da29cd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/guest.ts @@ -0,0 +1,12 @@ +import { BasePayload } from './token'; +import { TokenService } from './service'; + +interface GuestPayload extends BasePayload { + sessionId: string; +} + +export async function authenticateGuest(token: string): Promise { + const svc = new TokenService(); + const payload = await svc.verify(token, 'guest-secret'); + return payload; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/service.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/service.ts new file mode 100644 index 000000000..675dab9c2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/service.ts @@ -0,0 +1,7 @@ +import { BasePayload } from './token'; + +export class TokenService { + verify(token: string, secret: string): T { + return JSON.parse(Buffer.from(token, 'base64').toString()) as T; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/token.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/token.ts new file mode 100644 index 000000000..4fbf5001e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/token.ts @@ -0,0 +1,7 @@ +export interface BasePayload { + sub: string; +} + +export function verifyToken(token: string, secret: string): T { + return JSON.parse(Buffer.from(token, 'base64').toString()) as T; +} diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index c883678ac..0d6da9e27 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -145,6 +145,49 @@ describe('TypeScript call resolution with arity filtering', () => { }); }); +// --------------------------------------------------------------------------- +// Generic function call resolution: await fn(args) creates CALLS edges +// --------------------------------------------------------------------------- + +describe('TypeScript generic awaited call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-generic-calls'), () => {}); + }, 60000); + + it('resolves authenticateUser → verifyToken via awaited generic call', () => { + const calls = getRelationships(result, 'CALLS'); + const authCall = calls.find( + (c) => c.source === 'authenticateUser' && c.target === 'verifyToken', + ); + expect(authCall).toBeDefined(); + expect(authCall!.targetFilePath).toBe('src/token.ts'); + }); + + it('resolves authenticateAdmin → verifyToken via awaited generic call', () => { + const calls = getRelationships(result, 'CALLS'); + const adminCall = calls.find( + (c) => c.source === 'authenticateAdmin' && c.target === 'verifyToken', + ); + expect(adminCall).toBeDefined(); + expect(adminCall!.targetFilePath).toBe('src/token.ts'); + }); + + it('resolves authenticateGuest → verify via awaited generic member call', () => { + const calls = getRelationships(result, 'CALLS'); + const guestCall = calls.find((c) => c.source === 'authenticateGuest' && c.target === 'verify'); + expect(guestCall).toBeDefined(); + expect(guestCall!.targetFilePath).toBe('src/service.ts'); + }); + + it('verifyToken has exactly 2 incoming CALLS edges (both free-call callers resolved)', () => { + const calls = getRelationships(result, 'CALLS'); + const incoming = calls.filter((c) => c.target === 'verifyToken'); + expect(incoming.length).toBe(2); + }); +}); + // --------------------------------------------------------------------------- // Member-call resolution: obj.method() resolves through pipeline // --------------------------------------------------------------------------- From 9f4109a33fd2c259ef42240fab5ef248368b68a4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:17:30 +0100 Subject: [PATCH 14/15] fix: remove `file:../gitnexus-shared` from runtime dependencies (#803) * Initial plan * fix: remove file:../gitnexus-shared from dependencies to fix npm install outside monorepo Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2ea9c1da-1b0c-4ab0-b370-f3970cc54ffa Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- gitnexus/package-lock.json | 1 - gitnexus/package.json | 1 - 2 files changed, 2 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index a746292e8..644cc0058 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -18,7 +18,6 @@ "commander": "^12.0.0", "cors": "^2.8.5", "express": "^4.19.2", - "gitnexus-shared": "file:../gitnexus-shared", "glob": "^11.0.0", "graphology": "^0.25.4", "graphology-indices": "^0.17.0", diff --git a/gitnexus/package.json b/gitnexus/package.json index 435f9b325..064ede056 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -59,7 +59,6 @@ "commander": "^12.0.0", "cors": "^2.8.5", "express": "^4.19.2", - "gitnexus-shared": "file:../gitnexus-shared", "glob": "^11.0.0", "graphology": "^0.25.4", "graphology-indices": "^0.17.0", From a6421b3b1b821318f32ed34469c689045240c379 Mon Sep 17 00:00:00 2001 From: Arkh74278 <136110739+Arkh74278@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:21:11 +0300 Subject: [PATCH 15/15] [dart] Add call patterns for await, cascade, lambda, and widget-tree contexts (#801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dart): add call patterns for await, cascade, lambda, and widget-tree contexts * fix(dart): address review feedback — await member-chain, cascade comment, static_final comment, add to query-compilation smoke test * test(dart): add integration tests for await and widget-tree call patterns * style: apply prettier formatting to dart integration tests --------- Co-authored-by: arkh --- .../src/core/ingestion/tree-sitter-queries.ts | 52 ++++++++++++++ .../lang-resolution/dart-await-calls/app.dart | 6 ++ .../dart-await-calls/service.dart | 5 ++ .../dart-widget-tree-calls/app.dart | 10 +++ .../dart-widget-tree-calls/builders.dart | 3 + .../integration/query-compilation.test.ts | 1 + .../test/integration/resolvers/dart.test.ts | 67 +++++++++++++++++++ .../test/unit/tree-sitter-queries.test.ts | 64 ++++++++++++++++++ 8 files changed, 208 insertions(+) create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-await-calls/app.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-await-calls/service.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/app.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/builders.dart diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index eb96ffb4a..99fd3b21c 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -1158,6 +1158,58 @@ export const DART_QUERIES = ` (identifier) @call.name)) (selector (argument_part))) @call +; ── Calls: await direct (await doSomething()) ──────────────────────────────── +(await_expression + (identifier) @call.name + . + (selector (argument_part))) @call + +; ── Calls: await method chain (await obj.method()) ─────────────────────────── +; Requires argument_part to distinguish method calls from field access (await obj.field) +(await_expression + (selector + (unconditional_assignable_selector + (identifier) @call.name)) + (selector (argument_part))) @call + +; ── Calls: named argument (foo(child: buildX())) ───────────────────────────── +(named_argument + (identifier) @call.name + . + (selector (argument_part))) @call + +; ── Calls: inside list literals ([buildA(), buildB()]) ─────────────────────── +(list_literal + (identifier) @call.name + . + (selector (argument_part))) @call + +; ── Calls: cascade (obj..add(x)..sort()) ───────────────────────────────────── +; Note: cascade_selector contains identifier directly (no unconditional_assignable_selector +; wrapper in Dart grammar), so inferCallForm() classifies these as free calls rather than +; member calls. Cross-file resolution still benefits from the call being recorded. +(cascade_section + (cascade_selector (identifier) @call.name) + (argument_part)) @call + +; ── Calls: static final field initializers (static final _svc = MyService()) ── +(static_final_declaration + (identifier) @call.name + . + (selector (argument_part))) @call + +; ── Calls: arrow function body (=> buildWidget()) ──────────────────────────── +(function_body "=>" + (identifier) @call.name + . + (selector (argument_part))) @call + +; ── Calls: lambda body (() => doSomething()) ───────────────────────────────── +(function_expression_body + (identifier) @call.name + . + (selector (argument_part))) @call + ; ── Re-exports (export 'foo.dart') ─────────────────────────────────────────── (import_or_export (library_export diff --git a/gitnexus/test/fixtures/lang-resolution/dart-await-calls/app.dart b/gitnexus/test/fixtures/lang-resolution/dart-await-calls/app.dart new file mode 100644 index 000000000..efa4c00ed --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-await-calls/app.dart @@ -0,0 +1,6 @@ +import 'service.dart'; + +Future run() async { + final user = await fetchUser(); + await processData(user); +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-await-calls/service.dart b/gitnexus/test/fixtures/lang-resolution/dart-await-calls/service.dart new file mode 100644 index 000000000..c0251b632 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-await-calls/service.dart @@ -0,0 +1,5 @@ +Future fetchUser() async { + return 'user'; +} + +Future processData(String data) async {} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/app.dart b/gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/app.dart new file mode 100644 index 000000000..844e26abe --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/app.dart @@ -0,0 +1,10 @@ +import 'builders.dart'; + +// Named argument call: child: buildHeader() +// List literal calls: children: [buildBody(), buildFooter()] +dynamic buildPage() { + return Column( + child: buildHeader(), + children: [buildBody(), buildFooter()], + ); +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/builders.dart b/gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/builders.dart new file mode 100644 index 000000000..8e1d981d2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/builders.dart @@ -0,0 +1,3 @@ +dynamic buildHeader() => null; +dynamic buildBody() => null; +dynamic buildFooter() => null; diff --git a/gitnexus/test/integration/query-compilation.test.ts b/gitnexus/test/integration/query-compilation.test.ts index d0fd038a7..6652a59bf 100644 --- a/gitnexus/test/integration/query-compilation.test.ts +++ b/gitnexus/test/integration/query-compilation.test.ts @@ -33,6 +33,7 @@ describe('Query compilation smoke tests', () => { [SupportedLanguages.PHP]: 'test.php', [SupportedLanguages.Kotlin]: 'Test.kt', [SupportedLanguages.Swift]: 'test.swift', + [SupportedLanguages.Dart]: 'test.dart', }; // Known query compilation failures — remove from this set as PRs fix them diff --git a/gitnexus/test/integration/resolvers/dart.test.ts b/gitnexus/test/integration/resolvers/dart.test.ts index 82fa77f22..9989e0de0 100644 --- a/gitnexus/test/integration/resolvers/dart.test.ts +++ b/gitnexus/test/integration/resolvers/dart.test.ts @@ -512,3 +512,70 @@ describe.skipIf(!dartAvailable)( }); }, ); + +// --------------------------------------------------------------------------- +// await call patterns: await fetchUser(), await processData() +// --------------------------------------------------------------------------- + +describe.skipIf(!dartAvailable)('Dart await call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-await-calls'), () => {}); + }, 60000); + + it('detects fetchUser and processData as functions', () => { + const fns = getNodesByLabel(result, 'Function'); + expect(fns).toContain('fetchUser'); + expect(fns).toContain('processData'); + }); + + it('resolves run → fetchUser via await direct call', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find((c) => c.source === 'run' && c.target === 'fetchUser'); + expect(edge).toBeDefined(); + }); + + it('resolves run → processData via await direct call', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find((c) => c.source === 'run' && c.target === 'processData'); + expect(edge).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Widget-tree call patterns: named argument and list literal +// --------------------------------------------------------------------------- + +describe.skipIf(!dartAvailable)('Dart widget-tree call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-widget-tree-calls'), () => {}); + }, 60000); + + it('detects buildHeader, buildBody, buildFooter as functions', () => { + const fns = getNodesByLabel(result, 'Function'); + expect(fns).toContain('buildHeader'); + expect(fns).toContain('buildBody'); + expect(fns).toContain('buildFooter'); + }); + + it('resolves buildPage → buildHeader via named argument call', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find((c) => c.source === 'buildPage' && c.target === 'buildHeader'); + expect(edge).toBeDefined(); + }); + + it('resolves buildPage → buildBody via list literal call', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find((c) => c.source === 'buildPage' && c.target === 'buildBody'); + expect(edge).toBeDefined(); + }); + + it('resolves buildPage → buildFooter via list literal call', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find((c) => c.source === 'buildPage' && c.target === 'buildFooter'); + expect(edge).toBeDefined(); + }); +}); diff --git a/gitnexus/test/unit/tree-sitter-queries.test.ts b/gitnexus/test/unit/tree-sitter-queries.test.ts index 6a926def2..ffb207bdd 100644 --- a/gitnexus/test/unit/tree-sitter-queries.test.ts +++ b/gitnexus/test/unit/tree-sitter-queries.test.ts @@ -11,6 +11,7 @@ import { RUST_QUERIES, PHP_QUERIES, SWIFT_QUERIES, + DART_QUERIES, } from '../../src/core/ingestion/tree-sitter-queries.js'; describe('tree-sitter queries', () => { @@ -292,4 +293,67 @@ describe('tree-sitter queries', () => { expect(SWIFT_QUERIES).toContain('"actor"'); }); }); + + describe('Dart queries', () => { + it('captures class, mixin, extension, enum declarations', () => { + expect(DART_QUERIES).toContain('@definition.class'); + expect(DART_QUERIES).toContain('@definition.trait'); + expect(DART_QUERIES).toContain('@definition.enum'); + }); + + it('captures top-level functions and methods', () => { + expect(DART_QUERIES).toContain('@definition.function'); + expect(DART_QUERIES).toContain('@definition.method'); + }); + + it('captures constructors including factory constructors', () => { + expect(DART_QUERIES).toContain('@definition.constructor'); + expect(DART_QUERIES).toContain('factory_constructor_signature'); + }); + + it('captures field declarations and getters/setters', () => { + expect(DART_QUERIES).toContain('@definition.property'); + expect(DART_QUERIES).toContain('getter_signature'); + expect(DART_QUERIES).toContain('setter_signature'); + }); + + it('captures import statements', () => { + expect(DART_QUERIES).toContain('@import'); + expect(DART_QUERIES).toContain('library_import'); + }); + + it('captures heritage (extends, implements, with)', () => { + expect(DART_QUERIES).toContain('@heritage.extends'); + }); + + it('captures direct calls and method chains', () => { + expect(DART_QUERIES).toContain('expression_statement'); + expect(DART_QUERIES).toContain('unconditional_assignable_selector'); + expect(DART_QUERIES).toContain('@call'); + }); + + it('captures await expressions as calls', () => { + expect(DART_QUERIES).toContain('await_expression'); + }); + + it('captures named argument calls (widget children)', () => { + expect(DART_QUERIES).toContain('named_argument'); + }); + + it('captures list literal calls (widget children lists)', () => { + expect(DART_QUERIES).toContain('list_literal'); + }); + + it('captures cascade calls (obj..method())', () => { + expect(DART_QUERIES).toContain('cascade_section'); + }); + + it('captures arrow function body calls (=> expr)', () => { + expect(DART_QUERIES).toContain('function_body "=>"'); + }); + + it('captures lambda body calls (() => expr)', () => { + expect(DART_QUERIES).toContain('function_expression_body'); + }); + }); });