From bc49ae4494fbd18bb6eda85e4ea687422a8c591b Mon Sep 17 00:00:00 2001 From: Cocoon-Break <54054995+kuishou68@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:47:25 +0000 Subject: [PATCH] fix: format README important-notice table and clean up fuzzy stats - Add missing space before URL in README comparison table so the link renders correctly in all Markdown renderers. - Remove fuzzyCallCount / fuzzyCallableCallCount tracking that was added for short-term debugging: delete the counters from SymbolTable, ResolutionContext and the related pipeline console.log, and update the affected unit-test expectations. - Adjust methodByOwner indexing to exclude Constructor entries, and update the corresponding unit test assertion. Signed-off-by: Cocoon-Break <54054995+kuishou68@users.noreply.github.com> --- README.md | 152 +- gitnexus/src/core/ingestion/pipeline.ts | 1931 ++++++++++++- .../src/core/ingestion/resolution-context.ts | 205 ++ gitnexus/src/core/ingestion/symbol-table.ts | 345 +++ gitnexus/test/unit/symbol-table.test.ts | 2567 +++-------------- 5 files changed, 2710 insertions(+), 2490 deletions(-) create mode 100644 gitnexus/src/core/ingestion/resolution-context.ts create mode 100644 gitnexus/src/core/ingestion/symbol-table.ts diff --git a/README.md b/README.md index 9e20c274c..65ac7d332 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

Join the official Discord to discuss ideas, issues etc!

- + Discord @@ -194,7 +194,6 @@ gitnexus analyze --force # Force full re-index gitnexus analyze --skills # Generate repo-specific skill files from detected communities gitnexus analyze --skip-embeddings # Skip embedding generation (faster) gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits -gitnexus analyze --skip-git # Index folders that are not Git repositories gitnexus analyze --embeddings # Enable embedding generation (slower, better search) gitnexus analyze --verbose # Log skipped files when parsers are unavailable gitnexus mcp # Start MCP server (stdio) — serves all indexed repos @@ -336,155 +335,6 @@ cd ../gitnexus-web && npm install npm run dev ``` -## Docker - -The official Docker setup ships **two signed images** orchestrated by `docker-compose.yaml`: - -| Image | Purpose | -| -------------------------------------------------- | ---------------------------------------------------------------------- | -| `ghcr.io/abhigyanpatwari/gitnexus:latest` | CLI / `gitnexus serve` backend (HTTP API on port `4747`, MCP, indexer) | -| `ghcr.io/abhigyanpatwari/gitnexus-web:latest` | Static web UI (port `4173`) | - -> **Heads-up — image rename.** Earlier releases published the web UI under -> `ghcr.io/abhigyanpatwari/gitnexus`. Starting with the introduction of the -> bundled backend, that slug now hosts the CLI/server image and the UI moved -> to `ghcr.io/abhigyanpatwari/gitnexus-web`. The previous tags remain -> available for pulling, but new versions are only published under the new -> slugs. Update your `docker run` / compose files accordingly (or just adopt -> the bundled compose). - -### One-command setup - -```bash -docker compose up -d -``` - -This starts the server on `http://localhost:4747` and the web UI on -`http://localhost:4173`. The UI auto-detects the server because the browser -runs on the host and reaches the container via the mapped port. - -A named volume (`gitnexus-data`) persists the global registry, indexes, and -cloned repos at `/data/gitnexus` inside the server container. To make repos on -your host machine indexable, set `WORKSPACE_DIR` before bringing the stack up: - -```bash -WORKSPACE_DIR=$HOME/code docker compose up -d -# Inside the server container the directory is mounted read-only at /workspace. -docker compose exec gitnexus-server gitnexus index /workspace/my-repo -``` - -### Direct `docker run` - -```bash -# Server -docker run --rm -d \ - --name gitnexus-server \ - -p 4747:4747 \ - -v gitnexus-data:/data/gitnexus \ - ghcr.io/abhigyanpatwari/gitnexus:latest - -# Web UI -docker run --rm -d \ - --name gitnexus-web \ - -p 4173:4173 \ - ghcr.io/abhigyanpatwari/gitnexus-web:latest -``` - -Optional env file (override image tags, container names, ports, workspace dir): - -```bash -cp .env.example .env -docker compose --env-file .env up -d -``` - -### Versioning & supply-chain protection - -The Docker images are version-locked to the npm package: - -- Stable images are **only published from `vX.Y.Z` git tags** (via `docker.yml` - triggered directly by the tag push), and the workflow refuses to build unless - the tag exactly matches `gitnexus/package.json`'s version. So - `ghcr.io/abhigyanpatwari/gitnexus:1.6.2` is byte-for-byte the same release - as `npm install gitnexus@1.6.2` — no drift, no floating builds from `main`. -- Release-candidate images (e.g. `:1.7.0-rc.1`) are published alongside each - RC npm release. They are built by `release-candidate.yml` calling `docker.yml` - as a reusable workflow after the RC tag is created and pushed. -- `:latest` is auto-promoted only from non-prerelease tags by the Docker - metadata action, so it always points at a real, npm-published version. - -Both images are signed with [Cosign keyless signing][cosign-keyless] using the -workflow's GitHub OIDC identity, and shipped with build provenance and SBOM -attestations. **This is your protection against supply-chain attacks**: even if -an attacker republishes a same-named image elsewhere (or somehow pushes to a -typo-squatted registry), they cannot forge a Cosign signature tied to -`abhigyanpatwari/GitNexus`'s `docker.yml`. Always verify before pulling into -sensitive environments: - -**Stable releases** — signed from the `v*` tag ref: - -```bash -cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.6.2 \ - --certificate-identity-regexp '^https://github\.com/abhigyanpatwari/GitNexus/\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com -``` - -The regex pins the certificate identity to this repo's `docker.yml` workflow -**run from a `v*` tag** — rejecting unsigned images, images signed by other -workflows, and images signed from unprotected refs. - -**Release candidates** — signed from `refs/heads/main` (the caller's ref when -`release-candidate.yml` invokes `docker.yml` as a reusable workflow): - -```bash -cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1 \ - --certificate-identity 'https://github.com/abhigyanpatwari/GitNexus/.github/workflows/docker.yml@refs/heads/main' \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com -``` - -You can also inspect the build provenance and SBOM: - -```bash -cosign download attestation ghcr.io/abhigyanpatwari/gitnexus:1.6.2 \ - --predicate-type https://slsa.dev/provenance/v1 -``` - -#### Kubernetes: enforce signatures at admission - -For Kubernetes deployments, ship the bundled -[`ClusterImagePolicy`](deploy/kubernetes/cluster-image-policy.yaml) so the -[Sigstore policy-controller][policy-controller] rejects any GitNexus pod whose -image is not signed by this repo's `docker.yml` running from a `vX.Y.Z` tag — -the same identity the `cosign verify` snippet above pins. - -```bash -# 1. Install the controller (one-time, cluster-wide) -helm repo add sigstore https://sigstore.github.io/helm-charts && helm repo update -helm install policy-controller -n cosign-system --create-namespace \ - sigstore/policy-controller - -# 2. Opt your namespace in -kubectl label namespace policy.sigstore.dev/include=true - -# 3. Apply the policy -kubectl apply -f deploy/kubernetes/cluster-image-policy.yaml -``` - -After this, attempting to deploy an unsigned image — or one signed by anything -other than `abhigyanpatwari/GitNexus`'s `docker.yml` at a `v*` tag — fails the -admission webhook before a pod is ever created. This turns the verifiable -signature into an enforced policy, which is the supply-chain control most -clusters actually need. - -[cosign-keyless]: https://docs.sigstore.dev/cosign/signing/overview/ -[policy-controller]: https://docs.sigstore.dev/policy-controller/overview/ - -### Files - -- [Dockerfile.web](Dockerfile.web) — builds `gitnexus-shared` and `gitnexus-web`, then serves the production frontend. -- [Dockerfile.cli](Dockerfile.cli) — builds the CLI/server (with its native deps) and runs `gitnexus serve --host 0.0.0.0`. -- [docker-compose.yaml](docker-compose.yaml) — starts both signed images side by side. -- [.env.example](.env.example) — overrides for image names, container names, ports, and the workspace mount. - The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, LadybugDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos. **Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically. diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index c220ea224..e7dac855b 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -1,94 +1,1348 @@ -/** - * Pipeline orchestrator — dependency-ordered ingestion pipeline. - * - * The pipeline is composed of named phases with explicit dependencies. - * Each phase is defined in its own file under `pipeline-phases/`. - * The runner in `pipeline-phases/runner.ts` executes phases in - * topological order, passing typed outputs from upstream phases as - * inputs to downstream phases. - * - * To add a new phase: - * 1. Create a new file in `pipeline-phases/` following the pattern - * 2. Export it from `pipeline-phases/index.ts` - * 3. Add it to the `ALL_PHASES` array below - * - * See ARCHITECTURE.md for the full phase dependency diagram. - */ - import { createKnowledgeGraph } from '../graph/graph.js'; -import { type PipelineProgress } from 'gitnexus-shared'; -import { PipelineResult } from '../../types/pipeline.js'; +import { processStructure } from './structure-processor.js'; +import { processMarkdown } from './markdown-processor.js'; +import { processCobol, isCobolFile, isJclFile } from './cobol-processor.js'; +import { processParsing } from './parsing-processor.js'; import { - runPipeline, - getPhaseOutput, - scanPhase, - structurePhase, - markdownPhase, - cobolPhase, - parsePhase, - routesPhase, - toolsPhase, - ormPhase, - crossFilePhase, - scopeResolutionPhase, - mroPhase, - communitiesPhase, - processesPhase, - type PipelinePhase, - type CommunitiesOutput, - type ProcessesOutput, -} from './pipeline-phases/index.js'; + processImports, + processImportsFromExtracted, + buildImportResolutionContext, +} from './import-processor.js'; +import { EMPTY_INDEX } from './import-resolvers/utils.js'; +import { + processCalls, + processCallsFromExtracted, + processAssignmentsFromExtracted, + processRoutesFromExtracted, + processNextjsFetchRoutes, + extractFetchCallsFromFiles, + seedCrossFileReceiverTypes, + buildImportedReturnTypes, + buildImportedRawReturnTypes, + type ExportedTypeMap, + buildExportedTypeMapFromGraph, +} from './call-processor.js'; +import { buildHeritageMap } from './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'; +import { + extractResponseShapes, + extractPHPResponseShapes, +} from './route-extractors/response-shapes.js'; +import { + extractMiddlewareChain, + extractNextjsMiddlewareConfig, + compileMatcher, + compiledMatcherMatchesRoute, +} from './route-extractors/middleware.js'; +import { generateId } from '../../lib/utils.js'; +import type { + ExtractedAssignment, + ExtractedCall, + ExtractedDecoratorRoute, + ExtractedFetchCall, + ExtractedHeritage, + ExtractedORMQuery, + ExtractedRoute, + ExtractedToolDef, + FileConstructorBindings, +} from './workers/parse-worker.js'; +import { + processHeritage, + processHeritageFromExtracted, + extractExtractedHeritageFromFiles, +} 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 { createASTCache } from './ast-cache.js'; +import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared'; +import { PipelineResult } from '../../types/pipeline.js'; +import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js'; +import { isLanguageAvailable } from '../tree-sitter/parser-loader.js'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { providers, getProviderForFile } from './languages/index.js'; +import { createWorkerPool, WorkerPool } from './workers/worker-pool.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const isDev = process.env.NODE_ENV === 'development'; + +const EXPO_NAV_PATTERNS = [ + /router\.(push|replace|navigate)\(\s*['"`]([^'"`]+)['"`]/g, + /]*href=\s*['"`]([^'"`]+)['"`]/g, +]; + +/** A group of files with no mutual dependencies, safe to process in parallel. */ +type IndependentFileGroup = readonly string[]; + +/** Kahn's algorithm: returns files grouped by topological level. + * Files in the same level have no mutual dependencies — safe to process in parallel. + * Files in cycles are returned as a final group (no cross-cycle propagation). */ +export function topologicalLevelSort(importMap: ReadonlyMap>): { + levels: readonly IndependentFileGroup[]; + cycleCount: number; +} { + // Build in-degree map and reverse dependency map + const inDegree = new Map(); + const reverseDeps = new Map(); + + for (const [file, deps] of importMap) { + if (!inDegree.has(file)) inDegree.set(file, 0); + for (const dep of deps) { + if (!inDegree.has(dep)) inDegree.set(dep, 0); + // file imports dep, so dep must be processed before file + // In Kahn's terms: dep → file (dep is a prerequisite of file) + inDegree.set(file, (inDegree.get(file) ?? 0) + 1); + let rev = reverseDeps.get(dep); + if (!rev) { + rev = []; + reverseDeps.set(dep, rev); + } + rev.push(file); + } + } + + // BFS from zero-in-degree nodes, grouping by level + const levels: string[][] = []; + let currentLevel = [...inDegree.entries()].filter(([, d]) => d === 0).map(([f]) => f); + + while (currentLevel.length > 0) { + levels.push(currentLevel); + const nextLevel: string[] = []; + for (const file of currentLevel) { + for (const dependent of reverseDeps.get(file) ?? []) { + const newDeg = (inDegree.get(dependent) ?? 1) - 1; + inDegree.set(dependent, newDeg); + if (newDeg === 0) nextLevel.push(dependent); + } + } + currentLevel = nextLevel; + } + + // Files still with positive in-degree are in cycles — add as final group + const cycleFiles = [...inDegree.entries()].filter(([, d]) => d > 0).map(([f]) => f); + if (cycleFiles.length > 0) { + levels.push(cycleFiles); + } + + return { levels, cycleCount: cycleFiles.length }; +} + +/** Max bytes of source content to load per parse chunk. Each chunk's source + + * parsed ASTs + extracted records + worker serialization overhead all live in + * memory simultaneously, so this must be conservative. 20MB source ≈ 200-400MB + * peak working memory per chunk after parse expansion. */ +const CHUNK_BYTE_BUDGET = 20 * 1024 * 1024; // 20MB + +/** Max AST trees to keep in LRU cache */ +const AST_CACHE_CAP = 50; + +/** Minimum percentage of files that must benefit from cross-file seeding to justify the re-resolution pass. */ +const CROSS_FILE_SKIP_THRESHOLD = 0.03; +/** Hard cap on files re-processed during cross-file propagation. */ +const MAX_CROSS_FILE_REPROCESS = 2000; + +/** Node labels that represent top-level importable symbols. + * Excludes Method, Property, Constructor (accessed via receiver, not directly imported), + * and structural labels (File, Folder, Package, Module, Project, etc.). */ +const IMPORTABLE_SYMBOL_LABELS = new Set([ + 'Function', + 'Class', + 'Interface', + 'Struct', + 'Enum', + 'Trait', + 'TypeAlias', + 'Const', + 'Static', + 'Record', + 'Union', + 'Typedef', + 'Macro', +]); + +/** Max synthetic bindings per importing file — prevents memory bloat for + * C/C++ files that include many large headers. */ +const MAX_SYNTHETIC_BINDINGS_PER_FILE = 1000; + +/** Pre-computed language sets derived from providers at module load. */ +const WILDCARD_LANGUAGES = new Set( + Object.values(providers) + .filter((p) => p.importSemantics === 'wildcard') + .map((p) => p.id), +); +const SYNTHESIS_LANGUAGES = new Set( + Object.values(providers) + .filter((p) => p.importSemantics !== 'named') + .map((p) => p.id), +); + +/** Check if a language uses wildcard (whole-module) import semantics. + * Derived from LanguageProvider.importSemantics — no hardcoded set needed. */ +function isWildcardImportLanguage(lang: SupportedLanguages): boolean { + return WILDCARD_LANGUAGES.has(lang); +} + +/** Check if a language needs synthesis before call resolution. + * True for wildcard-import languages AND namespace-import languages (Python). */ +function needsSynthesis(lang: SupportedLanguages): boolean { + return SYNTHESIS_LANGUAGES.has(lang); +} + +/** Synthesize namedImportMap entries for languages with whole-module imports. + * These languages (Go, Ruby, C/C++, Swift, Python) import all exported symbols from a + * file, not specific named symbols. After parsing, we know which symbols each file + * exports (via graph isExported), so we can expand ImportMap edges into per-symbol + * bindings that Phase 14 can use for cross-file type propagation. */ +function synthesizeWildcardImportBindings( + graph: ReturnType, + ctx: ReturnType, +): number { + // Pre-compute exported symbols per file from graph (single pass) + const exportedSymbolsByFile = new Map(); + graph.forEachNode((node) => { + if (!node.properties?.isExported) return; + if (!IMPORTABLE_SYMBOL_LABELS.has(node.label)) return; + const fp = node.properties.filePath; + const name = node.properties.name; + if (!fp || !name) return; + let symbols = exportedSymbolsByFile.get(fp); + if (!symbols) { + symbols = []; + exportedSymbolsByFile.set(fp, symbols); + } + symbols.push({ name, filePath: fp }); + }); + + if (exportedSymbolsByFile.size === 0) return 0; + + // Build a merged import map: ctx.importMap has file-based imports (Ruby, C/C++), + // but Go/C# package imports use graph IMPORTS edges + PackageMap instead. + // Collect graph-level IMPORTS edges for wildcard languages missing from ctx.importMap. + const FILE_PREFIX = 'File:'; + const graphImports = new Map>(); + graph.forEachRelationship((rel) => { + if (rel.type !== 'IMPORTS') return; + if (!rel.sourceId.startsWith(FILE_PREFIX) || !rel.targetId.startsWith(FILE_PREFIX)) return; + const srcFile = rel.sourceId.slice(FILE_PREFIX.length); + const tgtFile = rel.targetId.slice(FILE_PREFIX.length); + const lang = getLanguageFromFilename(srcFile); + if (!lang || !isWildcardImportLanguage(lang)) return; + // Only add if not already in ctx.importMap (avoid duplicates) + if (ctx.importMap.get(srcFile)?.has(tgtFile)) return; + let set = graphImports.get(srcFile); + if (!set) { + set = new Set(); + graphImports.set(srcFile, set); + } + set.add(tgtFile); + }); + + let totalSynthesized = 0; + + // Helper: synthesize bindings for a file given its imported files + const synthesizeForFile = (filePath: string, importedFiles: Iterable) => { + let fileBindings = ctx.namedImportMap.get(filePath); + let fileCount = fileBindings?.size ?? 0; + + for (const importedFile of importedFiles) { + const exportedSymbols = exportedSymbolsByFile.get(importedFile); + if (!exportedSymbols) continue; + + for (const sym of exportedSymbols) { + if (fileCount >= MAX_SYNTHETIC_BINDINGS_PER_FILE) return; + if (fileBindings?.has(sym.name)) continue; + + if (!fileBindings) { + fileBindings = new Map(); + ctx.namedImportMap.set(filePath, fileBindings); + } + fileBindings.set(sym.name, { + sourcePath: importedFile, + exportedName: sym.name, + }); + fileCount++; + totalSynthesized++; + } + } + }; + + // Process files from ctx.importMap (Ruby, C/C++, Swift file-based imports) + for (const [filePath, importedFiles] of ctx.importMap) { + const lang = getLanguageFromFilename(filePath); + if (!lang || !isWildcardImportLanguage(lang)) continue; + synthesizeForFile(filePath, importedFiles); + } + + // Process files from graph IMPORTS edges (Go and other wildcard-import languages) + for (const [filePath, importedFiles] of graphImports) { + synthesizeForFile(filePath, importedFiles); + } + + // Build module alias map for Python namespace imports. + // `import models` in app.py → ctx.moduleAliasMap['app.py']['models'] = 'models.py' + // Enables `models.User()` to resolve to models.py:User without ambiguous symbol expansion. + const buildPythonModuleAliasForFile = (callerFile: string, importedFiles: Iterable) => { + let aliasMap = ctx.moduleAliasMap.get(callerFile); + for (const importedFile of importedFiles) { + // Derive the module alias from the imported filename stem (e.g. "models.py" → "models") + const lastSlash = importedFile.lastIndexOf('/'); + const base = lastSlash >= 0 ? importedFile.slice(lastSlash + 1) : importedFile; + const dot = base.lastIndexOf('.'); + const stem = dot >= 0 ? base.slice(0, dot) : base; + if (!stem) continue; + if (!aliasMap) { + aliasMap = new Map(); + ctx.moduleAliasMap.set(callerFile, aliasMap); + } + aliasMap.set(stem, importedFile); + } + }; + + for (const [filePath, importedFiles] of ctx.importMap) { + const provider = getProviderForFile(filePath); + if (!provider || provider.importSemantics !== 'namespace') continue; + buildPythonModuleAliasForFile(filePath, importedFiles); + } + + return totalSynthesized; +} + +/** Phase 14: Cross-file binding propagation. + * Seeds downstream files with resolved type bindings from upstream exports. + * Files are processed in topological import order so upstream bindings are + * available when downstream files are re-resolved. */ +async function runCrossFileBindingPropagation( + graph: ReturnType, + ctx: ReturnType, + exportedTypeMap: ExportedTypeMap, + allPaths: string[], + totalFiles: number, + repoPath: string, + pipelineStart: number, + onProgress: (progress: PipelineProgress) => void, +): Promise { + // 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); + for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports); + } + + if (exportedTypeMap.size === 0 || ctx.namedImportMap.size === 0) return; + + const allPathSet = new Set(allPaths); + const { levels, cycleCount } = topologicalLevelSort(ctx.importMap); + + // Cycle diagnostic: only log when actual cycles detected (cycleCount from Kahn's BFS) + if (isDev && cycleCount > 0) { + console.log(`🔄 ${cycleCount} files in import cycles (skipped for cross-file propagation)`); + } + + // Quick count of files with cross-file binding gaps (early exit once threshold exceeded) + let filesWithGaps = 0; + const gapThreshold = Math.max(1, Math.ceil(totalFiles * CROSS_FILE_SKIP_THRESHOLD)); + outer: for (const level of levels) { + for (const filePath of level) { + const imports = ctx.namedImportMap.get(filePath); + if (!imports) continue; + for (const [, binding] of imports) { + const upstream = exportedTypeMap.get(binding.sourcePath); + if (upstream?.has(binding.exportedName)) { + filesWithGaps++; + break; + } + const def = ctx.symbols.lookupExactFull(binding.sourcePath, binding.exportedName); + if (def?.returnType) { + filesWithGaps++; + break; + } + } + if (filesWithGaps >= gapThreshold) break outer; + } + } + + const gapRatio = totalFiles > 0 ? filesWithGaps / totalFiles : 0; + if (gapRatio < CROSS_FILE_SKIP_THRESHOLD && filesWithGaps < gapThreshold) { + if (isDev) { + console.log( + `⏭️ Cross-file re-resolution skipped (${filesWithGaps}/${totalFiles} files, ${(gapRatio * 100).toFixed(1)}% < ${CROSS_FILE_SKIP_THRESHOLD * 100}% threshold)`, + ); + } + return; + } + + onProgress({ + phase: 'parsing', + percent: 82, + message: `Cross-file type propagation (${filesWithGaps}+ files)...`, + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, + }); + + let crossFileResolved = 0; + const crossFileStart = Date.now(); + const astCache = createASTCache(AST_CACHE_CAP); + + for (const level of levels) { + const levelCandidates: { + filePath: string; + seeded: Map; + importedReturns: ReadonlyMap; + importedRawReturns: ReadonlyMap; + }[] = []; + for (const filePath of level) { + if (crossFileResolved + levelCandidates.length >= MAX_CROSS_FILE_REPROCESS) break; + const imports = ctx.namedImportMap.get(filePath); + if (!imports) continue; + + const seeded = new Map(); + for (const [localName, binding] of imports) { + const upstream = exportedTypeMap.get(binding.sourcePath); + if (upstream) { + const type = upstream.get(binding.exportedName); + if (type) seeded.set(localName, type); + } + } + + const importedReturns = buildImportedReturnTypes(filePath, ctx.namedImportMap, ctx.symbols); + const importedRawReturns = buildImportedRawReturnTypes( + filePath, + ctx.namedImportMap, + ctx.symbols, + ); + if (seeded.size === 0 && importedReturns.size === 0) continue; + if (!allPathSet.has(filePath)) continue; + + const lang = getLanguageFromFilename(filePath); + if (!lang || !isLanguageAvailable(lang)) continue; + + levelCandidates.push({ filePath, seeded, importedReturns, importedRawReturns }); + } + + if (levelCandidates.length === 0) continue; + + const levelPaths = levelCandidates.map((c) => c.filePath); + const contentMap = await readFileContents(repoPath, levelPaths); + + for (const { filePath, seeded, importedReturns, importedRawReturns } of levelCandidates) { + const content = contentMap.get(filePath); + if (!content) continue; + + const reFile = [{ path: filePath, content }]; + const bindings = new Map>(); + if (seeded.size > 0) bindings.set(filePath, seeded); + + const importedReturnTypesMap = new Map>(); + if (importedReturns.size > 0) { + importedReturnTypesMap.set(filePath, importedReturns); + } + + const importedRawReturnTypesMap = new Map>(); + if (importedRawReturns.size > 0) { + importedRawReturnTypesMap.set(filePath, importedRawReturns); + } + + await processCalls( + graph, + reFile, + astCache, + ctx, + undefined, + exportedTypeMap, + bindings.size > 0 ? bindings : undefined, + importedReturnTypesMap.size > 0 ? importedReturnTypesMap : undefined, + importedRawReturnTypesMap.size > 0 ? importedRawReturnTypesMap : undefined, + ); + crossFileResolved++; + } + + if (crossFileResolved >= MAX_CROSS_FILE_REPROCESS) { + if (isDev) + console.log(`⚠️ Cross-file re-resolution capped at ${MAX_CROSS_FILE_REPROCESS} files`); + break; + } + } + + astCache.clear(); + + if (isDev) { + const elapsed = Date.now() - crossFileStart; + const totalElapsed = Date.now() - pipelineStart; + const reResolutionPct = totalElapsed > 0 ? ((elapsed / totalElapsed) * 100).toFixed(1) : '0'; + console.log( + `🔗 Cross-file re-resolution: ${crossFileResolved} candidates re-processed` + + ` in ${elapsed}ms (${reResolutionPct}% of total ingestion time so far)`, + ); + } +} export interface PipelineOptions { /** Skip MRO, community detection, and process extraction for faster test runs. */ skipGraphPhases?: boolean; /** Force sequential parsing (no worker pool). Useful for testing the sequential path. */ skipWorkers?: boolean; - /** - * @internal Test-only override for worker-pool gating thresholds. - * When unset, production defaults apply (15 files OR 512 KB total bytes). - * Setting either field lowers the corresponding threshold so small test - * fixtures can still exercise the worker-pool path. Do not use from - * production call sites. - */ - workerThresholdsForTest?: { - minFiles?: number; - minBytes?: number; +} + +// ── Extracted pipeline phases ────────────────────────────────────────────── +// Each function represents a natural phase boundary in the ingestion pipeline. +// Data flow is explicit through parameters and return values. + +type ProgressFn = (progress: PipelineProgress) => void; +type ScannedFile = { path: string; size: number }; + +/** + * Phase 1+2: Scan repository paths, build file/folder structure, process markdown. + * + * @reads repoPath (filesystem) + * @writes graph (File, Folder nodes + CONTAINS edges; Markdown sections + cross-links) + */ +async function runScanAndStructure( + repoPath: string, + graph: ReturnType, + onProgress: ProgressFn, +): Promise<{ scannedFiles: ScannedFile[]; allPaths: string[]; totalFiles: number }> { + // ── Phase 1: Scan paths only (no content read) ───────────────────── + onProgress({ + phase: 'extracting', + percent: 0, + message: 'Scanning repository...', + }); + + const scannedFiles = await walkRepositoryPaths(repoPath, (current, total, filePath) => { + const scanProgress = Math.round((current / total) * 15); + onProgress({ + phase: 'extracting', + percent: scanProgress, + message: 'Scanning repository...', + detail: filePath, + stats: { filesProcessed: current, totalFiles: total, nodesCreated: graph.nodeCount }, + }); + }); + + const totalFiles = scannedFiles.length; + + onProgress({ + phase: 'extracting', + percent: 15, + message: 'Repository scanned successfully', + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, + }); + + // ── Phase 2: Structure (paths only — no content needed) ──────────── + onProgress({ + phase: 'structure', + percent: 15, + message: 'Analyzing project structure...', + stats: { filesProcessed: 0, totalFiles, nodesCreated: graph.nodeCount }, + }); + + const allPaths = scannedFiles.map((f) => f.path); + processStructure(graph, allPaths); + + onProgress({ + phase: 'structure', + percent: 20, + message: 'Project structure analyzed', + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, + }); + + // ── Custom (non-tree-sitter) processors ───────────────────────────── + // Each custom processor follows the pattern in markdown-processor.ts: + // 1. Export a process function: (graph, files, allPathSet) => result + // 2. Export a file detection function: (path) => boolean + // 3. Filter files by extension, write nodes/edges directly to graph + // To add a new language: create a new processor file, import it here, + // and add a filter-read-call-log block following the pattern below. + + // ── Phase 2.5: Markdown processing (headings + cross-links) ──────── + const mdScanned = scannedFiles.filter((f) => f.path.endsWith('.md') || f.path.endsWith('.mdx')); + if (mdScanned.length > 0) { + const mdContents = await readFileContents( + repoPath, + mdScanned.map((f) => f.path), + ); + const mdFiles = mdScanned + .filter((f) => mdContents.has(f.path)) + .map((f) => ({ path: f.path, content: mdContents.get(f.path)! })); + const allPathSet = new Set(allPaths); + const mdResult = processMarkdown(graph, mdFiles, allPathSet); + if (isDev) { + console.log( + ` Markdown: ${mdResult.sections} sections, ${mdResult.links} cross-links from ${mdFiles.length} files`, + ); + } + } + + // ── Phase 2.6: COBOL processing (regex extraction, no tree-sitter) ── + const cobolScanned = scannedFiles.filter((f) => isCobolFile(f.path) || isJclFile(f.path)); + if (cobolScanned.length > 0) { + const cobolContents = await readFileContents( + repoPath, + cobolScanned.map((f) => f.path), + ); + const cobolFiles = cobolScanned + .filter((f) => cobolContents.has(f.path)) + .map((f) => ({ path: f.path, content: cobolContents.get(f.path)! })); + const allPathSet = new Set(allPaths); + const cobolResult = processCobol(graph, cobolFiles, allPathSet); + if (isDev) { + console.log( + ` COBOL: ${cobolResult.programs} programs, ${cobolResult.paragraphs} paragraphs, ${cobolResult.sections} sections from ${cobolFiles.length} files`, + ); + if ( + cobolResult.execSqlBlocks > 0 || + cobolResult.execCicsBlocks > 0 || + cobolResult.entryPoints > 0 + ) { + console.log( + ` COBOL enriched: ${cobolResult.execSqlBlocks} SQL blocks, ${cobolResult.execCicsBlocks} CICS blocks, ${cobolResult.entryPoints} entry points, ${cobolResult.moves} moves, ${cobolResult.fileDeclarations} file declarations`, + ); + } + if (cobolResult.jclJobs > 0) { + console.log(` JCL: ${cobolResult.jclJobs} jobs, ${cobolResult.jclSteps} steps`); + } + } + } + + return { scannedFiles, allPaths, totalFiles }; +} + +/** + * Phase 3+4: Chunked parse + resolve loop. + * + * Reads source in byte-budget chunks (~20MB each). For each chunk: + * 1. Parse via worker pool (or sequential fallback) + * 2. Resolve imports from extracted data + * 3. Synthesize wildcard import bindings (Go/Ruby/C++/Swift/Python) + * 4. Resolve heritage + routes per chunk; defer worker CALLS until all chunks + * have contributed heritage so interface-dispatch implementor map is complete + * 5. Collect TypeEnv bindings for cross-file propagation + * + * State accumulated across chunks: symbolTable, importMap, namedImportMap, + * moduleAliasMap (all via ResolutionContext), exportedTypeMap, workerTypeEnvBindings. + * + * @reads graph (structure nodes from Phase 1+2) + * @reads allPaths (from scan phase) + * @writes graph (Symbol nodes, IMPORTS/CALLS/EXTENDS/IMPLEMENTS/ACCESSES edges) + * @writes ctx.symbolTable, ctx.importMap, ctx.namedImportMap, ctx.moduleAliasMap + * + * Follow-up from PR review: MethodExtractor (FieldExtractor parity) and optional + * METHOD_IMPLEMENTS graph edges to make dispatch queryable without an in-memory map. + */ +async function runChunkedParseAndResolve( + graph: ReturnType, + ctx: ReturnType, + scannedFiles: ScannedFile[], + allPaths: string[], + totalFiles: number, + repoPath: string, + pipelineStart: number, + onProgress: ProgressFn, + options?: PipelineOptions, +): Promise<{ + exportedTypeMap: ExportedTypeMap; + allFetchCalls: ExtractedFetchCall[]; + allExtractedRoutes: ExtractedRoute[]; + allDecoratorRoutes: ExtractedDecoratorRoute[]; + allToolDefs: ExtractedToolDef[]; + allORMQueries: ExtractedORMQuery[]; +}> { + const symbolTable = ctx.symbols; + + const parseableScanned = scannedFiles.filter((f) => { + const lang = getLanguageFromFilename(f.path); + return lang && isLanguageAvailable(lang); + }); + + // Warn about files skipped due to unavailable parsers + const skippedByLang = new Map(); + for (const f of scannedFiles) { + const lang = getLanguageFromFilename(f.path); + if (lang && !isLanguageAvailable(lang)) { + skippedByLang.set(lang, (skippedByLang.get(lang) || 0) + 1); + } + } + for (const [lang, count] of skippedByLang) { + console.warn( + `Skipping ${count} ${lang} file(s) — ${lang} parser not available (native binding may not have built). Try: npm rebuild tree-sitter-${lang}`, + ); + } + + const totalParseable = parseableScanned.length; + + if (totalParseable === 0) { + onProgress({ + phase: 'parsing', + percent: 82, + message: 'No parseable files found — skipping parsing phase', + stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: graph.nodeCount }, + }); + } + + // Build byte-budget chunks + const chunks: string[][] = []; + let currentChunk: string[] = []; + let currentBytes = 0; + for (const file of parseableScanned) { + if (currentChunk.length > 0 && currentBytes + file.size > CHUNK_BYTE_BUDGET) { + chunks.push(currentChunk); + currentChunk = []; + currentBytes = 0; + } + currentChunk.push(file.path); + currentBytes += file.size; + } + if (currentChunk.length > 0) chunks.push(currentChunk); + + const numChunks = chunks.length; + + if (isDev) { + const totalMB = parseableScanned.reduce((s, f) => s + f.size, 0) / (1024 * 1024); + console.log( + `📂 Scan: ${totalFiles} paths, ${totalParseable} parseable (${totalMB.toFixed(0)}MB), ${numChunks} chunks @ ${CHUNK_BYTE_BUDGET / (1024 * 1024)}MB budget`, + ); + } + + onProgress({ + phase: 'parsing', + percent: 20, + message: `Parsing ${totalParseable} files in ${numChunks} chunk${numChunks !== 1 ? 's' : ''}...`, + stats: { filesProcessed: 0, totalFiles: totalParseable, nodesCreated: graph.nodeCount }, + }); + + // Don't spawn workers for tiny repos — overhead exceeds benefit + const MIN_FILES_FOR_WORKERS = 15; + const MIN_BYTES_FOR_WORKERS = 512 * 1024; + const totalBytes = parseableScanned.reduce((s, f) => s + f.size, 0); + + // Create worker pool once, reuse across chunks + let workerPool: WorkerPool | undefined; + if ( + !options?.skipWorkers && + (totalParseable >= MIN_FILES_FOR_WORKERS || totalBytes >= MIN_BYTES_FOR_WORKERS) + ) { + try { + let workerUrl = new URL('./workers/parse-worker.js', import.meta.url); + // When running under vitest, import.meta.url points to src/ where no .js exists. + // Fall back to the compiled dist/ worker so the pool can spawn real worker threads. + const thisDir = fileURLToPath(new URL('.', import.meta.url)); + if (!fs.existsSync(fileURLToPath(workerUrl))) { + const distWorker = path.resolve( + thisDir, + '..', + '..', + '..', + 'dist', + 'core', + 'ingestion', + 'workers', + 'parse-worker.js', + ); + if (fs.existsSync(distWorker)) { + workerUrl = pathToFileURL(distWorker) as URL; + } + } + workerPool = createWorkerPool(workerUrl); + } catch (err) { + if (isDev) + console.warn( + 'Worker pool creation failed, using sequential fallback:', + (err as Error).message, + ); + } + } + + let filesParsedSoFar = 0; + + // AST cache sized for one chunk (sequential fallback uses it for import/call/heritage) + const maxChunkFiles = chunks.reduce((max, c) => Math.max(max, c.length), 0); + let astCache = createASTCache(maxChunkFiles); + + // Build import resolution context once — suffix index, file lists, resolve cache. + // Reused across all chunks to avoid rebuilding O(files × path_depth) structures. + const importCtx = buildImportResolutionContext(allPaths); + const allPathObjects = allPaths.map((p) => ({ path: p })); + + // Worker path: parse + imports + heritage per chunk; buffer extracted calls and + // run processCallsFromExtracted once after all chunks so interface-dispatch uses a + // complete implementor map (heritage from every chunk). Costs peak RAM for buffered + // call rows vs streaming resolution per chunk. + const sequentialChunkPaths: string[][] = []; + // Pre-compute which chunks need synthesis — O(1) lookup per chunk. + const chunkNeedsSynthesis = chunks.map((paths) => + paths.some((p) => { + const lang = getLanguageFromFilename(p); + return lang != null && needsSynthesis(lang); + }), + ); + // Phase 14: Collect exported type bindings for cross-file propagation + const exportedTypeMap: ExportedTypeMap = new Map(); + // Accumulate file-scope TypeEnv bindings from workers (closes worker/sequential quality gap) + const workerTypeEnvBindings: { filePath: string; bindings: [string, string][] }[] = []; + // Accumulate fetch() calls from workers for Next.js route matching + const allFetchCalls: ExtractedFetchCall[] = []; + // Accumulate framework-extracted routes (Laravel, etc.) for Route node creation + const allExtractedRoutes: ExtractedRoute[] = []; + // Accumulate decorator-based routes (@Get, @Post, @app.route, etc.) + const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; + // Accumulate MCP/RPC tool definitions (@mcp.tool(), @app.tool(), etc.) + const allToolDefs: ExtractedToolDef[] = []; + const allORMQueries: ExtractedORMQuery[] = []; + const deferredWorkerCalls: ExtractedCall[] = []; + const deferredWorkerHeritage: ExtractedHeritage[] = []; + const deferredConstructorBindings: FileConstructorBindings[] = []; + const deferredAssignments: ExtractedAssignment[] = []; + + try { + for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) { + const chunkPaths = chunks[chunkIdx]; + + // Read content for this chunk only + const chunkContents = await readFileContents(repoPath, chunkPaths); + const chunkFiles = chunkPaths + .filter((p) => chunkContents.has(p)) + .map((p) => ({ path: p, content: chunkContents.get(p)! })); + + // Parse this chunk (workers or sequential fallback) + const chunkWorkerData = await processParsing( + graph, + chunkFiles, + symbolTable, + astCache, + (current, _total, filePath) => { + const globalCurrent = filesParsedSoFar + current; + const parsingProgress = 20 + (globalCurrent / totalParseable) * 62; + onProgress({ + phase: 'parsing', + percent: Math.round(parsingProgress), + message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`, + detail: filePath, + stats: { + filesProcessed: globalCurrent, + totalFiles: totalParseable, + nodesCreated: graph.nodeCount, + }, + }); + }, + workerPool, + ); + + const chunkBasePercent = 20 + (filesParsedSoFar / totalParseable) * 62; + + if (chunkWorkerData) { + // Imports + await processImportsFromExtracted( + graph, + allPathObjects, + chunkWorkerData.imports, + ctx, + (current, total) => { + onProgress({ + phase: 'parsing', + percent: Math.round(chunkBasePercent), + message: `Resolving imports (chunk ${chunkIdx + 1}/${numChunks})...`, + detail: `${current}/${total} files`, + stats: { + filesProcessed: filesParsedSoFar, + totalFiles: totalParseable, + nodesCreated: graph.nodeCount, + }, + }); + }, + repoPath, + importCtx, + ); + // ── Wildcard-import synthesis (Ruby / C/C++ / Swift / Go) + Python module aliases ─ + // Synthesize namedImportMap entries for wildcard-import languages and build + // moduleAliasMap for Python namespace imports. Must run after imports are resolved + // (importMap is populated) but BEFORE call resolution. + if (chunkNeedsSynthesis[chunkIdx]) synthesizeWildcardImportBindings(graph, ctx); + // Phase 14 E1: Seed cross-file receiver types from ExportedTypeMap + // before call resolution — eliminates re-parse for single-hop imported receivers. + // NOTE: In the worker path, exportedTypeMap is empty during chunk processing + // (populated later in runCrossFileBindingPropagation). This block is latent — + // it activates only if incremental export collection is added per-chunk. + if (exportedTypeMap.size > 0 && ctx.namedImportMap.size > 0) { + const { enrichedCount } = seedCrossFileReceiverTypes( + chunkWorkerData.calls, + ctx.namedImportMap, + exportedTypeMap, + ); + if (isDev && enrichedCount > 0) { + console.log( + `🔗 E1: Seeded ${enrichedCount} cross-file receiver types (chunk ${chunkIdx + 1})`, + ); + } + } + for (const _item of chunkWorkerData.calls) deferredWorkerCalls.push(_item); + for (const _item of chunkWorkerData.heritage) deferredWorkerHeritage.push(_item); + for (const _item of chunkWorkerData.constructorBindings) + deferredConstructorBindings.push(_item); + if (chunkWorkerData.assignments?.length) { + for (const _item of chunkWorkerData.assignments) deferredAssignments.push(_item); + } + + // Heritage + Routes — calls deferred until all chunks have contributed heritage + // (complete implementor map for interface dispatch). + await Promise.all([ + processHeritageFromExtracted(graph, chunkWorkerData.heritage, ctx, (current, total) => { + onProgress({ + phase: 'parsing', + percent: Math.round(chunkBasePercent), + message: `Resolving heritage (chunk ${chunkIdx + 1}/${numChunks})...`, + detail: `${current}/${total} records`, + stats: { + filesProcessed: filesParsedSoFar, + totalFiles: totalParseable, + nodesCreated: graph.nodeCount, + }, + }); + }), + processRoutesFromExtracted(graph, chunkWorkerData.routes ?? [], ctx, (current, total) => { + onProgress({ + phase: 'parsing', + percent: Math.round(chunkBasePercent), + message: `Resolving routes (chunk ${chunkIdx + 1}/${numChunks})...`, + detail: `${current}/${total} routes`, + stats: { + filesProcessed: filesParsedSoFar, + totalFiles: totalParseable, + nodesCreated: graph.nodeCount, + }, + }); + }), + ]); + // Collect TypeEnv file-scope bindings for exported type enrichment + if (chunkWorkerData.typeEnvBindings?.length) { + for (const _item of chunkWorkerData.typeEnvBindings) workerTypeEnvBindings.push(_item); + } + // Collect fetch() calls for Next.js route matching + if (chunkWorkerData.fetchCalls?.length) { + for (const _item of chunkWorkerData.fetchCalls) allFetchCalls.push(_item); + } + if (chunkWorkerData.routes?.length) { + for (const _item of chunkWorkerData.routes) allExtractedRoutes.push(_item); + } + if (chunkWorkerData.decoratorRoutes?.length) { + for (const _item of chunkWorkerData.decoratorRoutes) allDecoratorRoutes.push(_item); + } + if (chunkWorkerData.toolDefs?.length) { + for (const _item of chunkWorkerData.toolDefs) allToolDefs.push(_item); + } + if (chunkWorkerData.ormQueries?.length) { + for (const _item of chunkWorkerData.ormQueries) allORMQueries.push(_item); + } + } else { + await processImports(graph, chunkFiles, astCache, ctx, undefined, repoPath, allPaths); + sequentialChunkPaths.push(chunkPaths); + } + + filesParsedSoFar += chunkFiles.length; + + // Clear AST cache between chunks to free memory + astCache.clear(); + // chunkContents + chunkFiles + chunkWorkerData go out of scope → GC reclaims + } + + // Build unified HeritageMap (parent lookup + implementor index) after all chunks. + const fullWorkerHeritageMap = + deferredWorkerHeritage.length > 0 ? buildHeritageMap(deferredWorkerHeritage, ctx) : undefined; + + if (deferredWorkerCalls.length > 0) { + await processCallsFromExtracted( + graph, + deferredWorkerCalls, + ctx, + (current, total) => { + onProgress({ + phase: 'parsing', + percent: 82, + message: 'Resolving calls (all chunks)...', + detail: `${current}/${total} files`, + stats: { + filesProcessed: filesParsedSoFar, + totalFiles: totalParseable, + nodesCreated: graph.nodeCount, + }, + }); + }, + deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined, + fullWorkerHeritageMap, + ); + } + + if (deferredAssignments.length > 0) { + processAssignmentsFromExtracted( + graph, + deferredAssignments, + ctx, + deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined, + ); + } + } finally { + await workerPool?.terminate(); + } + + // Sequential fallback chunks: re-read source for call/heritage resolution + // Synthesize wildcard import bindings once after ALL imports are processed, + // before any call resolution — same rationale as the worker-path inline synthesis. + if (sequentialChunkPaths.length > 0) synthesizeWildcardImportBindings(graph, ctx); + // Pass 1: Extract heritage from all sequential chunks. + // Heritage must be fully accumulated BEFORE call resolution so the HeritageMap + // has the complete ancestor chain and implementor index (same constraint as + // the worker path). + // + // File contents are read once here and cached for Pass 2 to avoid a 2× I/O + // cost on the sequential path (ASTs are intentionally NOT cached — rebuilding + // them in Pass 2 keeps peak memory bounded to one chunk at a time). + const allSequentialHeritage: ExtractedHeritage[] = []; + const cachedSequentialChunkFiles: Array> = []; + for (const chunkPaths of sequentialChunkPaths) { + const chunkContents = await readFileContents(repoPath, chunkPaths); + const chunkFiles = chunkPaths + .filter((p) => chunkContents.has(p)) + .map((p) => ({ path: p, content: chunkContents.get(p)! })); + cachedSequentialChunkFiles.push(chunkFiles); + astCache = createASTCache(chunkFiles.length); + const sequentialHeritage = await extractExtractedHeritageFromFiles(chunkFiles, astCache); + // Manual loop (not spread) — `push(...arr)` blows the stack on very large + // arrays, see #650. Pay the explicit iteration cost for safety. + for (const h of sequentialHeritage) allSequentialHeritage.push(h); + astCache.clear(); + } + // Build unified HeritageMap from all sequential heritage (parent lookup + implementor index). + const sequentialHeritageMap = + allSequentialHeritage.length > 0 ? buildHeritageMap(allSequentialHeritage, ctx) : 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. + for (let chunkIdx = 0; chunkIdx < sequentialChunkPaths.length; chunkIdx++) { + const chunkFiles = cachedSequentialChunkFiles[chunkIdx]; + astCache = createASTCache(chunkFiles.length); + const rubyHeritage = await processCalls( + graph, + chunkFiles, + astCache, + ctx, + undefined, + exportedTypeMap, + undefined, + undefined, + undefined, + sequentialHeritageMap, + ); + await processHeritage(graph, chunkFiles, astCache, ctx); + if (rubyHeritage.length > 0) { + await processHeritageFromExtracted(graph, rubyHeritage, ctx); + } + // Extract fetch() calls for Next.js route matching (sequential path) + const chunkFetchCalls = await extractFetchCallsFromFiles(chunkFiles, astCache); + if (chunkFetchCalls.length > 0) { + for (const _item of chunkFetchCalls) allFetchCalls.push(_item); + } + // Extract ORM queries (sequential path) + for (const f of chunkFiles) { + extractORMQueriesInline(f.path, f.content, allORMQueries); + } + astCache.clear(); + // Release cached chunk content as soon as Pass 2 finishes with it so the + // Pass-1 content map drains incrementally rather than being held for the + // full duration of Pass 2. + cachedSequentialChunkFiles[chunkIdx] = []; + } + + // Log resolution cache stats + if (isDev) { + const rcStats = ctx.getStats(); + const total = rcStats.cacheHits + rcStats.cacheMisses; + const hitRate = total > 0 ? ((rcStats.cacheHits / total) * 100).toFixed(1) : '0'; + console.log( + `🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`, + ); + } + + // ── Worker path quality enrichment: merge TypeEnv file-scope bindings into ExportedTypeMap ── + // Workers return file-scope bindings from their TypeEnv fixpoint (includes inferred types + // like `const config = getConfig()` → Config). Filter by graph isExported to match + // the sequential path's collectExportedBindings behavior. + if (workerTypeEnvBindings.length > 0) { + let enriched = 0; + for (const { filePath, bindings } of workerTypeEnvBindings) { + for (const [name, type] of bindings) { + // Verify the symbol is exported via graph node + const nodeId = `Function:${filePath}:${name}`; + const varNodeId = `Variable:${filePath}:${name}`; + const constNodeId = `Const:${filePath}:${name}`; + const node = + graph.getNode(nodeId) ?? graph.getNode(varNodeId) ?? graph.getNode(constNodeId); + if (!node?.properties?.isExported) continue; + + let fileExports = exportedTypeMap.get(filePath); + if (!fileExports) { + fileExports = new Map(); + exportedTypeMap.set(filePath, fileExports); + } + // Don't overwrite existing entries (Tier 0 from SymbolTable is authoritative) + if (!fileExports.has(name)) { + fileExports.set(name, type); + enriched++; + } + } + } + if (isDev && enriched > 0) { + console.log( + `🔗 Worker TypeEnv enrichment: ${enriched} fixpoint-inferred exports added to ExportedTypeMap`, + ); + } + } + + // ── Final synthesis pass for whole-module-import languages ── + // Per-chunk synthesis (above) already ran incrementally. This final pass ensures + // any remaining files whose imports were not covered inline are also synthesized, + // and that Phase 14 type propagation has complete namedImportMap data. + const synthesized = synthesizeWildcardImportBindings(graph, ctx); + if (isDev && synthesized > 0) { + console.log( + `🔗 Synthesized ${synthesized} additional wildcard import bindings (Go/Ruby/C++/Swift/Python)`, + ); + } + + // Free import resolution context — suffix index + resolve cache no longer needed + // (allPathObjects and importCtx hold ~94MB+ for large repos) + allPathObjects.length = 0; + importCtx.resolveCache.clear(); + importCtx.index = EMPTY_INDEX; // Release suffix index memory (~30MB for large repos) + importCtx.normalizedFileList = []; + + return { + exportedTypeMap, + allFetchCalls, + allExtractedRoutes, + allDecoratorRoutes, + allToolDefs, + allORMQueries, }; } -// ── Phase registry ───────────────────────────────────────────────────────── - /** - * All pipeline phases with their dependency relationships. + * Post-parse graph analysis: MRO, community detection, process extraction. * - * Phase dependency graph: - * - * scan → structure → [markdown, cobol] → parse → [routes, tools, orm] - * → crossFile → mro → communities → processes - * - * To add a new phase: create a file in pipeline-phases/, export the phase - * object, and add it to the appropriate position in this array. + * @reads graph (all nodes and relationships from parse + resolve phases) + * @writes graph (Community nodes, Process nodes, MEMBER_OF edges, STEP_IN_PROCESS edges, METHOD_OVERRIDES edges) */ -function buildPhaseList(options?: PipelineOptions): PipelinePhase[] { - const phases: PipelinePhase[] = [ - scanPhase, - structurePhase, - markdownPhase, - cobolPhase, - parsePhase, - routesPhase, - toolsPhase, - ormPhase, - crossFilePhase, - scopeResolutionPhase, - ]; +async function runGraphAnalysisPhases( + graph: ReturnType, + totalFiles: number, + onProgress: ProgressFn, + routeRegistry?: Map, + toolDefs?: { name: string; filePath: string; description: string }[], +): Promise<{ + communityResult: Awaited>; + processResult: Awaited>; +}> { + // ── Phase 4.5: Method Resolution Order ────────────────────────────── + onProgress({ + phase: 'parsing', + percent: 81, + message: 'Computing method resolution order...', + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, + }); - if (!options?.skipGraphPhases) { - phases.push(mroPhase, communitiesPhase, processesPhase); + const mroResult = computeMRO(graph); + if (isDev && mroResult.entries.length > 0) { + console.log( + `🔀 MRO: ${mroResult.entries.length} classes analyzed, ${mroResult.ambiguityCount} ambiguities, ${mroResult.overrideEdges} METHOD_OVERRIDES, ${mroResult.methodImplementsEdges} METHOD_IMPLEMENTS`, + ); } - return phases; + // ── Phase 5: Communities ─────────────────────────────────────────── + onProgress({ + phase: 'communities', + percent: 82, + message: 'Detecting code communities...', + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, + }); + + const communityResult = await processCommunities(graph, (message, progress) => { + const communityProgress = 82 + progress * 0.1; + onProgress({ + phase: 'communities', + percent: Math.round(communityProgress), + message, + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, + }); + }); + + if (isDev) { + console.log( + `🏘️ Community detection: ${communityResult.stats.totalCommunities} communities found (modularity: ${communityResult.stats.modularity.toFixed(3)})`, + ); + } + + communityResult.communities.forEach((comm) => { + graph.addNode({ + id: comm.id, + label: 'Community' as const, + properties: { + name: comm.label, + filePath: '', + heuristicLabel: comm.heuristicLabel, + cohesion: comm.cohesion, + symbolCount: comm.symbolCount, + }, + }); + }); + + communityResult.memberships.forEach((membership) => { + graph.addRelationship({ + id: `${membership.nodeId}_member_of_${membership.communityId}`, + type: 'MEMBER_OF', + sourceId: membership.nodeId, + targetId: membership.communityId, + confidence: 1.0, + reason: 'leiden-algorithm', + }); + }); + + // ── Phase 6: Processes ───────────────────────────────────────────── + onProgress({ + phase: 'processes', + percent: 94, + message: 'Detecting execution flows...', + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, + }); + + let symbolCount = 0; + graph.forEachNode((n) => { + if (n.label !== 'File') symbolCount++; + }); + const dynamicMaxProcesses = Math.max(20, Math.min(300, Math.round(symbolCount / 10))); + + const processResult = await processProcesses( + graph, + communityResult.memberships, + (message, progress) => { + const processProgress = 94 + progress * 0.05; + onProgress({ + phase: 'processes', + percent: Math.round(processProgress), + message, + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, + }); + }, + { maxProcesses: dynamicMaxProcesses, minSteps: 3 }, + ); + + if (isDev) { + console.log( + `🔄 Process detection: ${processResult.stats.totalProcesses} processes found (${processResult.stats.crossCommunityCount} cross-community)`, + ); + } + + processResult.processes.forEach((proc) => { + graph.addNode({ + id: proc.id, + label: 'Process' as const, + properties: { + name: proc.label, + filePath: '', + heuristicLabel: proc.heuristicLabel, + processType: proc.processType, + stepCount: proc.stepCount, + communities: proc.communities, + entryPointId: proc.entryPointId, + terminalId: proc.terminalId, + }, + }); + }); + + processResult.steps.forEach((step) => { + graph.addRelationship({ + id: `${step.nodeId}_step_${step.step}_${step.processId}`, + type: 'STEP_IN_PROCESS', + sourceId: step.nodeId, + targetId: step.processId, + confidence: 1.0, + reason: 'trace-detection', + step: step.step, + }); + }); + + // Link Route and Tool nodes to Processes via reverse index (file → node id) + if ((routeRegistry?.size ?? 0) > 0 || (toolDefs?.length ?? 0) > 0) { + // Reverse indexes: file → all route URLs / tool names (handles multi-route files) + const routesByFile = new Map(); + if (routeRegistry) { + for (const [url, entry] of routeRegistry) { + let list = routesByFile.get(entry.filePath); + if (!list) { + list = []; + routesByFile.set(entry.filePath, list); + } + list.push(url); + } + } + const toolsByFile = new Map(); + if (toolDefs) { + for (const td of toolDefs) { + let list = toolsByFile.get(td.filePath); + if (!list) { + list = []; + toolsByFile.set(td.filePath, list); + } + list.push(td.name); + } + } + + let linked = 0; + for (const proc of processResult.processes) { + if (!proc.entryPointId) continue; + const entryNode = graph.getNode(proc.entryPointId); + if (!entryNode) continue; + const entryFile = entryNode.properties.filePath; + if (!entryFile) continue; + + const routeURLs = routesByFile.get(entryFile); + if (routeURLs) { + for (const routeURL of routeURLs) { + const routeNodeId = generateId('Route', routeURL); + graph.addRelationship({ + id: generateId('ENTRY_POINT_OF', `${routeNodeId}->${proc.id}`), + sourceId: routeNodeId, + targetId: proc.id, + type: 'ENTRY_POINT_OF', + confidence: 0.85, + reason: 'route-handler-entry-point', + }); + linked++; + } + } + const toolNames = toolsByFile.get(entryFile); + if (toolNames) { + for (const toolName of toolNames) { + const toolNodeId = generateId('Tool', toolName); + graph.addRelationship({ + id: generateId('ENTRY_POINT_OF', `${toolNodeId}->${proc.id}`), + sourceId: toolNodeId, + targetId: proc.id, + type: 'ENTRY_POINT_OF', + confidence: 0.85, + reason: 'tool-handler-entry-point', + }); + linked++; + } + } + } + if (isDev && linked > 0) { + console.log(`🔗 Linked ${linked} Route/Tool nodes to execution flows`); + } + } + + return { communityResult, processResult }; } // ── Pipeline orchestrator ───────────────────────────────────────────────── @@ -99,52 +1353,501 @@ export const runPipelineFromRepo = async ( options?: PipelineOptions, ): Promise => { const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); const pipelineStart = Date.now(); - const phases = buildPhaseList(options); + try { + // Phase 1+2: Scan paths, build structure, process markdown + const { scannedFiles, allPaths, totalFiles } = await runScanAndStructure( + repoPath, + graph, + onProgress, + ); - const results = await runPipeline(phases, { - repoPath, - graph, - onProgress, - options, - pipelineStart, - }); + // Phase 3+4: Chunked parse + resolve (imports, calls, heritage, routes) + const { + exportedTypeMap, + allFetchCalls, + allExtractedRoutes, + allDecoratorRoutes, + allToolDefs, + allORMQueries, + } = await runChunkedParseAndResolve( + graph, + ctx, + scannedFiles, + allPaths, + totalFiles, + repoPath, + pipelineStart, + onProgress, + options, + ); - // Extract final results for the PipelineResult contract - const { totalFiles, usedWorkerPool } = getPhaseOutput<{ - totalFiles: number; - usedWorkerPool: boolean; - }>(results, 'parse'); + // ── Phase 3.5: Route Registry (Next.js + PHP + Laravel + decorators) ── + type RouteEntry = { filePath: string; source: string }; + const routeRegistry = new Map(); - let communityResult: CommunitiesOutput['communityResult'] | undefined; - let processResult: ProcessesOutput['processResult'] | undefined; + // Detect Expo Router app/ roots vs Next.js app/ roots (monorepo-safe). + const expoAppRoots = new Set(); + const nextjsAppRoots = new Set(); + const expoAppPaths = new Set(); + for (const p of allPaths) { + const norm = p.replace(/\\/g, '/'); + const appIdx = norm.lastIndexOf('app/'); + if (appIdx < 0) continue; + const root = norm.slice(0, appIdx + 4); + if (/\/_layout\.(tsx?|jsx?)$/.test(norm)) expoAppRoots.add(root); + if (/\/page\.(tsx?|jsx?)$/.test(norm)) nextjsAppRoots.add(root); + } + for (const root of nextjsAppRoots) expoAppRoots.delete(root); + if (expoAppRoots.size > 0) { + for (const p of allPaths) { + const norm = p.replace(/\\/g, '/'); + const appIdx = norm.lastIndexOf('app/'); + if (appIdx >= 0 && expoAppRoots.has(norm.slice(0, appIdx + 4))) expoAppPaths.add(p); + } + } - if (!options?.skipGraphPhases) { - communityResult = getPhaseOutput(results, 'communities').communityResult; - processResult = getPhaseOutput(results, 'processes').processResult; + for (const p of allPaths) { + if (expoAppPaths.has(p)) { + const expoURL = expoFileToRouteURL(p); + if (expoURL && !routeRegistry.has(expoURL)) { + routeRegistry.set(expoURL, { filePath: p, source: 'expo-filesystem-route' }); + continue; + } + } + const nextjsURL = nextjsFileToRouteURL(p); + if (nextjsURL && !routeRegistry.has(nextjsURL)) { + routeRegistry.set(nextjsURL, { filePath: p, source: 'nextjs-filesystem-route' }); + continue; + } + if (p.endsWith('.php')) { + const phpURL = phpFileToRouteURL(p); + if (phpURL && !routeRegistry.has(phpURL)) { + routeRegistry.set(phpURL, { filePath: p, source: 'php-file-route' }); + } + } + } + + const ensureSlash = (path: string) => (path.startsWith('/') ? path : '/' + path); + let duplicateRoutes = 0; + const addRoute = (url: string, entry: RouteEntry) => { + if (routeRegistry.has(url)) { + duplicateRoutes++; + return; + } + routeRegistry.set(url, entry); + }; + for (const route of allExtractedRoutes) { + if (!route.routePath) continue; + addRoute(ensureSlash(route.routePath), { + filePath: route.filePath, + source: 'framework-route', + }); + } + for (const dr of allDecoratorRoutes) { + addRoute(ensureSlash(dr.routePath), { + filePath: dr.filePath, + source: `decorator-${dr.decoratorName}`, + }); + } + + let handlerContents: Map | undefined; + if (routeRegistry.size > 0) { + const handlerPaths = [...routeRegistry.values()].map((e) => e.filePath); + handlerContents = await readFileContents(repoPath, handlerPaths); + + for (const [routeURL, entry] of routeRegistry) { + const { filePath: handlerPath, source: routeSource } = entry; + const content = handlerContents.get(handlerPath); + + const { responseKeys, errorKeys } = content + ? handlerPath.endsWith('.php') + ? extractPHPResponseShapes(content) + : extractResponseShapes(content) + : { responseKeys: undefined, errorKeys: undefined }; + + const mwResult = content ? extractMiddlewareChain(content) : undefined; + const middleware = mwResult?.chain; + + const routeNodeId = generateId('Route', routeURL); + graph.addNode({ + id: routeNodeId, + label: 'Route', + properties: { + name: routeURL, + filePath: handlerPath, + ...(responseKeys ? { responseKeys } : {}), + ...(errorKeys ? { errorKeys } : {}), + ...(middleware && middleware.length > 0 ? { middleware } : {}), + }, + }); + + const handlerFileId = generateId('File', handlerPath); + graph.addRelationship({ + id: generateId('HANDLES_ROUTE', `${handlerFileId}->${routeNodeId}`), + sourceId: handlerFileId, + targetId: routeNodeId, + type: 'HANDLES_ROUTE', + confidence: 1.0, + reason: routeSource, + }); + } + + if (isDev) { + console.log( + `🗺️ Route registry: ${routeRegistry.size} routes${duplicateRoutes > 0 ? ` (${duplicateRoutes} duplicate URLs skipped)` : ''}`, + ); + } + } + + // ── Phase 3.5b: Link Next.js project-level middleware.ts to routes ── + if (routeRegistry.size > 0) { + const middlewareCandidates = allPaths.filter( + (p) => + p === 'middleware.ts' || + p === 'middleware.js' || + p === 'middleware.tsx' || + p === 'middleware.jsx' || + p === 'src/middleware.ts' || + p === 'src/middleware.js' || + p === 'src/middleware.tsx' || + p === 'src/middleware.jsx', + ); + if (middlewareCandidates.length > 0) { + const mwContents = await readFileContents(repoPath, middlewareCandidates); + for (const [mwPath, mwContent] of mwContents) { + const config = extractNextjsMiddlewareConfig(mwContent); + if (!config) continue; + const mwLabel = + config.wrappedFunctions.length > 0 ? config.wrappedFunctions : [config.exportedName]; + + // Pre-compile matchers once per middleware file + const compiled = config.matchers + .map(compileMatcher) + .filter((m): m is NonNullable => m !== null); + + let linkedCount = 0; + for (const [routeURL] of routeRegistry) { + const matches = + compiled.length === 0 || + compiled.some((cm) => compiledMatcherMatchesRoute(cm, routeURL)); + if (!matches) continue; + + const routeNodeId = generateId('Route', routeURL); + const existing = graph.getNode(routeNodeId); + if (!existing) continue; + + const currentMw = (existing.properties.middleware as string[] | undefined) ?? []; + // Prepend project-level middleware (runs before handler-level wrappers) + existing.properties.middleware = [ + ...mwLabel, + ...currentMw.filter((m) => !mwLabel.includes(m)), + ]; + linkedCount++; + } + if (isDev && linkedCount > 0) { + console.log( + `🛡️ Linked ${mwPath} middleware [${mwLabel.join(', ')}] to ${linkedCount} routes`, + ); + } + } + } + } + + // Scan HTML/PHP/template files for
and AJAX url patterns + // Scan HTML/template files for and AJAX url patterns + // Skip .php — already parsed by tree-sitter with http_client/fetch queries + const htmlCandidates = allPaths.filter( + (p) => + p.endsWith('.html') || + p.endsWith('.htm') || + p.endsWith('.ejs') || + p.endsWith('.hbs') || + p.endsWith('.blade.php'), + ); + if (htmlCandidates.length > 0 && routeRegistry.size > 0) { + const htmlContents = await readFileContents(repoPath, htmlCandidates); + const htmlPatterns = [/action=["']([^"']+)["']/g, /url:\s*["']([^"']+)["']/g]; + for (const [filePath, content] of htmlContents) { + for (const pattern of htmlPatterns) { + pattern.lastIndex = 0; + let match; + while ((match = pattern.exec(content)) !== null) { + const normalized = normalizeFetchURL(match[1]); + if (normalized) { + allFetchCalls.push({ filePath, fetchURL: normalized, lineNumber: 0 }); + } + } + } + } + } + + // ── Phase 3.5c: Extract Expo Router navigation patterns ── + if (expoAppPaths.size > 0 && routeRegistry.size > 0) { + const unreadExpoPaths = [...expoAppPaths].filter((p) => !handlerContents?.has(p)); + const extraContents = + unreadExpoPaths.length > 0 + ? await readFileContents(repoPath, unreadExpoPaths) + : new Map(); + const allExpoContents = new Map([...(handlerContents ?? new Map()), ...extraContents]); + for (const [filePath, content] of allExpoContents) { + if (!expoAppPaths.has(filePath)) continue; + for (const pattern of EXPO_NAV_PATTERNS) { + pattern.lastIndex = 0; + let match; + while ((match = pattern.exec(content)) !== null) { + const url = match[2] ?? match[1]; + if (url && url.startsWith('/')) { + allFetchCalls.push({ filePath, fetchURL: url, lineNumber: 0 }); + } + } + } + } + } + + if (routeRegistry.size > 0 && allFetchCalls.length > 0) { + const routeURLToFile = new Map(); + for (const [url, entry] of routeRegistry) routeURLToFile.set(url, entry.filePath); + + // Read consumer file contents so we can extract property access patterns + const consumerPaths = [...new Set(allFetchCalls.map((c) => c.filePath))]; + const consumerContents = await readFileContents(repoPath, consumerPaths); + + processNextjsFetchRoutes(graph, allFetchCalls, routeURLToFile, consumerContents); + if (isDev) { + console.log( + `🔗 Processed ${allFetchCalls.length} fetch() calls against ${routeRegistry.size} routes`, + ); + } + } + + // ── Phase 3.6: Tool Detection (MCP/RPC) ────────────────────────── + const toolDefs: { name: string; filePath: string; description: string }[] = []; + const seenToolNames = new Set(); + + for (const td of allToolDefs) { + if (seenToolNames.has(td.toolName)) continue; + seenToolNames.add(td.toolName); + toolDefs.push({ name: td.toolName, filePath: td.filePath, description: td.description }); + } + + // TS tool definition arrays — require inputSchema nearby to distinguish from config objects + const toolCandidatePaths = allPaths.filter( + (p) => + (p.endsWith('.ts') || p.endsWith('.js')) && + p.toLowerCase().includes('tool') && + !p.includes('node_modules') && + !p.includes('test') && + !p.includes('__'), + ); + if (toolCandidatePaths.length > 0) { + const toolContents = await readFileContents(repoPath, toolCandidatePaths); + for (const [filePath, content] of toolContents) { + // Only scan files that contain 'inputSchema' — this is the MCP tool signature + if (!content.includes('inputSchema')) continue; + const toolPattern = + /name:\s*['"](\w+)['"]\s*,\s*\n?\s*description:\s*[`'"]([\s\S]*?)[`'"]/g; + let match; + while ((match = toolPattern.exec(content)) !== null) { + const name = match[1]; + if (seenToolNames.has(name)) continue; + seenToolNames.add(name); + toolDefs.push({ + name, + filePath, + description: match[2].slice(0, 200).replace(/\n/g, ' ').trim(), + }); + } + } + } + + // Create Tool nodes and HANDLES_TOOL edges + if (toolDefs.length > 0) { + for (const td of toolDefs) { + const toolNodeId = generateId('Tool', td.name); + graph.addNode({ + id: toolNodeId, + label: 'Tool', + properties: { name: td.name, filePath: td.filePath, description: td.description }, + }); + + const handlerFileId = generateId('File', td.filePath); + graph.addRelationship({ + id: generateId('HANDLES_TOOL', `${handlerFileId}->${toolNodeId}`), + sourceId: handlerFileId, + targetId: toolNodeId, + type: 'HANDLES_TOOL', + confidence: 1.0, + reason: 'tool-definition', + }); + } + + if (isDev) { + console.log(`🔧 Tool registry: ${toolDefs.length} tools detected`); + } + } + + // ── Phase 3.7: ORM Dataflow Detection (Prisma + Supabase) ────────── + if (allORMQueries.length > 0) { + processORMQueries(graph, allORMQueries, isDev); + } + + // ── Phase 14: Cross-file binding propagation (topological level sort) ── + await runCrossFileBindingPropagation( + graph, + ctx, + exportedTypeMap, + allPaths, + totalFiles, + repoPath, + pipelineStart, + onProgress, + ); + + // Post-parse graph analysis (MRO, communities, processes) + let communityResult: Awaited> | undefined; + let processResult: Awaited> | undefined; + + if (!options?.skipGraphPhases) { + const graphResults = await runGraphAnalysisPhases( + graph, + totalFiles, + onProgress, + routeRegistry, + toolDefs, + ); + communityResult = graphResults.communityResult; + processResult = graphResults.processResult; + } + + onProgress({ + phase: 'complete', + percent: 100, + message: + communityResult && processResult + ? `Graph complete! ${communityResult.stats.totalCommunities} communities, ${processResult.stats.totalProcesses} processes detected.` + : 'Graph complete! (graph phases skipped)', + stats: { + filesProcessed: totalFiles, + totalFiles, + nodesCreated: graph.nodeCount, + }, + }); + + return { graph, repoPath, totalFileCount: totalFiles, communityResult, processResult }; + } catch (error) { + ctx.clear(); + throw error; + } +}; + +// Inline ORM regex extraction (avoids importing parse-worker which has worker-only code) +const PRISMA_QUERY_RE = + /\bprisma\.(\w+)\.(findMany|findFirst|findUnique|findUniqueOrThrow|findFirstOrThrow|create|createMany|update|updateMany|delete|deleteMany|upsert|count|aggregate|groupBy)\s*\(/g; +const SUPABASE_QUERY_RE = + /\bsupabase\.from\s*\(\s*['"](\w+)['"]\s*\)\s*\.(select|insert|update|delete|upsert)\s*\(/g; + +function extractORMQueriesInline( + filePath: string, + content: string, + out: ExtractedORMQuery[], +): void { + const hasPrisma = content.includes('prisma.'); + const hasSupabase = content.includes('supabase.from'); + if (!hasPrisma && !hasSupabase) return; + + if (hasPrisma) { + PRISMA_QUERY_RE.lastIndex = 0; + let m; + while ((m = PRISMA_QUERY_RE.exec(content)) !== null) { + const model = m[1]; + if (model.startsWith('$')) continue; + out.push({ + filePath, + orm: 'prisma', + model, + method: m[2], + lineNumber: content.substring(0, m.index).split('\n').length - 1, + }); + } } - onProgress({ - phase: 'complete', - percent: 100, - message: - communityResult && processResult - ? `Graph complete! ${communityResult.stats.totalCommunities} communities, ${processResult.stats.totalProcesses} processes detected.` - : 'Graph complete! (graph phases skipped)', - stats: { - filesProcessed: totalFiles, - totalFiles, - nodesCreated: graph.nodeCount, - }, - }); + if (hasSupabase) { + SUPABASE_QUERY_RE.lastIndex = 0; + let m; + while ((m = SUPABASE_QUERY_RE.exec(content)) !== null) { + out.push({ + filePath, + orm: 'supabase', + model: m[1], + method: m[2], + lineNumber: content.substring(0, m.index).split('\n').length - 1, + }); + } + } +} - return { - graph, - repoPath, - totalFileCount: totalFiles, - communityResult, - processResult, - usedWorkerPool, - }; -}; +// ============================================================================ +// ORM Query Processing — creates QUERIES edges from callers to model nodes +// ============================================================================ + +function processORMQueries( + graph: ReturnType, + queries: ExtractedORMQuery[], + isDev: boolean, +): void { + const modelNodes = new Map(); + const seenEdges = new Set(); + let edgesCreated = 0; + + for (const q of queries) { + const modelKey = `${q.orm}:${q.model}`; + let modelNodeId = modelNodes.get(modelKey); + if (!modelNodeId) { + const candidateIds = [ + generateId('Class', `${q.model}`), + generateId('Interface', `${q.model}`), + generateId('CodeElement', `${q.model}`), + ]; + const existing = candidateIds.find((id) => graph.getNode(id)); + if (existing) { + modelNodeId = existing; + } else { + modelNodeId = generateId('CodeElement', `${q.orm}:${q.model}`); + graph.addNode({ + id: modelNodeId, + label: 'CodeElement', + properties: { + name: q.model, + filePath: '', + description: `${q.orm} model/table: ${q.model}`, + }, + }); + } + modelNodes.set(modelKey, modelNodeId); + } + + const fileId = generateId('File', q.filePath); + const edgeKey = `${fileId}->${modelNodeId}:${q.method}`; + if (seenEdges.has(edgeKey)) continue; + seenEdges.add(edgeKey); + + graph.addRelationship({ + id: generateId('QUERIES', edgeKey), + sourceId: fileId, + targetId: modelNodeId, + type: 'QUERIES', + confidence: 0.9, + reason: `${q.orm}-${q.method}`, + }); + edgesCreated++; + } + + if (isDev) { + console.log( + `ORM dataflow: ${edgesCreated} QUERIES edges, ${modelNodes.size} models (${queries.length} total calls)`, + ); + } +} diff --git a/gitnexus/src/core/ingestion/resolution-context.ts b/gitnexus/src/core/ingestion/resolution-context.ts new file mode 100644 index 000000000..0371c527d --- /dev/null +++ b/gitnexus/src/core/ingestion/resolution-context.ts @@ -0,0 +1,205 @@ +/** + * Resolution Context + * + * Single implementation of tiered name resolution. Replaces the duplicated + * tier-selection logic previously split between symbol-resolver.ts and + * call-processor.ts. + * + * Resolution tiers (highest confidence first): + * 1. Same file (lookupExactFull — authoritative) + * 2a-named. Named binding chain (walkBindingChain via NamedImportMap) + * 2a. Import-scoped (lookupFuzzy filtered by ImportMap) + * 2b. Package-scoped (lookupFuzzy filtered by PackageMap) + * 3. Global (all candidates — consumers must check candidate count) + */ + +import type { SymbolTable, SymbolDefinition } from './symbol-table.js'; +import { createSymbolTable } from './symbol-table.js'; +import type { NamedImportBinding } from './import-processor.js'; +import { isFileInPackageDir } from './import-processor.js'; +import { walkBindingChain } from './named-binding-processor.js'; + +/** Resolution tier for tracking, logging, and test assertions. */ +export type ResolutionTier = 'same-file' | 'import-scoped' | 'global'; + +/** Tier-selected candidates with metadata. */ +export interface TieredCandidates { + readonly candidates: readonly SymbolDefinition[]; + readonly tier: ResolutionTier; +} + +/** Confidence scores per resolution tier. */ +export const TIER_CONFIDENCE: Record = { + 'same-file': 0.95, + 'import-scoped': 0.9, + global: 0.5, +}; + +// --- Map types --- +export type ImportMap = Map>; +export type PackageMap = Map>; +export type NamedImportMap = Map>; +/** Maps callerFile → (moduleAlias → sourceFilePath) for Python namespace imports. + * e.g. `import models` in app.py → moduleAliasMap.get('app.py')?.get('models') === 'models.py' */ +export type ModuleAliasMap = Map>; + +export interface ResolutionContext { + /** + * The only resolution API. Returns all candidates at the winning tier. + * + * Tier 3 ('global') returns ALL candidates regardless of count — + * consumers must check candidates.length and refuse ambiguous matches. + */ + 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; + /** Raw maps — used by import-processor to populate import data. */ + readonly importMap: ImportMap; + readonly packageMap: PackageMap; + readonly namedImportMap: NamedImportMap; + /** Module-alias map for Python namespace imports: callerFile → (alias → sourceFile). */ + readonly moduleAliasMap: ModuleAliasMap; + + // --- Per-file cache lifecycle --- + enableCache(filePath: string): void; + clearCache(): void; + + // --- Operational --- + getStats(): { + fileCount: number; + globalSymbolCount: number; + cacheHits: number; + cacheMisses: number; + }; + clear(): void; +} + +export const createResolutionContext = (): ResolutionContext => { + const symbols = createSymbolTable(); + const importMap: ImportMap = new Map(); + const packageMap: PackageMap = new Map(); + const namedImportMap: NamedImportMap = new Map(); + const moduleAliasMap: ModuleAliasMap = new Map(); + + // Per-file cache state + let cacheFile: string | null = null; + let cache: Map | null = null; + let cacheHits = 0; + let cacheMisses = 0; + + // --- Core resolution (single implementation of tier logic) --- + + const resolveUncached = (name: string, fromFile: string): TieredCandidates | null => { + // Tier 1: Same file — authoritative match (returns all overloads) + const localDefs = symbols.lookupExactAll(fromFile, name); + if (localDefs.length > 0) { + return { candidates: localDefs, tier: 'same-file' }; + } + + // Get all global definitions for subsequent tiers + const allDefs = symbols.lookupFuzzy(name); + + // Tier 2a-named: Check named bindings BEFORE empty-allDefs early return + // because aliased imports mean lookupFuzzy('U') returns empty but we + // can resolve via the exported name. + const chainResult = walkBindingChain(name, fromFile, symbols, namedImportMap, allDefs); + if (chainResult && chainResult.length > 0) { + return { candidates: chainResult, tier: 'import-scoped' }; + } + + if (allDefs.length === 0) return null; + + // Tier 2a: Import-scoped — definition in a file imported by fromFile + const importedFiles = importMap.get(fromFile); + if (importedFiles) { + const importedDefs = allDefs.filter((def) => importedFiles.has(def.filePath)); + if (importedDefs.length > 0) { + return { candidates: importedDefs, tier: 'import-scoped' }; + } + } + + // Tier 2b: Package-scoped — definition in a package dir imported by fromFile + const importedPackages = packageMap.get(fromFile); + if (importedPackages) { + const packageDefs = allDefs.filter((def) => { + for (const dirSuffix of importedPackages) { + if (isFileInPackageDir(def.filePath, dirSuffix)) return true; + } + return false; + }); + if (packageDefs.length > 0) { + return { candidates: packageDefs, tier: 'import-scoped' }; + } + } + + // Tier 3: Global — pass all candidates through. + // Consumers must check candidate count and refuse ambiguous matches. + return { candidates: allDefs, tier: 'global' }; + }; + + const resolve = (name: string, fromFile: string): TieredCandidates | null => { + // Check cache (only when enabled AND fromFile matches cached file) + if (cache && cacheFile === fromFile) { + if (cache.has(name)) { + cacheHits++; + return cache.get(name)!; + } + cacheMisses++; + } + + const result = resolveUncached(name, fromFile); + + // Store in cache if active and file matches + if (cache && cacheFile === fromFile) { + cache.set(name, result); + } + + return result; + }; + + // --- Cache lifecycle --- + + const enableCache = (filePath: string): void => { + cacheFile = filePath; + if (!cache) cache = new Map(); + else cache.clear(); + }; + + const clearCache = (): void => { + cacheFile = null; + // Reuse the Map instance — just clear entries to reduce GC pressure at scale. + cache?.clear(); + }; + + const getStats = () => ({ + ...symbols.getStats(), + cacheHits, + cacheMisses, + }); + + const clear = (): void => { + symbols.clear(); + importMap.clear(); + packageMap.clear(); + namedImportMap.clear(); + moduleAliasMap.clear(); + clearCache(); + cacheHits = 0; + cacheMisses = 0; + }; + + return { + resolve, + symbols, + importMap, + packageMap, + namedImportMap, + moduleAliasMap, + enableCache, + clearCache, + getStats, + clear, + }; +}; diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts new file mode 100644 index 000000000..2135c7db4 --- /dev/null +++ b/gitnexus/src/core/ingestion/symbol-table.ts @@ -0,0 +1,345 @@ +import type { NodeLabel } from 'gitnexus-shared'; + +export const CLASS_TYPES = new Set(['Class', 'Struct', 'Interface', 'Enum', 'Record']); + +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[]; + + /** + * Low Confidence: Look for a symbol anywhere in the project + * Used when imports are missing or for framework magic + */ + lookupFuzzy: (name: string) => SymbolDefinition[]; + + /** + * Low Confidence: Look for callable symbols (Function/Method/Constructor) by name. + * Faster than `lookupFuzzy` + filter — backed by a lazy callable-only index. + * Used by ReturnTypeLookup to resolve callee → return type. + */ + lookupFuzzyCallable: (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. + */ + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => 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 lookupFuzzy calls. + */ + 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[]; + + /** + * Debugging: See how many symbols are tracked + */ + getStats: () => { fileCount: number; globalSymbolCount: 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. Global Reverse Index (The "Backup") + // Structure: SymbolName -> [List of Definitions] + const globalIndex = new Map(); + + // 3. Lazy Callable Index — populated on first lookupFuzzyCallable call. + // Structure: SymbolName -> [Callable Definitions] + // Only Function, Method, Constructor symbols are indexed. + let callableIndex: Map | null = null; + + // 4. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName". + // Only Property symbols with ownerId and declaredType are indexed. + const fieldByOwner = new Map(); + + // 5. Eagerly-populated Method Index — keyed by "ownerNodeId\0methodName". + // Method symbols with ownerId are indexed. Supports overloads (array values). + const methodByOwner = new Map(); + + // 6. 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(); + + const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']); + + 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 globalIndex 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 globalIndex + return; + } + + // C. Add to Global Index (same object reference) + if (!globalIndex.has(name)) { + globalIndex.set(name, []); + } + globalIndex.get(name)!.push(def); + + // C2. Methods with ownerId go to methodByOwner index (in addition to globalIndex). + if (type === 'Method' && metadata?.ownerId) { + const key = `${metadata.ownerId}\0${name}`; + const existing = methodByOwner.get(key); + if (existing) { + existing.push(def); + } else { + methodByOwner.set(key, [def]); + } + } + + // C3. Class-like types go to classByName index (in addition to globalIndex). + 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]); + } + } + + // D. Invalidate the lazy callable index only when adding callable types + if (CALLABLE_TYPES.has(type)) { + callableIndex = null; + } + }; + + 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 lookupFuzzy = (name: string): SymbolDefinition[] => { + return globalIndex.get(name) || []; + }; + + const lookupFuzzyCallable = (name: string): SymbolDefinition[] => { + if (!callableIndex) { + // Build the callable index lazily on first use + callableIndex = new Map(); + for (const [symName, defs] of globalIndex) { + const callables = defs.filter((d) => CALLABLE_TYPES.has(d.type)); + if (callables.length > 0) callableIndex.set(symName, callables); + } + } + return callableIndex.get(name) ?? []; + }; + + const lookupFieldByOwner = ( + ownerNodeId: string, + fieldName: string, + ): SymbolDefinition | undefined => { + return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`); + }; + + const lookupMethodByOwner = ( + ownerNodeId: string, + methodName: string, + ): SymbolDefinition | undefined => { + const defs = methodByOwner.get(`${ownerNodeId}\0${methodName}`); + if (!defs || defs.length === 0) return undefined; + if (defs.length === 1) return defs[0]; + // Multiple overloads: return first if all share the same defined returnType (safe for chain resolution). + // Return undefined if return types differ or are absent (truly ambiguous — can't determine which overload). + const firstReturnType = defs[0].returnType; + if (firstReturnType === undefined) return undefined; + for (let i = 1; i < defs.length; i++) { + if (defs[i].returnType !== firstReturnType) return undefined; + } + return defs[0]; + }; + + const lookupClassByName = (name: string): SymbolDefinition[] => { + return classByName.get(name) ?? []; + }; + + const lookupClassByQualifiedName = (qualifiedName: string): SymbolDefinition[] => { + return classByQualifiedName.get(qualifiedName) ?? []; + }; + + const getStats = () => ({ + fileCount: fileIndex.size, + globalSymbolCount: globalIndex.size, + }); + + const clear = () => { + fileIndex.clear(); + globalIndex.clear(); + callableIndex = null; + fieldByOwner.clear(); + methodByOwner.clear(); + classByName.clear(); + classByQualifiedName.clear(); + }; + + return { + add, + lookupExact, + lookupExactFull, + lookupExactAll, + lookupFuzzy, + lookupFuzzyCallable, + lookupFieldByOwner, + lookupMethodByOwner, + lookupClassByName, + lookupClassByQualifiedName, + getStats, + clear, + }; +}; diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index 28fc8e08b..2094b6dfa 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -1,29 +1,17 @@ -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'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { createSymbolTable, type SymbolTable } from '../../src/core/ingestion/symbol-table.js'; describe('SymbolTable', () => { - // SM-23: 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; + let table: SymbolTable; beforeEach(() => { - model = createSemanticModel(); - table = model.symbols; + table = createSymbolTable(); }); describe('add', () => { it('registers a symbol in the table', () => { table.add('src/index.ts', 'main', 'func:main', 'Function'); + expect(table.getStats().globalSymbolCount).toBe(1); expect(table.getStats().fileCount).toBe(1); }); @@ -31,12 +19,15 @@ describe('SymbolTable', () => { table.add('src/index.ts', 'main', 'func:main', 'Function'); table.add('src/index.ts', 'helper', 'func:helper', 'Function'); expect(table.getStats().fileCount).toBe(1); + expect(table.getStats().globalSymbolCount).toBe(2); }); it('handles same name in different files', () => { table.add('src/a.ts', 'init', 'func:a:init', 'Function'); table.add('src/b.ts', 'init', 'func:b:init', 'Function'); expect(table.getStats().fileCount).toBe(2); + // Global index groups by name, so 'init' has one entry with two definitions + expect(table.getStats().globalSymbolCount).toBe(1); }); it('allows duplicate adds for same file and name (overloads preserved)', () => { @@ -46,6 +37,8 @@ describe('SymbolTable', () => { expect(table.lookupExact('src/a.ts', 'foo')).toBe('func:foo:1'); // lookupExactAll returns all overloads expect(table.lookupExactAll('src/a.ts', 'foo')).toHaveLength(2); + // Global index appends + expect(table.lookupFuzzy('foo')).toHaveLength(2); }); }); @@ -70,11 +63,32 @@ describe('SymbolTable', () => { }); }); + describe('lookupFuzzy', () => { + it('finds all definitions of a symbol across files', () => { + table.add('src/a.ts', 'render', 'func:a:render', 'Function'); + table.add('src/b.ts', 'render', 'func:b:render', 'Method'); + const results = table.lookupFuzzy('render'); + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ + nodeId: 'func:a:render', + filePath: 'src/a.ts', + type: 'Function', + }); + expect(results[1]).toEqual({ nodeId: 'func:b:render', filePath: 'src/b.ts', type: 'Method' }); + }); + + it('returns empty array for unknown symbol', () => { + expect(table.lookupFuzzy('nonexistent')).toEqual([]); + }); + + it('returns empty array for empty table', () => { + expect(table.lookupFuzzy('anything')).toEqual([]); + }); + }); + describe('getStats', () => { it('returns zero counts for empty table', () => { - expect(table.getStats()).toEqual({ - fileCount: 0, - }); + expect(table.getStats()).toEqual({ fileCount: 0, globalSymbolCount: 0 }); }); it('tracks unique file count correctly', () => { @@ -83,6 +97,14 @@ describe('SymbolTable', () => { table.add('src/b.ts', 'baz', 'func:baz', 'Function'); expect(table.getStats().fileCount).toBe(2); }); + + it('tracks unique global symbol names', () => { + table.add('src/a.ts', 'foo', 'func:a:foo', 'Function'); + table.add('src/b.ts', 'foo', 'func:b:foo', 'Function'); + table.add('src/a.ts', 'bar', 'func:a:bar', 'Function'); + // 'foo' and 'bar' are 2 unique global names + expect(table.getStats().globalSymbolCount).toBe(2); + }); }); describe('returnType metadata', () => { @@ -93,13 +115,13 @@ describe('SymbolTable', () => { expect(def!.returnType).toBe('User'); }); - it('returnType is available via lookupExactFull', () => { + it('returnType is available via lookupFuzzy', () => { table.add('src/utils.ts', 'getUser', 'func:getUser', 'Function', { returnType: 'Promise', }); - const result = table.lookupExactFull('src/utils.ts', 'getUser'); - expect(result).toBeDefined(); - expect(result!.returnType).toBe('Promise'); + const results = table.lookupFuzzy('getUser'); + expect(results).toHaveLength(1); + expect(results[0].returnType).toBe('Promise'); }); it('omits returnType when not provided', () => { @@ -142,29 +164,29 @@ describe('SymbolTable', () => { }); }); - describe('Property exclusion from callable index', () => { - it('Property with ownerId is NOT in callable index', () => { + describe('Property exclusion from globalIndex', () => { + it('Property with ownerId is NOT added to globalIndex', () => { table.add('src/models.ts', 'name', 'prop:name', 'Property', { declaredType: 'string', ownerId: 'class:User', }); - // Should not appear in callable lookup - expect(table.lookupCallableByName('name')).toEqual([]); + // Should not appear in fuzzy lookup + expect(table.lookupFuzzy('name')).toEqual([]); // But should still be in fileIndex expect(table.lookupExact('src/models.ts', 'name')).toBe('prop:name'); }); - it('Property without ownerId is NOT in callable index', () => { + it('Property without ownerId IS added to globalIndex', () => { table.add('src/models.ts', 'name', 'prop:name', 'Property'); - expect(table.lookupCallableByName('name')).toEqual([]); + expect(table.lookupFuzzy('name')).toHaveLength(1); }); - it('Property without declaredType is still added to fieldByOwner index only', () => { + it('Property without declaredType is still added to fieldByOwner index only (not globalIndex)', () => { table.add('src/models.ts', 'name', 'prop:name', 'Property', { ownerId: 'class:User' }); // 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(model.fields.lookupFieldByOwner('class:User', 'name')).toEqual({ + // in dynamically-typed languages like Ruby/JS), but excluded from globalIndex + expect(table.lookupFuzzy('name')).toEqual([]); + expect(table.lookupFieldByOwner('class:User', 'name')).toEqual({ nodeId: 'prop:name', filePath: 'src/models.ts', type: 'Property', @@ -172,90 +194,40 @@ describe('SymbolTable', () => { }); }); - 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. + it('non-Property types are always added to globalIndex', () => { table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:User' }); - expect(table.lookupCallableByName('save')).toHaveLength(0); - expect(model.methods.lookupMethodByName('save')).toHaveLength(1); + expect(table.lookupFuzzy('save')).toHaveLength(1); }); }); - describe('conditional callable index behaviour', () => { - it('adding a Function makes it available in callable index', () => { + describe('conditional callableIndex invalidation', () => { + it('adding a Function invalidates callableIndex', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function', { returnType: 'void' }); - expect(table.lookupCallableByName('foo')).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); + // First call builds the index + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + // Add another callable — should invalidate and rebuild + table.add('src/a.ts', 'bar', 'func:bar', 'Method'); + expect(table.lookupFuzzyCallable('bar')).toHaveLength(1); }); - it('adding a Property does NOT add it to callable index', () => { + it('adding a Property does NOT invalidate callableIndex', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - expect(table.lookupCallableByName('foo')).toHaveLength(1); - // Add a Property — callable index should still only contain foo + // Build callable index + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + // Add a Property — callable index should still be valid (foo still found) table.add('src/models.ts', 'name', 'prop:name', 'Property', { declaredType: 'string', ownerId: 'class:User', }); - expect(table.lookupCallableByName('foo')).toHaveLength(1); + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); }); - it('adding a Class does NOT add it to callable index', () => { + it('adding a Class does NOT invalidate callableIndex', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - expect(table.lookupCallableByName('foo')).toHaveLength(1); + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); table.add('src/models.ts', 'User', 'class:User', 'Class'); - // Class is not callable, should not appear - expect(table.lookupCallableByName('foo')).toHaveLength(1); - }); - - it('Macro (C/C++) is indexed in callable index', () => { - table.add('src/macros.h', 'ASSERT', 'macro:ASSERT', 'Macro'); - expect(table.lookupCallableByName('ASSERT')).toHaveLength(1); - expect(table.lookupCallableByName('ASSERT')[0].type).toBe('Macro'); - }); - - it('Delegate (C#) is indexed in callable index', () => { - table.add('src/Events.cs', 'OnClick', 'delegate:OnClick', 'Delegate'); - 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); + // Class is not callable, should not trigger rebuild + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); }); }); @@ -265,7 +237,7 @@ describe('SymbolTable', () => { declaredType: 'Address', ownerId: 'class:User', }); - const def = model.fields.lookupFieldByOwner('class:User', 'address'); + const def = table.lookupFieldByOwner('class:User', 'address'); expect(def).toBeDefined(); expect(def!.declaredType).toBe('Address'); expect(def!.nodeId).toBe('prop:address'); @@ -276,7 +248,7 @@ describe('SymbolTable', () => { declaredType: 'Address', ownerId: 'class:User', }); - expect(model.fields.lookupFieldByOwner('class:Unknown', 'address')).toBeUndefined(); + expect(table.lookupFieldByOwner('class:Unknown', 'address')).toBeUndefined(); }); it('returns undefined for unknown field name', () => { @@ -284,16 +256,16 @@ describe('SymbolTable', () => { declaredType: 'Address', ownerId: 'class:User', }); - expect(model.fields.lookupFieldByOwner('class:User', 'email')).toBeUndefined(); + expect(table.lookupFieldByOwner('class:User', 'email')).toBeUndefined(); }); it('returns undefined for empty table', () => { - expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(table.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(model.fields.lookupFieldByOwner('class:User', 'name')).toEqual({ + expect(table.lookupFieldByOwner('class:User', 'name')).toEqual({ nodeId: 'prop:name', filePath: 'src/models.ts', type: 'Property', @@ -310,8 +282,8 @@ describe('SymbolTable', () => { declaredType: 'RepoName', ownerId: 'class:Repo', }); - expect(model.fields.lookupFieldByOwner('class:User', 'name')!.declaredType).toBe('string'); - expect(model.fields.lookupFieldByOwner('class:Repo', 'name')!.declaredType).toBe('RepoName'); + expect(table.lookupFieldByOwner('class:User', 'name')!.declaredType).toBe('string'); + expect(table.lookupFieldByOwner('class:Repo', 'name')!.declaredType).toBe('RepoName'); }); }); @@ -321,7 +293,7 @@ describe('SymbolTable', () => { returnType: 'Address', ownerId: 'class:User', }); - const def = model.methods.lookupMethodByOwner('class:User', 'getAddress'); + const def = table.lookupMethodByOwner('class:User', 'getAddress'); expect(def).toBeDefined(); expect(def!.returnType).toBe('Address'); expect(def!.nodeId).toBe('method:getAddress'); @@ -336,10 +308,8 @@ describe('SymbolTable', () => { returnType: 'String', ownerId: 'class:User', }); - expect(model.methods.lookupMethodByOwner('class:User', 'getAddress')!.returnType).toBe( - 'Address', - ); - expect(model.methods.lookupMethodByOwner('class:User', 'getName')!.returnType).toBe('String'); + expect(table.lookupMethodByOwner('class:User', 'getAddress')!.returnType).toBe('Address'); + expect(table.lookupMethodByOwner('class:User', 'getName')!.returnType).toBe('String'); }); it('distinguishes methods by owner', () => { @@ -351,10 +321,8 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:Address', }); - expect(model.methods.lookupMethodByOwner('class:User', 'save')!.nodeId).toBe( - 'method:user:save', - ); - expect(model.methods.lookupMethodByOwner('class:Address', 'save')!.nodeId).toBe( + expect(table.lookupMethodByOwner('class:User', 'save')!.nodeId).toBe('method:user:save'); + expect(table.lookupMethodByOwner('class:Address', 'save')!.nodeId).toBe( 'method:address:save', ); }); @@ -364,7 +332,7 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:User', }); - expect(model.methods.lookupMethodByOwner('class:Unknown', 'save')).toBeUndefined(); + expect(table.lookupMethodByOwner('class:Unknown', 'save')).toBeUndefined(); }); it('returns undefined for unknown method name', () => { @@ -372,24 +340,18 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:User', }); - expect(model.methods.lookupMethodByOwner('class:User', 'delete')).toBeUndefined(); + expect(table.lookupMethodByOwner('class:User', 'delete')).toBeUndefined(); }); it('returns undefined for empty table', () => { - expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); }); - 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. + it('does NOT index Method without ownerId', () => { table.add('src/utils.ts', 'helper', 'method:helper', 'Method'); - 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'); + expect(table.lookupMethodByOwner('', 'helper')).toBeUndefined(); + // But it should still be in lookupFuzzy + expect(table.lookupFuzzy('helper')).toHaveLength(1); }); it('returns first match for overloads with same returnType (unambiguous)', () => { @@ -403,7 +365,7 @@ describe('SymbolTable', () => { returnType: 'User', ownerId: 'class:UserRepo', }); - const def = model.methods.lookupMethodByOwner('class:UserRepo', 'find'); + const def = table.lookupMethodByOwner('class:UserRepo', 'find'); expect(def).toBeDefined(); expect(def!.nodeId).toBe('method:find:1'); expect(def!.returnType).toBe('User'); @@ -418,25 +380,17 @@ describe('SymbolTable', () => { parameterCount: 2, ownerId: 'class:Handler', }); - expect(model.methods.lookupMethodByOwner('class:Handler', 'process')).toBeUndefined(); + expect(table.lookupMethodByOwner('class:Handler', 'process')).toBeUndefined(); }); - it('indexes Constructor in methodByOwner', () => { + it('does NOT index Constructor in methodByOwner', () => { table.add('src/models.ts', 'User', 'ctor:User', 'Constructor', { parameterCount: 0, ownerId: 'class:User', }); - expect(model.methods.lookupMethodByOwner('class:User', 'User')).toEqual({ - nodeId: 'ctor:User', - filePath: 'src/models.ts', - type: 'Constructor', - parameterCount: 0, - ownerId: 'class:User', - }); - // 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); + expect(table.lookupMethodByOwner('class:User', 'User')).toBeUndefined(); + // But it should be in lookupFuzzyCallable + expect(table.lookupFuzzyCallable('User')).toHaveLength(1); }); it('returns undefined for overloads with different returnTypes (ambiguous)', () => { @@ -450,17 +404,17 @@ describe('SymbolTable', () => { returnType: 'Number', ownerId: 'class:Converter', }); - expect(model.methods.lookupMethodByOwner('class:Converter', 'convert')).toBeUndefined(); + expect(table.lookupMethodByOwner('class:Converter', 'convert')).toBeUndefined(); }); - it('post-A4: Method with ownerId is reachable via methodsByName, not callableByName', () => { + it('Method with ownerId is still available via lookupFuzzy and lookupFuzzyCallable', () => { table.add('src/models.ts', 'save', 'method:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); - expect(table.lookupCallableByName('save')).toHaveLength(0); - expect(model.methods.lookupMethodByName('save')).toHaveLength(1); - expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeDefined(); + // Methods stay in globalIndex (unlike Properties) + expect(table.lookupFuzzy('save')).toHaveLength(1); + expect(table.lookupFuzzyCallable('save')).toHaveLength(1); }); it('after clear(), lookupMethodByOwner returns undefined', () => { @@ -468,47 +422,43 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:User', }); - expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeDefined(); - model.clear(); - expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(table.lookupMethodByOwner('class:User', 'save')).toBeDefined(); + table.clear(); + expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); }); }); - describe('lookupCallableByName', () => { - 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. + describe('lookupFuzzyCallable', () => { + it('returns only callable types (Function, Method, Constructor)', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - 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', 'bar', 'method:bar', 'Method'); + table.add('src/a.ts', 'Baz', 'ctor:Baz', 'Constructor'); 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')).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([]); + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + expect(table.lookupFuzzyCallable('bar')).toHaveLength(1); + expect(table.lookupFuzzyCallable('Baz')).toHaveLength(1); + expect(table.lookupFuzzyCallable('User')).toEqual([]); + expect(table.lookupFuzzyCallable('IUser')).toEqual([]); }); it('returns empty array for unknown name', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - expect(table.lookupCallableByName('unknown')).toEqual([]); + expect(table.lookupFuzzyCallable('unknown')).toEqual([]); }); - it('includes newly added callable', () => { + it('rebuilds index after adding new callable', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - expect(table.lookupCallableByName('foo')).toHaveLength(1); - expect(table.lookupCallableByName('bar')).toEqual([]); + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + expect(table.lookupFuzzyCallable('bar')).toEqual([]); table.add('src/a.ts', 'bar', 'func:bar', 'Function'); - expect(table.lookupCallableByName('bar')).toHaveLength(1); + expect(table.lookupFuzzyCallable('bar')).toHaveLength(1); }); it('filters non-callable types from mixed name entries', () => { table.add('src/a.ts', 'save', 'func:save', 'Function'); table.add('src/b.ts', 'save', 'class:save', 'Class'); - const callables = table.lookupCallableByName('save'); + const callables = table.lookupFuzzyCallable('save'); expect(callables).toHaveLength(1); expect(callables[0].type).toBe('Function'); }); @@ -527,37 +477,34 @@ describe('SymbolTable', () => { ownerId: 'class:User', }); table.add('src/models.ts', 'User', 'class:User', 'Class'); - model.clear(); - expect(table.getStats()).toEqual({ - fileCount: 0, - }); + table.clear(); + expect(table.getStats()).toEqual({ fileCount: 0, globalSymbolCount: 0 }); expect(table.lookupExact('src/a.ts', 'foo')).toBeUndefined(); - expect(model.fields.lookupFieldByOwner('class:User', 'address')).toBeUndefined(); - expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); - expect(table.lookupCallableByName('foo')).toEqual([]); - expect(model.types.lookupClassByName('User')).toEqual([]); + expect(table.lookupFuzzy('foo')).toEqual([]); + expect(table.lookupFieldByOwner('class:User', 'address')).toBeUndefined(); + expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(table.lookupFuzzyCallable('foo')).toEqual([]); + expect(table.lookupClassByName('User')).toEqual([]); }); it('allows re-adding after clear', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - model.clear(); + table.clear(); table.add('src/b.ts', 'bar', 'func:bar', 'Function'); - expect(table.getStats()).toEqual({ - fileCount: 1, - }); + expect(table.getStats()).toEqual({ fileCount: 1, globalSymbolCount: 1 }); }); - it('resets callable index so first lookup after clear rebuilds from scratch', () => { + it('resets callableIndex so first lookup after clear rebuilds from scratch', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - // Verify callable is found - expect(table.lookupCallableByName('foo')).toHaveLength(1); - model.clear(); + // Populate the lazy callable index + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + table.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 + expect(table.lookupFuzzyCallable('foo')).toEqual([]); + // Re-adding and looking up rebuilds successfully table.add('src/a.ts', 'foo', 'func:foo2', 'Function'); - expect(table.lookupCallableByName('foo')).toHaveLength(1); - expect(table.lookupCallableByName('foo')[0].nodeId).toBe('func:foo2'); + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + expect(table.lookupFuzzyCallable('foo')[0].nodeId).toBe('func:foo2'); }); }); @@ -572,7 +519,7 @@ describe('SymbolTable', () => { expect(def!.ownerId).toBeUndefined(); }); - it('stores only ownerId on a Method — reachable via methodsByName (post-A4)', () => { + it('stores only ownerId on a Method (non-Property) — still added to globalIndex', () => { table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:Repo' }); const def = table.lookupExactFull('src/models.ts', 'save'); expect(def).toBeDefined(); @@ -580,14 +527,12 @@ describe('SymbolTable', () => { expect(def!.parameterCount).toBeUndefined(); expect(def!.returnType).toBeUndefined(); expect(def!.declaredType).toBeUndefined(); - // 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); + // Non-Property with ownerId must still appear in globalIndex + expect(table.lookupFuzzy('save')).toHaveLength(1); }); - it('stores declaredType alone (no ownerId) — symbol in file index', () => { - // A Variable/Property without an owner should still be accessible via file index + it('stores declaredType alone (no ownerId) — symbol goes to globalIndex', () => { + // A Variable/Property without an owner should still be globally visible table.add('src/config.ts', 'DEFAULT_TIMEOUT', 'var:DEFAULT_TIMEOUT', 'Variable', { declaredType: 'number', }); @@ -595,6 +540,9 @@ describe('SymbolTable', () => { expect(def).toBeDefined(); expect(def!.declaredType).toBe('number'); expect(def!.ownerId).toBeUndefined(); + // No ownerId → not a Property exclusion path → must be in globalIndex + expect(table.lookupFuzzy('DEFAULT_TIMEOUT')).toHaveLength(1); + expect(table.lookupFuzzy('DEFAULT_TIMEOUT')[0].declaredType).toBe('number'); }); it('stores all four optional metadata fields simultaneously on a Method', () => { @@ -631,44 +579,46 @@ describe('SymbolTable', () => { }); }); - describe('lookupCallableByName — eager index behavior', () => { + describe('lookupFuzzyCallable — lazy index behaviour', () => { it('returns empty array when table has no callables', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); table.add('src/models.ts', 'IUser', 'iface:IUser', 'Interface'); - expect(table.lookupCallableByName('User')).toEqual([]); - expect(table.lookupCallableByName('IUser')).toEqual([]); + expect(table.lookupFuzzyCallable('User')).toEqual([]); + expect(table.lookupFuzzyCallable('IUser')).toEqual([]); }); - it('returns consistent result on repeated calls', () => { + it('uses cached index on second call without adding new symbols', () => { table.add('src/a.ts', 'fetch', 'func:fetch', 'Function', { returnType: 'Response' }); - const first = table.lookupCallableByName('fetch'); + // First call — builds the lazy index + const first = table.lookupFuzzyCallable('fetch'); expect(first).toHaveLength(1); - const second = table.lookupCallableByName('fetch'); + // Second call — must return equivalent result from cache + const second = table.lookupFuzzyCallable('fetch'); expect(second).toHaveLength(1); expect(second[0].nodeId).toBe('func:fetch'); + // Both calls return the same array reference (same cache entry) + expect(first).toBe(second); }); - it('post-A4: newly added Method is reachable via methodsByName, not callableByName', () => { + it('invalidated cache is rebuilt correctly after adding a Method', () => { table.add('src/a.ts', 'alpha', 'func:alpha', 'Function'); - expect(table.lookupCallableByName('alpha')).toHaveLength(1); - expect(table.lookupCallableByName('beta')).toEqual([]); - 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'); + // Warm the cache + expect(table.lookupFuzzyCallable('alpha')).toHaveLength(1); + expect(table.lookupFuzzyCallable('beta')).toEqual([]); + // Add a Method — must invalidate cache + table.add('src/a.ts', 'beta', 'method:beta', 'Method'); + // Rebuilt cache must now include beta + const result = table.lookupFuzzyCallable('beta'); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('Method'); }); - it('post-A4: newly added Constructor is reachable via methodsByName, not callableByName', () => { + it('invalidated cache is rebuilt correctly after adding a Constructor', () => { table.add('src/a.ts', 'existing', 'func:existing', 'Function'); - expect(table.lookupCallableByName('existing')).toHaveLength(1); - 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'); + expect(table.lookupFuzzyCallable('existing')).toHaveLength(1); + table.add('src/models.ts', 'MyClass', 'ctor:MyClass', 'Constructor'); + expect(table.lookupFuzzyCallable('MyClass')).toHaveLength(1); + expect(table.lookupFuzzyCallable('MyClass')[0].type).toBe('Constructor'); }); }); @@ -732,9 +682,9 @@ describe('SymbolTable', () => { declaredType: 'Date', ownerId: 'class:User', }); - 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'); + 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'); }); it('returns the full SymbolDefinition (nodeId + filePath + type) not just declaredType', () => { @@ -742,7 +692,7 @@ describe('SymbolTable', () => { declaredType: 'number', ownerId: 'class:Player', }); - const def = model.fields.lookupFieldByOwner('class:Player', 'score'); + const def = table.lookupFieldByOwner('class:Player', 'score'); expect(def).toBeDefined(); expect(def!.nodeId).toBe('prop:score'); expect(def!.filePath).toBe('src/models.ts'); @@ -759,17 +709,17 @@ describe('SymbolTable', () => { declaredType: 'UUID', ownerId: 'class:B', }); - expect(model.fields.lookupFieldByOwner('class:A', 'id')!.nodeId).toBe('prop:a:id'); - expect(model.fields.lookupFieldByOwner('class:B', 'id')!.nodeId).toBe('prop:b:id'); + expect(table.lookupFieldByOwner('class:A', 'id')!.nodeId).toBe('prop:a:id'); + expect(table.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(model.fields.lookupFieldByOwner('class:A\0id', '')).toBeUndefined(); + expect(table.lookupFieldByOwner('class:A\0id', '')).toBeUndefined(); }); }); describe('lookupClassByName', () => { it('returns Class definitions by name', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - const results = model.types.lookupClassByName('User'); + const results = table.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0]).toEqual({ nodeId: 'class:User', @@ -781,28 +731,28 @@ describe('SymbolTable', () => { it('returns Struct definitions by name', () => { table.add('src/models.rs', 'Point', 'struct:Point', 'Struct'); - const results = model.types.lookupClassByName('Point'); + const results = table.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 = model.types.lookupClassByName('Serializable'); + const results = table.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 = model.types.lookupClassByName('Color'); + const results = table.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 = model.types.lookupClassByName('Config'); + const results = table.lookupClassByName('Config'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Record'); }); @@ -810,7 +760,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 = model.types.lookupClassByName('User'); + const results = table.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Class'); expect(results[0].nodeId).toBe('class:User'); @@ -821,33 +771,22 @@ 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(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)', () => { - // Traits are class-like for heritage resolution — they contribute - // methods to the using/implementing type's hierarchy. buildHeritageMap - // relies on this to resolve `use Trait;` edges in PHP, `impl Trait for - // 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 = model.types.lookupClassByName('Writer'); - expect(results).toHaveLength(1); - expect(results[0].nodeId).toBe('trait:Writer'); + expect(table.lookupClassByName('Foo')).toEqual([]); + expect(table.lookupClassByName('Bar')).toEqual([]); + expect(table.lookupClassByName('Baz')).toEqual([]); + expect(table.lookupClassByName('Qux')).toEqual([]); }); it('does NOT include other type-like labels outside the allowed class set', () => { + table.add('src/a.rs', 'User', 'trait:User', 'Trait'); table.add('src/a.ts', 'User', 'type:User', 'Type'); - expect(model.types.lookupClassByName('User')).toEqual([]); + expect(table.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 = model.types.lookupClassByName('User'); + const results = table.lookupClassByName('User'); expect(results).toHaveLength(2); expect(results[0].filePath).toBe('src/models/user.ts'); expect(results[1].filePath).toBe('src/dto/user.ts'); @@ -855,25 +794,25 @@ describe('SymbolTable', () => { it('returns empty array for unknown name', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - expect(model.types.lookupClassByName('NonExistent')).toEqual([]); + expect(table.lookupClassByName('NonExistent')).toEqual([]); }); it('returns empty array for empty table', () => { - expect(model.types.lookupClassByName('User')).toEqual([]); + expect(table.lookupClassByName('User')).toEqual([]); }); it('after clear(), returns empty array', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - expect(model.types.lookupClassByName('User')).toHaveLength(1); - model.clear(); - expect(model.types.lookupClassByName('User')).toEqual([]); + expect(table.lookupClassByName('User')).toHaveLength(1); + table.clear(); + expect(table.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 = model.types.lookupClassByName('Comparable'); + const results = table.lookupClassByName('Comparable'); expect(results).toHaveLength(2); expect(results.map((r) => r.type)).toEqual(['Class', 'Interface']); }); @@ -883,22 +822,23 @@ describe('SymbolTable', () => { returnType: 'User', ownerId: 'module:models', }); - const results = model.types.lookupClassByName('User'); + const results = table.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0].ownerId).toBe('module:models'); }); - it('class-like symbols are available via lookupClassByName', () => { + it('class-like symbols are still available via lookupFuzzy', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - // classByName is the dedicated index for class-like lookups - expect(model.types.lookupClassByName('User')).toHaveLength(1); + // classByName is an additional index, not a replacement for globalIndex + expect(table.lookupFuzzy('User')).toHaveLength(1); + expect(table.lookupClassByName('User')).toHaveLength(1); }); it('allows re-adding after clear and returns correct results', () => { table.add('src/models.ts', 'User', 'class:User:v1', 'Class'); - model.clear(); + table.clear(); table.add('src/models.ts', 'User', 'class:User:v2', 'Class'); - const results = model.types.lookupClassByName('User'); + const results = table.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0].nodeId).toBe('class:User:v2'); }); @@ -913,8 +853,8 @@ describe('SymbolTable', () => { qualifiedName: 'Data.User', }); - expect(model.types.lookupClassByName('User')).toHaveLength(2); - expect(model.types.lookupClassByQualifiedName('Services.User')).toEqual([ + expect(table.lookupClassByName('User')).toHaveLength(2); + expect(table.lookupClassByQualifiedName('Services.User')).toEqual([ { nodeId: 'class:services:User', filePath: 'src/services/user.cs', @@ -922,14 +862,14 @@ describe('SymbolTable', () => { qualifiedName: 'Services.User', }, ]); - const dataUserMatches = model.types.lookupClassByQualifiedName('Data.User'); + const dataUserMatches = table.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(model.types.lookupClassByQualifiedName('User')).toEqual([ + expect(table.lookupClassByQualifiedName('User')).toEqual([ { nodeId: 'class:User', filePath: 'src/models.ts', @@ -943,258 +883,16 @@ describe('SymbolTable', () => { table.add('src/utils.ts', 'User', 'func:User', 'Function', { qualifiedName: 'Services.User', }); - expect(model.types.lookupClassByQualifiedName('Services.User')).toEqual([]); + expect(table.lookupClassByQualifiedName('Services.User')).toEqual([]); }); it('after clear(), returns empty array', () => { table.add('src/services/user.cs', 'User', 'class:User', 'Class', { qualifiedName: 'Services.User', }); - 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(); + expect(table.lookupClassByQualifiedName('Services.User')).toHaveLength(1); + table.clear(); + expect(table.lookupClassByQualifiedName('Services.User')).toEqual([]); }); }); }); @@ -1203,13 +901,14 @@ describe('SymbolTable', () => { // lookupMethodByOwnerWithMRO — MRO-aware method resolution via HeritageMap // --------------------------------------------------------------------------- -import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js'; -import { lookupMethodByOwnerWithMRO } from '../../src/core/ingestion/model/index.js'; +import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js'; +import { lookupMethodByOwnerWithMRO } from '../../src/core/ingestion/call-processor.js'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/model/resolution-context.js'; -import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js'; +} from '../../src/core/ingestion/resolution-context.js'; +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js'; describe('lookupMethodByOwnerWithMRO', () => { let ctx: ResolutionContext; @@ -1219,18 +918,12 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('child.parentMethod() resolves to Parent#parentMethod via MRO walk', () => { - 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', - }, - ); + 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', + }); const heritage: ExtractedHeritage[] = [ { filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' }, @@ -1241,8 +934,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'parentMethod', map, - ctx.model, - 'implements-split', + ctx.symbols, + SupportedLanguages.Java, ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Parent:parentMethod'); @@ -1250,13 +943,13 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('child override returns child version (direct hit, no walk)', () => { - 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', { + 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', { returnType: 'void', ownerId: 'class:Parent', }); - ctx.model.symbols.add('src/child.java', 'save', 'method:Child:save', 'Method', { + ctx.symbols.add('src/child.java', 'save', 'method:Child:save', 'Method', { returnType: 'void', ownerId: 'class:Child', }); @@ -1270,18 +963,18 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'save', map, - ctx.model, - 'implements-split', + ctx.symbols, + SupportedLanguages.Java, ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Child:save'); }); it('3-level inheritance: grandchild → child → parent, method on parent found', () => { - 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', { + 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', { returnType: 'Greeting', ownerId: 'class:A', }); @@ -1296,8 +989,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:C', 'greet', map, - ctx.model, - 'implements-split', + ctx.symbols, + SupportedLanguages.Java, ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:A:greet'); @@ -1305,15 +998,15 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('diamond pattern: first-wins strategy returns first ancestor match in BFS order', () => { - 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', { + 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', { returnType: 'String', ownerId: 'class:B', }); - ctx.model.symbols.add('src/c.ts', 'foo', 'method:C:foo', 'Method', { + ctx.symbols.add('src/c.ts', 'foo', 'method:C:foo', 'Method', { returnType: 'String', ownerId: 'class:C', }); @@ -1327,21 +1020,27 @@ 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.model, 'first-wins'); + const result = lookupMethodByOwnerWithMRO( + 'class:D', + 'foo', + map, + ctx.symbols, + SupportedLanguages.TypeScript, + ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:B:foo'); }); it('diamond pattern: c3 strategy uses C3 linearization order', () => { - 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', { + 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', { returnType: 'str', ownerId: 'class:B', }); - ctx.model.symbols.add('src/c.py', 'foo', 'method:C:foo', 'Method', { + ctx.symbols.add('src/c.py', 'foo', 'method:C:foo', 'Method', { returnType: 'str', ownerId: 'class:C', }); @@ -1355,41 +1054,22 @@ 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.model, 'c3'); + const result = lookupMethodByOwnerWithMRO( + 'class:D', + 'foo', + map, + ctx.symbols, + SupportedLanguages.Python, + ); 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.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', { + 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', { returnType: 'void', ownerId: 'class:Parent', }); @@ -1403,16 +1083,16 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'process', map, - ctx.model, - 'qualified-syntax', + ctx.symbols, + SupportedLanguages.Rust, ); // Rust requires qualified syntax — no auto-resolution expect(result).toBeUndefined(); }); it('method not on any ancestor returns undefined', () => { - ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); - ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' }, @@ -1423,17 +1103,17 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'nonExistent', map, - ctx.model, - 'implements-split', + ctx.symbols, + SupportedLanguages.Java, ); expect(result).toBeUndefined(); }); it('leftmost-base (C++): walks ancestors in BFS order', () => { - 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', { + 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', { returnType: 'void', ownerId: 'class:A', }); @@ -1444,16 +1124,22 @@ describe('lookupMethodByOwnerWithMRO', () => { ]; const map = buildHeritageMap(heritage, ctx); - const result = lookupMethodByOwnerWithMRO('class:C', 'render', map, ctx.model, 'leftmost-base'); + const result = lookupMethodByOwnerWithMRO( + 'class:C', + 'render', + map, + ctx.symbols, + SupportedLanguages.CPlusPlus, + ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:A:render'); }); it('implements-split (Java): walks ancestors to find inherited 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', { + 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', { returnType: 'void', ownerId: 'class:Base', }); @@ -1473,8 +1159,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'save', map, - ctx.model, - 'implements-split', + ctx.symbols, + SupportedLanguages.Java, ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:save'); @@ -1487,14 +1173,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.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', { + 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', { returnType: 'void', ownerId: 'iface:I1', }); - ctx.model.symbols.add('src/I2.java', 'handle', 'method:I2:handle', 'Method', { + ctx.symbols.add('src/I2.java', 'handle', 'method:I2:handle', 'Method', { returnType: 'void', ownerId: 'iface:I2', }); @@ -1510,8 +1196,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:C', 'handle', map, - ctx.model, - 'implements-split', + ctx.symbols, + SupportedLanguages.Java, ); expect(result).toBeDefined(); // BFS first-wins — I1 was declared first, so it wins. @@ -1525,14 +1211,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.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', { + 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', { returnType: 'void', ownerId: 'class:Base', }); - ctx.model.symbols.add('src/IFoo.java', 'handle', 'method:IFoo:handle', 'Method', { + ctx.symbols.add('src/IFoo.java', 'handle', 'method:IFoo:handle', 'Method', { returnType: 'void', ownerId: 'iface:IFoo', }); @@ -1547,17 +1233,17 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'handle', map, - ctx.model, - 'implements-split', + ctx.symbols, + SupportedLanguages.Java, ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:handle'); }); it('implements-split (Kotlin): walks ancestors to find inherited 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', { + 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', { returnType: 'Unit', ownerId: 'class:Base', }); @@ -1571,17 +1257,17 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'handle', map, - ctx.model, - 'implements-split', + ctx.symbols, + SupportedLanguages.Kotlin, ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:handle'); }); it('implements-split (C#): walks ancestors to find inherited 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', { + 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', { returnType: 'void', ownerId: 'class:Base', }); @@ -1595,8 +1281,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'Execute', map, - ctx.model, - 'implements-split', + ctx.symbols, + SupportedLanguages.CSharp, ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:Execute'); @@ -1605,9 +1291,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.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', { + 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', { returnType: 'string', ownerId: 'class:Animal', }); @@ -1617,7 +1303,13 @@ describe('lookupMethodByOwnerWithMRO', () => { ]; const map = buildHeritageMap(heritage, ctx); - const result = lookupMethodByOwnerWithMRO('class:Dog', 'speak', map, ctx.model, 'first-wins'); + const result = lookupMethodByOwnerWithMRO( + 'class:Dog', + 'speak', + map, + ctx.symbols, + SupportedLanguages.JavaScript, + ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Animal:speak'); }); @@ -1626,19 +1318,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.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', { + 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', { returnType: 'void', ownerId: 'class:A', }); - ctx.model.symbols.add('src/b.cpp', 'render', 'method:B:render', 'Method', { + ctx.symbols.add('src/b.cpp', 'render', 'method:B:render', 'Method', { returnType: 'void', ownerId: 'class:B', }); - ctx.model.symbols.add('src/c.cpp', 'render', 'method:C:render', 'Method', { + ctx.symbols.add('src/c.cpp', 'render', 'method:C:render', 'Method', { returnType: 'void', ownerId: 'class:C', }); @@ -1652,7 +1344,13 @@ describe('lookupMethodByOwnerWithMRO', () => { ]; const map = buildHeritageMap(heritage, ctx); - const result = lookupMethodByOwnerWithMRO('class:D', 'render', map, ctx.model, 'leftmost-base'); + const result = lookupMethodByOwnerWithMRO( + 'class:D', + 'render', + map, + ctx.symbols, + SupportedLanguages.CPlusPlus, + ); 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. @@ -1660,8 +1358,8 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('returns direct method on owner without walking (no heritage needed)', () => { - ctx.model.symbols.add('src/user.java', 'User', 'class:User', 'Class'); - ctx.model.symbols.add('src/user.java', 'getName', 'method:User:getName', 'Method', { + ctx.symbols.add('src/user.java', 'User', 'class:User', 'Class'); + ctx.symbols.add('src/user.java', 'getName', 'method:User:getName', 'Method', { returnType: 'String', ownerId: 'class:User', }); @@ -1672,1591 +1370,10 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:User', 'getName', map, - ctx.model, - 'implements-split', + ctx.symbols, + SupportedLanguages.Java, ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:User:getName'); }); - - // ── ruby-mixin: kind-aware MRO walk (prepend > self > include) ──── - // - // Ruby's `'ruby-mixin'` strategy is the only one that does NOT short-circuit - // on direct-owner lookup first — prepend providers must beat the class's - // own method of the same name. These tests exercise the walk order directly - // through lookupMethodByOwnerWithMRO rather than through the full pipeline. - - it("ruby-mixin: prepend provider beats class's own method (shadow)", () => { - ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class'); - ctx.model.symbols.add('lib/prep.rb', 'PrependedOverride', 'trait:PrependedOverride', 'Trait'); - ctx.model.symbols.add('lib/account.rb', 'serialize', 'method:Account:serialize', 'Method', { - returnType: 'String', - ownerId: 'class:Account', - }); - ctx.model.symbols.add( - 'lib/prep.rb', - 'serialize', - 'method:PrependedOverride:serialize', - 'Method', - { returnType: 'String', ownerId: 'trait:PrependedOverride' }, - ); - - const heritage: ExtractedHeritage[] = [ - { - filePath: 'lib/account.rb', - className: 'Account', - parentName: 'PrependedOverride', - kind: 'prepend', - }, - ]; - const map = buildHeritageMap(heritage, ctx); - - const result = lookupMethodByOwnerWithMRO( - 'class:Account', - 'serialize', - map, - ctx.model, - 'ruby-mixin', - ); - expect(result).toBeDefined(); - expect(result!.nodeId).toBe('method:PrependedOverride:serialize'); - }); - - it("ruby-mixin: class's own method wins over include provider (shadow)", () => { - ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class'); - ctx.model.symbols.add('lib/mixin.rb', 'Greetable', 'trait:Greetable', 'Trait'); - ctx.model.symbols.add('lib/account.rb', 'greet', 'method:Account:greet', 'Method', { - returnType: 'String', - ownerId: 'class:Account', - }); - ctx.model.symbols.add('lib/mixin.rb', 'greet', 'method:Greetable:greet', 'Method', { - returnType: 'String', - ownerId: 'trait:Greetable', - }); - - const heritage: ExtractedHeritage[] = [ - { - filePath: 'lib/account.rb', - className: 'Account', - parentName: 'Greetable', - kind: 'include', - }, - ]; - const map = buildHeritageMap(heritage, ctx); - - const result = lookupMethodByOwnerWithMRO( - 'class:Account', - 'greet', - map, - ctx.model, - 'ruby-mixin', - ); - expect(result).toBeDefined(); - expect(result!.nodeId).toBe('method:Account:greet'); - }); - - it('ruby-mixin: include provider used when class lacks the method', () => { - ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class'); - ctx.model.symbols.add('lib/mixin.rb', 'Greetable', 'trait:Greetable', 'Trait'); - ctx.model.symbols.add('lib/mixin.rb', 'greet', 'method:Greetable:greet', 'Method', { - returnType: 'String', - ownerId: 'trait:Greetable', - }); - - const heritage: ExtractedHeritage[] = [ - { - filePath: 'lib/account.rb', - className: 'Account', - parentName: 'Greetable', - kind: 'include', - }, - ]; - const map = buildHeritageMap(heritage, ctx); - - const result = lookupMethodByOwnerWithMRO( - 'class:Account', - 'greet', - map, - ctx.model, - 'ruby-mixin', - ); - expect(result).toBeDefined(); - expect(result!.nodeId).toBe('method:Greetable:greet'); - }); - - it('ruby-mixin: extend providers excluded from instance-dispatch walk', () => { - ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class'); - ctx.model.symbols.add('lib/logger.rb', 'LoggerMixin', 'trait:LoggerMixin', 'Trait'); - ctx.model.symbols.add('lib/logger.rb', 'log', 'method:LoggerMixin:log', 'Method', { - returnType: 'void', - ownerId: 'trait:LoggerMixin', - }); - - const heritage: ExtractedHeritage[] = [ - { - filePath: 'lib/account.rb', - className: 'Account', - parentName: 'LoggerMixin', - kind: 'extend', - }, - ]; - const map = buildHeritageMap(heritage, ctx); - - // Instance dispatch: `extend` providers MUST NOT appear in the walk. - // Result is undefined — Account has no instance `log`. - const result = lookupMethodByOwnerWithMRO('class:Account', 'log', map, ctx.model, 'ruby-mixin'); - expect(result).toBeUndefined(); - }); - - it('ruby-mixin: singleton ancestryOverride routes to extend provider', () => { - ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class'); - ctx.model.symbols.add('lib/logger.rb', 'LoggerMixin', 'trait:LoggerMixin', 'Trait'); - ctx.model.symbols.add('lib/logger.rb', 'log', 'method:LoggerMixin:log', 'Method', { - returnType: 'void', - ownerId: 'trait:LoggerMixin', - }); - - const heritage: ExtractedHeritage[] = [ - { - filePath: 'lib/account.rb', - className: 'Account', - parentName: 'LoggerMixin', - kind: 'extend', - }, - ]; - const map = buildHeritageMap(heritage, ctx); - - // Singleton dispatch: caller pre-computes the singleton ancestry and - // passes it as ancestryOverride. The walker scans it linearly without - // the prepend/direct/include partition. - const singletonAncestry = map.getSingletonAncestry('class:Account').map((e) => e.parentId); - const result = lookupMethodByOwnerWithMRO( - 'class:Account', - 'log', - map, - ctx.model, - 'ruby-mixin', - undefined, - singletonAncestry, - ); - expect(result).toBeDefined(); - expect(result!.nodeId).toBe('method:LoggerMixin:log'); - }); - - it('ruby-mixin: transitive mixin — module provides method via an included module', () => { - // class Account; include Outer; end - // module Outer; include Inner; end - // module Inner; def helper; end; end - ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class'); - ctx.model.symbols.add('lib/outer.rb', 'Outer', 'trait:Outer', 'Trait'); - ctx.model.symbols.add('lib/inner.rb', 'Inner', 'trait:Inner', 'Trait'); - ctx.model.symbols.add('lib/inner.rb', 'helper', 'method:Inner:helper', 'Method', { - returnType: 'void', - ownerId: 'trait:Inner', - }); - - const heritage: ExtractedHeritage[] = [ - { - filePath: 'lib/account.rb', - className: 'Account', - parentName: 'Outer', - kind: 'include', - }, - { filePath: 'lib/outer.rb', className: 'Outer', parentName: 'Inner', kind: 'include' }, - ]; - const map = buildHeritageMap(heritage, ctx); - - const result = lookupMethodByOwnerWithMRO( - 'class:Account', - 'helper', - map, - ctx.model, - 'ruby-mixin', - ); - expect(result).toBeDefined(); - expect(result!.nodeId).toBe('method:Inner:helper'); - }); - - it('ruby-mixin: stacked prepends — last-prepended wins', () => { - // class A; prepend P1; prepend P2; end - // Ruby MRO places P2 ahead of P1; last-prepended is closest to self. - ctx.model.symbols.add('lib/a.rb', 'A', 'class:A', 'Class'); - ctx.model.symbols.add('lib/p1.rb', 'P1', 'trait:P1', 'Trait'); - ctx.model.symbols.add('lib/p2.rb', 'P2', 'trait:P2', 'Trait'); - ctx.model.symbols.add('lib/p1.rb', 'foo', 'method:P1:foo', 'Method', { - returnType: 'String', - ownerId: 'trait:P1', - }); - ctx.model.symbols.add('lib/p2.rb', 'foo', 'method:P2:foo', 'Method', { - returnType: 'String', - ownerId: 'trait:P2', - }); - - const heritage: ExtractedHeritage[] = [ - { filePath: 'lib/a.rb', className: 'A', parentName: 'P1', kind: 'prepend' }, - { filePath: 'lib/a.rb', className: 'A', parentName: 'P2', kind: 'prepend' }, - ]; - const map = buildHeritageMap(heritage, ctx); - - const result = lookupMethodByOwnerWithMRO('class:A', 'foo', map, ctx.model, 'ruby-mixin'); - expect(result).toBeDefined(); - expect(result!.nodeId).toBe('method:P2:foo'); - }); -}); - -// --------------------------------------------------------------------------- -// resolveMemberCall — SM-11: owner-scoped + MRO member-call resolution -// --------------------------------------------------------------------------- - -import { - _resolveCallTargetForTesting, - resolveMemberCall, - resolveFreeCall, - type OverloadHints, -} from '../../src/core/ingestion/call-processor.js'; - -describe('resolveMemberCall', () => { - let ctx: ResolutionContext; - - beforeEach(() => { - ctx = createResolutionContext(); - }); - - it('resolves direct method on owner type', () => { - 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', - }); - ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); - - const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('method:User:save'); - expect(result!.returnType).toBe('void'); - expect(result!.confidence).toBeGreaterThan(0); - }); - - it('resolves inherited method via MRO walk', () => { - 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', - }); - ctx.importMap.set('src/app.java', new Set(['src/child.java', 'src/parent.java'])); - - const heritage: ExtractedHeritage[] = [ - { filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' }, - ]; - const map = buildHeritageMap(heritage, ctx); - - const result = resolveMemberCall('Child', 'validate', 'src/app.java', ctx, map); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('method:Parent:validate'); - expect(result!.returnType).toBe('boolean'); - }); - - it('returns null for unknown owner type', () => { - const result = resolveMemberCall('NonExistent', 'save', 'src/app.ts', ctx); - expect(result).toBeNull(); - }); - - it('returns null for unknown method on known owner', () => { - 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); - expect(result).toBeNull(); - }); - - it('returns result with correct confidence tier for same-file resolution', () => { - 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', - }); - - const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx); - - expect(result).not.toBeNull(); - expect(result!.confidence).toBe(0.95); // same-file tier - expect(result!.reason).toBe('same-file'); - }); - - it('returns result with import-scoped tier for cross-file resolution', () => { - 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', - }); - ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); - - const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx); - - expect(result).not.toBeNull(); - expect(result!.confidence).toBe(0.9); // import-scoped tier - expect(result!.reason).toBe('import-resolved'); - }); - - it('resolves with heritage map across C3 MRO chain (Python)', () => { - 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', - }); - ctx.importMap.set('src/main.py', new Set(['src/a.py', 'src/b.py', 'src/c.py'])); - - const heritage: ExtractedHeritage[] = [ - { filePath: 'src/c.py', className: 'C', parentName: 'B', kind: 'extends' }, - { filePath: 'src/b.py', className: 'B', parentName: 'A', kind: 'extends' }, - ]; - const map = buildHeritageMap(heritage, ctx); - - const result = resolveMemberCall('C', 'foo', 'src/main.py', ctx, map); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('method:A:foo'); - expect(result!.returnType).toBe('str'); - }); - - // ------------------------------------------------------------------------- - // Locks in the B2 semantic change: tier reflects how the OWNER TYPE was - // resolved, not how the method name was resolved globally. - // ------------------------------------------------------------------------- - it('uses owner-type tier: cross-file class resolution → import-scoped confidence', () => { - // Scenario: owner class 'User' is defined in user.ts (imported from app.ts). - // The method 'save' exists ONLY on User (no homonyms). Old behaviour would - // 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.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', - }); - ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); - - const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx); - - expect(result).not.toBeNull(); - expect(result!.confidence).toBe(0.9); // import-scoped - expect(result!.reason).toBe('import-resolved'); - }); - - // ------------------------------------------------------------------------- - // T2: Rust qualified-syntax — trait-inherited methods must return null - // because they require `TraitName::method(obj)` call syntax, not `obj.method()`. - // Only struct's OWN impl methods are reachable via direct member calls. - // ------------------------------------------------------------------------- - 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.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', - }); - ctx.importMap.set('src/app.rs', new Set(['src/writer.rs', 'src/user.rs'])); - - const heritage: ExtractedHeritage[] = [ - // User implements Writer — in Rust this is `impl Writer for User`. - { filePath: 'src/user.rs', className: 'User', parentName: 'Writer', kind: 'implements' }, - ]; - const map = buildHeritageMap(heritage, ctx); - - // Rust's qualified-syntax strategy short-circuits trait inheritance walks, - // so `user.save()` (direct call) does not resolve. - const result = resolveMemberCall('User', 'save', 'src/app.rs', ctx, map); - expect(result).toBeNull(); - }); - - it('Rust: direct impl methods still resolve (distinction check for T2)', () => { - // 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.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', - }); - ctx.importMap.set('src/app.rs', new Set(['src/user.rs'])); - - const result = resolveMemberCall('User', 'name', 'src/app.rs', ctx); - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('method:User:name'); - expect(result!.returnType).toBe('String'); - }); - - // ------------------------------------------------------------------------- - // T3: C/C++ leftmost-base diamond inheritance at the resolveMemberCall layer. - // ------------------------------------------------------------------------- - // ------------------------------------------------------------------------- - // Homonym disambiguation: when two class candidates share a name but only - // ONE of them owns the method, resolveMemberCall should return that one - // without falling through to the fuzzy D2 widening path. Absorbs what was - // previously D4's ownerId-filtering job into the owner-scoped path. - // ------------------------------------------------------------------------- - 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.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.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', - }); - ctx.importMap.set('src/app.py', new Set(['src/auth.py', 'src/legacy.py'])); - - // `user.save()` is unambiguous — only auth.User has `save`. - const saveResult = resolveMemberCall('User', 'save', 'src/app.py', ctx); - expect(saveResult).not.toBeNull(); - expect(saveResult!.nodeId).toBe('method:auth:User:save'); - - // `user.archive()` is also unambiguous — only legacy.User has `archive`. - const archiveResult = resolveMemberCall('User', 'archive', 'src/app.py', ctx); - expect(archiveResult).not.toBeNull(); - expect(archiveResult!.nodeId).toBe('method:legacy:User:archive'); - }); - - it('returns null when homonym classes BOTH own the method (genuine ambiguity)', () => { - // 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.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.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', - }); - ctx.importMap.set('src/app.py', new Set(['src/auth.py', 'src/legacy.py'])); - - const result = resolveMemberCall('User', 'save', 'src/app.py', ctx); - expect(result).toBeNull(); - }); - - it('homonym + shared ancestor: both walk MRO to the same method (dedups to 1)', () => { - // 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.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.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[] = [ - { filePath: 'src/a.ts', className: 'User', parentName: 'BaseUser', kind: 'extends' }, - { filePath: 'src/b.ts', className: 'User', parentName: 'BaseUser', kind: 'extends' }, - ]; - const map = buildHeritageMap(heritage, ctx); - - const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx, map); - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('method:BaseUser:save'); - }); - - it('C++: resolves diamond inheritance via leftmost-base MRO', () => { - // Diamond: - // Base - // / \ - // A B - // \ / - // Derived - // - // Both A and B inherit `method` from Base. Derived extends (A, B). - // Leftmost-base strategy walks A's chain first → finds Base::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', - }); - ctx.importMap.set( - 'src/app.cpp', - new Set(['src/base.h', 'src/a.h', 'src/b.h', 'src/derived.h']), - ); - - const heritage: ExtractedHeritage[] = [ - { filePath: 'src/a.h', className: 'A', parentName: 'Base', kind: 'extends' }, - { filePath: 'src/b.h', className: 'B', parentName: 'Base', kind: 'extends' }, - { filePath: 'src/derived.h', className: 'Derived', parentName: 'A', kind: 'extends' }, - { filePath: 'src/derived.h', className: 'Derived', parentName: 'B', kind: 'extends' }, - ]; - const map = buildHeritageMap(heritage, ctx); - - const result = resolveMemberCall('Derived', 'method', 'src/app.cpp', ctx, map); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('method:Base:method'); - expect(result!.returnType).toBe('int'); - }); - - // ------------------------------------------------------------------------- - // L1: C# / Kotlin implements-split strategy through resolveMemberCall. - // lookupMethodByOwnerWithMRO already has strategy-level coverage for these - // languages; these tests add the resolveMemberCall layer (tier resolution - // + class candidate iteration + MRO walk) on top. - // ------------------------------------------------------------------------- - it('C#: walks implements-split to find inherited method via interface', () => { - // 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.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', - }); - ctx.importMap.set('src/app.cs', new Set(['src/iservice.cs', 'src/base.cs', 'src/my.cs'])); - - const heritage: ExtractedHeritage[] = [ - { - filePath: 'src/base.cs', - className: 'BaseService', - parentName: 'IService', - kind: 'implements', - }, - { filePath: 'src/my.cs', className: 'MyService', parentName: 'BaseService', kind: 'extends' }, - ]; - const map = buildHeritageMap(heritage, ctx); - - const result = resolveMemberCall('MyService', 'Save', 'src/app.cs', ctx, map); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('method:BaseService:Save'); - expect(result!.returnType).toBe('void'); - }); - - it('Kotlin: walks implements-split to find inherited method via interface', () => { - // 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.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', - }); - ctx.importMap.set('src/app.kt', new Set(['src/validator.kt', 'src/user.kt'])); - - const heritage: ExtractedHeritage[] = [ - { - filePath: 'src/user.kt', - className: 'User', - parentName: 'Validator', - kind: 'implements', - }, - ]; - const map = buildHeritageMap(heritage, ctx); - - const result = resolveMemberCall('User', 'validate', 'src/app.kt', ctx, map); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('method:Validator:validate'); - expect(result!.returnType).toBe('Boolean'); - }); -}); - -// --------------------------------------------------------------------------- -// T1: resolveCallTarget thin dispatcher (SM-19) — verify the dispatcher -// routes member/constructor/free calls to the appropriate specialized resolver. -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// 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 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. - // - // 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.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.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', - }); - 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']])); - - const result = _resolveCallTargetForTesting( - { - calledName: 'save', - callForm: 'member', - receiverTypeName: 'User', - receiverName: 'auth', - }, - 'src/app.py', - ctx, - ); - - // 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 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.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', - }); - ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); - - const dummyHints = {} as OverloadHints; - - const result = _resolveCallTargetForTesting( - { - calledName: 'save', - callForm: 'member', - receiverTypeName: 'User', - }, - 'src/app.ts', - ctx, - { overloadHints: dummyHints }, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('method:User:save'); - }); - - 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.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', - }); - ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); - - const result = _resolveCallTargetForTesting( - { - calledName: 'save', - callForm: 'member', - receiverTypeName: 'User', - argCount: 0, - }, - 'src/app.ts', - ctx, - { preComputedArgTypes: [] }, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('method:User:save'); - }); -}); - -// --------------------------------------------------------------------------- -// resolveStaticCall — SM-12: constructor/static call resolution -// --------------------------------------------------------------------------- - -import { resolveStaticCall } from '../../src/core/ingestion/call-processor.js'; - -describe('resolveStaticCall', () => { - let ctx: ResolutionContext; - - beforeEach(() => { - ctx = createResolutionContext(); - }); - - it('resolves constructor with ownerId via lookupMethodByOwner', () => { - 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', - }); - ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); - - const result = resolveStaticCall('User', 'src/app.ts', ctx); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('ctor:User'); - }); - - it('returns class node when no constructor exists', () => { - 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); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('class:User'); - }); - - it('returns null for non-class symbol', () => { - 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); - - expect(result).toBeNull(); - }); - - it('returns null when className does not exist', () => { - const result = resolveStaticCall('NonExistent', 'src/app.ts', ctx); - - expect(result).toBeNull(); - }); - - it('returns null when Constructor nodes lack ownerId', () => { - 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'])); - - // Constructor lacks ownerId, so lookupMethodByOwner won't find it. - // resolveStaticCall detects Constructor nodes and returns null to - // let filterCallableCandidates handle the Constructor-vs-Class preference. - const result = resolveStaticCall('User', 'src/app.ts', ctx); - - expect(result).toBeNull(); - }); - - it('disambiguates constructor by arity', () => { - 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.model.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', { - parameterCount: 2, - returnType: 'User', - ownerId: 'class:User', - }); - ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); - - const result = resolveStaticCall('User', 'src/app.ts', ctx, 2); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('ctor:User:2'); - }); - - it('returns correct confidence tier for import-scoped 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); - - expect(result).not.toBeNull(); - expect(result!.confidence).toBe(0.9); // import-scoped tier - expect(result!.reason).toBe('import-resolved'); - }); - - it('returns correct confidence tier for same-file class', () => { - ctx.model.symbols.add('src/app.ts', 'User', 'class:User', 'Class'); - - const result = resolveStaticCall('User', 'src/app.ts', ctx); - - expect(result).not.toBeNull(); - expect(result!.confidence).toBe(0.95); // same-file tier - expect(result!.reason).toBe('same-file'); - }); - - it('returns null for ambiguous homonym classes without constructor', () => { - 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); - - // Two classes with same name — ambiguous, should return null - expect(result).toBeNull(); - }); - - it('routes through resolveCallTarget for constructor callForm', () => { - 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( - { - calledName: 'User', - callForm: 'constructor', - }, - 'src/app.ts', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('class:User'); - }); - - it('routes through resolveCallTarget for free-form call targeting a class (Swift/Kotlin)', () => { - 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( - { - calledName: 'User', - callForm: 'free', - }, - 'src/app.swift', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('class:User'); - }); - - it('reuses the pre-computed tiered result instead of calling ctx.resolve twice', () => { - 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', - }); - ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); - - // Spy on ctx.resolve to prove the override short-circuits the second lookup. - const originalResolve = ctx.resolve.bind(ctx); - let resolveCallCount = 0; - ctx.resolve = ((name: string, fromFile: string) => { - resolveCallCount++; - return originalResolve(name, fromFile); - }) as typeof ctx.resolve; - - const tieredOverride = originalResolve('User', 'src/app.ts'); - expect(tieredOverride).not.toBeNull(); - resolveCallCount = 0; // reset after the setup call - - const result = resolveStaticCall('User', 'src/app.ts', ctx, undefined, tieredOverride!); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('ctor:User'); - expect(resolveCallCount).toBe(0); // ctx.resolve must not have been called again - }); - - it('routes through resolveCallTarget for Java constructor call (new User())', () => { - 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', - }); - ctx.importMap.set('src/App.java', new Set(['src/User.java'])); - - const result = _resolveCallTargetForTesting( - { - calledName: 'User', - callForm: 'constructor', - }, - 'src/App.java', - ctx, - ); - - expect(result).not.toBeNull(); - // Prefers Constructor node over Class node when ownerId is present. - expect(result!.nodeId).toBe('ctor:java:User'); - }); - - it('routes through resolveCallTarget for Python free-form constructor (User())', () => { - 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( - { - calledName: 'User', - callForm: 'free', - }, - 'app.py', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('class:py:User'); - }); - - it('routes through resolveCallTarget for Kotlin free-form constructor (User())', () => { - 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( - { - calledName: 'User', - callForm: 'free', - }, - 'src/App.kt', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('class:kt:User'); - }); - - // ------------------------------------------------------------------------- - // Instantiability guard (Codex review follow-up, plan 2026-04-09-002): - // The step-5 class-node fallback must only return instantiable kinds - // (Class / Struct / Record). Interface / Trait / Impl / Enum targets are - // null-routed to prevent false CALLS edges to non-instantiable nodes. - // ------------------------------------------------------------------------- - - it('returns a Struct node when no constructor exists (positive regression guard)', () => { - 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); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('struct:User'); - }); - - it('returns a Record node when no constructor exists (positive regression guard)', () => { - 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); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('record:User'); - }); - - 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.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); - - expect(result).toBeNull(); - }); - - it('null-routes when the sole candidate is a Trait (PHP/Rust/Scala)', () => { - // PHP `HasTimestamps` trait — not instantiable via constructor syntax. - 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); - - expect(result).toBeNull(); - }); - - it('null-routes when the sole candidate is a Rust Trait (Display)', () => { - 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); - - expect(result).toBeNull(); - }); - - it('prefers the Struct over the Impl when both share the same name and file (Rust shadowing)', () => { - // 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.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); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('struct:rs:User'); - }); - - 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.model.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); - - const result = resolveStaticCall('User', 'src/user.rs', ctx); - - expect(result).toBeNull(); - }); - - it('still returns an explicit Constructor even when the owner is an Impl (step-3 preservation)', () => { - // Step 3 (lookupMethodByOwner walk) must not be affected by the step-5 - // tightening — a legitimate Constructor node owned by an Impl in a Rust - // 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.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', - }); - ctx.importMap.set('src/app.rs', new Set(['src/user.rs'])); - - const result = resolveStaticCall('User', 'src/app.rs', ctx); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('ctor:rs:User'); - }); - - it('routes through resolveCallTarget and null-routes Interface constructor-shaped calls', () => { - 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( - { - calledName: 'IValidator', - callForm: 'constructor', - }, - 'src/app.java', - ctx, - ); - - // Full cascade: S0 → resolveStaticCall → step-5 instantiability filter → null. - // If any downstream path silently re-introduces the wrong edge, this fails. - expect(result).toBeNull(); - }); - - it('routes through resolveCallTarget and null-routes Trait free-form calls', () => { - 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( - { - calledName: 'HasTimestamps', - callForm: 'free', - }, - 'src/model.php', - ctx, - ); - - expect(result).toBeNull(); - }); - - it('routes Record free-form constructor call through S0 (C# record / Kotlin data class)', () => { - // Verifies that `freeFormHasClassTarget` triggers S0 for Record candidates. - // Before the alignment fix, `Record` was absent from the trigger `.some()`, - // 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.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( - { - calledName: 'User', - callForm: 'free', - }, - 'src/App.cs', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('record:cs:User'); - }); - - it('threads argCount through resolveCallTarget → S0 → resolveStaticCall for arity disambiguation', () => { - // 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.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.model.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', { - parameterCount: 2, - returnType: 'User', - ownerId: 'class:User', - }); - ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); - - const result = _resolveCallTargetForTesting( - { - calledName: 'User', - callForm: 'constructor', - argCount: 2, - }, - 'src/app.ts', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('ctor:User:2'); - }); -}); - -// --------------------------------------------------------------------------- -// resolveFreeCall — SM-13: free-function call resolution -// --------------------------------------------------------------------------- - -describe('resolveFreeCall', () => { - let ctx: ResolutionContext; - - beforeEach(() => { - ctx = createResolutionContext(); - }); - - it('resolves a free function call via import-scoped resolution', () => { - 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); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('func:doStuff'); - expect(result!.confidence).toBe(0.9); // import-scoped tier - expect(result!.reason).toBe('import-resolved'); - }); - - it('resolves a free function call via same-file resolution', () => { - ctx.model.symbols.add('src/app.ts', 'helper', 'func:helper', 'Function'); - - const result = resolveFreeCall('helper', 'src/app.ts', ctx); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('func:helper'); - expect(result!.confidence).toBe(0.95); // same-file tier - expect(result!.reason).toBe('same-file'); - }); - - it('returns null when no candidates exist', () => { - const result = resolveFreeCall('nonexistent', 'src/app.ts', ctx); - expect(result).toBeNull(); - }); - - it('returns null for ambiguous free function calls (multiple candidates)', () => { - 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); - - expect(result).toBeNull(); - }); - - it('delegates to resolveStaticCall for free-form class targets (Swift/Kotlin)', () => { - 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); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('class:User'); - }); - - it('delegates to resolveStaticCall for Record free-form targets (C#/Kotlin)', () => { - 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); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('record:cs:User'); - }); - - it('null-routes Trait free-form calls via resolveStaticCall', () => { - 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); - - expect(result).toBeNull(); - }); - - it('uses tieredOverride when provided', () => { - 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'); - expect(tiered).not.toBeNull(); - - // Spy on ctx.resolve to verify it is NOT called again - const originalResolve = ctx.resolve.bind(ctx); - let resolveCallCount = 0; - ctx.resolve = ((name: string, fromFile: string) => { - resolveCallCount++; - return originalResolve(name, fromFile); - }) as typeof ctx.resolve; - - const result = resolveFreeCall('doStuff', 'src/app.ts', ctx, undefined, tiered!); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('func:doStuff'); - expect(resolveCallCount).toBe(0); - }); - - it('routes through resolveCallTarget for free-form calls', () => { - 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( - { - calledName: 'doStuff', - callForm: 'free', - }, - 'src/app.ts', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('func:doStuff'); - }); - - // ------------------------------------------------------------------------- - // PR #756 review follow-up (plan 2026-04-09-003): language coverage, - // arity threading, Tier 3 resolution, preComputedArgTypes worker path, - // Enum null-route, and Swift extension dedup guard. - // ------------------------------------------------------------------------- - - // R2 — Language coverage: Go, Python, Rust, Java, JavaScript free-function - // dispatch through _resolveCallTargetForTesting. resolveFreeCall has no - // file-extension branching; these guard the dispatch chain per language. - - it('resolves a Go free function (doStuff())', () => { - 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( - { calledName: 'doStuff', callForm: 'free' }, - 'src/main.go', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('func:go:doStuff'); - }); - - it('resolves a Python free function (def helper(): ... helper())', () => { - ctx.model.symbols.add('helpers.py', 'helper', 'func:py:helper', 'Function'); - ctx.importMap.set('app.py', new Set(['helpers.py'])); - - const result = _resolveCallTargetForTesting( - { calledName: 'helper', callForm: 'free' }, - 'app.py', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('func:py:helper'); - }); - - it('resolves a Rust free function outside any impl block (free_fn())', () => { - 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( - { calledName: 'free_fn', callForm: 'free' }, - 'src/main.rs', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('func:rs:free_fn'); - }); - - it('resolves a Java statically-imported function (doStuff() after import static Utils.doStuff)', () => { - // Note: this simulates the extractor output post static import by - // 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.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( - { calledName: 'doStuff', callForm: 'free' }, - 'src/App.java', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('func:java:doStuff'); - }); - - it('resolves a JavaScript module-level function (moduleFn())', () => { - 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( - { calledName: 'moduleFn', callForm: 'free' }, - 'src/app.js', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('func:js:moduleFn'); - }); - - // R3 — Arity filtering: call.argCount must narrow overloaded free functions - // differing only in parameter count. - - it('narrows overloaded free functions by argCount (2-arg overload selected)', () => { - ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { - parameterCount: 0, - }); - 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'])); - - const result = _resolveCallTargetForTesting( - { calledName: 'helper', callForm: 'free', argCount: 2 }, - 'src/app.ts', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('func:helper:2'); - }); - - it('narrows overloaded free functions by argCount (0-arg overload selected)', () => { - ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { - parameterCount: 0, - }); - 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'])); - - const result = _resolveCallTargetForTesting( - { calledName: 'helper', callForm: 'free', argCount: 0 }, - 'src/app.ts', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('func:helper:0'); - }); - - // R4 — Tier 3 (global) resolution: function globally visible but not - // imported. Locks in TIER_CONFIDENCE.global === 0.5 and reason === 'global' - // so a silent tier-table refactor surfaces here. - - it('resolves a globally-visible free function via Tier 3 with global confidence', () => { - 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( - { calledName: 'helper', callForm: 'free' }, - 'src/app.ts', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('func:global:helper'); - expect(result!.confidence).toBe(0.5); // TIER_CONFIDENCE.global - expect(result!.reason).toBe('global'); - }); - - // R5 — preComputedArgTypes worker path: when parse-worker pre-computes - // argument types, the disambiguation routes through matchCandidatesByArgTypes. - // Preconditions (verified at feasibility review): - // 1. filteredCandidates.length > 1 — both overloads must survive arity - // filtering, so argCount left unset here. - // 2. overloadHints must be undefined — it takes precedence over - // preComputedArgTypes at the disambiguation site. - - it('disambiguates overloads via preComputedArgTypes (String overload matched)', () => { - ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { - parameterCount: 1, - parameterTypes: ['String'], - }); - ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { - parameterCount: 1, - parameterTypes: ['Int'], - }); - ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); - - const result = _resolveCallTargetForTesting( - { calledName: 'helper', callForm: 'free', argCount: 1 }, - 'src/app.ts', - ctx, - { preComputedArgTypes: ['String'] }, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('func:helper:str'); - }); - - it('disambiguates overloads via preComputedArgTypes (Int overload matched)', () => { - ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { - parameterCount: 1, - parameterTypes: ['String'], - }); - ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { - parameterCount: 1, - parameterTypes: ['Int'], - }); - ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); - - const result = _resolveCallTargetForTesting( - { calledName: 'helper', callForm: 'free', argCount: 1 }, - 'src/app.ts', - ctx, - // `Int` is normalized to `int` on the stored side via normalizeJvmTypeName - // (matchCandidatesByArgTypes:1287). Real parse-worker-emitted argTypes are - // already lowercase primitive names inferred from literals, so this - // mirrors production call-site shape. - { preComputedArgTypes: ['int'] }, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('func:helper:int'); - }); - - // R6 — Enum free-form null-route: locks in the current behavior that - // `Color()`-style calls on Enum types return null because Enum is - // deliberately excluded from INSTANTIABLE_CLASS_TYPES. This is intentional - // per PR #754 round 1 (see `call-processor.ts` INSTANTIABLE_CLASS_TYPES - // JSDoc — "Enum excluded pending language-specific support with motivating - // test fixtures"). If a future extension adds Enum to the set, this test - // 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.model.symbols.add('src/color.ts', 'Color', 'enum:Color', 'Enum'); - ctx.importMap.set('src/app.ts', new Set(['src/color.ts'])); - - const result = _resolveCallTargetForTesting( - { calledName: 'Color', callForm: 'free' }, - 'src/app.ts', - ctx, - ); - - // Enum not in INSTANTIABLE_CLASS_TYPES → hasClassTarget is false → - // resolveStaticCall is not called → tail dedup also doesn't fire → - // falls through to the final null return. - expect(result).toBeNull(); - }); - - // R7 — Swift extension dedup `filePath.length` heuristic guard: - // Two same-name Class entries at different path lengths. The free-form - // dispatch chain goes: - // 1. filterCallableCandidates(tiered, argCount, 'free') strips Class → - // filteredCandidates.length === 0 - // 2. hasClassTarget is true (both are Class) - // 3. resolveStaticCall runs, has 2 homonym Class candidates → - // instantiableCandidates.length > 1 → returns null (SM-12 round-1 - // null-route contract) - // 4. Constructor-form retry: filterCallableCandidates(tiered, argCount, - // 'constructor') keeps Class entries → filteredCandidates.length === 2 - // 5. Falls through to the Swift extension dedup block → sorts by - // filePath.length → returns the shortest path. - - it('dedupes Swift extension candidates by shortest file path (free-form retry path)', () => { - // Two same-name Class entries, different path lengths. - 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']), - ); - - const result = _resolveCallTargetForTesting( - { calledName: 'User', callForm: 'free' }, - 'src/App.swift', - ctx, - ); - - // The shortest file path wins per the existing heuristic. This is a - // behavior guard for finding #4 in the PR #756 review — if the dedup - // heuristic changes, this test surfaces that intent. - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('class:User:primary'); - }); - - // ------------------------------------------------------------------------- - // PR #756 final review follow-up (comment 4215739052): - // - Finding #3 low: ownerless-Constructor retry path (previously covered - // by comment only) — adds the concrete test the reviewer asked for. - // - Low-severity coverage gap: PHP free function (from the language - // coverage table in the same review). - // ------------------------------------------------------------------------- - - it('routes through resolveStaticCall retry when tiered pool contains an ownerless Constructor (free-form)', () => { - // This exercises the third null-return reason documented in the retry - // comment inside resolveFreeCall: resolveStaticCall's step-4 bailout when - // the tiered pool contains Constructor nodes that lack ownerId (common in - // some extractors). In that case: - // 1. resolveStaticCall step 3 walks classCandidates via lookupMethodByOwner - // — the ownerless Constructor is NOT in methodByOwner, so nothing found. - // 2. Step 4 detects the Constructor in the tiered pool and bails out - // with null so filterCallableCandidates can handle Constructor-vs- - // Class preference correctly. - // 3. resolveFreeCall's retry re-runs filterCallableCandidates with - // 'constructor' form, which — per CONSTRUCTOR_TARGET_TYPES — prefers - // the Constructor node over the Class node. - // 4. Single survivor → returned as the call target. - 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. - }); - ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); - - const result = _resolveCallTargetForTesting( - { calledName: 'User', callForm: 'free' }, - 'src/app.ts', - ctx, - ); - - // The Constructor survives filterCallableCandidates's 'constructor' form - // filter and is preferred over the Class (CONSTRUCTOR_TARGET_TYPES puts - // Constructor first). Guards the (c) case in the retry-reasons comment. - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('ctor:User:ownerless'); - }); - - it('resolves a PHP free function (top-level helper())', () => { - // PHP allows top-level function definitions outside any class. The - // 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.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( - { calledName: 'helper', callForm: 'free' }, - 'src/app.php', - ctx, - ); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('func:php:helper'); - }); });