Merge upstream/main into fix/ruby-singleton-class-sequential

This commit is contained in:
ideepakchauhan7 2026-04-13 17:28:06 +05:30
commit 8d175c81bf
135 changed files with 25660 additions and 3147 deletions

View file

@ -63,7 +63,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (3883 symbols, 9861 relationships, 225 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **GitNexus** (3975 symbols, 10043 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View file

@ -52,7 +52,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
GitNexus MCP rules are in the `<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (3883 symbols, 9861 relationships, 225 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **GitNexus** (3975 symbols, 10043 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View file

@ -19,6 +19,7 @@ export type { NodeTableName, RelType } from './lbug/schema-constants.js';
// Language support
export { SupportedLanguages } from './languages.js';
export { getLanguageFromFilename, getSyntaxLanguageFromFilename } from './language-detection.js';
export type { MroStrategy } from './mro-strategy.js';
// Pipeline progress
export type { PipelinePhase, PipelineProgress } from './pipeline.js';

View file

@ -0,0 +1,23 @@
/**
* MRO (Method Resolution Order) strategy shared between CLI and any
* future consumer that reasons about multiple-inheritance semantics.
*
* Lives in `gitnexus-shared` so the low-level resolution module
* (`core/ingestion/model/resolve.ts`) does not need to import from
* `languages/` keeping the `model/` layer free of language-registry
* coupling.
*
* Strategy semantics:
* - `first-wins`: BFS ancestor walk, first match wins (default).
* - `leftmost-base`: BFS ancestor walk, leftmost base wins (C++).
* - `c3`: C3-linearized ancestor order, first match wins (Python).
* - `implements-split`: BFS walk, first match wins (Java/C#/Kotlin) full
* interface-default ambiguity is handled at graph level.
* - `qualified-syntax`: No auto-resolution (Rust requires `<T as Trait>::m`).
*/
export type MroStrategy =
| 'first-wins'
| 'c3'
| 'leftmost-base'
| 'implements-split'
| 'qualified-syntax';

View file

@ -264,11 +264,13 @@ const fetchWithTimeout = async (
const assertOk = async (response: Response): Promise<void> => {
if (response.ok) return;
let message = `Backend returned ${response.status} ${response.statusText}`;
let message = response.statusText;
try {
const body = await response.json();
if (body && typeof body.error === 'string') {
message = body.error;
} else if (body && typeof body.message === 'string') {
message = body.message;
}
} catch {
// Response body was not JSON

View file

@ -2,6 +2,65 @@
All notable changes to GitNexus will be documented in this file.
## [1.6.0] - 2026-04-12
### Added
- **SemanticModel architecture refactor (SM-8 through SM-19)** — extracted registries into `model/` module with ISP-compliant interfaces: TypeRegistry, MethodRegistry, FieldRegistry, RegistrationTable, ResolutionContext (#786)
- HeritageMap built from accumulated `ExtractedHeritage[]` for MRO-aware resolution (#739)
- `lookupMethodByOwnerWithMRO` using HeritageMap for cross-class method dispatch (#740)
- MRO fast path before D2 fuzzy widening in call resolution (#741)
- BindingAccumulator for cross-file return type propagation (#743, #763)
- Restructured `resolveUncached` replacing `lookupFuzzy` data source for all tiers (#764)
- Deleted `lookupFuzzy`, `lookupFuzzyCallable`, `globalIndex`, `callableIndex` — replaced with structured lookups (#769)
- Deleted `resolveCallTarget` god-method — replaced with thin dispatcher delegating to `resolveMemberCall` (#744), `resolveStaticCall` (#754), `resolveFreeCall` (#756) (#770)
- **Service group infrastructure** — service boundary detection, contract extractors, sync pipeline, CLI/MCP tools, monorepo fixture; bridge.lbug storage and contract matching expansion (#795)
- **C# interface-to-interface heritage** capture (#789)
- **Vue SFC support** with destructured call result tracking (#604)
- **Java method reference** resolution — `obj::method` as call sites (#622)
- **C/C++ MethodExtractor** config with pure virtual detection (#617)
- **MethodExtractor configs** for Python, PHP, Swift, Dart, Rust, Ruby (#624)
- **METHOD_IMPLEMENTS edges** with overload disambiguation and MethodExtractor unification (#642)
- **Same-arity overload disambiguation** via type-hash suffix (#658)
- **`GITNEXUS_HOME` env var** to customize global directory (#746)
- **Verbose analyze output** prints skipped large file paths (#745)
- **Class name lookup index** for O(1) qualified lookups (#707, #716)
- **`lookupMethodByOwner` index** for O(1) cross-class chain resolution (#665)
- **Fuzzy lookup counters** for performance visibility (#708)
### Fixed
- **Stack overflow on large PHP files** — iterative AST traversal (#783)
- **Large repository graph loading** failure (#732)
- **Windows multi-repo switching** — false 404 errors and stale repo context (#633)
- **`detect_changes` diff mapping** — map diff hunks to symbol line ranges (#779)
- **HTTP client vs Express route detection** and Spring interface attribution (#780)
- **VECTOR extension** not loaded during DB init for semantic search (#782)
- **tree-sitter-swift** postinstall patch for macOS ARM64 (#788)
- **tree-sitter-c** peer dependency conflict pinned (#723)
- **Constructor indexing** in methodByOwner (#694, #753)
- **Named binding processor**`lookupExact` replaced with `lookupExactAll` (#755)
- **`.gitnexusignore` negation patterns** now respected (#654)
- **MCP setup** prefers global gitnexus binary over npx (#653)
- **CORS rejection** returns clean error instead of 500 (#646)
- **Array.push stack overflow** — replaced spread with loop (#650)
- **MCP stdout silencing** prevents embedder/pool-adapter conflicts (#645)
- **Web heartbeat** — graceful reconnection replaces aggressive disconnect (#643)
- **Web repo scoping** — backend calls scoped to active repo (#644)
- **OpenCode config path** and FTS extension load order (#781)
- **OnboardingGuide** dev-mode serve command corrected (#725)
- **Security issues** and critical bugs from code review (#709)
### Changed
- Replaced class-type fuzzy lookups with structured indices in type-env (#733, #734, #736)
- Extracted `CLASS_LIKE_TYPES` constant (#693)
## [1.5.3] - 2026-04-01
### Added
- **TypeScript/JavaScript MethodExtractor** config (#588)
### Fixed
- **Wiki Azure OpenAI** compat and HTML viewer script injection (#618)
## [1.5.2] - 2026-04-01
### Fixed

View file

@ -1,12 +1,13 @@
{
"name": "gitnexus",
"version": "1.5.3",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.5.3",
"version": "1.6.0",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@huggingface/transformers": "^3.0.0",
@ -17,7 +18,6 @@
"commander": "^12.0.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"gitnexus-shared": "file:../gitnexus-shared",
"glob": "^11.0.0",
"graphology": "^0.25.4",
"graphology-indices": "^0.17.0",
@ -64,6 +64,7 @@
"optionalDependencies": {
"tree-sitter-dart": "github:UserNobody14/tree-sitter-dart#80e23c07b64494f7e21090bb3450223ef0b192f4",
"tree-sitter-kotlin": "^0.3.8",
"tree-sitter-proto": "file:./vendor/tree-sitter-proto",
"tree-sitter-swift": "^0.6.0"
}
},
@ -5295,6 +5296,10 @@
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/tree-sitter-proto": {
"resolved": "vendor/tree-sitter-proto",
"link": true
},
"node_modules/tree-sitter-python": {
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.23.4.tgz",
@ -5876,6 +5881,29 @@
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
},
"vendor/tree-sitter-proto": {
"version": "0.4.1",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0"
},
"peerDependencies": {
"tree-sitter": ">=0.21.0"
}
},
"vendor/tree-sitter-proto/node_modules/node-addon-api": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^18 || ^20 || >= 21"
}
}
}
}

View file

@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.5.3",
"version": "1.6.0",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",
@ -46,6 +46,7 @@
"test:integration": "vitest run test/integration",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"postinstall": "node scripts/patch-tree-sitter-swift.cjs",
"prepare": "node scripts/build.js",
"prepack": "node scripts/build.js"
},
@ -58,7 +59,6 @@
"commander": "^12.0.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"gitnexus-shared": "file:../gitnexus-shared",
"glob": "^11.0.0",
"graphology": "^0.25.4",
"graphology-indices": "^0.17.0",
@ -86,6 +86,7 @@
"optionalDependencies": {
"tree-sitter-dart": "github:UserNobody14/tree-sitter-dart#80e23c07b64494f7e21090bb3450223ef0b192f4",
"tree-sitter-kotlin": "^0.3.8",
"tree-sitter-proto": "file:./vendor/tree-sitter-proto",
"tree-sitter-swift": "^0.6.0"
},
"devDependencies": {

View file

@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* WORKAROUND: tree-sitter-swift@0.6.0 binding.gyp build failure
*
* Background:
* tree-sitter-swift@0.6.0's binding.gyp contains an "actions" array that
* invokes `tree-sitter generate` to regenerate parser.c from grammar.js.
* This is intended for grammar developers, but the published npm package
* already ships pre-generated parser files (parser.c, scanner.c), so the
* actions are unnecessary for consumers. Since consumers don't have
* tree-sitter-cli installed, the actions always fail during `npm install`.
*
* Why we can't just upgrade:
* tree-sitter-swift@0.7.1 fixes this (removes postinstall, ships prebuilds),
* but it requires tree-sitter@^0.22.1. The upstream project pins tree-sitter
* to ^0.21.0 and all other grammar packages depend on that version.
* Upgrading tree-sitter would be a separate breaking change.
*
* How this workaround works:
* 1. tree-sitter-swift's own postinstall fails (npm warns but continues)
* 2. This script runs as gitnexus's postinstall
* 3. It removes the "actions" array from binding.gyp
* 4. It rebuilds the native binding with the cleaned binding.gyp
*
* TODO: Remove this script when tree-sitter is upgraded to ^0.22.x,
* which allows using tree-sitter-swift@0.7.1+ directly.
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift');
const bindingPath = path.join(swiftDir, 'binding.gyp');
try {
if (!fs.existsSync(bindingPath)) {
process.exit(0);
}
const content = fs.readFileSync(bindingPath, 'utf8');
let needsRebuild = false;
if (content.includes('"actions"')) {
// Strip Python-style comments (#) and trailing commas before JSON parsing
const cleaned = content
.replace(/#[^\n]*/g, '') // Remove # comments
.replace(/,(\s*[\]}])/g, '$1'); // Remove trailing commas before ] or }
const gyp = JSON.parse(cleaned);
if (gyp.targets && gyp.targets[0] && gyp.targets[0].actions) {
delete gyp.targets[0].actions;
fs.writeFileSync(bindingPath, JSON.stringify(gyp, null, 2) + '\n');
console.log('[tree-sitter-swift] Patched binding.gyp (removed actions array)');
needsRebuild = true;
}
}
// Check if native binding exists
const bindingNode = path.join(swiftDir, 'build', 'Release', 'tree_sitter_swift_binding.node');
if (!fs.existsSync(bindingNode)) {
needsRebuild = true;
}
if (needsRebuild) {
console.log('[tree-sitter-swift] Rebuilding native binding...');
execSync('npx node-gyp rebuild', {
cwd: swiftDir,
stdio: 'pipe',
timeout: 120000,
});
console.log('[tree-sitter-swift] Native binding built successfully');
}
} catch (err) {
console.warn('[tree-sitter-swift] Could not build native binding:', err.message);
console.warn(
'[tree-sitter-swift] You may need to manually run: cd node_modules/tree-sitter-swift && npx node-gyp rebuild',
);
}

View file

@ -26,6 +26,7 @@ interface RepoStats {
export interface AIContextOptions {
skipAgentsMd?: boolean;
noStats?: boolean;
}
const GITNEXUS_START_MARKER = '<!-- gitnexus:start -->';
@ -64,6 +65,7 @@ function generateGitNexusContent(
stats: RepoStats,
generatedSkills?: GeneratedSkillInfo[],
groupNames?: string[],
noStats?: boolean,
): string {
const generatedRows =
generatedSkills && generatedSkills.length > 0
@ -87,7 +89,7 @@ function generateGitNexusContent(
return `${GITNEXUS_START_MARKER}
# GitNexus Code Intelligence
This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows)`}. Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first.
@ -332,7 +334,13 @@ export async function generateAIContextFiles(
options?: AIContextOptions,
): Promise<{ files: string[] }> {
const groupNames = await findGroupsContainingRegistryName(projectName);
const content = generateGitNexusContent(projectName, stats, generatedSkills, groupNames);
const content = generateGitNexusContent(
projectName,
stats,
generatedSkills,
groupNames,
options?.noStats,
);
const createdFiles: string[] = [];
if (!options?.skipAgentsMd) {

View file

@ -47,6 +47,8 @@ export interface AnalyzeOptions {
verbose?: boolean;
/** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */
skipAgentsMd?: boolean;
/** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
noStats?: boolean;
/** Index the folder even when no .git directory is present. */
skipGit?: boolean;
}
@ -177,6 +179,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
embeddings: options?.embeddings,
skipGit: options?.skipGit,
skipAgentsMd: options?.skipAgentsMd,
noStats: options?.noStats,
},
{
onProgress: (_phase, percent, message) => {
@ -240,7 +243,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
processes: s.processes,
},
skillResult.skills,
{ skipAgentsMd: options?.skipAgentsMd },
{ skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats },
);
}
} catch {

View file

@ -26,6 +26,7 @@ program
.option('--embeddings', 'Enable embedding generation for semantic search (off by default)')
.option('--skills', 'Generate repo-specific skill files from detected communities')
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
.option('--skip-git', 'Index a folder without requiring a .git directory')
.option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)')
.addHelpText(

View file

@ -265,7 +265,7 @@ async function setupOpenCode(result: SetupResult): Promise<void> {
return;
}
const configPath = path.join(opencodeDir, 'config.json');
const configPath = path.join(opencodeDir, 'opencode.json');
try {
const existing = await readJsonFile(configPath);
const config = existing || {};

View file

@ -0,0 +1,139 @@
# Group Analysis Pipeline
Flow chart of the cross-repo contract extraction + matching pipeline.
This covers what runs **inside this PR** (extractors + manifest) and
the downstream handoff to the bridge storage (PR #795) and
cross-impact query (PR #606).
## High-level overview
```mermaid
flowchart TD
A[group.yaml] --> B[GroupConfig parser]
B --> C{For each repo<br/>in group}
C --> D[Per-repo LadybugDB<br/>indexed by main pipeline]
D --> E1[TopicExtractor]
D --> E2[HttpRouteExtractor]
D --> E3[GrpcExtractor]
E1 --> F[ExtractedContract array<br/>per repo]
E2 --> F
E3 --> F
B --> M[ManifestExtractor]
M --> G[Manifest contracts<br/>+ cross-links]
F --> H[Contract matching<br/>exact + wildcard]
G --> H
H --> I[(bridge.lbug<br/>#795)]
I --> J[runGroupImpact<br/>#606]
J --> K[CrossRepoImpact]
```
## Per-repo extractor pipeline
Each extractor under `src/core/group/extractors/` follows the same
two-strategy shape:
```mermaid
flowchart TD
R[RepoHandle + CypherExecutor<br/>for this repo] --> S{Graph-assisted<br/>Strategy A<br/>available?}
S -->|yes| A1[Cypher query against<br/>per-repo LadybugDB]
A1 --> A2{non-empty<br/>result?}
A2 -->|yes| OUT[ExtractedContract array]
A2 -->|no| B1
S -->|no| B1[Source-scan Strategy B]
B1 --> B2[glob repo source files]
B2 --> B3{ext in registry?}
B3 -->|yes| B4[Per-language plugin<br/>scan parsed tree]
B3 -->|no| SKIP[skip file]
B4 --> OUT
SKIP --> B2
```
**Strategy A** (graph-assisted) uses Cypher over edges already produced
by the main ingestion pipeline:
- HTTP: `HANDLES_ROUTE` / `FETCHES` edges from `(File)-[]->(Route)`
- topic: none (pipeline doesn't yet produce topic nodes — Strategy B only)
- gRPC: none (Strategy B + proto map only)
**Strategy B** (source-scan) is 100% tree-sitter based after this PR.
Each `*-patterns/<lang>.ts` plugin owns its grammar + S-expression
queries; the top-level orchestrator imports neither.
## Plugin architecture
```mermaid
flowchart LR
O[Orchestrator<br/>topic|http|grpc-extractor.ts] --> REG[REGISTRY<br/>*-patterns/index.ts]
REG --> P1[java.ts<br/>tree-sitter-java]
REG --> P2[go.ts<br/>tree-sitter-go]
REG --> P3[python.ts<br/>tree-sitter-python]
REG --> P4[node.ts<br/>JS + TS + TSX]
REG --> P5[php.ts<br/>tree-sitter-php<br/>HTTP only]
REG --> P6[proto.ts<br/>tree-sitter-proto<br/>gRPC only, optional]
P1 --> SCAN[tree-sitter-scanner.ts<br/>compilePatterns + runCompiledPatterns]
P2 --> SCAN
P3 --> SCAN
P4 --> SCAN
P5 --> SCAN
P6 --> SCAN
SCAN --> DET[Detection objects<br/>TopicMeta / HttpDetection / GrpcDetection]
DET --> O
O --> CT[ExtractedContract array]
```
The orchestrator never imports a grammar. Adding a new language /
framework = drop one file in `*-patterns/`, register it in
`index.ts`. No orchestrator edits required.
## Manifest extraction
```mermaid
flowchart TD
Y[group.yaml links] --> ME[ManifestExtractor]
ME --> LOOP{for each link}
LOOP --> RES[resolveSymbol<br/>label-scoped Cypher]
RES --> OK{found?}
OK -->|yes| REF[real symbol uid + ref]
OK -->|no| SYN[synthetic uid<br/>manifest::repo::cid]
REF --> EMIT[emit provider + consumer<br/>Contract objects<br/>+ CrossLink]
SYN --> EMIT
EMIT --> BRIDGE[(bridge.lbug<br/>#795)]
```
Label-scoped queries in `resolveSymbol` keep accidental cross-matches
out:
- `topic``(n:Function|Method|Class|Interface)`
- `grpc` method → `(n:Function|Method)`, service → `(n:Class|Interface)`
- `lib``(n:Package|Module)`
## Cross-impact query (PR #606)
```mermaid
flowchart TD
U[User changes symbol S<br/>in repo R] --> LI[Local impact engine<br/>per-repo uid expansion]
LI --> IDS[Affected uid set]
IDS --> BR[Bridge query<br/>MATCH Contract WHERE uid IN ids]
BR --> CL[CrossLink traversal]
CL --> OTHER[Matching contract in<br/>other repo]
OTHER --> FE[Fan-out impact<br/>to consuming repo]
FE --> OUT[CrossRepoImpact<br/>per affected repo]
```
The bridge stores every extracted contract keyed by `symbolUid`.
Manifest-sourced contracts use the synthetic uid form so both sides
of the `(local impact) ↔ (bridge query)` join derive the same uid
without coordinating through any shared state.

View file

@ -0,0 +1,588 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import lbug from '@ladybugdb/core';
import type { LbugValue } from '@ladybugdb/core';
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
export function contractNodeId(
repo: string,
contractId: string,
role: string,
filePath: string,
): string {
return createHash('sha256').update(`${repo}\0${contractId}\0${role}\0${filePath}`).digest('hex');
}
/* ------------------------------------------------------------------ */
/* ContractLookupIndex — in-memory lookup for findContractNode */
/* ------------------------------------------------------------------ */
/**
* In-memory index of contract node IDs keyed three ways, mirroring the
* three-tier fallback lookup in {@link findContractNode}. Built once per
* `writeBridge` call after all contracts are successfully inserted, then
* consulted for every cross-link which eliminates the former N+1 query
* pattern (up to `6 × cross-links` DB round-trips) and turns cross-link
* resolution into constant-time per link.
*
* Keys are deliberately flat strings (not tuples) so `Map<string, ...>`
* works; the separator `\0` can't occur in any legal repo path / file
* path / symbol identifier, which makes the encoding injection-safe.
*/
export interface ContractLookupIndex {
/** tier 1: `repo + role + symbolUid` → contract node id */
byUid: Map<string, string>;
/** tier 2: `repo + role + filePath + symbolName` → contract node id */
byRef: Map<string, string>;
/** tier 3: `repo + role + filePath` → list of contract node ids in that file */
byFile: Map<string, string[]>;
}
export function createContractLookupIndex(): ContractLookupIndex {
return {
byUid: new Map(),
byRef: new Map(),
byFile: new Map(),
};
}
function uidKey(repo: string, role: string, symbolUid: string): string {
return `${repo}\0${role}\0${symbolUid}`;
}
function refKey(repo: string, role: string, filePath: string, symbolName: string): string {
return `${repo}\0${role}\0${filePath}\0${symbolName}`;
}
function fileKey(repo: string, role: string, filePath: string): string {
return `${repo}\0${role}\0${filePath}`;
}
/**
* Add a successfully-inserted contract to the lookup index. Must be called
* AFTER the DB insert succeeds (not before) so failed inserts don't poison
* the index and cause cross-links to point at non-existent rows.
*/
export function indexContract(
index: ContractLookupIndex,
contract: StoredContract,
nodeId: string,
): void {
if (contract.symbolUid) {
index.byUid.set(uidKey(contract.repo, contract.role, contract.symbolUid), nodeId);
}
index.byRef.set(
refKey(contract.repo, contract.role, contract.symbolRef.filePath, contract.symbolRef.name),
nodeId,
);
const fk = fileKey(contract.repo, contract.role, contract.symbolRef.filePath);
const existing = index.byFile.get(fk);
if (existing) {
existing.push(nodeId);
} else {
index.byFile.set(fk, [nodeId]);
}
}
/**
* Resolve a cross-link endpoint (consumer or provider reference) to an
* already-inserted contract node id. Returns `null` if no match the
* caller is expected to count that as a dropped link in `WriteBridgeReport`.
*
* The resolution order matches the pre-cache DB-query behavior:
* 1. exact `symbolUid` match in the same `(repo, role)` scope
* 2. exact `(filePath, symbolName)` match
* 3. if exactly one contract lives in the file that one (fallback for
* legacy graph-assisted extractors that couldn't resolve a symbol name)
*
* This is a pure function no I/O, no DB so it's trivial to unit-test
* in isolation (which was the reviewer's main clean-code concern on the
* original 35-line inner closure in `writeBridge`).
*/
export function findContractNode(
index: ContractLookupIndex,
repo: string,
role: 'consumer' | 'provider',
symbolUid: string,
filePath: string,
symbolName: string,
): string | null {
if (symbolUid) {
const uidHit = index.byUid.get(uidKey(repo, role, symbolUid));
if (uidHit !== undefined) return uidHit;
}
const refHit = index.byRef.get(refKey(repo, role, filePath, symbolName));
if (refHit !== undefined) return refHit;
const fileCandidates = index.byFile.get(fileKey(repo, role, filePath));
if (fileCandidates && fileCandidates.length === 1) return fileCandidates[0];
return null;
}
export async function openBridgeDb(dbPath: string): Promise<BridgeHandle> {
const parentDir = path.dirname(dbPath);
await fsp.mkdir(parentDir, { recursive: true });
const db = new lbug.Database(dbPath, 0, false, false); // writable
const conn = new lbug.Connection(db);
return { _db: db, _conn: conn, groupDir: parentDir } as BridgeHandle;
}
/**
* LadybugDB returns an error whose message contains this substring when a
* CREATE NODE TABLE or CREATE REL TABLE statement hits an already-existing
* table. LadybugDB DDL doesn't support IF NOT EXISTS, and its JS driver
* doesn't expose typed error codes, so we match on the message substring
* the same pattern used by `core/lbug/lbug-adapter.ts`. If a future
* LadybugDB release changes the wording, update this constant.
*/
const LBUG_ALREADY_EXISTS_MSG = 'already exists';
export async function ensureBridgeSchema(handle: BridgeHandle): Promise<void> {
const conn = handle._conn as lbug.Connection;
for (const q of BRIDGE_SCHEMA_QUERIES) {
try {
await conn.query(q);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes(LBUG_ALREADY_EXISTS_MSG)) throw err;
}
}
}
export async function queryBridge<T>(
handle: BridgeHandle,
cypher: string,
params?: Record<string, LbugValue>,
): Promise<T[]> {
const conn = handle._conn as lbug.Connection;
if (params && Object.keys(params).length > 0) {
const stmt = await conn.prepare(cypher);
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
throw new Error(`Bridge query prepare failed: ${errMsg}`);
}
const queryResult = await conn.execute(stmt, params);
const result = unwrapQueryResult(queryResult);
return (await result.getAll()) as T[];
}
const queryResult = await conn.query(cypher);
const result = unwrapQueryResult(queryResult);
return (await result.getAll()) as T[];
}
/**
* LadybugDB's `conn.query` / `conn.execute` can return either a single
* `QueryResult` (for a single statement) or an array of them (when a
* multi-statement script is dispatched). We always pass a single statement,
* so the array form is a wrapper we unwrap here but an empty top-level
* array would cause `.getAll()` on `undefined` and crash with a confusing
* stack. Throwing an explicit error makes a driver-contract regression
* visible immediately instead of masking it.
*/
function unwrapQueryResult(queryResult: lbug.QueryResult | lbug.QueryResult[]): lbug.QueryResult {
if (Array.isArray(queryResult)) {
if (queryResult.length === 0) {
throw new Error('Bridge query returned an empty QueryResult array');
}
return queryResult[0];
}
return queryResult;
}
export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
try {
await (handle._conn as lbug.Connection).close();
} catch {
/* ignore */
}
try {
await (handle._db as lbug.Database).close();
} catch {
/* ignore */
}
}
/* ------------------------------------------------------------------ */
/* retryRename — handles transient EBUSY/EPERM/EACCES on Windows */
/* ------------------------------------------------------------------ */
const RETRY_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
export async function retryRename(src: string, dst: string, attempts = 3): Promise<void> {
for (let i = 1; i <= attempts; i++) {
try {
await fsp.rename(src, dst);
return;
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (!code || !RETRY_CODES.has(code) || i === attempts) throw err;
await new Promise((r) => setTimeout(r, 100 * Math.pow(2, i - 1)));
}
}
}
/* ------------------------------------------------------------------ */
/* writeBridgeMeta / readBridgeMeta */
/* ------------------------------------------------------------------ */
export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise<void> {
const target = path.join(groupDir, 'meta.json');
const tmp = `${target}.tmp.${Date.now()}`;
await fsp.writeFile(tmp, JSON.stringify(meta, null, 2), 'utf-8');
// Use retryRename for consistency with writeBridge's atomic swap — on
// Windows a concurrent reader can cause EBUSY/EPERM even on a tiny
// meta.json, and we don't want meta write to be less robust than the
// bridge.lbug swap it accompanies.
await retryRename(tmp, target);
}
export async function readBridgeMeta(groupDir: string): Promise<BridgeMeta> {
try {
const content = await fsp.readFile(path.join(groupDir, 'meta.json'), 'utf-8');
return JSON.parse(content) as BridgeMeta;
} catch {
return { version: 0, generatedAt: '', missingRepos: [] };
}
}
/* ------------------------------------------------------------------ */
/* writeBridge — atomic write-to-temp-then-rename */
/* ------------------------------------------------------------------ */
export interface WriteBridgeInput {
contracts: StoredContract[];
crossLinks: CrossLink[];
repoSnapshots: Record<string, RepoSnapshot>;
missingRepos: string[];
}
/**
* Non-fatal issues encountered during writeBridge. Callers can log these to
* surface partial-success state without aborting the whole sync.
* `sampleErrors` is capped at MAX_SAMPLE_ERRORS per category to bound memory.
*/
export interface WriteBridgeReport {
contractsInserted: number;
contractsFailed: number;
snapshotsInserted: number;
snapshotsFailed: number;
linksInserted: number;
linksFailed: number;
/** Cross-links skipped because their from/to contract nodes weren't found. */
linksDroppedMissingNode: number;
sampleErrors: Array<{
kind: 'contract' | 'snapshot' | 'link';
id: string;
message: string;
}>;
}
const MAX_SAMPLE_ERRORS = 10;
function errMessage(err: unknown): string {
if (err instanceof Error) return err.message;
try {
return String(err);
} catch {
return 'unknown error';
}
}
export async function writeBridge(
groupDir: string,
input: WriteBridgeInput,
): Promise<WriteBridgeReport> {
await fsp.mkdir(groupDir, { recursive: true });
const contracts = dedupeContracts(input.contracts);
const crossLinks = dedupeCrossLinks(input.crossLinks);
const finalPath = path.join(groupDir, 'bridge.lbug');
const tmpPath = path.join(groupDir, 'bridge.lbug.tmp');
const bakPath = path.join(groupDir, 'bridge.lbug.bak');
const report: WriteBridgeReport = {
contractsInserted: 0,
contractsFailed: 0,
snapshotsInserted: 0,
snapshotsFailed: 0,
linksInserted: 0,
linksFailed: 0,
linksDroppedMissingNode: 0,
sampleErrors: [],
};
const recordError = (kind: 'contract' | 'snapshot' | 'link', id: string, err: unknown) => {
if (report.sampleErrors.length < MAX_SAMPLE_ERRORS) {
report.sampleErrors.push({ kind, id, message: errMessage(err) });
}
};
// Clean up any leftover tmp
try {
await fsp.rm(tmpPath, { recursive: true, force: true });
} catch {
/* ignore */
}
// 1. Create temp DB, insert all data.
//
// Everything after `openBridgeDb` must run inside a try/finally so that
// if ANY step before the explicit `closeBridgeDb` throws — schema
// creation, a contract insert loop that rethrows, a snapshot write, the
// cross-link loop, or anything else — the handle is still released. A
// leaked handle holds the native LadybugDB file lock on tmpPath, which
// (a) leaks a FD and (b) prevents the next writeBridge call from
// reusing the same tmp slot.
const handle = await openBridgeDb(tmpPath);
let handleClosed = false;
try {
await ensureBridgeSchema(handle);
// Build the lookup index incrementally as contracts are inserted, so
// failed inserts are never in the index (and therefore never resolved
// by the cross-link loop below). This replaces a previous N+1 query
// pattern where each link made up to 6 DB round-trips to find its
// endpoints — see ContractLookupIndex.
const lookupIndex = createContractLookupIndex();
// Insert contracts — tolerate individual failures (e.g., a corrupt meta
// that can't be serialized). The whole sync must not fail because one
// contract is broken.
for (const c of contracts) {
const id = contractNodeId(c.repo, c.contractId, c.role, c.symbolRef.filePath);
try {
await queryBridge(
handle,
`CREATE (n:Contract {
id: $id,
contractId: $contractId,
type: $type,
role: $role,
repo: $repo,
service: $service,
symbolUid: $symbolUid,
filePath: $filePath,
symbolName: $symbolName,
confidence: $confidence,
meta: $meta
})`,
{
id,
contractId: c.contractId,
type: c.type,
role: c.role,
repo: c.repo,
service: c.service ?? '',
symbolUid: c.symbolUid,
filePath: c.symbolRef.filePath,
symbolName: c.symbolName,
confidence: c.confidence,
meta: JSON.stringify(c.meta),
},
);
report.contractsInserted++;
// Only index on successful insert — the cross-link loop must never
// resolve to a row that isn't actually in the DB.
indexContract(lookupIndex, c, id);
} catch (err) {
report.contractsFailed++;
recordError('contract', id, err);
}
}
// Insert repo snapshots
for (const [repoId, snap] of Object.entries(input.repoSnapshots)) {
try {
await queryBridge(
handle,
`CREATE (s:RepoSnapshot {
id: $id,
indexedAt: $indexedAt,
lastCommit: $lastCommit
})`,
{
id: repoId,
indexedAt: snap.indexedAt,
lastCommit: snap.lastCommit,
},
);
report.snapshotsInserted++;
} catch (err) {
report.snapshotsFailed++;
recordError('snapshot', repoId, err);
}
}
// Insert cross-links (tolerating missing nodes).
//
// `findContractNode` consults the in-memory lookup index built above,
// not the DB — that's an O(1) pure-function lookup per endpoint instead
// of the previous 2-3 DB queries. For M cross-links, the previous code
// issued up to 6M round-trips; this version issues zero.
//
// `link.contractId` may differ between the consumer and provider sides
// (e.g. wildcard consumer `grpc::Service/*` → method-level provider
// `grpc::Service/Method`) — that's why we resolve each endpoint
// independently via its own `(repo, role, symbolUid, filePath, symbolName)`
// tuple rather than matching on contractId.
for (const link of crossLinks) {
const linkId = `${link.from.repo}::${link.contractId}->${link.to.repo}::${link.contractId}`;
try {
const fromId = findContractNode(
lookupIndex,
link.from.repo,
'consumer',
link.from.symbolUid,
link.from.symbolRef.filePath,
link.from.symbolRef.name,
);
const toId = findContractNode(
lookupIndex,
link.to.repo,
'provider',
link.to.symbolUid,
link.to.symbolRef.filePath,
link.to.symbolRef.name,
);
if (!fromId || !toId) {
report.linksDroppedMissingNode++;
continue;
}
await queryBridge(
handle,
`
MATCH (a:Contract), (b:Contract)
WHERE a.id = $fromId AND b.id = $toId
CREATE (a)-[:ContractLink {
matchType: $matchType,
confidence: $confidence,
contractId: $contractId,
fromRepo: $fromRepo,
toRepo: $toRepo
}]->(b)
`,
{
fromId,
toId,
matchType: link.matchType,
confidence: link.confidence,
contractId: link.contractId,
fromRepo: link.from.repo,
toRepo: link.to.repo,
},
);
report.linksInserted++;
} catch (err) {
report.linksFailed++;
recordError('link', linkId, err);
}
}
// 2. Close temp DB (happy path). The finally block also calls
// closeBridgeDb if we threw above; `handleClosed` prevents a
// double-close on the native handle.
await closeBridgeDb(handle);
handleClosed = true;
} finally {
if (!handleClosed) {
await closeBridgeDb(handle).catch(() => {
/* ignore: cleanup path, best effort */
});
}
}
// 3. Atomic swap: old→.bak, tmp→final, rm .bak
try {
await fsp.access(finalPath);
await retryRename(finalPath, bakPath);
} catch {
/* no existing db */
}
await retryRename(tmpPath, finalPath);
try {
await fsp.rm(bakPath, { recursive: true, force: true });
} catch {
/* ignore */
}
// 4. Write meta.json
await writeBridgeMeta(groupDir, {
version: BRIDGE_SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
missingRepos: input.missingRepos,
});
return report;
}
/* ------------------------------------------------------------------ */
/* openBridgeDbReadOnly */
/* ------------------------------------------------------------------ */
export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHandle | null> {
const dbPath = path.join(groupDir, 'bridge.lbug');
try {
await fsp.access(dbPath);
} catch {
// Check for .bak recovery. Use `retryRename` (not `fsp.rename`) for the
// exact same reason the rest of this file does: the scenario that
// triggers bak recovery is an interrupted writer, which on Windows may
// still be holding an open handle on `.bak` for a few milliseconds when
// a reader races in. EBUSY/EPERM retries recover that case silently.
const bakPath = path.join(groupDir, 'bridge.lbug.bak');
try {
await fsp.access(bakPath);
await retryRename(bakPath, dbPath);
} catch {
return null;
}
}
// Version gate: check meta.json version compatibility
const meta = await readBridgeMeta(groupDir);
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
return null; // incompatible schema version — fallback to JSON or re-sync
}
// Open the native handle. If Connection construction throws AFTER
// Database was successfully allocated, we'd leak the native Database
// object. Wrap each step separately and tear down the partial handle.
let db: lbug.Database | undefined;
let conn: lbug.Connection | undefined;
try {
db = new lbug.Database(dbPath, 0, false, true); // readOnly
conn = new lbug.Connection(db);
return { _db: db, _conn: conn, groupDir } as BridgeHandle;
} catch {
if (conn) {
try {
await conn.close();
} catch {
/* ignore */
}
}
if (db) {
try {
await db.close();
} catch {
/* ignore */
}
}
return null;
}
}
/* ------------------------------------------------------------------ */
/* bridgeExists */
/* ------------------------------------------------------------------ */
export async function bridgeExists(groupDir: string): Promise<boolean> {
const handle = await openBridgeDbReadOnly(groupDir);
if (!handle) return false;
await closeBridgeDb(handle);
return true;
}

View file

@ -0,0 +1,60 @@
/**
* Bridge LadybugDB schema for cross-repo Contract Registry.
* Separate from per-repo schema in lbug/schema.ts.
*/
/**
* Version of the bridge.lbug schema below. `openBridgeDbReadOnly` compares
* this against `meta.json`'s version field and returns `null` on mismatch,
* which trips the caller into either the JSON fallback path or a fresh
* `group sync` that rebuilds `bridge.lbug` from scratch.
*
* Migration contract for contributors bumping this constant:
* 1. Bump the number (e.g. `1` `2`).
* 2. Update the DDL below to match the new schema.
* 3. DO NOT attempt an online migration in this file the version gate
* is intentionally a "discard and re-sync" strategy for V1. An old
* bridge.lbug whose version doesn't match is treated as opaque and
* rebuilt by the next `group sync`.
* 4. If online migration becomes necessary (e.g. when groups accumulate
* large amounts of embedding data), add a migration path as a
* separate `bridge-migrations.ts` module rather than bloating this
* file keep schema and migration concerns separate.
*/
export const BRIDGE_SCHEMA_VERSION = 1;
export const CONTRACT_SCHEMA = `
CREATE NODE TABLE Contract (
id STRING,
contractId STRING,
type STRING,
role STRING,
repo STRING,
service STRING DEFAULT '',
symbolUid STRING DEFAULT '',
filePath STRING DEFAULT '',
symbolName STRING DEFAULT '',
confidence DOUBLE DEFAULT 0.0,
meta STRING DEFAULT '{}',
PRIMARY KEY (id)
)`;
export const REPO_SNAPSHOT_SCHEMA = `
CREATE NODE TABLE RepoSnapshot (
id STRING,
indexedAt STRING DEFAULT '',
lastCommit STRING DEFAULT '',
PRIMARY KEY (id)
)`;
export const CONTRACT_LINK_SCHEMA = `
CREATE REL TABLE ContractLink (
FROM Contract TO Contract,
matchType STRING,
confidence DOUBLE,
contractId STRING,
fromRepo STRING,
toRepo STRING
)`;
export const BRIDGE_SCHEMA_QUERIES = [CONTRACT_SCHEMA, REPO_SNAPSHOT_SCHEMA, CONTRACT_LINK_SCHEMA];

View file

@ -0,0 +1,23 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
/**
* Safely read a file inside a repo, rejecting any path that escapes
* `repoPath` via `..` traversal or absolute segments. Returns `null` if
* the path is outside the repo or the file can't be read.
*
* Used by every source-scan extractor under this directory. Kept as a
* single shared implementation so the path-traversal guard (security-
* sensitive) lives in exactly one place.
*/
export function readSafe(repoPath: string, rel: string): string | null {
const abs = path.resolve(repoPath, rel);
const base = path.resolve(repoPath);
const relToBase = path.relative(base, abs);
if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
try {
return fs.readFileSync(abs, 'utf-8');
} catch {
return null;
}
}

View file

@ -1,20 +1,38 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { glob } from 'glob';
import Parser from 'tree-sitter';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
import {
GRPC_SCAN_GLOB,
getPluginForFile,
hasProtoPlugin,
type GrpcDetection,
} from './grpc-patterns/index.js';
function readSafe(repoPath: string, rel: string): string | null {
const abs = path.resolve(repoPath, rel);
const base = path.resolve(repoPath);
const relToBase = path.relative(base, abs);
if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
try {
return fs.readFileSync(abs, 'utf-8');
} catch {
return null;
}
}
/**
* Language-agnostic orchestrator for gRPC (provider + consumer) contract
* extraction.
*
* Two parts:
*
* 1. **`.proto` parsing** tree-sitter when `tree-sitter-proto` is
* installed (optionalDependency vendored in `vendor/tree-sitter-proto/`),
* via the `.proto` entry in `grpc-patterns/` and `hasProtoPlugin`.
* When the grammar isn't available (platform incompatibility, native
* build failure) the orchestrator falls back to the in-process
* string-sanitizing parser defined below (`stripProtoCommentsAndStrings`
* + `extractServiceBlocks`). The fallback preserves offsets so any
* downstream regex scans run against a sanitized copy without
* affecting line numbers of the original.
*
* 2. **Source-scan providers / consumers** delegated to per-language
* plugins in `./grpc-patterns/`. The orchestrator imports NO
* tree-sitter grammars or query strings each plugin owns its own.
*/
// ─── .proto fallback parser (used only when tree-sitter-proto is absent) ───
function contractId(pkg: string, service: string, method: string): string {
const prefix = pkg ? `${pkg}.${service}` : service;
@ -25,20 +43,110 @@ function serviceOnlyContractId(serviceName: string): string {
return `grpc::${serviceName}/*`;
}
/**
* Replace all .proto comments and string literals with spaces, preserving the
* original length and character offsets of the input. This lets downstream
* regex / brace-depth parsers run on a "sanitized" copy without having to
* understand proto syntax, while any RegExp.exec/index-based lookups that
* were already positional against `content` continue to work against the
* original string.
*
* Supported comment forms: `// line comment`, `/* block comment * /`.
* Supported strings: double-quoted ("…") and single-quoted ('…') with `\`
* escape handling. Raw/unterminated strings are not supported we stop
* on a line break for line-style comments and on EOF for unterminated
* strings/blocks, which matches how most real proto files parse.
*/
function stripProtoCommentsAndStrings(content: string): string {
const out = new Array<string>(content.length);
let i = 0;
while (i < content.length) {
const ch = content[i];
const next = content[i + 1];
// Line comment: // ... \n
if (ch === '/' && next === '/') {
out[i] = ' ';
out[i + 1] = ' ';
i += 2;
while (i < content.length && content[i] !== '\n') {
out[i] = content[i] === '\r' ? '\r' : ' ';
i++;
}
continue;
}
// Block comment: /* ... */
if (ch === '/' && next === '*') {
out[i] = ' ';
out[i + 1] = ' ';
i += 2;
while (i < content.length) {
if (content[i] === '*' && content[i + 1] === '/') {
out[i] = ' ';
out[i + 1] = ' ';
i += 2;
break;
}
// Preserve newlines so line numbers stay stable for downstream code.
out[i] = content[i] === '\n' || content[i] === '\r' ? content[i] : ' ';
i++;
}
continue;
}
// String literal: "..." or '...'
if (ch === '"' || ch === "'") {
const quote = ch;
out[i] = ' '; // replace opening quote
i++;
while (i < content.length) {
const c = content[i];
if (c === '\\' && i + 1 < content.length) {
// Skip escaped pair (e.g. \" \n \\)
out[i] = ' ';
out[i + 1] = ' ';
i += 2;
continue;
}
if (c === quote) {
out[i] = ' ';
i++;
break;
}
// Preserve newlines; proto technically disallows unescaped newlines
// inside strings, but real files occasionally have them.
out[i] = c === '\n' || c === '\r' ? c : ' ';
i++;
}
continue;
}
out[i] = ch;
i++;
}
return out.join('');
}
function extractServiceBlocks(content: string): Array<{ name: string; body: string }> {
const results: Array<{ name: string; body: string }> = [];
// v1: brace-depth only — braces inside comments or string literals are not filtered (see spec Fix 2)
// Sanitize comments and string literals so braces inside them don't
// throw off the depth counter. The sanitized copy has the same length
// and offsets as the original, so we use it ONLY to scan for service
// headers and braces; the service body we return is sliced from the
// ORIGINAL content to preserve exact source text for downstream use.
const sanitized = stripProtoCommentsAndStrings(content);
const headerRe = /service\s+(\w+)\s*\{/g;
let headerMatch: RegExpExecArray | null;
while ((headerMatch = headerRe.exec(content)) !== null) {
while ((headerMatch = headerRe.exec(sanitized)) !== null) {
const serviceName = headerMatch[1];
const bodyStart = headerMatch.index + headerMatch[0].length;
let depth = 1;
let pos = bodyStart;
while (pos < content.length && depth > 0) {
const ch = content[pos];
while (pos < sanitized.length && depth > 0) {
const ch = sanitized[pos];
if (ch === '{') depth++;
else if (ch === '}') depth--;
pos++;
@ -75,6 +183,165 @@ function makeContract(
};
}
export interface ProtoServiceInfo {
package: string;
serviceName: string;
methods: string[];
protoPath: string;
}
function normalizeProtoPath(rel: string): string {
return rel.replace(/\\/g, '/');
}
function extractProtoImports(content: string): string[] {
const imports: string[] = [];
const re = /^\s*import\s+"([^"]+)"\s*;/gm;
let match: RegExpExecArray | null;
while ((match = re.exec(content)) !== null) {
imports.push(match[1]);
}
return imports;
}
function longestSharedSegmentRun(aPath: string, bPath: string): number {
const a = aPath.split('/').filter(Boolean);
const b = bPath.split('/').filter(Boolean);
let best = 0;
for (let i = 0; i < a.length; i++) {
for (let j = 0; j < b.length; j++) {
let run = 0;
while (a[i + run] && b[j + run] && a[i + run] === b[j + run]) {
run++;
}
if (run > best) best = run;
}
}
return best;
}
async function buildProtoContext(repoPath: string): Promise<{
packagesByProto: Map<string, string>;
servicesByName: Map<string, ProtoServiceInfo[]>;
}> {
const servicesByName = new Map<string, ProtoServiceInfo[]>();
const protoFiles = await glob('**/*.proto', {
cwd: repoPath,
absolute: false,
nodir: true,
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'],
});
const contents = new Map<string, string>();
for (const rel of protoFiles) {
const content = readSafe(repoPath, rel);
if (!content) continue;
contents.set(normalizeProtoPath(rel), content);
}
const packagesByProto = new Map<string, string>();
const resolvePackage = (protoPath: string, seen = new Set<string>()): string => {
if (packagesByProto.has(protoPath)) return packagesByProto.get(protoPath) ?? '';
if (seen.has(protoPath)) return '';
const content = contents.get(protoPath);
if (!content) return '';
seen.add(protoPath);
const pkgMatch = content.match(/^\s*package\s+([\w.]+)\s*;/m);
if (pkgMatch?.[1]) {
packagesByProto.set(protoPath, pkgMatch[1]);
return pkgMatch[1];
}
for (const importPath of extractProtoImports(content)) {
const normalizedImport = normalizeProtoPath(importPath);
const candidates = [
normalizeProtoPath(
path.posix.normalize(path.posix.join(path.posix.dirname(protoPath), normalizedImport)),
),
normalizedImport,
];
for (const candidate of candidates) {
if (!contents.has(candidate)) continue;
const inheritedPackage = resolvePackage(candidate, seen);
if (inheritedPackage) {
packagesByProto.set(protoPath, inheritedPackage);
return inheritedPackage;
}
}
}
packagesByProto.set(protoPath, '');
return '';
};
for (const rel of protoFiles) {
const normalizedRel = normalizeProtoPath(rel);
const content = contents.get(normalizedRel);
if (!content) continue;
const pkg = resolvePackage(normalizedRel);
const serviceBlocks = extractServiceBlocks(content);
for (const block of serviceBlocks) {
const rpcRe = /rpc\s+(\w+)\s*\(/g;
const methods: string[] = [];
let m: RegExpExecArray | null;
while ((m = rpcRe.exec(block.body)) !== null) {
methods.push(m[1]);
}
const info: ProtoServiceInfo = {
package: pkg,
serviceName: block.name,
methods,
protoPath: normalizedRel,
};
const existing = servicesByName.get(block.name) ?? [];
existing.push(info);
servicesByName.set(block.name, existing);
}
}
return { packagesByProto, servicesByName };
}
export async function buildProtoMap(repoPath: string): Promise<Map<string, ProtoServiceInfo[]>> {
const { servicesByName } = await buildProtoContext(repoPath);
return servicesByName;
}
export function resolveProtoConflict(
_serviceName: string,
sourceFilePath: string,
candidates: ProtoServiceInfo[],
): ProtoServiceInfo | null {
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
const sourceDir = normalizeProtoPath(path.dirname(sourceFilePath));
let best = candidates[0];
let bestScore = -1;
for (const c of candidates) {
const protoDir = normalizeProtoPath(path.dirname(c.protoPath));
const sharedRun = longestSharedSegmentRun(sourceDir, protoDir);
if (sharedRun > bestScore) {
bestScore = sharedRun;
best = c;
}
}
return best;
}
export function serviceContractId(pkg: string, serviceName: string): string {
const prefix = pkg ? `${pkg}.${serviceName}` : serviceName;
return `grpc::${prefix}/*`;
}
// ─── Orchestrator ────────────────────────────────────────────────────
export class GrpcExtractor implements ContractExtractor {
type = 'grpc' as const;
@ -88,270 +355,111 @@ export class GrpcExtractor implements ContractExtractor {
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
const out: ExtractedContract[] = [];
const protoContext = await buildProtoContext(repoPath);
const protoMap = protoContext.servicesByName;
// Proto files — definitive provider source
const protoFiles = await glob('**/*.proto', {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'],
nodir: true,
});
for (const rel of protoFiles) {
const content = readSafe(repoPath, rel);
if (content) out.push(...this.parseProtoFile(content, rel));
// ─── Proto files — definitive provider source ─────────────────
// When tree-sitter-proto is available, .proto files are handled by
// the plugin loop below (they're in GRPC_SCAN_GLOB). Otherwise
// emit provider contracts directly from the proto map that
// `buildProtoContext` already built — no second glob / parse pass.
if (!hasProtoPlugin) {
for (const infos of protoMap.values()) {
for (const info of infos) {
for (const methodName of info.methods) {
const cid = contractId(info.package, info.serviceName, methodName);
out.push(
makeContract(
cid,
'provider',
info.protoPath,
`${info.serviceName}.${methodName}`,
0.85,
{
package: info.package,
service: info.serviceName,
method: methodName,
source: 'proto',
},
),
);
}
}
}
}
// Source files — server/client detection
const sourceFiles = await glob('**/*.{go,java,py,ts,tsx,js,jsx}', {
// ─── Source files (+ .proto when plugin available) ────────────
const sourceFiles = await glob(GRPC_SCAN_GLOB, {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
nodir: true,
});
const parser = new Parser();
for (const rel of sourceFiles) {
const plugin = getPluginForFile(rel);
if (!plugin) continue;
const content = readSafe(repoPath, rel);
if (!content) continue;
const ext = path.extname(rel).toLowerCase();
if (ext === '.go') {
out.push(...this.scanGoProviders(content, rel));
out.push(...this.scanGoConsumers(content, rel));
} else if (ext === '.java') {
out.push(...this.scanJavaProviders(content, rel));
out.push(...this.scanJavaConsumers(content, rel));
} else if (ext === '.py') {
out.push(...this.scanPythonProviders(content, rel));
out.push(...this.scanPythonConsumers(content, rel));
} else if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
out.push(...this.scanTsProviders(content, rel));
let detections: GrpcDetection[] = [];
try {
parser.setLanguage(plugin.language);
const tree = parser.parse(content);
detections = plugin.scan(tree);
} catch {
continue;
}
for (const d of detections) {
out.push(this.detectionToContract(d, rel, protoMap));
}
}
return this.dedupe(out);
}
private parseProtoFile(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m);
const pkg = pkgMatch ? pkgMatch[1] : '';
for (const { name: serviceName, body } of extractServiceBlocks(content)) {
const rpcRe = /rpc\s+(\w+)\s*\(/g;
let rpcMatch: RegExpExecArray | null;
while ((rpcMatch = rpcRe.exec(body)) !== null) {
const methodName = rpcMatch[1];
const cid = contractId(pkg, serviceName, methodName);
out.push(
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.85, {
package: pkg,
service: serviceName,
method: methodName,
source: 'proto',
}),
);
}
}
return out;
}
private scanGoProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// pb.RegisterXxxServer(
const registerRe = /\w+\.Register(\w+)Server\s*\(/g;
let m: RegExpExecArray | null;
while ((m = registerRe.exec(content)) !== null) {
const serviceName = m[1];
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'provider',
filePath,
`Register${serviceName}Server`,
0.8,
{ service: serviceName, source: 'go_register' },
),
);
}
// pb.UnimplementedXxxServer
const unimplRe = /\w+\.Unimplemented(\w+)Server\b/g;
while ((m = unimplRe.exec(content)) !== null) {
const serviceName = m[1];
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'provider',
filePath,
`Unimplemented${serviceName}Server`,
0.8,
{ service: serviceName, source: 'go_unimplemented' },
),
);
}
return out;
}
private scanGoConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /\w+\.New(\w+)Client\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'consumer',
filePath,
`New${serviceName}Client`,
0.7,
{ service: serviceName, source: 'go_client' },
),
);
}
return out;
}
private scanJavaProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// @GrpcService
if (content.includes('@GrpcService')) {
const implBaseRe = /extends\s+(\w+)Grpc\.(\w+)ImplBase/;
const m = content.match(implBaseRe);
if (m) {
out.push(
makeContract(serviceOnlyContractId(m[1]), 'provider', filePath, m[2], 0.8, {
service: m[1],
source: 'java_grpc_service',
}),
);
} else {
// Try extracting service name from class name
const classRe =
/class\s+(\w*?)(?:Grpc)?(?:Service)?\s+extends\s+(\w+)(?:Grpc\.(\w+))?ImplBase/;
const cm = content.match(classRe);
if (cm) {
const svcName = cm[2].replace(/Grpc$/, '');
out.push(
makeContract(serviceOnlyContractId(svcName), 'provider', filePath, cm[1], 0.8, {
service: svcName,
source: 'java_grpc_service',
}),
);
}
}
}
// extends XxxImplBase (without @GrpcService)
if (!content.includes('@GrpcService')) {
const implRe = /extends\s+(\w+?)(?:Grpc\.(\w+))?ImplBase/;
const m = content.match(implRe);
if (m) {
const svcName = m[2] || m[1].replace(/Grpc$/, '');
out.push(
makeContract(serviceOnlyContractId(svcName), 'provider', filePath, svcName, 0.8, {
service: svcName,
source: 'java_impl_base',
}),
);
}
}
return out;
}
private scanJavaConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// XxxGrpc.newBlockingStub( or XxxGrpc.newStub(
const re = /(\w+)Grpc\.new(?:Blocking)?Stub\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'consumer',
filePath,
`${serviceName}Stub`,
0.7,
{ service: serviceName, source: 'java_stub' },
),
);
}
return out;
}
private scanPythonProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// add_XxxServicer_to_server(
const re = /add_(\w+?)Servicer_to_server\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'provider',
filePath,
`add_${serviceName}Servicer_to_server`,
0.8,
{ service: serviceName, source: 'python_servicer' },
),
);
}
return out;
}
private scanPythonConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// XxxStub(
const re = /(\w+)Stub\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const name = m[1];
// Filter out common false positives
if (['Mock', 'Test', 'Fake', 'Stub'].includes(name)) continue;
out.push(
makeContract(serviceOnlyContractId(name), 'consumer', filePath, `${name}Stub`, 0.7, {
service: name,
source: 'python_stub',
}),
);
}
return out;
}
private scanTsProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// @GrpcMethod('ServiceName', 'MethodName')
const re = /@GrpcMethod\s*\(\s*['"](\w+)['"]\s*,\s*['"](\w+)['"]\s*\)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
const methodName = m[2];
const cid = contractId('', serviceName, methodName);
out.push(
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.8, {
service: serviceName,
method: methodName,
source: 'ts_grpc_method',
}),
);
}
return out;
/**
* Convert a plugin `GrpcDetection` into a concrete `ExtractedContract`
* by resolving the short service name against the proto map, building
* either a service-level (`grpc::pkg.Svc/*`) or method-level
* (`grpc::pkg.Svc/Method`) contract id, and selecting confidence
* based on whether the proto map had an entry.
*/
private detectionToContract(
d: GrpcDetection,
filePath: string,
protoMap: Map<string, ProtoServiceInfo[]>,
): ExtractedContract {
const candidates = protoMap.get(d.serviceName);
const proto = resolveProtoConflict(d.serviceName, filePath, candidates ?? []);
const pkg = proto?.package ?? '';
const cid = d.methodName
? contractId(pkg, d.serviceName, d.methodName)
: proto
? serviceContractId(pkg, d.serviceName)
: serviceOnlyContractId(d.serviceName);
const confidence = proto ? d.confidenceWithProto : d.confidenceWithoutProto;
const meta: Record<string, unknown> = {
service: d.serviceName,
source: d.source,
};
if (d.methodName) meta.method = d.methodName;
return makeContract(cid, d.role, filePath, d.symbolName, confidence, meta);
}
private dedupe(items: ExtractedContract[]): ExtractedContract[] {
const seen = new Set<string>();
const out: ExtractedContract[] = [];
const byKey = new Map<string, ExtractedContract>();
for (const c of items) {
const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(c);
const existing = byKey.get(k);
if (
!existing ||
c.confidence > existing.confidence ||
(c.confidence === existing.confidence &&
String(c.meta.source) < String(existing.meta.source))
) {
byKey.set(k, c);
}
}
return out;
return Array.from(byKey.values());
}
}

View file

@ -0,0 +1,109 @@
import Go from 'tree-sitter-go';
import {
compilePatterns,
runCompiledPatterns,
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import type { GrpcDetection, GrpcLanguagePlugin } from './types.js';
/**
* Go gRPC plugin. Detects:
* - Provider: `pb.RegisterXxxServer(...)` calls
* - Provider: `pb.UnimplementedXxxServer` embedded in a struct
* - Consumer: `pb.NewXxxClient(conn)` calls
*/
const REGISTER_RE = /^Register(\w+)Server$/;
const UNIMPLEMENTED_RE = /^Unimplemented(\w+)Server$/;
const NEW_CLIENT_RE = /^New(\w+)Client$/;
// Any `xxx.<fn>(...)` call — plugin filters the field identifier text.
const SELECTOR_CALL_PATTERNS = compilePatterns({
name: 'go-grpc-selector-call',
language: Go,
patterns: [
{
meta: {},
query: `
(call_expression
function: (selector_expression
field: (field_identifier) @fn))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// Any `qualified_type` used as a struct field — for `pb.UnimplementedXxxServer`.
const STRUCT_EMBEDDING_PATTERNS = compilePatterns({
name: 'go-grpc-struct-embedding',
language: Go,
patterns: [
{
meta: {},
query: `
(struct_type
(field_declaration_list
(field_declaration
type: (qualified_type
name: (type_identifier) @field_type))))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
export const GO_GRPC_PLUGIN: GrpcLanguagePlugin = {
name: 'go-grpc',
language: Go,
scan(tree) {
const out: GrpcDetection[] = [];
for (const match of runCompiledPatterns(SELECTOR_CALL_PATTERNS, tree)) {
const fnNode = match.captures.fn;
if (!fnNode) continue;
const fnText = fnNode.text;
const registerMatch = REGISTER_RE.exec(fnText);
if (registerMatch) {
out.push({
role: 'provider',
serviceName: registerMatch[1],
symbolName: fnText,
source: 'go_register',
confidenceWithProto: 0.8,
confidenceWithoutProto: 0.65,
});
continue;
}
const newClientMatch = NEW_CLIENT_RE.exec(fnText);
if (newClientMatch) {
out.push({
role: 'consumer',
serviceName: newClientMatch[1],
symbolName: fnText,
source: 'go_client',
confidenceWithProto: 0.75,
confidenceWithoutProto: 0.55,
});
continue;
}
}
for (const match of runCompiledPatterns(STRUCT_EMBEDDING_PATTERNS, tree)) {
const fieldNode = match.captures.field_type;
if (!fieldNode) continue;
const unimpl = UNIMPLEMENTED_RE.exec(fieldNode.text);
if (!unimpl) continue;
out.push({
role: 'provider',
serviceName: unimpl[1],
symbolName: fieldNode.text,
source: 'go_unimplemented',
confidenceWithProto: 0.8,
confidenceWithoutProto: 0.65,
});
}
return out;
},
};

View file

@ -0,0 +1,53 @@
import * as path from 'node:path';
import type { GrpcLanguagePlugin } from './types.js';
import { GO_GRPC_PLUGIN } from './go.js';
import { JAVA_GRPC_PLUGIN } from './java.js';
import { PYTHON_GRPC_PLUGIN } from './python.js';
import { JAVASCRIPT_GRPC_PLUGIN, TYPESCRIPT_GRPC_PLUGIN, TSX_GRPC_PLUGIN } from './node.js';
import { PROTO_GRPC_PLUGIN } from './proto.js';
export type { GrpcDetection, GrpcLanguagePlugin, GrpcRole } from './types.js';
export { PROTO_GRPC_PLUGIN, extractPackageFromTree } from './proto.js';
/**
* File-extension gRPC language plugin registry. Mirrors the shape
* of `http-patterns/index.ts` and `topic-patterns/index.ts`.
*
* `.proto` files are registered only when `tree-sitter-proto` is
* available (it's an optionalDependency). When absent, the orchestrator
* falls back to the built-in manual proto parser.
*/
const REGISTRY: Record<string, GrpcLanguagePlugin> = {
'.go': GO_GRPC_PLUGIN,
'.java': JAVA_GRPC_PLUGIN,
'.py': PYTHON_GRPC_PLUGIN,
'.js': JAVASCRIPT_GRPC_PLUGIN,
'.jsx': JAVASCRIPT_GRPC_PLUGIN,
'.ts': TYPESCRIPT_GRPC_PLUGIN,
'.tsx': TSX_GRPC_PLUGIN,
...(PROTO_GRPC_PLUGIN ? { '.proto': PROTO_GRPC_PLUGIN } : {}),
};
/**
* Glob for source files worth scanning for gRPC server/client patterns.
* Includes `.proto` when the grammar is available.
*/
export const GRPC_SCAN_GLOB = PROTO_GRPC_PLUGIN
? '**/*.{go,java,py,ts,tsx,js,jsx,proto}'
: '**/*.{go,java,py,ts,tsx,js,jsx}';
/**
* Whether the tree-sitter proto plugin is available. The orchestrator
* uses this to decide between the tree-sitter path and the fallback
* manual parser for `.proto` files.
*/
export const hasProtoPlugin = PROTO_GRPC_PLUGIN !== null;
/**
* Return the gRPC plugin registered for the given file's extension,
* or `undefined` if the extension is not registered.
*/
export function getPluginForFile(rel: string): GrpcLanguagePlugin | undefined {
const ext = path.extname(rel).toLowerCase();
return REGISTRY[ext];
}

View file

@ -0,0 +1,179 @@
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
import {
compilePatterns,
runCompiledPatterns,
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import type { GrpcDetection, GrpcLanguagePlugin } from './types.js';
/**
* Java gRPC plugin. Detects:
* - Provider: classes extending `XxxServiceGrpc.XxxServiceImplBase`
* (with or without a `@GrpcService` annotation; the annotation
* only affects confidence labelling in the original regex version
* here we emit a single detection per class and pick the source
* label based on whether the annotation is present).
* - Consumer: `XxxServiceGrpc.newBlockingStub(ch)` /
* `XxxServiceGrpc.newStub(ch)` calls.
*/
const IMPL_BASE_RE = /^(\w+)ImplBase$/;
const GRPC_SUFFIX_RE = /^(\w+)Grpc$/;
// Classes extending `ScopedType.ScopedType` where the inner name ends
// in ImplBase. Covers `XxxServiceGrpc.XxxServiceImplBase`.
// Note: tree-sitter-java's `scoped_type_identifier` exposes its two
// segments as positional `type_identifier` children, NOT as named
// `scope:`/`name:` fields. We match positionally here and rely on the
// grammar's left-to-right ordering: first child = outer, second = inner.
const SCOPED_IMPL_BASE_PATTERNS = compilePatterns({
name: 'java-grpc-scoped-impl-base',
language: Java,
patterns: [
{
meta: {},
query: `
(class_declaration
name: (identifier) @class_name
superclass: (superclass
(scoped_type_identifier
(type_identifier) @outer
(type_identifier) @inner (#match? @inner "ImplBase$")))) @class
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// Classes extending a simple `XxxImplBase` identifier (no scope).
const PLAIN_IMPL_BASE_PATTERNS = compilePatterns({
name: 'java-grpc-plain-impl-base',
language: Java,
patterns: [
{
meta: {},
query: `
(class_declaration
name: (identifier) @class_name
superclass: (superclass
(type_identifier) @plain_type (#match? @plain_type "ImplBase$"))) @class
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// gRPC stub factories: `XxxGrpc.newStub(ch)` / `XxxGrpc.newBlockingStub(ch)`.
const STUB_PATTERNS = compilePatterns({
name: 'java-grpc-stub',
language: Java,
patterns: [
{
meta: {},
query: `
(method_invocation
object: (identifier) @grpc_cls
name: (identifier) @method (#match? @method "^new(Blocking)?Stub$"))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
/**
* Check whether a `class_declaration` node has a `@GrpcService`
* annotation in its modifiers list. In tree-sitter-java, class-level
* annotations live under `(class_declaration (modifiers (marker_annotation|annotation)))`.
*/
function hasGrpcServiceAnnotation(classNode: Parser.SyntaxNode): boolean {
for (let i = 0; i < classNode.namedChildCount; i++) {
const child = classNode.namedChild(i);
if (!child || child.type !== 'modifiers') continue;
for (let j = 0; j < child.namedChildCount; j++) {
const mod = child.namedChild(j);
if (!mod) continue;
if (mod.type !== 'marker_annotation' && mod.type !== 'annotation') continue;
const nameNode = mod.childForFieldName('name');
if (nameNode?.text === 'GrpcService') return true;
}
}
return false;
}
/**
* Given the inner type_identifier text like `AuthServiceImplBase`,
* return the service name (`AuthService`), or null if the text
* doesn't end in `ImplBase`.
*/
function extractServiceFromImplBase(text: string): string | null {
const m = IMPL_BASE_RE.exec(text);
if (!m) return null;
// Strip a trailing `Grpc` on the service name too — the original
// regex replaces `Grpc$` on the extracted prefix.
return m[1].replace(/Grpc$/, '');
}
export const JAVA_GRPC_PLUGIN: GrpcLanguagePlugin = {
name: 'java-grpc',
language: Java,
scan(tree) {
const out: GrpcDetection[] = [];
const emittedClassIds = new Set<number>();
// ─── Providers: scoped form (`...Grpc.XxxImplBase`) ─────────────
for (const match of runCompiledPatterns(SCOPED_IMPL_BASE_PATTERNS, tree)) {
const classNode = match.captures.class;
const innerNode = match.captures.inner;
if (!classNode || !innerNode) continue;
const serviceName = extractServiceFromImplBase(innerNode.text);
if (!serviceName) continue;
emittedClassIds.add(classNode.id);
const annotated = hasGrpcServiceAnnotation(classNode);
out.push({
role: 'provider',
serviceName,
symbolName: serviceName,
source: annotated ? 'java_grpc_service' : 'java_impl_base',
confidenceWithProto: 0.8,
confidenceWithoutProto: 0.65,
});
}
// ─── Providers: plain form (`XxxImplBase`) ──────────────────────
for (const match of runCompiledPatterns(PLAIN_IMPL_BASE_PATTERNS, tree)) {
const classNode = match.captures.class;
const plainNode = match.captures.plain_type;
if (!classNode || !plainNode) continue;
if (emittedClassIds.has(classNode.id)) continue;
const serviceName = extractServiceFromImplBase(plainNode.text);
if (!serviceName) continue;
emittedClassIds.add(classNode.id);
const annotated = hasGrpcServiceAnnotation(classNode);
out.push({
role: 'provider',
serviceName,
symbolName: serviceName,
source: annotated ? 'java_grpc_service' : 'java_impl_base',
confidenceWithProto: 0.8,
confidenceWithoutProto: 0.65,
});
}
// ─── Consumers: `XxxGrpc.newBlockingStub(...)` / `newStub(...)` ─
for (const match of runCompiledPatterns(STUB_PATTERNS, tree)) {
const grpcClsNode = match.captures.grpc_cls;
if (!grpcClsNode) continue;
const grpcMatch = GRPC_SUFFIX_RE.exec(grpcClsNode.text);
if (!grpcMatch) continue;
const serviceName = grpcMatch[1];
out.push({
role: 'consumer',
serviceName,
symbolName: `${serviceName}Stub`,
source: 'java_stub',
confidenceWithProto: 0.75,
confidenceWithoutProto: 0.55,
});
}
return out;
},
};

View file

@ -0,0 +1,314 @@
import Parser from 'tree-sitter';
import JavaScript from 'tree-sitter-javascript';
import TypeScript from 'tree-sitter-typescript';
import {
compilePatterns,
runCompiledPatterns,
unquoteLiteral,
type CompiledPatterns,
type LanguagePatterns,
type PatternSpec,
} from '../tree-sitter-scanner.js';
import type { GrpcDetection, GrpcLanguagePlugin } from './types.js';
/**
* Node.js / TypeScript gRPC plugin family. Detects:
* - Provider: NestJS `@GrpcMethod('Service', 'Method')` decorators
* - Consumer: NestJS `@GrpcClient(...) readonly x!: XxxServiceClient`
* - Consumer: `client.getService<X>('AuthService')`
* - Consumer: `new XxxServiceClient(...)` (generated client constructor)
* - Consumer: `new foo.bar.Xxx(...)` when the file uses
* `loadPackageDefinition` (gRPC dynamic proto loader)
*
* As with the HTTP `node.ts`, pattern sources are defined once and
* compiled against three grammar variants (JS / TS / TSX) because
* `Parser.Query` is not portable across grammar objects.
*/
const SERVICE_CLIENT_RE = /^(\w+Service)Client$/;
const CAPITALIZED_SERVICE_RE = /^[A-Z]\w+$/;
// @GrpcMethod('Service', 'Method')
const GRPC_METHOD_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(decorator
(call_expression
function: (identifier) @dec (#eq? @dec "GrpcMethod")
arguments: (arguments
. [(string) (template_string)] @service
. [(string) (template_string)] @method)))
`,
};
// @GrpcClient(...) standalone decorator — the plugin walks to the next
// sibling (a field definition) to read its type annotation.
const GRPC_CLIENT_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(decorator
(call_expression
function: (identifier) @dec (#eq? @dec "GrpcClient"))) @grpc_client_decorator
`,
};
// `.getService<X>('AuthService')` / `.getService('AuthService')`
const GET_SERVICE_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(call_expression
function: (member_expression
property: (property_identifier) @method (#eq? @method "getService"))
arguments: (arguments . [(string) (template_string)] @service))
`,
};
// `new XxxServiceClient(...)` — bare identifier constructor.
const NEW_SIMPLE_CTOR_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(new_expression
constructor: (identifier) @ctor)
`,
};
// `new foo.bar.XxxService(...)` — qualified constructor.
const NEW_QUALIFIED_CTOR_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(new_expression
constructor: (member_expression
property: (property_identifier) @ctor))
`,
};
// Detect whether the file uses `loadPackageDefinition` (gRPC dynamic
// proto loader). Matches either a bare call or an `obj.loadPackageDefinition(...)`
// call. Plugin gates the qualified-constructor consumer on this —
// structural check avoids materializing `tree.rootNode.text` for every file.
const LOAD_PACKAGE_DEFINITION_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(call_expression
function: [
(identifier) @fn (#eq? @fn "loadPackageDefinition")
(member_expression property: (property_identifier) @fn (#eq? @fn "loadPackageDefinition"))
])
`,
};
interface NodeGrpcPatternBundle {
grpcMethod: CompiledPatterns<Record<string, never>>;
grpcClient: CompiledPatterns<Record<string, never>>;
getService: CompiledPatterns<Record<string, never>>;
newSimpleCtor: CompiledPatterns<Record<string, never>>;
newQualifiedCtor: CompiledPatterns<Record<string, never>>;
loadPackageDefinition: CompiledPatterns<Record<string, never>>;
}
function compileBundle(language: unknown, name: string): NodeGrpcPatternBundle {
const mk = (spec: PatternSpec<Record<string, never>>, suffix: string) =>
compilePatterns({
name: `${name}-${suffix}`,
language,
patterns: [spec],
} satisfies LanguagePatterns<Record<string, never>>);
return {
grpcMethod: mk(GRPC_METHOD_SPEC, 'grpc-method'),
grpcClient: mk(GRPC_CLIENT_SPEC, 'grpc-client'),
getService: mk(GET_SERVICE_SPEC, 'get-service'),
newSimpleCtor: mk(NEW_SIMPLE_CTOR_SPEC, 'new-simple-ctor'),
newQualifiedCtor: mk(NEW_QUALIFIED_CTOR_SPEC, 'new-qualified-ctor'),
loadPackageDefinition: mk(LOAD_PACKAGE_DEFINITION_SPEC, 'load-package-definition'),
};
}
const JAVASCRIPT_BUNDLE = compileBundle(JavaScript, 'javascript-grpc');
const TYPESCRIPT_BUNDLE = compileBundle(TypeScript.typescript, 'typescript-grpc');
const TSX_BUNDLE = compileBundle(TypeScript.tsx, 'tsx-grpc');
/**
* Given a `@GrpcClient(...)` decorator node, find the type annotation
* text of the field it decorates (e.g. `AuthServiceClient`).
*
* In tree-sitter-typescript, decorators on class fields can appear in
* two configurations:
* - As a CHILD of `public_field_definition` alongside the field's
* type annotation (the common case for NestJS `@GrpcClient`).
* - As a SIBLING of the field in `class_body` (for method
* decorators, but kept for resilience against grammar variants).
* We walk the parent container and search for a type annotation.
*/
function resolveGrpcClientFieldType(decoratorNode: Parser.SyntaxNode): string | null {
const parent = decoratorNode.parent;
if (!parent) return null;
// Case 1: decorator is a child of the field definition — search
// the parent itself (which is the field definition) for a
// type_annotation child.
if (parent.type === 'public_field_definition' || parent.type.endsWith('field_definition')) {
return findFirstTypeAnnotationText(parent);
}
// Case 2: decorator is a sibling of the field in a class_body — walk
// forward through subsequent siblings until we find a node containing
// a type annotation.
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child && child.id === decoratorNode.id) {
for (let j = i + 1; j < parent.namedChildCount; j++) {
const next = parent.namedChild(j);
if (!next) continue;
if (next.type === 'decorator') continue;
const typeText = findFirstTypeAnnotationText(next);
if (typeText) return typeText;
return null;
}
return null;
}
}
return null;
}
/**
* Recursively search `node` for the first `type_annotation` child and
* return the text of its inner `type_identifier`, or null. Handles
* both `public_field_definition` and its variants.
*/
function findFirstTypeAnnotationText(node: Parser.SyntaxNode): string | null {
if (node.type === 'type_annotation') {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (child.type === 'type_identifier') return child.text;
}
return null;
}
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
const found = findFirstTypeAnnotationText(child);
if (found) return found;
}
return null;
}
function scanBundle(bundle: NodeGrpcPatternBundle, tree: Parser.Tree): GrpcDetection[] {
const out: GrpcDetection[] = [];
// ─── Provider: @GrpcMethod('Service', 'Method') ──────────────────
for (const match of runCompiledPatterns(bundle.grpcMethod, tree)) {
const svcNode = match.captures.service;
const methodNode = match.captures.method;
if (!svcNode || !methodNode) continue;
const svc = unquoteLiteral(svcNode.text);
const mth = unquoteLiteral(methodNode.text);
if (!svc || !mth) continue;
out.push({
role: 'provider',
serviceName: svc,
symbolName: `${svc}.${mth}`,
source: 'ts_grpc_method',
methodName: mth,
// @GrpcMethod hard-coded confidence 0.8 in the original code
// regardless of whether the proto map has a match.
confidenceWithProto: 0.8,
confidenceWithoutProto: 0.8,
});
}
// ─── Consumer: @GrpcClient() field with XxxServiceClient type ────
for (const match of runCompiledPatterns(bundle.grpcClient, tree)) {
const decoratorNode = match.captures.grpc_client_decorator;
if (!decoratorNode) continue;
const typeText = resolveGrpcClientFieldType(decoratorNode);
if (!typeText) continue;
const svcMatch = SERVICE_CLIENT_RE.exec(typeText);
if (!svcMatch) continue;
const serviceName = svcMatch[1];
out.push({
role: 'consumer',
serviceName,
symbolName: `${serviceName}Client`,
source: 'ts_grpc_client_decorator',
confidenceWithProto: 0.75,
confidenceWithoutProto: 0.55,
});
}
// ─── Consumer: client.getService<X>('Service') ───────────────────
for (const match of runCompiledPatterns(bundle.getService, tree)) {
const svcNode = match.captures.service;
if (!svcNode) continue;
const svc = unquoteLiteral(svcNode.text);
if (!svc) continue;
out.push({
role: 'consumer',
serviceName: svc,
symbolName: `${svc}Client`,
source: 'ts_client_grpc_get_service',
confidenceWithProto: 0.75,
confidenceWithoutProto: 0.55,
});
}
// ─── Consumer: new XxxServiceClient(...) ─────────────────────────
for (const match of runCompiledPatterns(bundle.newSimpleCtor, tree)) {
const ctorNode = match.captures.ctor;
if (!ctorNode) continue;
const svcMatch = SERVICE_CLIENT_RE.exec(ctorNode.text);
if (!svcMatch) continue;
const serviceName = svcMatch[1];
out.push({
role: 'consumer',
serviceName,
symbolName: `${serviceName}Client`,
source: 'ts_generated_client',
confidenceWithProto: 0.75,
confidenceWithoutProto: 0.55,
});
}
// ─── Consumer: loadPackageDefinition dynamic proto loader ────────
// Only emit when the file uses loadPackageDefinition, otherwise a
// generic `new foo.bar.Something()` in unrelated code would falsely
// register as a gRPC consumer. Check structurally via a dedicated
// query — avoids materializing `tree.rootNode.text` for the whole
// file (expensive on large files).
const usesLoadPackage = runCompiledPatterns(bundle.loadPackageDefinition, tree).length > 0;
if (usesLoadPackage) {
for (const match of runCompiledPatterns(bundle.newQualifiedCtor, tree)) {
const ctorNode = match.captures.ctor;
if (!ctorNode) continue;
if (!CAPITALIZED_SERVICE_RE.test(ctorNode.text)) continue;
out.push({
role: 'consumer',
serviceName: ctorNode.text,
symbolName: `${ctorNode.text}Client`,
source: 'ts_load_package_definition',
confidenceWithProto: 0.75,
confidenceWithoutProto: 0.55,
});
}
}
return out;
}
export const JAVASCRIPT_GRPC_PLUGIN: GrpcLanguagePlugin = {
name: 'javascript-grpc',
language: JavaScript,
scan: (tree) => scanBundle(JAVASCRIPT_BUNDLE, tree),
};
export const TYPESCRIPT_GRPC_PLUGIN: GrpcLanguagePlugin = {
name: 'typescript-grpc',
language: TypeScript.typescript,
scan: (tree) => scanBundle(TYPESCRIPT_BUNDLE, tree),
};
export const TSX_GRPC_PLUGIN: GrpcLanguagePlugin = {
name: 'tsx-grpc',
language: TypeScript.tsx,
scan: (tree) => scanBundle(TSX_BUNDLE, tree),
};

View file

@ -0,0 +1,147 @@
import { createRequire } from 'node:module';
import {
compilePatterns,
runCompiledPatterns,
type CompiledPatterns,
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import type { GrpcDetection, GrpcLanguagePlugin } from './types.js';
/**
* Protobuf (.proto) tree-sitter plugin for gRPC contract extraction.
*
* Uses `tree-sitter-proto` (coder3101/tree-sitter-proto) as an
* optionalDependency if the grammar is not installed (e.g. native
* compilation failed on an unusual platform), the plugin exports
* `null` and the orchestrator falls back to the existing manual
* string-sanitizing parser.
*
* The grammar is vendored in `vendor/tree-sitter-proto/` with
* parser.c regenerated against tree-sitter-cli 0.24 (ABI version 14)
* so it is compatible with the project's tree-sitter 0.25 runtime.
*/
const _require = createRequire(import.meta.url);
let ProtoGrammar: unknown = null;
try {
ProtoGrammar = _require('tree-sitter-proto');
} catch {
// Grammar not installed — PROTO_GRPC_PLUGIN will be null.
}
let PACKAGE_PATTERNS: CompiledPatterns<Record<string, never>> | null = null;
let SERVICE_PATTERNS: CompiledPatterns<Record<string, never>> | null = null;
if (ProtoGrammar) {
try {
// Validate that the grammar actually loads end-to-end: compile queries
// AND parse + walk a trivial proto file. tree-sitter's internal
// `initializeLanguageNodeClasses` can fail with a TDZ error in some
// test runners (vitest forks) when SyntaxNode isn't fully initialized
// yet. Catching that here ensures `PROTO_GRPC_PLUGIN` stays null and
// the orchestrator falls back to the manual parser.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const _Parser = _require('tree-sitter') as any;
// Smoke-test: parse + setLanguage to verify the grammar is
// end-to-end compatible with this tree-sitter runtime.
const _testParser = new _Parser();
_testParser.setLanguage(ProtoGrammar);
_testParser.parse('service X { rpc Y (R) returns (R); }');
PACKAGE_PATTERNS = compilePatterns({
name: 'proto-package',
language: ProtoGrammar,
patterns: [
{
meta: {},
query: `(package (full_ident) @pkg)`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
SERVICE_PATTERNS = compilePatterns({
name: 'proto-service',
language: ProtoGrammar,
patterns: [
{
meta: {},
query: `
(service
(service_name) @service_name
(rpc
(rpc_name) @rpc_name))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
} catch {
// Compilation failed (grammar ABI mismatch?) — fall back to null.
PACKAGE_PATTERNS = null;
SERVICE_PATTERNS = null;
ProtoGrammar = null;
}
}
function buildPlugin(): GrpcLanguagePlugin | null {
if (!ProtoGrammar || !PACKAGE_PATTERNS || !SERVICE_PATTERNS) return null;
const pkgPatterns = PACKAGE_PATTERNS;
const svcPatterns = SERVICE_PATTERNS;
return {
name: 'proto-grpc',
language: ProtoGrammar,
scan(tree) {
const out: GrpcDetection[] = [];
// Extract `package` declaration (first match wins).
let pkg = '';
for (const match of runCompiledPatterns(pkgPatterns, tree)) {
const pkgNode = match.captures.pkg;
if (pkgNode) {
pkg = pkgNode.text;
break;
}
}
// Extract `service → rpc` pairs. The query returns one match per
// (service, rpc) combination thanks to the nested structure.
for (const match of runCompiledPatterns(svcPatterns, tree)) {
const serviceNode = match.captures.service_name;
const rpcNode = match.captures.rpc_name;
if (!serviceNode || !rpcNode) continue;
const serviceName = serviceNode.text;
const methodName = rpcNode.text;
out.push({
role: 'provider',
serviceName,
symbolName: `${serviceName}.${methodName}`,
source: 'proto',
methodName,
// Proto definitions are the canonical source of truth — always
// high confidence regardless of cross-referencing.
confidenceWithProto: 0.85,
confidenceWithoutProto: 0.85,
});
}
return out;
},
};
}
/**
* The proto plugin, or `null` if tree-sitter-proto is not available.
* The orchestrator checks this at import time and decides whether to
* use the tree-sitter path or the fallback manual parser.
*/
export const PROTO_GRPC_PLUGIN: GrpcLanguagePlugin | null = buildPlugin();
/** The package declaration text from a proto file's tree. */
export function extractPackageFromTree(tree: import('tree-sitter').Tree): string {
if (!PACKAGE_PATTERNS) return '';
for (const match of runCompiledPatterns(PACKAGE_PATTERNS, tree)) {
const pkgNode = match.captures.pkg;
if (pkgNode) return pkgNode.text;
}
return '';
}

View file

@ -0,0 +1,77 @@
import Python from 'tree-sitter-python';
import {
compilePatterns,
runCompiledPatterns,
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import type { GrpcDetection, GrpcLanguagePlugin } from './types.js';
/**
* Python gRPC plugin. Detects:
* - Provider: `add_XxxServicer_to_server(...)` calls (bare identifier
* or qualified attribute form `auth_pb2_grpc.add_XxxServicer_to_server`)
* - Consumer: `XxxStub(channel)` calls (bare or `auth_pb2_grpc.XxxStub`)
*/
const ADD_SERVICER_RE = /^add_(\w+)Servicer_to_server$/;
const STUB_RE = /^(\w+)Stub$/;
/** Reserved names that would produce garbage service names. */
const STUB_IGNORE = new Set(['Mock', 'Test', 'Fake', 'Stub']);
// Any call whose target is either a bare identifier or an attribute
// access (`obj.method`). The plugin filters the function name in JS.
const CALL_PATTERNS = compilePatterns({
name: 'python-grpc-call',
language: Python,
patterns: [
{
meta: {},
query: `
(call
function: [
(identifier) @fn
(attribute attribute: (identifier) @fn)
])
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
export const PYTHON_GRPC_PLUGIN: GrpcLanguagePlugin = {
name: 'python-grpc',
language: Python,
scan(tree) {
const out: GrpcDetection[] = [];
for (const match of runCompiledPatterns(CALL_PATTERNS, tree)) {
const fnNode = match.captures.fn;
if (!fnNode) continue;
const fnText = fnNode.text;
const addServicer = ADD_SERVICER_RE.exec(fnText);
if (addServicer) {
out.push({
role: 'provider',
serviceName: addServicer[1],
symbolName: fnText,
source: 'python_servicer',
confidenceWithProto: 0.8,
confidenceWithoutProto: 0.65,
});
continue;
}
const stubMatch = STUB_RE.exec(fnText);
if (stubMatch && !STUB_IGNORE.has(stubMatch[1])) {
out.push({
role: 'consumer',
serviceName: stubMatch[1],
symbolName: fnText,
source: 'python_stub',
confidenceWithProto: 0.75,
confidenceWithoutProto: 0.55,
});
}
}
return out;
},
};

View file

@ -0,0 +1,54 @@
import type Parser from 'tree-sitter';
/**
* Shared types for the grpc-extractor language plugins.
*
* Each plugin lives in its own file (java.ts, go.ts, ...) and owns the
* tree-sitter grammar import + query sources. The top-level
* `grpc-extractor.ts` orchestrator only knows about this type module
* and the plugin registry (`./index.ts`). It MUST NOT import any
* grammar or query text directly.
*/
export type GrpcRole = 'provider' | 'consumer';
/**
* One raw gRPC detection produced by a plugin's `scan()` function. The
* orchestrator uses the proto map to resolve the full package-qualified
* contract id and choose a confidence based on whether the proto was
* found.
*
* Most patterns produce service-level detections; `TS @GrpcMethod` is
* the only pattern that captures an explicit `methodName`, producing
* a method-level contract (`grpc::pkg.Service/Method`).
*/
export interface GrpcDetection {
role: GrpcRole;
/** Short service name, e.g. `"AuthService"`. */
serviceName: string;
/** Symbol name emitted into the contract's symbolRef. */
symbolName: string;
/** Metadata source label (goes into `meta.source`). */
source: string;
/** Explicit method name; set only by TS `@GrpcMethod`. */
methodName?: string;
/** Confidence when the proto map resolves the service. */
confidenceWithProto: number;
/** Confidence when the proto map has no entry. */
confidenceWithoutProto: number;
}
/**
* One language-scoped gRPC plugin. Plugins own the tree-sitter grammar
* and a `scan(tree)` function that returns zero or more
* `GrpcDetection`s. The plugin is free to run multiple compiled query
* bundles and walk the AST to cross-reference captures.
*
* `language` is typed `unknown` for the same reason as in
* `tree-sitter-scanner.ts`.
*/
export interface GrpcLanguagePlugin {
name: string;
language: unknown;
scan(tree: Parser.Tree): GrpcDetection[];
}

View file

@ -0,0 +1,224 @@
import Go from 'tree-sitter-go';
import {
compilePatterns,
runCompiledPatterns,
unquoteLiteral,
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
/**
* Go HTTP plugin. Handles:
* - gin / echo / chi framework routing `r.GET("/path", handler)`
* - net/http stdlib `http.HandleFunc("/path", handler)`
* - net/http consumer `http.Get(...)`, `http.NewRequest("METHOD", ...)`
* - resty consumer `client.R().Delete("/path")`
*/
// ─── Provider: framework routing ──────────────────────────────────────
// Matches `\w+\.GET(...)` etc. (gin, echo, chi all share this shape).
// Captures the HTTP method (field name), path literal, and handler
// identifier passed as the second argument.
const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({
name: 'go-framework-route',
language: Go,
patterns: [
{
meta: {},
query: `
(call_expression
function: (selector_expression
field: (field_identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE|PATCH)$"))
arguments: (argument_list
(interpreted_string_literal) @path
(identifier) @handler))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Provider: net/http `http.HandleFunc("/p", handler)` ─────────────
const HANDLE_FUNC_PATTERNS = compilePatterns({
name: 'go-handle-func',
language: Go,
patterns: [
{
meta: {},
query: `
(call_expression
function: (selector_expression
operand: (identifier) @pkg (#eq? @pkg "http")
field: (field_identifier) @fn (#eq? @fn "HandleFunc"))
arguments: (argument_list
(interpreted_string_literal) @path
(identifier) @handler))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: net/http stdlib Get / Post / Head ─────────────────────
const HTTP_CLIENT_METHOD_TO_HTTP: Record<string, string> = {
Get: 'GET',
Post: 'POST',
Head: 'GET', // HEAD has no body semantics we care about — treat as GET for contract matching
};
const HTTP_CLIENT_PATTERNS = compilePatterns({
name: 'go-http-client',
language: Go,
patterns: [
{
meta: {},
query: `
(call_expression
function: (selector_expression
operand: (identifier) @pkg (#eq? @pkg "http")
field: (field_identifier) @fn (#match? @fn "^(Get|Post|Head)$"))
arguments: (argument_list . (interpreted_string_literal) @path))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: net/http `http.NewRequest("METHOD", "/path", ...)` ────
const NEW_REQUEST_PATTERNS = compilePatterns({
name: 'go-new-request',
language: Go,
patterns: [
{
meta: {},
query: `
(call_expression
function: (selector_expression
operand: (identifier) @pkg (#eq? @pkg "http")
field: (field_identifier) @fn (#eq? @fn "NewRequest"))
arguments: (argument_list
.
(interpreted_string_literal) @http_method
(interpreted_string_literal) @path))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: resty `client.R().Delete("/path")` ─────────────────────
// Matches any chained call whose receiver is `something.R()` and whose
// method name is an HTTP verb. This is how go-resty's fluent API looks.
const RESTY_PATTERNS = compilePatterns({
name: 'go-resty',
language: Go,
patterns: [
{
meta: {},
query: `
(call_expression
function: (selector_expression
operand: (call_expression
function: (selector_expression
field: (field_identifier) @r (#eq? @r "R")))
field: (field_identifier) @http_method (#match? @http_method "^(Get|Post|Put|Delete|Patch)$"))
arguments: (argument_list . (interpreted_string_literal) @path))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'go-http',
language: Go,
scan(tree) {
const out: HttpDetection[] = [];
// Framework providers: r.GET/POST/... with handler identifier
for (const match of runCompiledPatterns(FRAMEWORK_ROUTE_PATTERNS, tree)) {
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
const handlerNode = match.captures.handler;
if (!methodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'provider',
framework: 'go-framework',
method: methodNode.text.toUpperCase(),
path,
name: handlerNode?.text ?? null,
confidence: 0.8,
});
}
// net/http HandleFunc: default method GET
for (const match of runCompiledPatterns(HANDLE_FUNC_PATTERNS, tree)) {
const pathNode = match.captures.path;
const handlerNode = match.captures.handler;
if (!pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'provider',
framework: 'go-stdlib',
method: 'GET',
path,
name: handlerNode?.text ?? null,
confidence: 0.8,
});
}
// net/http client: http.Get/Post/Head
for (const match of runCompiledPatterns(HTTP_CLIENT_PATTERNS, tree)) {
const fnNode = match.captures.fn;
const pathNode = match.captures.path;
if (!fnNode || !pathNode) continue;
const httpMethod = HTTP_CLIENT_METHOD_TO_HTTP[fnNode.text];
if (!httpMethod) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'go-stdlib',
method: httpMethod,
path,
name: null,
confidence: 0.7,
});
}
// net/http NewRequest
for (const match of runCompiledPatterns(NEW_REQUEST_PATTERNS, tree)) {
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const method = unquoteLiteral(methodNode.text);
const path = unquoteLiteral(pathNode.text);
if (method === null || path === null) continue;
out.push({
role: 'consumer',
framework: 'go-stdlib',
method: method.toUpperCase(),
path,
name: null,
confidence: 0.7,
});
}
// resty
for (const match of runCompiledPatterns(RESTY_PATTERNS, tree)) {
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'go-resty',
method: methodNode.text.toUpperCase(),
path,
name: null,
confidence: 0.7,
});
}
return out;
},
};

View file

@ -0,0 +1,50 @@
import * as path from 'node:path';
import type { HttpLanguagePlugin } from './types.js';
import { JAVA_HTTP_PLUGIN } from './java.js';
import { GO_HTTP_PLUGIN } from './go.js';
import { PYTHON_HTTP_PLUGIN } from './python.js';
import { PHP_HTTP_PLUGIN } from './php.js';
import { JAVASCRIPT_HTTP_PLUGIN, TYPESCRIPT_HTTP_PLUGIN, TSX_HTTP_PLUGIN } from './node.js';
export type { HttpDetection, HttpLanguagePlugin, HttpRole } from './types.js';
/**
* File-extension HTTP language plugin registry. The top-level
* orchestrator (`http-route-extractor.ts`) looks up the plugin for each
* file it visits and delegates the tree-sitter scanning to the plugin.
*
* Keys are lowercase extensions including the leading dot. To add a
* new language, drop a `http-patterns/<lang>.ts` that exports a
* `HttpLanguagePlugin`, import it here and register the extension(s).
* No edits to `http-route-extractor.ts` are required.
*/
const REGISTRY: Record<string, HttpLanguagePlugin> = {
'.java': JAVA_HTTP_PLUGIN,
'.go': GO_HTTP_PLUGIN,
'.py': PYTHON_HTTP_PLUGIN,
'.php': PHP_HTTP_PLUGIN,
'.js': JAVASCRIPT_HTTP_PLUGIN,
'.jsx': JAVASCRIPT_HTTP_PLUGIN,
'.ts': TYPESCRIPT_HTTP_PLUGIN,
'.tsx': TSX_HTTP_PLUGIN,
};
/**
* Glob for files worth scanning for HTTP routes. Kept alongside the
* registry so adding a new language widens the glob in one edit.
*
* `.vue` / `.svelte` files are intentionally omitted for the source-scan
* path they need their own grammar-aware extraction and the existing
* regex fallback for them was never very accurate. The graph-assisted
* Strategy A still handles them via the ingestion pipeline.
*/
export const HTTP_SCAN_GLOB = '**/*.{ts,tsx,js,jsx,java,go,py,php}';
/**
* Return the HTTP plugin registered for the given file's extension,
* or `undefined` if the extension is not registered.
*/
export function getPluginForFile(rel: string): HttpLanguagePlugin | undefined {
const ext = path.extname(rel).toLowerCase();
return REGISTRY[ext];
}

View file

@ -0,0 +1,267 @@
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
import {
compilePatterns,
runCompiledPatterns,
unquoteLiteral,
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
/**
* Java HTTP plugin. Handles:
* - Spring `@RequestMapping` class prefixes + `@(Get|Post|...)Mapping` method annotations
* - Spring `RestTemplate.getForObject/...`, `WebClient.method(HttpMethod.X, ...)`
* - OkHttp `new Request.Builder().url("...")`
*
* The plugin runs two pattern bundles: one to collect class-level
* `@RequestMapping` prefixes keyed by the enclosing class node, and a
* second to match method-level annotations. The `scan` function walks
* up from each matched annotation to find its enclosing class and
* combines the prefix with the method path.
*/
const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
GetMapping: 'GET',
PostMapping: 'POST',
PutMapping: 'PUT',
DeleteMapping: 'DELETE',
PatchMapping: 'PATCH',
};
// ─── Provider: Spring class-level @RequestMapping prefix ──────────────
const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({
name: 'java-spring-class-prefix',
language: Java,
patterns: [
{
meta: {},
query: `
(class_declaration
(modifiers
(annotation
name: (identifier) @ann (#eq? @ann "RequestMapping")
arguments: (annotation_argument_list (string_literal) @prefix)))) @class
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Provider: Spring @(Get|Post|...)Mapping method annotations ───────
const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({
name: 'java-spring-method-route',
language: Java,
patterns: [
{
meta: {},
query: `
(method_declaration
(modifiers
(annotation
name: (identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")
arguments: (annotation_argument_list (string_literal) @path)))
name: (identifier) @method_name) @method
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: Spring RestTemplate (object-named + method-named) ──────
// RestTemplate.getForObject / getForEntity → GET
// RestTemplate.postForObject / postForEntity → POST
// RestTemplate.put → PUT
// RestTemplate.delete → DELETE
// RestTemplate.patchForObject → PATCH
const REST_TEMPLATE_TO_HTTP: Record<string, string> = {
getForObject: 'GET',
getForEntity: 'GET',
postForObject: 'POST',
postForEntity: 'POST',
put: 'PUT',
delete: 'DELETE',
patchForObject: 'PATCH',
};
interface RestTemplateMeta {
framework: 'spring-rest-template';
}
const REST_TEMPLATE_PATTERNS = compilePatterns({
name: 'java-rest-template',
language: Java,
patterns: [
{
meta: { framework: 'spring-rest-template' },
query: `
(method_invocation
object: (identifier) @obj (#eq? @obj "restTemplate")
name: (identifier) @method
arguments: (argument_list . (string_literal) @path))
`,
},
],
} satisfies LanguagePatterns<RestTemplateMeta>);
// ─── Consumer: Spring WebClient — webClient.method(HttpMethod.X, "path") ─
const WEB_CLIENT_PATTERNS = compilePatterns({
name: 'java-web-client',
language: Java,
patterns: [
{
meta: {},
query: `
(method_invocation
object: (identifier) @obj (#eq? @obj "webClient")
name: (identifier) @method (#eq? @method "method")
arguments: (argument_list
(field_access
object: (identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod")
field: (identifier) @http_method)
(string_literal) @path))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: OkHttp `new Request.Builder().url("path")` ─────────────
// Note: `Request.Builder` is a `scoped_type_identifier` whose text includes
// the dot, so `#eq?` against the literal string matches cleanly (no need
// to escape a regex dot).
const OK_HTTP_PATTERNS = compilePatterns({
name: 'java-okhttp',
language: Java,
patterns: [
{
meta: {},
query: `
(method_invocation
object: (object_creation_expression
type: (scoped_type_identifier) @type (#eq? @type "Request.Builder"))
name: (identifier) @method (#eq? @method "url")
arguments: (argument_list . (string_literal) @path))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
/**
* Find the nearest enclosing class_declaration ancestor for a node, or
* null if the node is top-level. Tree-sitter's SyntaxNode.parent walks
* one level at a time.
*/
function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
let cur: Parser.SyntaxNode | null = node.parent;
while (cur) {
if (cur.type === 'class_declaration') return cur;
cur = cur.parent;
}
return null;
}
/**
* Join a class-level prefix and a method-level path into a single URL
* path. Mirrors the semantics of the original regex implementation:
* strip trailing slashes on the prefix, then ensure a single slash
* between prefix and method path.
*/
function joinPath(prefix: string, methodPath: string): string {
const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, '');
const cleanSub = methodPath.replace(/^\/+/, '');
if (!cleanPrefix) return `/${cleanSub}`;
return `/${cleanPrefix}/${cleanSub}`;
}
export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'java-http',
language: Java,
scan(tree) {
const out: HttpDetection[] = [];
// ─── Providers: Spring class prefix + method annotations ────────
const prefixByClassId = new Map<number, string>();
for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) {
const prefixNode = match.captures.prefix;
const classNode = match.captures.class;
if (!prefixNode || !classNode) continue;
const prefix = unquoteLiteral(prefixNode.text);
if (prefix !== null) prefixByClassId.set(classNode.id, prefix);
}
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
const annNode = match.captures.ann;
const pathNode = match.captures.path;
const nameNode = match.captures.method_name;
const methodNode = match.captures.method;
if (!annNode || !pathNode || !methodNode) continue;
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
if (!httpMethod) continue;
const rawPath = unquoteLiteral(pathNode.text);
if (rawPath === null) continue;
const enclosingClass = findEnclosingClass(methodNode);
const prefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : '';
const fullPath = joinPath(prefix, rawPath);
out.push({
role: 'provider',
framework: 'spring',
method: httpMethod,
path: fullPath,
name: nameNode?.text ?? null,
confidence: 0.8,
});
}
// ─── Consumers: RestTemplate ────────────────────────────────────
for (const match of runCompiledPatterns(REST_TEMPLATE_PATTERNS, tree)) {
const methodNode = match.captures.method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const httpMethod = REST_TEMPLATE_TO_HTTP[methodNode.text];
if (!httpMethod) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'spring-rest-template',
method: httpMethod,
path,
name: null,
confidence: 0.7,
});
}
// ─── Consumers: WebClient.method(HttpMethod.X, "path") ──────────
for (const match of runCompiledPatterns(WEB_CLIENT_PATTERNS, tree)) {
const httpMethodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!httpMethodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'spring-web-client',
method: httpMethodNode.text.toUpperCase(),
path,
name: null,
confidence: 0.7,
});
}
// ─── Consumers: OkHttp Request.Builder().url("path") ────────────
for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) {
const pathNode = match.captures.path;
if (!pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'okhttp',
method: 'GET',
path,
name: null,
confidence: 0.7,
});
}
return out;
},
};

View file

@ -0,0 +1,373 @@
import Parser from 'tree-sitter';
import JavaScript from 'tree-sitter-javascript';
import TypeScript from 'tree-sitter-typescript';
import {
compilePatterns,
runCompiledPatterns,
unquoteLiteral,
type CompiledPatterns,
type LanguagePatterns,
type PatternSpec,
} from '../tree-sitter-scanner.js';
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
/**
* Node.js / TypeScript HTTP plugin family. Handles:
* - NestJS `@Controller('prefix')` classes with `@Get(':id')` methods
* - Express `router.get(...)` / `app.post(...)` providers
* - `fetch(url)` / `fetch(url, { method: 'POST' })` consumers
* - `axios.get(url)` / `axios.delete(url)` consumers
*
* Because the JavaScript and TypeScript tree-sitter grammars share
* node type names for every construct we query, pattern sources are
* defined once and compiled against each grammar variant. The plugin
* exports three `HttpLanguagePlugin`s (JS, TS, TSX) that share the
* same `scan` function but bind to different grammars.
*/
// ─── Provider: NestJS — class-level @Controller('prefix') ────────────
// In tree-sitter-typescript decorators are NOT children of
// class_declaration / method_definition — they're siblings in the
// surrounding class_body / program node. We therefore match the
// decorator standalone and walk to its related class/method in JS.
const NEST_CONTROLLER_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(decorator
(call_expression
function: (identifier) @dec (#eq? @dec "Controller")
arguments: (arguments . [(string) (template_string)] @prefix))) @ctrl_decorator
`,
};
// ─── Provider: NestJS — method-level @Get/@Post/... decorators ───────
// Matches either `@Get('path')` or `@Get()`. The `@path` capture is
// optional — when the first argument isn't a string, the plugin falls
// back to '/' for the method-level path.
const NEST_METHOD_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(decorator
(call_expression
function: (identifier) @dec (#match? @dec "^(Get|Post|Put|Delete|Patch)$")
arguments: (arguments) @args)) @method_decorator
`,
};
// ─── Provider: Express — router.get/app.post/... ─────────────────────
const EXPRESS_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(call_expression
function: (member_expression
object: (identifier) @obj (#match? @obj "^(router|app)$")
property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$"))
arguments: (arguments . [(string) (template_string)] @path))
`,
};
// ─── Consumer: fetch(url) with NO options ─────────────────────────────
const FETCH_NO_OPTIONS_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(call_expression
function: (identifier) @fn (#eq? @fn "fetch")
arguments: (arguments . [(string) (template_string)] @path .))
`,
};
// ─── Consumer: fetch(url, { method: 'X', ... }) ──────────────────────
const FETCH_WITH_OPTIONS_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(call_expression
function: (identifier) @fn (#eq? @fn "fetch")
arguments: (arguments
. [(string) (template_string)] @path
(object
(pair
key: (property_identifier) @key (#eq? @key "method")
value: (string) @http_method))))
`,
};
// ─── Consumer: axios.get/post/... ────────────────────────────────────
const AXIOS_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(call_expression
function: (member_expression
object: (identifier) @obj (#eq? @obj "axios")
property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$"))
arguments: (arguments . [(string) (template_string)] @path))
`,
};
interface NodePatternBundle {
controller: CompiledPatterns<Record<string, never>>;
methodDecorator: CompiledPatterns<Record<string, never>>;
express: CompiledPatterns<Record<string, never>>;
fetchNoOptions: CompiledPatterns<Record<string, never>>;
fetchWithOptions: CompiledPatterns<Record<string, never>>;
axios: CompiledPatterns<Record<string, never>>;
}
function compileBundle(language: unknown, name: string): NodePatternBundle {
const mk = (spec: PatternSpec<Record<string, never>>, suffix: string) =>
compilePatterns({
name: `${name}-${suffix}`,
language,
patterns: [spec],
} satisfies LanguagePatterns<Record<string, never>>);
return {
controller: mk(NEST_CONTROLLER_SPEC, 'nest-controller'),
methodDecorator: mk(NEST_METHOD_SPEC, 'nest-method-decorator'),
express: mk(EXPRESS_SPEC, 'express'),
fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'),
fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'),
axios: mk(AXIOS_SPEC, 'axios'),
};
}
const JAVASCRIPT_BUNDLE = compileBundle(JavaScript, 'javascript-http');
const TYPESCRIPT_BUNDLE = compileBundle(TypeScript.typescript, 'typescript-http');
const TSX_BUNDLE = compileBundle(TypeScript.tsx, 'tsx-http');
const NEST_DECORATOR_TO_HTTP: Record<string, string> = {
Get: 'GET',
Post: 'POST',
Put: 'PUT',
Delete: 'DELETE',
Patch: 'PATCH',
};
/**
* Find the nearest enclosing class_declaration for a node, or null.
*/
function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
let cur: Parser.SyntaxNode | null = node.parent;
while (cur) {
if (cur.type === 'class_declaration') return cur;
cur = cur.parent;
}
return null;
}
function joinPath(prefix: string, sub: string): string {
const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, '');
const cleanSub = sub.replace(/^\/+/, '');
if (!cleanPrefix) return `/${cleanSub}`;
return `/${cleanPrefix}/${cleanSub}`;
}
/**
* For a standalone `decorator` node (child of class_body / program),
* find the related `class_declaration` node that it decorates. In
* tree-sitter-typescript the decorator is placed before the class
* declaration as a sibling (when decorating a class) or inside the
* class_body before a method_definition (when decorating a method);
* we walk the parent chain until we find the enclosing class.
*/
function findDecoratedClass(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null {
const parent = decoratorNode.parent;
if (!parent) return null;
// Case 1: decorator is a sibling of the class_declaration at program /
// export_statement level. Walk forward through siblings until we find
// the class_declaration this decorator belongs to.
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child && child.id === decoratorNode.id) {
for (let j = i + 1; j < parent.namedChildCount; j++) {
const next = parent.namedChild(j);
if (!next) continue;
if (next.type === 'decorator') continue; // adjacent decorators stack
if (next.type === 'class_declaration') return next;
if (next.type === 'export_statement') {
// `export class Foo { ... }` wraps the declaration.
for (let k = 0; k < next.namedChildCount; k++) {
const inner = next.namedChild(k);
if (inner?.type === 'class_declaration') return inner;
}
}
break;
}
break;
}
}
// Case 2: decorator is inside a class_body (decorating a method) —
// walk up to the enclosing class_declaration.
return findEnclosingClass(decoratorNode);
}
/**
* For a method-level decorator node (child of class_body before a
* method_definition), find the method_definition it decorates.
*/
function findDecoratedMethod(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null {
const parent = decoratorNode.parent;
if (!parent || parent.type !== 'class_body') return null;
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child && child.id === decoratorNode.id) {
for (let j = i + 1; j < parent.namedChildCount; j++) {
const next = parent.namedChild(j);
if (!next) continue;
if (next.type === 'decorator') continue;
if (next.type === 'method_definition') return next;
return null;
}
return null;
}
}
return null;
}
function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection[] {
const out: HttpDetection[] = [];
// NestJS: collect `@Controller('prefix')` class decorators, keyed by
// the `class_declaration` they decorate.
const prefixByClassId = new Map<number, string>();
for (const match of runCompiledPatterns(bundle.controller, tree)) {
const prefixNode = match.captures.prefix;
const decoratorNode = match.captures.ctrl_decorator;
if (!prefixNode || !decoratorNode) continue;
const prefix = unquoteLiteral(prefixNode.text);
if (prefix === null) continue;
const classNode = findDecoratedClass(decoratorNode);
if (!classNode) continue;
prefixByClassId.set(classNode.id, prefix);
}
// NestJS: method-level @Get/@Post/... decorators. The decorator's
// arguments list may be empty (`@Get()`), a string (`@Get('path')`),
// or something else (which we skip).
for (const match of runCompiledPatterns(bundle.methodDecorator, tree)) {
const decNode = match.captures.dec;
const argsNode = match.captures.args;
const decoratorNode = match.captures.method_decorator;
if (!decNode || !argsNode || !decoratorNode) continue;
const httpMethod = NEST_DECORATOR_TO_HTTP[decNode.text];
if (!httpMethod) continue;
const methodNode = findDecoratedMethod(decoratorNode);
if (!methodNode) continue;
const enclosingClass = findEnclosingClass(methodNode);
// Only emit NestJS detections when the class actually has a
// @Controller decorator — without it, the match is almost certainly
// something else (e.g. an unrelated library using similar names).
if (!enclosingClass || !prefixByClassId.has(enclosingClass.id)) continue;
const prefix = prefixByClassId.get(enclosingClass.id) ?? '';
let rawPath = '/';
const firstArg = argsNode.namedChild(0);
if (firstArg && (firstArg.type === 'string' || firstArg.type === 'template_string')) {
const unquoted = unquoteLiteral(firstArg.text);
if (unquoted !== null) rawPath = unquoted;
}
// Get the method name from the decorated method_definition.
const methodNameNode = methodNode.childForFieldName('name');
const name = methodNameNode?.text ?? null;
out.push({
role: 'provider',
framework: 'nest',
method: httpMethod,
path: joinPath(prefix, rawPath),
name,
confidence: 0.8,
});
}
// Express: router/app.<verb>(...)
for (const match of runCompiledPatterns(bundle.express, tree)) {
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'provider',
framework: 'express',
method: methodNode.text.toUpperCase(),
path,
name: 'handler',
confidence: 0.8,
});
}
// Consumer: fetch with options { method: 'X' }
const fetchSeen = new Set<number>();
for (const match of runCompiledPatterns(bundle.fetchWithOptions, tree)) {
const pathNode = match.captures.path;
const methodNode = match.captures.http_method;
if (!pathNode || !methodNode) continue;
const path = unquoteLiteral(pathNode.text);
const method = unquoteLiteral(methodNode.text);
if (path === null || method === null) continue;
fetchSeen.add(pathNode.id);
out.push({
role: 'consumer',
framework: 'fetch',
method: method.toUpperCase(),
path,
name: null,
confidence: 0.7,
});
}
// Consumer: plain fetch(path) — default GET. Skip path nodes we already
// matched with the options variant so we don't double-emit.
for (const match of runCompiledPatterns(bundle.fetchNoOptions, tree)) {
const pathNode = match.captures.path;
if (!pathNode) continue;
if (fetchSeen.has(pathNode.id)) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'fetch',
method: 'GET',
path,
name: null,
confidence: 0.7,
});
}
// Consumer: axios.<verb>(url)
for (const match of runCompiledPatterns(bundle.axios, tree)) {
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'axios',
method: methodNode.text.toUpperCase(),
path,
name: null,
confidence: 0.7,
});
}
return out;
}
export const JAVASCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'javascript-http',
language: JavaScript,
scan: (tree) => scanBundle(JAVASCRIPT_BUNDLE, tree),
};
export const TYPESCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'typescript-http',
language: TypeScript.typescript,
scan: (tree) => scanBundle(TYPESCRIPT_BUNDLE, tree),
};
export const TSX_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'tsx-http',
language: TypeScript.tsx,
scan: (tree) => scanBundle(TSX_BUNDLE, tree),
};

View file

@ -0,0 +1,79 @@
import PHP from 'tree-sitter-php';
import {
compilePatterns,
runCompiledPatterns,
unquoteLiteral,
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
/**
* PHP HTTP plugin Laravel `Route::get/post/...` declarations.
*
* The pipeline already uses `PHP.php_only` for ingesting plain `.php`
* files (see `core/tree-sitter/parser-loader.ts`), and we do the same
* here so Laravel route files are parsed with the right grammar dialect.
*/
const LARAVEL_PATTERNS = compilePatterns({
name: 'php-laravel',
language: PHP.php_only,
patterns: [
{
meta: {},
query: `
(scoped_call_expression
scope: (name) @scope (#eq? @scope "Route")
name: (name) @method (#match? @method "^(get|post|put|delete|patch)$")
arguments: (arguments . (argument (string) @path)))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
/**
* Extract the inner text of a PHP `string` node. The tree-sitter-php
* grammar wraps single / double-quoted literals differently depending
* on content; we try both the raw `text` (with quotes) through
* `unquoteLiteral`, and a fallback via the `string_value` / `string_content`
* child nodes.
*/
function phpStringText(node: import('tree-sitter').SyntaxNode): string | null {
// Most single-quoted strings expose their inner content through the
// full node text (including quotes), which unquoteLiteral strips.
const direct = unquoteLiteral(node.text);
if (direct !== null && direct !== node.text) return direct;
// Fall back to child string_content / string_value node if present.
for (const child of node.children) {
if (child.type === 'string_content' || child.type === 'string_value') {
return child.text;
}
}
return direct;
}
export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'php-http',
language: PHP.php_only,
scan(tree) {
const out: HttpDetection[] = [];
for (const match of runCompiledPatterns(LARAVEL_PATTERNS, tree)) {
const methodNode = match.captures.method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const path = phpStringText(pathNode);
if (path === null) continue;
out.push({
role: 'provider',
framework: 'laravel',
method: methodNode.text.toUpperCase(),
path,
name: 'route',
confidence: 0.8,
});
}
return out;
},
};

View file

@ -0,0 +1,142 @@
import Python from 'tree-sitter-python';
import {
compilePatterns,
runCompiledPatterns,
unquoteLiteral,
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
/**
* Python HTTP plugin. Handles:
* - FastAPI `@app.get("/path")` provider decorators
* - `requests.get/post/...("url")` consumer calls
* - Generic `requests.request("METHOD", "url")` consumer calls
*/
const FASTAPI_VERBS: Record<string, string> = {
get: 'GET',
post: 'POST',
put: 'PUT',
delete: 'DELETE',
patch: 'PATCH',
};
// ─── Provider: FastAPI @app.get/... ──────────────────────────────────
const FASTAPI_PATTERNS = compilePatterns({
name: 'python-fastapi',
language: Python,
patterns: [
{
meta: {},
query: `
(decorator
(call
function: (attribute
object: (identifier) @obj (#eq? @obj "app")
attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$"))
arguments: (argument_list . (string) @path)))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: requests.get/post/... ──────────────────────────────────
const REQUESTS_VERB_PATTERNS = compilePatterns({
name: 'python-requests-verb',
language: Python,
patterns: [
{
meta: {},
query: `
(call
function: (attribute
object: (identifier) @obj (#eq? @obj "requests")
attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$"))
arguments: (argument_list . (string) @path))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: requests.request("METHOD", "url") ─────────────────────
const REQUESTS_GENERIC_PATTERNS = compilePatterns({
name: 'python-requests-generic',
language: Python,
patterns: [
{
meta: {},
query: `
(call
function: (attribute
object: (identifier) @obj (#eq? @obj "requests")
attribute: (identifier) @method (#eq? @method "request"))
arguments: (argument_list . (string) @http_method (string) @path))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'python-http',
language: Python,
scan(tree) {
const out: HttpDetection[] = [];
// Providers: FastAPI
for (const match of runCompiledPatterns(FASTAPI_PATTERNS, tree)) {
const methodNode = match.captures.method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const httpMethod = FASTAPI_VERBS[methodNode.text];
if (!httpMethod) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'provider',
framework: 'fastapi',
method: httpMethod,
path,
name: null,
confidence: 0.8,
});
}
// Consumers: requests.<verb>
for (const match of runCompiledPatterns(REQUESTS_VERB_PATTERNS, tree)) {
const methodNode = match.captures.method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'python-requests',
method: methodNode.text.toUpperCase(),
path,
name: null,
confidence: 0.7,
});
}
// Consumers: requests.request("METHOD", "url")
for (const match of runCompiledPatterns(REQUESTS_GENERIC_PATTERNS, tree)) {
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const methodRaw = unquoteLiteral(methodNode.text);
const path = unquoteLiteral(pathNode.text);
if (methodRaw === null || path === null) continue;
out.push({
role: 'consumer',
framework: 'python-requests',
method: methodRaw.toUpperCase(),
path,
name: null,
confidence: 0.7,
});
}
return out;
},
};

View file

@ -0,0 +1,65 @@
import type Parser from 'tree-sitter';
/**
* Shared types for the http-route-extractor language plugins.
*
* Each plugin lives in its own file (java.ts, node.ts, ...) and owns
* the tree-sitter grammar import + queries. The top-level
* `http-route-extractor.ts` orchestrator only knows about this type
* module and the plugin registry (`./index.ts`). It MUST NOT import
* any grammar or query text directly language-specific knowledge
* belongs in the plugins.
*/
export type HttpRole = 'provider' | 'consumer';
/**
* One raw HTTP detection produced by a plugin's `scan()` function. The
* orchestrator converts this into a full `ExtractedContract` by running
* path normalization and building the contract id.
*
* `path` is the raw literal string as it appeared in source (with
* `${...}` template placeholders still in place); the orchestrator
* runs the appropriate normalizer for provider vs. consumer paths.
*/
export interface HttpDetection {
role: HttpRole;
/** Short framework label, e.g. `'spring'`, `'nest'`, `'express'`. */
framework: string;
/** HTTP method in upper case (`'GET'`, `'POST'`, ...). */
method: string;
/** Raw path literal as seen in source (template placeholders intact). */
path: string;
/**
* Symbol name of the handler (for providers) or calling function
* (for consumers) when the plugin can determine it structurally.
* Null when no good candidate is available.
*/
name: string | null;
/** Confidence in (0, 1]. Source-scan plugins typically use 0.70.8. */
confidence: number;
}
/**
* One language-scoped HTTP plugin. The plugin owns the tree-sitter
* grammar and the `scan` function that translates a parsed tree into
* zero or more `HttpDetection`s. Plugins are free to run multiple
* compiled pattern bundles internally (see the shared scanner's
* `runCompiledPatterns` helper).
*
* `language` is typed as `unknown` for the same reason as
* `LanguagePatterns.language` in `tree-sitter-scanner.ts` the
* grammar modules export different shapes.
*/
export interface HttpLanguagePlugin {
/** Human-readable plugin name for diagnostics. */
name: string;
/** tree-sitter grammar object (passed to the shared parser). */
language: unknown;
/**
* Scan a parsed tree and return zero or more HTTP detections. Plugins
* must not throw they should swallow per-match errors so a single
* malformed construct does not abort the whole file.
*/
scan(tree: Parser.Tree): HttpDetection[];
}

View file

@ -1,8 +1,34 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { glob } from 'glob';
import Parser from 'tree-sitter';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
import { getPluginForFile, HTTP_SCAN_GLOB, type HttpDetection } from './http-patterns/index.js';
/**
* Language-agnostic orchestrator for HTTP route (provider + consumer)
* contract extraction. Two strategies, in order of preference per role:
*
* 1. **Graph-assisted (Strategy A)** if a per-repo LadybugDB executor
* is available, read `HANDLES_ROUTE` / `FETCHES` Cypher edges that
* the ingestion pipeline already produced via tree-sitter. This is
* the preferred path because the graph has richer symbol metadata
* (real uids, class/method structure, etc.).
*
* 2. **Source-scan fallback (Strategy B)** parse files directly with
* the per-language plugin registry in `./http-patterns/`. Used when
* the graph has no routes/fetches for this repo (e.g. a repo that
* hasn't been indexed yet, or whose indexer doesn't know the
* framework). Each plugin owns its tree-sitter grammar and query
* sources this orchestrator imports NO grammars or query strings.
*
* Adding a new language for Strategy B is a one-file edit in
* `http-patterns/index.ts`: register a new `HttpLanguagePlugin` and
* widen `HTTP_SCAN_GLOB` if needed.
*/
// ─── Graph-assisted queries ──────────────────────────────────────────
const HANDLES_ROUTE_QUERY = `
MATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
@ -23,14 +49,56 @@ WHERE sym.startLine IS NOT NULL
RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath, labels(sym) AS labels
ORDER BY sym.startLine`;
// ─── Path normalization (shared between provider / consumer paths) ──
/**
* Canonicalize a provider-side HTTP path for contract-id generation:
* - strip query string
* - lower-case
* - drop trailing slash
* - collapse `:id`, `{id}`, `[id]` path params into a single `{param}`
*/
export function normalizeHttpPath(p: string): string {
let s = p.trim().split('?')[0].toLowerCase().replace(/\/+$/, '');
s = s.replace(/:\w+/g, '{param}');
s = s.replace(/\{[^}]+\}/g, '{param}');
s = s.replace(/\[[^\]]+\]/g, '{param}');
return s;
// Preserve root: after stripping trailing slashes, the root "/"
// collapses to "" which would produce malformed contract ids like
// `http::GET::`. Restore a single slash for the root case.
return s === '' ? '/' : s;
}
/**
* Consumer-side normalization is more aggressive:
* - template literals (`${x}`) `{param}`
* - strip protocol + host if the URL is absolute
* - numeric segments `{param}` (so `/api/orders/42` `/api/orders/{param}`)
*/
function normalizeConsumerPath(url: string): string {
const templated = url.replace(/\$\{[^}]+\}/g, '{param}').trim();
let pathOnly = templated;
if (/^https?:\/\//i.test(templated)) {
try {
pathOnly = new URL(templated).pathname;
} catch {
pathOnly = templated.replace(/^https?:\/\/[^/]+/i, '');
}
}
const normalized = normalizeHttpPath(pathOnly || '/');
const segments = normalized
.split('/')
.filter(Boolean)
.map((segment) => (/^\d+$/.test(segment) ? '{param}' : segment));
return `/${segments.join('/')}`.replace(/\/+$/, '') || '/';
}
function contractIdFor(method: string, pathNorm: string): string {
return `http::${method.toUpperCase()}::${pathNorm}`;
}
// ─── Graph row helpers ───────────────────────────────────────────────
function methodFromRouteReason(reason: string): string | null {
const r = reason || '';
if (/GetMapping|decorator-Get/i.test(r)) return 'GET';
@ -41,50 +109,6 @@ function methodFromRouteReason(reason: string): string | null {
return null;
}
function contractIdFor(method: string, pathNorm: string): string {
return `http::${method.toUpperCase()}::${pathNorm}`;
}
function readSafe(repoPath: string, rel: string): string | null {
const abs = path.resolve(repoPath, rel);
const base = path.resolve(repoPath);
const relToBase = path.relative(base, abs);
if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
try {
return fs.readFileSync(abs, 'utf-8');
} catch {
return null;
}
}
function pickJavaHandlerName(
content: string,
routePath: string,
httpMethod: string,
): string | null {
const tail = routePath.split('/').filter(Boolean).pop() || '';
const mapNames: Record<string, string> = {
GET: 'GetMapping',
POST: 'PostMapping',
PUT: 'PutMapping',
DELETE: 'DeleteMapping',
PATCH: 'PatchMapping',
};
const ann = mapNames[httpMethod] || 'GetMapping';
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.includes(`@${ann}`)) continue;
if (!line.includes(`"${tail}"`) && !line.includes(`'${tail}'`) && tail && !line.includes(tail))
continue;
for (let j = i + 1; j < Math.min(i + 8, lines.length); j++) {
const m = lines[j].match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/);
if (m) return m[1];
}
}
return null;
}
function pickSymbolUid(
rows: Record<string, unknown>[],
preferredName: string | null,
@ -114,6 +138,8 @@ function pickSymbolUid(
};
}
// ─── Orchestrator ────────────────────────────────────────────────────
export class HttpRouteExtractor implements ContractExtractor {
type = 'http' as const;
@ -124,20 +150,76 @@ export class HttpRouteExtractor implements ContractExtractor {
async extract(
dbExecutor: CypherExecutor | null,
repoPath: string,
repo: RepoHandle,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
const graphP = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, repoPath) : [];
const providers = graphP.length > 0 ? graphP : await this.extractProvidersSourceScan(repoPath);
// Parse each file at most once and reuse the plugin results across
// both graph-assisted enrichment and source-scan emission.
const parser = new Parser();
const cachedDetections = new Map<string, HttpDetection[]>();
const getDetections = (rel: string): HttpDetection[] => {
const cached = cachedDetections.get(rel);
if (cached) return cached;
const plugin = getPluginForFile(rel);
if (!plugin) {
cachedDetections.set(rel, []);
return [];
}
const content = readSafe(repoPath, rel);
if (!content) {
cachedDetections.set(rel, []);
return [];
}
try {
parser.setLanguage(plugin.language);
const tree = parser.parse(content);
const detections = plugin.scan(tree);
cachedDetections.set(rel, detections);
return detections;
} catch {
cachedDetections.set(rel, []);
return [];
}
};
const graphC = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, repoPath) : [];
const consumers = graphC.length > 0 ? graphC : await this.extractConsumersSourceScan(repoPath);
// Glob the source-scan file list at most once per extract() —
// both provider and consumer fallback paths share the same list.
let scannedFiles: string[] | null = null;
const getScannedFiles = async (): Promise<string[]> => {
if (scannedFiles) return scannedFiles;
scannedFiles = await this.scanFiles(repoPath);
return scannedFiles;
};
const graphProviders =
dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, getDetections) : [];
const providers =
graphProviders.length > 0
? graphProviders
: this.extractProvidersSourceScan(await getScannedFiles(), getDetections);
const graphConsumers =
dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : [];
const consumers =
graphConsumers.length > 0
? graphConsumers
: this.extractConsumersSourceScan(await getScannedFiles(), getDetections);
return [...providers, ...consumers];
}
private async scanFiles(repoPath: string): Promise<string[]> {
return glob(HTTP_SCAN_GLOB, {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/vendor/**'],
nodir: true,
});
}
// ─── Graph-assisted providers ──────────────────────────────────────
private async extractProvidersGraph(
db: CypherExecutor,
repoPath: string,
getDetections: (rel: string) => HttpDetection[],
): Promise<ExtractedContract[]> {
const out: ExtractedContract[] = [];
let rows: Record<string, unknown>[];
@ -152,16 +234,26 @@ export class HttpRouteExtractor implements ContractExtractor {
const routePath = String(row.routePath ?? '');
const routeSource = String(row.routeSource ?? row.routeReason ?? '');
let method = methodFromRouteReason(routeSource);
const content = readSafe(repoPath, filePath);
if (!method && content) {
method = this.inferMethodFromFileScan(content, routePath, 'provider');
// Look up handler name (and backfill method if missing) from the
// plugin's scan of the handler file. This replaces the old
// regex-based `inferMethodFromFileScan` and `pickJavaHandlerName`
// helpers — tree-sitter gives both pieces of information
// structurally. Always run the lookup: even when method is set by
// `methodFromRouteReason`, we still need the handler name.
const detections = filePath ? getDetections(filePath) : [];
const providerDetections = detections.filter((d) => d.role === 'provider');
let handlerName: string | null = null;
const normalizedRoute = normalizeHttpPath(routePath);
const match = providerDetections.find((d) => normalizeHttpPath(d.path) === normalizedRoute);
if (match) {
if (!method) method = match.method;
handlerName = match.name;
}
if (!method) method = 'GET';
const pathNorm = normalizeHttpPath(routePath);
const cid = contractIdFor(method, pathNorm);
const handlerName =
content && routePath ? pickJavaHandlerName(content, routePath, method) : null;
let symbolUid = '';
let symbolName = path.basename(filePath) || 'handler';
@ -201,145 +293,44 @@ export class HttpRouteExtractor implements ContractExtractor {
return out;
}
private inferMethodFromFileScan(
content: string,
routePath: string,
_role: string,
): string | null {
const tail = routePath.split('/').filter(Boolean).pop() || '';
for (const m of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const) {
const mapNames: Record<string, string> = {
GET: 'GetMapping',
POST: 'PostMapping',
PUT: 'PutMapping',
DELETE: 'DeleteMapping',
PATCH: 'PatchMapping',
};
if (
content.includes(`@${mapNames[m]}`) &&
(content.includes(tail) || routePath.includes(tail))
) {
return m;
}
}
return null;
}
// ─── Source-scan providers ─────────────────────────────────────────
private async extractProvidersSourceScan(repoPath: string): Promise<ExtractedContract[]> {
const files = await glob('**/*.{ts,tsx,js,jsx,java,vue,svelte,php,py}', {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
nodir: true,
});
private extractProvidersSourceScan(
files: string[],
getDetections: (rel: string) => HttpDetection[],
): ExtractedContract[] {
const out: ExtractedContract[] = [];
for (const rel of files) {
const content = readSafe(repoPath, rel);
if (!content) continue;
out.push(...this.scanSpringProviders(content, rel));
out.push(...this.scanExpressProviders(content, rel));
out.push(...this.scanLaravelProviders(content, rel));
out.push(...this.scanFastApiProviders(content, rel));
const detections = getDetections(rel);
for (const d of detections) {
if (d.role !== 'provider') continue;
const pathNorm = normalizeHttpPath(d.path);
out.push({
contractId: contractIdFor(d.method, pathNorm),
type: 'http',
role: 'provider',
symbolUid: '',
symbolRef: { filePath: rel, name: d.name ?? 'handler' },
symbolName: d.name ?? 'handler',
confidence: d.confidence,
meta: {
method: d.method,
path: pathNorm,
pathSegments: pathNorm.split('/').filter(Boolean),
extractionStrategy: 'source_scan',
framework: d.framework,
},
});
}
}
return this.dedupeContracts(out);
}
private dedupeContracts(items: ExtractedContract[]): ExtractedContract[] {
const seen = new Set<string>();
const out: ExtractedContract[] = [];
for (const c of items) {
const k = `${c.contractId}|${c.symbolRef.filePath}|${c.symbolRef.name}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(c);
}
return out;
}
private scanSpringProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
let classPrefix = '';
const classRm = content.match(/@RequestMapping\s*\(\s*"([^"]+)"/);
if (classRm) classPrefix = classRm[1].replace(/\/+$/, '');
const re = /@(Get|Post|Put|Delete|Patch)Mapping\s*\(\s*"([^"]+)"/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
let p = m[2];
if (classPrefix) p = `${classPrefix}/${p.replace(/^\//, '')}`;
const pathNorm = normalizeHttpPath(p);
const sub = content.slice(m.index);
const nameM = sub.match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/);
const name = nameM ? nameM[1] : m[0];
out.push(this.makeProvider(filePath, method, pathNorm, name, 0.8));
}
return out;
}
private scanExpressProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /(?:router|app)\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
const pathNorm = normalizeHttpPath(m[2]);
out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8));
}
return out;
}
private scanLaravelProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /Route::(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
const pathNorm = normalizeHttpPath(m[2]);
out.push(this.makeProvider(filePath, method, pathNorm, 'route', 0.8));
}
return out;
}
private scanFastApiProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /@app\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
const pathNorm = normalizeHttpPath(m[2]);
out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8));
}
return out;
}
private makeProvider(
filePath: string,
method: string,
pathNorm: string,
name: string,
confidence: number,
): ExtractedContract {
const cid = contractIdFor(method, pathNorm);
return {
contractId: cid,
type: 'http',
role: 'provider',
symbolUid: '',
symbolRef: { filePath, name },
symbolName: name,
confidence,
meta: {
method,
path: pathNorm,
pathSegments: pathNorm.split('/').filter(Boolean),
extractionStrategy: 'source_scan',
},
};
}
// ─── Graph-assisted consumers ──────────────────────────────────────
private async extractConsumersGraph(
db: CypherExecutor,
repoPath: string,
getDetections: (rel: string) => HttpDetection[],
): Promise<ExtractedContract[]> {
const out: ExtractedContract[] = [];
let rows: Record<string, unknown>[];
@ -353,11 +344,14 @@ export class HttpRouteExtractor implements ContractExtractor {
const routePath = String(row.routePath ?? '');
const pathNorm = normalizeHttpPath(routePath);
let method = 'GET';
const content = readSafe(repoPath, filePath);
if (content) {
const inferred = this.inferFetchMethod(content, pathNorm);
if (inferred) method = inferred;
}
// Prefer the plugin's detected method if we can find a matching
// fetch/axios call in the same file.
const detections = filePath ? getDetections(filePath) : [];
const inferred = detections.find(
(d) => d.role === 'consumer' && normalizeConsumerPath(d.path) === pathNorm,
);
if (inferred) method = inferred.method;
const cid = contractIdFor(method, pathNorm);
let symbolUid = '';
let symbolName = 'fetch';
@ -395,81 +389,47 @@ export class HttpRouteExtractor implements ContractExtractor {
return out;
}
private inferFetchMethod(content: string, pathNorm: string): string | null {
const esc = pathNorm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const fetchRe = new RegExp(
`fetch\\s*\\(\\s*['"\`]([^'"\`]*${esc}[^'"\`]*)['"\`]\\s*,\\s*\\{[^}]*method:\\s*['"](\\w+)['"]`,
'i',
);
const m = content.match(fetchRe);
if (m) return m[2].toUpperCase();
return null;
}
// ─── Source-scan consumers ─────────────────────────────────────────
private async extractConsumersSourceScan(repoPath: string): Promise<ExtractedContract[]> {
const files = await glob('**/*.{ts,tsx,js,jsx,vue,svelte}', {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**'],
nodir: true,
});
private extractConsumersSourceScan(
files: string[],
getDetections: (rel: string) => HttpDetection[],
): ExtractedContract[] {
const out: ExtractedContract[] = [];
for (const rel of files) {
const content = readSafe(repoPath, rel);
if (!content) continue;
out.push(...this.scanFetchConsumers(content, rel));
out.push(...this.scanAxiosConsumers(content, rel));
const detections = getDetections(rel);
for (const d of detections) {
if (d.role !== 'consumer') continue;
const pathNorm = normalizeConsumerPath(d.path);
out.push({
contractId: contractIdFor(d.method, pathNorm),
type: 'http',
role: 'consumer',
symbolUid: '',
symbolRef: { filePath: rel, name: 'fetch' },
symbolName: 'fetch',
confidence: d.confidence,
meta: {
method: d.method,
path: pathNorm,
extractionStrategy: 'source_scan',
framework: d.framework,
},
});
}
}
return this.dedupeContracts(out);
}
private scanFetchConsumers(content: string, filePath: string): ExtractedContract[] {
private dedupeContracts(items: ExtractedContract[]): ExtractedContract[] {
const seen = new Set<string>();
const out: ExtractedContract[] = [];
const re =
/fetch\s*\(\s*['"`]([^'"`]+)['"`](?:\s*,\s*\{[^}]*method:\s*['"](\w+)['"][^}]*\})?\s*\)/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const pathNorm = normalizeHttpPath(this.templateToPattern(m[1]));
const method = (m[2] || 'GET').toUpperCase();
out.push(this.makeConsumer(filePath, method, pathNorm, 0.7));
for (const c of items) {
const k = `${c.contractId}|${c.symbolRef.filePath}|${c.symbolRef.name}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(c);
}
return out;
}
private templateToPattern(url: string): string {
return url.replace(/\$\{[^}]+\}/g, '{param}');
}
private scanAxiosConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /axios\.(get|post|put|delete|patch)\s*\(\s*[`'"]([^`'"]+)[`'"]/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
const pathNorm = normalizeHttpPath(this.templateToPattern(m[2]));
out.push(this.makeConsumer(filePath, method, pathNorm, 0.7));
}
return out;
}
private makeConsumer(
filePath: string,
method: string,
pathNorm: string,
confidence: number,
): ExtractedContract {
return {
contractId: contractIdFor(method, pathNorm),
type: 'http',
role: 'consumer',
symbolUid: '',
symbolRef: { filePath, name: 'fetch' },
symbolName: 'fetch',
confidence,
meta: {
method,
path: pathNorm,
extractionStrategy: 'source_scan',
},
};
}
}

View file

@ -0,0 +1,268 @@
import type { ContractType, CrossLink, GroupManifestLink, StoredContract } from '../types.js';
import type { CypherExecutor } from '../contract-extractor.js';
export interface ManifestExtractResult {
contracts: StoredContract[];
crossLinks: CrossLink[];
}
/**
* Canonicalize an HTTP path for matching against Route.name in the graph.
* Mirrors core/ingestion/pipeline.ts ensureSlash semantics:
* - Ensures a leading slash.
* - Strips trailing slashes (except the root "/").
* - Normalizes consecutive slashes.
* - Does NOT lowercase (route matching is case-sensitive).
*/
function normalizeRoutePath(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return '/';
const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
const collapsed = withLeading.replace(/\/+/g, '/');
if (collapsed === '/') return '/';
return collapsed.replace(/\/+$/, '');
}
/**
* Stable synthetic symbolUid for a manifest-declared contract whose target
* symbol could not be resolved against the per-repo graph (resolveSymbol
* returned null). Two reasons we don't leave the uid empty:
*
* 1. The bridge stores Contract nodes keyed in part by symbolUid; an empty
* uid means downstream Cypher queries that anchor on `provider.symbolUid`
* can't tell two different unresolved manifest contracts apart.
* 2. The cross-impact bridge query in cross-impact.ts joins local impact
* results to bridge contracts via `WHERE provider.symbolUid IN $localUids`.
* If the local impact engine produces a deterministic identifier for the
* unresolved target, it must agree with the value the bridge stored. A
* synthetic uid keyed off (repo, contractId) is the only thing both sides
* can derive without knowing about each other.
*
* Format: `manifest::<repo>::<contractId>`. Stable across syncs, scoped to a
* single repo within a group, and never collides with real indexer uids
* (which never start with `manifest::`).
*/
export function manifestSymbolUid(repo: string, contractId: string): string {
return `manifest::${repo}::${contractId}`;
}
export class ManifestExtractor {
async extractFromManifest(
links: GroupManifestLink[],
dbExecutors?: Map<string, CypherExecutor>,
): Promise<ManifestExtractResult> {
const contracts: StoredContract[] = [];
const crossLinks: CrossLink[] = [];
for (const link of links) {
const contractId = this.buildContractId(link.type, link.contract);
const providerRepo = link.role === 'provider' ? link.from : link.to;
const consumerRepo = link.role === 'provider' ? link.to : link.from;
const providerSymbol = await this.resolveSymbol(providerRepo, link, dbExecutors);
const consumerSymbol = await this.resolveSymbol(consumerRepo, link, dbExecutors);
const providerRef = providerSymbol || { filePath: '', name: link.contract };
const consumerRef = consumerSymbol || { filePath: '', name: link.contract };
// When the resolver finds a real graph symbol we keep its uid, otherwise
// fall back to the deterministic synthetic uid (see manifestSymbolUid).
const providerUid = providerSymbol?.uid || manifestSymbolUid(providerRepo, contractId);
const consumerUid = consumerSymbol?.uid || manifestSymbolUid(consumerRepo, contractId);
contracts.push({
contractId,
type: link.type,
role: 'provider',
symbolUid: providerUid,
symbolRef: providerRef,
symbolName: link.contract,
confidence: 1.0,
meta: { source: 'manifest' },
repo: providerRepo,
});
contracts.push({
contractId,
type: link.type,
role: 'consumer',
symbolUid: consumerUid,
symbolRef: consumerRef,
symbolName: link.contract,
confidence: 1.0,
meta: { source: 'manifest' },
repo: consumerRepo,
});
crossLinks.push({
from: { repo: consumerRepo, symbolUid: consumerUid, symbolRef: consumerRef },
to: { repo: providerRepo, symbolUid: providerUid, symbolRef: providerRef },
type: link.type,
contractId,
matchType: 'manifest',
confidence: 1.0,
});
}
return { contracts, crossLinks };
}
private async resolveSymbol(
repoPathKey: string,
link: GroupManifestLink,
dbExecutors?: Map<string, CypherExecutor>,
): Promise<{ filePath: string; name: string; uid: string } | null> {
const executor = dbExecutors?.get(repoPathKey);
if (!executor) return null;
// NOTE: All lookups use EXACT equality on the relevant name field and
// deterministic ORDER BY before LIMIT 1. Previous versions used CONTAINS
// for fuzzy matching (plus an unconditional ".proto" fallback for gRPC)
// which produced silent false positives: e.g. manifest "/orders" would
// match "/suborders", and a gRPC manifest entry in a repo with any
// .proto file would attach to a random proto symbol.
//
// If resolveSymbol returns null, the extractor falls back to a
// deterministic synthetic uid via `manifestSymbolUid(repo, contractId)`
// (see the function's docstring for why synthetic rather than empty).
// Cross-impact still works: the bridge query joins on the synthetic
// uid, and the local impact engine derives the same uid for the
// unresolved symbol — name-based hints are the additional safety net.
try {
let rows: Record<string, unknown>[];
if (link.type === 'http') {
// Route.name is the canonicalized URL path (see
// core/ingestion/pipeline.ts ensureSlash + generateId('Route', ...)).
// Normalize the manifest contract the same way so a user-written
// "/api/orders" matches "api/orders" in the graph.
const normalized = normalizeRoutePath(link.contract);
rows = await executor(
`MATCH (handler)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
WHERE route.name = $normalized
RETURN handler.id AS uid, handler.name AS name, handler.filePath AS filePath
ORDER BY handler.filePath ASC
LIMIT 1`,
{ normalized },
);
} else if (link.type === 'topic') {
// Topic names aren't a first-class NodeLabel in the graph —
// topics are referenced by function/method symbols (Kafka
// listeners, publishers). Restrict to symbol-like labels to
// avoid cross-matching Files/Variables/Imports that happen to
// share the topic name.
rows = await executor(
`MATCH (n:Function|Method|Class|Interface) WHERE n.name = $contract
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
{ contract: link.contract },
);
} else if (link.type === 'grpc') {
// Contract is "Service/Method" or just "Service" (or package.Service
// variants). Prefer matching by method name when present, otherwise
// by service name. NO .proto path fallback — that's guaranteed to
// return a wrong symbol in any repo with more than one proto file.
// Label filters scope lookups: methods → Function|Method, services
// → Class|Interface (no label match = no silent wrong hits on
// File/Variable nodes that happen to share the name).
const parts = link.contract.split('/');
const serviceName = parts[0]?.trim() ?? '';
const methodName = parts[1]?.trim() ?? '';
if (methodName) {
rows = await executor(
`MATCH (n:Function|Method) WHERE n.name = $methodName
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
{ methodName },
);
} else if (serviceName) {
rows = await executor(
`MATCH (n:Class|Interface) WHERE n.name = $serviceName
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
{ serviceName },
);
} else {
rows = [];
}
} else if (link.type === 'lib') {
// Only exact match on the symbol's name. Previous fallback to
// CONTAINS on n.filePath would promote "react" to "react-native"
// or "@types/react" — silent wrong attribution. Restrict to
// package-level labels so we don't return arbitrary symbols
// named after a library.
rows = await executor(
`MATCH (n:Package|Module) WHERE n.name = $contract
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
{ contract: link.contract },
);
} else {
return null;
}
if (rows.length > 0) {
return {
filePath: rows[0].filePath as string,
name: rows[0].name as string,
uid: String(rows[0].uid ?? ''),
};
}
} catch (err) {
// Log but don't throw: a broken graph query in one repo shouldn't
// fail the whole manifest extraction. Unresolved contracts still
// get a synthetic symbolUid below, so cross-impact can proceed.
const message = err instanceof Error ? err.message : String(err);
console.warn(
`[manifest-extractor] resolveSymbol failed for ${link.type}:${link.contract} ` +
`in ${repoPathKey}: ${message}`,
);
}
return null;
}
/**
* Build a canonical contract id for a manifest link.
*
* HTTP is the only type with two valid forms:
* - Explicit method: `"GET::/api/orders"` `"http::GET::/api/orders"`
* (matches exactly against `HttpRouteExtractor` provider/consumer
* contracts, which are also keyed by `http::<METHOD>::<path>`).
* - Method-agnostic: `"/api/orders"` `"http::*::/api/orders"`
* the `*` is a wildcard and is intended to match any concrete
* HTTP method on that path. Wildcard-aware matching is the
* responsibility of the sync / cross-impact layer (see #793);
* downstream code should treat `http::*::<path>` as matching
* every `http::<METHOD>::<path>` for the same path.
*
* Recommend the explicit-method form in group.yaml whenever the
* manifest author knows the method it round-trips through exact
* equality matching without requiring wildcard logic downstream.
*
* NOTE on exhaustiveness: the switch covers every current
* `ContractType` variant and falls through to a `never` assertion so
* TypeScript fails the build if a new variant is added without a
* corresponding case.
*/
private buildContractId(type: ContractType, contract: string): string {
switch (type) {
case 'http': {
if (/^[A-Za-z]+::/.test(contract)) return `http::${contract}`;
return `http::*::${contract}`;
}
case 'grpc':
return `grpc::${contract}`;
case 'topic':
return `topic::${contract}`;
case 'lib':
return `lib::${contract}`;
case 'custom':
return `custom::${contract}`;
default: {
const _exhaustive: never = type;
throw new Error(`Unhandled ContractType: ${String(_exhaustive)}`);
}
}
}
}

View file

@ -1,214 +1,49 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { glob } from 'glob';
import Parser from 'tree-sitter';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
import { scanFile, unquoteLiteral } from './tree-sitter-scanner.js';
import {
TOPIC_SCAN_GLOB,
getProviderForFile,
type Broker,
type TopicMeta,
} from './topic-patterns/index.js';
type Broker = 'kafka' | 'rabbitmq' | 'nats';
/**
* Language-agnostic orchestrator for topic (message broker) contract
* extraction. All grammar-specific knowledge lives in `topic-patterns/*`
* this file must not import any tree-sitter grammar directly.
*
* Flow per file:
* 1. `getProviderForFile(rel)` compiled plugin (or `undefined` if the
* file's extension isn't registered, in which case we skip it).
* 2. `scanFile(parser, provider, content)` list of `{meta, valueText}`
* pairs, one per matched literal.
* 3. `unquoteLiteral(valueText)` the raw topic string.
* 4. `makeContract(topic, meta, relPath)` `ExtractedContract`.
*
* Adding a new language is a one-file edit in `topic-patterns/index.ts`.
*/
function readSafe(repoPath: string, rel: string): string | null {
const abs = path.resolve(repoPath, rel);
const base = path.resolve(repoPath);
const relToBase = path.relative(base, abs);
if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
try {
return fs.readFileSync(abs, 'utf-8');
} catch {
return null;
}
}
function makeContract(
topicName: string,
role: 'provider' | 'consumer',
filePath: string,
symbolName: string,
confidence: number,
broker: Broker,
): ExtractedContract {
function makeContract(topicName: string, meta: TopicMeta, filePath: string): ExtractedContract {
return {
contractId: `topic::${topicName}`,
type: 'topic',
role,
role: meta.role,
symbolUid: '',
symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: symbolName },
symbolName,
confidence,
symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: meta.symbolName },
symbolName: meta.symbolName,
confidence: meta.confidence,
meta: {
broker,
broker: meta.broker satisfies Broker,
topicName,
extractionStrategy: 'source_scan',
extractionStrategy: 'tree_sitter',
},
};
}
interface PatternDef {
regex: RegExp;
role: 'provider' | 'consumer';
broker: Broker;
confidence: number;
topicGroup: number;
symbolName: string;
}
// --- Kafka patterns ---
const KAFKA_PATTERNS: PatternDef[] = [
// Java: @KafkaListener(topics = "xxx")
{
regex: /@KafkaListener\s*\(\s*topics\s*=\s*"([^"]+)"/g,
role: 'consumer',
broker: 'kafka',
confidence: 0.8,
topicGroup: 1,
symbolName: 'kafkaListener',
},
// Java: kafkaTemplate.send("xxx"
{
regex: /kafkaTemplate\.send\s*\(\s*"([^"]+)"/gi,
role: 'provider',
broker: 'kafka',
confidence: 0.8,
topicGroup: 1,
symbolName: 'kafkaTemplate.send',
},
// Node: producer.send({ topic: 'xxx'
{
regex: /producer\.send\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g,
role: 'provider',
broker: 'kafka',
confidence: 0.8,
topicGroup: 1,
symbolName: 'producer.send',
},
// Node: consumer.subscribe({ topic: 'xxx'
{
regex: /consumer\.subscribe\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g,
role: 'consumer',
broker: 'kafka',
confidence: 0.8,
topicGroup: 1,
symbolName: 'consumer.subscribe',
},
// Go: consumer.ConsumePartition("xxx"
{
regex: /\.ConsumePartition\s*\(\s*"([^"]+)"/g,
role: 'consumer',
broker: 'kafka',
confidence: 0.7,
topicGroup: 1,
symbolName: 'ConsumePartition',
},
// Python: KafkaConsumer('xxx'
{
regex: /KafkaConsumer\s*\(\s*['"]([^'"]+)['"]/g,
role: 'consumer',
broker: 'kafka',
confidence: 0.7,
topicGroup: 1,
symbolName: 'KafkaConsumer',
},
// Python: producer.send('xxx' or producer.produce('xxx'
{
regex: /producer\.(?:send|produce)\s*\(\s*['"]([^'"]+)['"]/g,
role: 'provider',
broker: 'kafka',
confidence: 0.7,
topicGroup: 1,
symbolName: 'producer.send',
},
];
// --- RabbitMQ patterns ---
const RABBITMQ_PATTERNS: PatternDef[] = [
// Java: @RabbitListener(queues = "xxx")
{
regex: /@RabbitListener\s*\(\s*queues\s*=\s*"([^"]+)"/g,
role: 'consumer',
broker: 'rabbitmq',
confidence: 0.8,
topicGroup: 1,
symbolName: 'rabbitListener',
},
// Java: rabbitTemplate.convertAndSend("xxx"
{
regex: /rabbitTemplate\.convertAndSend\s*\(\s*"([^"]+)"/gi,
role: 'provider',
broker: 'rabbitmq',
confidence: 0.8,
topicGroup: 1,
symbolName: 'rabbitTemplate.convertAndSend',
},
// Node: channel.consume("xxx"
{
regex: /channel\.consume\s*\(\s*"([^"]+)"/g,
role: 'consumer',
broker: 'rabbitmq',
confidence: 0.8,
topicGroup: 1,
symbolName: 'channel.consume',
},
// Node: channel.publish("xxx"
{
regex: /channel\.publish\s*\(\s*"([^"]+)"/g,
role: 'provider',
broker: 'rabbitmq',
confidence: 0.8,
topicGroup: 1,
symbolName: 'channel.publish',
},
// Node: channel.sendToQueue("xxx"
{
regex: /channel\.sendToQueue\s*\(\s*"([^"]+)"/g,
role: 'provider',
broker: 'rabbitmq',
confidence: 0.8,
topicGroup: 1,
symbolName: 'channel.sendToQueue',
},
// Python: channel.basic_consume(queue='xxx'
{
regex: /channel\.basic_consume\s*\(\s*queue\s*=\s*['"]([^'"]+)['"]/g,
role: 'consumer',
broker: 'rabbitmq',
confidence: 0.7,
topicGroup: 1,
symbolName: 'basic_consume',
},
// Python: channel.basic_publish(exchange='xxx'
{
regex: /channel\.basic_publish\s*\([^)]*exchange\s*=\s*['"]([^'"]+)['"]/g,
role: 'provider',
broker: 'rabbitmq',
confidence: 0.7,
topicGroup: 1,
symbolName: 'basic_publish',
},
];
// --- NATS patterns ---
const NATS_PATTERNS: PatternDef[] = [
// Go/Node: nc.Subscribe("xxx" or nc.subscribe("xxx"
{
regex: /nc\.(?:S|s)ubscribe\s*\(\s*"([^"]+)"/g,
role: 'consumer',
broker: 'nats',
confidence: 0.8,
topicGroup: 1,
symbolName: 'nc.Subscribe',
},
// Go/Node: nc.Publish("xxx" or nc.publish("xxx"
{
regex: /nc\.(?:P|p)ublish\s*\(\s*"([^"]+)"/g,
role: 'provider',
broker: 'nats',
confidence: 0.8,
topicGroup: 1,
symbolName: 'nc.Publish',
},
];
const ALL_PATTERNS: PatternDef[] = [...KAFKA_PATTERNS, ...RABBITMQ_PATTERNS, ...NATS_PATTERNS];
export class TopicExtractor implements ContractExtractor {
type = 'topic' as const;
@ -221,46 +56,48 @@ export class TopicExtractor implements ContractExtractor {
repoPath: string,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
const files = await glob('**/*.{ts,tsx,js,jsx,java,go,py}', {
const files = await glob(TOPIC_SCAN_GLOB, {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
ignore: [
'**/node_modules/**',
'**/.git/**',
'**/vendor/**',
'**/dist/**',
'**/build/**',
// Language-level test file conventions. Go test files
// `*_test.go` live next to source; other languages either use
// separate test directories (Python's `tests/`, Java's
// `src/test/`) or are already covered by the dist/build ignores.
// Pushed to the glob level so the orchestrator stays
// language-agnostic.
'**/*_test.go',
],
nodir: true,
});
// One parser reused across files; the scanner calls `setLanguage` per
// file based on which plugin the registry returns.
const parser = new Parser();
const out: ExtractedContract[] = [];
for (const rel of files) {
const provider = getProviderForFile(rel);
if (!provider) continue;
const content = readSafe(repoPath, rel);
if (!content) continue;
out.push(...this.scanFile(content, rel));
}
return this.dedupe(out);
}
private scanFile(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
for (const pattern of ALL_PATTERNS) {
// Reset regex state for each file
const re = new RegExp(pattern.regex.source, pattern.regex.flags);
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const topicName = m[pattern.topicGroup];
const matches = scanFile(parser, provider, content);
for (const match of matches) {
const valueNode = match.captures.value;
if (!valueNode) continue;
const topicName = unquoteLiteral(valueNode.text);
if (!topicName) continue;
out.push(
makeContract(
topicName,
pattern.role,
filePath,
pattern.symbolName,
pattern.confidence,
pattern.broker,
),
);
out.push(makeContract(topicName, match.meta, rel));
}
}
return out;
return this.dedupe(out);
}
private dedupe(items: ExtractedContract[]): ExtractedContract[] {

View file

@ -0,0 +1,123 @@
import Go from 'tree-sitter-go';
import { compilePatterns, type LanguagePatterns } from '../tree-sitter-scanner.js';
import type { TopicMeta } from './types.js';
/**
* Go topic extraction patterns.
*
* Detects Sarama, segmentio/kafka-go and nats.go producer/consumer APIs:
* - `X.ConsumePartition("topic", ...)`
* - `sarama.ProducerMessage{Topic: "xxx"}`
* - `kafka.Writer{Topic: "xxx"}` / `kafka.WriterConfig{Topic: ...}`
* - `kafka.Reader{Topic: "xxx"}` / `kafka.ReaderConfig{Topic: ...}`
* - `nc.Subscribe("topic", ...)` / `js.Subscribe("topic", ...)`
* - `nc.Publish("topic", ...)` / `js.Publish("topic", ...)`
*
* Every query MUST bind `@value` to the topic literal node.
*/
const GO_TOPIC_SPEC: LanguagePatterns<TopicMeta> = {
name: 'go-topic',
language: Go,
patterns: [
{
meta: {
role: 'consumer',
broker: 'kafka',
confidence: 0.7,
symbolName: 'ConsumePartition',
},
query: `
(call_expression
function: (selector_expression
field: (field_identifier) @method (#eq? @method "ConsumePartition"))
arguments: (argument_list . (interpreted_string_literal) @value))
`,
},
{
meta: {
role: 'provider',
broker: 'kafka',
confidence: 0.75,
symbolName: 'sarama.ProducerMessage',
},
query: `
(composite_literal
type: (qualified_type
package: (package_identifier) @pkg (#eq? @pkg "sarama")
name: (type_identifier) @ty (#eq? @ty "ProducerMessage"))
body: (literal_value
(keyed_element
(literal_element (identifier) @field (#eq? @field "Topic"))
(literal_element (interpreted_string_literal) @value))))
`,
},
{
meta: {
role: 'provider',
broker: 'kafka',
confidence: 0.75,
symbolName: 'kafka.Writer',
},
query: `
(composite_literal
type: (qualified_type
package: (package_identifier) @pkg (#eq? @pkg "kafka")
name: (type_identifier) @ty (#match? @ty "^(Writer|WriterConfig)$"))
body: (literal_value
(keyed_element
(literal_element (identifier) @field (#eq? @field "Topic"))
(literal_element (interpreted_string_literal) @value))))
`,
},
{
meta: {
role: 'consumer',
broker: 'kafka',
confidence: 0.75,
symbolName: 'kafka.Reader',
},
query: `
(composite_literal
type: (qualified_type
package: (package_identifier) @pkg (#eq? @pkg "kafka")
name: (type_identifier) @ty (#match? @ty "^(Reader|ReaderConfig)$"))
body: (literal_value
(keyed_element
(literal_element (identifier) @field (#eq? @field "Topic"))
(literal_element (interpreted_string_literal) @value))))
`,
},
{
meta: {
role: 'consumer',
broker: 'nats',
confidence: 0.8,
symbolName: 'nc.Subscribe',
},
query: `
(call_expression
function: (selector_expression
operand: (identifier) @obj (#match? @obj "^(nc|js)$")
field: (field_identifier) @method (#match? @method "^[Ss]ubscribe$"))
arguments: (argument_list . (interpreted_string_literal) @value))
`,
},
{
meta: {
role: 'provider',
broker: 'nats',
confidence: 0.8,
symbolName: 'nc.Publish',
},
query: `
(call_expression
function: (selector_expression
operand: (identifier) @obj (#match? @obj "^(nc|js)$")
field: (field_identifier) @method (#match? @method "^[Pp]ublish$"))
arguments: (argument_list . (interpreted_string_literal) @value))
`,
},
],
};
export const GO_TOPIC_PROVIDER = compilePatterns(GO_TOPIC_SPEC);

View file

@ -0,0 +1,49 @@
import * as path from 'node:path';
import type { CompiledPatterns } from '../tree-sitter-scanner.js';
import type { TopicMeta } from './types.js';
import { JAVA_TOPIC_PROVIDER } from './java.js';
import { GO_TOPIC_PROVIDER } from './go.js';
import { PYTHON_TOPIC_PROVIDER } from './python.js';
import {
JAVASCRIPT_TOPIC_PROVIDER,
TYPESCRIPT_TOPIC_PROVIDER,
TSX_TOPIC_PROVIDER,
} from './node.js';
export type { TopicMeta, Broker } from './types.js';
/**
* File-extension compiled-plugin registry for topic extraction. The
* top-level orchestrator (`topic-extractor.ts`) looks up the plugin for
* each file it visits and delegates the scanning to `tree-sitter-scanner`.
*
* Keys are lowercase extensions including the leading dot. To add a new
* language, drop a `topic-patterns/<lang>.ts` that exports a compiled
* provider, import it here and register the extension(s). No edits to
* `topic-extractor.ts` are required.
*/
const REGISTRY: Record<string, CompiledPatterns<TopicMeta>> = {
'.java': JAVA_TOPIC_PROVIDER,
'.go': GO_TOPIC_PROVIDER,
'.py': PYTHON_TOPIC_PROVIDER,
'.js': JAVASCRIPT_TOPIC_PROVIDER,
'.jsx': JAVASCRIPT_TOPIC_PROVIDER,
'.ts': TYPESCRIPT_TOPIC_PROVIDER,
'.tsx': TSX_TOPIC_PROVIDER,
};
/**
* Glob pattern for files worth scanning. Kept here so adding a new
* language to the registry also widens the glob automatically via a
* single edit.
*/
export const TOPIC_SCAN_GLOB = '**/*.{ts,tsx,js,jsx,java,go,py}';
/**
* Return the compiled provider registered for the given file's
* extension, or `undefined` if the extension is not registered.
*/
export function getProviderForFile(rel: string): CompiledPatterns<TopicMeta> | undefined {
const ext = path.extname(rel).toLowerCase();
return REGISTRY[ext];
}

View file

@ -0,0 +1,83 @@
import Java from 'tree-sitter-java';
import { compilePatterns, type LanguagePatterns } from '../tree-sitter-scanner.js';
import type { TopicMeta } from './types.js';
/**
* Java topic extraction patterns.
*
* Detects Kafka and RabbitMQ (Spring conventions) producer/consumer APIs:
* - `@KafkaListener(topics = "xxx")`
* - `@RabbitListener(queues = "xxx")`
* - `kafkaTemplate.send("xxx", ...)`
* - `rabbitTemplate.convertAndSend("xxx", ...)`
*
* Every query MUST bind `@value` to the topic literal node.
*/
const JAVA_TOPIC_SPEC: LanguagePatterns<TopicMeta> = {
name: 'java-topic',
language: Java,
patterns: [
{
meta: {
role: 'consumer',
broker: 'kafka',
confidence: 0.8,
symbolName: 'kafkaListener',
},
query: `
(annotation
name: (identifier) @name (#eq? @name "KafkaListener")
arguments: (annotation_argument_list
(element_value_pair
key: (identifier) @key (#eq? @key "topics")
value: (string_literal) @value)))
`,
},
{
meta: {
role: 'consumer',
broker: 'rabbitmq',
confidence: 0.8,
symbolName: 'rabbitListener',
},
query: `
(annotation
name: (identifier) @name (#eq? @name "RabbitListener")
arguments: (annotation_argument_list
(element_value_pair
key: (identifier) @key (#eq? @key "queues")
value: (string_literal) @value)))
`,
},
{
meta: {
role: 'provider',
broker: 'kafka',
confidence: 0.8,
symbolName: 'kafkaTemplate.send',
},
query: `
(method_invocation
object: (identifier) @obj (#eq? @obj "kafkaTemplate")
name: (identifier) @method (#eq? @method "send")
arguments: (argument_list . (string_literal) @value))
`,
},
{
meta: {
role: 'provider',
broker: 'rabbitmq',
confidence: 0.8,
symbolName: 'rabbitTemplate.convertAndSend',
},
query: `
(method_invocation
object: (identifier) @obj (#eq? @obj "rabbitTemplate")
name: (identifier) @method (#eq? @method "convertAndSend")
arguments: (argument_list . (string_literal) @value))
`,
},
],
};
export const JAVA_TOPIC_PROVIDER = compilePatterns(JAVA_TOPIC_SPEC);

View file

@ -0,0 +1,165 @@
import JavaScript from 'tree-sitter-javascript';
import TypeScript from 'tree-sitter-typescript';
import {
compilePatterns,
type LanguagePatterns,
type PatternSpec,
} from '../tree-sitter-scanner.js';
import type { TopicMeta } from './types.js';
/**
* Node.js / TypeScript topic extraction patterns.
*
* Detects kafkajs, amqplib (RabbitMQ), and nats.js producer/consumer APIs:
* - `producer.send({ topic: 'xxx', ... })` (kafkajs)
* - `consumer.subscribe({ topic: 'xxx', ... })` (kafkajs)
* - `channel.consume("queue", ...)` / `channel.publish(...)` / `channel.sendToQueue(...)`
* - `nc.subscribe("topic")` / `js.subscribe("topic")`
* - `nc.publish("topic", ...)` / `js.publish("topic", ...)`
*
* The JavaScript and TypeScript tree-sitter grammars share node type
* names for every construct we query here, so the pattern sources are
* defined once and compiled against each grammar variant. We export three
* providers because Parser.Query objects are NOT portable across grammar
* instances `.js` files use the JavaScript grammar, `.ts` uses
* TypeScript.typescript, and `.tsx` uses TypeScript.tsx.
*
* Every query MUST bind `@value` to the topic literal node.
*/
const NODE_TOPIC_PATTERNS: PatternSpec<TopicMeta>[] = [
{
meta: {
role: 'provider',
broker: 'kafka',
confidence: 0.8,
symbolName: 'producer.send',
},
query: `
(call_expression
function: (member_expression
object: (identifier) @obj (#eq? @obj "producer")
property: (property_identifier) @prop (#eq? @prop "send"))
arguments: (arguments
(object
(pair
key: (property_identifier) @key (#eq? @key "topic")
value: [(string) (template_string)] @value))))
`,
},
{
meta: {
role: 'consumer',
broker: 'kafka',
confidence: 0.8,
symbolName: 'consumer.subscribe',
},
query: `
(call_expression
function: (member_expression
object: (identifier) @obj (#eq? @obj "consumer")
property: (property_identifier) @prop (#eq? @prop "subscribe"))
arguments: (arguments
(object
(pair
key: (property_identifier) @key (#eq? @key "topic")
value: [(string) (template_string)] @value))))
`,
},
{
meta: {
role: 'consumer',
broker: 'rabbitmq',
confidence: 0.8,
symbolName: 'channel.consume',
},
query: `
(call_expression
function: (member_expression
object: (identifier) @obj (#eq? @obj "channel")
property: (property_identifier) @prop (#eq? @prop "consume"))
arguments: (arguments . [(string) (template_string)] @value))
`,
},
{
meta: {
role: 'provider',
broker: 'rabbitmq',
confidence: 0.8,
symbolName: 'channel.publish',
},
query: `
(call_expression
function: (member_expression
object: (identifier) @obj (#eq? @obj "channel")
property: (property_identifier) @prop (#eq? @prop "publish"))
arguments: (arguments . [(string) (template_string)] @value))
`,
},
{
meta: {
role: 'provider',
broker: 'rabbitmq',
confidence: 0.8,
symbolName: 'channel.sendToQueue',
},
query: `
(call_expression
function: (member_expression
object: (identifier) @obj (#eq? @obj "channel")
property: (property_identifier) @prop (#eq? @prop "sendToQueue"))
arguments: (arguments . [(string) (template_string)] @value))
`,
},
{
meta: {
role: 'consumer',
broker: 'nats',
confidence: 0.8,
symbolName: 'nc.subscribe',
},
query: `
(call_expression
function: (member_expression
object: (identifier) @obj (#match? @obj "^(nc|js)$")
property: (property_identifier) @prop (#match? @prop "^[Ss]ubscribe$"))
arguments: (arguments . [(string) (template_string)] @value))
`,
},
{
meta: {
role: 'provider',
broker: 'nats',
confidence: 0.8,
symbolName: 'nc.publish',
},
query: `
(call_expression
function: (member_expression
object: (identifier) @obj (#match? @obj "^(nc|js)$")
property: (property_identifier) @prop (#match? @prop "^[Pp]ublish$"))
arguments: (arguments . [(string) (template_string)] @value))
`,
},
];
const JAVASCRIPT_TOPIC_SPEC: LanguagePatterns<TopicMeta> = {
name: 'javascript-topic',
language: JavaScript,
patterns: NODE_TOPIC_PATTERNS,
};
const TYPESCRIPT_TOPIC_SPEC: LanguagePatterns<TopicMeta> = {
name: 'typescript-topic',
language: TypeScript.typescript,
patterns: NODE_TOPIC_PATTERNS,
};
const TSX_TOPIC_SPEC: LanguagePatterns<TopicMeta> = {
name: 'tsx-topic',
language: TypeScript.tsx,
patterns: NODE_TOPIC_PATTERNS,
};
export const JAVASCRIPT_TOPIC_PROVIDER = compilePatterns(JAVASCRIPT_TOPIC_SPEC);
export const TYPESCRIPT_TOPIC_PROVIDER = compilePatterns(TYPESCRIPT_TOPIC_SPEC);
export const TSX_TOPIC_PROVIDER = compilePatterns(TSX_TOPIC_SPEC);

View file

@ -0,0 +1,119 @@
import Python from 'tree-sitter-python';
import { compilePatterns, type LanguagePatterns } from '../tree-sitter-scanner.js';
import type { TopicMeta } from './types.js';
/**
* Python topic extraction patterns.
*
* Detects kafka-python, pika (RabbitMQ), and nats-py producer/consumer APIs:
* - `KafkaConsumer('topic', ...)`
* - `producer.send('topic', ...)` / `producer.produce('topic', ...)`
* - `channel.basic_consume(queue='xxx', ...)`
* - `channel.basic_publish(exchange='xxx', ...)`
* - `await nc.subscribe('topic')`
* - `await nc.publish('topic', ...)`
*
* Every query MUST bind `@value` to the topic literal node.
*/
const PYTHON_TOPIC_SPEC: LanguagePatterns<TopicMeta> = {
name: 'python-topic',
language: Python,
patterns: [
{
meta: {
role: 'consumer',
broker: 'kafka',
confidence: 0.7,
symbolName: 'KafkaConsumer',
},
query: `
(call
function: (identifier) @func (#eq? @func "KafkaConsumer")
arguments: (argument_list . (string) @value))
`,
},
{
meta: {
role: 'provider',
broker: 'kafka',
confidence: 0.7,
symbolName: 'producer.send',
},
query: `
(call
function: (attribute
object: (identifier) @obj (#eq? @obj "producer")
attribute: (identifier) @method (#match? @method "^(send|produce)$"))
arguments: (argument_list . (string) @value))
`,
},
{
meta: {
role: 'consumer',
broker: 'rabbitmq',
confidence: 0.7,
symbolName: 'basic_consume',
},
query: `
(call
function: (attribute
object: (identifier) @obj (#eq? @obj "channel")
attribute: (identifier) @method (#eq? @method "basic_consume"))
arguments: (argument_list
(keyword_argument
name: (identifier) @kw (#eq? @kw "queue")
value: (string) @value)))
`,
},
{
meta: {
role: 'provider',
broker: 'rabbitmq',
confidence: 0.7,
symbolName: 'basic_publish',
},
query: `
(call
function: (attribute
object: (identifier) @obj (#eq? @obj "channel")
attribute: (identifier) @method (#eq? @method "basic_publish"))
arguments: (argument_list
(keyword_argument
name: (identifier) @kw (#eq? @kw "exchange")
value: (string) @value)))
`,
},
{
meta: {
role: 'consumer',
broker: 'nats',
confidence: 0.75,
symbolName: 'nc.subscribe',
},
query: `
(call
function: (attribute
object: (identifier) @obj (#eq? @obj "nc")
attribute: (identifier) @method (#eq? @method "subscribe"))
arguments: (argument_list . (string) @value))
`,
},
{
meta: {
role: 'provider',
broker: 'nats',
confidence: 0.75,
symbolName: 'nc.publish',
},
query: `
(call
function: (attribute
object: (identifier) @obj (#eq? @obj "nc")
attribute: (identifier) @method (#eq? @method "publish"))
arguments: (argument_list . (string) @value))
`,
},
],
};
export const PYTHON_TOPIC_PROVIDER = compilePatterns(PYTHON_TOPIC_SPEC);

View file

@ -0,0 +1,27 @@
/**
* Shared types for the topic-extractor language plugins.
*
* Each plugin lives in its own file (java.ts, go.ts, ...) and owns the
* tree-sitter grammar import + query sources. The top-level
* `topic-extractor.ts` orchestrator only knows about this type module and
* the plugin registry (`./index.ts`). It MUST NOT import any grammar or
* query text directly that's the whole point of the split.
*/
export type Broker = 'kafka' | 'rabbitmq' | 'nats';
/**
* Per-pattern payload every topic plugin attaches to its query. Whatever
* the pattern matches, the orchestrator receives this object verbatim
* and uses it to build an `ExtractedContract`.
*
* Plugins produce one `TopicMeta` per pattern (not per match) because a
* single query uniquely identifies its broker/role/confidence triple.
*/
export interface TopicMeta {
role: 'provider' | 'consumer';
broker: Broker;
confidence: number;
/** Short human-readable label of the API being detected. */
symbolName: string;
}

View file

@ -0,0 +1,193 @@
import Parser from 'tree-sitter';
/**
* Shared, language-agnostic tree-sitter scanning utilities used by group
* extractors (topic, http, grpc, ...).
*
* Design goals:
* - The top-level extractors must not import any tree-sitter grammar.
* - Per-language plugins own their grammar import, their query sources,
* and the mapping from capture meta.
* - This module provides the plumbing: compile queries once per plugin,
* parse a file with a given grammar, run all patterns, and return the
* captured `string_literal`-style nodes together with the plugin's meta.
*/
/**
* One pattern owned by a language plugin. Each pattern owns a tree-sitter
* S-expression query. Plugins can freely choose which capture names to
* use the scanner exposes every capture in the returned `captures`
* map and does not privilege any particular name.
*
* `TMeta` is the plugin-specific payload the orchestrator receives back
* when this pattern matches e.g. for topic extraction it carries the
* broker name, role, confidence, symbol name.
*/
export interface PatternSpec<TMeta> {
/** Tree-sitter S-expression. */
query: string;
/** Plugin-specific payload returned on every match. */
meta: TMeta;
}
/**
* A set of patterns owned by one language plugin, bound to a specific
* tree-sitter grammar.
*
* `language` is typed as `unknown` because tree-sitter's TypeScript
* declarations use `any` for the grammar object, and the grammar modules
* export different shapes (plain grammar vs. namespace with `typescript`
* / `tsx` members). Callers pass the concrete grammar object; this
* module forwards it to `parser.setLanguage` / `new Parser.Query`.
*/
export interface LanguagePatterns<TMeta> {
/** Human-readable plugin name for diagnostics. */
name: string;
/** tree-sitter grammar object. */
language: unknown;
/** Patterns authored against `language`. */
patterns: PatternSpec<TMeta>[];
}
/**
* Compiled form of a `LanguagePatterns` bundle. Queries are compiled
* eagerly at module load time so a broken grammar/query pair fails
* loudly the first time the plugin is imported, instead of silently
* at scan time when no contract is produced.
*/
export interface CompiledPatterns<TMeta> {
name: string;
language: unknown;
patterns: CompiledPattern<TMeta>[];
}
export interface CompiledPattern<TMeta> {
query: Parser.Query;
meta: TMeta;
}
/**
* Map from capture name syntax node. Every named capture the query
* binds is exposed as an entry. If a query captures the same name more
* than once (unusual), the first occurrence wins plugins that need
* all occurrences should use distinct capture names or fall back to
* `match.captures` array directly by iterating `query.matches()`
* themselves.
*/
export type CaptureMap = Record<string, Parser.SyntaxNode>;
/**
* One match returned by `scanFile` / `runCompiledPatterns`. The caller
* receives the full capture map plus the plugin meta, and is
* responsible for turning it into a domain object.
*/
export interface ScanMatch<TMeta> {
meta: TMeta;
captures: CaptureMap;
}
/**
* Compile a LanguagePatterns bundle. Call this once per plugin, at
* module load time, and export the result. Throws if any pattern
* fails to compile against the grammar that's a bug in the plugin
* author's query, not a runtime condition.
*/
export function compilePatterns<TMeta>(bundle: LanguagePatterns<TMeta>): CompiledPatterns<TMeta> {
const compiled: CompiledPattern<TMeta>[] = [];
for (const spec of bundle.patterns) {
try {
const query = new Parser.Query(bundle.language, spec.query);
compiled.push({ query, meta: spec.meta });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`[tree-sitter-scanner] Failed to compile pattern in ${bundle.name}: ${message}\n` +
`Query source:\n${spec.query}`,
);
}
}
return { name: bundle.name, language: bundle.language, patterns: compiled };
}
/**
* Run every compiled pattern in `plugin` against an already-parsed
* tree. Use this when a plugin needs multiple query bundles against
* the same file (e.g. one query for class-level prefixes and another
* for method-level annotations) and wants to avoid re-parsing.
*/
export function runCompiledPatterns<TMeta>(
plugin: CompiledPatterns<TMeta>,
tree: Parser.Tree,
): ScanMatch<TMeta>[] {
const out: ScanMatch<TMeta>[] = [];
for (const compiled of plugin.patterns) {
let matches: Parser.QueryMatch[];
try {
matches = compiled.query.matches(tree.rootNode);
} catch {
continue;
}
for (const match of matches) {
const captures: CaptureMap = {};
for (const cap of match.captures) {
if (!(cap.name in captures)) captures[cap.name] = cap.node;
}
out.push({ meta: compiled.meta, captures });
}
}
return out;
}
/**
* Parse `content` with the plugin's grammar and run every compiled
* pattern against the AST. Returns one `ScanMatch` per matched query
* occurrence, carrying the plugin's meta payload.
*
* Errors are swallowed at the file level (malformed file must not abort
* the whole extract). Individual pattern failures are swallowed too so
* a single unusable query doesn't block the rest of the plugin.
*/
export function scanFile<TMeta>(
parser: Parser,
plugin: CompiledPatterns<TMeta>,
content: string,
): ScanMatch<TMeta>[] {
let tree: Parser.Tree;
try {
parser.setLanguage(plugin.language);
tree = parser.parse(content);
} catch {
return [];
}
return runCompiledPatterns(plugin, tree);
}
/**
* Strip enclosing quotes from a tree-sitter string literal node's text.
* Handles single / double / template quotes, Python triple-quoted strings,
* and Go raw string literals (backticks).
*
* Returns null for empty/nullish input so callers can uniformly skip
* captures whose value is missing.
*/
export function unquoteLiteral(raw: string): string | null {
if (!raw) return null;
// Python triple-quoted
if (
(raw.startsWith('"""') && raw.endsWith('"""')) ||
(raw.startsWith("'''") && raw.endsWith("'''"))
) {
return raw.slice(3, -3);
}
const first = raw[0];
const last = raw[raw.length - 1];
if ((first === '"' || first === "'" || first === '`') && last === first && raw.length >= 2) {
return raw.slice(1, -1);
}
// Some grammars expose the string content without quotes already (e.g.
// Python `string_content` child). Return as-is.
return raw;
}

View file

@ -5,6 +5,15 @@ export interface MatchResult {
unmatched: StoredContract[];
}
export interface WildcardMatchResult {
matched: CrossLink[];
remaining: StoredContract[];
}
function isGrpcWildcard(cid: string): boolean {
return cid.startsWith('grpc::') && cid.endsWith('/*');
}
export function normalizeContractId(id: string): string {
const colonIdx = id.indexOf('::');
if (colonIdx === -1) return id;
@ -24,6 +33,22 @@ export function normalizeContractId(id: string): string {
return id;
}
case 'grpc': {
// Canonical form: `grpc::<lowercased-package-or-service>[/<method>]`.
//
// The package/service segment is lowercased because gRPC package
// names are effectively case-insensitive across language bindings
// (`auth.AuthService`, `auth.authservice`, `AUTH.AUTHSERVICE` all
// describe the same wire protocol service). The RPC method segment
// is preserved as-is because the HTTP/2 path used on the wire is
// case-sensitive per the gRPC spec (`/Service/MethodName`), and
// method names in generated clients match the proto source exactly.
//
// A package-only id (no slash) and a package/method id are treated
// as DISTINCT canonical forms: `grpc::userservice` does not match
// `grpc::userservice/Login`. That's by design — callers that want
// service-level manifest matching against method-level providers
// should use the gRPC wildcard form `grpc::UserService/*` which is
// handled by runWildcardMatch below.
const slashIdx = rest.indexOf('/');
if (slashIdx > 0) {
const pkg = rest.substring(0, slashIdx).toLowerCase();
@ -31,12 +56,12 @@ export function normalizeContractId(id: string): string {
return `grpc::${pkg}${method}`;
}
if (slashIdx === 0) {
// Malformed "package/method" with leading slash — do not lowercase the whole string
// (method segment is case-sensitive per spec).
// Malformed "/method" with leading slash — keep as-is so two
// equally malformed ids can still match each other.
return `grpc::${rest}`;
}
// No slash: spec is ambiguous (package-only vs full service.method). MVP: lowercase
// the whole token; differs from pkg/method split above where RPC method keeps case.
// No slash: package/service only. Lowercase to match the package
// segment produced by the pkg/method branch above.
return `grpc::${rest.toLowerCase()}`;
}
case 'topic':
@ -66,27 +91,36 @@ function findMatchingKeys(contractId: string, index: Map<string, StoredContract[
return [];
}
export function runExactMatch(contracts: StoredContract[]): MatchResult {
export function buildProviderIndex(contracts: StoredContract[]): Map<string, StoredContract[]> {
const providers = contracts.filter((c) => c.role === 'provider');
const consumers = contracts.filter((c) => c.role === 'consumer');
const providerIndex = new Map<string, StoredContract[]>();
const index = new Map<string, StoredContract[]>();
for (const p of providers) {
const key = normalizeContractId(p.contractId);
const list = providerIndex.get(key) || [];
const list = index.get(key) || [];
list.push(p);
providerIndex.set(key, list);
index.set(key, list);
}
return index;
}
export function runExactMatch(
contracts: StoredContract[],
providerIndex?: Map<string, StoredContract[]>,
): MatchResult {
const index = providerIndex ?? buildProviderIndex(contracts);
// Skip gRPC wildcard consumers — they go to wildcard pass only
const consumers = contracts.filter((c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId));
const matched: CrossLink[] = [];
const matchedConsumerIds = new Set<string>();
const matchedProviderIds = new Set<string>();
for (const consumer of consumers) {
const matchingKeys = findMatchingKeys(consumer.contractId, providerIndex);
const matchingKeys = findMatchingKeys(consumer.contractId, index);
if (matchingKeys.length === 0) continue;
const allMatchingProviders = matchingKeys.flatMap((k) => providerIndex.get(k) || []);
const allMatchingProviders = matchingKeys.flatMap((k) => index.get(k) || []);
for (const provider of allMatchingProviders) {
if (provider.repo === consumer.repo) {
if (!provider.service || !consumer.service || provider.service === consumer.service) {
@ -118,10 +152,86 @@ export function runExactMatch(contracts: StoredContract[]): MatchResult {
}
}
const unmatched = contracts.filter((c) => {
// normalUnmatched: contracts that weren't matched in exact pass
const normalUnmatched = contracts.filter((c) => {
if (isGrpcWildcard(c.contractId)) return false; // excluded from exact, handled separately
const id = `${c.repo}::${c.contractId}`;
return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id);
});
// Re-add gRPC wildcard contracts — they were never in exact matching
const grpcWildcards = contracts.filter((c) => isGrpcWildcard(c.contractId));
const unmatched = [...normalUnmatched, ...grpcWildcards];
return { matched, unmatched };
}
export function runWildcardMatch(
unmatched: StoredContract[],
providerIndex: Map<string, StoredContract[]>,
): WildcardMatchResult {
const wildcardConsumers = unmatched.filter(
(c) => c.role === 'consumer' && isGrpcWildcard(c.contractId),
);
const matched: CrossLink[] = [];
const matchedConsumerIds = new Set<string>();
for (const consumer of wildcardConsumers) {
const normalized = normalizeContractId(consumer.contractId);
// "grpc::com.example.userservice/*" → "com.example.userservice"
// "grpc::userservice/*" → "userservice"
const fqService = normalized.slice(normalized.indexOf('::') + 2, -2); // strip "grpc::" and "/*"
for (const [key, providers] of providerIndex) {
// Only match against non-wildcard gRPC providers (method-level IDs)
if (!key.startsWith('grpc::') || key.endsWith('/*')) continue;
const afterPrefix = key.slice(6); // strip "grpc::"
const slashIdx = afterPrefix.indexOf('/');
if (slashIdx < 0) continue;
const providerFqService = afterPrefix.slice(0, slashIdx);
// Match: exact FQ service, or bare-name match when consumer has no package
const isMatch =
providerFqService === fqService ||
(!fqService.includes('.') && providerFqService.endsWith('.' + fqService));
if (!isMatch) continue;
for (const provider of providers) {
// Skip same-repo same-service (same logic as runExactMatch)
if (provider.repo === consumer.repo) {
if (!provider.service || !consumer.service || provider.service === consumer.service) {
continue;
}
}
matched.push({
from: {
repo: consumer.repo,
service: consumer.service,
symbolUid: consumer.symbolUid,
symbolRef: consumer.symbolRef,
},
to: {
repo: provider.repo,
service: provider.service,
symbolUid: provider.symbolUid,
symbolRef: provider.symbolRef,
},
type: consumer.type,
contractId: consumer.contractId, // consumer's wildcard ID
matchType: 'wildcard',
confidence: Math.min(provider.confidence, consumer.confidence),
});
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
}
}
}
const remaining = unmatched.filter((c) => {
if (c.role !== 'consumer' || !isGrpcWildcard(c.contractId)) return true;
return !matchedConsumerIds.has(`${c.repo}::${c.contractId}`);
});
return { matched, remaining };
}

View file

@ -0,0 +1,124 @@
import type { CrossLink, CrossLinkEndpoint, StoredContract } from './types.js';
function contractKey(contract: StoredContract): string {
return [contract.repo, contract.contractId, contract.role, contract.symbolRef.filePath].join(
'\0',
);
}
function endpointKey(endpoint: CrossLinkEndpoint): string {
return [
endpoint.repo,
endpoint.service ?? '',
endpoint.symbolRef.filePath,
endpoint.symbolRef.name,
].join('\0');
}
/**
* Score a contract by how much information it carries, so `dedupeContracts`
* can prefer the "richer" record when two contracts collide on the same
* `(repo, contractId, role, filePath)` key.
*
* Weights express a priority ordering, not calibrated probabilities:
* +3 `symbolUid` resolved (tier 1 of the downstream lookup highest
* signal because it's the strongest anchor for cross-impact traversal
* and the only one that's robust to renames)
* +2 any of `filePath`, `symbolRef.name`, or `symbolName` that's more
* specific than the contractId itself (tier 2 signal resolves
* uniquely in most cases and survives across syncs)
* +1 `service` tag (monorepo attribution useful but not sufficient
* on its own) or non-manifest origin (auto-extracted contracts are
* preferred over manifest-declared synthetic ones because the former
* are grounded in real source code)
*
* The absolute numbers don't matter, only their relative ordering.
*/
function contractRichness(contract: StoredContract): number {
let score = 0;
if (contract.symbolUid) score += 3;
if (contract.symbolRef.filePath) score += 2;
if (contract.symbolRef.name && contract.symbolRef.name !== contract.contractId) score += 2;
if (contract.symbolName && contract.symbolName !== contract.contractId) score += 2;
if (contract.service) score += 1;
if (contract.meta.source !== 'manifest') score += 1;
return score;
}
function mergeContracts(existing: StoredContract, incoming: StoredContract): StoredContract {
const [primary, secondary] =
contractRichness(incoming) > contractRichness(existing)
? [incoming, existing]
: [existing, incoming];
const symbolRefName = primary.symbolRef.name || secondary.symbolRef.name;
return {
...secondary,
...primary,
symbolUid: primary.symbolUid || secondary.symbolUid,
symbolRef: {
filePath: primary.symbolRef.filePath || secondary.symbolRef.filePath,
name: symbolRefName,
},
symbolName: primary.symbolName || secondary.symbolName || symbolRefName,
confidence: Math.max(existing.confidence, incoming.confidence),
service: primary.service ?? secondary.service,
meta: { ...secondary.meta, ...primary.meta },
};
}
function mergeEndpoints(
existing: CrossLinkEndpoint,
incoming: CrossLinkEndpoint,
): CrossLinkEndpoint {
return {
repo: existing.repo,
service: existing.service ?? incoming.service,
symbolUid: existing.symbolUid || incoming.symbolUid,
symbolRef: {
filePath: existing.symbolRef.filePath || incoming.symbolRef.filePath,
name: existing.symbolRef.name || incoming.symbolRef.name,
},
};
}
function crossLinkKey(link: CrossLink): string {
return [
link.type,
link.contractId,
link.matchType,
endpointKey(link.from),
endpointKey(link.to),
].join('\0');
}
export function dedupeContracts(items: StoredContract[]): StoredContract[] {
const deduped = new Map<string, StoredContract>();
for (const contract of items) {
const key = contractKey(contract);
const existing = deduped.get(key);
deduped.set(key, existing ? mergeContracts(existing, contract) : contract);
}
return [...deduped.values()];
}
export function dedupeCrossLinks(items: CrossLink[]): CrossLink[] {
const deduped = new Map<string, CrossLink>();
for (const link of items) {
const key = crossLinkKey(link);
const existing = deduped.get(key);
if (!existing) {
deduped.set(key, link);
continue;
}
const keepIncoming = link.confidence > existing.confidence;
const primary = keepIncoming ? link : existing;
const secondary = keepIncoming ? existing : link;
deduped.set(key, {
...primary,
confidence: Math.max(existing.confidence, link.confidence),
from: mergeEndpoints(primary.from, secondary.from),
to: mergeEndpoints(primary.to, secondary.to),
});
}
return [...deduped.values()];
}

View file

@ -1,5 +1,5 @@
export type ContractType = 'http' | 'grpc' | 'topic' | 'lib' | 'custom';
export type MatchType = 'exact' | 'manifest' | 'bm25' | 'embedding';
export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding';
export type ContractRole = 'provider' | 'consumer';
export interface GroupConfig {
@ -131,3 +131,17 @@ export interface OutOfScopeLink {
contractId: string;
confidence: number;
}
/** Opaque handle to an open bridge LadybugDB. */
export interface BridgeHandle {
/** Internal — do not access directly. */
readonly _db: unknown;
readonly _conn: unknown;
readonly groupDir: string;
}
export interface BridgeMeta {
version: number;
generatedAt: string;
missingRepos: string[];
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
// gitnexus/src/core/ingestion/field-types.ts
import type { TypeEnvironment } from './type-env.js';
import type { SymbolTable } from './symbol-table.js';
import type { SymbolTableReader } from './model/symbol-table.js';
import { SupportedLanguages } from 'gitnexus-shared';
/**
@ -57,7 +57,7 @@ export interface FieldExtractorContext {
/** Type environment for resolution */
typeEnv: TypeEnvironment;
/** Symbol table for FQN lookups */
symbolTable: SymbolTable;
symbolTable: SymbolTableReader;
/** Current file path */
filePath: string;
/** Language ID */

View file

@ -19,47 +19,34 @@ import { ASTCache } from './ast-cache.js';
import Parser from 'tree-sitter';
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { yieldToEventLoop } from './utils/event-loop.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { getProvider } from './languages/index.js';
import { getTreeSitterBufferSize } from './constants.js';
import type { ExtractedHeritage } from './workers/parse-worker.js';
import type { ResolutionContext } from './resolution-context.js';
import { TIER_CONFIDENCE } from './resolution-context.js';
import type {
ExtractedHeritage,
HeritageResolutionStrategy,
HeritageStrategyLookup,
} from './model/heritage-map.js';
import { resolveExtendsType } from './model/heritage-map.js';
import type { ResolutionContext } from './model/resolution-context.js';
import { TIER_CONFIDENCE } from './model/resolution-context.js';
/**
* Determine whether a heritage.extends capture is actually an IMPLEMENTS relationship.
* Uses the symbol table first (authoritative Tier 1); falls back to provider-defined
* heuristics for external symbols not present in the graph:
* - interfaceNamePattern: matched against parent name (e.g., /^I[A-Z]/ for C#/Java)
* - heritageDefaultEdge: 'IMPLEMENTS' causes all unresolved parents to map to IMPLEMENTS
* - All others: default EXTENDS
* Derive the heritage-resolution strategy for a language from its
* `LanguageProvider`. This is the production wiring that `buildHeritageMap`
* and the standalone `resolveExtendsType` call site use the model layer
* itself stays unaware of the provider registry.
*/
/** Exported for implementor-map construction (C#/Java: `extends` rows in base_list may be interfaces). */
export const resolveExtendsType = (
parentName: string,
currentFilePath: string,
ctx: ResolutionContext,
language: SupportedLanguages,
): { type: 'EXTENDS' | 'IMPLEMENTS'; idPrefix: string } => {
const resolved = ctx.resolve(parentName, currentFilePath);
if (resolved && resolved.candidates.length > 0) {
const isInterface = resolved.candidates[0].type === 'Interface';
return isInterface
? { type: 'IMPLEMENTS', idPrefix: 'Interface' }
: { type: 'EXTENDS', idPrefix: 'Class' };
}
// Unresolved symbol — fall back to provider-defined heuristics
const provider = getProvider(language);
if (provider.interfaceNamePattern?.test(parentName)) {
return { type: 'IMPLEMENTS', idPrefix: 'Interface' };
}
if (provider.heritageDefaultEdge === 'IMPLEMENTS') {
return { type: 'IMPLEMENTS', idPrefix: 'Interface' };
}
return { type: 'EXTENDS', idPrefix: 'Class' };
export const getHeritageStrategyForLanguage: HeritageStrategyLookup = (
lang: SupportedLanguages,
): HeritageResolutionStrategy => {
const provider = getProvider(lang);
return {
interfaceNamePattern: provider.interfaceNamePattern,
defaultEdge: provider.heritageDefaultEdge ?? 'EXTENDS',
};
};
/**
@ -180,7 +167,7 @@ export const processHeritage = async (
parentClassName,
file.path,
ctx,
language,
getHeritageStrategyForLanguage(language),
);
const child = resolveHeritageId(
@ -296,7 +283,7 @@ export const processHeritageFromExtracted = async (
h.parentName,
h.filePath,
ctx,
fileLanguage,
getHeritageStrategyForLanguage(fileLanguage),
);
const child = resolveHeritageId(

View file

@ -12,7 +12,11 @@ import type { ExtractedImport } from './workers/parse-worker.js';
import { getTreeSitterBufferSize } from './constants.js';
import { loadImportConfigs } from './language-config.js';
import { buildSuffixIndex } from './import-resolvers/utils.js';
import type { ResolutionContext, ModuleAliasMap } from './resolution-context.js';
import type {
ResolutionContext,
ModuleAliasMap,
NamedImportMap,
} from './model/resolution-context.js';
import type {
ImportResult,
ResolveCtx,
@ -61,30 +65,6 @@ function wireImplicitImports(
// Avoids expanding every Go package import into N individual ImportMap edges.
export type PackageMap = Map<string, Set<string>>;
// Type: Map<ImportingFilePath, Map<LocalName, {sourcePath, exportedName}>>
// Tracks which specific names a file imports from which sources (TS/Python only).
// Used to tighten Tier 2a resolution: `import { User } from './models'`
// means only `User` (not `Repo`) is visible from models.ts via this import.
// Stores both the resolved source path and the original exported name so that
// aliased imports (`import { User as U }`) can resolve U → User in the source file.
export interface NamedImportBinding {
sourcePath: string;
exportedName: string;
}
export type NamedImportMap = Map<string, Map<string, NamedImportBinding>>;
/**
* Check if a file path is directly inside a package directory identified by its suffix.
* Used by the symbol resolver for Go and C# directory-level import matching.
*/
export function isFileInPackageDir(filePath: string, dirSuffix: string): boolean {
// Prepend '/' so paths like "internal/auth/service.go" match suffix "/internal/auth/"
const normalized = '/' + filePath.replace(/\\/g, '/');
if (!normalized.includes(dirSuffix)) return false;
const afterDir = normalized.substring(normalized.indexOf(dirSuffix) + dirSuffix.length);
return !afterDir.includes('/');
}
// ImportResolutionContext is defined in ./import-resolvers/types.ts — re-exported here for consumers.
export function buildImportResolutionContext(allPaths: string[]): ImportResolutionContext {

View file

@ -9,7 +9,7 @@
* so adding a language to the enum without creating a provider is a compiler error.
*/
import type { SupportedLanguages } from 'gitnexus-shared';
import type { SupportedLanguages, MroStrategy } from 'gitnexus-shared';
import type { LanguageTypeConfig } from './type-extractors/types.js';
import type { CallRouter } from './call-routing.js';
import type { ClassExtractor } from './class-types.js';
@ -26,13 +26,10 @@ import type { NodeLabel } from 'gitnexus-shared';
export type CaptureMap = Record<string, SyntaxNode | undefined>;
// ── Strategy tag types ─────────────────────────────────────────────────────
/** MRO strategy for multiple inheritance resolution. */
export type MroStrategy =
| 'first-wins'
| 'c3'
| 'leftmost-base'
| 'implements-split'
| 'qualified-syntax';
// NOTE: `MroStrategy` is defined in `gitnexus-shared` and re-exported above
// so `core/ingestion/model/resolve.ts` can consume it without importing from
// this file (which would pull in the full language-registry dependency graph).
/** How a language handles imports — determines wildcard synthesis behavior. */
export type ImportSemantics = 'named' | 'wildcard' | 'namespace';

View file

@ -160,18 +160,22 @@ function extractPhpPropertyDescription(propName: string, propDeclNode: SyntaxNod
* Returns description like "hasMany(Post)" or null.
*/
function extractEloquentRelationDescription(methodNode: SyntaxNode): string | null {
function findRelationCall(node: SyntaxNode): SyntaxNode | null {
if (node.type === 'member_call_expression') {
function findRelationCall(root: SyntaxNode): SyntaxNode | null {
const stack: SyntaxNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.type === 'member_call_expression') {
const children = node.children ?? [];
const objectNode = children.find(
(c: SyntaxNode) => c.type === 'variable_name' && c.text === '$this',
);
const nameNode = children.find((c: SyntaxNode) => c.type === 'name');
if (objectNode && nameNode && ELOQUENT_RELATIONS.has(nameNode.text)) return node;
}
const children = node.children ?? [];
const objectNode = children.find(
(c: SyntaxNode) => c.type === 'variable_name' && c.text === '$this',
);
const nameNode = children.find((c: SyntaxNode) => c.type === 'name');
if (objectNode && nameNode && ELOQUENT_RELATIONS.has(nameNode.text)) return node;
}
for (const child of node.children ?? []) {
const found = findRelationCall(child);
if (found) return found;
for (let i = children.length - 1; i >= 0; i--) {
stack.push(children[i]);
}
}
return null;
}

View file

@ -0,0 +1,53 @@
/**
* Field Registry
*
* Owner-scoped field/property index extracted from SymbolTable.
* Stores Property symbols keyed by `ownerNodeId\0fieldName` for O(1) lookup.
*/
import type { SymbolDefinition } from './symbol-table.js';
// ---------------------------------------------------------------------------
// Public read-only interface
// ---------------------------------------------------------------------------
export interface FieldRegistry {
/** Look up a field/property by its owning class nodeId and field name. */
lookupFieldByOwner(ownerNodeId: string, fieldName: string): SymbolDefinition | undefined;
}
// ---------------------------------------------------------------------------
// Mutable interface (used internally by SymbolTable.add / clear)
// ---------------------------------------------------------------------------
export interface MutableFieldRegistry extends FieldRegistry {
/** Register a field/property under its owner. */
register(ownerNodeId: string, fieldName: string, def: SymbolDefinition): void;
/** Clear all entries. */
clear(): void;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export const createFieldRegistry = (): MutableFieldRegistry => {
const fieldByOwner = new Map<string, SymbolDefinition>();
const lookupFieldByOwner = (
ownerNodeId: string,
fieldName: string,
): SymbolDefinition | undefined => {
return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`);
};
const register = (ownerNodeId: string, fieldName: string, def: SymbolDefinition): void => {
fieldByOwner.set(`${ownerNodeId}\0${fieldName}`, def);
};
const clear = (): void => {
fieldByOwner.clear();
};
return { lookupFieldByOwner, register, clear };
};

View file

@ -7,16 +7,78 @@
* resolves type names to nodeIds via `lookupClassByName`, NOT graph-edge
* queries.
*
* Combines two previously separate concerns:
* Combines two concerns:
* 1. **Parent/ancestor lookup** (MRO-aware method resolution)
* 2. **Implementor lookup** (interface dispatch which files contain
* classes implementing a given interface)
*/
import type { ExtractedHeritage } from './workers/parse-worker.js';
import type { ResolutionContext } from './resolution-context.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import { resolveExtendsType } from './heritage-processor.js';
import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared';
// ---------------------------------------------------------------------------
// ExtractedHeritage — the shape produced by the parse worker / heritage
// extractor. Defined here so `model/` has no upward imports; consumers
// import this type from the model module.
// ---------------------------------------------------------------------------
export interface ExtractedHeritage {
filePath: string;
className: string;
parentName: string;
/** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */
kind: string;
}
// ---------------------------------------------------------------------------
// Heritage resolution strategy (the per-language knobs that drive
// `resolveExtendsType` below). Pulled out as an explicit strategy object so
// the model layer depends on a plain data shape rather than on the language
// provider registry.
// ---------------------------------------------------------------------------
export interface HeritageResolutionStrategy {
/** If set and the parent name matches, force IMPLEMENTS even when the
* symbol is unresolved (e.g. `/^I[A-Z]/` for C# / Java). */
readonly interfaceNamePattern?: RegExp;
/** Fallback edge for unresolved parents when the name pattern doesn't
* match (Swift uses 'IMPLEMENTS' for protocol conformance). */
readonly defaultEdge: 'EXTENDS' | 'IMPLEMENTS';
}
/** Callback used by `buildHeritageMap` to look up the resolution strategy
* for a given language. Injected by callers so the model module doesn't
* depend on `../languages/index.js`. */
export type HeritageStrategyLookup = (lang: SupportedLanguages) => HeritageResolutionStrategy;
/**
* Determine whether a heritage.extends capture is actually an IMPLEMENTS
* relationship. Consults the symbol table first (authoritative Tier 1 /
* Tier 2 resolution); falls back to the injected {@link HeritageResolutionStrategy}
* heuristics for external symbols not present in the graph.
*/
export const resolveExtendsType = (
parentName: string,
currentFilePath: string,
ctx: ResolutionContext,
strategy: HeritageResolutionStrategy,
): { type: 'EXTENDS' | 'IMPLEMENTS'; idPrefix: string } => {
const resolved = ctx.resolve(parentName, currentFilePath);
if (resolved && resolved.candidates.length > 0) {
const isInterface = resolved.candidates[0].type === 'Interface';
return isInterface
? { type: 'IMPLEMENTS', idPrefix: 'Interface' }
: { type: 'EXTENDS', idPrefix: 'Class' };
}
// Unresolved symbol — fall back to strategy heuristics.
if (strategy.interfaceNamePattern?.test(parentName)) {
return { type: 'IMPLEMENTS', idPrefix: 'Interface' };
}
if (strategy.defaultEdge === 'IMPLEMENTS') {
return { type: 'IMPLEMENTS', idPrefix: 'Interface' };
}
return { type: 'EXTENDS', idPrefix: 'Class' };
};
// ---------------------------------------------------------------------------
// Public types
@ -41,6 +103,12 @@ export interface HeritageMap {
/** Shared empty set returned when no implementors are found. */
const EMPTY_SET: ReadonlySet<string> = new Set();
/** Default strategy used when `buildHeritageMap` is called without an
* explicit `getHeritageStrategy` callback the fallback for a language
* whose provider sets no interface-name pattern and no non-default
* `heritageDefaultEdge`. */
const DEFAULT_HERITAGE_STRATEGY: HeritageResolutionStrategy = { defaultEdge: 'EXTENDS' };
// ---------------------------------------------------------------------------
// Builder
// ---------------------------------------------------------------------------
@ -49,18 +117,18 @@ const EMPTY_SET: ReadonlySet<string> = new Set();
* Build a HeritageMap from accumulated ExtractedHeritage records.
*
* Resolves class/interface/struct/trait names to nodeIds via
* `ctx.symbols.lookupClassByName`. When a name resolves to multiple
* `ctx.model.types.lookupClassByName`. When a name resolves to multiple
* candidates, all are recorded (partial-class / cross-file scenario).
* Unresolvable names are silently skipped a missing parent is better
* than a wrong edge.
*
* Also builds the implementor index (interface name implementing file
* paths) that was previously maintained by `buildImplementorMap` in
* call-processor.ts.
* paths) used by interface-dispatch in call resolution.
*/
export const buildHeritageMap = (
heritage: readonly ExtractedHeritage[],
ctx: ResolutionContext,
getHeritageStrategy?: HeritageStrategyLookup,
): HeritageMap => {
// childNodeId → Set<parentNodeId> (Set to deduplicate cross-chunk duplicates)
const directParents = new Map<string, Set<string>>();
@ -70,8 +138,8 @@ export const buildHeritageMap = (
for (const h of heritage) {
// ── Parent lookup (nodeId-based) ────────────────────────────────
const childDefs = ctx.symbols.lookupClassByName(h.className);
const parentDefs = ctx.symbols.lookupClassByName(h.parentName);
const childDefs = ctx.model.types.lookupClassByName(h.className);
const parentDefs = ctx.model.types.lookupClassByName(h.parentName);
if (childDefs.length > 0 && parentDefs.length > 0) {
for (const child of childDefs) {
@ -99,16 +167,15 @@ export const buildHeritageMap = (
//
// Known limitation: `getImplementorFiles` is keyed by interface **name**
// (string), so two interfaces with the same unqualified name in different
// packages (e.g. `pkgA.IRepository` vs `pkgB.IRepository`) collide. This
// matches the behavior of the prior standalone `ImplementorMap` and is
// not a regression introduced by this consolidation.
// packages (e.g. `pkgA.IRepository` vs `pkgB.IRepository`) collide.
let isImpl = false;
if (h.kind === 'implements') {
isImpl = true;
} else if (h.kind === 'extends') {
const lang = getLanguageFromFilename(h.filePath);
if (lang) {
const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, lang);
const strategy = getHeritageStrategy?.(lang) ?? DEFAULT_HERITAGE_STRATEGY;
const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, strategy);
isImpl = type === 'IMPLEMENTS';
}
}

View file

@ -0,0 +1,88 @@
/**
* Semantic Model public module surface.
*
* Barrel re-export for the `model/` module. Consumers outside `model/`
* should import from this file rather than reaching into individual
* registry files.
*
* The model is owner-scoped type/method/field knowledge layered above
* `SymbolTable`. File-indexed and name-keyed callable lookups stay in
* `SymbolTable` by design.
*/
// Unified semantic model (factory + interfaces). SemanticModel is the
// top-level container and owns the file/callable SymbolTable as a
// nested `symbols` field.
export {
type SemanticModel,
type MutableSemanticModel,
createSemanticModel,
} from './semantic-model.js';
// SymbolTable is exclusively owned by SemanticModel. Re-exported here
// for the rare caller that needs the file/callable interface in
// isolation (e.g. tests).
export {
type SymbolTableReader,
type SymbolTableWriter,
createSymbolTable,
} from './symbol-table.js';
// Type registry (classes, structs, interfaces, enums, records, impls)
export {
type TypeRegistry,
type MutableTypeRegistry,
createTypeRegistry,
} from './type-registry.js';
// Method registry (owner-scoped methods with arity-aware overload lookup)
export {
type MethodRegistry,
type MutableMethodRegistry,
createMethodRegistry,
} from './method-registry.js';
// Field registry (owner-scoped fields/properties)
export {
type FieldRegistry,
type MutableFieldRegistry,
createFieldRegistry,
} from './field-registry.js';
// MRO-aware method resolution (C3, first-wins, leftmost-base, implements-split,
// qualified-syntax). Pure function that depends only on the model + HeritageMap.
// `MroStrategy` itself lives in `gitnexus-shared`; re-exported here for
// consumers that reach model behavior through the barrel.
export { lookupMethodByOwnerWithMRO } from './resolve.js';
// Named-import types and package-dir helper. Re-exported so barrel
// consumers don't need to reach into a specific model file.
export {
type NamedImportBinding,
type NamedImportMap,
isFileInPackageDir,
} from './resolution-context.js';
// Heritage types. `buildHeritageMap` + `resolveExtendsType` are exported
// directly from `heritage-map.ts` and are not re-surfaced here to keep
// the barrel narrow.
export {
type ExtractedHeritage,
type HeritageResolutionStrategy,
type HeritageStrategyLookup,
} from './heritage-map.js';
// Behavior-grouped dispatch table for SymbolTable.add() routing.
// See registration-table.ts module JSDoc for the behavior group taxonomy
// and "how to add a new NodeLabel" checklist.
// NOTE: createRegistrationTable, RegistrationHook, and RegistrationTableDeps
// are deliberately NOT re-exported here — they are factory internals of
// SemanticModel and should only be imported directly from registration-table.js
// by semantic-model.ts and the registration-table.test.ts file.
export {
CALLABLE_ONLY_LABELS,
INERT_LABELS,
DISPATCH_LABELS,
ALL_NODE_LABELS,
type LabelBehavior,
} from './registration-table.js';

View file

@ -0,0 +1,204 @@
/**
* Method Registry
*
* Owner-scoped method index extracted from SymbolTable.
* Stores Method/Constructor/Function-with-ownerId symbols keyed by
* `ownerNodeId\0methodName` for O(1) lookup. Supports overloads
* (array values) and arity-based filtering.
*/
import type { SymbolDefinition } from './symbol-table.js';
// ---------------------------------------------------------------------------
// Public read-only interface
// ---------------------------------------------------------------------------
export interface MethodRegistry {
/**
* Look up a method by owner class + name, optionally filtered by arity.
*
* When `argCount` is provided, overloads whose parameter count doesn't
* accommodate the call's argument count are filtered out before the
* returnType dedup runs. This lets D0 (`resolveMemberCall`) disambiguate
* arity-differing overloads (e.g. C++ `greet()` vs `greet(string)`) that
* would otherwise collide on the shared `ownerId + methodName` key.
*
* Same-arity, same-returnType overloads (e.g. `save(int)` vs `save(String)`,
* both returning `void`) still collapse to the first match callers must
* gate D0 on overload concern before invoking this function for that case.
*/
lookupMethodByOwner(
ownerNodeId: string,
methodName: string,
argCount?: number,
): SymbolDefinition | undefined;
/**
* Flat-by-name lookup across all owners. Returns every method registered
* with the given unqualified name, in registration order, accumulated
* across owners and overloads.
*
* Required by Tier 3 global resolution: Method and Constructor do not
* land in `SymbolTable.callableByName`, so Tier 3 reaches them through
* this flat index instead. Returns `[]` on miss never `undefined`
* so callers can concatenate without null checks.
*
* Reference identity: each returned def is the same object reference
* stored under `lookupMethodByOwner`, so a method symbol occupies one
* allocation reachable from two indexes.
*/
lookupMethodByName(name: string): readonly SymbolDefinition[];
/**
* True iff at least one registered def has `type === 'Function'` i.e.,
* a Python/Rust/Kotlin class method emitted by the worker as
* `Function + ownerId` rather than as a strict `Method` label. Such defs
* are double-indexed: they land in `SymbolTable.callableByName` (via the
* Function callable-index gate) AND in this registry (via the
* dispatch-key normalization in `wrappedAdd`). Tier 3 resolution must
* then dedup the two indexes by nodeId.
*
* When this flag is false, the callable and method indexes are
* guaranteed disjoint and Tier 3 can skip the dedup pass entirely.
* The flag is monotonic (falsetrue once, never back) for the lifetime
* of the MethodRegistry.
*/
readonly hasFunctionMethods: boolean;
}
// ---------------------------------------------------------------------------
// Mutable interface (used internally by SymbolTable.add / clear)
// ---------------------------------------------------------------------------
export interface MutableMethodRegistry extends MethodRegistry {
/** Register a method under its owner. Supports multiple overloads. */
register(ownerNodeId: string, methodName: string, def: SymbolDefinition): void;
/** Clear all entries. */
clear(): void;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export const createMethodRegistry = (): MutableMethodRegistry => {
const methodByOwner = new Map<string, SymbolDefinition[]>();
// Secondary flat-by-name index. Values are the SAME SymbolDefinition
// references stored under `methodByOwner` — no copy, just a second key.
// Populated in lockstep by `register()` and emptied by `clear()`.
const methodsByName = new Map<string, SymbolDefinition[]>();
const EMPTY: readonly SymbolDefinition[] = Object.freeze([]);
// Set once when a Function+ownerId def lands here, powers the Tier 3
// dedup fast-path. Monotonic: never unset except on `clear()`.
let hasFunctionMethodsFlag = false;
const lookupMethodByOwner = (
ownerNodeId: string,
methodName: string,
argCount?: number,
): SymbolDefinition | undefined => {
const defs = methodByOwner.get(`${ownerNodeId}\0${methodName}`);
if (!defs || defs.length === 0) return undefined;
// Arity narrowing: when an argCount is provided and there are multiple
// overloads, keep only those whose parameterCount can accommodate the
// call. This resolves arity-differing overloads (e.g. C++ `greet()` vs
// `greet(string)`) that share the same `ownerId + methodName` key.
//
// Candidates with `parameterCount === undefined` (extractor didn't
// populate the count — typically variadic or unknown) are retained
// conservatively so that legitimate variadic matches still resolve.
//
// Streaming loop avoids allocating a filtered array on the common
// "arity selects 0 or 1 match" path. We scan once, count arity
// matches, and only materialize a narrowed array if at least one
// match was found and at least one non-match exists. If arity rules
// out every candidate, fall back to the unfiltered set so the
// caller's fuzzy path still has something to work with.
let pool: readonly SymbolDefinition[] = defs;
if (argCount !== undefined && defs.length > 1) {
let matchedCount = 0;
let rejectedCount = 0;
for (const d of defs) {
if (d.parameterCount === undefined) {
matchedCount++;
continue;
}
const min = d.requiredParameterCount ?? d.parameterCount;
if (argCount >= min && argCount <= d.parameterCount) matchedCount++;
else rejectedCount++;
}
// Only narrow when the filter actually discriminates: at least one
// match AND at least one rejection. Pure-match and pure-reject
// paths both keep the unfiltered pool (the latter because fallback
// semantics demand it).
if (matchedCount > 0 && rejectedCount > 0) {
const arityMatched: SymbolDefinition[] = [];
for (const d of defs) {
if (d.parameterCount === undefined) {
arityMatched.push(d);
continue;
}
const min = d.requiredParameterCount ?? d.parameterCount;
if (argCount >= min && argCount <= d.parameterCount) arityMatched.push(d);
}
pool = arityMatched;
}
}
if (pool.length === 1) return pool[0];
// Multiple overloads after arity narrowing: return first if all share
// the same defined returnType (safe for chain resolution), undefined if
// return types differ (truly ambiguous — can't determine which overload).
const firstReturnType = pool[0].returnType;
if (firstReturnType === undefined) return undefined;
for (let i = 1; i < pool.length; i++) {
if (pool[i].returnType !== firstReturnType) return undefined;
}
return pool[0];
};
const lookupMethodByName = (name: string): readonly SymbolDefinition[] => {
return methodsByName.get(name) ?? EMPTY;
};
const register = (ownerNodeId: string, methodName: string, def: SymbolDefinition): void => {
const key = `${ownerNodeId}\0${methodName}`;
const existing = methodByOwner.get(key);
if (existing) {
existing.push(def);
} else {
methodByOwner.set(key, [def]);
}
const byName = methodsByName.get(methodName);
if (byName) {
byName.push(def);
} else {
methodsByName.set(methodName, [def]);
}
// A `Function`-typed def reaching MethodRegistry means the worker
// emitted a Python/Rust/Kotlin class method as `Function + ownerId`.
// It was already written into `SymbolTable.callableByName` by the
// upstream Function callable-index gate, so the two indexes are no
// longer disjoint for this registry's lifetime — Tier 3 must dedup.
if (!hasFunctionMethodsFlag && def.type === 'Function') {
hasFunctionMethodsFlag = true;
}
};
const clear = (): void => {
methodByOwner.clear();
methodsByName.clear();
hasFunctionMethodsFlag = false;
};
return {
lookupMethodByOwner,
lookupMethodByName,
register,
clear,
get hasFunctionMethods() {
return hasFunctionMethodsFlag;
},
};
};

View file

@ -0,0 +1,333 @@
/**
* Registration Dispatch Table
*
* Behavior-grouped O(1) dispatch table for routing `SymbolTable.add()`
* registrations into the semantic registries. Replaces the cascading
* `if/else` ladder in `symbol-table.ts` with a `Map<NodeLabel, RoutingDecision>`
* whose entries point to closure-captured hooks.
*
* ## Ownership diagram
*
* SemanticModel
* types (TypeRegistry) classLikeHook / implHook write here
* methods (MethodRegistry) methodHook writes here
* fields (FieldRegistry) propertyHook writes here
* symbols (SymbolTable) owns fileIndex + callableByName,
* calls dispatch() in add()
*
* ## Behavior groups (5 hooks, 13 table entries)
*
* | Group | NodeLabel values | Hook | Skip callable? |
* |---------------|---------------------------------------------------|--------------|----------------|
* | class-like | Class, Struct, Interface, Enum, Record, Trait | classLikeHook | no |
* | method-like | Method, Constructor | methodHook | no |
* | property | Property | propertyHook | YES |
* | impl-block | Impl | implHook | no |
* | callable-only | Function, Macro, Delegate | (no entry) | no |
*
* Every other `NodeLabel` is "inert" reached by `fileIndex` only. No
* specialized registry, no callable index append.
*
* ## How to add a new NodeLabel
*
* 1. Add the variant to the `NodeLabel` union in `gitnexus-shared/src/graph/types.ts`.
* 2. Decide which behavior group it belongs to by asking "which lookups must
* return this symbol?" (not "what language feature is it?"). A new Swift
* `Extension` is class-like if you want owner-scoped method lookup on it;
* a new Kotlin `Object` is class-like for the same reason.
* 3. Either:
* - Add a table entry here pointing at one of the existing hooks, OR
* - Add it to `CALLABLE_ONLY_LABELS` if it is a free callable, OR
* - Add it to `INERT_LABELS` if it's metadata-only (File, Folder, Decorator,
* etc.) never queried via owner/class lookups.
* 4. If none of the above fit the new kind needs a brand-new registry
* design the registry first in `model/`, then add a new hook closure
* and table entries. Update `DISPATCH_LABELS` / the exhaustiveness guard
* accordingly.
*
* The runtime exhaustiveness guard in `symbol-table.ts` will warn if a
* `NodeLabel` is missing from all three sets.
*/
import type { NodeLabel } from 'gitnexus-shared';
import type { SymbolDefinition, ClassLikeLabel, FreeCallableLabel } from './symbol-table.js';
import { FREE_CALLABLE_TYPES } from './symbol-table.js';
import type { MutableTypeRegistry } from './type-registry.js';
import type { MutableMethodRegistry } from './method-registry.js';
import type { MutableFieldRegistry } from './field-registry.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* Registration hook a pure side-effectful function closed over a
* specific registry. Performs the specialized registry write into the
* appropriate owner-scoped registry for one NodeLabel.
*
* Closure capture is the isolation mechanism: `propertyHook` literally
* cannot call `types.registerClass` because its closure does not hold
* a reference to `types`. This is the runtime half of the principle of
* least authority the compile-time half is enforced by TypeScript.
*
* The callable-index gate lives inside `SymbolTable.add()` via the
* `FREE_CALLABLE_TYPES` allowlist the dispatch table does not
* participate in that decision.
*/
export type RegistrationHook = (name: string, def: SymbolDefinition) => void;
/**
* Dependencies required to build the dispatch table. Matches the shape
* that `createSemanticModel()` passes into `createRegistrationTable()`.
*/
export interface RegistrationTableDeps {
readonly types: MutableTypeRegistry;
readonly methods: MutableMethodRegistry;
readonly fields: MutableFieldRegistry;
}
// ---------------------------------------------------------------------------
// Single source of truth: NodeLabel → behavior category
// ---------------------------------------------------------------------------
/**
* Behavior category for a NodeLabel during ingestion. Determines which
* registry (if any) receives the symbol write during `SymbolTable.add()`:
*
* - `dispatch` owner-scoped registry write via the dispatch table
* (Class/Struct/Interface/Enum/Record/Trait types.registerClass,
* Method/Constructor methods.register,
* Property fields.register,
* Impl types.registerImpl)
* - `callable-only` no specialized registry; symbol appears in
* `callableByName` via `SymbolTable.add()`'s
* FREE_CALLABLE_TYPES gate (Function/Macro/Delegate)
* - `inert` no registry, no callable index; file-index only
* (metadata / structural nodes like Project, Module,
* Import, Decorator, etc.)
*
* `Function` has a twist: `Function`-with-`ownerId` (Python `def` in a
* class body, Rust trait method, Kotlin companion method) is pre-normalized
* to `Method` in `createSemanticModel`'s `wrappedAdd` before dispatch lookup,
* so only free functions actually flow through the callable-only path.
*/
export type LabelBehavior = 'dispatch' | 'callable-only' | 'inert';
/**
* **Single source of truth** for NodeLabel classification. Every NodeLabel
* has exactly one behavior category enforced at compile time by the
* `as const satisfies Record<NodeLabel, LabelBehavior>` combo:
*
* - **Completeness** `Record<NodeLabel, LabelBehavior>` requires every
* NodeLabel to be a key. Missing a label fails to compile with
* "Property 'X' is missing in type ..." naming the drifted label.
* - **No extras** `satisfies` performs excess-property checking on
* object literals, so a non-NodeLabel string key fails to compile.
* - **No duplicates** object keys are unique by construction. A label
* cannot be classified into two categories by accident.
* - **Valid values** `LabelBehavior` is a narrow union, so a typo in
* the category name fails to compile.
*
* Adding a new NodeLabel to `gitnexus-shared`: TypeScript will flag this
* file as incomplete. Add the new label with its behavior category and
* the three `*_LABELS` Sets + `ALL_NODE_LABELS` array below are derived
* automatically no separate list to update, no runtime drift detection
* needed.
*
* NOTE: `Type` and `CodeElement` are inert wrappers for language features
* that don't yet have a dedicated registry (typedefs, synthesized dynamic
* calls). If future work needs owner-scoped lookup for them, change their
* category to `'dispatch'` and add a hook in `createRegistrationTable`.
* Do not special-case them inside `SymbolTable.add()`.
*/
const LABEL_BEHAVIOR = {
// dispatch — owner-scoped registry writes
Class: 'dispatch',
Struct: 'dispatch',
Interface: 'dispatch',
Enum: 'dispatch',
Record: 'dispatch',
Trait: 'dispatch',
Method: 'dispatch',
Constructor: 'dispatch',
Property: 'dispatch',
Impl: 'dispatch',
// callable-only — file index + callableByName, no owner scope
Function: 'callable-only',
Macro: 'callable-only',
Delegate: 'callable-only',
// inert — file index only
Project: 'inert',
Package: 'inert',
Module: 'inert',
Folder: 'inert',
File: 'inert',
Variable: 'inert',
Decorator: 'inert',
Import: 'inert',
Type: 'inert',
CodeElement: 'inert',
Community: 'inert',
Process: 'inert',
Typedef: 'inert',
Union: 'inert',
Namespace: 'inert',
TypeAlias: 'inert',
Const: 'inert',
Static: 'inert',
Annotation: 'inert',
Template: 'inert',
Section: 'inert',
Route: 'inert',
Tool: 'inert',
} as const satisfies Record<NodeLabel, LabelBehavior> &
// Cross-invariant 1 — every class-like label (participates in
// qualifiedName fallback in `SymbolTable.add()`) MUST be classified as
// 'dispatch'. Adding a label to `CLASS_TYPES_TUPLE` without classifying
// it as 'dispatch' fails with a type error naming the drifted label.
Record<ClassLikeLabel, 'dispatch'> &
// Cross-invariant 2 — every free-callable label (gate in
// `SymbolTable.add()` via `FREE_CALLABLE_TYPES`) MUST be classified as
// 'callable-only'. Adding a label to `FREE_CALLABLE_TUPLE` without
// classifying it as 'callable-only' fails with a type error naming the
// drifted label.
Record<FreeCallableLabel, 'callable-only'>;
// ---------------------------------------------------------------------------
// Derived runtime collections — all keyed off LABEL_BEHAVIOR
// ---------------------------------------------------------------------------
/**
* All known NodeLabels, derived from the keys of `LABEL_BEHAVIOR`. The
* `satisfies Record<NodeLabel, LabelBehavior>` bijection above proves
* that `Object.keys(LABEL_BEHAVIOR)` is exactly the NodeLabel set
* the cast to `NodeLabel[]` is sound, not a type-system bypass.
*
* Consumers (e.g., the semantic-model barrel re-export for tests) can
* rely on this list being complete by construction. No runtime drift
* check is needed or possible the type system is the proof.
*/
export const ALL_NODE_LABELS: readonly NodeLabel[] = Object.keys(LABEL_BEHAVIOR) as NodeLabel[];
const labelsWithBehavior = (behavior: LabelBehavior): NodeLabel[] =>
ALL_NODE_LABELS.filter((label) => LABEL_BEHAVIOR[label] === behavior);
/**
* NodeLabel values that are free callables appear in `callableByName`
* but have no owner-scoped specialized registry. Alias of
* {@link FREE_CALLABLE_TYPES} exported here for taxonomy-test use. The
* compile-time cross-invariant on `LABEL_BEHAVIOR` above guarantees the
* alias and the LABEL_BEHAVIOR `callable-only` classification cannot
* drift.
*/
export const CALLABLE_ONLY_LABELS: ReadonlySet<NodeLabel> = FREE_CALLABLE_TYPES;
/**
* NodeLabel values that touch only the file index no specialized
* registry, no callable index.
*/
export const INERT_LABELS: ReadonlySet<NodeLabel> = new Set(labelsWithBehavior('inert'));
/**
* NodeLabel values that have a dispatch table entry. `createRegistrationTable`
* below must provide a hook for exactly this set the test file's behavior-
* group tests and the integration tests pin the hooklabel correspondence.
*/
export const DISPATCH_LABELS: ReadonlySet<NodeLabel> = new Set(labelsWithBehavior('dispatch'));
/**
* Type-level extraction of every label classified as `'dispatch'` in
* {@link LABEL_BEHAVIOR}. Used by {@link createRegistrationTable} as the
* key set of its internal object literal, so the `satisfies
* Record<DispatchLabel, RegistrationHook>` check fails at build time if
* a dispatch-classified label is missing a hook, or a hook is wired to
* a non-dispatch label. This closes the last compile-time gap between
* `LABEL_BEHAVIOR` and the dispatch table.
*/
type DispatchLabel = {
[K in NodeLabel]: (typeof LABEL_BEHAVIOR)[K] extends 'dispatch' ? K : never;
}[NodeLabel];
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Build the dispatch table. Must be called once per `createSymbolTable`
* invocation so each hook closes over that SymbolTable's injected
* registries. Reusing a single module-level instance would cause hooks
* to write into the wrong SemanticModel.
*/
export const createRegistrationTable = (
deps: RegistrationTableDeps,
): Map<NodeLabel, RegistrationHook> => {
const { types, methods, fields } = deps;
// Hook 1: class-like — Class, Struct, Interface, Enum, Record, Trait.
// Shared reference — six table entries point at this one closure.
const classLikeHook: RegistrationHook = (name, def) => {
const qualifiedKey = def.qualifiedName ?? name;
types.registerClass(name, qualifiedKey, def);
};
// Hook 2: method-like — Method, Constructor. Silently skipped if the
// caller did not provide an ownerId (Property without ownerId is
// treated the same way).
const methodHook: RegistrationHook = (name, def) => {
if (def.ownerId) {
methods.register(def.ownerId, name, def);
}
};
// Hook 3: property — Property. Silently skipped without ownerId.
// Property is not in `FREE_CALLABLE_TYPES`, so `SymbolTable.add()` already
// excludes it from `callableByName`; common property names like
// `id` / `name` / `type` never pollute the callable index.
const propertyHook: RegistrationHook = (name, def) => {
if (def.ownerId) {
fields.register(def.ownerId, name, def);
}
};
// Hook 4: impl-block — Rust `impl` blocks. Kept separate from classLikeHook
// because heritage resolution must not treat Impls as class candidates
// (an Impl is not a parent type, it's an ancillary dispatch table).
const implHook: RegistrationHook = (name, def) => {
types.registerImpl(name, def);
};
// Single source of truth for the label → hook mapping. The
// `satisfies Record<DispatchLabel, RegistrationHook>` intersection
// fails at build time if (a) any label classified as 'dispatch' in
// `LABEL_BEHAVIOR` is missing here, or (b) any key here is not
// classified as 'dispatch'. This is the compile-time twin of the
// runtime taxonomy — no drift possible.
const dispatchByLabel = {
// class-like — six labels share the single `classLikeHook` closure,
// kept in lockstep with `CLASS_TYPES_TUPLE` via the
// `Record<ClassLikeLabel, 'dispatch'>` cross-invariant on
// `LABEL_BEHAVIOR`.
Class: classLikeHook,
Struct: classLikeHook,
Interface: classLikeHook,
Enum: classLikeHook,
Record: classLikeHook,
Trait: classLikeHook,
// method-like — routed via dispatch-key normalization in
// `wrappedAdd` so Function+ownerId also reaches `methodHook`.
Method: methodHook,
Constructor: methodHook,
// property — callable-index exclusion is enforced by
// `SymbolTable.add()` (Property is not in `FREE_CALLABLE_TYPES`).
Property: propertyHook,
// impl-block — Rust `impl` blocks. Separate from classLikeHook because
// heritage resolution must not treat Impls as class candidates.
Impl: implHook,
} as const satisfies Record<DispatchLabel, RegistrationHook>;
return new Map<NodeLabel, RegistrationHook>(
Object.entries(dispatchByLabel) as [NodeLabel, RegistrationHook][],
);
};

View file

@ -1,9 +1,7 @@
/**
* Resolution Context
*
* Single implementation of tiered name resolution. Replaces the duplicated
* tier-selection logic previously split between symbol-resolver.ts and
* call-processor.ts.
* Single implementation of tiered name resolution.
*
* Resolution tiers (highest confidence first):
* 1. Same file (lookupExactAll authoritative)
@ -20,11 +18,112 @@
* (three O(1) index lookups with a narrow, type-specific result set).
*/
import type { SymbolTable, SymbolDefinition } from './symbol-table.js';
import { createSymbolTable } from './symbol-table.js';
import type { NamedImportMap } from './import-processor.js';
import { isFileInPackageDir } from './import-processor.js';
import { walkBindingChain } from './named-binding-processor.js';
import type { SymbolDefinition, SymbolTableReader } from './symbol-table.js';
import type { MutableSemanticModel } from './semantic-model.js';
import { createSemanticModel } from './semantic-model.js';
// ---------------------------------------------------------------------------
// Named-import types — describe how a file imports specific names from a
// source file. Consumed by the Tier 2a-named binding-chain walker below.
// ---------------------------------------------------------------------------
/**
* A single named binding in a source file (e.g. `import { User as U }`).
* Stores both the resolved source path and the original exported name so
* that aliased imports can resolve U User in the source file.
*/
export interface NamedImportBinding {
sourcePath: string;
exportedName: string;
}
/**
* Map<ImportingFilePath, Map<LocalName, NamedImportBinding>>.
*
* Tracks which specific names a file imports from which sources (TS / Python
* / Rust / Java-static / ...). Used to tighten Tier 2a resolution:
* `import { User } from './models'` means only `User` (not `Repo`) is
* visible from models.ts via this import.
*/
export type NamedImportMap = Map<string, Map<string, NamedImportBinding>>;
/**
* Check if a file path is directly inside a package directory identified by
* its suffix. Used by Tier 2b package-scoped resolution (Go / C#).
*/
export function isFileInPackageDir(filePath: string, dirSuffix: string): boolean {
// Prepend '/' so paths like "internal/auth/service.go" match suffix "/internal/auth/"
const normalized = '/' + filePath.replace(/\\/g, '/');
if (!normalized.includes(dirSuffix)) return false;
const afterDir = normalized.substring(normalized.indexOf(dirSuffix) + dirSuffix.length);
return !afterDir.includes('/');
}
/** Maximum re-export hops walkBindingChain will follow before giving up.
* A hard cap is needed to defend against pathological cycles that slip
* past the `visited` Set (e.g. a binding chain whose key is equal by
* string value but visits distinct modules). Five hops covers the
* common TypeScript monorepo pattern (component pkg/index
* packages/index root/index types/index). Chains longer than this
* fall through to Tier 2a-import / Tier 2b / Tier 3 resolution, which
* is a silent false-negative that the caller may or may not recover
* from. If a real repo hits this limit, raise it there is no
* correctness reason to keep it at exactly 5. */
const MAX_BINDING_CHAIN_DEPTH = 5;
/**
* Walk a named-binding re-export chain through NamedImportMap.
*
* When file A imports { User } from B, and B re-exports { User } from C,
* the NamedImportMap for A points to B, but B has no User definition.
* This function follows the chain: A B C until a definition is found.
*
* Returns the definitions found at the end of the chain, or null if the
* chain breaks (missing binding, circular reference, or
* {@link MAX_BINDING_CHAIN_DEPTH} exceeded). Internal to
* resolution-context not exported from the model barrel.
*/
function walkBindingChain(
name: string,
currentFilePath: string,
symbolTable: SymbolTableReader,
namedImportMap: NamedImportMap,
): readonly SymbolDefinition[] | null {
// Fast exit: most files have no named imports at all. Skip the Set
// allocation + loop entry on the common empty-binding path so resolve()
// stays allocation-free for the typical call site.
const firstBindings = namedImportMap.get(currentFilePath);
if (!firstBindings) return null;
const firstBinding = firstBindings.get(name);
if (!firstBinding) return null;
let lookupFile = currentFilePath;
let lookupName = name;
const visited = new Set<string>();
for (let depth = 0; depth < MAX_BINDING_CHAIN_DEPTH; depth++) {
const bindings = depth === 0 ? firstBindings : namedImportMap.get(lookupFile);
if (!bindings) return null;
const binding = depth === 0 ? firstBinding : bindings.get(lookupName);
if (!binding) return null;
const key = `${binding.sourcePath}:${binding.exportedName}`;
if (visited.has(key)) return null; // circular
visited.add(key);
const targetName = binding.exportedName;
const resolvedDefs = symbolTable.lookupExactAll(binding.sourcePath, targetName);
if (resolvedDefs.length > 0) return resolvedDefs;
// No definition in source file → follow re-export chain
lookupFile = binding.sourcePath;
lookupName = targetName;
}
return null;
}
/** Resolution tier for tracking, logging, and test assertions. */
export type ResolutionTier = 'same-file' | 'import-scoped' | 'global';
@ -59,8 +158,13 @@ export interface ResolutionContext {
resolve(name: string, fromFile: string): TieredCandidates | null;
// --- Data access (for pipeline wiring, not resolution) ---
/** Symbol table — used by parsing-processor to populate symbols. */
readonly symbols: SymbolTable;
/** Semantic model the top-level container for types, methods, fields,
* and the nested file/callable SymbolTable. Typed as
* {@link MutableSemanticModel} because `ResolutionContext` is the
* lifecycle owner the pipeline registers symbols through it during
* the fan-out phase. Resolvers that only query should annotate their
* own fields as {@link SemanticModel} to drop write access. */
readonly model: MutableSemanticModel;
/** Raw maps — used by import-processor to populate import data. */
readonly importMap: ImportMap;
readonly packageMap: PackageMap;
@ -86,7 +190,8 @@ export interface ResolutionContext {
}
export const createResolutionContext = (): ResolutionContext => {
const symbols = createSymbolTable();
const model = createSemanticModel();
const symbols = model.symbols;
const importMap: ImportMap = new Map();
const packageMap: PackageMap = new Map();
const namedImportMap: NamedImportMap = new Map();
@ -194,27 +299,75 @@ export const createResolutionContext = (): ResolutionContext => {
// Tier 3: Global — targeted O(1) index lookups for each symbol category.
// Class-like symbols (Class, Struct, Interface, Enum, Record, Trait) are
// covered by lookupClassByName; Rust impl blocks by lookupImplByName
// (separate to avoid polluting heritage resolution); callables (Function,
// Method, Constructor, Macro, Delegate) by lookupCallableByName.
// The three indexes cover disjoint symbol types so no dedup is needed.
// Consumers must check candidates.length and refuse ambiguous matches.
// (separate to avoid polluting heritage resolution); free callables
// (Function, Macro, Delegate) by lookupCallableByName; owner-scoped
// methods and constructors by `model.methods.lookupMethodByName`.
//
// FREE_CALLABLE_TYPES excludes Method/Constructor, so strictly-labeled
// methods are disjoint between the two indexes.
//
// Partial-state caveat: Python/Rust/Kotlin class methods are emitted
// as Function + ownerId — `rawSymbols.add` routes them through both
// the Function callable index AND, via the dispatch-key normalization
// in `wrappedAdd`, the method registry. The same `SymbolDefinition`
// reference lands in both `callableDefs` and `methodDefs`, so the
// Set-based dedup below is required.
//
// Known exclusion: TypeAlias, Const, and Variable are NOT reachable at
// Tier 3 — they don't belong to any of the three indexes. In practice
// they were never useful as Tier 3 candidates: TypeAlias is not a call
// target, Const/Variable are resolved via import or same-file tiers.
// If a future language needs them at Tier 3, add a dedicated index.
// Macro (C/C++) and Delegate (C#) ARE included in the callable index
// Tier 3 — they don't belong to any of the indexes. TypeAlias is not
// a call target; Const/Variable are resolved via import or same-file
// tiers. Macro (C/C++) and Delegate (C#) stay in the callable index
// since call-processor.ts treats them as callable targets.
const classDefs = symbols.lookupClassByName(name);
const implDefs = symbols.lookupImplByName(name);
const classDefs = model.types.lookupClassByName(name);
const implDefs = model.types.lookupImplByName(name);
const callableDefs = symbols.lookupCallableByName(name);
const methodDefs = model.methods.lookupMethodByName(name);
if (classDefs.length === 0 && implDefs.length === 0 && callableDefs.length === 0) {
if (
classDefs.length === 0 &&
implDefs.length === 0 &&
callableDefs.length === 0 &&
methodDefs.length === 0
) {
tierMiss++;
return null;
}
const globalDefs = [...classDefs, ...implDefs, ...callableDefs];
// Fast path: if no `Function + ownerId` class method was ever
// registered into the method registry (the only source of
// cross-index duplication), the callable and method indexes are
// guaranteed disjoint and we can concat without dedup.
if (!model.methods.hasFunctionMethods) {
const globalDefs: SymbolDefinition[] = [
...classDefs,
...implDefs,
...callableDefs,
...methodDefs,
];
tierGlobal++;
return { candidates: globalDefs, tier: 'global' };
}
// Slow path: dedup by nodeId because the same SymbolDefinition
// reference can land in both `callableDefs` (via the Function
// callable-index gate) and `methodDefs` (via the dispatch-key
// normalization routing Function+ownerId into MethodRegistry).
// Dedup covers all four index reads so any nodeId overlap (even
// theoretical ones between classDefs/implDefs) is caught.
const globalDefs: SymbolDefinition[] = [];
const seen = new Set<string>();
const pushUnique = (pool: readonly SymbolDefinition[]): void => {
for (const def of pool) {
if (seen.has(def.nodeId)) continue;
seen.add(def.nodeId);
globalDefs.push(def);
}
};
pushUnique(classDefs);
pushUnique(implDefs);
pushUnique(callableDefs);
pushUnique(methodDefs);
tierGlobal++;
return { candidates: globalDefs, tier: 'global' };
};
@ -271,7 +424,7 @@ export const createResolutionContext = (): ResolutionContext => {
});
const clear = (): void => {
symbols.clear();
model.clear();
importMap.clear();
packageMap.clear();
namedImportMap.clear();
@ -288,7 +441,7 @@ export const createResolutionContext = (): ResolutionContext => {
return {
resolve,
symbols,
model,
importMap,
packageMap,
namedImportMap,

View file

@ -0,0 +1,284 @@
/**
* Deterministic Resolution Functions
*
* Pure functions that resolve methods across the inheritance hierarchy
* using only the SemanticModel registries and HeritageMap NO dependency
* on resolution-context.ts (circular dependency risk).
*/
import type { SymbolDefinition } from './symbol-table.js';
import type { SemanticModel } from './semantic-model.js';
import type { HeritageMap } from './heritage-map.js';
import type { MroStrategy } from 'gitnexus-shared';
// ---------------------------------------------------------------------------
// MRO primitives.
//
// `c3Linearize` and its BFS helper `gatherAncestors` live here so the model
// layer stays a pure leaf — mro-processor.ts (graph-level MRO emission)
// imports `c3Linearize` from this file.
// ---------------------------------------------------------------------------
/**
* Gather all ancestor IDs in BFS / topological order.
* Returns the linearized list of ancestor IDs (excluding the class itself).
*/
function gatherAncestors(classId: string, parentMap: Map<string, string[]>): string[] {
const visited = new Set<string>();
const order: string[] = [];
const queue: string[] = [...(parentMap.get(classId) ?? [])];
while (queue.length > 0) {
const id = queue.shift()!;
if (visited.has(id)) continue;
visited.add(id);
order.push(id);
const grandparents = parentMap.get(id);
if (grandparents) {
for (const gp of grandparents) {
if (!visited.has(gp)) queue.push(gp);
}
}
}
return order;
}
/**
* Compute C3 linearization for a class given a parentMap.
* Returns an array of ancestor IDs in C3 order (excluding the class itself),
* or null if linearization fails (inconsistent or cyclic hierarchy).
*
* Used internally by `lookupMethodByOwnerWithMRO` for the Python MRO
* strategy and re-exported for mro-processor.ts (graph-level MRO emission).
*/
export function c3Linearize(
classId: string,
parentMap: Map<string, string[]>,
cache: Map<string, string[] | null>,
inProgress?: Set<string>,
): string[] | null {
if (cache.has(classId)) return cache.get(classId)!;
// Cycle detection: if we're already computing this class, the hierarchy is cyclic
const visiting = inProgress ?? new Set<string>();
if (visiting.has(classId)) {
cache.set(classId, null);
return null;
}
visiting.add(classId);
const directParents = parentMap.get(classId);
if (!directParents || directParents.length === 0) {
visiting.delete(classId);
cache.set(classId, []);
return [];
}
// Compute linearization for each parent first
const parentLinearizations: string[][] = [];
for (const pid of directParents) {
const pLin = c3Linearize(pid, parentMap, cache, visiting);
if (pLin === null) {
visiting.delete(classId);
cache.set(classId, null);
return null;
}
parentLinearizations.push([pid, ...pLin]);
}
// Add the direct parents list as the final sequence
const sequences = [...parentLinearizations, [...directParents]];
const result: string[] = [];
while (sequences.some((s) => s.length > 0)) {
// Find a good head: one that doesn't appear in the tail of any other sequence
let head: string | null = null;
for (const seq of sequences) {
if (seq.length === 0) continue;
const candidate = seq[0];
const inTail = sequences.some(
(other) => other.length > 1 && other.indexOf(candidate, 1) !== -1,
);
if (!inTail) {
head = candidate;
break;
}
}
if (head === null) {
// Inconsistent hierarchy
visiting.delete(classId);
cache.set(classId, null);
return null;
}
result.push(head);
// Remove the chosen head from all sequences
for (const seq of sequences) {
if (seq.length > 0 && seq[0] === head) {
seq.shift();
}
}
}
visiting.delete(classId);
cache.set(classId, result);
return result;
}
// `gatherAncestors` is exported so mro-processor.ts can reuse the same
// BFS traversal for graph-level MRO emission.
export { gatherAncestors };
// ---------------------------------------------------------------------------
// C3 linearization cache (per HeritageMap, auto-drained via WeakMap)
// ---------------------------------------------------------------------------
/**
* Per-HeritageMap cache of C3 linearization results keyed by owner nodeId.
*
* HeritageMap instances are immutable after construction, so C3 output is
* stable for the lifetime of a HeritageMap. WeakMap lets the cache auto-drain
* when the HeritageMap is garbage collected (end of ingestion run), so we
* never need to manually invalidate it.
*
* `null` is a sentinel for "C3 failed for this owner" (cyclic or inconsistent
* hierarchy) so we don't re-run the expensive linearization repeatedly.
*/
const c3LinearizationCache = new WeakMap<HeritageMap, Map<string, readonly string[] | null>>();
const getCachedC3Linearization = (
ownerNodeId: string,
heritageMap: HeritageMap,
): readonly string[] | null => {
let perHmCache = c3LinearizationCache.get(heritageMap);
if (!perHmCache) {
perHmCache = new Map();
c3LinearizationCache.set(heritageMap, perHmCache);
}
const cached = perHmCache.get(ownerNodeId);
if (cached !== undefined) return cached;
const parentMap = buildParentMapFromHeritage(ownerNodeId, heritageMap);
const result = c3Linearize(ownerNodeId, parentMap, new Map()) ?? null;
perHmCache.set(ownerNodeId, result);
return result;
};
// ---------------------------------------------------------------------------
// Heritage → parentMap conversion
// ---------------------------------------------------------------------------
/**
* Build a parentMap from HeritageMap for use with c3Linearize.
* Traverses the parent chain starting from startNodeId, collecting all
* parentchildren relationships into a Map<string, string[]>.
*
* Uses a head-pointer BFS (queue[head++]) instead of Array.shift() to avoid
* O(n) per-dequeue re-indexing. For wide/shallow hierarchies common in
* large Java/C# codebases this keeps the walk linear in ancestor count.
*/
const buildParentMapFromHeritage = (
startNodeId: string,
heritageMap: HeritageMap,
): Map<string, string[]> => {
const parentMap = new Map<string, string[]>();
const visited = new Set<string>();
const queue: string[] = [startNodeId];
let head = 0;
while (head < queue.length) {
const nodeId = queue[head++]!;
if (visited.has(nodeId)) continue;
visited.add(nodeId);
const parents = heritageMap.getParents(nodeId);
if (parents.length > 0) {
parentMap.set(nodeId, parents);
for (const p of parents) {
if (!visited.has(p)) queue.push(p);
}
}
}
return parentMap;
};
// ---------------------------------------------------------------------------
// MRO-aware method lookup
// ---------------------------------------------------------------------------
/**
* Look up a method on an owner class, walking the parent chain via HeritageMap
* when the method isn't found on the direct owner.
*
* Respects the 5 per-language MRO strategies:
* - `first-wins`: BFS ancestor walk, first match wins (default)
* - `leftmost-base`: BFS ancestor walk, leftmost base in declaration order wins (C++);
* HeritageMap preserves insertion order matching source declaration,
* so BFS order is equivalent to leftmost-base semantics
* - `c3`: C3-linearized ancestor order, first match wins (Python)
* - `implements-split`: BFS ancestor walk, first match wins (Java/C#)
* full ambiguity detection for multiple interface defaults
* is handled by computeMRO at graph level
* - `qualified-syntax`: No auto-resolution (Rust) returns undefined
*
* Uses the `c3Linearize` defined in this file (also consumed by
* mro-processor.ts for graph-level MRO emission) for the `c3` strategy.
*
* Depends only on {@link SemanticModel} + {@link HeritageMap} + an
* {@link MroStrategy} literal NO dependency on SymbolTable, the language
* registry, or resolution-context, which keeps the `model/` module free of
* cross-layer imports. Callers derive the strategy from their language
* provider before invoking this function.
*
* @internal This is the low-level MRO walker. Exported so call-processor's
* higher-level resolvers (and unit tests) can invoke it directly. Callers
* outside `core/ingestion/` should use the higher-level resolvers in
* call-processor.ts instead of depending on this function.
*/
export const lookupMethodByOwnerWithMRO = (
ownerNodeId: string,
methodName: string,
heritageMap: HeritageMap,
model: SemanticModel,
strategy: MroStrategy,
argCount?: number,
): SymbolDefinition | undefined => {
// Direct lookup first (child override — no walk needed).
// argCount is threaded through so arity-differing overloads on the direct
// owner can be disambiguated before the MRO walk starts.
const direct = model.methods.lookupMethodByOwner(ownerNodeId, methodName, argCount);
if (direct) return direct;
// Rust: requires qualified syntax (<Type as Trait>::method), no auto-resolution
if (strategy === 'qualified-syntax') return undefined;
// Determine ancestor walk order based on MRO strategy.
// readonly to accept the cached (frozen) c3 linearization without copying.
let ancestors: readonly string[];
if (strategy === 'c3') {
// C3 linearization (memoized per HeritageMap
// so repeated calls for the same owner within an ingestion run reuse the
// linearization instead of rebuilding the parent map and re-running C3).
// c3Linearize returns ancestors only (excludes the owner itself),
// matching heritageMap.getAncestors() semantics.
const c3Result = getCachedC3Linearization(ownerNodeId, heritageMap);
// Fall back to BFS order if C3 fails (cyclic or inconsistent hierarchy).
// Note: BFS order may not preserve Python MRO semantics in these edge
// cases, but cyclic/inconsistent hierarchies are invalid in Python anyway.
ancestors = c3Result ?? heritageMap.getAncestors(ownerNodeId);
} else {
// first-wins, leftmost-base, implements-split: BFS order via HeritageMap
ancestors = heritageMap.getAncestors(ownerNodeId);
}
// Walk ancestors in MRO order — first match wins.
// argCount narrows overloaded ancestors the same way as the direct lookup.
for (const ancestorId of ancestors) {
const method = model.methods.lookupMethodByOwner(ancestorId, methodName, argCount);
if (method) return method;
}
return undefined;
};

View file

@ -0,0 +1,193 @@
/**
* Semantic Model
*
* Top-level orchestrator for all resolution-time data. Owns:
*
* - Three owner-scoped registries (types, methods, fields)
* - A nested SymbolTable (file + callable name indexes) wrapped so
* that `add()` fans out into the registries via the dispatch table
*
* ## DAG direction
*
* gitnexus-shared (NodeLabel) leaf
*
* symbol-table.ts pure file/callable index
*
* model/type-registry / method-registry / field-registry
*
* model/registration-table.ts dispatch table factory
*
* model/semantic-model.ts THIS FILE (orchestrator)
*
* resolve.ts, call-processor.ts, resolution-context.ts, ...
*
* `symbol-table.ts` is a leaf it never imports from `./model/`. This
* file (semantic-model.ts) is the ONLY place where SymbolTable and the
* owner-scoped registries are composed. Upstream consumers pass around
* the `SemanticModel` interface and reach into `.symbols` for file-scoped
* operations or `.types` / `.methods` / `.fields` for owner-scoped ones.
*
* ## Fan-out via wrapped add()
*
* `createSemanticModel()` creates a pure SymbolTable, creates the three
* registries, builds a dispatch table via `createRegistrationTable`, and
* exposes a SymbolTable-shaped façade whose `add()`:
*
* 1. Calls `rawSymbols.add()` writes the fileIndex + callable index
* and returns the fully-built `SymbolDefinition`.
* 2. Runs pre-dispatch normalization (`Function`-with-`ownerId` routes
* as `Method`).
* 3. Looks up the dispatch table and invokes the hook, which writes to
* the appropriate owner-scoped registry.
*
* The wrapper is the only place where the two layers are combined. A
* direct `createSymbolTable()` caller (e.g. an isolated unit test) gets
* the pure, registry-free behavior no surprises, no hidden side
* effects.
*/
import type { NodeLabel } from 'gitnexus-shared';
import type { TypeRegistry, MutableTypeRegistry } from './type-registry.js';
import type { MethodRegistry, MutableMethodRegistry } from './method-registry.js';
import type { FieldRegistry, MutableFieldRegistry } from './field-registry.js';
import { createTypeRegistry } from './type-registry.js';
import { createMethodRegistry } from './method-registry.js';
import { createFieldRegistry } from './field-registry.js';
import type {
SymbolTableReader,
SymbolTableWriter,
SymbolDefinition,
AddMetadata,
} from './symbol-table.js';
import { createSymbolTable } from './symbol-table.js';
import { createRegistrationTable } from './registration-table.js';
// ---------------------------------------------------------------------------
// Public read-only interface
// ---------------------------------------------------------------------------
/**
* Aggregated read-only view of the semantic registries plus the nested
* file/callable SymbolTable.
*
* `symbols` is typed as {@link SymbolTableReader} consumers can query
* symbols but cannot register new ones or trigger a reset. Callers that
* need to register symbols or reset state must hold a
* {@link MutableSemanticModel} reference instead, which widens
* `symbols` back to {@link SymbolTableWriter} and adds `clear()` on the
* model itself.
*
* This segregation is the runtime half of the principle of least
* authority: a resolver that receives `SemanticModel` physically cannot
* mutate the index, so it cannot desync the leaf from the owner-scoped
* registries even accidentally.
*/
export interface SemanticModel {
readonly types: TypeRegistry;
readonly methods: MethodRegistry;
readonly fields: FieldRegistry;
readonly symbols: SymbolTableReader;
}
// ---------------------------------------------------------------------------
// Mutable interface
// ---------------------------------------------------------------------------
/** Mutable variant exposes the MutableX registries, a Writer-typed
* `symbols` facade, and a full-cascade reset. This is the interface
* held by the lifecycle owner (pipeline, resolution-context); resolvers
* that only query should hold the narrower {@link SemanticModel}. */
export interface MutableSemanticModel extends SemanticModel {
readonly types: MutableTypeRegistry;
readonly methods: MutableMethodRegistry;
readonly fields: MutableFieldRegistry;
readonly symbols: SymbolTableWriter;
/** Clear all registries AND the nested SymbolTable. */
clear(): void;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
//
// NodeLabel taxonomy drift detection lives in `registration-table.ts` as a
// pure compile-time check — the `LABEL_BEHAVIOR` map is
// `Record<NodeLabel, LabelBehavior>` with `as const satisfies`, which proves
// coverage, uniqueness, and no-extra-keys at build time. No runtime guard
// is needed because drift is structurally impossible in the source.
export const createSemanticModel = (): MutableSemanticModel => {
// 1. Create the pure, registry-unaware SymbolTable leaf.
// rawSymbols is the only handle in the codebase whose type (the
// internal createSymbolTable return) includes `.clear()`. cascadeClear
// below reaches it here; no external caller receives this variable.
const rawSymbols = createSymbolTable();
// 2. Create the three owner-scoped registries.
const types = createTypeRegistry();
const methods = createMethodRegistry();
const fields = createFieldRegistry();
// 3. Build the dispatch table, closed over THIS instance's registries.
const dispatchTable = createRegistrationTable({ types, methods, fields });
// 4. Wrap rawSymbols so `add()` fans out into the registries via the
// dispatch table. See module JSDoc for the three-step contract.
const wrappedAdd = (
filePath: string,
name: string,
nodeId: string,
type: NodeLabel,
metadata?: AddMetadata,
): SymbolDefinition => {
const def = rawSymbols.add(filePath, name, nodeId, type, metadata);
// Function-with-ownerId (Python `def` in a class body, Rust trait
// method, Kotlin companion method) routes as Method. Keeps the
// dispatch table single-purpose.
const dispatchKey: NodeLabel =
type === 'Function' && metadata?.ownerId !== undefined ? 'Method' : type;
const hook = dispatchTable.get(dispatchKey);
if (hook) {
hook(name, def);
}
return def;
};
// Cascade clear: single source of truth for "reset the entire model".
// Wired into both `model.clear()` AND `model.symbols.clear()` so that a
// caller holding only a SymbolTable reference can't leave the
// owner-scoped registries populated while the file/callable indexes go
// empty (the phantom-resolution failure mode).
const cascadeClear = (): void => {
types.clear();
methods.clear();
fields.clear();
rawSymbols.clear();
};
// Writer-typed facade: exposes reads + add, but NO `clear` field.
// Callers holding a `SemanticModel.symbols` reference cannot desync
// the leaf indexes from the owner-scoped registries. Consumers that
// only query should widen their annotation to SymbolTableReader for
// least-authority clarity.
const symbols: SymbolTableWriter = {
add: wrappedAdd,
lookupExact: rawSymbols.lookupExact,
lookupExactFull: rawSymbols.lookupExactFull,
lookupExactAll: rawSymbols.lookupExactAll,
lookupCallableByName: rawSymbols.lookupCallableByName,
getFiles: rawSymbols.getFiles,
getStats: rawSymbols.getStats,
};
return {
types,
methods,
fields,
symbols,
clear: cascadeClear,
};
};

View file

@ -0,0 +1,381 @@
/**
* Symbol Table file-indexed + callable-name symbol storage.
*
* This module is a PURE LEAF in the ingestion DAG. It owns two orthogonal
* O(1) indexes:
*
* 1. fileIndex Map<filePath, Map<name, SymbolDefinition[]>>
* for same-file lookups (Tier 1 resolution)
* 2. callableByName Map<name, SymbolDefinition[]>
* for name-keyed callable lookups (Tier 3 widen)
*
* SymbolTable deliberately knows NOTHING about the owner-scoped registries
* (types, methods, fields) that sit above it in the DAG. Those registries
* live in `model/` and depend on SymbolTable, not the other way around.
* {@link createSemanticModel} composes this pure SymbolTable with the
* registries and wraps `add()` to fan out registrations into both layers.
*
* DAG direction (strictly enforced):
*
* gitnexus-shared (NodeLabel) leaf type
*
* symbol-table.ts THIS FILE (pure storage)
*
* model/type-registry.ts, method-registry.ts, field-registry.ts
*
* model/registration-table.ts dispatch table factory
*
* model/semantic-model.ts orchestrator, wraps add()
*
* model/resolve.ts, call-processor.ts, resolution-context.ts, ...
*
* No arrow ever points downward from this file. If you are tempted to
* import from `./model/` here, you are going the wrong way move the
* logic up the DAG instead.
*/
import type { NodeLabel } from 'gitnexus-shared';
/**
* Class-like NodeLabels used for qualifiedName fallback inside
* `SymbolTable.add()` and (via import into `model/registration-table.ts`)
* as the single source of truth for which labels route to classHook
* in the dispatch table.
*
* Exported as a `readonly` tuple so that `typeof CLASS_TYPES_TUPLE[number]`
* yields a precise literal union (`ClassLikeLabel`). The model layer
* imports this tuple and uses `Record<ClassLikeLabel, 'dispatch'>` in a
* `satisfies` intersection to enforce at COMPILE TIME that every label
* listed here is also classified as dispatch in `LABEL_BEHAVIOR`. Adding
* a new class-like label to this tuple without updating `LABEL_BEHAVIOR`
* fails TypeScript.
*
* Traits are class-like for heritage resolution: PHP `use Trait;`, Rust
* `impl Trait for Struct`, and Scala traits all contribute methods to the
* hierarchy of their using/implementing type.
*/
export const CLASS_TYPES_TUPLE = [
'Class',
'Struct',
'Interface',
'Enum',
'Record',
'Trait',
] as const satisfies readonly NodeLabel[];
export type ClassLikeLabel = (typeof CLASS_TYPES_TUPLE)[number];
export const CLASS_TYPES: ReadonlySet<NodeLabel> = new Set(CLASS_TYPES_TUPLE);
/** Free-callable labels single source of truth for "callables that have
* NO owner scope". Methods and constructors are owner-scoped and live in
* `MethodRegistry` Tier 3 reaches them via
* `model.methods.lookupMethodByName`. See `resolution-context.ts` Tier 3
* for how both indexes are consulted together.
*
* Exported as a `readonly` tuple so that `typeof FREE_CALLABLE_TUPLE[number]`
* yields a precise literal union (`FreeCallableLabel`). `registration-table.ts`
* imports this type and uses `Record<FreeCallableLabel, 'callable-only'>` in
* a `satisfies` intersection to enforce at COMPILE TIME that every label
* listed here is also classified as `callable-only` in `LABEL_BEHAVIOR`.
* Adding a label to this tuple without updating `LABEL_BEHAVIOR` fails
* TypeScript.
*
* Partial-state caveat: Python/Rust/Kotlin class methods are emitted by
* the worker as `Function` + `ownerId` (not `Method`), so they still land
* here via the `Function` entry. Collapsing those three languages onto the
* `Method` label is pending a `def.type` preservation decision.
*/
export const FREE_CALLABLE_TUPLE = [
'Function',
'Macro', // C/C++
'Delegate', // C#
] as const satisfies readonly NodeLabel[];
export type FreeCallableLabel = (typeof FREE_CALLABLE_TUPLE)[number];
export const FREE_CALLABLE_TYPES: ReadonlySet<NodeLabel> = new Set(FREE_CALLABLE_TUPLE);
/** Symbol types that can be the TARGET of a call in the resolver's kind
* filter superset of {@link FREE_CALLABLE_TYPES} that also admits
* owner-scoped methods and constructors pulled in from `MethodRegistry`.
*
* Why the split: `FREE_CALLABLE_TYPES` now has a narrow meaning (free
* callables indexed in `callableByName`), but call resolution still
* needs to accept Method and Constructor candidates once they have been
* unioned in from `model.methods.lookupMethodByName`. The resolver uses
* this constant for kind filtering in
* `filterCallableCandidates` / `countCallableCandidates`.
*/
export const CALL_TARGET_TYPES: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
...FREE_CALLABLE_TYPES,
'Method',
'Constructor',
]);
export interface SymbolDefinition {
nodeId: string;
filePath: string;
type: NodeLabel;
/** Canonical dot-separated qualified type name for class-like symbols
* (e.g. `App.Models.User`). Falls back to the simple symbol name when no
* package/namespace/module scope exists or no explicit qualified metadata is provided. */
qualifiedName?: string;
parameterCount?: number;
/** Number of required (non-optional, non-default) parameters.
* Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */
requiredParameterCount?: number;
/** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']).
* Populated when parameter types are resolvable from AST (any typed language). */
parameterTypes?: string[];
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
returnType?: string;
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
declaredType?: string;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
}
/**
* Optional metadata accepted by {@link SymbolTable.add}. Kept as a separate
* type alias so callers and wrappers can share the same shape.
*/
export interface AddMetadata {
parameterCount?: number;
requiredParameterCount?: number;
parameterTypes?: string[];
returnType?: string;
declaredType?: string;
ownerId?: string;
qualifiedName?: string;
}
/**
* Pure read-only view over the file and callable indexes. Does NOT
* include `add()` or `clear()`.
*
* Used by consumers that only query symbols (resolvers, type-env, field
* extractors). The interface is strictly observational holding a
* `SymbolTableReader` cannot mutate the table in any way.
*
* For consumers that also need to register symbols, use
* {@link SymbolTableWriter}, which extends this interface with `add()`.
* Neither interface exposes `clear()` that capability lives on the
* internal factory return type and is reachable only inside
* `SemanticModel` via `rawSymbols`.
*
* Segregating the observer contract from the mutation contract means
* callers holding only a Reader can never desync the model.
*/
export interface SymbolTableReader {
/**
* High Confidence: Look for a symbol specifically inside a file.
* Returns the Node ID if found.
*/
lookupExact: (filePath: string, name: string) => string | undefined;
/**
* High Confidence: Look for a symbol in a specific file, returning full definition.
* Returns first matching definition use lookupExactAll for overloaded methods.
*/
lookupExactFull: (filePath: string, name: string) => SymbolDefinition | undefined;
/**
* High Confidence: Look for ALL symbols with this name in a specific file.
* Returns all definitions, including overloaded methods with the same name.
* The returned array is a view into the live internal index callers
* MUST NOT mutate it. Use `readonly` to enforce this at the type level.
*/
lookupExactAll: (filePath: string, name: string) => readonly SymbolDefinition[];
/**
* Look up callable symbols (Function, Macro, Delegate) by name.
* O(1) via dedicated eagerly-populated index keyed by symbol name.
* Returned array is a view into the live index do not mutate.
*/
lookupCallableByName: (name: string) => readonly SymbolDefinition[];
/**
* Iterate all indexed file paths.
* Used by Tier 2b (package-scoped) resolution to walk files matching a
* package directory suffix without a global name scan.
*/
getFiles: () => IterableIterator<string>;
/**
* Debugging: See how many files are tracked.
*/
getStats: () => {
fileCount: number;
};
}
/**
* Writer view reads + symbol registration. Does NOT include `clear()`.
*
* `MutableSemanticModel.symbols` is typed as this interface, so the
* lifecycle owner can register symbols and query them. Full-model
* resets flow through `model.clear()`.
*
* The cascading `clear()` capability lives exclusively on the internal
* factory return type ({@link createSymbolTable}) a private handle
* held only by `SemanticModel` via `rawSymbols`.
*/
export interface SymbolTableWriter extends SymbolTableReader {
/**
* Register a symbol in the file and (if callable) name-keyed indexes.
*
* Returns the constructed {@link SymbolDefinition} so higher-layer
* wrappers (e.g. `createSemanticModel`) can reuse it without rebuilding
* the def. This keeps the fan-out in one allocation.
*/
add: (
filePath: string,
name: string,
nodeId: string,
type: NodeLabel,
metadata?: AddMetadata,
) => SymbolDefinition;
}
/**
* Internal return type for {@link createSymbolTable} extends the
* writer with `clear()`. This capability is intentionally NOT exported
* as a named interface; consumers should hold a `SymbolTableReader` or
* `SymbolTableWriter` instead.
*
* `SemanticModel`'s constructor is the only caller of `createSymbolTable`,
* and it retains the returned handle as the private `rawSymbols`
* reference so `cascadeClear` can reach `clear()`. Every other consumer
* receives the narrower `SymbolTableWriter` facade on `model.symbols`.
*/
interface InternalSymbolTable extends SymbolTableWriter {
/**
* Cleanup memory. Clears only the file and callable indexes owned here
* owner-scoped registries are cleared by their respective owners via
* `model.clear()`.
*/
clear: () => void;
}
export const createSymbolTable = (): InternalSymbolTable => {
// 1. File-Specific Index — stores full SymbolDefinition(s) for O(1) lookup.
// Structure: FilePath -> (SymbolName -> SymbolDefinition[])
// Array allows overloaded methods (same name, different signatures) to coexist.
const fileIndex = new Map<string, Map<string, SymbolDefinition[]>>();
// 2. Eagerly-populated Callable Index — maintained on add().
// Structure: SymbolName -> [Callable Definitions]
// Only Function, Method, Constructor, Macro, Delegate symbols are indexed.
const callableByName = new Map<string, SymbolDefinition[]>();
const add = (
filePath: string,
name: string,
nodeId: string,
type: NodeLabel,
metadata?: AddMetadata,
): SymbolDefinition => {
const qualifiedName = CLASS_TYPES.has(type)
? (metadata?.qualifiedName ?? name)
: metadata?.qualifiedName;
const def: SymbolDefinition = {
nodeId,
filePath,
type,
...(qualifiedName !== undefined ? { qualifiedName } : {}),
...(metadata?.parameterCount !== undefined
? { parameterCount: metadata.parameterCount }
: {}),
...(metadata?.requiredParameterCount !== undefined
? { requiredParameterCount: metadata.requiredParameterCount }
: {}),
...(metadata?.parameterTypes !== undefined
? { parameterTypes: metadata.parameterTypes }
: {}),
...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}),
...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}),
...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}),
};
// A. File Index — unconditional.
if (!fileIndex.has(filePath)) {
fileIndex.set(filePath, new Map());
}
const fileMap = fileIndex.get(filePath)!;
if (!fileMap.has(name)) {
fileMap.set(name, [def]);
} else {
fileMap.get(name)!.push(def);
}
// B. Callable Index — gated by FREE_CALLABLE_TYPES.
// Note: Property is NOT in FREE_CALLABLE_TYPES, so it never lands here.
// This is the single source of truth for callable-index membership;
// the higher-layer dispatch table only decides owner-scoped routing.
//
// Fallback: `Method` or `Constructor` without an `ownerId` is an
// extractor contract violation (AST-degraded parse, or a buggy
// language extractor). The owner-scoped dispatch hook silently
// skips such defs because it has no owner to key them under, so
// without this fallback they would be invisible at Tier 3 global
// resolution. Route them through `callableByName` so they remain
// reachable by name — matching pre-dispatch-table behavior.
const isOrphanedOwnerScoped =
(type === 'Method' || type === 'Constructor') && metadata?.ownerId === undefined;
if (FREE_CALLABLE_TYPES.has(type) || isOrphanedOwnerScoped) {
const existing = callableByName.get(name);
if (existing) {
existing.push(def);
} else {
callableByName.set(name, [def]);
}
}
return def;
};
const lookupExact = (filePath: string, name: string): string | undefined => {
const defs = fileIndex.get(filePath)?.get(name);
return defs?.[0]?.nodeId;
};
const lookupExactFull = (filePath: string, name: string): SymbolDefinition | undefined => {
const defs = fileIndex.get(filePath)?.get(name);
return defs?.[0];
};
const lookupExactAll = (filePath: string, name: string): SymbolDefinition[] => {
return fileIndex.get(filePath)?.get(name) ?? [];
};
const lookupCallableByName = (name: string): SymbolDefinition[] => {
return callableByName.get(name) ?? [];
};
/** Returns a live iterator over all indexed file paths (fileIndex.keys()).
* The iterator is invalidated if add() changes fileIndex.size during
* iteration (ES2015 Map spec). Safe in the current pipeline because all
* symbols are added before resolution begins. */
const getFiles = (): IterableIterator<string> => fileIndex.keys();
const getStats = () => ({
fileCount: fileIndex.size,
});
const clear = () => {
fileIndex.clear();
callableByName.clear();
};
return {
add,
lookupExact,
lookupExactFull,
lookupExactAll,
lookupCallableByName,
getFiles,
getStats,
clear,
};
};

View file

@ -0,0 +1,113 @@
/**
* Type Registry
*
* Class/struct/interface index extracted from SymbolTable.
* Eagerly-populated indexes keyed by symbol name and qualified name.
* Also includes a separate index for Rust Impl blocks.
*/
import type { SymbolDefinition } from './symbol-table.js';
// ---------------------------------------------------------------------------
// Public read-only interface
// ---------------------------------------------------------------------------
export interface TypeRegistry {
/**
* Look up class-like definitions (Class, Struct, Interface, Enum, Record, Trait)
* by simple name. Returns all matching definitions across files
* (e.g. partial classes). Returned array is a view into the live
* internal index do not mutate.
*/
lookupClassByName(name: string): readonly SymbolDefinition[];
/**
* Look up class-like definitions by canonical qualified name.
* Qualified names are normalized to dot-separated scope segments across languages,
* e.g. `App.Models.User`, `com.example.User`, or `Admin.User`.
* Returned array is a view into the live index do not mutate.
*/
lookupClassByQualifiedName(qualifiedName: string): readonly SymbolDefinition[];
/**
* Look up Impl nodes by name. Used by Tier 3 resolution to include Rust
* impl blocks alongside class-like candidates.
* Returned array is a view into the live index do not mutate.
*/
lookupImplByName(name: string): readonly SymbolDefinition[];
}
// ---------------------------------------------------------------------------
// Mutable interface (used internally by SymbolTable.add / clear)
// ---------------------------------------------------------------------------
export interface MutableTypeRegistry extends TypeRegistry {
/** Register a class-like type by name and qualified name. */
registerClass(name: string, qualifiedName: string, def: SymbolDefinition): void;
/** Register a Rust Impl block by name. */
registerImpl(name: string, def: SymbolDefinition): void;
/** Clear all entries. */
clear(): void;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export const createTypeRegistry = (): MutableTypeRegistry => {
const classByName = new Map<string, SymbolDefinition[]>();
const classByQualifiedName = new Map<string, SymbolDefinition[]>();
const implByName = new Map<string, SymbolDefinition[]>();
const lookupClassByName = (name: string): SymbolDefinition[] => {
return classByName.get(name) ?? [];
};
const lookupClassByQualifiedName = (qualifiedName: string): SymbolDefinition[] => {
return classByQualifiedName.get(qualifiedName) ?? [];
};
const lookupImplByName = (name: string): SymbolDefinition[] => {
return implByName.get(name) ?? [];
};
const registerClass = (name: string, qualifiedName: string, def: SymbolDefinition): void => {
const existing = classByName.get(name);
if (existing) {
existing.push(def);
} else {
classByName.set(name, [def]);
}
const qualifiedMatches = classByQualifiedName.get(qualifiedName);
if (qualifiedMatches) {
qualifiedMatches.push(def);
} else {
classByQualifiedName.set(qualifiedName, [def]);
}
};
const registerImpl = (name: string, def: SymbolDefinition): void => {
const existing = implByName.get(name);
if (existing) {
existing.push(def);
} else {
implByName.set(name, [def]);
}
};
const clear = (): void => {
classByName.clear();
classByQualifiedName.clear();
implByName.clear();
};
return {
lookupClassByName,
lookupClassByQualifiedName,
lookupImplByName,
registerClass,
registerImpl,
clear,
};
};

View file

@ -23,6 +23,7 @@ import { KnowledgeGraph } from '../graph/types.js';
import { generateId } from '../../lib/utils.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { getProvider } from './languages/index.js';
import { c3Linearize, gatherAncestors } from './model/resolve.js';
// ---------------------------------------------------------------------------
// Public types
@ -93,115 +94,9 @@ function buildAdjacency(graph: KnowledgeGraph) {
return { parentMap, methodMap, parentEdgeType };
}
/**
* Gather all ancestor IDs in BFS / topological order.
* Returns the linearized list of ancestor IDs (excluding the class itself).
*/
function gatherAncestors(classId: string, parentMap: Map<string, string[]>): string[] {
const visited = new Set<string>();
const order: string[] = [];
const queue: string[] = [...(parentMap.get(classId) ?? [])];
while (queue.length > 0) {
const id = queue.shift()!;
if (visited.has(id)) continue;
visited.add(id);
order.push(id);
const grandparents = parentMap.get(id);
if (grandparents) {
for (const gp of grandparents) {
if (!visited.has(gp)) queue.push(gp);
}
}
}
return order;
}
// ---------------------------------------------------------------------------
// C3 linearization (Python MRO)
// ---------------------------------------------------------------------------
/**
* Compute C3 linearization for a class given a parentMap.
* Returns an array of ancestor IDs in C3 order (excluding the class itself),
* or null if linearization fails (inconsistent or cyclic hierarchy).
*/
export function c3Linearize(
classId: string,
parentMap: Map<string, string[]>,
cache: Map<string, string[] | null>,
inProgress?: Set<string>,
): string[] | null {
if (cache.has(classId)) return cache.get(classId)!;
// Cycle detection: if we're already computing this class, the hierarchy is cyclic
const visiting = inProgress ?? new Set<string>();
if (visiting.has(classId)) {
cache.set(classId, null);
return null;
}
visiting.add(classId);
const directParents = parentMap.get(classId);
if (!directParents || directParents.length === 0) {
visiting.delete(classId);
cache.set(classId, []);
return [];
}
// Compute linearization for each parent first
const parentLinearizations: string[][] = [];
for (const pid of directParents) {
const pLin = c3Linearize(pid, parentMap, cache, visiting);
if (pLin === null) {
visiting.delete(classId);
cache.set(classId, null);
return null;
}
parentLinearizations.push([pid, ...pLin]);
}
// Add the direct parents list as the final sequence
const sequences = [...parentLinearizations, [...directParents]];
const result: string[] = [];
while (sequences.some((s) => s.length > 0)) {
// Find a good head: one that doesn't appear in the tail of any other sequence
let head: string | null = null;
for (const seq of sequences) {
if (seq.length === 0) continue;
const candidate = seq[0];
const inTail = sequences.some(
(other) => other.length > 1 && other.indexOf(candidate, 1) !== -1,
);
if (!inTail) {
head = candidate;
break;
}
}
if (head === null) {
// Inconsistent hierarchy
visiting.delete(classId);
cache.set(classId, null);
return null;
}
result.push(head);
// Remove the chosen head from all sequences
for (const seq of sequences) {
if (seq.length > 0 && seq[0] === head) {
seq.shift();
}
}
}
visiting.delete(classId);
cache.set(classId, result);
return result;
}
// `gatherAncestors` and `c3Linearize` live in `./model/resolve.ts` and
// are imported at the top of this file for internal use by `computeMRO`
// and the method-override edge emitter.
// ---------------------------------------------------------------------------
// Language-specific resolution

View file

@ -1,47 +0,0 @@
import type { SymbolTable, SymbolDefinition } from './symbol-table.js';
import type { NamedImportMap } from './import-processor.js';
/**
* Walk a named-binding re-export chain through NamedImportMap.
*
* When file A imports { User } from B, and B re-exports { User } from C,
* the NamedImportMap for A points to B, but B has no User definition.
* This function follows the chain: ABC until a definition is found.
*
* Returns the definitions found at the end of the chain, or null if the
* chain breaks (missing binding, circular reference, or depth exceeded).
* Max depth 5 to prevent infinite loops.
*/
export function walkBindingChain(
name: string,
currentFilePath: string,
symbolTable: SymbolTable,
namedImportMap: NamedImportMap,
): SymbolDefinition[] | null {
let lookupFile = currentFilePath;
let lookupName = name;
const visited = new Set<string>();
for (let depth = 0; depth < 5; depth++) {
const bindings = namedImportMap.get(lookupFile);
if (!bindings) return null;
const binding = bindings.get(lookupName);
if (!binding) return null;
const key = `${binding.sourcePath}:${binding.exportedName}`;
if (visited.has(key)) return null; // circular
visited.add(key);
const targetName = binding.exportedName;
const resolvedDefs = symbolTable.lookupExactAll(binding.sourcePath, targetName);
if (resolvedDefs.length > 0) return resolvedDefs;
// No definition in source file → follow re-export chain
lookupFile = binding.sourcePath;
lookupName = targetName;
}
return null;
}

View file

@ -4,7 +4,9 @@ import Parser from 'tree-sitter';
import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js';
import { getProvider } from './languages/index.js';
import { generateId } from '../../lib/utils.js';
import type { SymbolTable } from './symbol-table.js';
import type { SymbolTableReader, SymbolTableWriter } from './model/symbol-table.js';
// SymbolTableReader is used for the FieldExtractorContext stub; the
// parsing functions themselves need Writer because they call .add().
import { ASTCache } from './ast-cache.js';
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
import { extractVueScript, isVueSetupTopLevel } from './vue-sfc-extractor.js';
@ -36,7 +38,6 @@ import type {
ExtractedImport,
ExtractedCall,
ExtractedAssignment,
ExtractedHeritage,
ExtractedRoute,
ExtractedFetchCall,
ExtractedDecoratorRoute,
@ -45,6 +46,7 @@ import type {
FileScopeBindings,
ExtractedORMQuery,
} from './workers/parse-worker.js';
import type { ExtractedHeritage } from './model/heritage-map.js';
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
@ -70,7 +72,7 @@ export interface WorkerExtractedData {
const processParsingWithWorkers = async (
graph: KnowledgeGraph,
files: { path: string; content: string }[],
symbolTable: SymbolTable,
symbolTable: SymbolTableWriter,
astCache: ASTCache,
workerPool: WorkerPool,
onFileProgress?: FileProgressCallback,
@ -255,7 +257,10 @@ function seqFindEnclosingClassNode(node: SyntaxNode): SyntaxNode | null {
return null;
}
function seqFindEnclosingMethodContainerNode(node: SyntaxNode): SyntaxNode | null {
/** Raw enclosing container lookup for extractor-only context.
* Unlike seqFindEnclosingClassNode(), this intentionally returns
* `singleton_class` so Ruby `class << self` methods preserve static context. */
function seqFindRawEnclosingContainerNode(node: SyntaxNode): SyntaxNode | null {
let current = node.parent;
while (current) {
if (CLASS_CONTAINER_TYPES.has(current.type)) return current;
@ -265,12 +270,17 @@ function seqFindEnclosingMethodContainerNode(node: SyntaxNode): SyntaxNode | nul
}
/** Minimal no-op SymbolTable stub for sequential extractor contexts. The real
* SymbolTable is not fully populated yet at this stage, so use the stub for safety. */
const NOOP_SYMBOL_TABLE_SEQ = {
lookupExactAll: () => [],
* SymbolTable is not fully populated yet at this stage, so use the stub for safety.
* Implements the full {@link SymbolTableReader} surface so future extractor additions
* don't silently fall off an `as unknown as` cast. */
const NOOP_SYMBOL_TABLE_SEQ: SymbolTableReader = {
lookupExact: () => undefined,
lookupExactFull: () => undefined,
} as unknown as SymbolTable;
lookupExactAll: () => [],
lookupCallableByName: () => [],
getFiles: () => [][Symbol.iterator](),
getStats: () => ({ fileCount: 0 }),
};
function seqGetFieldInfo(
classNode: SyntaxNode,
@ -292,7 +302,7 @@ function seqGetFieldInfo(
const processParsingSequential = async (
graph: KnowledgeGraph,
files: { path: string; content: string }[],
symbolTable: SymbolTable,
symbolTable: SymbolTableWriter,
astCache: ASTCache,
onFileProgress?: FileProgressCallback,
) => {
@ -455,7 +465,7 @@ const processParsingSequential = async (
// Try class-based extraction (method inside a class/struct/trait body).
// Ruby `class << self` needs the singleton_class node for `isStatic`,
// while owner/class resolution still skips it elsewhere.
const methodOwnerNode = seqFindEnclosingMethodContainerNode(definitionNode);
const methodOwnerNode = seqFindRawEnclosingContainerNode(definitionNode);
if (methodOwnerNode) {
// Cache extract() results per class node to avoid re-traversing the
// same class body for every method it contains (O(N) -> O(1) per hit).
@ -666,7 +676,7 @@ const processParsingSequential = async (
export const processParsing = async (
graph: KnowledgeGraph,
files: { path: string; content: string }[],
symbolTable: SymbolTable,
symbolTable: SymbolTableWriter,
astCache: ASTCache,
onFileProgress?: FileProgressCallback,
workerPool?: WorkerPool,

View file

@ -27,7 +27,7 @@ import {
type ExportedTypeMap,
buildExportedTypeMapFromGraph,
} from './call-processor.js';
import { buildHeritageMap } from './heritage-map.js';
import { buildHeritageMap } from './model/heritage-map.js';
import { nextjsFileToRouteURL, normalizeFetchURL } from './route-extractors/nextjs.js';
import { expoFileToRouteURL } from './route-extractors/expo.js';
import { phpFileToRouteURL } from './route-extractors/php.js';
@ -47,21 +47,22 @@ import type {
ExtractedCall,
ExtractedDecoratorRoute,
ExtractedFetchCall,
ExtractedHeritage,
ExtractedORMQuery,
ExtractedRoute,
ExtractedToolDef,
FileConstructorBindings,
} from './workers/parse-worker.js';
import type { ExtractedHeritage } from './model/heritage-map.js';
import {
processHeritage,
processHeritageFromExtracted,
extractExtractedHeritageFromFiles,
getHeritageStrategyForLanguage,
} from './heritage-processor.js';
import { computeMRO } from './mro-processor.js';
import { processCommunities } from './community-processor.js';
import { processProcesses } from './process-processor.js';
import { createResolutionContext } from './resolution-context.js';
import { createResolutionContext } from './model/resolution-context.js';
import { createASTCache } from './ast-cache.js';
import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared';
import { PipelineResult } from '../../types/pipeline.js';
@ -334,7 +335,7 @@ async function runCrossFileBindingPropagation(
// For the worker path, buildTypeEnv runs inside workers without SymbolTable,
// so exported bindings must be collected from graph + SymbolTable in main thread.
if (exportedTypeMap.size === 0 && graph.nodeCount > 0) {
const graphExports = buildExportedTypeMapFromGraph(graph, ctx.symbols);
const graphExports = buildExportedTypeMapFromGraph(graph, ctx.model.symbols);
for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports);
}
@ -361,7 +362,7 @@ async function runCrossFileBindingPropagation(
filesWithGaps++;
break;
}
const def = ctx.symbols.lookupExactFull(binding.sourcePath, binding.exportedName);
const def = ctx.model.symbols.lookupExactFull(binding.sourcePath, binding.exportedName);
if (def?.returnType) {
filesWithGaps++;
break;
@ -413,11 +414,15 @@ async function runCrossFileBindingPropagation(
}
}
const importedReturns = buildImportedReturnTypes(filePath, ctx.namedImportMap, ctx.symbols);
const importedReturns = buildImportedReturnTypes(
filePath,
ctx.namedImportMap,
ctx.model.symbols,
);
const importedRawReturns = buildImportedRawReturnTypes(
filePath,
ctx.namedImportMap,
ctx.symbols,
ctx.model.symbols,
);
if (seeded.size === 0 && importedReturns.size === 0) continue;
if (!allPathSet.has(filePath)) continue;
@ -657,7 +662,7 @@ async function runChunkedParseAndResolve(
allORMQueries: ExtractedORMQuery[];
bindingAccumulator: BindingAccumulator;
}> {
const symbolTable = ctx.symbols;
const symbolTable = ctx.model.symbols;
const parseableScanned = scannedFiles.filter((f) => {
const lang = getLanguageFromFilename(f.path);
@ -978,7 +983,9 @@ async function runChunkedParseAndResolve(
// Build unified HeritageMap (parent lookup + implementor index) after all chunks.
const fullWorkerHeritageMap =
deferredWorkerHeritage.length > 0 ? buildHeritageMap(deferredWorkerHeritage, ctx) : undefined;
deferredWorkerHeritage.length > 0
? buildHeritageMap(deferredWorkerHeritage, ctx, getHeritageStrategyForLanguage)
: undefined;
if (deferredWorkerCalls.length > 0) {
await processCallsFromExtracted(
@ -1058,7 +1065,9 @@ async function runChunkedParseAndResolve(
}
// Build unified HeritageMap from all sequential heritage (parent lookup + implementor index).
const sequentialHeritageMap =
allSequentialHeritage.length > 0 ? buildHeritageMap(allSequentialHeritage, ctx) : undefined;
allSequentialHeritage.length > 0
? buildHeritageMap(allSequentialHeritage, ctx, getHeritageStrategyForLanguage)
: undefined;
// Pass 2: Process calls, heritage edges, fetch calls, and ORM queries per chunk.
// Reuse the file contents cached in Pass 1 instead of re-reading from disk.

View file

@ -1,439 +0,0 @@
import type { NodeLabel } from 'gitnexus-shared';
export const CLASS_TYPES = new Set([
'Class',
'Struct',
'Interface',
'Enum',
'Record',
// Traits are class-like for heritage resolution: PHP `use Trait;`, Rust
// `impl Trait for Struct`, and Scala traits all contribute methods to the
// hierarchy of their using/implementing type. Including Trait here lets
// buildHeritageMap resolve `h.parentName` to a Trait nodeId so the MRO
// walker can visit the trait and find its methods.
'Trait',
]);
/** Callable symbol types indexed in callableByName for Tier 3 resolution
* and D2 widen in call-processor.ts. Single source of truth do not
* duplicate this set elsewhere. */
export const CALLABLE_TYPES = new Set([
'Function',
'Method',
'Constructor',
'Macro', // C/C++
'Delegate', // C#
]);
export interface SymbolDefinition {
nodeId: string;
filePath: string;
type: NodeLabel;
/** Canonical dot-separated qualified type name for class-like symbols
* (e.g. `App.Models.User`). Falls back to the simple symbol name when no
* package/namespace/module scope exists or no explicit qualified metadata is provided. */
qualifiedName?: string;
parameterCount?: number;
/** Number of required (non-optional, non-default) parameters.
* Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */
requiredParameterCount?: number;
/** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']).
* Populated when parameter types are resolvable from AST (any typed language).
* Used for disambiguation in overloading languages (Java, Kotlin, C#, C++). */
parameterTypes?: string[];
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
returnType?: string;
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
declaredType?: string;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
}
export interface SymbolTable {
/**
* Register a new symbol definition
*/
add: (
filePath: string,
name: string,
nodeId: string,
type: NodeLabel,
metadata?: {
parameterCount?: number;
requiredParameterCount?: number;
parameterTypes?: string[];
returnType?: string;
declaredType?: string;
ownerId?: string;
qualifiedName?: string;
},
) => void;
/**
* High Confidence: Look for a symbol specifically inside a file
* Returns the Node ID if found
*/
lookupExact: (filePath: string, name: string) => string | undefined;
/**
* High Confidence: Look for a symbol in a specific file, returning full definition.
* Includes type information needed for heritage resolution (Class vs Interface).
* Returns first matching definition use lookupExactAll for overloaded methods.
*/
lookupExactFull: (filePath: string, name: string) => SymbolDefinition | undefined;
/**
* High Confidence: Look for ALL symbols with this name in a specific file.
* Returns all definitions, including overloaded methods with the same name.
* Used by resolution-context to pass all same-file overloads to candidate filtering.
*/
lookupExactAll: (filePath: string, name: string) => SymbolDefinition[];
/**
* Look up callable symbols (Function, Method, Constructor, Macro, Delegate) by name.
* O(1) via dedicated eagerly-populated index keyed by symbol name.
* Used by Tier 3 resolution and ReturnTypeLookup to resolve callee return type.
*/
lookupCallableByName: (name: string) => SymbolDefinition[];
/**
* Look up a field/property by its owning class nodeId and field name.
* O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0fieldName`.
* Returns undefined when no matching property exists or the owner is ambiguous.
*/
lookupFieldByOwner: (ownerNodeId: string, fieldName: string) => SymbolDefinition | undefined;
/**
* Look up a method by its owning class nodeId and method name.
* O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0methodName`.
* For overloaded methods (same owner + name): returns the first match when all
* overloads share the same returnType, undefined when return types differ (ambiguous).
* Used by walkMixedChain for deterministic cross-class chain resolution.
*/
/**
* Lookup a method by owner class + name, optionally filtered by arity.
*
* When `argCount` is provided, overloads whose parameter count doesn't
* accommodate the call's argument count are filtered out before the
* returnType dedup runs. This lets D0 (`resolveMemberCall`) disambiguate
* arity-differing overloads (e.g. C++ `greet()` vs `greet(string)`) that
* would otherwise collide on the shared `ownerId + methodName` key.
*
* Same-arity, same-returnType overloads (e.g. `save(int)` vs `save(String)`,
* both returning `void`) still collapse to the first match callers must
* gate D0 on overload concern before invoking this function for that case.
*/
lookupMethodByOwner: (
ownerNodeId: string,
methodName: string,
argCount?: number,
) => SymbolDefinition | undefined;
/**
* Look up class-like definitions (Class, Struct, Interface, Enum, Record) by name.
* O(1) via dedicated eagerly-populated index keyed by symbol name.
* Returns all matching definitions across files (e.g. partial classes).
* Used by Phase 1 semantic-model tasks to replace filtered global lookups.
*/
lookupClassByName: (name: string) => SymbolDefinition[];
/**
* Look up class-like definitions by canonical qualified name.
* Qualified names are normalized to dot-separated scope segments across languages,
* e.g. `App.Models.User`, `com.example.User`, or `Admin.User`.
* Top-level class-like symbols with no explicit scope are indexed under their simple name.
*/
lookupClassByQualifiedName: (qualifiedName: string) => SymbolDefinition[];
/**
* Look up Impl nodes by name.
* O(1) via dedicated eagerly-populated index keyed by symbol name.
* Used by Tier 3 resolution to include Rust impl blocks alongside
* class-like candidates so method lookups on `impl User { fn save() }` work
* correctly (Rust methods are indexed under the Impl nodeId, not the Struct).
*/
lookupImplByName: (name: string) => SymbolDefinition[];
/**
* Iterate all indexed file paths.
* Used by Tier 2b (package-scoped) resolution to walk files matching a
* package directory suffix without a global name scan.
*/
getFiles: () => IterableIterator<string>;
/**
* Debugging: See how many symbols are tracked
*/
getStats: () => {
fileCount: number;
};
/**
* Cleanup memory
*/
clear: () => void;
}
export const createSymbolTable = (): SymbolTable => {
// 1. File-Specific Index — stores full SymbolDefinition(s) for O(1) lookup.
// Structure: FilePath -> (SymbolName -> SymbolDefinition[])
// Array allows overloaded methods (same name, different signatures) to coexist.
const fileIndex = new Map<string, Map<string, SymbolDefinition[]>>();
// 2. Eagerly-populated Callable Index — maintained on add().
// Structure: SymbolName -> [Callable Definitions]
// Only Function, Method, Constructor, Macro, Delegate symbols are indexed.
const callableByName = new Map<string, SymbolDefinition[]>();
// 3. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName".
// Only Property symbols with ownerId and declaredType are indexed.
const fieldByOwner = new Map<string, SymbolDefinition>();
// 4. Eagerly-populated Method Index — keyed by "ownerNodeId\0methodName".
// Method symbols with ownerId are indexed. Supports overloads (array values).
const methodByOwner = new Map<string, SymbolDefinition[]>();
// 5. Eagerly-populated Class-type Index — keyed by symbol name.
// Only Class, Struct, Interface, Enum, Record symbols are indexed.
const classByName = new Map<string, SymbolDefinition[]>();
const classByQualifiedName = new Map<string, SymbolDefinition[]>();
// 6. Eagerly-populated Impl Index — keyed by symbol name.
// Rust impl blocks (type 'Impl') are stored here to keep them out of
// classByName (which drives heritage resolution) while still being
// reachable from Tier 3 resolution for method lookup.
const implByName = new Map<string, SymbolDefinition[]>();
// Use the module-level CALLABLE_TYPES constant (exported for call-processor.ts).
const add = (
filePath: string,
name: string,
nodeId: string,
type: NodeLabel,
metadata?: {
parameterCount?: number;
requiredParameterCount?: number;
parameterTypes?: string[];
returnType?: string;
declaredType?: string;
ownerId?: string;
qualifiedName?: string;
},
) => {
const qualifiedName = CLASS_TYPES.has(type)
? (metadata?.qualifiedName ?? name)
: metadata?.qualifiedName;
const def: SymbolDefinition = {
nodeId,
filePath,
type,
...(qualifiedName !== undefined ? { qualifiedName } : {}),
...(metadata?.parameterCount !== undefined
? { parameterCount: metadata.parameterCount }
: {}),
...(metadata?.requiredParameterCount !== undefined
? { requiredParameterCount: metadata.requiredParameterCount }
: {}),
...(metadata?.parameterTypes !== undefined
? { parameterTypes: metadata.parameterTypes }
: {}),
...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}),
...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}),
...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}),
};
// A. Add to File Index (shared reference — zero additional memory)
if (!fileIndex.has(filePath)) {
fileIndex.set(filePath, new Map());
}
const fileMap = fileIndex.get(filePath)!;
if (!fileMap.has(name)) {
fileMap.set(name, [def]);
} else {
fileMap.get(name)!.push(def);
}
// B. Properties go to fieldByOwner index only — skip other indexes to prevent
// namespace pollution for common names like 'id', 'name', 'type'.
// Index ALL properties (even without declaredType) so write-access tracking
// can resolve field ownership for dynamically-typed languages (Ruby, JS).
if (type === 'Property' && metadata?.ownerId) {
fieldByOwner.set(`${metadata.ownerId}\0${name}`, def);
// Still add to fileIndex above (for lookupExact), but skip other indexes
return;
}
// C. Methods, constructors, and ownerId-bound Functions go to
// methodByOwner index.
//
// Some language extractors emit class methods as `Function` with an
// `ownerId` — notably Python (`def method(self):` inside a class body),
// Rust trait methods, and Kotlin object/companion methods. Treating
// `Function` with ownerId the same as `Method` here makes D0
// (`resolveMemberCall`) work uniformly across all supported languages
// instead of silently falling through to D1-D4 widening.
if ((type === 'Method' || type === 'Constructor' || type === 'Function') && metadata?.ownerId) {
const key = `${metadata.ownerId}\0${name}`;
const existing = methodByOwner.get(key);
if (existing) {
existing.push(def);
} else {
methodByOwner.set(key, [def]);
}
}
// C2. Class-like types go to classByName index.
if (CLASS_TYPES.has(type)) {
const existing = classByName.get(name);
if (existing) {
existing.push(def);
} else {
classByName.set(name, [def]);
}
const qualifiedKey = qualifiedName ?? name;
const qualifiedMatches = classByQualifiedName.get(qualifiedKey);
if (qualifiedMatches) {
qualifiedMatches.push(def);
} else {
classByQualifiedName.set(qualifiedKey, [def]);
}
}
// C3. Rust Impl blocks go to implByName (separate from classByName to avoid
// polluting heritage resolution with Impl nodes as parent candidates).
if (type === 'Impl') {
const existing = implByName.get(name);
if (existing) {
existing.push(def);
} else {
implByName.set(name, [def]);
}
}
// D. Eagerly maintain callable index (like classByName, implByName).
if (CALLABLE_TYPES.has(type)) {
const existing = callableByName.get(name);
if (existing) {
existing.push(def);
} else {
callableByName.set(name, [def]);
}
}
};
const lookupExact = (filePath: string, name: string): string | undefined => {
const defs = fileIndex.get(filePath)?.get(name);
return defs?.[0]?.nodeId;
};
const lookupExactFull = (filePath: string, name: string): SymbolDefinition | undefined => {
const defs = fileIndex.get(filePath)?.get(name);
return defs?.[0];
};
const lookupExactAll = (filePath: string, name: string): SymbolDefinition[] => {
return fileIndex.get(filePath)?.get(name) ?? [];
};
const lookupCallableByName = (name: string): SymbolDefinition[] => {
return callableByName.get(name) ?? [];
};
const lookupFieldByOwner = (
ownerNodeId: string,
fieldName: string,
): SymbolDefinition | undefined => {
return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`);
};
const lookupMethodByOwner = (
ownerNodeId: string,
methodName: string,
argCount?: number,
): SymbolDefinition | undefined => {
const defs = methodByOwner.get(`${ownerNodeId}\0${methodName}`);
if (!defs || defs.length === 0) return undefined;
// Arity narrowing: when an argCount is provided and there are multiple
// overloads, keep only those whose parameterCount can accommodate the
// call. This resolves arity-differing overloads (e.g. C++ `greet()` vs
// `greet(string)`) that share the same `ownerId + methodName` key.
//
// Candidates with `parameterCount === undefined` (extractor didn't
// populate the count — typically variadic or unknown) are retained
// conservatively so that legitimate variadic matches still resolve.
let pool = defs;
if (argCount !== undefined && defs.length > 1) {
const arityMatched = defs.filter((d) => {
if (d.parameterCount === undefined) return true;
const min = d.requiredParameterCount ?? d.parameterCount;
return argCount >= min && argCount <= d.parameterCount;
});
// Only adopt the arity-narrowed pool when it found matches; if arity
// rules out every candidate, fall back to the unfiltered set so the
// caller's fuzzy path still has something to work with.
if (arityMatched.length > 0) pool = arityMatched;
}
if (pool.length === 1) return pool[0];
// Multiple overloads after arity narrowing: return first if all share
// the same defined returnType (safe for chain resolution), undefined if
// return types differ (truly ambiguous — can't determine which overload).
const firstReturnType = pool[0].returnType;
if (firstReturnType === undefined) return undefined;
for (let i = 1; i < pool.length; i++) {
if (pool[i].returnType !== firstReturnType) return undefined;
}
return pool[0];
};
const lookupClassByName = (name: string): SymbolDefinition[] => {
return classByName.get(name) ?? [];
};
const lookupClassByQualifiedName = (qualifiedName: string): SymbolDefinition[] => {
return classByQualifiedName.get(qualifiedName) ?? [];
};
const lookupImplByName = (name: string): SymbolDefinition[] => {
return implByName.get(name) ?? [];
};
/** Returns a live iterator over all indexed file paths (fileIndex.keys()).
* The iterator is invalidated if add() changes fileIndex.size during
* iteration (ES2015 Map spec). Safe in the current pipeline because all
* symbols are added before resolution begins. */
const getFiles = (): IterableIterator<string> => fileIndex.keys();
const getStats = () => ({
fileCount: fileIndex.size,
});
const clear = () => {
fileIndex.clear();
callableByName.clear();
fieldByOwner.clear();
methodByOwner.clear();
classByName.clear();
classByQualifiedName.clear();
implByName.clear();
};
return {
add,
lookupExact,
lookupExactFull,
lookupExactAll,
lookupCallableByName,
lookupFieldByOwner,
lookupMethodByOwner,
lookupClassByName,
lookupClassByQualifiedName,
lookupImplByName,
getFiles,
getStats,
clear,
};
};

View file

@ -75,6 +75,23 @@ export const TYPESCRIPT_QUERIES = `
function: (member_expression
property: (property_identifier) @call.name)) @call
; Generic awaited free call: await fn<T>(args)
; tree-sitter-typescript parses "await fn<T>(args)" as a call_expression whose
; "function" field is an await_expression (not a bare identifier), because the
; grammar resolves the ambiguity between generics and comparisons by consuming
; "await fn" as an expression before attaching <T> as type_arguments.
(call_expression
function: (await_expression
(identifier) @call.name)
(type_arguments)) @call
; Generic awaited member call: await obj.fn<T>(args)
(call_expression
function: (await_expression
(member_expression
property: (property_identifier) @call.name))
(type_arguments)) @call
; Constructor calls: new Foo()
(new_expression
constructor: (identifier) @call.name) @call
@ -623,6 +640,14 @@ export const CSHARP_QUERIES = `
(class_declaration name: (identifier) @heritage.class
(base_list (generic_name (identifier) @heritage.extends))) @heritage
; Interface inheritance: interface IFoo : IBar / interface IFoo : IBar, IBaz
; Without these patterns, interface-to-interface relationships are never
; captured, so transitive "class X implements IBar" chains are broken.
(interface_declaration name: (identifier) @heritage.class
(base_list (identifier) @heritage.extends)) @heritage
(interface_declaration name: (identifier) @heritage.class
(base_list (generic_name (identifier) @heritage.extends))) @heritage
; Write access: obj.field = value
(assignment_expression
left: (member_access_expression
@ -1133,6 +1158,58 @@ export const DART_QUERIES = `
(identifier) @call.name))
(selector (argument_part))) @call
; Calls: await direct (await doSomething())
(await_expression
(identifier) @call.name
.
(selector (argument_part))) @call
; Calls: await method chain (await obj.method())
; Requires argument_part to distinguish method calls from field access (await obj.field)
(await_expression
(selector
(unconditional_assignable_selector
(identifier) @call.name))
(selector (argument_part))) @call
; Calls: named argument (foo(child: buildX()))
(named_argument
(identifier) @call.name
.
(selector (argument_part))) @call
; Calls: inside list literals ([buildA(), buildB()])
(list_literal
(identifier) @call.name
.
(selector (argument_part))) @call
; Calls: cascade (obj..add(x)..sort())
; Note: cascade_selector contains identifier directly (no unconditional_assignable_selector
; wrapper in Dart grammar), so inferCallForm() classifies these as free calls rather than
; member calls. Cross-file resolution still benefits from the call being recorded.
(cascade_section
(cascade_selector (identifier) @call.name)
(argument_part)) @call
; Calls: static final field initializers (static final _svc = MyService())
(static_final_declaration
(identifier) @call.name
.
(selector (argument_part))) @call
; Calls: arrow function body (=> buildWidget())
(function_body "=>"
(identifier) @call.name
.
(selector (argument_part))) @call
; Calls: lambda body (() => doSomething())
(function_expression_body
(identifier) @call.name
.
(selector (argument_part))) @call
; Re-exports (export 'foo.dart')
(import_or_export
(library_export

View file

@ -21,7 +21,7 @@ import {
stripNullable,
extractReturnTypeName,
} from './type-extractors/shared.js';
import type { SymbolTable } from './symbol-table.js';
import type { SemanticModel } from './model/semantic-model.js';
import type { NodeLabel } from 'gitnexus-shared';
/**
@ -416,11 +416,8 @@ const findEnclosingScopeKey = (
* Only `.has()` is exposed the SymbolTable doesn't support iteration.
* Results are memoized to avoid redundant class-index scans across declarations.
*/
const createClassNameLookup = (
localNames: Set<string>,
symbolTable?: SymbolTable,
): ClassNameLookup => {
if (!symbolTable) return localNames;
const createClassNameLookup = (localNames: Set<string>, model?: SemanticModel): ClassNameLookup => {
if (!model) return localNames;
const memo = new Map<string, boolean>();
return {
@ -428,7 +425,7 @@ const createClassNameLookup = (
if (localNames.has(name)) return true;
const cached = memo.get(name);
if (cached !== undefined) return cached;
const result = symbolTable
const result = model.types
.lookupClassByName(name)
.some((def) => def.type === 'Class' || def.type === 'Enum' || def.type === 'Struct');
memo.set(name, result);
@ -481,20 +478,20 @@ const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface']);
type ClassDefRef = { nodeId: string; type: string; filePath: string };
const lookupClassDefsByName = (
symbolTable: SymbolTable,
model: SemanticModel,
name: string,
allowedTypes: ReadonlySet<string> = CLASS_LIKE_TYPES,
): ClassDefRef[] => symbolTable.lookupClassByName(name).filter((d) => allowedTypes.has(d.type));
): ClassDefRef[] => model.types.lookupClassByName(name).filter((d) => allowedTypes.has(d.type));
/** Memoize class definition lookups during fixpoint iteration.
* SymbolTable is immutable during type resolution, so results never change.
* Eliminates redundant array allocations + filter scans across iterations. */
const createClassDefCache = (symbolTable?: SymbolTable) => {
const createClassDefCache = (model?: SemanticModel) => {
const cache = new Map<string, ClassDefRef[]>();
return (typeName: string) => {
let result = cache.get(typeName);
if (result === undefined) {
result = symbolTable ? lookupClassDefsByName(symbolTable, typeName) : [];
result = model ? lookupClassDefsByName(model, typeName) : [];
cache.set(typeName, result);
}
return result;
@ -615,22 +612,22 @@ const resolveFieldType = (
receiver: string,
field: string,
scopeEnv: ReadonlyMap<string, string>,
symbolTable?: SymbolTable,
model?: SemanticModel,
getClassDefs?: (typeName: string) => ClassDefRef[],
parentMap?: ReadonlyMap<string, readonly string[]>,
): string | undefined => {
if (!symbolTable) return undefined;
if (!model) return undefined;
const receiverType = scopeEnv.get(receiver);
if (!receiverType) return undefined;
const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name));
const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(model, name));
const classDefs = lookup(receiverType);
if (classDefs.length !== 1) return undefined;
// Direct lookup first
const fieldDef = symbolTable.lookupFieldByOwner(classDefs[0].nodeId, field);
const fieldDef = model.fields.lookupFieldByOwner(classDefs[0].nodeId, field);
if (fieldDef?.declaredType) return extractReturnTypeName(fieldDef.declaredType);
// MRO parent chain walking on miss
const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => {
const f = symbolTable.lookupFieldByOwner(nodeId, field);
const f = model.fields.lookupFieldByOwner(nodeId, field);
return f?.declaredType ? extractReturnTypeName(f.declaredType) : undefined;
});
return inherited;
@ -644,30 +641,30 @@ const resolveMethodReturnType = (
receiver: string,
method: string,
scopeEnv: ReadonlyMap<string, string>,
symbolTable?: SymbolTable,
model?: SemanticModel,
getClassDefs?: (typeName: string) => ClassDefRef[],
parentMap?: ReadonlyMap<string, readonly string[]>,
): string | undefined => {
if (!symbolTable) return undefined;
if (!model) return undefined;
let receiverType = scopeEnv.get(receiver);
// When substituteThisReceiver replaced $this/self with the enclosing class name,
// the receiver IS the type — look it up directly as a class name.
if (!receiverType) {
const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name));
const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(model, name));
if (lookup(receiver).length > 0) receiverType = receiver;
}
if (!receiverType) return undefined;
const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name));
const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(model, name));
const classDefs = lookup(receiverType);
if (classDefs.length === 0) return undefined;
// Direct lookup first
const directMethodLookups = classDefs.map((d) => ({
classDef: d,
methodDef: symbolTable.lookupMethodByOwner(d.nodeId, method),
methodDef: model.methods.lookupMethodByOwner(d.nodeId, method),
}));
const hasAmbiguousDirectLookup = directMethodLookups.some(({ classDef, methodDef }) => {
if (methodDef) return false;
return symbolTable
return model.symbols
.lookupExactAll(classDef.filePath, method)
.some((d) => d.ownerId === classDef.nodeId);
});
@ -681,7 +678,7 @@ const resolveMethodReturnType = (
// MRO parent chain walking on miss
if (methods.length === 0) {
const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => {
const parentMethod = symbolTable.lookupMethodByOwner(nodeId, method);
const parentMethod = model.methods.lookupMethodByOwner(nodeId, method);
if (!parentMethod?.returnType) return undefined;
return extractReturnTypeName(parentMethod.returnType);
});
@ -707,11 +704,11 @@ const resolveFixpointBindings = (
pendingItems: Array<{ scope: string } & PendingAssignment>,
env: TypeEnv,
returnTypeLookup: ReturnTypeLookup,
symbolTable?: SymbolTable,
model?: SemanticModel,
parentMap?: ReadonlyMap<string, readonly string[]>,
): void => {
if (pendingItems.length === 0) return;
const getClassDefs = createClassDefCache(symbolTable);
const getClassDefs = createClassDefCache(model);
const resolved = new Set<number>();
for (let iter = 0; iter < MAX_FIXPOINT_ITERATIONS; iter++) {
let changed = false;
@ -740,7 +737,7 @@ const resolveFixpointBindings = (
item.receiver,
item.field,
scopeEnv,
symbolTable,
model,
getClassDefs,
parentMap,
);
@ -750,7 +747,7 @@ const resolveFixpointBindings = (
item.receiver,
item.method,
scopeEnv,
symbolTable,
model,
getClassDefs,
parentMap,
);
@ -785,7 +782,7 @@ const resolveFixpointBindings = (
* Uses an options object to allow future extensions without positional parameter sprawl.
*/
export interface BuildTypeEnvOptions {
symbolTable?: SymbolTable;
model?: SemanticModel;
parentMap?: ReadonlyMap<string, readonly string[]>;
/** Pre-resolved bindings from upstream files (Phase 14).
* Seeded into FILE_SCOPE after walk() for names with no local binding.
@ -837,7 +834,7 @@ export const buildTypeEnv = (
enclosingClassNameCache.clear();
enclosingParentClassNameCache.clear();
const symbolTable = options?.symbolTable;
const model = options?.model;
const parentMap = options?.parentMap;
const extractFuncNameHook = options?.extractFunctionName;
const env: TypeEnv = new Map();
@ -848,7 +845,7 @@ export const buildTypeEnv = (
// e.g., `Animal a = new Dog()` → constructorTypeMap.set('func@42\0a', 'Dog')
const constructorTypeMap = new Map<string, string>();
const localClassNames = new Set<string>();
const classNames = createClassNameLookup(localClassNames, symbolTable);
const classNames = createClassNameLookup(localClassNames, model);
const provider = getProvider(language);
const config = provider.typeConfig;
const bindings: ConstructorBinding[] = [];
@ -856,29 +853,47 @@ export const buildTypeEnv = (
// Build ReturnTypeLookup: SymbolTable is authoritative when it has an unambiguous match.
// Cross-file importedReturnTypes are consulted ONLY when SymbolTable has 0 matches.
// Ambiguous (2+) → undefined, no cross-file fallback (conservative, local-first principle).
// Post-A4 Unit 4: callableByName no longer holds Method/Constructor, so
// for-loop binding inference must also consult methodsByName to find
// return types on class methods (e.g. `user.getItems()` iteration).
// Take `model` as an explicit argument so the non-null precondition
// is visible at the type level. Callers must enter these via an
// `if (model)` guard on their side and pass the narrowed reference.
const getCallableUnionCount = (m: SemanticModel, callee: string): number => {
return (
m.symbols.lookupCallableByName(callee).length + m.methods.lookupMethodByName(callee).length
);
};
const getFirstCallable = (m: SemanticModel, callee: string) => {
const free = m.symbols.lookupCallableByName(callee);
if (free.length > 0) return free[0];
const methods = m.methods.lookupMethodByName(callee);
return methods.length > 0 ? methods[0] : undefined;
};
const returnTypeLookup: ReturnTypeLookup = {
lookupReturnType(callee: string): string | undefined {
// SymbolTable is authoritative when it has an unambiguous match
if (symbolTable) {
if (model) {
if (provider.isBuiltInName(callee)) return undefined;
const callables = symbolTable.lookupCallableByName(callee);
if (callables.length === 1) {
const rawReturn = callables[0].returnType;
const count = getCallableUnionCount(model, callee);
if (count === 1) {
const rawReturn = getFirstCallable(model, callee)?.returnType;
if (rawReturn) return extractReturnTypeName(rawReturn);
}
// Ambiguous (2+) → return undefined (conservative, no cross-file fallback)
if (callables.length > 1) return undefined;
if (count > 1) return undefined;
}
// No match (0 results or no symbolTable) → fall back to cross-file
return options?.importedReturnTypes?.get(callee);
},
lookupRawReturnType(callee: string): string | undefined {
if (symbolTable) {
if (model) {
if (provider.isBuiltInName(callee)) return undefined;
const callables = symbolTable.lookupCallableByName(callee);
if (callables.length === 1) return callables[0].returnType;
const count = getCallableUnionCount(model, callee);
if (count === 1) return getFirstCallable(model, callee)?.returnType;
// Ambiguous (2+) → return undefined (conservative, no cross-file fallback)
if (callables.length > 1) return undefined;
if (count > 1) return undefined;
}
// Cross-file fallback uses importedRawReturnTypes (raw declared types, e.g., 'User[]')
// NOT importedReturnTypes (which contains processed/simple types via extractReturnTypeName)
@ -1088,7 +1103,11 @@ export const buildTypeEnv = (
}
};
const walk = (node: SyntaxNode, currentScope: string): void => {
const stack: Array<{ node: SyntaxNode; scope: string }> = [
{ node: tree.rootNode, scope: FILE_SCOPE },
];
const processNode = (node: SyntaxNode, currentScope: string): void => {
// Fast skip: subtrees that can never contain type-relevant nodes (leaf-like literals).
if (SKIP_SUBTREE_TYPES.has(node.type)) return;
@ -1205,14 +1224,19 @@ export const buildTypeEnv = (
}
}
// Recurse into children
for (let i = 0; i < node.childCount; i++) {
// Push children onto stack (reverse order so first child is processed first)
for (let i = node.childCount - 1; i >= 0; i--) {
const child = node.child(i);
if (child) walk(child, scope);
if (child) stack.push({ node: child, scope });
}
};
walk(tree.rootNode, FILE_SCOPE);
// Iterative traversal using explicit stack instead of recursion
// to avoid "Maximum call stack size exceeded" on large files (2000+ lines)
while (stack.length > 0) {
const { node, scope } = stack.pop()!;
processNode(node, scope);
}
// Phase 14: Seed cross-file bindings from upstream files AFTER walk
// (local declarations from walk() take precedence — first-writer-wins)
@ -1220,7 +1244,7 @@ export const buildTypeEnv = (
seedImportedBindings(env, options.importedBindings);
}
resolveFixpointBindings(pendingItems, env, returnTypeLookup, symbolTable, parentMap);
resolveFixpointBindings(pendingItems, env, returnTypeLookup, model, parentMap);
// Post-fixpoint for-loop replay (Phase 10 / ex-9B loop-fixpoint bridge):
// For-loop nodes whose iterables were unresolved at walk-time may now be
@ -1247,7 +1271,7 @@ export const buildTypeEnv = (
return scopeEnv && !scopeEnv.has(item.lhs);
});
if (unresolvedBefore.length > 0) {
resolveFixpointBindings(unresolvedBefore, env, returnTypeLookup, symbolTable);
resolveFixpointBindings(unresolvedBefore, env, returnTypeLookup, model);
}
}

View file

@ -412,11 +412,16 @@ export const CALL_ARGUMENT_LIST_TYPES = new Set(['arguments', 'argument_list', '
// ============================================================================
/** Walk an AST node depth-first, returning the first descendant with the given type. */
export function findDescendant(node: SyntaxNode, type: string): SyntaxNode | null {
if (node.type === type) return node;
for (const child of node.children ?? []) {
const found = findDescendant(child, type);
if (found) return found;
export function findDescendant(root: SyntaxNode, type: string): SyntaxNode | null {
const stack: SyntaxNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.type === type) return node;
// Push in reverse order so left children are visited first (depth-first)
const children = node.children ?? [];
for (let i = children.length - 1; i >= 0; i--) {
stack.push(children[i]);
}
}
return null;
}

View file

@ -15,7 +15,8 @@ import { createRequire } from 'node:module';
import { SupportedLanguages } from 'gitnexus-shared';
import { getProvider } from '../languages/index.js';
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from '../constants.js';
import type { SymbolTable } from '../symbol-table.js';
import type { SymbolTableReader } from '../model/symbol-table.js';
import type { ExtractedHeritage } from '../model/heritage-map.js';
/** Language grammar type accepted by Parser.setLanguage(). */
type TreeSitterLanguage = Parameters<typeof Parser.prototype.setLanguage>[0];
@ -181,13 +182,8 @@ export interface ExtractedAssignment {
receiverTypeName?: string;
}
export interface ExtractedHeritage {
filePath: string;
className: string;
parentName: string;
/** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */
kind: string;
}
// `ExtractedHeritage` now lives in `../model/heritage-map.ts` and is
// re-exported at the top of this file.
export interface ExtractedRoute {
filePath: string;
@ -459,14 +455,20 @@ function findClassNodeByQualifiedName(node: SyntaxNode): SyntaxNode | null {
/**
* Minimal no-op SymbolTable stub for FieldExtractorContext in the worker.
* Field extraction only uses symbolTable.lookupExactAll for optional type resolution
* returning [] causes the extractor to use the raw type string, which is fine for us.
* Field extraction only uses symbolTable.lookupExactAll for optional type
* resolution returning [] causes the extractor to use the raw type
* string, which is fine for us. Every other method is a no-op so the
* stub remains safe if a future FieldExtractor consults it through the
* full {@link SymbolTableReader} surface.
*/
const NOOP_SYMBOL_TABLE = {
lookupExactAll: () => [],
const NOOP_SYMBOL_TABLE: SymbolTableReader = {
lookupExact: () => undefined,
lookupExactFull: () => undefined,
} as unknown as SymbolTable;
lookupExactAll: () => [],
lookupCallableByName: () => [],
getFiles: () => [][Symbol.iterator](),
getStats: () => ({ fileCount: 0 }),
};
/**
* Get (or extract and cache) field info for a class node.
@ -853,6 +855,11 @@ const HTTP_CLIENT_RECEIVERS = new Set([
'apiclient',
'client',
'httpclient',
'api',
'$http',
'session',
'httpservice',
'conn',
]);
// Decorator names that indicate HTTP route handlers (NestJS, Flask, FastAPI, Spring)
@ -1588,7 +1595,35 @@ const processFileGroup = (
// as Express route registrations.
const callNode = captureMap['express_route'];
const funcNode = callNode.childForFieldName?.('function') ?? callNode.children?.[0];
const receiverNode = funcNode?.childForFieldName?.('object') ?? funcNode?.children?.[0];
// Walk through nested member_expressions and call_expressions to
// reach the innermost receiver identifier. Handles chains like:
// this.httpService.get('/path') -> member chain -> 'httpservice'
// getClient().get('/path') -> call_expression -> 'getclient'
// axios.get('/path') -> bare identifier -> 'axios'
let receiverNode = funcNode?.childForFieldName?.('object') ?? funcNode?.children?.[0];
while (
receiverNode?.type === 'member_expression' ||
receiverNode?.type === 'call_expression'
) {
if (receiverNode.type === 'member_expression') {
// Drill into the property (rightmost part) of the member expression
const propNode = receiverNode.childForFieldName?.('property');
if (propNode) {
receiverNode = propNode;
} else {
break;
}
} else {
// call_expression: unwrap to the function being called
const innerFunc =
receiverNode.childForFieldName?.('function') ?? receiverNode.children?.[0];
if (innerFunc && innerFunc !== receiverNode) {
receiverNode = innerFunc;
} else {
break;
}
}
}
const receiverText = receiverNode?.text?.toLowerCase() ?? '';
if (HTTP_CLIENT_RECEIVERS.has(receiverText)) {
@ -1998,6 +2033,22 @@ const processFileGroup = (
? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300))
: null;
// Suppress Spring framework hint for methods inside interfaces
// (Feign clients, JAX-RS proxies are consumers, not providers)
if (frameworkHint && definitionNode) {
let classCheck = definitionNode.parent;
while (classCheck) {
if (classCheck.type === 'interface_declaration') {
frameworkHint = null;
break;
}
if (classCheck.type === 'class_declaration' || classCheck.type === 'program') {
break;
}
classCheck = classCheck.parent;
}
}
// Decorators appear on lines immediately before their definition; allow up to
// MAX_DECORATOR_SCAN_LINES gap for blank lines / multi-line decorator stacks.
const MAX_DECORATOR_SCAN_LINES = 5;

View file

@ -17,6 +17,7 @@ let db: lbug.Database | null = null;
let conn: lbug.Connection | null = null;
let currentDbPath: string | null = null;
let ftsLoaded = false;
let vectorExtensionLoaded = false;
/** Expose the current Database for pool adapter reuse in tests. */
export const getDatabase = (): lbug.Database | null => db;
@ -104,6 +105,7 @@ export const withLbugDb = async <T>(dbPath: string, operation: () => Promise<T>)
db = null;
currentDbPath = null;
ftsLoaded = false;
vectorExtensionLoaded = false;
});
// Sleep outside the lock — no need to block others while waiting
await new Promise((resolve) => setTimeout(resolve, DB_LOCK_RETRY_DELAY_MS * attempt));
@ -135,6 +137,7 @@ const doInitLbug = async (dbPath: string) => {
db = null;
currentDbPath = null;
ftsLoaded = false;
vectorExtensionLoaded = false;
}
// LadybugDB stores the database as a single file (not a directory).
@ -182,6 +185,9 @@ const doInitLbug = async (dbPath: string) => {
}
}
// Load VECTOR extension for semantic search support
await loadVectorExtension();
currentDbPath = dbPath;
return { db, conn };
};
@ -807,6 +813,7 @@ export const closeLbug = async (): Promise<void> => {
}
currentDbPath = null;
ftsLoaded = false;
vectorExtensionLoaded = false;
};
export const isLbugReady = (): boolean => conn !== null && db !== null;
@ -909,9 +916,42 @@ export const loadFTSExtension = async (): Promise<void> => {
throw new Error('LadybugDB not initialized. Call initLbug first.');
}
try {
await conn.query('INSTALL fts');
// Try loading locally first (no network required)
await conn.query('LOAD EXTENSION fts');
ftsLoaded = true;
} catch {
// Fall back to install + load (requires network)
try {
await conn.query('INSTALL fts');
await conn.query('LOAD EXTENSION fts');
ftsLoaded = true;
} catch (err: any) {
const msg = err?.message || '';
if (
msg.includes('already loaded') ||
msg.includes('already installed') ||
msg.includes('already exists')
) {
ftsLoaded = true;
} else {
console.error('GitNexus: FTS extension load failed:', msg);
}
}
}
};
/**
* Load the VECTOR extension (required before using QUERY_VECTOR_INDEX).
* Safe to call multiple times -- tracks loaded state via module-level vectorExtensionLoaded.
*/
export const loadVectorExtension = async (): Promise<void> => {
if (vectorExtensionLoaded) return;
if (!conn) {
throw new Error('LadybugDB not initialized. Call initLbug first.');
}
try {
await conn.query('INSTALL VECTOR');
await conn.query('LOAD EXTENSION VECTOR');
vectorExtensionLoaded = true;
} catch (err: any) {
const msg = err?.message || '';
if (
@ -919,13 +959,12 @@ export const loadFTSExtension = async (): Promise<void> => {
msg.includes('already installed') ||
msg.includes('already exists')
) {
ftsLoaded = true;
vectorExtensionLoaded = true;
} else {
console.error('GitNexus: FTS extension load failed:', msg);
console.error('GitNexus: VECTOR extension load failed:', msg);
}
}
};
/**
* Create a full-text search index on a table
* @param tableName - The node table name (e.g., 'File', 'CodeSymbol')

View file

@ -44,6 +44,7 @@ interface SharedDB {
db: lbug.Database;
refCount: number;
ftsLoaded: boolean;
vectorLoaded: boolean;
/** When true, closeOne skips db.close() — the Database is owned externally. */
external?: boolean;
}
@ -148,6 +149,8 @@ function closeOne(repoId: string): void {
// or remove from cache. Keep the entry so future initLbug() calls
// for the same dbPath reuse it instead of hitting a file lock.
shared.refCount = 0;
shared.ftsLoaded = false;
shared.vectorLoaded = false;
} else {
shared.db.close().catch(() => {});
dbCache.delete(entry.dbPath);
@ -276,7 +279,7 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
true, // readOnly
);
restoreStdout();
shared = { db, refCount: 0, ftsLoaded: false };
shared = { db, refCount: 0, ftsLoaded: false, vectorLoaded: false };
dbCache.set(dbPath, shared);
break;
} catch (err: any) {
@ -325,6 +328,17 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
}
}
// Load VECTOR extension once per shared Database for semantic search support.
if (!shared.vectorLoaded) {
try {
await available[0].query('INSTALL VECTOR');
await available[0].query('LOAD EXTENSION VECTOR');
shared.vectorLoaded = true;
} catch {
// VECTOR extension may not be available
}
}
// Register pool entry only after all connections are pre-warmed and FTS is
// loaded. Concurrent executeQuery calls see either "not initialized"
// (and throw cleanly) or a fully ready pool — never a half-built one.
@ -368,7 +382,7 @@ export async function initLbugWithDb(
// closeOne() respects the external flag and skips db.close().
let shared = dbCache.get(dbPath);
if (!shared) {
shared = { db: existingDb, refCount: 0, ftsLoaded: false, external: true };
shared = { db: existingDb, refCount: 0, ftsLoaded: false, vectorLoaded: false, external: true };
dbCache.set(dbPath, shared);
}
shared.refCount++;
@ -384,10 +398,24 @@ export async function initLbugWithDb(
}
// Load FTS extension if not already loaded on this Database
try {
await available[0].query('LOAD EXTENSION fts');
} catch {
// Extension may already be loaded or not installed
if (!shared.ftsLoaded) {
try {
await available[0].query('LOAD EXTENSION fts');
shared.ftsLoaded = true;
} catch {
// Extension may already be loaded or not installed
}
}
// Load VECTOR extension for semantic search support
if (!shared.vectorLoaded) {
try {
await available[0].query('INSTALL VECTOR');
await available[0].query('LOAD EXTENSION VECTOR');
shared.vectorLoaded = true;
} catch {
// VECTOR extension may not be available
}
}
pool.set(repoId, {

View file

@ -48,6 +48,8 @@ export interface AnalyzeOptions {
skipGit?: boolean;
/** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */
skipAgentsMd?: boolean;
/** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
noStats?: boolean;
}
export interface AnalyzeResult {
@ -327,7 +329,7 @@ export async function runFullAnalysis(
processes: pipelineResult.processResult?.stats.totalProcesses,
},
undefined,
{ skipAgentsMd: options.skipAgentsMd },
{ skipAgentsMd: options.skipAgentsMd, noStats: options.noStats },
);
} catch {
// Best-effort — don't fail the entire analysis for context file issues

View file

@ -21,6 +21,7 @@ export { isWriteQuery };
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
// git utilities available if needed
// import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
import { parseDiffHunks, type FileDiff } from '../../storage/git.js';
import {
listRegisteredRepos,
cleanupOldKuzuFiles,
@ -1528,33 +1529,31 @@ export class LocalBackend {
let diffArgs: string[];
switch (scope) {
case 'staged':
diffArgs = ['diff', '--staged', '--name-only'];
diffArgs = ['diff', '--staged', '-U0'];
break;
case 'all':
diffArgs = ['diff', 'HEAD', '--name-only'];
diffArgs = ['diff', 'HEAD', '-U0'];
break;
case 'compare':
if (!params.base_ref) return { error: 'base_ref is required for "compare" scope' };
diffArgs = ['diff', params.base_ref, '--name-only'];
diffArgs = ['diff', params.base_ref, '-U0'];
break;
case 'unstaged':
default:
diffArgs = ['diff', '--name-only'];
diffArgs = ['diff', '-U0'];
break;
}
let changedFiles: string[];
let diffOutput: string;
try {
const output = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' });
changedFiles = output
.trim()
.split('\n')
.filter((f) => f.length > 0);
diffOutput = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' });
} catch (err: any) {
return { error: `Git diff failed: ${err.message}` };
}
if (changedFiles.length === 0) {
const fileDiffs: FileDiff[] = parseDiffHunks(diffOutput);
if (fileDiffs.length === 0) {
return {
summary: {
changed_count: 0,
@ -1567,27 +1566,39 @@ export class LocalBackend {
};
}
// Map changed files to indexed symbols
// Map diff hunks to indexed symbols via range overlap
const changedSymbols: any[] = [];
for (const file of changedFiles) {
const normalizedFile = file.replace(/\\/g, '/');
for (const fileDiff of fileDiffs) {
if (fileDiff.hunks.length === 0) continue;
// Build range overlap conditions for all hunks in this file
const overlapConditions = fileDiff.hunks
.map((_, i) => `(n.startLine <= $hunkEnd${i} AND n.endLine >= $hunkStart${i})`)
.join(' OR ');
const queryParams: Record<string, any> = { filePath: fileDiff.filePath };
fileDiff.hunks.forEach((hunk, i) => {
queryParams[`hunkStart${i}`] = hunk.startLine;
queryParams[`hunkEnd${i}`] = hunk.endLine;
});
const symbolQuery = `
MATCH (n) WHERE n.filePath ENDS WITH $filePath
AND n.startLine IS NOT NULL AND n.endLine IS NOT NULL
AND (${overlapConditions})
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type,
n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
`;
try {
const symbols = await executeParameterized(
repo.id,
`
MATCH (n) WHERE n.filePath CONTAINS $filePath
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 20
`,
{ filePath: normalizedFile },
);
for (const sym of symbols) {
const rows = await executeParameterized(repo.id, symbolQuery, queryParams);
for (const sym of rows) {
changedSymbols.push({
id: sym.id || sym[0],
name: sym.name || sym[1],
type: sym.type || sym[2],
filePath: sym.filePath || sym[3],
change_type: 'Modified',
change_type: 'touched',
});
}
} catch (e) {
@ -1595,32 +1606,37 @@ export class LocalBackend {
}
}
// Find affected processes
// Find affected processes -- single batched query instead of N+1
const affectedProcesses = new Map<string, any>();
for (const sym of changedSymbols) {
if (changedSymbols.length > 0) {
const symIds = changedSymbols.map((s) => s.id);
const symNameById = new Map(changedSymbols.map((s) => [s.id, s.name]));
try {
const procs = await executeParameterized(
repo.id,
`
MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
RETURN p.id AS pid, p.heuristicLabel AS label, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
WHERE n.id IN $ids
RETURN n.id AS nodeId, p.id AS pid, p.heuristicLabel AS label,
p.processType AS processType, p.stepCount AS stepCount, r.step AS step
`,
{ nodeId: sym.id },
{ ids: symIds },
);
for (const proc of procs) {
const pid = proc.pid || proc[0];
const nodeId = proc.nodeId || proc[0];
const pid = proc.pid || proc[1];
if (!affectedProcesses.has(pid)) {
affectedProcesses.set(pid, {
id: pid,
name: proc.label || proc[1],
process_type: proc.processType || proc[2],
step_count: proc.stepCount || proc[3],
name: proc.label || proc[2],
process_type: proc.processType || proc[3],
step_count: proc.stepCount || proc[4],
changed_steps: [],
});
}
affectedProcesses.get(pid)!.changed_steps.push({
symbol: sym.name,
step: proc.step || proc[4],
symbol: symNameById.get(nodeId) ?? nodeId,
step: proc.step || proc[5],
});
}
} catch (e) {
@ -1642,7 +1658,7 @@ export class LocalBackend {
summary: {
changed_count: changedSymbols.length,
affected_count: processCount,
changed_files: changedFiles.length,
changed_files: fileDiffs.length,
risk_level: risk,
},
changed_symbols: changedSymbols,

View file

@ -52,3 +52,38 @@ export const hasGitDir = (dirPath: string): boolean => {
return false;
}
};
export interface DiffHunk {
startLine: number;
endLine: number;
}
export interface FileDiff {
filePath: string;
hunks: DiffHunk[];
}
/**
* Parse unified diff output (with -U0) into per-file hunk ranges.
* Extracts the new-file line ranges from @@ hunk headers.
*/
export function parseDiffHunks(diffOutput: string): FileDiff[] {
const files: FileDiff[] = [];
let current: FileDiff | null = null;
for (const line of diffOutput.split('\n')) {
if (line.startsWith('+++ b/')) {
current = { filePath: line.slice(6), hunks: [] };
files.push(current);
} else if (line.startsWith('@@') && current) {
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
if (match) {
const start = parseInt(match[1], 10);
const count = match[2] !== undefined ? parseInt(match[2], 10) : 1;
if (count > 0) {
current.hunks.push({ startLine: start, endLine: start + count - 1 });
}
}
}
}
return files;
}

View file

@ -0,0 +1,6 @@
namespace Contracts;
public interface IAuditableService : IFooService, IBarService
{
string AuditTrail { get; }
}

View file

@ -0,0 +1,6 @@
namespace Contracts;
public interface IBarService
{
void BarMethod();
}

View file

@ -0,0 +1,6 @@
namespace Contracts;
public interface IBaseInterface
{
void BaseMethod();
}

View file

@ -0,0 +1,6 @@
namespace Contracts;
public interface IFooService : IBaseInterface
{
void FooMethod();
}

View file

@ -0,0 +1,12 @@
namespace Services;
using Contracts;
public class MyService : IAuditableService
{
public string AuditTrail => "audit";
public void BaseMethod() { }
public void FooMethod() { }
public void BarMethod() { }
}

View file

@ -0,0 +1,6 @@
import 'service.dart';
Future<void> run() async {
final user = await fetchUser();
await processData(user);
}

View file

@ -0,0 +1,5 @@
Future<String> fetchUser() async {
return 'user';
}
Future<void> processData(String data) async {}

View file

@ -0,0 +1,10 @@
import 'builders.dart';
// Named argument call: child: buildHeader()
// List literal calls: children: [buildBody(), buildFooter()]
dynamic buildPage() {
return Column(
child: buildHeader(),
children: [buildBody(), buildFooter()],
);
}

View file

@ -0,0 +1,3 @@
dynamic buildHeader() => null;
dynamic buildBody() => null;
dynamic buildFooter() => null;

View file

@ -0,0 +1,10 @@
import { verifyToken, BasePayload } from './token';
interface AdminPayload extends BasePayload {
role: string;
}
export async function authenticateAdmin(token: string): Promise<AdminPayload> {
const payload = await verifyToken<AdminPayload>(token, 'admin-secret');
return payload;
}

View file

@ -0,0 +1,10 @@
import { verifyToken, BasePayload } from './token';
interface UserPayload extends BasePayload {
userId: string;
}
export async function authenticateUser(token: string): Promise<UserPayload> {
const payload = await verifyToken<UserPayload>(token, 'secret');
return payload;
}

View file

@ -0,0 +1,12 @@
import { BasePayload } from './token';
import { TokenService } from './service';
interface GuestPayload extends BasePayload {
sessionId: string;
}
export async function authenticateGuest(token: string): Promise<GuestPayload> {
const svc = new TokenService();
const payload = await svc.verify<GuestPayload>(token, 'guest-secret');
return payload;
}

View file

@ -0,0 +1,7 @@
import { BasePayload } from './token';
export class TokenService {
verify<T extends BasePayload>(token: string, secret: string): T {
return JSON.parse(Buffer.from(token, 'base64').toString()) as T;
}
}

View file

@ -0,0 +1,7 @@
export interface BasePayload {
sub: string;
}
export function verifyToken<T extends BasePayload>(token: string, secret: string): T {
return JSON.parse(Buffer.from(token, 'base64').toString()) as T;
}

View file

@ -8,7 +8,7 @@ import {
} from '../../src/core/ingestion/filesystem-walker.js';
import { processParsing } from '../../src/core/ingestion/parsing-processor.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js';
import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js';
import { createASTCache } from '../../src/core/ingestion/ast-cache.js';
import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../src/config/supported-languages.js';

View file

@ -0,0 +1,75 @@
/**
* Integration Tests: Vector extension loading and state reset
*
* Tests: loadVectorExtension idempotency, vectorExtensionLoaded reset
* on closeLbug and busy-retry cleanup paths.
*
* Follows existing lbug integration test patterns (lbug-core-adapter,
* lbug-lock-retry).
*/
import { describe, it, expect } from 'vitest';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
withTestLbugDB('vector-extension', (handle) => {
describe('loadVectorExtension', () => {
it('loads the VECTOR extension without error', async () => {
const { loadVectorExtension } = await import('../../src/core/lbug/lbug-adapter.js');
// Should resolve without throwing -- idempotent if already loaded by doInitLbug
await expect(loadVectorExtension()).resolves.toBeUndefined();
});
it('is idempotent -- calling twice does not throw', async () => {
const { loadVectorExtension } = await import('../../src/core/lbug/lbug-adapter.js');
await loadVectorExtension();
await expect(loadVectorExtension()).resolves.toBeUndefined();
});
});
describe('vectorExtensionLoaded reset on closeLbug', () => {
it('re-initializes vector extension after close + re-init cycle', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// Ensure vector extension is loaded
await adapter.loadVectorExtension();
// Close the adapter -- should reset vectorExtensionLoaded
await adapter.closeLbug();
expect(adapter.isLbugReady()).toBe(false);
// Re-initialize -- doInitLbug calls loadVectorExtension internally
await adapter.initLbug(handle.dbPath);
expect(adapter.isLbugReady()).toBe(true);
// loadVectorExtension should succeed (not skip due to stale flag)
await expect(adapter.loadVectorExtension()).resolves.toBeUndefined();
});
});
describe('vectorExtensionLoaded reset on busy-retry cleanup', () => {
it('withLbugDb resets vectorExtensionLoaded on BUSY retry', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// Ensure vector extension is loaded
await adapter.loadVectorExtension();
// Simulate a BUSY error on first attempt, success on second.
// The retry path should reset vectorExtensionLoaded so the
// re-initialized DB gets a fresh extension load.
let callCount = 0;
const result = await adapter.withLbugDb(handle.dbPath, async () => {
callCount++;
if (callCount === 1) throw new Error('database is BUSY');
return 'recovered';
});
expect(result).toBe('recovered');
expect(callCount).toBe(2);
// After recovery, vector extension should still be loadable
// (the flag was reset and re-loaded during re-init)
await expect(adapter.loadVectorExtension()).resolves.toBeUndefined();
});
});
});

View file

@ -1,13 +1,18 @@
import { describe, expect, it } from 'vitest';
import { createASTCache } from '../../src/core/ingestion/ast-cache.js';
import { processParsing } from '../../src/core/ingestion/parsing-processor.js';
import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js';
import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
describe('qualified class lookups', () => {
it('derives canonical dot-separated names from namespaces, packages, and modules', async () => {
const graph = createKnowledgeGraph();
const symbolTable = createSymbolTable();
const model = createSemanticModel();
// model.symbols is the SymbolTable leaf that processParsing writes into.
// Fan-out writes still reach model.types / model.methods / model.fields
// via SemanticModel's wrappedAdd — this alias is purely for convenience
// at call sites that want the SymbolTable-shaped interface.
const symbolTable = model.symbols;
const astCache = createASTCache();
await processParsing(
@ -34,33 +39,38 @@ describe('qualified class lookups', () => {
astCache,
);
const userMatches = symbolTable.lookupClassByName('User');
const userMatches = model.types.lookupClassByName('User');
expect(userMatches).toHaveLength(3);
expect(userMatches.map((match) => match.qualifiedName).sort()).toEqual(
['Admin.User', 'Data.Auth.User', 'Services.Auth.User'].sort(),
);
const servicesUser = symbolTable.lookupClassByQualifiedName('Services.Auth.User');
const servicesUser = model.types.lookupClassByQualifiedName('Services.Auth.User');
expect(servicesUser).toHaveLength(1);
expect(servicesUser[0].filePath).toBe('src/Services/User.cs');
expect(servicesUser[0].qualifiedName).toBe('Services.Auth.User');
const dataUser = symbolTable.lookupClassByQualifiedName('Data.Auth.User');
const dataUser = model.types.lookupClassByQualifiedName('Data.Auth.User');
expect(dataUser).toHaveLength(1);
expect(dataUser[0].filePath).toBe('src/Data/User.cs');
const javaConfig = symbolTable.lookupClassByQualifiedName('com.example.models.Config');
const javaConfig = model.types.lookupClassByQualifiedName('com.example.models.Config');
expect(javaConfig).toHaveLength(1);
expect(javaConfig[0].qualifiedName).toBe('com.example.models.Config');
const rubyUser = symbolTable.lookupClassByQualifiedName('Admin.User');
const rubyUser = model.types.lookupClassByQualifiedName('Admin.User');
expect(rubyUser).toHaveLength(1);
expect(rubyUser[0].qualifiedName).toBe('Admin.User');
});
it('falls back to the simple name for top-level class-like symbols', async () => {
const graph = createKnowledgeGraph();
const symbolTable = createSymbolTable();
const model = createSemanticModel();
// model.symbols is the SymbolTable leaf that processParsing writes into.
// Fan-out writes still reach model.types / model.methods / model.fields
// via SemanticModel's wrappedAdd — this alias is purely for convenience
// at call sites that want the SymbolTable-shaped interface.
const symbolTable = model.symbols;
const astCache = createASTCache();
await processParsing(
@ -70,11 +80,11 @@ describe('qualified class lookups', () => {
astCache,
);
const simpleMatches = symbolTable.lookupClassByName('User');
const simpleMatches = model.types.lookupClassByName('User');
expect(simpleMatches).toHaveLength(1);
expect(simpleMatches[0].qualifiedName).toBe('User');
const matches = symbolTable.lookupClassByQualifiedName('User');
const matches = model.types.lookupClassByQualifiedName('User');
expect(matches).toHaveLength(1);
expect(matches[0].qualifiedName).toBe('User');
});

View file

@ -33,6 +33,7 @@ describe('Query compilation smoke tests', () => {
[SupportedLanguages.PHP]: 'test.php',
[SupportedLanguages.Kotlin]: 'Test.kt',
[SupportedLanguages.Swift]: 'test.swift',
[SupportedLanguages.Dart]: 'test.dart',
};
// Known query compilation failures — remove from this set as PRs fix them

View file

@ -1997,3 +1997,54 @@ describe('C# User implements IValidator — interface default method (SM-11)', (
expect(validateCall!.source).toBe('Run');
});
});
// ---------------------------------------------------------------------------
// Interface-to-interface heritage (single + multi base interface)
// ---------------------------------------------------------------------------
describe('C# interface-to-interface heritage', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-interface-heritage'), () => {});
}, 60000);
it('detects 1 class and 4 interfaces', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['MyService']);
expect(getNodesByLabel(result, 'Interface')).toEqual([
'IAuditableService',
'IBarService',
'IBaseInterface',
'IFooService',
]);
});
it('emits no EXTENDS edges (fixture has no class inheritance)', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(0);
});
it('emits IMPLEMENTS edge: IFooService → IBaseInterface (single base interface)', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
const targets = edgeSet(implements_);
expect(targets).toContain('IFooService → IBaseInterface');
});
it('emits IMPLEMENTS edges: IAuditableService → IFooService, IBarService (multi base interfaces)', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
const targets = edgeSet(implements_);
expect(targets).toContain('IAuditableService → IFooService');
expect(targets).toContain('IAuditableService → IBarService');
});
it('emits IMPLEMENTS edge: MyService → IAuditableService (class implements derived interface)', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
const targets = edgeSet(implements_);
expect(targets).toContain('MyService → IAuditableService');
});
it('emits exactly 4 IMPLEMENTS edges total', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
expect(implements_.length).toBe(4);
});
});

View file

@ -512,3 +512,70 @@ describe.skipIf(!dartAvailable)(
});
},
);
// ---------------------------------------------------------------------------
// await call patterns: await fetchUser(), await processData()
// ---------------------------------------------------------------------------
describe.skipIf(!dartAvailable)('Dart await call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-await-calls'), () => {});
}, 60000);
it('detects fetchUser and processData as functions', () => {
const fns = getNodesByLabel(result, 'Function');
expect(fns).toContain('fetchUser');
expect(fns).toContain('processData');
});
it('resolves run → fetchUser via await direct call', () => {
const calls = getRelationships(result, 'CALLS');
const edge = calls.find((c) => c.source === 'run' && c.target === 'fetchUser');
expect(edge).toBeDefined();
});
it('resolves run → processData via await direct call', () => {
const calls = getRelationships(result, 'CALLS');
const edge = calls.find((c) => c.source === 'run' && c.target === 'processData');
expect(edge).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Widget-tree call patterns: named argument and list literal
// ---------------------------------------------------------------------------
describe.skipIf(!dartAvailable)('Dart widget-tree call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-widget-tree-calls'), () => {});
}, 60000);
it('detects buildHeader, buildBody, buildFooter as functions', () => {
const fns = getNodesByLabel(result, 'Function');
expect(fns).toContain('buildHeader');
expect(fns).toContain('buildBody');
expect(fns).toContain('buildFooter');
});
it('resolves buildPage → buildHeader via named argument call', () => {
const calls = getRelationships(result, 'CALLS');
const edge = calls.find((c) => c.source === 'buildPage' && c.target === 'buildHeader');
expect(edge).toBeDefined();
});
it('resolves buildPage → buildBody via list literal call', () => {
const calls = getRelationships(result, 'CALLS');
const edge = calls.find((c) => c.source === 'buildPage' && c.target === 'buildBody');
expect(edge).toBeDefined();
});
it('resolves buildPage → buildFooter via list literal call', () => {
const calls = getRelationships(result, 'CALLS');
const edge = calls.find((c) => c.source === 'buildPage' && c.target === 'buildFooter');
expect(edge).toBeDefined();
});
});

View file

@ -145,6 +145,49 @@ describe('TypeScript call resolution with arity filtering', () => {
});
});
// ---------------------------------------------------------------------------
// Generic function call resolution: await fn<T>(args) creates CALLS edges
// ---------------------------------------------------------------------------
describe('TypeScript generic awaited call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-generic-calls'), () => {});
}, 60000);
it('resolves authenticateUser → verifyToken via awaited generic call', () => {
const calls = getRelationships(result, 'CALLS');
const authCall = calls.find(
(c) => c.source === 'authenticateUser' && c.target === 'verifyToken',
);
expect(authCall).toBeDefined();
expect(authCall!.targetFilePath).toBe('src/token.ts');
});
it('resolves authenticateAdmin → verifyToken via awaited generic call', () => {
const calls = getRelationships(result, 'CALLS');
const adminCall = calls.find(
(c) => c.source === 'authenticateAdmin' && c.target === 'verifyToken',
);
expect(adminCall).toBeDefined();
expect(adminCall!.targetFilePath).toBe('src/token.ts');
});
it('resolves authenticateGuest → verify via awaited generic member call', () => {
const calls = getRelationships(result, 'CALLS');
const guestCall = calls.find((c) => c.source === 'authenticateGuest' && c.target === 'verify');
expect(guestCall).toBeDefined();
expect(guestCall!.targetFilePath).toBe('src/service.ts');
});
it('verifyToken has exactly 2 incoming CALLS edges (both free-call callers resolved)', () => {
const calls = getRelationships(result, 'CALLS');
const incoming = calls.filter((c) => c.target === 'verifyToken');
expect(incoming.length).toBe(2);
});
});
// ---------------------------------------------------------------------------
// Member-call resolution: obj.method() resolves through pipeline
// ---------------------------------------------------------------------------

View file

@ -4,7 +4,7 @@ import {
extractReceiverName,
} from '../../src/core/ingestion/utils/call-analysis.js';
import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js';
import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js';
import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import Python from 'tree-sitter-python';
@ -452,9 +452,13 @@ describe('ownerId on SymbolDefinition', () => {
expect(def!.ownerId).toBeUndefined();
});
it('propagates ownerId through lookupCallableByName', () => {
it('propagates ownerId through a free Function registration', () => {
// Post-A4 Unit 4, Method is no longer in FREE_CALLABLE_TYPES so this test
// exercises ownerId propagation through the free-callable index using
// a Function label. Method-with-ownerId propagation is covered via
// methodsByName in method-registry.test.ts.
const st = createSymbolTable();
st.add('src/foo.ts', 'save', 'Method:src/foo.ts:save', 'Method', {
st.add('src/foo.ts', 'save', 'Function:src/foo.ts:save', 'Function', {
ownerId: 'Class:src/foo.ts:User',
});

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@ import { cppConfig } from '../../src/core/ingestion/field-extractors/configs/c-c
import { rubyConfig } from '../../src/core/ingestion/field-extractors/configs/ruby.js';
import type { FieldExtractorContext } from '../../src/core/ingestion/field-types.js';
import type { TypeEnvironment } from '../../src/core/ingestion/type-env.js';
import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js';
import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import Python from 'tree-sitter-python';
@ -26,7 +26,13 @@ const parse = (code: string) => {
return parser.parse(code);
};
// Mock context for tests
// Mock context for tests. symbolTable comes from createSemanticModel().symbols
// (the facade) rather than createSymbolTable() (the raw leaf) — this mirrors
// production, where FieldExtractorContext always receives the SemanticModel-
// wrapped facade so any .add() write dispatches through the owner-scoped
// registries. No current field extractor calls symbolTable.add(), but
// matching the production shape prevents silent drift if a future extractor
// starts registering dynamically-discovered properties.
const createMockContext = (): FieldExtractorContext => ({
typeEnv: {
lookup: () => undefined,
@ -35,7 +41,7 @@ const createMockContext = (): FieldExtractorContext => ({
allScopes: () => new Map(),
constructorTypeMap: new Map(),
} as TypeEnvironment,
symbolTable: createSymbolTable(),
symbolTable: createSemanticModel().symbols,
filePath: 'test.ts',
language: SupportedLanguages.TypeScript,
});

View file

@ -0,0 +1,178 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import {
writeBridge,
openBridgeDbReadOnly,
queryBridge,
closeBridgeDb,
} from '../../../src/core/group/bridge-db.js';
import type { CrossLink } from '../../../src/core/group/types.js';
import { makeContract } from './fixtures.js';
describe('bridge-db edge cases', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-edge-'));
});
afterEach(async () => {
await fsp.rm(tmpDir, { recursive: true, force: true });
});
it('test_openBridgeDbReadOnly_version_gate_returns_null_for_incompatible', async () => {
// Create a dummy bridge.lbug file so the access check passes
await fsp.writeFile(path.join(tmpDir, 'bridge.lbug'), 'dummy');
// Write meta.json with an incompatible version (999)
await fsp.writeFile(
path.join(tmpDir, 'meta.json'),
JSON.stringify({ version: 999, generatedAt: '', missingRepos: [] }),
);
const handle = await openBridgeDbReadOnly(tmpDir);
expect(handle).toBeNull();
});
it('test_openBridgeDbReadOnly_bak_recovery_restores_bridge', async () => {
// Write a valid bridge
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
// Move bridge.lbug → bridge.lbug.bak (simulating interrupted swap)
const dbPath = path.join(tmpDir, 'bridge.lbug');
const bakPath = path.join(tmpDir, 'bridge.lbug.bak');
await fsp.rename(dbPath, bakPath);
// openBridgeDbReadOnly should auto-recover from .bak
const handle = await openBridgeDbReadOnly(tmpDir);
expect(handle).not.toBeNull();
const rows = await queryBridge<{ repo: string }>(
handle!,
'MATCH (c:Contract) RETURN c.repo AS repo',
);
expect(rows).toHaveLength(1);
await closeBridgeDb(handle!);
});
it('test_writeBridge_crossLink_with_missing_to_node_silently_skipped', async () => {
const provider = makeContract({ repo: 'backend', role: 'provider' });
const consumer = makeContract({
repo: 'frontend',
role: 'consumer',
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
symbolName: 'fetchUsers',
});
// CrossLink referencing a 'to' endpoint that doesn't match any contract node
const link: CrossLink = {
from: {
repo: 'frontend',
symbolUid: '',
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
},
to: {
repo: 'nonexistent-repo',
symbolUid: 'uid-missing',
symbolRef: { filePath: 'src/missing.ts', name: 'missingFn' },
},
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'exact',
confidence: 1.0,
};
// Should not throw — the link is silently skipped
await writeBridge(tmpDir, {
contracts: [provider, consumer],
crossLinks: [link],
repoSnapshots: {},
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
expect(handle).not.toBeNull();
// No cross-links should exist since 'to' node was missing
const rows = await queryBridge<{ matchType: string }>(
handle!,
'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.matchType AS matchType',
);
expect(rows).toHaveLength(0);
// But contracts should still be present
const contractRows = await queryBridge<{ repo: string }>(
handle!,
'MATCH (c:Contract) RETURN c.repo AS repo',
);
expect(contractRows).toHaveLength(2);
await closeBridgeDb(handle!);
});
it('test_writeBridge_manifest_grpc_link_with_symbol_uids_persists_queryable_contract_edge', async () => {
const provider = makeContract({
contractId: 'grpc::auth.AuthService/Login',
type: 'grpc',
role: 'provider',
repo: 'platform/auth',
symbolUid: 'uid-auth-login',
symbolRef: { filePath: 'src/auth.proto', name: 'Login' },
symbolName: 'auth.AuthService/Login',
});
const consumer = makeContract({
contractId: 'grpc::auth.AuthService/Login',
type: 'grpc',
role: 'consumer',
repo: 'platform/orders',
symbolUid: 'uid-orders-client',
symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' },
symbolName: 'auth.AuthService/Login',
});
const link: CrossLink = {
from: {
repo: 'platform/orders',
symbolUid: 'uid-orders-client',
symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' },
},
to: {
repo: 'platform/auth',
symbolUid: 'uid-auth-login',
symbolRef: { filePath: 'src/auth.proto', name: 'Login' },
},
type: 'grpc',
contractId: 'grpc::auth.AuthService/Login',
matchType: 'manifest',
confidence: 1.0,
};
await writeBridge(tmpDir, {
contracts: [provider, consumer],
crossLinks: [link],
repoSnapshots: {},
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
expect(handle).not.toBeNull();
const rows = await queryBridge<{
contractId: string;
matchType: string;
fromRepo: string;
toRepo: string;
}>(
handle!,
`MATCH (a:Contract)-[l:ContractLink]->(b:Contract)
RETURN l.contractId AS contractId, l.matchType AS matchType, l.fromRepo AS fromRepo, l.toRepo AS toRepo`,
);
expect(rows).toEqual([
{
contractId: 'grpc::auth.AuthService/Login',
matchType: 'manifest',
fromRepo: 'platform/orders',
toRepo: 'platform/auth',
},
]);
await closeBridgeDb(handle!);
});
});

Some files were not shown because too many files have changed in this diff Show more