mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-11 22:53:04 +00:00
Merge upstream/main into fix/ruby-singleton-class-sequential
This commit is contained in:
commit
8d175c81bf
135 changed files with 25660 additions and 3147 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
23
gitnexus-shared/src/mro-strategy.ts
Normal file
23
gitnexus-shared/src/mro-strategy.ts
Normal 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';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
34
gitnexus/package-lock.json
generated
34
gitnexus/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
78
gitnexus/scripts/patch-tree-sitter-swift.cjs
Normal file
78
gitnexus/scripts/patch-tree-sitter-swift.cjs
Normal 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',
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 || {};
|
||||
|
|
|
|||
139
gitnexus/src/core/group/PIPELINE.md
Normal file
139
gitnexus/src/core/group/PIPELINE.md
Normal 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.
|
||||
588
gitnexus/src/core/group/bridge-db.ts
Normal file
588
gitnexus/src/core/group/bridge-db.ts
Normal 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;
|
||||
}
|
||||
60
gitnexus/src/core/group/bridge-schema.ts
Normal file
60
gitnexus/src/core/group/bridge-schema.ts
Normal 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];
|
||||
23
gitnexus/src/core/group/extractors/fs-utils.ts
Normal file
23
gitnexus/src/core/group/extractors/fs-utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
109
gitnexus/src/core/group/extractors/grpc-patterns/go.ts
Normal file
109
gitnexus/src/core/group/extractors/grpc-patterns/go.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
53
gitnexus/src/core/group/extractors/grpc-patterns/index.ts
Normal file
53
gitnexus/src/core/group/extractors/grpc-patterns/index.ts
Normal 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];
|
||||
}
|
||||
179
gitnexus/src/core/group/extractors/grpc-patterns/java.ts
Normal file
179
gitnexus/src/core/group/extractors/grpc-patterns/java.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
314
gitnexus/src/core/group/extractors/grpc-patterns/node.ts
Normal file
314
gitnexus/src/core/group/extractors/grpc-patterns/node.ts
Normal 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),
|
||||
};
|
||||
147
gitnexus/src/core/group/extractors/grpc-patterns/proto.ts
Normal file
147
gitnexus/src/core/group/extractors/grpc-patterns/proto.ts
Normal 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 '';
|
||||
}
|
||||
77
gitnexus/src/core/group/extractors/grpc-patterns/python.ts
Normal file
77
gitnexus/src/core/group/extractors/grpc-patterns/python.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
54
gitnexus/src/core/group/extractors/grpc-patterns/types.ts
Normal file
54
gitnexus/src/core/group/extractors/grpc-patterns/types.ts
Normal 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[];
|
||||
}
|
||||
224
gitnexus/src/core/group/extractors/http-patterns/go.ts
Normal file
224
gitnexus/src/core/group/extractors/http-patterns/go.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
50
gitnexus/src/core/group/extractors/http-patterns/index.ts
Normal file
50
gitnexus/src/core/group/extractors/http-patterns/index.ts
Normal 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];
|
||||
}
|
||||
267
gitnexus/src/core/group/extractors/http-patterns/java.ts
Normal file
267
gitnexus/src/core/group/extractors/http-patterns/java.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
373
gitnexus/src/core/group/extractors/http-patterns/node.ts
Normal file
373
gitnexus/src/core/group/extractors/http-patterns/node.ts
Normal 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),
|
||||
};
|
||||
79
gitnexus/src/core/group/extractors/http-patterns/php.ts
Normal file
79
gitnexus/src/core/group/extractors/http-patterns/php.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
142
gitnexus/src/core/group/extractors/http-patterns/python.ts
Normal file
142
gitnexus/src/core/group/extractors/http-patterns/python.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
65
gitnexus/src/core/group/extractors/http-patterns/types.ts
Normal file
65
gitnexus/src/core/group/extractors/http-patterns/types.ts
Normal 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.7–0.8. */
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One language-scoped HTTP plugin. The plugin owns the tree-sitter
|
||||
* grammar and the `scan` function that translates a parsed tree into
|
||||
* zero or more `HttpDetection`s. Plugins are free to run multiple
|
||||
* compiled pattern bundles internally (see the shared scanner's
|
||||
* `runCompiledPatterns` helper).
|
||||
*
|
||||
* `language` is typed as `unknown` for the same reason as
|
||||
* `LanguagePatterns.language` in `tree-sitter-scanner.ts` — the
|
||||
* grammar modules export different shapes.
|
||||
*/
|
||||
export interface HttpLanguagePlugin {
|
||||
/** Human-readable plugin name for diagnostics. */
|
||||
name: string;
|
||||
/** tree-sitter grammar object (passed to the shared parser). */
|
||||
language: unknown;
|
||||
/**
|
||||
* Scan a parsed tree and return zero or more HTTP detections. Plugins
|
||||
* must not throw — they should swallow per-match errors so a single
|
||||
* malformed construct does not abort the whole file.
|
||||
*/
|
||||
scan(tree: Parser.Tree): HttpDetection[];
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
268
gitnexus/src/core/group/extractors/manifest-extractor.ts
Normal file
268
gitnexus/src/core/group/extractors/manifest-extractor.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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[] {
|
||||
|
|
|
|||
123
gitnexus/src/core/group/extractors/topic-patterns/go.ts
Normal file
123
gitnexus/src/core/group/extractors/topic-patterns/go.ts
Normal 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);
|
||||
49
gitnexus/src/core/group/extractors/topic-patterns/index.ts
Normal file
49
gitnexus/src/core/group/extractors/topic-patterns/index.ts
Normal 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];
|
||||
}
|
||||
83
gitnexus/src/core/group/extractors/topic-patterns/java.ts
Normal file
83
gitnexus/src/core/group/extractors/topic-patterns/java.ts
Normal 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);
|
||||
165
gitnexus/src/core/group/extractors/topic-patterns/node.ts
Normal file
165
gitnexus/src/core/group/extractors/topic-patterns/node.ts
Normal 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);
|
||||
119
gitnexus/src/core/group/extractors/topic-patterns/python.ts
Normal file
119
gitnexus/src/core/group/extractors/topic-patterns/python.ts
Normal 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);
|
||||
27
gitnexus/src/core/group/extractors/topic-patterns/types.ts
Normal file
27
gitnexus/src/core/group/extractors/topic-patterns/types.ts
Normal 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;
|
||||
}
|
||||
193
gitnexus/src/core/group/extractors/tree-sitter-scanner.ts
Normal file
193
gitnexus/src/core/group/extractors/tree-sitter-scanner.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
124
gitnexus/src/core/group/normalization.ts
Normal file
124
gitnexus/src/core/group/normalization.ts
Normal 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()];
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
53
gitnexus/src/core/ingestion/model/field-registry.ts
Normal file
53
gitnexus/src/core/ingestion/model/field-registry.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
88
gitnexus/src/core/ingestion/model/index.ts
Normal file
88
gitnexus/src/core/ingestion/model/index.ts
Normal 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';
|
||||
204
gitnexus/src/core/ingestion/model/method-registry.ts
Normal file
204
gitnexus/src/core/ingestion/model/method-registry.ts
Normal 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 (false→true once, never back) for the lifetime
|
||||
* of the MethodRegistry.
|
||||
*/
|
||||
readonly hasFunctionMethods: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutable interface (used internally by SymbolTable.add / clear)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MutableMethodRegistry extends MethodRegistry {
|
||||
/** Register a method under its owner. Supports multiple overloads. */
|
||||
register(ownerNodeId: string, methodName: string, def: SymbolDefinition): void;
|
||||
/** Clear all entries. */
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const createMethodRegistry = (): MutableMethodRegistry => {
|
||||
const methodByOwner = new Map<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;
|
||||
},
|
||||
};
|
||||
};
|
||||
333
gitnexus/src/core/ingestion/model/registration-table.ts
Normal file
333
gitnexus/src/core/ingestion/model/registration-table.ts
Normal 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 hook↔label 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][],
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
284
gitnexus/src/core/ingestion/model/resolve.ts
Normal file
284
gitnexus/src/core/ingestion/model/resolve.ts
Normal 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
|
||||
* parent→children 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;
|
||||
};
|
||||
193
gitnexus/src/core/ingestion/model/semantic-model.ts
Normal file
193
gitnexus/src/core/ingestion/model/semantic-model.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
381
gitnexus/src/core/ingestion/model/symbol-table.ts
Normal file
381
gitnexus/src/core/ingestion/model/symbol-table.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
113
gitnexus/src/core/ingestion/model/type-registry.ts
Normal file
113
gitnexus/src/core/ingestion/model/type-registry.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import type { SymbolTable, SymbolDefinition } from './symbol-table.js';
|
||||
import type { NamedImportMap } from './import-processor.js';
|
||||
|
||||
/**
|
||||
* Walk a named-binding re-export chain through NamedImportMap.
|
||||
*
|
||||
* When file A imports { User } from B, and B re-exports { User } from C,
|
||||
* the NamedImportMap for A points to B, but B has no User definition.
|
||||
* This function follows the chain: A→B→C until a definition is found.
|
||||
*
|
||||
* Returns the definitions found at the end of the chain, or null if the
|
||||
* chain breaks (missing binding, circular reference, or depth exceeded).
|
||||
* Max depth 5 to prevent infinite loops.
|
||||
*/
|
||||
export function walkBindingChain(
|
||||
name: string,
|
||||
currentFilePath: string,
|
||||
symbolTable: SymbolTable,
|
||||
namedImportMap: NamedImportMap,
|
||||
): SymbolDefinition[] | null {
|
||||
let lookupFile = currentFilePath;
|
||||
let lookupName = name;
|
||||
const visited = new Set<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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
6
gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IAuditableService.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IAuditableService.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace Contracts;
|
||||
|
||||
public interface IAuditableService : IFooService, IBarService
|
||||
{
|
||||
string AuditTrail { get; }
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBarService.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBarService.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace Contracts;
|
||||
|
||||
public interface IBarService
|
||||
{
|
||||
void BarMethod();
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBaseInterface.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBaseInterface.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace Contracts;
|
||||
|
||||
public interface IBaseInterface
|
||||
{
|
||||
void BaseMethod();
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IFooService.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IFooService.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace Contracts;
|
||||
|
||||
public interface IFooService : IBaseInterface
|
||||
{
|
||||
void FooMethod();
|
||||
}
|
||||
12
gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/MyService.cs
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/MyService.cs
vendored
Normal 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() { }
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/dart-await-calls/app.dart
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/dart-await-calls/app.dart
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import 'service.dart';
|
||||
|
||||
Future<void> run() async {
|
||||
final user = await fetchUser();
|
||||
await processData(user);
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/dart-await-calls/service.dart
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/dart-await-calls/service.dart
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Future<String> fetchUser() async {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
Future<void> processData(String data) async {}
|
||||
10
gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/app.dart
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/app.dart
vendored
Normal 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()],
|
||||
);
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/builders.dart
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/builders.dart
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
dynamic buildHeader() => null;
|
||||
dynamic buildBody() => null;
|
||||
dynamic buildFooter() => null;
|
||||
10
gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/admin.ts
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/admin.ts
vendored
Normal 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;
|
||||
}
|
||||
10
gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/auth.ts
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/auth.ts
vendored
Normal 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;
|
||||
}
|
||||
12
gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/guest.ts
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/guest.ts
vendored
Normal 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;
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/service.ts
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/service.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/token.ts
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/token.ts
vendored
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
75
gitnexus/test/integration/lbug-vector-extension.test.ts
Normal file
75
gitnexus/test/integration/lbug-vector-extension.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
178
gitnexus/test/unit/group/bridge-db-edge.test.ts
Normal file
178
gitnexus/test/unit/group/bridge-db-edge.test.ts
Normal 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
Loading…
Add table
Reference in a new issue