mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-24 05:43:42 +00:00
fix: format README important-notice table and clean up fuzzy stats
- Add missing space before URL in README comparison table so the link renders correctly in all Markdown renderers. - Remove fuzzyCallCount / fuzzyCallableCallCount tracking that was added for short-term debugging: delete the counters from SymbolTable, ResolutionContext and the related pipeline console.log, and update the affected unit-test expectations. - Adjust methodByOwner indexing to exclude Constructor entries, and update the corresponding unit test assertion. Signed-off-by: Cocoon-Break <54054995+kuishou68@users.noreply.github.com>
This commit is contained in:
parent
962f22482b
commit
bc49ae4494
5 changed files with 2710 additions and 2490 deletions
152
README.md
152
README.md
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
<h2>Join the official Discord to discuss ideas, issues etc!</h2>
|
||||
|
||||
<a href="https://discord.gg/MgJrmsqr62">
|
||||
<a href="https://discord.gg/AAsRVT6fGb">
|
||||
<img src="https://img.shields.io/discord/1477255801545429032?color=5865F2&logo=discord&logoColor=white" alt="Discord"/>
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/gitnexus">
|
||||
|
|
@ -194,7 +194,6 @@ gitnexus analyze --force # Force full re-index
|
|||
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
|
||||
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
gitnexus analyze --skip-git # Index folders that are not Git repositories
|
||||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
|
|
@ -336,155 +335,6 @@ cd ../gitnexus-web && npm install
|
|||
npm run dev
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
The official Docker setup ships **two signed images** orchestrated by `docker-compose.yaml`:
|
||||
|
||||
| Image | Purpose |
|
||||
| -------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `ghcr.io/abhigyanpatwari/gitnexus:latest` | CLI / `gitnexus serve` backend (HTTP API on port `4747`, MCP, indexer) |
|
||||
| `ghcr.io/abhigyanpatwari/gitnexus-web:latest` | Static web UI (port `4173`) |
|
||||
|
||||
> **Heads-up — image rename.** Earlier releases published the web UI under
|
||||
> `ghcr.io/abhigyanpatwari/gitnexus`. Starting with the introduction of the
|
||||
> bundled backend, that slug now hosts the CLI/server image and the UI moved
|
||||
> to `ghcr.io/abhigyanpatwari/gitnexus-web`. The previous tags remain
|
||||
> available for pulling, but new versions are only published under the new
|
||||
> slugs. Update your `docker run` / compose files accordingly (or just adopt
|
||||
> the bundled compose).
|
||||
|
||||
### One-command setup
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts the server on `http://localhost:4747` and the web UI on
|
||||
`http://localhost:4173`. The UI auto-detects the server because the browser
|
||||
runs on the host and reaches the container via the mapped port.
|
||||
|
||||
A named volume (`gitnexus-data`) persists the global registry, indexes, and
|
||||
cloned repos at `/data/gitnexus` inside the server container. To make repos on
|
||||
your host machine indexable, set `WORKSPACE_DIR` before bringing the stack up:
|
||||
|
||||
```bash
|
||||
WORKSPACE_DIR=$HOME/code docker compose up -d
|
||||
# Inside the server container the directory is mounted read-only at /workspace.
|
||||
docker compose exec gitnexus-server gitnexus index /workspace/my-repo
|
||||
```
|
||||
|
||||
### Direct `docker run`
|
||||
|
||||
```bash
|
||||
# Server
|
||||
docker run --rm -d \
|
||||
--name gitnexus-server \
|
||||
-p 4747:4747 \
|
||||
-v gitnexus-data:/data/gitnexus \
|
||||
ghcr.io/abhigyanpatwari/gitnexus:latest
|
||||
|
||||
# Web UI
|
||||
docker run --rm -d \
|
||||
--name gitnexus-web \
|
||||
-p 4173:4173 \
|
||||
ghcr.io/abhigyanpatwari/gitnexus-web:latest
|
||||
```
|
||||
|
||||
Optional env file (override image tags, container names, ports, workspace dir):
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose --env-file .env up -d
|
||||
```
|
||||
|
||||
### Versioning & supply-chain protection
|
||||
|
||||
The Docker images are version-locked to the npm package:
|
||||
|
||||
- Stable images are **only published from `vX.Y.Z` git tags** (via `docker.yml`
|
||||
triggered directly by the tag push), and the workflow refuses to build unless
|
||||
the tag exactly matches `gitnexus/package.json`'s version. So
|
||||
`ghcr.io/abhigyanpatwari/gitnexus:1.6.2` is byte-for-byte the same release
|
||||
as `npm install gitnexus@1.6.2` — no drift, no floating builds from `main`.
|
||||
- Release-candidate images (e.g. `:1.7.0-rc.1`) are published alongside each
|
||||
RC npm release. They are built by `release-candidate.yml` calling `docker.yml`
|
||||
as a reusable workflow after the RC tag is created and pushed.
|
||||
- `:latest` is auto-promoted only from non-prerelease tags by the Docker
|
||||
metadata action, so it always points at a real, npm-published version.
|
||||
|
||||
Both images are signed with [Cosign keyless signing][cosign-keyless] using the
|
||||
workflow's GitHub OIDC identity, and shipped with build provenance and SBOM
|
||||
attestations. **This is your protection against supply-chain attacks**: even if
|
||||
an attacker republishes a same-named image elsewhere (or somehow pushes to a
|
||||
typo-squatted registry), they cannot forge a Cosign signature tied to
|
||||
`abhigyanpatwari/GitNexus`'s `docker.yml`. Always verify before pulling into
|
||||
sensitive environments:
|
||||
|
||||
**Stable releases** — signed from the `v*` tag ref:
|
||||
|
||||
```bash
|
||||
cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.6.2 \
|
||||
--certificate-identity-regexp '^https://github\.com/abhigyanpatwari/GitNexus/\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com
|
||||
```
|
||||
|
||||
The regex pins the certificate identity to this repo's `docker.yml` workflow
|
||||
**run from a `v*` tag** — rejecting unsigned images, images signed by other
|
||||
workflows, and images signed from unprotected refs.
|
||||
|
||||
**Release candidates** — signed from `refs/heads/main` (the caller's ref when
|
||||
`release-candidate.yml` invokes `docker.yml` as a reusable workflow):
|
||||
|
||||
```bash
|
||||
cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1 \
|
||||
--certificate-identity 'https://github.com/abhigyanpatwari/GitNexus/.github/workflows/docker.yml@refs/heads/main' \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com
|
||||
```
|
||||
|
||||
You can also inspect the build provenance and SBOM:
|
||||
|
||||
```bash
|
||||
cosign download attestation ghcr.io/abhigyanpatwari/gitnexus:1.6.2 \
|
||||
--predicate-type https://slsa.dev/provenance/v1
|
||||
```
|
||||
|
||||
#### Kubernetes: enforce signatures at admission
|
||||
|
||||
For Kubernetes deployments, ship the bundled
|
||||
[`ClusterImagePolicy`](deploy/kubernetes/cluster-image-policy.yaml) so the
|
||||
[Sigstore policy-controller][policy-controller] rejects any GitNexus pod whose
|
||||
image is not signed by this repo's `docker.yml` running from a `vX.Y.Z` tag —
|
||||
the same identity the `cosign verify` snippet above pins.
|
||||
|
||||
```bash
|
||||
# 1. Install the controller (one-time, cluster-wide)
|
||||
helm repo add sigstore https://sigstore.github.io/helm-charts && helm repo update
|
||||
helm install policy-controller -n cosign-system --create-namespace \
|
||||
sigstore/policy-controller
|
||||
|
||||
# 2. Opt your namespace in
|
||||
kubectl label namespace <your-ns> policy.sigstore.dev/include=true
|
||||
|
||||
# 3. Apply the policy
|
||||
kubectl apply -f deploy/kubernetes/cluster-image-policy.yaml
|
||||
```
|
||||
|
||||
After this, attempting to deploy an unsigned image — or one signed by anything
|
||||
other than `abhigyanpatwari/GitNexus`'s `docker.yml` at a `v*` tag — fails the
|
||||
admission webhook before a pod is ever created. This turns the verifiable
|
||||
signature into an enforced policy, which is the supply-chain control most
|
||||
clusters actually need.
|
||||
|
||||
[cosign-keyless]: https://docs.sigstore.dev/cosign/signing/overview/
|
||||
[policy-controller]: https://docs.sigstore.dev/policy-controller/overview/
|
||||
|
||||
### Files
|
||||
|
||||
- [Dockerfile.web](Dockerfile.web) — builds `gitnexus-shared` and `gitnexus-web`, then serves the production frontend.
|
||||
- [Dockerfile.cli](Dockerfile.cli) — builds the CLI/server (with its native deps) and runs `gitnexus serve --host 0.0.0.0`.
|
||||
- [docker-compose.yaml](docker-compose.yaml) — starts both signed images side by side.
|
||||
- [.env.example](.env.example) — overrides for image names, container names, ports, and the workspace mount.
|
||||
|
||||
The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, LadybugDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos.
|
||||
|
||||
**Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
205
gitnexus/src/core/ingestion/resolution-context.ts
Normal file
205
gitnexus/src/core/ingestion/resolution-context.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* Resolution Context
|
||||
*
|
||||
* Single implementation of tiered name resolution. Replaces the duplicated
|
||||
* tier-selection logic previously split between symbol-resolver.ts and
|
||||
* call-processor.ts.
|
||||
*
|
||||
* Resolution tiers (highest confidence first):
|
||||
* 1. Same file (lookupExactFull — authoritative)
|
||||
* 2a-named. Named binding chain (walkBindingChain via NamedImportMap)
|
||||
* 2a. Import-scoped (lookupFuzzy filtered by ImportMap)
|
||||
* 2b. Package-scoped (lookupFuzzy filtered by PackageMap)
|
||||
* 3. Global (all candidates — consumers must check candidate count)
|
||||
*/
|
||||
|
||||
import type { SymbolTable, SymbolDefinition } from './symbol-table.js';
|
||||
import { createSymbolTable } from './symbol-table.js';
|
||||
import type { NamedImportBinding } from './import-processor.js';
|
||||
import { isFileInPackageDir } from './import-processor.js';
|
||||
import { walkBindingChain } from './named-binding-processor.js';
|
||||
|
||||
/** Resolution tier for tracking, logging, and test assertions. */
|
||||
export type ResolutionTier = 'same-file' | 'import-scoped' | 'global';
|
||||
|
||||
/** Tier-selected candidates with metadata. */
|
||||
export interface TieredCandidates {
|
||||
readonly candidates: readonly SymbolDefinition[];
|
||||
readonly tier: ResolutionTier;
|
||||
}
|
||||
|
||||
/** Confidence scores per resolution tier. */
|
||||
export const TIER_CONFIDENCE: Record<ResolutionTier, number> = {
|
||||
'same-file': 0.95,
|
||||
'import-scoped': 0.9,
|
||||
global: 0.5,
|
||||
};
|
||||
|
||||
// --- Map types ---
|
||||
export type ImportMap = Map<string, Set<string>>;
|
||||
export type PackageMap = Map<string, Set<string>>;
|
||||
export type NamedImportMap = Map<string, Map<string, NamedImportBinding>>;
|
||||
/** Maps callerFile → (moduleAlias → sourceFilePath) for Python namespace imports.
|
||||
* e.g. `import models` in app.py → moduleAliasMap.get('app.py')?.get('models') === 'models.py' */
|
||||
export type ModuleAliasMap = Map<string, Map<string, string>>;
|
||||
|
||||
export interface ResolutionContext {
|
||||
/**
|
||||
* The only resolution API. Returns all candidates at the winning tier.
|
||||
*
|
||||
* Tier 3 ('global') returns ALL candidates regardless of count —
|
||||
* consumers must check candidates.length and refuse ambiguous matches.
|
||||
*/
|
||||
resolve(name: string, fromFile: string): TieredCandidates | null;
|
||||
|
||||
// --- Data access (for pipeline wiring, not resolution) ---
|
||||
/** Symbol table — used by parsing-processor to populate symbols. */
|
||||
readonly symbols: SymbolTable;
|
||||
/** Raw maps — used by import-processor to populate import data. */
|
||||
readonly importMap: ImportMap;
|
||||
readonly packageMap: PackageMap;
|
||||
readonly namedImportMap: NamedImportMap;
|
||||
/** Module-alias map for Python namespace imports: callerFile → (alias → sourceFile). */
|
||||
readonly moduleAliasMap: ModuleAliasMap;
|
||||
|
||||
// --- Per-file cache lifecycle ---
|
||||
enableCache(filePath: string): void;
|
||||
clearCache(): void;
|
||||
|
||||
// --- Operational ---
|
||||
getStats(): {
|
||||
fileCount: number;
|
||||
globalSymbolCount: number;
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
};
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export const createResolutionContext = (): ResolutionContext => {
|
||||
const symbols = createSymbolTable();
|
||||
const importMap: ImportMap = new Map();
|
||||
const packageMap: PackageMap = new Map();
|
||||
const namedImportMap: NamedImportMap = new Map();
|
||||
const moduleAliasMap: ModuleAliasMap = new Map();
|
||||
|
||||
// Per-file cache state
|
||||
let cacheFile: string | null = null;
|
||||
let cache: Map<string, TieredCandidates | null> | null = null;
|
||||
let cacheHits = 0;
|
||||
let cacheMisses = 0;
|
||||
|
||||
// --- Core resolution (single implementation of tier logic) ---
|
||||
|
||||
const resolveUncached = (name: string, fromFile: string): TieredCandidates | null => {
|
||||
// Tier 1: Same file — authoritative match (returns all overloads)
|
||||
const localDefs = symbols.lookupExactAll(fromFile, name);
|
||||
if (localDefs.length > 0) {
|
||||
return { candidates: localDefs, tier: 'same-file' };
|
||||
}
|
||||
|
||||
// Get all global definitions for subsequent tiers
|
||||
const allDefs = symbols.lookupFuzzy(name);
|
||||
|
||||
// Tier 2a-named: Check named bindings BEFORE empty-allDefs early return
|
||||
// because aliased imports mean lookupFuzzy('U') returns empty but we
|
||||
// can resolve via the exported name.
|
||||
const chainResult = walkBindingChain(name, fromFile, symbols, namedImportMap, allDefs);
|
||||
if (chainResult && chainResult.length > 0) {
|
||||
return { candidates: chainResult, tier: 'import-scoped' };
|
||||
}
|
||||
|
||||
if (allDefs.length === 0) return null;
|
||||
|
||||
// Tier 2a: Import-scoped — definition in a file imported by fromFile
|
||||
const importedFiles = importMap.get(fromFile);
|
||||
if (importedFiles) {
|
||||
const importedDefs = allDefs.filter((def) => importedFiles.has(def.filePath));
|
||||
if (importedDefs.length > 0) {
|
||||
return { candidates: importedDefs, tier: 'import-scoped' };
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2b: Package-scoped — definition in a package dir imported by fromFile
|
||||
const importedPackages = packageMap.get(fromFile);
|
||||
if (importedPackages) {
|
||||
const packageDefs = allDefs.filter((def) => {
|
||||
for (const dirSuffix of importedPackages) {
|
||||
if (isFileInPackageDir(def.filePath, dirSuffix)) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (packageDefs.length > 0) {
|
||||
return { candidates: packageDefs, tier: 'import-scoped' };
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: Global — pass all candidates through.
|
||||
// Consumers must check candidate count and refuse ambiguous matches.
|
||||
return { candidates: allDefs, tier: 'global' };
|
||||
};
|
||||
|
||||
const resolve = (name: string, fromFile: string): TieredCandidates | null => {
|
||||
// Check cache (only when enabled AND fromFile matches cached file)
|
||||
if (cache && cacheFile === fromFile) {
|
||||
if (cache.has(name)) {
|
||||
cacheHits++;
|
||||
return cache.get(name)!;
|
||||
}
|
||||
cacheMisses++;
|
||||
}
|
||||
|
||||
const result = resolveUncached(name, fromFile);
|
||||
|
||||
// Store in cache if active and file matches
|
||||
if (cache && cacheFile === fromFile) {
|
||||
cache.set(name, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// --- Cache lifecycle ---
|
||||
|
||||
const enableCache = (filePath: string): void => {
|
||||
cacheFile = filePath;
|
||||
if (!cache) cache = new Map();
|
||||
else cache.clear();
|
||||
};
|
||||
|
||||
const clearCache = (): void => {
|
||||
cacheFile = null;
|
||||
// Reuse the Map instance — just clear entries to reduce GC pressure at scale.
|
||||
cache?.clear();
|
||||
};
|
||||
|
||||
const getStats = () => ({
|
||||
...symbols.getStats(),
|
||||
cacheHits,
|
||||
cacheMisses,
|
||||
});
|
||||
|
||||
const clear = (): void => {
|
||||
symbols.clear();
|
||||
importMap.clear();
|
||||
packageMap.clear();
|
||||
namedImportMap.clear();
|
||||
moduleAliasMap.clear();
|
||||
clearCache();
|
||||
cacheHits = 0;
|
||||
cacheMisses = 0;
|
||||
};
|
||||
|
||||
return {
|
||||
resolve,
|
||||
symbols,
|
||||
importMap,
|
||||
packageMap,
|
||||
namedImportMap,
|
||||
moduleAliasMap,
|
||||
enableCache,
|
||||
clearCache,
|
||||
getStats,
|
||||
clear,
|
||||
};
|
||||
};
|
||||
345
gitnexus/src/core/ingestion/symbol-table.ts
Normal file
345
gitnexus/src/core/ingestion/symbol-table.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
|
||||
export const CLASS_TYPES = new Set(['Class', 'Struct', 'Interface', 'Enum', 'Record']);
|
||||
|
||||
export interface SymbolDefinition {
|
||||
nodeId: string;
|
||||
filePath: string;
|
||||
type: NodeLabel;
|
||||
/** Canonical dot-separated qualified type name for class-like symbols
|
||||
* (e.g. `App.Models.User`). Falls back to the simple symbol name when no
|
||||
* package/namespace/module scope exists or no explicit qualified metadata is provided. */
|
||||
qualifiedName?: string;
|
||||
parameterCount?: number;
|
||||
/** Number of required (non-optional, non-default) parameters.
|
||||
* Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */
|
||||
requiredParameterCount?: number;
|
||||
/** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']).
|
||||
* Populated when parameter types are resolvable from AST (any typed language).
|
||||
* Used for disambiguation in overloading languages (Java, Kotlin, C#, C++). */
|
||||
parameterTypes?: string[];
|
||||
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<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[];
|
||||
|
||||
/**
|
||||
* Low Confidence: Look for a symbol anywhere in the project
|
||||
* Used when imports are missing or for framework magic
|
||||
*/
|
||||
lookupFuzzy: (name: string) => SymbolDefinition[];
|
||||
|
||||
/**
|
||||
* Low Confidence: Look for callable symbols (Function/Method/Constructor) by name.
|
||||
* Faster than `lookupFuzzy` + filter — backed by a lazy callable-only index.
|
||||
* Used by ReturnTypeLookup to resolve callee → return type.
|
||||
*/
|
||||
lookupFuzzyCallable: (name: string) => SymbolDefinition[];
|
||||
|
||||
/**
|
||||
* Look up a field/property by its owning class nodeId and field name.
|
||||
* O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0fieldName`.
|
||||
* Returns undefined when no matching property exists or the owner is ambiguous.
|
||||
*/
|
||||
lookupFieldByOwner: (ownerNodeId: string, fieldName: string) => SymbolDefinition | undefined;
|
||||
|
||||
/**
|
||||
* Look up a method by its owning class nodeId and method name.
|
||||
* O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0methodName`.
|
||||
* For overloaded methods (same owner + name): returns the first match when all
|
||||
* overloads share the same returnType, undefined when return types differ (ambiguous).
|
||||
* Used by walkMixedChain for deterministic cross-class chain resolution.
|
||||
*/
|
||||
lookupMethodByOwner: (ownerNodeId: string, methodName: string) => SymbolDefinition | undefined;
|
||||
|
||||
/**
|
||||
* Look up class-like definitions (Class, Struct, Interface, Enum, Record) by name.
|
||||
* O(1) via dedicated eagerly-populated index keyed by symbol name.
|
||||
* Returns all matching definitions across files (e.g. partial classes).
|
||||
* Used by Phase 1 semantic-model tasks to replace filtered lookupFuzzy calls.
|
||||
*/
|
||||
lookupClassByName: (name: string) => SymbolDefinition[];
|
||||
|
||||
/**
|
||||
* Look up class-like definitions by canonical qualified name.
|
||||
* Qualified names are normalized to dot-separated scope segments across languages,
|
||||
* e.g. `App.Models.User`, `com.example.User`, or `Admin.User`.
|
||||
* Top-level class-like symbols with no explicit scope are indexed under their simple name.
|
||||
*/
|
||||
lookupClassByQualifiedName: (qualifiedName: string) => SymbolDefinition[];
|
||||
|
||||
/**
|
||||
* Debugging: See how many symbols are tracked
|
||||
*/
|
||||
getStats: () => { fileCount: number; globalSymbolCount: number };
|
||||
|
||||
/**
|
||||
* Cleanup memory
|
||||
*/
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const createSymbolTable = (): SymbolTable => {
|
||||
// 1. File-Specific Index — stores full SymbolDefinition(s) for O(1) lookup.
|
||||
// Structure: FilePath -> (SymbolName -> SymbolDefinition[])
|
||||
// Array allows overloaded methods (same name, different signatures) to coexist.
|
||||
const fileIndex = new Map<string, Map<string, SymbolDefinition[]>>();
|
||||
|
||||
// 2. Global Reverse Index (The "Backup")
|
||||
// Structure: SymbolName -> [List of Definitions]
|
||||
const globalIndex = new Map<string, SymbolDefinition[]>();
|
||||
|
||||
// 3. Lazy Callable Index — populated on first lookupFuzzyCallable call.
|
||||
// Structure: SymbolName -> [Callable Definitions]
|
||||
// Only Function, Method, Constructor symbols are indexed.
|
||||
let callableIndex: Map<string, SymbolDefinition[]> | null = null;
|
||||
|
||||
// 4. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName".
|
||||
// Only Property symbols with ownerId and declaredType are indexed.
|
||||
const fieldByOwner = new Map<string, SymbolDefinition>();
|
||||
|
||||
// 5. Eagerly-populated Method Index — keyed by "ownerNodeId\0methodName".
|
||||
// Method symbols with ownerId are indexed. Supports overloads (array values).
|
||||
const methodByOwner = new Map<string, SymbolDefinition[]>();
|
||||
|
||||
// 6. 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[]>();
|
||||
|
||||
const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']);
|
||||
|
||||
const add = (
|
||||
filePath: string,
|
||||
name: string,
|
||||
nodeId: string,
|
||||
type: NodeLabel,
|
||||
metadata?: {
|
||||
parameterCount?: number;
|
||||
requiredParameterCount?: number;
|
||||
parameterTypes?: string[];
|
||||
returnType?: string;
|
||||
declaredType?: string;
|
||||
ownerId?: string;
|
||||
qualifiedName?: string;
|
||||
},
|
||||
) => {
|
||||
const qualifiedName = CLASS_TYPES.has(type)
|
||||
? (metadata?.qualifiedName ?? name)
|
||||
: metadata?.qualifiedName;
|
||||
const def: SymbolDefinition = {
|
||||
nodeId,
|
||||
filePath,
|
||||
type,
|
||||
...(qualifiedName !== undefined ? { qualifiedName } : {}),
|
||||
...(metadata?.parameterCount !== undefined
|
||||
? { parameterCount: metadata.parameterCount }
|
||||
: {}),
|
||||
...(metadata?.requiredParameterCount !== undefined
|
||||
? { requiredParameterCount: metadata.requiredParameterCount }
|
||||
: {}),
|
||||
...(metadata?.parameterTypes !== undefined
|
||||
? { parameterTypes: metadata.parameterTypes }
|
||||
: {}),
|
||||
...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}),
|
||||
...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}),
|
||||
...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}),
|
||||
};
|
||||
|
||||
// A. Add to File Index (shared reference — zero additional memory)
|
||||
if (!fileIndex.has(filePath)) {
|
||||
fileIndex.set(filePath, new Map());
|
||||
}
|
||||
const fileMap = fileIndex.get(filePath)!;
|
||||
if (!fileMap.has(name)) {
|
||||
fileMap.set(name, [def]);
|
||||
} else {
|
||||
fileMap.get(name)!.push(def);
|
||||
}
|
||||
|
||||
// B. Properties go to fieldByOwner index only — skip globalIndex to prevent
|
||||
// namespace pollution for common names like 'id', 'name', 'type'.
|
||||
// Index ALL properties (even without declaredType) so write-access tracking
|
||||
// can resolve field ownership for dynamically-typed languages (Ruby, JS).
|
||||
if (type === 'Property' && metadata?.ownerId) {
|
||||
fieldByOwner.set(`${metadata.ownerId}\0${name}`, def);
|
||||
// Still add to fileIndex above (for lookupExact), but skip globalIndex
|
||||
return;
|
||||
}
|
||||
|
||||
// C. Add to Global Index (same object reference)
|
||||
if (!globalIndex.has(name)) {
|
||||
globalIndex.set(name, []);
|
||||
}
|
||||
globalIndex.get(name)!.push(def);
|
||||
|
||||
// C2. Methods with ownerId go to methodByOwner index (in addition to globalIndex).
|
||||
if (type === 'Method' && metadata?.ownerId) {
|
||||
const key = `${metadata.ownerId}\0${name}`;
|
||||
const existing = methodByOwner.get(key);
|
||||
if (existing) {
|
||||
existing.push(def);
|
||||
} else {
|
||||
methodByOwner.set(key, [def]);
|
||||
}
|
||||
}
|
||||
|
||||
// C3. Class-like types go to classByName index (in addition to globalIndex).
|
||||
if (CLASS_TYPES.has(type)) {
|
||||
const existing = classByName.get(name);
|
||||
if (existing) {
|
||||
existing.push(def);
|
||||
} else {
|
||||
classByName.set(name, [def]);
|
||||
}
|
||||
|
||||
const qualifiedKey = qualifiedName ?? name;
|
||||
const qualifiedMatches = classByQualifiedName.get(qualifiedKey);
|
||||
if (qualifiedMatches) {
|
||||
qualifiedMatches.push(def);
|
||||
} else {
|
||||
classByQualifiedName.set(qualifiedKey, [def]);
|
||||
}
|
||||
}
|
||||
|
||||
// D. Invalidate the lazy callable index only when adding callable types
|
||||
if (CALLABLE_TYPES.has(type)) {
|
||||
callableIndex = null;
|
||||
}
|
||||
};
|
||||
|
||||
const lookupExact = (filePath: string, name: string): string | undefined => {
|
||||
const defs = fileIndex.get(filePath)?.get(name);
|
||||
return defs?.[0]?.nodeId;
|
||||
};
|
||||
|
||||
const lookupExactFull = (filePath: string, name: string): SymbolDefinition | undefined => {
|
||||
const defs = fileIndex.get(filePath)?.get(name);
|
||||
return defs?.[0];
|
||||
};
|
||||
|
||||
const lookupExactAll = (filePath: string, name: string): SymbolDefinition[] => {
|
||||
return fileIndex.get(filePath)?.get(name) ?? [];
|
||||
};
|
||||
|
||||
const lookupFuzzy = (name: string): SymbolDefinition[] => {
|
||||
return globalIndex.get(name) || [];
|
||||
};
|
||||
|
||||
const lookupFuzzyCallable = (name: string): SymbolDefinition[] => {
|
||||
if (!callableIndex) {
|
||||
// Build the callable index lazily on first use
|
||||
callableIndex = new Map();
|
||||
for (const [symName, defs] of globalIndex) {
|
||||
const callables = defs.filter((d) => CALLABLE_TYPES.has(d.type));
|
||||
if (callables.length > 0) callableIndex.set(symName, callables);
|
||||
}
|
||||
}
|
||||
return callableIndex.get(name) ?? [];
|
||||
};
|
||||
|
||||
const lookupFieldByOwner = (
|
||||
ownerNodeId: string,
|
||||
fieldName: string,
|
||||
): SymbolDefinition | undefined => {
|
||||
return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`);
|
||||
};
|
||||
|
||||
const lookupMethodByOwner = (
|
||||
ownerNodeId: string,
|
||||
methodName: string,
|
||||
): SymbolDefinition | undefined => {
|
||||
const defs = methodByOwner.get(`${ownerNodeId}\0${methodName}`);
|
||||
if (!defs || defs.length === 0) return undefined;
|
||||
if (defs.length === 1) return defs[0];
|
||||
// Multiple overloads: return first if all share the same defined returnType (safe for chain resolution).
|
||||
// Return undefined if return types differ or are absent (truly ambiguous — can't determine which overload).
|
||||
const firstReturnType = defs[0].returnType;
|
||||
if (firstReturnType === undefined) return undefined;
|
||||
for (let i = 1; i < defs.length; i++) {
|
||||
if (defs[i].returnType !== firstReturnType) return undefined;
|
||||
}
|
||||
return defs[0];
|
||||
};
|
||||
|
||||
const lookupClassByName = (name: string): SymbolDefinition[] => {
|
||||
return classByName.get(name) ?? [];
|
||||
};
|
||||
|
||||
const lookupClassByQualifiedName = (qualifiedName: string): SymbolDefinition[] => {
|
||||
return classByQualifiedName.get(qualifiedName) ?? [];
|
||||
};
|
||||
|
||||
const getStats = () => ({
|
||||
fileCount: fileIndex.size,
|
||||
globalSymbolCount: globalIndex.size,
|
||||
});
|
||||
|
||||
const clear = () => {
|
||||
fileIndex.clear();
|
||||
globalIndex.clear();
|
||||
callableIndex = null;
|
||||
fieldByOwner.clear();
|
||||
methodByOwner.clear();
|
||||
classByName.clear();
|
||||
classByQualifiedName.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
add,
|
||||
lookupExact,
|
||||
lookupExactFull,
|
||||
lookupExactAll,
|
||||
lookupFuzzy,
|
||||
lookupFuzzyCallable,
|
||||
lookupFieldByOwner,
|
||||
lookupMethodByOwner,
|
||||
lookupClassByName,
|
||||
lookupClassByQualifiedName,
|
||||
getStats,
|
||||
clear,
|
||||
};
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue