Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a34669b2ba | ||
|
|
ae3455069d | ||
|
|
0733670f34 | ||
|
|
6a4e220f20 | ||
|
|
bfb40b7e20 | ||
|
|
4746675692 |
@@ -1,257 +0,0 @@
|
||||
"""Pure math utilities for triage sweep embedding analysis.
|
||||
|
||||
All functions are stateless and perform no I/O (except model loading by FastEmbed).
|
||||
Each function operates on numpy arrays and returns numpy arrays or plain Python types.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
from fastembed import TextEmbedding
|
||||
from sklearn.decomposition import PCA
|
||||
from sklearn.covariance import EllipticEnvelope
|
||||
from sklearn.metrics.pairwise import cosine_similarity
|
||||
|
||||
# FastEmbed model — BAAI/bge-small-en-v1.5 produces 384-dimensional embeddings.
|
||||
# ~46MB quantized ONNX, runs on CPU in ~0.5s per batch of 32.
|
||||
EMBEDDING_MODEL: str = "BAAI/bge-small-en-v1.5"
|
||||
|
||||
# Embedding dimensionality (determined by model choice).
|
||||
EMBEDDING_DIM: int = 384
|
||||
|
||||
# Batch size for FastEmbed. 32 balances memory and throughput on
|
||||
# a 2-vCPU GitHub Actions runner with ~7GB RAM.
|
||||
EMBEDDING_BATCH_SIZE: int = 32
|
||||
|
||||
|
||||
def embed_texts(texts: list[str]) -> NDArray[np.float32]:
|
||||
"""Embed a list of texts into dense vectors using FastEmbed.
|
||||
|
||||
Returns an array of shape (len(texts), 384) with dtype float32.
|
||||
Empty input returns a (0, 384) array.
|
||||
"""
|
||||
if not texts:
|
||||
return np.empty((0, EMBEDDING_DIM), dtype=np.float32)
|
||||
|
||||
model = TextEmbedding(model_name=EMBEDDING_MODEL)
|
||||
vectors = list(model.embed(texts, batch_size=EMBEDDING_BATCH_SIZE))
|
||||
return np.vstack(vectors).astype(np.float32)
|
||||
|
||||
|
||||
def normalize_rows(matrix: NDArray[np.float32]) -> NDArray[np.float32]:
|
||||
"""L2-normalize each row to unit length.
|
||||
|
||||
Zero-norm rows (e.g. from empty text) remain zero vectors.
|
||||
Uses eps=1e-10 in the denominator to avoid division by zero.
|
||||
"""
|
||||
if matrix.shape[0] == 0:
|
||||
return matrix
|
||||
|
||||
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
|
||||
return matrix / (norms + 1e-10)
|
||||
|
||||
|
||||
def reduce_dimensions(
|
||||
matrix: NDArray[np.float32],
|
||||
max_components: int,
|
||||
) -> NDArray[np.float32]:
|
||||
"""Reduce dimensionality via PCA.
|
||||
|
||||
Computes n_components = min(max_components, n-1, d). If n_components < 1,
|
||||
returns the matrix unchanged. Logs explained variance for observability.
|
||||
"""
|
||||
n, d = matrix.shape
|
||||
if n <= 1:
|
||||
return matrix
|
||||
|
||||
n_components = min(max_components, n - 1, d)
|
||||
if n_components < 1:
|
||||
return matrix
|
||||
|
||||
pca = PCA(n_components=n_components)
|
||||
reduced = pca.fit_transform(matrix)
|
||||
explained = pca.explained_variance_ratio_.sum()
|
||||
print(f"PCA: {d}d -> {n_components}d, explained variance: {explained:.3f}")
|
||||
return reduced.astype(np.float32)
|
||||
|
||||
|
||||
def detect_outliers(
|
||||
matrix: NDArray[np.float32],
|
||||
contamination: float = 0.1,
|
||||
iqr_multiplier: float = 3.0,
|
||||
max_outlier_pct: float = 0.05,
|
||||
) -> list[tuple[int, float]]:
|
||||
"""Flag items whose Mahalanobis distance exceeds an IQR-based cutoff.
|
||||
|
||||
Uses EllipticEnvelope (robust covariance via MCD) to estimate the
|
||||
multivariate Gaussian, then computes sqrt(squared Mahalanobis distance)
|
||||
for each sample. The cutoff is Q75 + iqr_multiplier * IQR, which
|
||||
adapts to the actual distribution of distances.
|
||||
|
||||
A hard cap ensures no more than max_outlier_pct * n items are flagged;
|
||||
when the cap is hit, only the most extreme items (sorted by distance
|
||||
descending) are kept.
|
||||
|
||||
Returns (index, distance) tuples sorted by index ascending, along with
|
||||
the cutoff value stored as an attribute on the returned list.
|
||||
"""
|
||||
n = matrix.shape[0]
|
||||
if n < 2:
|
||||
return []
|
||||
|
||||
envelope = EllipticEnvelope(contamination=contamination, random_state=42)
|
||||
envelope.fit(matrix)
|
||||
|
||||
# .mahalanobis() returns squared Mahalanobis distances
|
||||
distances = np.sqrt(envelope.mahalanobis(matrix))
|
||||
|
||||
# IQR-based cutoff
|
||||
q25, q75 = np.percentile(distances, [25, 75])
|
||||
iqr = q75 - q25
|
||||
cutoff = q75 + iqr_multiplier * iqr
|
||||
|
||||
outlier_mask = distances > cutoff
|
||||
indices = np.where(outlier_mask)[0]
|
||||
|
||||
# Hard cap: keep at most max_outlier_pct * n items
|
||||
max_count = max(1, int(max_outlier_pct * n))
|
||||
if len(indices) > max_count:
|
||||
# Sort by distance descending, take the most extreme
|
||||
sorted_by_dist = sorted(indices, key=lambda i: distances[i], reverse=True)
|
||||
indices = np.array(sorted_by_dist[:max_count])
|
||||
|
||||
# Sort by index ascending for stable output
|
||||
indices = np.sort(indices)
|
||||
result = [(int(idx), float(distances[idx])) for idx in indices]
|
||||
|
||||
# Attach cutoff as metadata so the report can use it
|
||||
result = _OutlierResult(result) # type: ignore[assignment]
|
||||
result.cutoff = float(cutoff) # type: ignore[attr-defined]
|
||||
return result # type: ignore[return-value]
|
||||
|
||||
|
||||
class _OutlierResult(list):
|
||||
"""A list subclass that carries metadata (cutoff) from outlier detection."""
|
||||
cutoff: float = 0.0
|
||||
|
||||
|
||||
def find_duplicate_pairs(
|
||||
matrix: NDArray[np.float32],
|
||||
threshold: float,
|
||||
) -> list[tuple[int, int, float]]:
|
||||
"""Find pairs of items with cosine similarity above threshold.
|
||||
|
||||
Returns (i, j, similarity) tuples where i < j. The input should be
|
||||
L2-normalized embeddings (full dimensionality, not PCA-reduced) so
|
||||
cosine similarity equals the dot product.
|
||||
"""
|
||||
n = matrix.shape[0]
|
||||
if n <= 1:
|
||||
return []
|
||||
|
||||
sim_matrix = cosine_similarity(matrix)
|
||||
# Upper triangle indices (i < j), excluding diagonal
|
||||
rows, cols = np.triu_indices(n, k=1)
|
||||
similarities = sim_matrix[rows, cols]
|
||||
|
||||
mask = similarities > threshold
|
||||
pairs: list[tuple[int, int, float]] = []
|
||||
for idx in np.where(mask)[0]:
|
||||
pairs.append((int(rows[idx]), int(cols[idx]), float(similarities[idx])))
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
# ── Label suggestion via z-score normalized embedding similarity ──────
|
||||
|
||||
# Z-score threshold: a label must be this many standard deviations above
|
||||
# the column mean to be considered a match.
|
||||
LABEL_Z_THRESHOLD: float = 1.5
|
||||
|
||||
# Margin gate: the top-1 label must beat the second-best by this many
|
||||
# z-score units to be accepted (subsequent labels don't need a margin).
|
||||
LABEL_Z_MARGIN: float = 0.5
|
||||
|
||||
# Floor for per-column standard deviation to avoid division by near-zero.
|
||||
LABEL_Z_STD_FLOOR: float = 0.01
|
||||
|
||||
# Minimum raw cosine similarity required even if z-score is high.
|
||||
# Prevents suggesting labels that are "relatively best" but still poor.
|
||||
MIN_RAW_SIMILARITY: float = 0.3
|
||||
|
||||
# Maximum number of labels to suggest per item.
|
||||
MAX_LABELS_PER_ITEM: int = 3
|
||||
|
||||
|
||||
def suggest_labels(
|
||||
item_embeddings: NDArray[np.float32],
|
||||
label_embeddings: NDArray[np.float32],
|
||||
label_names: list[str],
|
||||
z_threshold: float = LABEL_Z_THRESHOLD,
|
||||
z_margin: float = LABEL_Z_MARGIN,
|
||||
std_floor: float = LABEL_Z_STD_FLOOR,
|
||||
min_raw_sim: float = MIN_RAW_SIMILARITY,
|
||||
max_per_item: int = MAX_LABELS_PER_ITEM,
|
||||
) -> list[list[tuple[str, float]]]:
|
||||
"""Suggest labels for each item using z-score normalized similarity.
|
||||
|
||||
1. Compute raw cosine similarity matrix (n items x m labels).
|
||||
2. Column-wise z-score: for each label j, normalize across all items.
|
||||
3. For each item, rank labels by z-score descending.
|
||||
4. Accept a label only if z >= z_threshold AND raw_sim >= min_raw_sim.
|
||||
5. Margin gate: the top-1 label must beat #2 by z_margin; subsequent
|
||||
labels don't need a margin.
|
||||
6. Cap at max_per_item.
|
||||
|
||||
Returns a list of length n, where each element is a list of
|
||||
(label_name, raw_similarity) tuples. Empty list if nothing qualifies.
|
||||
"""
|
||||
n = item_embeddings.shape[0]
|
||||
m = label_embeddings.shape[0]
|
||||
if n == 0 or m == 0:
|
||||
return [[] for _ in range(n)]
|
||||
|
||||
# (n, m) raw similarity matrix
|
||||
sim_matrix = cosine_similarity(item_embeddings, label_embeddings)
|
||||
|
||||
# Column-wise z-score normalization
|
||||
col_means = sim_matrix.mean(axis=0) # shape (m,)
|
||||
col_stds = sim_matrix.std(axis=0) # shape (m,)
|
||||
col_stds = np.maximum(col_stds, std_floor)
|
||||
z_matrix = (sim_matrix - col_means) / col_stds
|
||||
|
||||
suggestions: list[list[tuple[str, float]]] = []
|
||||
for i in range(n):
|
||||
z_row = z_matrix[i]
|
||||
raw_row = sim_matrix[i]
|
||||
|
||||
# Rank labels by z-score descending
|
||||
ranked = np.argsort(z_row)[::-1]
|
||||
|
||||
item_labels: list[tuple[str, float]] = []
|
||||
|
||||
# Margin gate: top-1 z-score must beat #2 by z_margin.
|
||||
# If not, the assignment is ambiguous — skip this item entirely.
|
||||
if len(ranked) > 1:
|
||||
top1_z = float(z_row[ranked[0]])
|
||||
top2_z = float(z_row[ranked[1]])
|
||||
if top1_z - top2_z < z_margin:
|
||||
suggestions.append(item_labels)
|
||||
continue
|
||||
|
||||
for rank_pos, idx in enumerate(ranked):
|
||||
if len(item_labels) >= max_per_item:
|
||||
break
|
||||
|
||||
z_val = float(z_row[idx])
|
||||
raw_val = float(raw_row[idx])
|
||||
|
||||
# Must pass both z-threshold and raw similarity floor
|
||||
if z_val < z_threshold or raw_val < min_raw_sim:
|
||||
continue
|
||||
|
||||
item_labels.append((label_names[idx], raw_val))
|
||||
|
||||
suggestions.append(item_labels)
|
||||
|
||||
return suggestions
|
||||
@@ -1,4 +0,0 @@
|
||||
fastembed>=0.5.0
|
||||
numpy>=1.26.0
|
||||
scikit-learn>=1.4.0
|
||||
scipy>=1.10.0
|
||||
@@ -1,600 +0,0 @@
|
||||
"""Triage sweep: fetch open issues/PRs, detect outliers and duplicates, generate a report.
|
||||
|
||||
Entrypoint script for the triage-sweep workflow. Fetches all open items via
|
||||
the GitHub REST API, delegates embedding and analysis to embedding_utils,
|
||||
generates a markdown report, and optionally creates a report issue.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from typing import TypedDict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from embedding_utils import (
|
||||
embed_texts,
|
||||
normalize_rows,
|
||||
reduce_dimensions,
|
||||
detect_outliers,
|
||||
find_duplicate_pairs,
|
||||
suggest_labels,
|
||||
LABEL_Z_THRESHOLD,
|
||||
LABEL_Z_MARGIN,
|
||||
LABEL_Z_STD_FLOOR,
|
||||
MIN_RAW_SIMILARITY,
|
||||
MAX_LABELS_PER_ITEM,
|
||||
)
|
||||
|
||||
# ── Thresholds (overridable via workflow_dispatch inputs) ──────────────
|
||||
|
||||
# IQR multiplier for outlier cutoff: cutoff = Q75 + IQR_MULTIPLIER * IQR.
|
||||
IQR_MULTIPLIER: float = float(os.environ.get("INPUT_IQR_MULTIPLIER", "3.0"))
|
||||
|
||||
# Hard cap: at most this fraction of items can be flagged as outliers.
|
||||
MAX_OUTLIER_PCT: float = float(os.environ.get("INPUT_MAX_OUTLIER_PCT", "0.05"))
|
||||
|
||||
# EllipticEnvelope contamination: expected fraction of outliers in the data.
|
||||
# Governs how aggressively the robust covariance downweights extreme points.
|
||||
CONTAMINATION: float = float(os.environ.get("INPUT_CONTAMINATION", "0.1"))
|
||||
|
||||
# Cosine similarity above which two items are flagged as duplicates.
|
||||
# 0.92 catches near-identical issues while tolerating paraphrasing.
|
||||
COSINE_THRESHOLD: float = float(os.environ.get("INPUT_COSINE_THRESHOLD", "0.92"))
|
||||
|
||||
# Hard cap on items to process. Prevents runaway costs on very large repos.
|
||||
MAX_ITEMS: int = int(os.environ.get("INPUT_MAX_ITEMS", "500"))
|
||||
|
||||
# When true, print report to stdout/file but do not create a GitHub issue.
|
||||
DRY_RUN: bool = os.environ.get("INPUT_DRY_RUN", "false").lower() == "true"
|
||||
|
||||
# ── Fixed constants (not user-configurable) ───────────────────────────
|
||||
|
||||
# Minimum number of samples required for EllipticEnvelope to fit
|
||||
# a Gaussian reliably. Must be >= 3 * PCA_MAX_COMPONENTS so the
|
||||
# covariance matrix is estimated from enough data points.
|
||||
PCA_MAX_COMPONENTS: int = 20
|
||||
MIN_SAMPLES_FOR_OUTLIER_DETECTION: int = 100
|
||||
|
||||
# Max character length for embedding input text. bge-small-en-v1.5 has a
|
||||
# 512-token context window (~4 chars/token). We keep title + body under
|
||||
# this limit so the model sees the full text instead of silently truncating.
|
||||
MAX_EMBED_CHARS: int = 2000
|
||||
|
||||
# GitHub REST API page size (max allowed is 100).
|
||||
API_PAGE_SIZE: int = 100
|
||||
|
||||
# Report issue label.
|
||||
REPORT_LABEL: str = "triage-report"
|
||||
|
||||
# Report file path (written for the summary step to pick up).
|
||||
REPORT_FILE: str = "/tmp/triage-report.md"
|
||||
|
||||
|
||||
class TriageItem(TypedDict):
|
||||
"""One open issue or PR, with only the fields we need."""
|
||||
number: int
|
||||
title: str
|
||||
html_url: str
|
||||
is_pr: bool
|
||||
labels: list[str]
|
||||
created_at: str
|
||||
# title + body concatenated, used as embedding input
|
||||
text: str
|
||||
|
||||
|
||||
def github_api_get(path: str) -> list[dict]:
|
||||
"""Make a single authenticated GET request to the GitHub REST API.
|
||||
|
||||
Reads GITHUB_TOKEN and GITHUB_REPOSITORY from env. Raises SystemExit
|
||||
with the HTTP status and response body on any non-2xx response.
|
||||
"""
|
||||
token = os.environ["GITHUB_TOKEN"]
|
||||
repo = os.environ["GITHUB_REPOSITORY"]
|
||||
url = f"https://api.github.com/repos/{repo}{path}"
|
||||
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Accept", "application/vnd.github+json")
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
req.add_header("X-GitHub-Api-Version", "2022-11-28")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
print(f"::error::GitHub API {e.code}: {body}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def fetch_all_open_items() -> list[TriageItem]:
|
||||
"""Paginate through all open issues and PRs.
|
||||
|
||||
Returns up to MAX_ITEMS TriageItem dicts. Items with a pull_request
|
||||
key are marked is_pr=True. The text field is title + body concatenated.
|
||||
"""
|
||||
items: list[TriageItem] = []
|
||||
page = 1
|
||||
|
||||
while len(items) < MAX_ITEMS:
|
||||
path = (
|
||||
f"/issues?state=open&per_page={API_PAGE_SIZE}"
|
||||
f"&sort=created&direction=desc&page={page}"
|
||||
)
|
||||
data = github_api_get(path)
|
||||
|
||||
if not data:
|
||||
break
|
||||
|
||||
for raw in data:
|
||||
if len(items) >= MAX_ITEMS:
|
||||
break
|
||||
|
||||
body = raw.get("body", "") or ""
|
||||
full_text = f"{raw['title']}\n\n{body}"
|
||||
# Truncate to fit the embedding model's token window.
|
||||
# Title is always preserved; body gets clipped if needed.
|
||||
if len(full_text) > MAX_EMBED_CHARS:
|
||||
full_text = full_text[:MAX_EMBED_CHARS]
|
||||
items.append(TriageItem(
|
||||
number=raw["number"],
|
||||
title=raw["title"],
|
||||
html_url=raw["html_url"],
|
||||
is_pr="pull_request" in raw,
|
||||
labels=[lbl["name"] for lbl in raw.get("labels", [])],
|
||||
created_at=raw["created_at"],
|
||||
text=full_text,
|
||||
))
|
||||
|
||||
if len(data) < API_PAGE_SIZE:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
return items
|
||||
|
||||
|
||||
class RepoLabel(TypedDict):
|
||||
"""A label from the repo with its embedding text."""
|
||||
name: str
|
||||
description: str
|
||||
# "name: description" concatenated for embedding
|
||||
text: str
|
||||
|
||||
|
||||
def fetch_repo_labels() -> list[RepoLabel]:
|
||||
"""Fetch all labels from the repository, paginating if needed.
|
||||
|
||||
Returns labels with name, description, and a text field suitable
|
||||
for embedding ("name: description"). Labels with no description
|
||||
use just the name.
|
||||
"""
|
||||
labels: list[RepoLabel] = []
|
||||
page = 1
|
||||
|
||||
while True:
|
||||
data = github_api_get(f"/labels?per_page={API_PAGE_SIZE}&page={page}")
|
||||
for raw in data:
|
||||
name = raw["name"]
|
||||
desc = raw.get("description", "") or ""
|
||||
text = f"{name}: {desc}" if desc else name
|
||||
labels.append(RepoLabel(name=name, description=desc, text=text))
|
||||
|
||||
if len(data) < API_PAGE_SIZE:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return labels
|
||||
|
||||
|
||||
def apply_labels_to_item(item_number: int, labels: list[str]) -> None:
|
||||
"""Add labels to a single issue/PR via the GitHub API.
|
||||
|
||||
Skips silently if labels list is empty. Uses POST which adds labels
|
||||
without removing existing ones.
|
||||
"""
|
||||
if not labels:
|
||||
return
|
||||
|
||||
token = os.environ["GITHUB_TOKEN"]
|
||||
repo = os.environ["GITHUB_REPOSITORY"]
|
||||
url = f"https://api.github.com/repos/{repo}/issues/{item_number}/labels"
|
||||
|
||||
payload = json.dumps({"labels": labels}).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=payload, method="POST")
|
||||
req.add_header("Accept", "application/vnd.github+json")
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
req.add_header("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
# Non-fatal: log warning but don't abort the sweep
|
||||
print(f"::warning::Failed to label #{item_number}: {e.code} {body}")
|
||||
|
||||
|
||||
def _item_age(created_at: str) -> str:
|
||||
"""Compute a human-readable age string from an ISO 8601 created_at timestamp."""
|
||||
try:
|
||||
created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
||||
delta = datetime.now(timezone.utc) - created
|
||||
days = delta.days
|
||||
if days < 1:
|
||||
return "<1d"
|
||||
if days < 30:
|
||||
return f"{days}d"
|
||||
if days < 365:
|
||||
return f"{days // 30}mo"
|
||||
return f"{days // 365}y"
|
||||
except (ValueError, TypeError):
|
||||
return "?"
|
||||
|
||||
|
||||
def _suggested_action(a: TriageItem, b: TriageItem) -> str:
|
||||
"""Determine a suggested action for a duplicate pair based on types and age."""
|
||||
if a["is_pr"] and b["is_pr"]:
|
||||
return "Review for overlap"
|
||||
if not a["is_pr"] and not b["is_pr"]:
|
||||
# Both issues — close the newer one
|
||||
try:
|
||||
a_dt = datetime.fromisoformat(a["created_at"].replace("Z", "+00:00"))
|
||||
b_dt = datetime.fromisoformat(b["created_at"].replace("Z", "+00:00"))
|
||||
newer = b if b_dt > a_dt else a
|
||||
except (ValueError, TypeError):
|
||||
newer = b
|
||||
return f"Close #{newer['number']} as duplicate"
|
||||
# One issue, one PR
|
||||
return "Link PR to issue"
|
||||
|
||||
|
||||
def generate_report(
|
||||
items: list[TriageItem],
|
||||
outlier_results: list[tuple[int, float]],
|
||||
duplicate_pairs: list[tuple[int, int, float]],
|
||||
label_suggestions: list[list[tuple[str, float]]] | None = None,
|
||||
) -> str:
|
||||
"""Generate a structured markdown triage report."""
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "unknown/repo")
|
||||
|
||||
# Compute label suggestion counts early for the health table
|
||||
outlier_set = {idx for idx, _ in outlier_results}
|
||||
suggested_count = 0
|
||||
if label_suggestions is not None:
|
||||
suggested_count = sum(
|
||||
1 for i, s in enumerate(label_suggestions)
|
||||
if s and not items[i]["labels"] and i not in outlier_set
|
||||
)
|
||||
|
||||
# ── Health summary table at the top ──────────────────────────────
|
||||
lines: list[str] = [
|
||||
"## Triage Sweep Report",
|
||||
"",
|
||||
f"**Run:** {now} UTC",
|
||||
f"**Items analyzed:** {len(items)}",
|
||||
f"**Thresholds:** IQR multiplier {IQR_MULTIPLIER}, Cosine > {COSINE_THRESHOLD}",
|
||||
"",
|
||||
"### Health Summary",
|
||||
"",
|
||||
"| Metric | Value |",
|
||||
"|--------|-------|",
|
||||
f"| Items analyzed | {len(items)} |",
|
||||
f"| Outliers flagged | {len(outlier_results)} |",
|
||||
f"| Duplicate pairs | {len(duplicate_pairs)} |",
|
||||
f"| Label suggestions | {suggested_count} |",
|
||||
"",
|
||||
]
|
||||
|
||||
# ── Outlier section ──────────────────────────────────────────────
|
||||
# Determine cutoff for high-confidence split
|
||||
cutoff = getattr(outlier_results, "cutoff", 0.0)
|
||||
high_conf_cutoff = 2 * cutoff if cutoff > 0 else float("inf")
|
||||
|
||||
high_conf = [(idx, d) for idx, d in outlier_results if d > high_conf_cutoff]
|
||||
borderline = [(idx, d) for idx, d in outlier_results if d <= high_conf_cutoff]
|
||||
|
||||
lines.extend([
|
||||
f"### Potential Outliers / Spam ({len(outlier_results)})",
|
||||
"",
|
||||
"Items with unusually high Mahalanobis distance from the distribution center.",
|
||||
"These may be spam, off-topic, or poorly described.",
|
||||
"",
|
||||
])
|
||||
|
||||
if high_conf:
|
||||
lines.append(f"**High Confidence** ({len(high_conf)} items, distance > 2x cutoff)")
|
||||
lines.append("")
|
||||
lines.append("| # | Type | Title | Distance | Age |")
|
||||
lines.append("|---|------|-------|----------|-----|")
|
||||
for idx, distance in high_conf:
|
||||
item = items[idx]
|
||||
kind = "PR" if item["is_pr"] else "Issue"
|
||||
age = _item_age(item["created_at"])
|
||||
title = item["title"][:80] + ("..." if len(item["title"]) > 80 else "")
|
||||
lines.append(
|
||||
f"| [#{item['number']}]({item['html_url']}) "
|
||||
f"| {kind} | {title} | {distance:.2f} | {age} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if borderline:
|
||||
lines.append("<details>")
|
||||
lines.append(f"<summary>Borderline ({len(borderline)} items)</summary>")
|
||||
lines.append("")
|
||||
lines.append("| # | Type | Title | Distance | Age |")
|
||||
lines.append("|---|------|-------|----------|-----|")
|
||||
for idx, distance in borderline:
|
||||
item = items[idx]
|
||||
kind = "PR" if item["is_pr"] else "Issue"
|
||||
age = _item_age(item["created_at"])
|
||||
title = item["title"][:80] + ("..." if len(item["title"]) > 80 else "")
|
||||
lines.append(
|
||||
f"| [#{item['number']}]({item['html_url']}) "
|
||||
f"| {kind} | {title} | {distance:.2f} | {age} |"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("</details>")
|
||||
lines.append("")
|
||||
|
||||
if not outlier_results:
|
||||
lines.append("None found.")
|
||||
|
||||
# ── Duplicate pairs section ──────────────────────────────────────
|
||||
lines.extend([
|
||||
"",
|
||||
f"### Potential Duplicates ({len(duplicate_pairs)} pairs)",
|
||||
"",
|
||||
"Pairs of items with cosine similarity above the threshold.",
|
||||
"",
|
||||
])
|
||||
|
||||
if duplicate_pairs:
|
||||
lines.append("| Item A | Item B | Similarity | Suggested Action |")
|
||||
lines.append("|--------|--------|------------|------------------|")
|
||||
for i, j, sim in duplicate_pairs:
|
||||
a = items[i]
|
||||
b = items[j]
|
||||
kind_a = "PR" if a["is_pr"] else "Issue"
|
||||
kind_b = "PR" if b["is_pr"] else "Issue"
|
||||
action = _suggested_action(a, b)
|
||||
lines.append(
|
||||
f"| [#{a['number']}]({a['html_url']}) {kind_a}: {a['title']} "
|
||||
f"| [#{b['number']}]({b['html_url']}) {kind_b}: {b['title']} "
|
||||
f"| {sim:.3f} | {action} |"
|
||||
)
|
||||
else:
|
||||
lines.append("None found.")
|
||||
|
||||
# ── Label suggestions section ────────────────────────────────────
|
||||
if label_suggestions is not None:
|
||||
# High confidence: top-1 label with raw_sim >= 0.5
|
||||
# Low confidence: top-1 label with raw_sim < 0.5
|
||||
high_conf_labels: list[tuple[int, list[tuple[str, float]]]] = []
|
||||
low_conf_labels: list[tuple[int, list[tuple[str, float]]]] = []
|
||||
for i, sugs in enumerate(label_suggestions):
|
||||
if sugs and not items[i]["labels"] and i not in outlier_set:
|
||||
top1 = sugs[:1]
|
||||
if top1[0][1] >= 0.5:
|
||||
high_conf_labels.append((i, top1))
|
||||
else:
|
||||
low_conf_labels.append((i, top1))
|
||||
|
||||
total_suggestions = len(high_conf_labels) + len(low_conf_labels)
|
||||
lines.extend([
|
||||
"",
|
||||
f"### Suggested Labels ({total_suggestions} unlabeled items)",
|
||||
"",
|
||||
"Labels suggested by z-score normalized embedding similarity against repo label descriptions.",
|
||||
"Only shown for unlabeled items that were not flagged as outliers.",
|
||||
"",
|
||||
])
|
||||
|
||||
# Label concentration warning
|
||||
if total_suggestions > 0:
|
||||
label_counts: dict[str, int] = {}
|
||||
for _, sugs in high_conf_labels + low_conf_labels:
|
||||
for name, _ in sugs:
|
||||
label_counts[name] = label_counts.get(name, 0) + 1
|
||||
for name, count in label_counts.items():
|
||||
if count > total_suggestions * 0.5:
|
||||
lines.append(
|
||||
f"> **Warning:** Label `{name}` accounts for "
|
||||
f"{count}/{total_suggestions} suggestions "
|
||||
f"({count * 100 // total_suggestions}%). "
|
||||
f"Consider reviewing label descriptions for specificity."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if high_conf_labels:
|
||||
lines.append("| # | Type | Title | Suggested Label |")
|
||||
lines.append("|---|------|-------|--------------------|")
|
||||
for idx, sugs in high_conf_labels:
|
||||
item = items[idx]
|
||||
kind = "PR" if item["is_pr"] else "Issue"
|
||||
label_strs = [f"`{name}` ({score:.2f})" for name, score in sugs]
|
||||
lines.append(
|
||||
f"| [#{item['number']}]({item['html_url']}) "
|
||||
f"| {kind} | {item['title']} | {', '.join(label_strs)} |"
|
||||
)
|
||||
|
||||
if low_conf_labels:
|
||||
lines.append("")
|
||||
lines.append("<details>")
|
||||
lines.append(f"<summary>Low-confidence suggestions ({len(low_conf_labels)} items)</summary>")
|
||||
lines.append("")
|
||||
lines.append("| # | Type | Title | Suggested Label |")
|
||||
lines.append("|---|------|-------|--------------------|")
|
||||
for idx, sugs in low_conf_labels:
|
||||
item = items[idx]
|
||||
kind = "PR" if item["is_pr"] else "Issue"
|
||||
label_strs = [f"`{name}` ({score:.2f})" for name, score in sugs]
|
||||
lines.append(
|
||||
f"| [#{item['number']}]({item['html_url']}) "
|
||||
f"| {kind} | {item['title']} | {', '.join(label_strs)} |"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("</details>")
|
||||
|
||||
if not high_conf_labels and not low_conf_labels:
|
||||
lines.append("No unlabeled items need suggestions.")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"### Summary",
|
||||
"",
|
||||
f"- {len(outlier_results)} outliers flagged for review",
|
||||
f"- {len(duplicate_pairs)} duplicate pairs found",
|
||||
f"- {len(items)} items analyzed in total",
|
||||
])
|
||||
|
||||
if label_suggestions is not None:
|
||||
lines.append(f"- {suggested_count} items suggested for labeling")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"---",
|
||||
f"*Generated by [triage-sweep](https://github.com/{repo}/actions) — no LLM was used.*",
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def create_report_issue(report_body: str) -> None:
|
||||
"""Create a GitHub issue with the triage report.
|
||||
|
||||
Posts to the issues API with the triage-report label.
|
||||
Raises SystemExit on non-201 response.
|
||||
"""
|
||||
token = os.environ["GITHUB_TOKEN"]
|
||||
repo = os.environ["GITHUB_REPOSITORY"]
|
||||
url = f"https://api.github.com/repos/{repo}/issues"
|
||||
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
payload = json.dumps({
|
||||
"title": f"Triage Sweep Report — {today}",
|
||||
"body": report_body,
|
||||
"labels": [REPORT_LABEL],
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(url, data=payload, method="POST")
|
||||
req.add_header("Accept", "application/vnd.github+json")
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
req.add_header("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
resp_body = resp.read().decode("utf-8")
|
||||
if resp.status != 201:
|
||||
print(f"::error::Failed to create issue: {resp.status} {resp_body}")
|
||||
sys.exit(1)
|
||||
result = json.loads(resp_body)
|
||||
print(f"Created issue: {result.get('html_url', 'unknown')}")
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
print(f"::error::Failed to create issue: {e.code} {body}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def write_report(report: str) -> None:
|
||||
"""Write the report to the file system for the summary step."""
|
||||
with open(REPORT_FILE, "w", encoding="utf-8") as f:
|
||||
f.write(report)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Orchestrate the full triage sweep."""
|
||||
# 1. Validate environment
|
||||
for var in ("GITHUB_TOKEN", "GITHUB_REPOSITORY"):
|
||||
if not os.environ.get(var):
|
||||
print(f"::error::Missing required environment variable: {var}")
|
||||
sys.exit(1)
|
||||
|
||||
# 2. Fetch all open issues + PRs
|
||||
items = fetch_all_open_items()
|
||||
print(f"Fetched {len(items)} open items")
|
||||
|
||||
if len(items) == 0:
|
||||
report = "## Triage Sweep Report\n\nNo open issues or PRs found."
|
||||
write_report(report)
|
||||
print("No items to analyze.")
|
||||
return
|
||||
|
||||
# 3. Extract texts for embedding
|
||||
texts: list[str] = [item["text"] for item in items]
|
||||
|
||||
# 4. Embed all texts (returns numpy float32 array of shape [n, 384])
|
||||
embeddings = embed_texts(texts)
|
||||
|
||||
# 5. L2-normalize
|
||||
embeddings = normalize_rows(embeddings)
|
||||
|
||||
# 6. Outlier detection (Mahalanobis via EllipticEnvelope)
|
||||
outlier_results: list[tuple[int, float]] = []
|
||||
if len(items) >= MIN_SAMPLES_FOR_OUTLIER_DETECTION:
|
||||
reduced = reduce_dimensions(embeddings, PCA_MAX_COMPONENTS)
|
||||
outlier_results = detect_outliers(
|
||||
reduced,
|
||||
contamination=CONTAMINATION,
|
||||
iqr_multiplier=IQR_MULTIPLIER,
|
||||
max_outlier_pct=MAX_OUTLIER_PCT,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"Skipping outlier detection: {len(items)} items < "
|
||||
f"{MIN_SAMPLES_FOR_OUTLIER_DETECTION} minimum"
|
||||
)
|
||||
|
||||
# 7. Duplicate detection (pairwise cosine similarity)
|
||||
duplicate_pairs = find_duplicate_pairs(embeddings, COSINE_THRESHOLD)
|
||||
|
||||
# 8. Label suggestion via embedding similarity
|
||||
label_suggestions: list[list[tuple[str, float]]] | None = None
|
||||
repo_labels = fetch_repo_labels()
|
||||
if repo_labels:
|
||||
label_texts = [lbl["text"] for lbl in repo_labels]
|
||||
label_names = [lbl["name"] for lbl in repo_labels]
|
||||
label_embeddings = embed_texts(label_texts)
|
||||
label_embeddings = normalize_rows(label_embeddings)
|
||||
label_suggestions = suggest_labels(embeddings, label_embeddings, label_names)
|
||||
print(f"Computed label suggestions against {len(repo_labels)} repo labels")
|
||||
|
||||
# NOTE: Auto-labeling is disabled. The report shows suggestions for
|
||||
# human review. To re-enable, uncomment the block below.
|
||||
#
|
||||
# # Apply top label to unlabeled items (unless dry run)
|
||||
# # Skip outliers — flagged items shouldn't get categorized
|
||||
# outlier_set = {idx for idx, _ in outlier_results}
|
||||
# if not DRY_RUN:
|
||||
# applied_count = 0
|
||||
# for i, sugs in enumerate(label_suggestions):
|
||||
# if sugs and not items[i]["labels"] and i not in outlier_set:
|
||||
# # Apply only the top-1 label (highest confidence)
|
||||
# apply_labels_to_item(items[i]["number"], [sugs[0][0]])
|
||||
# applied_count += 1
|
||||
# print(f"Applied labels to {applied_count} unlabeled items")
|
||||
else:
|
||||
print("No repo labels found — skipping label suggestions")
|
||||
|
||||
# 9. Generate report
|
||||
report = generate_report(items, outlier_results, duplicate_pairs, label_suggestions)
|
||||
|
||||
# 10. Write report to file (for summary step)
|
||||
write_report(report)
|
||||
|
||||
# 11. Create report issue (unless dry run)
|
||||
if DRY_RUN:
|
||||
print("Dry run — skipping issue creation and label application.")
|
||||
print(report)
|
||||
else:
|
||||
create_report_issue(report)
|
||||
print("Report issue created.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,468 +0,0 @@
|
||||
"""Tests for embedding_utils.py — all embedding model calls are mocked."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# Mock fastembed before importing the module under test (persistent)
|
||||
if "fastembed" not in sys.modules:
|
||||
sys.modules["fastembed"] = MagicMock()
|
||||
|
||||
from embedding_utils import (
|
||||
embed_texts,
|
||||
normalize_rows,
|
||||
reduce_dimensions,
|
||||
detect_outliers,
|
||||
find_duplicate_pairs,
|
||||
suggest_labels,
|
||||
EMBEDDING_DIM,
|
||||
EMBEDDING_MODEL,
|
||||
EMBEDDING_BATCH_SIZE,
|
||||
LABEL_Z_THRESHOLD,
|
||||
LABEL_Z_MARGIN,
|
||||
LABEL_Z_STD_FLOOR,
|
||||
MIN_RAW_SIMILARITY,
|
||||
MAX_LABELS_PER_ITEM,
|
||||
)
|
||||
|
||||
|
||||
class TestEmbedTexts:
|
||||
"""Tests for the embed_texts function."""
|
||||
|
||||
def test_empty_list_returns_empty_array(self):
|
||||
result = embed_texts([])
|
||||
assert result.shape == (0, EMBEDDING_DIM)
|
||||
assert result.dtype == np.float32
|
||||
|
||||
@patch("embedding_utils.TextEmbedding")
|
||||
def test_single_text(self, mock_cls):
|
||||
mock_model = MagicMock()
|
||||
mock_cls.return_value = mock_model
|
||||
vec = np.random.randn(EMBEDDING_DIM).astype(np.float32)
|
||||
mock_model.embed.return_value = iter([vec])
|
||||
|
||||
result = embed_texts(["hello world"])
|
||||
|
||||
mock_cls.assert_called_once_with(model_name=EMBEDDING_MODEL)
|
||||
mock_model.embed.assert_called_once_with(
|
||||
["hello world"], batch_size=EMBEDDING_BATCH_SIZE
|
||||
)
|
||||
assert result.shape == (1, EMBEDDING_DIM)
|
||||
assert result.dtype == np.float32
|
||||
np.testing.assert_array_almost_equal(result[0], vec)
|
||||
|
||||
@patch("embedding_utils.TextEmbedding")
|
||||
def test_multiple_texts(self, mock_cls):
|
||||
mock_model = MagicMock()
|
||||
mock_cls.return_value = mock_model
|
||||
vecs = [
|
||||
np.random.randn(EMBEDDING_DIM).astype(np.float32)
|
||||
for _ in range(5)
|
||||
]
|
||||
mock_model.embed.return_value = iter(vecs)
|
||||
|
||||
result = embed_texts(["a", "b", "c", "d", "e"])
|
||||
assert result.shape == (5, EMBEDDING_DIM)
|
||||
assert result.dtype == np.float32
|
||||
|
||||
|
||||
class TestNormalizeRows:
|
||||
"""Tests for L2 row normalization."""
|
||||
|
||||
def test_empty_matrix(self):
|
||||
m = np.empty((0, 10), dtype=np.float32)
|
||||
result = normalize_rows(m)
|
||||
assert result.shape == (0, 10)
|
||||
|
||||
def test_single_row(self):
|
||||
m = np.array([[3.0, 4.0]], dtype=np.float32)
|
||||
result = normalize_rows(m)
|
||||
# Norm should be ~1.0
|
||||
norm = np.linalg.norm(result[0])
|
||||
assert abs(norm - 1.0) < 1e-5
|
||||
|
||||
def test_multiple_rows(self):
|
||||
rng = np.random.default_rng(42)
|
||||
m = rng.standard_normal((10, 50)).astype(np.float32)
|
||||
result = normalize_rows(m)
|
||||
norms = np.linalg.norm(result, axis=1)
|
||||
np.testing.assert_allclose(norms, 1.0, atol=1e-5)
|
||||
|
||||
def test_zero_row_stays_near_zero(self):
|
||||
m = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], dtype=np.float32)
|
||||
result = normalize_rows(m)
|
||||
# Zero row divided by eps -> very small values
|
||||
assert np.linalg.norm(result[0]) < 1e-3
|
||||
# Non-zero row should be unit norm
|
||||
assert abs(np.linalg.norm(result[1]) - 1.0) < 1e-5
|
||||
|
||||
def test_preserves_direction(self):
|
||||
m = np.array([[2.0, 0.0], [0.0, 3.0]], dtype=np.float32)
|
||||
result = normalize_rows(m)
|
||||
np.testing.assert_allclose(result[0], [1.0, 0.0], atol=1e-5)
|
||||
np.testing.assert_allclose(result[1], [0.0, 1.0], atol=1e-5)
|
||||
|
||||
|
||||
class TestReduceDimensions:
|
||||
"""Tests for PCA dimensionality reduction."""
|
||||
|
||||
def test_single_sample_returns_unchanged(self):
|
||||
m = np.random.randn(1, 50).astype(np.float32)
|
||||
result = reduce_dimensions(m, 10)
|
||||
np.testing.assert_array_equal(result, m)
|
||||
|
||||
def test_reduces_dimensions(self):
|
||||
rng = np.random.default_rng(42)
|
||||
m = rng.standard_normal((100, 50)).astype(np.float32)
|
||||
result = reduce_dimensions(m, 10)
|
||||
assert result.shape == (100, 10)
|
||||
assert result.dtype == np.float32
|
||||
|
||||
def test_caps_at_n_minus_1(self):
|
||||
rng = np.random.default_rng(42)
|
||||
# 5 samples, 20 features -> max components = 4 (n-1)
|
||||
m = rng.standard_normal((5, 20)).astype(np.float32)
|
||||
result = reduce_dimensions(m, 50)
|
||||
assert result.shape == (5, 4)
|
||||
|
||||
def test_caps_at_d(self):
|
||||
rng = np.random.default_rng(42)
|
||||
# 100 samples, 3 features -> max components = 3
|
||||
m = rng.standard_normal((100, 3)).astype(np.float32)
|
||||
result = reduce_dimensions(m, 50)
|
||||
assert result.shape == (100, 3)
|
||||
|
||||
def test_max_components_respected(self):
|
||||
rng = np.random.default_rng(42)
|
||||
m = rng.standard_normal((50, 30)).astype(np.float32)
|
||||
result = reduce_dimensions(m, 5)
|
||||
assert result.shape[1] == 5
|
||||
|
||||
|
||||
class TestDetectOutliers:
|
||||
"""Tests for IQR-based outlier detection."""
|
||||
|
||||
def test_single_sample_returns_empty(self):
|
||||
m = np.random.randn(1, 5).astype(np.float32)
|
||||
result = detect_outliers(m)
|
||||
assert result == []
|
||||
|
||||
def test_empty_returns_empty(self):
|
||||
# n < 2 case
|
||||
m = np.empty((0, 5), dtype=np.float32)
|
||||
result = detect_outliers(m)
|
||||
assert result == []
|
||||
|
||||
def test_finds_outliers_in_synthetic_data(self):
|
||||
rng = np.random.default_rng(42)
|
||||
# Create a tight cluster with one obvious outlier
|
||||
cluster = rng.standard_normal((50, 3)).astype(np.float32) * 0.1
|
||||
outlier = np.array([[100.0, 100.0, 100.0]], dtype=np.float32)
|
||||
m = np.vstack([cluster, outlier])
|
||||
result = detect_outliers(m)
|
||||
# The outlier (index 50) should be detected
|
||||
outlier_indices = [idx for idx, _ in result]
|
||||
assert 50 in outlier_indices
|
||||
|
||||
def test_returns_list_of_index_distance_tuples(self):
|
||||
rng = np.random.default_rng(42)
|
||||
# Tight cluster + outlier to guarantee at least one result
|
||||
cluster = rng.standard_normal((20, 3)).astype(np.float32) * 0.1
|
||||
far_point = np.array([[50.0, 50.0, 50.0]], dtype=np.float32)
|
||||
m = np.vstack([cluster, far_point])
|
||||
result = detect_outliers(m)
|
||||
assert isinstance(result, list)
|
||||
for item in result:
|
||||
assert isinstance(item, tuple)
|
||||
assert len(item) == 2
|
||||
idx, dist = item
|
||||
assert isinstance(idx, int)
|
||||
assert isinstance(dist, float)
|
||||
assert dist > 0
|
||||
|
||||
def test_iqr_cutoff_behavior(self):
|
||||
"""Lower IQR multiplier should flag more items than higher multiplier."""
|
||||
rng = np.random.default_rng(42)
|
||||
m = rng.standard_normal((100, 3)).astype(np.float32)
|
||||
low = detect_outliers(m, iqr_multiplier=1.0, max_outlier_pct=0.5)
|
||||
high = detect_outliers(m, iqr_multiplier=5.0, max_outlier_pct=0.5)
|
||||
assert len(low) >= len(high)
|
||||
|
||||
def test_dimension_aware_no_mass_flagging(self):
|
||||
"""High-dimensional clean Gaussian data should not flag everything."""
|
||||
rng = np.random.default_rng(42)
|
||||
# 500 samples, 10 dims — well-conditioned for robust covariance
|
||||
m = rng.standard_normal((500, 10)).astype(np.float32)
|
||||
result = detect_outliers(m)
|
||||
# With IQR-based cutoff on clean Gaussian data,
|
||||
# only a small fraction should be flagged (well under 50%)
|
||||
assert len(result) < 250
|
||||
|
||||
def test_contamination_parameter(self):
|
||||
rng = np.random.default_rng(42)
|
||||
m = rng.standard_normal((50, 3)).astype(np.float32)
|
||||
# Should not raise with different contamination values
|
||||
result = detect_outliers(m, contamination=0.05)
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_max_outlier_pct_hard_cap(self):
|
||||
"""The hard cap should limit outlier count to max_outlier_pct * n."""
|
||||
rng = np.random.default_rng(42)
|
||||
# Create data with many potential outliers (bimodal)
|
||||
cluster = rng.standard_normal((80, 3)).astype(np.float32) * 0.1
|
||||
outliers = rng.standard_normal((20, 3)).astype(np.float32) * 50.0
|
||||
m = np.vstack([cluster, outliers])
|
||||
# Very low IQR multiplier to flag a lot, but cap at 5%
|
||||
result = detect_outliers(m, iqr_multiplier=0.5, max_outlier_pct=0.05)
|
||||
max_allowed = max(1, int(0.05 * 100)) # 5
|
||||
assert len(result) <= max_allowed
|
||||
|
||||
def test_hard_cap_keeps_most_extreme(self):
|
||||
"""When capped, the most extreme items (highest distance) should be kept."""
|
||||
rng = np.random.default_rng(42)
|
||||
cluster = rng.standard_normal((90, 3)).astype(np.float32) * 0.1
|
||||
# Create outliers with increasing extremity
|
||||
outliers = np.array([
|
||||
[10.0, 10.0, 10.0],
|
||||
[20.0, 20.0, 20.0],
|
||||
[50.0, 50.0, 50.0],
|
||||
], dtype=np.float32)
|
||||
m = np.vstack([cluster, outliers])
|
||||
# Cap at ~1 item (0.01 * 93 = 0, but min is 1)
|
||||
result = detect_outliers(m, iqr_multiplier=0.5, max_outlier_pct=0.02)
|
||||
if len(result) > 0:
|
||||
# The most extreme (index 92, distance for [50,50,50]) should be kept
|
||||
indices = [idx for idx, _ in result]
|
||||
assert 92 in indices
|
||||
|
||||
def test_cutoff_attribute(self):
|
||||
"""Returned result should carry a cutoff attribute."""
|
||||
rng = np.random.default_rng(42)
|
||||
m = rng.standard_normal((50, 3)).astype(np.float32)
|
||||
result = detect_outliers(m)
|
||||
assert hasattr(result, "cutoff")
|
||||
assert isinstance(result.cutoff, float)
|
||||
assert result.cutoff > 0
|
||||
|
||||
|
||||
class TestFindDuplicatePairs:
|
||||
"""Tests for cosine similarity duplicate detection."""
|
||||
|
||||
def test_single_item_returns_empty(self):
|
||||
m = np.random.randn(1, 10).astype(np.float32)
|
||||
result = find_duplicate_pairs(m, 0.9)
|
||||
assert result == []
|
||||
|
||||
def test_empty_returns_empty(self):
|
||||
m = np.empty((0, 10), dtype=np.float32)
|
||||
result = find_duplicate_pairs(m, 0.9)
|
||||
assert result == []
|
||||
|
||||
def test_identical_vectors_detected(self):
|
||||
vec = np.random.randn(10).astype(np.float32)
|
||||
vec = vec / np.linalg.norm(vec)
|
||||
m = np.vstack([vec, vec, np.random.randn(10).astype(np.float32)])
|
||||
result = find_duplicate_pairs(m, 0.99)
|
||||
# Items 0 and 1 are identical, should be found
|
||||
assert any(i == 0 and j == 1 for i, j, _ in result)
|
||||
|
||||
def test_orthogonal_vectors_not_detected(self):
|
||||
m = np.eye(5, dtype=np.float32)
|
||||
result = find_duplicate_pairs(m, 0.5)
|
||||
assert result == []
|
||||
|
||||
def test_returns_correct_format(self):
|
||||
vec = np.random.randn(10).astype(np.float32)
|
||||
vec = vec / np.linalg.norm(vec)
|
||||
m = np.vstack([vec, vec])
|
||||
result = find_duplicate_pairs(m, 0.5)
|
||||
assert len(result) >= 1
|
||||
for item in result:
|
||||
assert len(item) == 3
|
||||
i, j, sim = item
|
||||
assert isinstance(i, int)
|
||||
assert isinstance(j, int)
|
||||
assert isinstance(sim, float)
|
||||
assert i < j
|
||||
|
||||
def test_i_less_than_j(self):
|
||||
rng = np.random.default_rng(42)
|
||||
# Create some similar vectors
|
||||
base = rng.standard_normal(10).astype(np.float32)
|
||||
m = np.vstack([base + rng.standard_normal(10) * 0.01 for _ in range(5)])
|
||||
result = find_duplicate_pairs(m, 0.5)
|
||||
for i, j, _ in result:
|
||||
assert i < j
|
||||
|
||||
def test_high_threshold_fewer_pairs(self):
|
||||
rng = np.random.default_rng(42)
|
||||
m = rng.standard_normal((10, 20)).astype(np.float32)
|
||||
# Normalize for meaningful cosine similarities
|
||||
norms = np.linalg.norm(m, axis=1, keepdims=True)
|
||||
m = m / norms
|
||||
low = find_duplicate_pairs(m, 0.3)
|
||||
high = find_duplicate_pairs(m, 0.9)
|
||||
assert len(low) >= len(high)
|
||||
|
||||
|
||||
class TestSuggestLabels:
|
||||
"""Tests for z-score normalized label suggestion."""
|
||||
|
||||
def test_empty_items_returns_empty_lists(self):
|
||||
items = np.empty((0, 10), dtype=np.float32)
|
||||
labels = np.random.randn(3, 10).astype(np.float32)
|
||||
result = suggest_labels(items, labels, ["a", "b", "c"])
|
||||
assert result == []
|
||||
|
||||
def test_empty_labels_returns_empty_per_item(self):
|
||||
items = np.random.randn(5, 10).astype(np.float32)
|
||||
labels = np.empty((0, 10), dtype=np.float32)
|
||||
result = suggest_labels(items, labels, [])
|
||||
assert len(result) == 5
|
||||
assert all(s == [] for s in result)
|
||||
|
||||
def test_identical_embedding_gets_that_label(self):
|
||||
"""If an item embedding strongly matches one label, z-score should highlight it."""
|
||||
# Create multiple items so z-score normalization is meaningful
|
||||
rng = np.random.default_rng(42)
|
||||
# 10 random items + 1 item that matches label "bug" exactly
|
||||
random_items = rng.standard_normal((10, 3)).astype(np.float32)
|
||||
bug_vec = np.array([[1.0, 0.0, 0.0]], dtype=np.float32)
|
||||
items = np.vstack([random_items, bug_vec])
|
||||
labels = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32)
|
||||
result = suggest_labels(
|
||||
items, labels, ["bug", "feature", "docs"],
|
||||
z_threshold=0.5, z_margin=0.0, min_raw_sim=0.1,
|
||||
)
|
||||
# The last item (matching bug_vec) should get "bug" as top suggestion
|
||||
last_item_sugs = result[-1]
|
||||
if last_item_sugs:
|
||||
assert last_item_sugs[0][0] == "bug"
|
||||
|
||||
def test_z_score_suppresses_dominant_label(self):
|
||||
"""When all items are similar to one label, z-scores should be low
|
||||
(none stands out) and that label should not be blindly suggested."""
|
||||
# All items identical — z-score for every item on every label is 0
|
||||
items = np.ones((10, 3), dtype=np.float32)
|
||||
labels = np.array([[1.0, 1.0, 1.0], [0.0, 1.0, 0.0]], dtype=np.float32)
|
||||
result = suggest_labels(
|
||||
items, labels, ["catch-all", "specific"],
|
||||
z_threshold=1.5, min_raw_sim=0.3,
|
||||
)
|
||||
# With identical items, std=0 -> z-scores are all 0 -> nothing passes z_threshold
|
||||
for sugs in result:
|
||||
assert sugs == []
|
||||
|
||||
def test_margin_gate_blocks_top1(self):
|
||||
"""Top-1 label must beat #2 by z_margin to be accepted as position 0."""
|
||||
rng = np.random.default_rng(99)
|
||||
# 20 items, each slightly different, 2 labels
|
||||
items = rng.standard_normal((20, 5)).astype(np.float32)
|
||||
# Two labels that are nearly identical -> margin gate should block top-1
|
||||
labels = np.array([[1.0, 0.5, 0.0, 0.0, 0.0],
|
||||
[1.0, 0.5, 0.01, 0.0, 0.0]], dtype=np.float32)
|
||||
result = suggest_labels(
|
||||
items, labels, ["label-a", "label-b"],
|
||||
z_threshold=0.0, z_margin=10.0, min_raw_sim=0.0, max_per_item=1,
|
||||
)
|
||||
# With a huge margin requirement and max_per_item=1, nothing should pass
|
||||
# because the only candidate (top-1) is blocked by margin gate,
|
||||
# and max_per_item=1 prevents falling through to position 2
|
||||
for sugs in result:
|
||||
assert sugs == []
|
||||
|
||||
def test_margin_gate_passes_when_clear_winner(self):
|
||||
"""When top-1 clearly beats #2, it should pass the margin gate."""
|
||||
# Create items where one strongly matches label 0 vs label 1
|
||||
items = np.array([
|
||||
[1.0, 0.0, 0.0, 0.0, 0.0], # strongly matches label-a
|
||||
[0.0, 0.0, 0.0, 0.0, 1.0], # matches neither well
|
||||
] * 5, dtype=np.float32) # 10 items for stable z-scores
|
||||
labels = np.array([
|
||||
[1.0, 0.0, 0.0, 0.0, 0.0], # label-a
|
||||
[0.0, 1.0, 0.0, 0.0, 0.0], # label-b (orthogonal)
|
||||
], dtype=np.float32)
|
||||
result = suggest_labels(
|
||||
items, labels, ["label-a", "label-b"],
|
||||
z_threshold=0.5, z_margin=0.3, min_raw_sim=0.1,
|
||||
)
|
||||
# Items matching label-a should get it suggested (clear z-score advantage)
|
||||
got_label_a = sum(1 for sugs in result if sugs and sugs[0][0] == "label-a")
|
||||
assert got_label_a > 0
|
||||
|
||||
def test_min_raw_similarity_filter(self):
|
||||
"""Even with high z-score, low raw similarity should be filtered out."""
|
||||
# Items are orthogonal to all labels -> raw similarity near 0
|
||||
items = np.array([[1.0, 0.0, 0.0]], dtype=np.float32)
|
||||
labels = np.array([[0.0, 0.0, 1.0]], dtype=np.float32)
|
||||
result = suggest_labels(
|
||||
items, labels, ["irrelevant"],
|
||||
z_threshold=0.0, z_margin=0.0, min_raw_sim=0.9,
|
||||
)
|
||||
# Raw similarity is ~0, which is below min_raw_sim=0.9
|
||||
assert result[0] == []
|
||||
|
||||
def test_max_per_item_respected(self):
|
||||
"""Even if many labels qualify, max_per_item caps the results."""
|
||||
rng = np.random.default_rng(42)
|
||||
# Create items with some variance so z-scores differentiate
|
||||
items = rng.standard_normal((20, 10)).astype(np.float32)
|
||||
base = items[0]
|
||||
# All labels very similar to item 0
|
||||
labels = np.array([base + rng.standard_normal(10) * 0.01 for _ in range(10)])
|
||||
names = [f"label-{i}" for i in range(10)]
|
||||
result = suggest_labels(
|
||||
items, labels, names,
|
||||
z_threshold=0.0, z_margin=0.0, min_raw_sim=0.0, max_per_item=2,
|
||||
)
|
||||
for sugs in result:
|
||||
assert len(sugs) <= 2
|
||||
|
||||
def test_returns_raw_similarity_not_z_score(self):
|
||||
"""Returned scores should be raw cosine similarity, not z-scores."""
|
||||
rng = np.random.default_rng(42)
|
||||
items = rng.standard_normal((15, 5)).astype(np.float32)
|
||||
labels = rng.standard_normal((3, 5)).astype(np.float32)
|
||||
names = ["bug", "feature", "docs"]
|
||||
result = suggest_labels(
|
||||
items, labels, names,
|
||||
z_threshold=0.0, z_margin=0.0, min_raw_sim=-1.0,
|
||||
)
|
||||
# Raw cosine similarity should be in [-1, 1] range
|
||||
for sugs in result:
|
||||
for name, score in sugs:
|
||||
assert -1.0 <= score <= 1.0 + 1e-5
|
||||
assert isinstance(name, str)
|
||||
assert isinstance(score, float)
|
||||
|
||||
def test_returns_correct_format(self):
|
||||
rng = np.random.default_rng(42)
|
||||
items = rng.standard_normal((3, 10)).astype(np.float32)
|
||||
labels = rng.standard_normal((5, 10)).astype(np.float32)
|
||||
names = ["bug", "feature", "docs", "ci", "test"]
|
||||
result = suggest_labels(
|
||||
items, labels, names,
|
||||
z_threshold=0.0, z_margin=0.0, min_raw_sim=-1.0,
|
||||
)
|
||||
assert len(result) == 3
|
||||
for sugs in result:
|
||||
for name, score in sugs:
|
||||
assert isinstance(name, str)
|
||||
assert isinstance(score, float)
|
||||
assert name in names
|
||||
|
||||
def test_text_truncation_in_labels(self):
|
||||
"""Label names should be returned as-is even when very long."""
|
||||
rng = np.random.default_rng(42)
|
||||
items = rng.standard_normal((10, 5)).astype(np.float32)
|
||||
long_name = "a" * 200
|
||||
labels = rng.standard_normal((1, 5)).astype(np.float32)
|
||||
result = suggest_labels(
|
||||
items, labels, [long_name],
|
||||
z_threshold=0.0, z_margin=0.0, min_raw_sim=-1.0,
|
||||
)
|
||||
for sugs in result:
|
||||
if sugs:
|
||||
assert sugs[0][0] == long_name
|
||||
@@ -1,873 +0,0 @@
|
||||
"""Tests for sweep.py — all external calls (API, embedding) are mocked."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch, MagicMock, mock_open
|
||||
from urllib.error import HTTPError
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# Mock fastembed before importing sweep (which imports embedding_utils)
|
||||
sys.modules["fastembed"] = MagicMock()
|
||||
|
||||
# Set required env vars before importing sweep (module-level constants read env)
|
||||
os.environ.setdefault("GITHUB_TOKEN", "test-token")
|
||||
os.environ.setdefault("GITHUB_REPOSITORY", "owner/repo")
|
||||
|
||||
from sweep import (
|
||||
github_api_get,
|
||||
fetch_all_open_items,
|
||||
fetch_repo_labels,
|
||||
apply_labels_to_item,
|
||||
generate_report,
|
||||
create_report_issue,
|
||||
write_report,
|
||||
main,
|
||||
TriageItem,
|
||||
RepoLabel,
|
||||
REPORT_FILE,
|
||||
REPORT_LABEL,
|
||||
API_PAGE_SIZE,
|
||||
MIN_SAMPLES_FOR_OUTLIER_DETECTION,
|
||||
PCA_MAX_COMPONENTS,
|
||||
MAX_EMBED_CHARS,
|
||||
IQR_MULTIPLIER,
|
||||
MAX_OUTLIER_PCT,
|
||||
_item_age,
|
||||
_suggested_action,
|
||||
)
|
||||
|
||||
|
||||
def _make_api_issue(number: int, title: str = "Test issue", is_pr: bool = False,
|
||||
body: str = "Issue body", labels: list[str] | None = None,
|
||||
created_at: str = "2026-03-21T00:00:00Z") -> dict:
|
||||
"""Helper to build a mock GitHub API issue response object."""
|
||||
result: dict = {
|
||||
"number": number,
|
||||
"title": title,
|
||||
"html_url": f"https://github.com/owner/repo/issues/{number}",
|
||||
"body": body,
|
||||
"created_at": created_at,
|
||||
"labels": [{"name": lbl} for lbl in (labels or [])],
|
||||
}
|
||||
if is_pr:
|
||||
result["pull_request"] = {"url": "..."}
|
||||
return result
|
||||
|
||||
|
||||
class TestGithubApiGet:
|
||||
"""Tests for the github_api_get function."""
|
||||
|
||||
@patch("sweep.urllib.request.urlopen")
|
||||
def test_successful_request(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps([{"id": 1}]).encode()
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
result = github_api_get("/issues?state=open")
|
||||
assert result == [{"id": 1}]
|
||||
|
||||
@patch("sweep.urllib.request.urlopen")
|
||||
def test_http_error_exits(self, mock_urlopen):
|
||||
error = HTTPError(
|
||||
url="https://api.github.com/repos/owner/repo/issues",
|
||||
code=403,
|
||||
msg="Forbidden",
|
||||
hdrs=None, # type: ignore[arg-type]
|
||||
fp=BytesIO(b'{"message": "rate limited"}'),
|
||||
)
|
||||
mock_urlopen.side_effect = error
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
github_api_get("/issues")
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Tests for module-level constants."""
|
||||
|
||||
def test_min_samples_is_at_least_3x_pca_max(self):
|
||||
"""MIN_SAMPLES must be >= 3 * PCA_MAX_COMPONENTS for reliable covariance."""
|
||||
assert MIN_SAMPLES_FOR_OUTLIER_DETECTION >= 3 * PCA_MAX_COMPONENTS
|
||||
|
||||
def test_min_samples_is_100(self):
|
||||
assert MIN_SAMPLES_FOR_OUTLIER_DETECTION == 100
|
||||
|
||||
def test_pca_max_components_is_20(self):
|
||||
assert PCA_MAX_COMPONENTS == 20
|
||||
|
||||
def test_iqr_multiplier_default(self):
|
||||
assert IQR_MULTIPLIER == 3.0
|
||||
|
||||
def test_max_outlier_pct_default(self):
|
||||
assert MAX_OUTLIER_PCT == 0.05
|
||||
|
||||
|
||||
class TestFetchAllOpenItems:
|
||||
"""Tests for fetch_all_open_items."""
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_empty_repo(self, mock_get):
|
||||
mock_get.return_value = []
|
||||
items = fetch_all_open_items()
|
||||
assert items == []
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_single_page(self, mock_get):
|
||||
mock_get.return_value = [
|
||||
_make_api_issue(1, "Bug report"),
|
||||
_make_api_issue(2, "Feature request", is_pr=True),
|
||||
]
|
||||
items = fetch_all_open_items()
|
||||
assert len(items) == 2
|
||||
assert items[0]["number"] == 1
|
||||
assert items[0]["is_pr"] is False
|
||||
assert items[1]["is_pr"] is True
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_text_field_constructed(self, mock_get):
|
||||
mock_get.return_value = [
|
||||
_make_api_issue(1, "My Title", body="My Body"),
|
||||
]
|
||||
items = fetch_all_open_items()
|
||||
assert items[0]["text"] == "My Title\n\nMy Body"
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_long_body_truncated(self, mock_get):
|
||||
"""Bodies exceeding MAX_EMBED_CHARS are truncated to fit the token window."""
|
||||
long_body = "x" * (MAX_EMBED_CHARS + 500)
|
||||
mock_get.return_value = [
|
||||
_make_api_issue(1, "Title", body=long_body),
|
||||
]
|
||||
items = fetch_all_open_items()
|
||||
assert len(items[0]["text"]) == MAX_EMBED_CHARS
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_short_body_not_truncated(self, mock_get):
|
||||
"""Bodies under the limit are left intact."""
|
||||
mock_get.return_value = [
|
||||
_make_api_issue(1, "Title", body="Short body"),
|
||||
]
|
||||
items = fetch_all_open_items()
|
||||
assert items[0]["text"] == "Title\n\nShort body"
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_null_body_handled(self, mock_get):
|
||||
issue = _make_api_issue(1, "No body")
|
||||
issue["body"] = None
|
||||
mock_get.return_value = [issue]
|
||||
items = fetch_all_open_items()
|
||||
assert items[0]["text"] == "No body\n\n"
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_labels_extracted(self, mock_get):
|
||||
mock_get.return_value = [
|
||||
_make_api_issue(1, "Labeled", labels=["bug", "high-priority"]),
|
||||
]
|
||||
items = fetch_all_open_items()
|
||||
assert items[0]["labels"] == ["bug", "high-priority"]
|
||||
|
||||
@patch("sweep.MAX_ITEMS", 3)
|
||||
@patch("sweep.github_api_get")
|
||||
def test_max_items_cap(self, mock_get):
|
||||
mock_get.return_value = [_make_api_issue(i) for i in range(100)]
|
||||
items = fetch_all_open_items()
|
||||
assert len(items) == 3
|
||||
|
||||
@patch("sweep.API_PAGE_SIZE", 2)
|
||||
@patch("sweep.github_api_get")
|
||||
def test_pagination(self, mock_get):
|
||||
# First page: 2 items (full page), second page: 1 item (partial -> stop)
|
||||
mock_get.side_effect = [
|
||||
[_make_api_issue(1), _make_api_issue(2)],
|
||||
[_make_api_issue(3)],
|
||||
]
|
||||
items = fetch_all_open_items()
|
||||
assert len(items) == 3
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
|
||||
class TestItemAge:
|
||||
"""Tests for _item_age helper."""
|
||||
|
||||
def test_recent_item(self):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
recent = (datetime.now(timezone.utc) - timedelta(hours=12)).isoformat()
|
||||
assert _item_age(recent) == "<1d"
|
||||
|
||||
def test_days_old(self):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
old = (datetime.now(timezone.utc) - timedelta(days=15)).isoformat()
|
||||
assert _item_age(old) == "15d"
|
||||
|
||||
def test_months_old(self):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
old = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat()
|
||||
assert _item_age(old) == "3mo"
|
||||
|
||||
def test_years_old(self):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
old = (datetime.now(timezone.utc) - timedelta(days=400)).isoformat()
|
||||
assert _item_age(old) == "1y"
|
||||
|
||||
def test_invalid_date(self):
|
||||
assert _item_age("not-a-date") == "?"
|
||||
|
||||
|
||||
class TestSuggestedAction:
|
||||
"""Tests for _suggested_action helper."""
|
||||
|
||||
def test_both_issues_close_newer(self):
|
||||
a = TriageItem(
|
||||
number=1, title="A", html_url="u", is_pr=False, labels=[],
|
||||
created_at="2026-01-01T00:00:00Z", text="t",
|
||||
)
|
||||
b = TriageItem(
|
||||
number=2, title="B", html_url="u", is_pr=False, labels=[],
|
||||
created_at="2026-02-01T00:00:00Z", text="t",
|
||||
)
|
||||
result = _suggested_action(a, b)
|
||||
assert "Close #2 as duplicate" in result
|
||||
|
||||
def test_both_prs_review(self):
|
||||
a = TriageItem(
|
||||
number=1, title="A", html_url="u", is_pr=True, labels=[],
|
||||
created_at="2026-01-01T00:00:00Z", text="t",
|
||||
)
|
||||
b = TriageItem(
|
||||
number=2, title="B", html_url="u", is_pr=True, labels=[],
|
||||
created_at="2026-01-01T00:00:00Z", text="t",
|
||||
)
|
||||
assert _suggested_action(a, b) == "Review for overlap"
|
||||
|
||||
def test_issue_pr_link(self):
|
||||
a = TriageItem(
|
||||
number=1, title="A", html_url="u", is_pr=False, labels=[],
|
||||
created_at="2026-01-01T00:00:00Z", text="t",
|
||||
)
|
||||
b = TriageItem(
|
||||
number=2, title="B", html_url="u", is_pr=True, labels=[],
|
||||
created_at="2026-01-01T00:00:00Z", text="t",
|
||||
)
|
||||
assert _suggested_action(a, b) == "Link PR to issue"
|
||||
|
||||
|
||||
class TestGenerateReport:
|
||||
"""Tests for the markdown report generator."""
|
||||
|
||||
def test_no_findings(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Test", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="Test",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [])
|
||||
assert "## Triage Sweep Report" in report
|
||||
assert "Items analyzed:** 1" in report
|
||||
assert "None found." in report
|
||||
assert "0 outliers flagged" in report
|
||||
assert "0 duplicate pairs found" in report
|
||||
|
||||
def test_health_summary_table(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Test", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="Test",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [])
|
||||
assert "### Health Summary" in report
|
||||
assert "| Metric | Value |" in report
|
||||
assert "| Items analyzed | 1 |" in report
|
||||
|
||||
def test_iqr_multiplier_in_thresholds(self):
|
||||
"""Report should show IQR multiplier, not percentile."""
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Test", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="Test",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [])
|
||||
assert "IQR multiplier" in report
|
||||
assert "percentile" not in report.lower().split("thresholds")[0] # not in thresholds line
|
||||
|
||||
def test_with_outliers_shows_distance_and_age(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=10, title="Spam Issue", html_url="https://example.com/10",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="spam",
|
||||
),
|
||||
TriageItem(
|
||||
number=20, title="Good Issue", html_url="https://example.com/20",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="good",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [(0, 12.34)], [])
|
||||
assert "#10" in report
|
||||
assert "Spam Issue" in report
|
||||
assert "12.34" in report
|
||||
assert "1 outliers flagged" in report
|
||||
# Age column should be present
|
||||
assert "| Age |" in report
|
||||
|
||||
def test_outlier_borderline_in_details(self):
|
||||
"""Borderline outliers should be in a <details> section."""
|
||||
from embedding_utils import _OutlierResult
|
||||
items = [
|
||||
TriageItem(
|
||||
number=10, title="Borderline", html_url="https://example.com/10",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="spam",
|
||||
),
|
||||
]
|
||||
# Create outlier results with cutoff=10.0, distance=12.0 (< 2*cutoff=20)
|
||||
outlier_results = _OutlierResult([(0, 12.0)])
|
||||
outlier_results.cutoff = 10.0
|
||||
report = generate_report(items, outlier_results, [])
|
||||
assert "<details>" in report
|
||||
assert "Borderline" in report
|
||||
|
||||
def test_outlier_high_confidence(self):
|
||||
"""Items with distance > 2x cutoff should be in high confidence section."""
|
||||
from embedding_utils import _OutlierResult
|
||||
items = [
|
||||
TriageItem(
|
||||
number=10, title="Definite Spam", html_url="https://example.com/10",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="spam",
|
||||
),
|
||||
]
|
||||
outlier_results = _OutlierResult([(0, 25.0)])
|
||||
outlier_results.cutoff = 10.0
|
||||
report = generate_report(items, outlier_results, [])
|
||||
assert "High Confidence" in report
|
||||
|
||||
def test_with_duplicates_suggested_action(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="First", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="a",
|
||||
),
|
||||
TriageItem(
|
||||
number=2, title="Second", html_url="https://example.com/2",
|
||||
is_pr=True, labels=[], created_at="2026-02-01T00:00:00Z", text="b",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [(0, 1, 0.954)])
|
||||
assert "#1" in report
|
||||
assert "#2" in report
|
||||
assert "0.954" in report
|
||||
assert "1 duplicate pairs found" in report
|
||||
assert "Suggested Action" in report
|
||||
assert "Link PR to issue" in report
|
||||
|
||||
def test_duplicate_both_issues_close_newer(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="First", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="a",
|
||||
),
|
||||
TriageItem(
|
||||
number=2, title="Second", html_url="https://example.com/2",
|
||||
is_pr=False, labels=[], created_at="2026-02-01T00:00:00Z", text="b",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [(0, 1, 0.95)])
|
||||
assert "Close #2 as duplicate" in report
|
||||
|
||||
def test_duplicate_both_prs_review(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="PR A", html_url="https://example.com/1",
|
||||
is_pr=True, labels=[], created_at="2026-01-01T00:00:00Z", text="a",
|
||||
),
|
||||
TriageItem(
|
||||
number=2, title="PR B", html_url="https://example.com/2",
|
||||
is_pr=True, labels=[], created_at="2026-01-01T00:00:00Z", text="b",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [(0, 1, 0.95)])
|
||||
assert "Review for overlap" in report
|
||||
|
||||
def test_pr_type_label(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=5, title="PR Title", html_url="https://example.com/5",
|
||||
is_pr=True, labels=[], created_at="2026-01-01T00:00:00Z", text="pr",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [(0, 8.5)], [])
|
||||
assert "| PR |" in report
|
||||
|
||||
def test_footer_present(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="T", html_url="u",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="t",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [])
|
||||
assert "no LLM was used" in report
|
||||
|
||||
|
||||
class TestCreateReportIssue:
|
||||
"""Tests for creating the report GitHub issue."""
|
||||
|
||||
@patch("sweep.urllib.request.urlopen")
|
||||
def test_successful_creation(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 201
|
||||
mock_resp.read.return_value = json.dumps({
|
||||
"html_url": "https://github.com/owner/repo/issues/99",
|
||||
}).encode()
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
# Should not raise
|
||||
create_report_issue("# Test Report")
|
||||
|
||||
@patch("sweep.urllib.request.urlopen")
|
||||
def test_http_error_exits(self, mock_urlopen):
|
||||
error = HTTPError(
|
||||
url="https://api.github.com/repos/owner/repo/issues",
|
||||
code=422,
|
||||
msg="Unprocessable",
|
||||
hdrs=None, # type: ignore[arg-type]
|
||||
fp=BytesIO(b'{"message": "validation failed"}'),
|
||||
)
|
||||
mock_urlopen.side_effect = error
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
create_report_issue("# Test Report")
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
class TestWriteReport:
|
||||
"""Tests for the write_report helper."""
|
||||
|
||||
@patch("builtins.open", mock_open())
|
||||
def test_writes_to_file(self):
|
||||
write_report("# Report Content")
|
||||
from builtins import open as builtin_open # noqa
|
||||
# Verify open was called with the right path
|
||||
from unittest.mock import call
|
||||
open_mock = open # The patched version
|
||||
open_mock.assert_called_once_with(REPORT_FILE, "w", encoding="utf-8") # type: ignore[attr-defined]
|
||||
open_mock().write.assert_called_once_with("# Report Content") # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestFetchRepoLabels:
|
||||
"""Tests for fetch_repo_labels."""
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_fetches_and_constructs_labels(self, mock_get):
|
||||
mock_get.return_value = [
|
||||
{"name": "bug", "description": "Something isn't working"},
|
||||
{"name": "enhancement", "description": "New feature or request"},
|
||||
{"name": "docs", "description": ""},
|
||||
]
|
||||
labels = fetch_repo_labels()
|
||||
assert len(labels) == 3
|
||||
assert labels[0]["name"] == "bug"
|
||||
assert labels[0]["text"] == "bug: Something isn't working"
|
||||
assert labels[2]["text"] == "docs" # no description, just name
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_empty_repo_labels(self, mock_get):
|
||||
mock_get.return_value = []
|
||||
labels = fetch_repo_labels()
|
||||
assert labels == []
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_null_description_handled(self, mock_get):
|
||||
mock_get.return_value = [
|
||||
{"name": "wontfix", "description": None},
|
||||
]
|
||||
labels = fetch_repo_labels()
|
||||
assert labels[0]["text"] == "wontfix"
|
||||
|
||||
@patch("sweep.API_PAGE_SIZE", 2)
|
||||
@patch("sweep.github_api_get")
|
||||
def test_label_pagination(self, mock_get):
|
||||
"""Repos with more labels than one page should fetch all pages."""
|
||||
mock_get.side_effect = [
|
||||
# First page: full (2 items = API_PAGE_SIZE)
|
||||
[
|
||||
{"name": "bug", "description": "Broken"},
|
||||
{"name": "feature", "description": "New"},
|
||||
],
|
||||
# Second page: partial (1 item < API_PAGE_SIZE) -> stop
|
||||
[
|
||||
{"name": "docs", "description": "Documentation"},
|
||||
],
|
||||
]
|
||||
labels = fetch_repo_labels()
|
||||
assert len(labels) == 3
|
||||
assert mock_get.call_count == 2
|
||||
assert labels[0]["name"] == "bug"
|
||||
assert labels[2]["name"] == "docs"
|
||||
|
||||
|
||||
class TestApplyLabelsToItem:
|
||||
"""Tests for apply_labels_to_item."""
|
||||
|
||||
def test_empty_labels_skips(self):
|
||||
# Should not make any API call
|
||||
apply_labels_to_item(1, [])
|
||||
|
||||
@patch("sweep.urllib.request.urlopen")
|
||||
def test_successful_label_application(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b'[{"name": "bug"}]'
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
# Should not raise
|
||||
apply_labels_to_item(42, ["bug", "enhancement"])
|
||||
|
||||
@patch("sweep.urllib.request.urlopen")
|
||||
def test_http_error_is_non_fatal(self, mock_urlopen):
|
||||
error = HTTPError(
|
||||
url="https://api.github.com/repos/owner/repo/issues/1/labels",
|
||||
code=404,
|
||||
msg="Not Found",
|
||||
hdrs=None, # type: ignore[arg-type]
|
||||
fp=BytesIO(b'{"message": "not found"}'),
|
||||
)
|
||||
mock_urlopen.side_effect = error
|
||||
|
||||
# Should NOT raise — labeling failures are warnings, not fatal
|
||||
apply_labels_to_item(1, ["bug"])
|
||||
|
||||
|
||||
class TestGenerateReportWithLabels:
|
||||
"""Tests for label suggestions in the report."""
|
||||
|
||||
def test_report_includes_label_section_high_confidence(self):
|
||||
"""High-confidence label (raw_sim >= 0.5) should appear in main table."""
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Fix crash", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="crash",
|
||||
),
|
||||
]
|
||||
suggestions = [[("bug", 0.85)]]
|
||||
report = generate_report(items, [], [], label_suggestions=suggestions)
|
||||
assert "Suggested Labels" in report
|
||||
assert "`bug` (0.85)" in report
|
||||
assert "1 items suggested for labeling" in report
|
||||
|
||||
def test_report_low_confidence_in_details(self):
|
||||
"""Low-confidence label (raw_sim < 0.5) should be in <details> section."""
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Something", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="something",
|
||||
),
|
||||
]
|
||||
suggestions = [[("maybe-bug", 0.35)]]
|
||||
report = generate_report(items, [], [], label_suggestions=suggestions)
|
||||
assert "Low-confidence suggestions" in report
|
||||
assert "<details>" in report
|
||||
assert "`maybe-bug` (0.35)" in report
|
||||
|
||||
def test_report_skips_already_labeled_items(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Already labeled", html_url="https://example.com/1",
|
||||
is_pr=False, labels=["bug"], created_at="2026-01-01T00:00:00Z", text="bug",
|
||||
),
|
||||
]
|
||||
suggestions = [[("bug", 0.95)]]
|
||||
report = generate_report(items, [], [], label_suggestions=suggestions)
|
||||
assert "0 items suggested for labeling" in report
|
||||
assert "No unlabeled items" in report
|
||||
|
||||
def test_report_excludes_outliers_from_suggestions(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Spam garbage", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="spam",
|
||||
),
|
||||
TriageItem(
|
||||
number=2, title="Real bug", html_url="https://example.com/2",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="bug",
|
||||
),
|
||||
]
|
||||
suggestions = [[("bug", 0.85)], [("bug", 0.90)]]
|
||||
# Item 0 is an outlier (with distance) — should be excluded from label suggestions
|
||||
report = generate_report(items, [(0, 15.2)], [], label_suggestions=suggestions)
|
||||
assert "1 unlabeled items" in report # only item 2
|
||||
assert "#2" in report
|
||||
# Item 0 (outlier) should NOT be in the suggestions table
|
||||
assert "Spam garbage" not in report.split("Suggested Labels")[1]
|
||||
|
||||
def test_report_without_label_suggestions(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="T", html_url="u",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="t",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [], label_suggestions=None)
|
||||
assert "Suggested Labels" not in report
|
||||
|
||||
def test_label_concentration_warning(self):
|
||||
"""When >50% of suggestions point to the same label, a warning should appear."""
|
||||
items = [
|
||||
TriageItem(
|
||||
number=i, title=f"Item {i}", html_url=f"https://example.com/{i}",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text=f"text {i}",
|
||||
)
|
||||
for i in range(4)
|
||||
]
|
||||
# 3 out of 4 items get "bug" label -> 75% concentration
|
||||
suggestions = [
|
||||
[("bug", 0.85)],
|
||||
[("bug", 0.80)],
|
||||
[("bug", 0.75)],
|
||||
[("enhancement", 0.90)],
|
||||
]
|
||||
report = generate_report(items, [], [], label_suggestions=suggestions)
|
||||
assert "Warning" in report
|
||||
assert "`bug`" in report
|
||||
assert "3/4" in report
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for the main orchestration function."""
|
||||
|
||||
@patch.dict(os.environ, {"GITHUB_TOKEN": "", "GITHUB_REPOSITORY": "owner/repo"})
|
||||
def test_missing_token_exits(self):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
@patch.dict(os.environ, {"GITHUB_TOKEN": "tok", "GITHUB_REPOSITORY": ""})
|
||||
def test_missing_repo_exits(self):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
@patch("sweep.write_report")
|
||||
@patch("sweep.fetch_all_open_items", return_value=[])
|
||||
def test_no_items(self, mock_fetch, mock_write):
|
||||
main()
|
||||
mock_write.assert_called_once()
|
||||
report = mock_write.call_args[0][0]
|
||||
assert "No open issues or PRs found" in report
|
||||
|
||||
@patch("sweep.create_report_issue")
|
||||
@patch("sweep.write_report")
|
||||
@patch("sweep.suggest_labels", return_value=[])
|
||||
@patch("sweep.find_duplicate_pairs", return_value=[])
|
||||
@patch("sweep.detect_outliers", return_value=[])
|
||||
@patch("sweep.reduce_dimensions")
|
||||
@patch("sweep.normalize_rows")
|
||||
@patch("sweep.embed_texts")
|
||||
@patch("sweep.fetch_repo_labels")
|
||||
@patch("sweep.fetch_all_open_items")
|
||||
def test_full_flow_with_enough_items(
|
||||
self, mock_fetch, mock_labels, mock_embed, mock_norm, mock_reduce,
|
||||
mock_outliers, mock_dupes, mock_suggest, mock_write, mock_create,
|
||||
):
|
||||
"""Test the full flow with >= MIN_SAMPLES items (outlier detection runs)."""
|
||||
n = MIN_SAMPLES_FOR_OUTLIER_DETECTION
|
||||
items = [
|
||||
TriageItem(
|
||||
number=i, title=f"Item {i}", html_url=f"https://example.com/{i}",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text=f"text {i}",
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
mock_fetch.return_value = items
|
||||
mock_labels.return_value = [
|
||||
RepoLabel(name="bug", description="Something broken", text="bug: Something broken"),
|
||||
]
|
||||
|
||||
embeddings = np.random.randn(n, 384).astype(np.float32)
|
||||
mock_embed.return_value = embeddings
|
||||
mock_norm.return_value = embeddings
|
||||
mock_reduce.return_value = np.random.randn(n, 10).astype(np.float32)
|
||||
|
||||
main()
|
||||
|
||||
mock_fetch.assert_called_once()
|
||||
mock_labels.assert_called_once()
|
||||
# embed_texts called twice: once for items, once for labels
|
||||
assert mock_embed.call_count == 2
|
||||
mock_norm.assert_called()
|
||||
mock_reduce.assert_called_once()
|
||||
mock_outliers.assert_called_once()
|
||||
mock_dupes.assert_called_once()
|
||||
mock_suggest.assert_called_once()
|
||||
mock_write.assert_called_once()
|
||||
mock_create.assert_called_once()
|
||||
|
||||
@patch("sweep.create_report_issue")
|
||||
@patch("sweep.write_report")
|
||||
@patch("sweep.suggest_labels", return_value=[])
|
||||
@patch("sweep.find_duplicate_pairs", return_value=[])
|
||||
@patch("sweep.detect_outliers")
|
||||
@patch("sweep.reduce_dimensions")
|
||||
@patch("sweep.normalize_rows")
|
||||
@patch("sweep.embed_texts")
|
||||
@patch("sweep.fetch_repo_labels", return_value=[])
|
||||
@patch("sweep.fetch_all_open_items")
|
||||
def test_skips_outlier_detection_for_few_items(
|
||||
self, mock_fetch, mock_labels, mock_embed, mock_norm, mock_reduce,
|
||||
mock_outliers, mock_dupes, mock_suggest, mock_write, mock_create,
|
||||
):
|
||||
"""With < MIN_SAMPLES items, outlier detection should be skipped."""
|
||||
n = MIN_SAMPLES_FOR_OUTLIER_DETECTION - 1
|
||||
items = [
|
||||
TriageItem(
|
||||
number=i, title=f"Item {i}", html_url=f"https://example.com/{i}",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text=f"text {i}",
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
mock_fetch.return_value = items
|
||||
|
||||
embeddings = np.random.randn(n, 384).astype(np.float32)
|
||||
mock_embed.return_value = embeddings
|
||||
mock_norm.return_value = embeddings
|
||||
|
||||
main()
|
||||
|
||||
# Outlier detection should not have been called
|
||||
mock_reduce.assert_not_called()
|
||||
mock_outliers.assert_not_called()
|
||||
# But duplicates should still be checked
|
||||
mock_dupes.assert_called_once()
|
||||
|
||||
@patch.dict(os.environ, {"INPUT_DRY_RUN": "true"})
|
||||
@patch("sweep.DRY_RUN", True)
|
||||
@patch("sweep.write_report")
|
||||
@patch("sweep.create_report_issue")
|
||||
@patch("sweep.apply_labels_to_item")
|
||||
@patch("sweep.suggest_labels", return_value=[[("bug", 0.85)]])
|
||||
@patch("sweep.find_duplicate_pairs", return_value=[])
|
||||
@patch("sweep.normalize_rows")
|
||||
@patch("sweep.embed_texts")
|
||||
@patch("sweep.fetch_repo_labels")
|
||||
@patch("sweep.fetch_all_open_items")
|
||||
def test_dry_run_skips_issue_creation_and_labeling(
|
||||
self, mock_fetch, mock_labels, mock_embed, mock_norm,
|
||||
mock_dupes, mock_suggest, mock_apply, mock_create, mock_write,
|
||||
):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Item", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="text",
|
||||
)
|
||||
]
|
||||
mock_fetch.return_value = items
|
||||
mock_labels.return_value = [
|
||||
RepoLabel(name="bug", description="Broken", text="bug: Broken"),
|
||||
]
|
||||
embeddings = np.random.randn(1, 384).astype(np.float32)
|
||||
mock_embed.return_value = embeddings
|
||||
mock_norm.return_value = embeddings
|
||||
|
||||
main()
|
||||
|
||||
mock_create.assert_not_called()
|
||||
mock_apply.assert_not_called()
|
||||
mock_write.assert_called_once()
|
||||
|
||||
@patch("sweep.create_report_issue")
|
||||
@patch("sweep.write_report")
|
||||
@patch("sweep.apply_labels_to_item")
|
||||
@patch("sweep.suggest_labels")
|
||||
@patch("sweep.find_duplicate_pairs", return_value=[])
|
||||
@patch("sweep.normalize_rows")
|
||||
@patch("sweep.embed_texts")
|
||||
@patch("sweep.fetch_repo_labels")
|
||||
@patch("sweep.fetch_all_open_items")
|
||||
def test_labels_not_auto_applied(
|
||||
self, mock_fetch, mock_labels, mock_embed, mock_norm,
|
||||
mock_dupes, mock_suggest, mock_apply, mock_write, mock_create,
|
||||
):
|
||||
"""Auto-labeling is disabled; labels should appear in report only."""
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Crash bug", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="crash",
|
||||
),
|
||||
TriageItem(
|
||||
number=2, title="Already labeled", html_url="https://example.com/2",
|
||||
is_pr=False, labels=["enhancement"], created_at="2026-01-01T00:00:00Z", text="feat",
|
||||
),
|
||||
]
|
||||
mock_fetch.return_value = items
|
||||
mock_labels.return_value = [
|
||||
RepoLabel(name="bug", description="Broken", text="bug: Broken"),
|
||||
]
|
||||
mock_suggest.return_value = [
|
||||
[("bug", 0.90)],
|
||||
[("bug", 0.45)],
|
||||
]
|
||||
|
||||
embeddings = np.random.randn(2, 384).astype(np.float32)
|
||||
mock_embed.return_value = embeddings
|
||||
mock_norm.return_value = embeddings
|
||||
|
||||
main()
|
||||
|
||||
# Auto-labeling is disabled — apply_labels_to_item should never be called
|
||||
mock_apply.assert_not_called()
|
||||
|
||||
@patch("sweep.create_report_issue")
|
||||
@patch("sweep.write_report")
|
||||
@patch("sweep.apply_labels_to_item")
|
||||
@patch("sweep.suggest_labels")
|
||||
@patch("sweep.find_duplicate_pairs", return_value=[])
|
||||
@patch("sweep.detect_outliers")
|
||||
@patch("sweep.reduce_dimensions")
|
||||
@patch("sweep.normalize_rows")
|
||||
@patch("sweep.embed_texts")
|
||||
@patch("sweep.fetch_repo_labels")
|
||||
@patch("sweep.fetch_all_open_items")
|
||||
def test_outliers_excluded_from_report_suggestions(
|
||||
self, mock_fetch, mock_labels, mock_embed, mock_norm, mock_reduce,
|
||||
mock_outliers, mock_dupes, mock_suggest, mock_apply, mock_write, mock_create,
|
||||
):
|
||||
"""Items flagged as outliers should not appear in report label suggestions."""
|
||||
n = MIN_SAMPLES_FOR_OUTLIER_DETECTION
|
||||
items = [
|
||||
TriageItem(
|
||||
number=i, title=f"Item {i}", html_url=f"https://example.com/{i}",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text=f"text {i}",
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
mock_fetch.return_value = items
|
||||
mock_labels.return_value = [
|
||||
RepoLabel(name="bug", description="Broken", text="bug: Broken"),
|
||||
]
|
||||
mock_outliers.return_value = [(0, 12.5), (5, 15.3)]
|
||||
mock_suggest.return_value = [[("bug", 0.85)] for _ in range(n)]
|
||||
|
||||
embeddings = np.random.randn(n, 384).astype(np.float32)
|
||||
mock_embed.return_value = embeddings
|
||||
mock_norm.return_value = embeddings
|
||||
mock_reduce.return_value = np.random.randn(n, 10).astype(np.float32)
|
||||
|
||||
main()
|
||||
|
||||
# Auto-labeling is disabled
|
||||
mock_apply.assert_not_called()
|
||||
# Report should still be generated (outliers excluded from suggestions in report)
|
||||
mock_write.assert_called_once()
|
||||
report = mock_write.call_args[0][0]
|
||||
# Outlier items 0 and 5 should not appear in the label suggestions section
|
||||
assert "Item 0" not in report.split("Suggested Labels")[1] if "Suggested Labels" in report else True
|
||||
@@ -1,94 +0,0 @@
|
||||
name: PR Description Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, reopened]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: pr-desc-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-description:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check PR description quality
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
const MIN_BODY_LENGTH = 50;
|
||||
const LABEL = 'needs-description';
|
||||
|
||||
const pr = context.payload.pull_request;
|
||||
const body = (pr.body || '').trim();
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const number = pr.number;
|
||||
|
||||
const hasLabel = pr.labels.some(l => l.name === LABEL);
|
||||
|
||||
if (body.length < MIN_BODY_LENGTH) {
|
||||
// Add label if not already present
|
||||
if (!hasLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: number,
|
||||
labels: [LABEL],
|
||||
});
|
||||
}
|
||||
|
||||
// Post or update a comment
|
||||
const marker = '<!-- pr-desc-check -->';
|
||||
const message = [
|
||||
marker,
|
||||
`### PR description is too short`,
|
||||
'',
|
||||
`This PR's description is **${body.length}** characters, ` +
|
||||
`but the minimum is **${MIN_BODY_LENGTH}**.`,
|
||||
'',
|
||||
'Please update the PR description to explain:',
|
||||
'- **What** this PR changes',
|
||||
'- **Why** the change is needed',
|
||||
'',
|
||||
'Use the PR template as a guide. This check will re-run when you edit the description.',
|
||||
].join('\n');
|
||||
|
||||
// Find existing bot comment to update (avoid spam)
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner, repo, issue_number: number,
|
||||
});
|
||||
const existing = comments.data.find(c =>
|
||||
c.body && c.body.includes(marker)
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner, repo, comment_id: existing.id,
|
||||
body: message,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: number,
|
||||
body: message,
|
||||
});
|
||||
}
|
||||
|
||||
core.setFailed(
|
||||
`PR description is ${body.length} chars (minimum: ${MIN_BODY_LENGTH})`
|
||||
);
|
||||
} else {
|
||||
// Description is acceptable — remove the label if present
|
||||
if (hasLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: number,
|
||||
name: LABEL,
|
||||
}).catch(() => {});
|
||||
// .catch: label may have been removed manually
|
||||
}
|
||||
|
||||
core.info(`PR description OK (${body.length} chars)`);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
name: Triage Sweep
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
iqr_multiplier:
|
||||
description: >-
|
||||
IQR multiplier for outlier cutoff.
|
||||
cutoff = Q75 + multiplier * IQR.
|
||||
Higher = fewer outliers flagged.
|
||||
type: number
|
||||
default: 3.0
|
||||
max_outlier_pct:
|
||||
description: >-
|
||||
Maximum fraction of items that can be flagged as outliers (0-1).
|
||||
Hard cap to prevent over-flagging.
|
||||
type: number
|
||||
default: 0.05
|
||||
contamination:
|
||||
description: >-
|
||||
Expected fraction of outliers in the data (0-0.5).
|
||||
Controls how aggressively EllipticEnvelope downweights extremes.
|
||||
type: number
|
||||
default: 0.1
|
||||
cosine_threshold:
|
||||
description: >-
|
||||
Cosine similarity threshold for duplicate detection.
|
||||
Pairs with similarity above this are flagged as potential duplicates.
|
||||
Higher = only very similar pairs flagged.
|
||||
type: number
|
||||
default: 0.92
|
||||
max_items:
|
||||
description: >-
|
||||
Maximum number of open issues + PRs to process.
|
||||
Hard cap to prevent runaway costs on very large repos.
|
||||
type: number
|
||||
default: 500
|
||||
dry_run:
|
||||
description: >-
|
||||
Check this to only log results to the workflow summary.
|
||||
Uncheck to create a GitHub issue with the report and apply labels.
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: triage-sweep
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts/triage
|
||||
sparse-checkout-cone-mode: false
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: .github/scripts/triage/requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r .github/scripts/triage/requirements.txt
|
||||
|
||||
- name: Cache FastEmbed model weights
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/.fastembed_cache
|
||||
key: fastembed-bge-small-en-v1.5
|
||||
|
||||
- name: Run triage sweep
|
||||
id: sweep
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
FASTEMBED_CACHE_PATH: ${{ github.workspace }}/.fastembed_cache
|
||||
INPUT_IQR_MULTIPLIER: ${{ inputs.iqr_multiplier }}
|
||||
INPUT_MAX_OUTLIER_PCT: ${{ inputs.max_outlier_pct }}
|
||||
INPUT_CONTAMINATION: ${{ inputs.contamination }}
|
||||
INPUT_COSINE_THRESHOLD: ${{ inputs.cosine_threshold }}
|
||||
INPUT_MAX_ITEMS: ${{ inputs.max_items }}
|
||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: python .github/scripts/triage/sweep.py
|
||||
|
||||
- name: Post summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/triage-report.md ]; then
|
||||
cat /tmp/triage-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "No report generated." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
@@ -70,5 +70,3 @@ gitnexus/test/fixtures/lang-resolution/**/bin
|
||||
GitNexus.sln
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
/github/scripts/triage/__pycache__/
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-commit hook (husky): typecheck + unit tests for both packages.
|
||||
# Mirrors CI checks from ci-quality.yml and ci-tests.yml.
|
||||
# Skip with: git commit --no-verify
|
||||
#
|
||||
# CI coverage:
|
||||
# quality / typecheck → tsc --noEmit in gitnexus/
|
||||
# quality / typecheck-web → tsc -b --noEmit in gitnexus-web/
|
||||
# tests / ubuntu+coverage → vitest run in gitnexus/ (all projects)
|
||||
# e2e / chromium → playwright (requires servers — skipped)
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
WEB_CHANGED=$(git diff --cached --name-only -- 'gitnexus-web/' | head -1)
|
||||
CLI_CHANGED=$(git diff --cached --name-only -- 'gitnexus/' | head -1)
|
||||
|
||||
if [ -n "$WEB_CHANGED" ]; then
|
||||
echo "pre-commit: typechecking gitnexus-web (tsc -b)..."
|
||||
cd "$ROOT/gitnexus-web" && npx tsc -b --noEmit
|
||||
|
||||
echo "pre-commit: running gitnexus-web unit tests..."
|
||||
npx vitest run --reporter=dot
|
||||
fi
|
||||
|
||||
if [ -n "$CLI_CHANGED" ]; then
|
||||
echo "pre-commit: typechecking gitnexus..."
|
||||
cd "$ROOT/gitnexus" && npx tsc --noEmit
|
||||
|
||||
echo "pre-commit: running gitnexus unit tests (default project)..."
|
||||
npx vitest run --project default --reporter=dot
|
||||
fi
|
||||
|
||||
echo "pre-commit: all checks passed"
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (2298 symbols, 5501 relationships, 175 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **GitNexus** (2184 symbols, 5245 relationships, 167 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.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (2298 symbols, 5501 relationships, 175 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **GitNexus** (2184 symbols, 5245 relationships, 167 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.
|
||||
|
||||
|
||||
@@ -91,16 +91,11 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
||||
|
||||
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that auto-reindex after commits.
|
||||
|
||||
## Community Integrations
|
||||
### Community Integrations
|
||||
|
||||
Built by the community — not officially maintained, but worth checking out.
|
||||
|
||||
| Project | Author | Description |
|
||||
|---------|--------|-------------|
|
||||
| [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) | [@tintinweb](https://github.com/tintinweb) | GitNexus plugin for [pi](https://pi.dev) — `pi install npm:pi-gitnexus` |
|
||||
| [gitnexus-stable-ops](https://github.com/ShunsukeHayashi/gitnexus-stable-ops) | [@ShunsukeHayashi](https://github.com/ShunsukeHayashi) | Stable ops & deployment workflows (Miyabi ecosystem) |
|
||||
|
||||
> Have a project built on GitNexus? Open a PR to add it here!
|
||||
| Agent | Install | Source |
|
||||
|-------|---------|--------|
|
||||
| [pi](https://pi.dev) | `pi install npm:pi-gitnexus` | [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) |
|
||||
|
||||
If you prefer manual configuration:
|
||||
|
||||
|
||||
Generated
+8
-1079
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.0.0",
|
||||
@@ -66,7 +65,6 @@
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.0",
|
||||
"vite-plugin-static-copy": "^3.1.4",
|
||||
"vitest": "^4.0.18"
|
||||
"vite-plugin-static-copy": "^3.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
+31
-47
@@ -13,7 +13,6 @@ import { FileEntry } from './services/zip';
|
||||
import { getActiveProviderConfig } from './core/llm/settings-service';
|
||||
import { createKnowledgeGraph } from './core/graph/graph';
|
||||
import { connectToServer, fetchRepos, normalizeServerUrl, type ConnectToServerResult } from './services/server-connection';
|
||||
import { HelpPanel } from './components/HelpPanel';
|
||||
|
||||
const AppContent = () => {
|
||||
const {
|
||||
@@ -29,8 +28,6 @@ const AppContent = () => {
|
||||
runPipelineFromFiles,
|
||||
isSettingsPanelOpen,
|
||||
setSettingsPanelOpen,
|
||||
isHelpDialogBoxOpen,
|
||||
setHelpDialogBoxOpen,
|
||||
refreshLLMSettings,
|
||||
initializeAgent,
|
||||
startEmbeddings,
|
||||
@@ -43,8 +40,6 @@ const AppContent = () => {
|
||||
availableRepos,
|
||||
setAvailableRepos,
|
||||
switchRepo,
|
||||
loadServerGraph,
|
||||
graph
|
||||
} = useAppState();
|
||||
|
||||
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
|
||||
@@ -137,13 +132,13 @@ const AppContent = () => {
|
||||
}
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings, initializeAgent]);
|
||||
|
||||
const handleServerConnect = useCallback((result: ConnectToServerResult): Promise<void> => {
|
||||
const handleServerConnect = useCallback((result: ConnectToServerResult) => {
|
||||
// Extract project name from repoPath
|
||||
const repoPath = result.repoInfo.repoPath;
|
||||
const projectName = repoPath.split('/').pop() || 'server-project';
|
||||
setProjectName(projectName);
|
||||
|
||||
// Build KnowledgeGraph from server data for visualization
|
||||
// Build KnowledgeGraph from server data (bypasses WASM pipeline entirely)
|
||||
const graph = createKnowledgeGraph();
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode(node);
|
||||
@@ -163,30 +158,20 @@ const AppContent = () => {
|
||||
// Transition directly to exploring view
|
||||
setViewMode('exploring');
|
||||
|
||||
// Load graph into LadybugDB (in-browser WASM database) for Nexus AI queries,
|
||||
// then initialize agent once the database is ready
|
||||
const loadGraphPromise = loadServerGraph(result.nodes, result.relationships, result.fileContents)
|
||||
.then(() => {
|
||||
if (getActiveProviderConfig()) {
|
||||
return initializeAgent(projectName);
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
startEmbeddings().catch((err) => {
|
||||
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
|
||||
startEmbeddings('wasm').catch(console.warn);
|
||||
} else {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Failed to load graph into LadybugDB:', err);
|
||||
// Agent won't work but graph visualization still does
|
||||
});
|
||||
// Initialize agent if LLM is configured
|
||||
if (getActiveProviderConfig()) {
|
||||
initializeAgent(projectName);
|
||||
}
|
||||
|
||||
return loadGraphPromise;
|
||||
}, [setViewMode, setGraph, setFileContents, setProjectName, loadServerGraph, initializeAgent, startEmbeddings]);
|
||||
// Auto-start embeddings
|
||||
startEmbeddings().catch((err) => {
|
||||
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
|
||||
startEmbeddings('wasm').catch(console.warn);
|
||||
} else {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
}
|
||||
});
|
||||
}, [setViewMode, setGraph, setFileContents, setProjectName, initializeAgent, startEmbeddings]);
|
||||
|
||||
// Auto-connect when ?server query param is present (bookmarkable shortcut)
|
||||
const autoConnectRan = useRef(false);
|
||||
@@ -218,12 +203,16 @@ const AppContent = () => {
|
||||
setProgress({ phase: 'extracting', percent: 97, message: 'Processing...', detail: 'Extracting file contents' });
|
||||
}
|
||||
}).then(async (result) => {
|
||||
await handleServerConnect(result);
|
||||
setProgress(null);
|
||||
handleServerConnect(result);
|
||||
|
||||
// Store server URL and fetch available repos for the repo switcher
|
||||
setServerBaseUrl(baseUrl);
|
||||
fetchRepos(baseUrl)
|
||||
.then((repos) => setAvailableRepos(repos))
|
||||
.catch((e) => console.warn('Failed to fetch repo list:', e));
|
||||
try {
|
||||
const repos = await fetchRepos(baseUrl);
|
||||
setAvailableRepos(repos);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch repo list:', e);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error('Auto-connect failed:', err);
|
||||
setProgress({
|
||||
@@ -257,14 +246,16 @@ const AppContent = () => {
|
||||
onFileSelect={handleFileSelect}
|
||||
onGitClone={handleGitClone}
|
||||
onServerConnect={async (result, serverUrl) => {
|
||||
await handleServerConnect(result);
|
||||
setProgress(null);
|
||||
handleServerConnect(result);
|
||||
if (serverUrl) {
|
||||
const baseUrl = normalizeServerUrl(serverUrl);
|
||||
setServerBaseUrl(baseUrl);
|
||||
fetchRepos(baseUrl)
|
||||
.then((repos) => setAvailableRepos(repos))
|
||||
.catch((e) => console.warn('Failed to fetch repo list:', e));
|
||||
try {
|
||||
const repos = await fetchRepos(baseUrl);
|
||||
setAvailableRepos(repos);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch repo list:', e);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -309,13 +300,6 @@ const AppContent = () => {
|
||||
onSettingsSaved={handleSettingsSaved}
|
||||
/>
|
||||
|
||||
<HelpPanel
|
||||
isOpen={isHelpDialogBoxOpen}
|
||||
onClose={() => setHelpDialogBoxOpen(false)}
|
||||
nodeCount={graph!.nodes.length}
|
||||
edgeCount={graph!.relationships.length}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,9 +26,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
blastRadiusNodeIds,
|
||||
isAIHighlightsEnabled,
|
||||
toggleAIHighlights,
|
||||
clearAIToolHighlights,
|
||||
clearAICitationHighlights,
|
||||
clearBlastRadius,
|
||||
animatedNodes,
|
||||
} = useAppState();
|
||||
const [hoveredNodeName, setHoveredNodeName] = useState<string | null>(null);
|
||||
@@ -308,13 +305,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
<div className="absolute top-4 right-4 z-20">
|
||||
<button
|
||||
onClick={() => {
|
||||
// If turning off, also clear process highlights
|
||||
if (isAIHighlightsEnabled) {
|
||||
// Turning off — clear AI highlights and selection (preserve user query highlights)
|
||||
clearAIToolHighlights();
|
||||
clearAICitationHighlights();
|
||||
clearBlastRadius();
|
||||
setSelectedNode(null);
|
||||
setSigmaSelectedNode(null);
|
||||
setHighlightedNodeIds(new Set());
|
||||
}
|
||||
toggleAIHighlights();
|
||||
}}
|
||||
|
||||
@@ -32,7 +32,6 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
|
||||
isRightPanelOpen,
|
||||
rightPanelTab,
|
||||
setSettingsPanelOpen,
|
||||
setHelpDialogBoxOpen
|
||||
} = useAppState();
|
||||
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
|
||||
const repoDropdownRef = useRef<HTMLDivElement>(null);
|
||||
@@ -267,13 +266,10 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
|
||||
className="w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
|
||||
title="AI Settings"
|
||||
>
|
||||
<Settings className="w-4.5 h-4.5" />
|
||||
<Settings className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
<button
|
||||
title="Help"
|
||||
onClick={() => setHelpDialogBoxOpen(true)}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors">
|
||||
<HelpCircle className="w-4.5 h-4.5" />
|
||||
<button className="w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors">
|
||||
<HelpCircle className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
|
||||
{/* AI Button */}
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, GitBranch, Search, Filter, Zap, Keyboard, BarChart2, HelpCircle } from 'lucide-react';
|
||||
|
||||
interface HelpPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
}
|
||||
|
||||
type TabId = 'overview' | 'graph' | 'search' | 'ai' | 'shortcuts' | 'status';
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: 'overview', label: 'Overview', icon: <HelpCircle className="w-4 h-4" /> },
|
||||
{ id: 'graph', label: 'Graph & nodes', icon: <GitBranch className="w-4 h-4" /> },
|
||||
{ id: 'search', label: 'Search & filter', icon: <Search className="w-4 h-4" /> },
|
||||
{ id: 'ai', label: 'Nexus AI', icon: <Zap className="w-4 h-4" /> },
|
||||
{ id: 'shortcuts', label: 'Shortcuts', icon: <Keyboard className="w-4 h-4" /> },
|
||||
{ id: 'status', label: 'Status bar', icon: <BarChart2 className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
const shortcuts = [
|
||||
{ label: 'Search nodes', mac: '⌘ K', win: 'Ctrl K' },
|
||||
{ label: 'Deselect / close', mac: 'Esc', win: 'Esc' },
|
||||
];
|
||||
|
||||
const nodeColors = [
|
||||
{ color: '#10b981', label: 'Function', desc: 'Function declarations' },
|
||||
{ color: '#3b82f6', label: 'File', desc: 'Source files' },
|
||||
{ color: '#f59e0b', label: 'Class', desc: 'Class declarations' },
|
||||
{ color: '#14b8a6', label: 'Method', desc: 'Class methods' },
|
||||
{ color: '#ec4899', label: 'Interface', desc: 'TypeScript interfaces' },
|
||||
{ color: '#6366f1', label: 'Folder', desc: 'Directory nodes' },
|
||||
];
|
||||
|
||||
const getStatusItems = (nodeCount: number, edgeCount: number) => [
|
||||
{ badge: <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#34d399', display: 'inline-block', flexShrink: 0 }} />, title: 'Ready', desc: 'Graph is fully loaded and interactive' },
|
||||
{ badge: <span style={{ fontSize: 12, fontWeight: 500, color: '#a78bfa', flexShrink: 0 }}>{nodeCount}</span>, title: 'Nodes count', desc: 'Total files and symbols in the graph' },
|
||||
{ badge: <span style={{ fontSize: 12, fontWeight: 500, color: '#60a5fa', flexShrink: 0 }}>{edgeCount}</span>, title: 'Edges count', desc: 'Import / dependency connections' },
|
||||
{ badge: <span style={{ fontSize: 11, fontWeight: 500, color: '#34d399', flexShrink: 0, whiteSpace: 'nowrap' }}>Semantic Ready</span>, title: 'AI index status', desc: 'Repo is fully indexed for AI queries' },
|
||||
// { badge: <span style={{ fontSize: 11, fontWeight: 500, color: '#9ca3af', flexShrink: 0 }}>typescript</span>, title: 'Language', desc: 'Primary language detected in the repo' },
|
||||
];
|
||||
|
||||
const kbdStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
borderRadius: 4,
|
||||
padding: '2px 8px',
|
||||
color: '#e2e2e8',
|
||||
fontFamily: 'monospace',
|
||||
border: '0.5px solid rgba(255,255,255,0.12)',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
const kbdWinStyle: React.CSSProperties = {
|
||||
...kbdStyle,
|
||||
color: '#93c5fd',
|
||||
};
|
||||
|
||||
function TabContent({ active, nodeCount, edgeCount }: {
|
||||
active: TabId;
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
}) {
|
||||
if (active === 'overview') return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<p style={{ fontSize: 11, color: '#6b7280', margin: '0 0 4px', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Getting started</p>
|
||||
|
||||
<div style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 10, padding: '12px 14px', borderLeft: '2px solid #a78bfa' }}>
|
||||
<p style={{ fontSize: 13, fontWeight: 500, color: '#e2e2e8', margin: '0 0 4px' }}>What is GitNexus?</p>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>An interactive graph explorer for your codebase. Every file, function, and import becomes a node you can explore, query, and navigate visually.</p>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 10, padding: '12px 14px', borderLeft: '2px solid #34d399' }}>
|
||||
<p style={{ fontSize: 13, fontWeight: 500, color: '#e2e2e8', margin: '0 0 4px' }}>Your current repo</p>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
Loaded: <span style={{ color: '#a78bfa', fontFamily: 'monospace' }}></span> {nodeCount} nodes · {edgeCount} edges
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 10, padding: '12px 14px', borderLeft: '2px solid #60a5fa' }}>
|
||||
<p style={{ fontSize: 13, fontWeight: 500, color: '#e2e2e8', margin: '0 0 4px' }}>Three ways to explore</p>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
<strong style={{ color: '#e2e2e8', fontWeight: 500 }}>1.</strong> Click nodes to inspect
|
||||
<br/>
|
||||
<strong style={{ color: '#e2e2e8', fontWeight: 500 }}>2.</strong> Search by name or type
|
||||
<br/>
|
||||
<strong style={{ color: '#e2e2e8', fontWeight: 500 }}>3.</strong> Ask Nexus AI a natural language question
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 10, padding: '12px 14px', borderLeft: '2px solid #fbbf24' }}>
|
||||
<p style={{ fontSize: 13, fontWeight: 500, color: '#e2e2e8', margin: '0 0 4px' }}>Navigation</p>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
· Scroll to zoom <br/>
|
||||
· Click and drag to pan <br/>
|
||||
· Double-click a node to focus its subgraph
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (active === 'graph') return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<p style={{ fontSize: 11, color: '#6b7280', margin: '0 0 4px', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Node color legend</p>
|
||||
|
||||
{nodeColors.map(({ color, label, desc }) => (
|
||||
<div key={label} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<span style={{ width: 12, height: 12, borderRadius: '50%', background: color, flexShrink: 0, marginTop: 2 }} />
|
||||
<div>
|
||||
<p style={{ fontSize: 12, fontWeight: 500, color: '#e2e2e8', margin: '0 0 2px' }}>{label} nodes</p>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0 }}>{desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ borderTop: '0.5px solid rgba(255,255,255,0.08)', margin: '4px 0' }} />
|
||||
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
Node <strong style={{ color: '#e2e2e8', fontWeight: 500 }}>size</strong> reflects connection count — larger nodes are depended on by more files. Edges point from importer → imported.
|
||||
</p>
|
||||
|
||||
<div style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 10, padding: '10px 14px' }}>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
Click any node to open its detail panel — showing imports, exports, and reverse dependencies.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (active === 'search') return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<p style={{ fontSize: 11, color: '#6b7280', margin: '0 0 4px', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Search & filter</p>
|
||||
|
||||
<div style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 10, padding: '12px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<kbd style={kbdStyle}>⌘K</kbd>/
|
||||
<kbd style={kbdStyle}>Ctrl K</kbd>
|
||||
<p style={{ fontSize: 12, fontWeight: 500, color: '#e2e2e8', margin: 0 }}>Search nodes</p>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
Search by filename, function name, or import path. Matching nodes are highlighted live in the graph.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 10, padding: '12px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<Filter style={{ width: 14, height: 14, color: '#a78bfa', flexShrink: 0 }} />
|
||||
<p style={{ fontSize: 12, fontWeight: 500, color: '#e2e2e8', margin: 0 }}>Filter panel</p>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
Use the filter icon in the left sidebar to isolate specific node types, hide leaf nodes, or focus on a depth range from a selected root.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 10, padding: '12px 14px' }}>
|
||||
<p style={{ fontSize: 12, fontWeight: 500, color: '#e2e2e8', margin: '0 0 6px' }}>Search syntax</p>
|
||||
{[
|
||||
{ query: 'auth', hint: 'match by name fragment' },
|
||||
{ query: './utils/', hint: 'match by path prefix' },
|
||||
{ query: 'type:config', hint: 'filter by node type' },
|
||||
].map(({ query, hint }) => (
|
||||
<div key={query} style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 4 }}>
|
||||
<code style={{ fontSize: 11, color: '#a78bfa', background: 'rgba(167,139,250,0.1)', borderRadius: 4, padding: '1px 6px', fontFamily: 'monospace', flexShrink: 0 }}>{query}</code>
|
||||
<span style={{ fontSize: 12, color: '#6b7280' }}>{hint}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (active === 'ai') return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<p style={{ fontSize: 11, color: '#6b7280', margin: '0 0 4px', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Nexus AI</p>
|
||||
|
||||
<div style={{ background: 'rgba(167,139,250,0.08)', border: '0.5px solid rgba(167,139,250,0.25)', borderRadius: 10, padding: '12px 14px' }}>
|
||||
<p style={{ fontSize: 12, fontWeight: 500, color: '#a78bfa', margin: '0 0 4px' }}>✓ Semantic Ready</p>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
Your repo is indexed and ready for semantic queries. Nexus AI understands code structure and relationships, not just file names.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: '4px 0 2px' }}>Try asking:</p>
|
||||
{[
|
||||
'"Which files depend on the auth module?"',
|
||||
'"Find circular dependencies in this repo"',
|
||||
'"What are the most connected components?"',
|
||||
'"Show me all files that import useEffect"',
|
||||
].map(q => (
|
||||
<div key={q} style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 8, padding: '8px 12px', fontSize: 12, color: '#e2e2e8', fontStyle: 'italic' }}>{q}</div>
|
||||
))}
|
||||
|
||||
<div style={{ borderTop: '0.5px solid rgba(255,255,255,0.08)', margin: '4px 0' }} />
|
||||
|
||||
<p style={{ fontSize: 12, color: '#6b7280', margin: 0, lineHeight: 1.6 }}>
|
||||
Open the prompt via the{' '}
|
||||
<span style={{ color: '#e2e2e8' }}>Nexus AI</span> button (top-right).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (active === 'shortcuts') return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Column headers */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 80px 88px',
|
||||
gap: 8,
|
||||
padding: '0 0 8px',
|
||||
borderBottom: '0.5px solid rgba(255,255,255,0.08)',
|
||||
marginBottom: 4,
|
||||
}}>
|
||||
<span style={{ fontSize: 11, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Action</span>
|
||||
<span style={{ fontSize: 11, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.08em', textAlign: 'center' }}>Mac</span>
|
||||
<span style={{ fontSize: 11, color: '#93c5fd', textTransform: 'uppercase', letterSpacing: '0.08em', textAlign: 'center' }}>Windows</span>
|
||||
</div>
|
||||
|
||||
{shortcuts.map(({ label, mac, win }, i) => (
|
||||
<div
|
||||
key={label}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 80px 88px',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
padding: '8px 0',
|
||||
borderBottom: i < shortcuts.length - 1 ? '0.5px solid rgba(255,255,255,0.05)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: '#9ca3af' }}>{label}</span>
|
||||
<span style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<kbd style={kbdStyle}>{mac}</kbd>
|
||||
</span>
|
||||
<span style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<kbd style={kbdWinStyle}>{win}</kbd>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (active === 'status') return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<p style={{ fontSize: 11, color: '#6b7280', margin: '0 0 4px', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Status bar explained</p>
|
||||
{getStatusItems(nodeCount, edgeCount).map(({ badge, title, desc }) => (
|
||||
<div key={title} style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 10, padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
{badge}
|
||||
<div>
|
||||
<p style={{ fontSize: 12, fontWeight: 500, color: '#e2e2e8', margin: '0 0 2px' }}>{title}</p>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0 }}>{desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const HelpPanel = ({ isOpen, onClose, nodeCount, edgeCount }: HelpPanelProps) => {
|
||||
const [active, setActive] = useState<TabId>('overview');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)' }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
background: '#12121a',
|
||||
border: '0.5px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 25px 60px rgba(0,0,0,0.7)',
|
||||
width: '100%',
|
||||
maxWidth: 680,
|
||||
margin: '0 16px',
|
||||
height: '60vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 20px',
|
||||
borderBottom: '0.5px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(167,139,250,0.15)', borderRadius: 12 }}>
|
||||
<HelpCircle style={{ width: 20, height: 20, color: '#a78bfa' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, color: '#e2e2e8', margin: 0 }}>Help & Reference</h2>
|
||||
<p style={{ fontSize: 12, color: '#6b7280', margin: 0 }}>GitNexus — graph explorer</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ padding: 8, color: '#6b7280', background: 'transparent', border: 'none', borderRadius: 8, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = '#e2e2e8')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = '#6b7280')}
|
||||
>
|
||||
<X style={{ width: 20, height: 20 }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body: sidebar + content */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '168px 1fr', flex: 1, overflow: 'hidden' }}>
|
||||
|
||||
{/* Sidebar nav */}
|
||||
<div style={{ borderRight: '0.5px solid rgba(255,255,255,0.08)', padding: '12px 8px', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{tabs.map(({ id, label, icon }) => {
|
||||
const isActive = active === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActive(id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
textAlign: 'left',
|
||||
background: isActive ? 'rgba(167,139,250,0.12)' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
fontSize: 12,
|
||||
fontFamily: 'inherit',
|
||||
color: isActive ? '#a78bfa' : '#9ca3af',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
width: '100%',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) { e.currentTarget.style.color = '#e2e2e8'; e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; } }}
|
||||
onMouseLeave={e => { if (!isActive) { e.currentTarget.style.color = '#9ca3af'; e.currentTarget.style.background = 'transparent'; } }}
|
||||
>
|
||||
<span style={{ color: isActive ? '#a78bfa' : '#6b7280', display: 'flex', flexShrink: 0 }}>{icon}</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content pane */}
|
||||
<div style={{ padding: '20px', overflowY: 'auto' }}>
|
||||
<TabContent active={active} nodeCount={nodeCount} edgeCount={edgeCount} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 20px',
|
||||
borderTop: '0.5px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(255,255,255,0.01)',
|
||||
}}>
|
||||
<span style={{ fontSize: 11, color: '#4b5563' }}>GitNexus — open source codebase graph explorer</span>
|
||||
<a
|
||||
href="https://github.com/abhigyanpatwari/GitNexus"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 11, color: '#a78bfa', textDecoration: 'none' }}
|
||||
>
|
||||
Docs & GitHub ↗
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import mermaid from 'mermaid';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { AlertTriangle, Maximize2 } from 'lucide-react';
|
||||
import { ProcessFlowModal } from './ProcessFlowModal';
|
||||
import type { ProcessData } from '../lib/mermaid-generator';
|
||||
@@ -141,7 +140,7 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex items-center justify-center p-4 overflow-auto max-h-[400px]"
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }) }}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { X, GitBranch, Copy, Focus, Layers, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import mermaid from 'mermaid';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { ProcessData, generateProcessMermaid } from '../lib/mermaid-generator';
|
||||
|
||||
interface ProcessFlowModalProps {
|
||||
@@ -91,7 +90,6 @@ export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScree
|
||||
// Handle keyboard zoom
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (e.key === '+' || e.key === '=') {
|
||||
setZoom(prev => Math.min(prev + 0.2, maxZoom));
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
@@ -138,8 +136,8 @@ export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScree
|
||||
const renderDiagram = async () => {
|
||||
try {
|
||||
// Check if we have raw mermaid code (from AI chat) or need to generate it
|
||||
const mermaidCode = process.rawMermaid
|
||||
? process.rawMermaid
|
||||
const mermaidCode = (process as any).rawMermaid
|
||||
? (process as any).rawMermaid
|
||||
: generateProcessMermaid(process);
|
||||
const id = `mermaid-${Date.now()}`;
|
||||
|
||||
@@ -147,8 +145,7 @@ export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScree
|
||||
diagramRef.current!.innerHTML = '';
|
||||
|
||||
const { svg } = await mermaid.render(id, mermaidCode);
|
||||
if (!diagramRef.current) return;
|
||||
diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } });
|
||||
diagramRef.current!.innerHTML = svg;
|
||||
} catch (error) {
|
||||
console.error('Mermaid render error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -11,9 +11,6 @@ import { useAppState } from '../hooks/useAppState';
|
||||
import { ProcessFlowModal } from './ProcessFlowModal';
|
||||
import type { ProcessData, ProcessStep } from '../lib/mermaid-generator';
|
||||
|
||||
/** Validate that an ID contains only expected node identifier characters (no Cypher metacharacters or spaces) */
|
||||
const isSafeId = (id: string): boolean => /^[a-zA-Z0-9_:.\-/@]+$/.test(id);
|
||||
|
||||
export const ProcessesPanel = () => {
|
||||
const { graph, runQuery, setHighlightedNodeIds, highlightedNodeIds } = useAppState();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -82,7 +79,7 @@ export const ProcessesPanel = () => {
|
||||
setLoadingProcess('all');
|
||||
|
||||
try {
|
||||
const allProcessIds = [...processes.cross, ...processes.intra].map(p => p.id).filter(isSafeId);
|
||||
const allProcessIds = [...processes.cross, ...processes.intra].map(p => p.id);
|
||||
|
||||
if (allProcessIds.length === 0) return;
|
||||
|
||||
@@ -113,7 +110,7 @@ export const ProcessesPanel = () => {
|
||||
}
|
||||
|
||||
const allSteps = Array.from(allStepsMap.values());
|
||||
const stepIds = allSteps.map(s => s.id).filter(isSafeId);
|
||||
const stepIds = allSteps.map(s => s.id);
|
||||
|
||||
// Query for all CALLS edges between the combined steps
|
||||
if (stepIds.length > 0) {
|
||||
@@ -158,7 +155,6 @@ export const ProcessesPanel = () => {
|
||||
|
||||
// Load process steps and open modal
|
||||
const handleViewProcess = useCallback(async (processId: string, label: string, processType: string) => {
|
||||
if (!isSafeId(processId)) return;
|
||||
setLoadingProcess(processId);
|
||||
|
||||
try {
|
||||
@@ -179,7 +175,7 @@ export const ProcessesPanel = () => {
|
||||
}));
|
||||
|
||||
// Get step IDs for edge query
|
||||
const stepIds = steps.map(s => s.id).filter(isSafeId);
|
||||
const stepIds = steps.map(s => s.id);
|
||||
|
||||
// Query for CALLS edges between the steps in this process
|
||||
let edges: Array<{ from: string; to: string; type: string }> = [];
|
||||
@@ -232,7 +228,6 @@ export const ProcessesPanel = () => {
|
||||
|
||||
// Toggle focus for any process - loads steps on demand
|
||||
const handleToggleFocusForProcess = useCallback(async (processId: string) => {
|
||||
if (!isSafeId(processId)) return;
|
||||
// If already focused on this process, turn off
|
||||
if (focusedProcessId === processId) {
|
||||
setHighlightedNodeIds(new Set());
|
||||
|
||||
@@ -21,57 +21,49 @@ import { generateAllCSVs } from './csv-generator';
|
||||
let lbug: any = null;
|
||||
let db: any = null;
|
||||
let conn: any = null;
|
||||
let initPromise: Promise<{ db: any; conn: any; lbug: any }> | null = null;
|
||||
|
||||
/**
|
||||
* Initialize LadybugDB WASM module and create in-memory database
|
||||
*/
|
||||
export const initLbug = async () => {
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
if (import.meta.env.DEV) console.log('🚀 Initializing LadybugDB...');
|
||||
if (conn) return { db, conn, lbug };
|
||||
|
||||
// 1. Dynamic Import (Fixes the "not a function" bundler issue)
|
||||
const lbugModule = await import('@ladybugdb/wasm-core');
|
||||
try {
|
||||
if (import.meta.env.DEV) console.log('🚀 Initializing LadybugDB...');
|
||||
|
||||
// 2. Handle Vite/Webpack "default" wrapping
|
||||
lbug = lbugModule.default || lbugModule;
|
||||
// 1. Dynamic Import (Fixes the "not a function" bundler issue)
|
||||
const lbugModule = await import('@ladybugdb/wasm-core');
|
||||
|
||||
// 3. Initialize WASM
|
||||
await lbug.init();
|
||||
// 2. Handle Vite/Webpack "default" wrapping
|
||||
lbug = lbugModule.default || lbugModule;
|
||||
|
||||
// 4. Create Database with 512MB buffer manager
|
||||
const BUFFER_POOL_SIZE = 512 * 1024 * 1024; // 512MB
|
||||
db = new lbug.Database(':memory:', BUFFER_POOL_SIZE);
|
||||
conn = new lbug.Connection(db);
|
||||
// 3. Initialize WASM
|
||||
await lbug.init();
|
||||
|
||||
if (import.meta.env.DEV) console.log('✅ LadybugDB WASM Initialized');
|
||||
// 4. Create Database with 512MB buffer manager
|
||||
const BUFFER_POOL_SIZE = 512 * 1024 * 1024; // 512MB
|
||||
db = new lbug.Database(':memory:', BUFFER_POOL_SIZE);
|
||||
conn = new lbug.Connection(db);
|
||||
|
||||
// 5. Initialize Schema (all node tables, then rel tables, then embedding table)
|
||||
for (let i = 0; i < SCHEMA_QUERIES.length; i++) {
|
||||
try {
|
||||
await conn.query(SCHEMA_QUERIES[i]);
|
||||
} catch (e) {
|
||||
// Schema might already exist, skip
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`Schema query ${i + 1}/${SCHEMA_QUERIES.length} skipped (may already exist):`, e);
|
||||
}
|
||||
if (import.meta.env.DEV) console.log('✅ LadybugDB WASM Initialized');
|
||||
|
||||
// 5. Initialize Schema (all node tables, then rel tables, then embedding table)
|
||||
for (const schemaQuery of SCHEMA_QUERIES) {
|
||||
try {
|
||||
await conn.query(schemaQuery);
|
||||
} catch (e) {
|
||||
// Schema might already exist, skip
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('Schema creation skipped (may already exist):', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) console.log('✅ LadybugDB Multi-Table Schema Created');
|
||||
|
||||
return { db, conn, lbug };
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('❌ LadybugDB Initialization Failed:', error);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
try {
|
||||
return await initPromise;
|
||||
|
||||
if (import.meta.env.DEV) console.log('✅ LadybugDB Multi-Table Schema Created');
|
||||
|
||||
return { db, conn, lbug };
|
||||
} catch (error) {
|
||||
initPromise = null; // Reset on failure so retry is possible
|
||||
if (import.meta.env.DEV) console.error('❌ LadybugDB Initialization Failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -80,47 +72,11 @@ export const initLbug = async () => {
|
||||
* Load a KnowledgeGraph into LadybugDB using COPY FROM (bulk load)
|
||||
* Uses batched CSV writes and COPY statements for optimal performance
|
||||
*/
|
||||
const isTestEnv = () => {
|
||||
// Browser-friendly check: Vite only exposes VITE_* vars at runtime; fall back to a window flag if injected by tests.
|
||||
if (typeof import.meta !== 'undefined' && typeof import.meta.env !== 'undefined') {
|
||||
if (import.meta.env.VITE_PLAYWRIGHT_TEST || import.meta.env.MODE === 'test') return true;
|
||||
}
|
||||
if (typeof window !== 'undefined' && (window as unknown as { __PLAYWRIGHT_TEST__?: boolean }).__PLAYWRIGHT_TEST__) {
|
||||
return true;
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && navigator.webdriver) {
|
||||
return true;
|
||||
}
|
||||
return typeof process !== 'undefined' && (process.env.PLAYWRIGHT_TEST || process.env.NODE_ENV === 'test');
|
||||
};
|
||||
|
||||
export const loadGraphToLbug = async (
|
||||
graph: KnowledgeGraph,
|
||||
fileContents: Map<string, string>
|
||||
) => {
|
||||
// In headless Playwright, skip heavy bulk load to avoid hangs; UI still functions with empty DB.
|
||||
if (isTestEnv()) {
|
||||
if (import.meta.env.DEV) console.log('🧪 Skipping LadybugDB bulk load in test mode');
|
||||
await initLbug(); // ensure module initialized for downstream calls
|
||||
return { success: true, count: 0 };
|
||||
}
|
||||
const { lbug: lbugModule } = await initLbug();
|
||||
|
||||
// Recreate a fresh in-memory DB each load to avoid cleanup/quoting issues with reserved names
|
||||
const BUFFER_POOL_SIZE = 512 * 1024 * 1024; // 512MB (mirror init)
|
||||
db = new lbugModule.Database(':memory:', BUFFER_POOL_SIZE);
|
||||
conn = new lbugModule.Connection(db);
|
||||
|
||||
// Re-run schema creation
|
||||
for (let i = 0; i < SCHEMA_QUERIES.length; i++) {
|
||||
try {
|
||||
await conn.query(SCHEMA_QUERIES[i]);
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`Schema query ${i + 1}/${SCHEMA_QUERIES.length} skipped (may already exist):`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
const { conn, lbug } = await initLbug();
|
||||
|
||||
try {
|
||||
if (import.meta.env.DEV) console.log(`LadybugDB: Generating CSVs for ${graph.nodeCount} nodes...`);
|
||||
@@ -174,86 +130,47 @@ export const loadGraphToLbug = async (
|
||||
let insertedRels = 0;
|
||||
let skippedRels = 0;
|
||||
const skippedRelStats = new Map<string, number>();
|
||||
|
||||
// Group relations by (fromLabel, toLabel) pair for prepared statement reuse
|
||||
const relsByLabelPair = new Map<string, Array<{ fromId: string; toId: string; relType: string; confidence: number; reason: string; step: number }>>();
|
||||
// RFC 4180 regex: handles doubled quotes ("") inside quoted fields
|
||||
const csvRegex = /"((?:[^"]|"")*)","((?:[^"]|"")*)","((?:[^"]|"")*)",([0-9.]+),"((?:[^"]|"")*)",([0-9-]+)/;
|
||||
|
||||
for (const line of relLines) {
|
||||
const match = line.match(csvRegex);
|
||||
if (!match) continue;
|
||||
try {
|
||||
// Format: "from","to","type",confidence,"reason",step
|
||||
const match = line.match(/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)",([0-9-]+)/);
|
||||
if (!match) continue;
|
||||
|
||||
// Unescape RFC 4180 doubled quotes
|
||||
const fromId = match[1].replace(/""/g, '"');
|
||||
const toId = match[2].replace(/""/g, '"');
|
||||
const relType = match[3].replace(/""/g, '"');
|
||||
const reason = match[5].replace(/""/g, '"');
|
||||
const [, fromId, toId, relType, confidenceStr, reason, stepStr] = match;
|
||||
|
||||
const fromLabel = getNodeLabel(fromId);
|
||||
const toLabel = getNodeLabel(toId);
|
||||
const fromLabel = getNodeLabel(fromId);
|
||||
const toLabel = getNodeLabel(toId);
|
||||
|
||||
// Skip relationships where either node's label doesn't have a table in LadybugDB
|
||||
// Querying a non-existent table causes a fatal native crash
|
||||
if (!validTables.has(fromLabel) || !validTables.has(toLabel)) {
|
||||
skippedRels++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${fromLabel}:${toLabel}`;
|
||||
if (!relsByLabelPair.has(key)) relsByLabelPair.set(key, []);
|
||||
relsByLabelPair.get(key)!.push({
|
||||
fromId,
|
||||
toId,
|
||||
relType,
|
||||
confidence: parseFloat(match[4]) || 1.0,
|
||||
reason,
|
||||
step: parseInt(match[6]) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Execute batched prepared statements per label pair
|
||||
const SUB_BATCH_SIZE = 4;
|
||||
for (const [key, rels] of relsByLabelPair) {
|
||||
const [fromLabel, toLabel] = key.split(':');
|
||||
const cypher = `
|
||||
MATCH (a:${escapeLabel(fromLabel)} {id: $fromId}),
|
||||
(b:${escapeLabel(toLabel)} {id: $toId})
|
||||
CREATE (a)-[:${REL_TABLE_NAME} {type: $relType, confidence: $confidence, reason: $reason, step: $step}]->(b)
|
||||
`;
|
||||
|
||||
for (let i = 0; i < rels.length; i += SUB_BATCH_SIZE) {
|
||||
const subBatch = rels.slice(i, i + SUB_BATCH_SIZE);
|
||||
const stmt = await conn.prepare(cypher);
|
||||
if (!stmt.isSuccess()) {
|
||||
const errMsg = await stmt.getErrorMessage();
|
||||
if (import.meta.env.DEV) console.warn(`Prepare failed for ${key}: ${errMsg}`);
|
||||
skippedRels += subBatch.length;
|
||||
await stmt.close();
|
||||
// Skip relationships where either node's label doesn't have a table in LadybugDB
|
||||
// Querying a non-existent table causes a fatal native crash
|
||||
if (!validTables.has(fromLabel) || !validTables.has(toLabel)) {
|
||||
skippedRels++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const r of subBatch) {
|
||||
try {
|
||||
await conn.execute(stmt, r);
|
||||
insertedRels++;
|
||||
} catch (err) {
|
||||
skippedRels++;
|
||||
const statKey = `${r.relType}:${fromLabel}->${toLabel}`;
|
||||
skippedRelStats.set(statKey, (skippedRelStats.get(statKey) || 0) + 1);
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`⚠️ Skipped: ${statKey} | "${r.fromId}" → "${r.toId}" | ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await stmt.close();
|
||||
}
|
||||
const confidence = parseFloat(confidenceStr) || 1.0;
|
||||
const step = parseInt(stepStr) || 0;
|
||||
|
||||
// Yield to event loop between sub-batches
|
||||
if (i + SUB_BATCH_SIZE < rels.length) {
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const insertQuery = `
|
||||
MATCH (a:${escapeLabel(fromLabel)} {id: '${fromId.replace(/'/g, "''")}'}),
|
||||
(b:${escapeLabel(toLabel)} {id: '${toId.replace(/'/g, "''")}'})
|
||||
CREATE (a)-[:${REL_TABLE_NAME} {type: '${relType}', confidence: ${confidence}, reason: '${reason.replace(/'/g, "''")}', step: ${step}}]->(b)
|
||||
`;
|
||||
await conn.query(insertQuery);
|
||||
insertedRels++;
|
||||
} catch (err) {
|
||||
skippedRels++;
|
||||
const match = line.match(/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)"/);
|
||||
if (match) {
|
||||
const [, fromId, toId, relType] = match;
|
||||
const fromLabel = getNodeLabel(fromId);
|
||||
const toLabel = getNodeLabel(toId);
|
||||
const key = `${relType}:${fromLabel}->` + toLabel;
|
||||
skippedRelStats.set(key, (skippedRelStats.get(key) || 0) + 1);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`⚠️ Skipped: ${key} | "${fromId}" → "${toId}" | ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,7 +190,7 @@ export const loadGraphToLbug = async (
|
||||
for (const tableName of NODE_TABLES) {
|
||||
try {
|
||||
const countRes = await conn.query(`MATCH (n:${tableName}) RETURN count(n) AS cnt`);
|
||||
const countRows = await countRes.getAllRows();
|
||||
const countRows = await countRes.getAll();
|
||||
const countRow = countRows[0];
|
||||
const count = countRow ? (countRow.cnt ?? countRow[0] ?? 0) : 0;
|
||||
totalNodes += Number(count);
|
||||
@@ -307,20 +224,12 @@ const BACKTICK_TABLES = new Set([
|
||||
'Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl',
|
||||
'TypeAlias', 'Const', 'Static', 'Property', 'Record', 'Delegate', 'Annotation',
|
||||
'Constructor', 'Template', 'Module',
|
||||
// Reserved/ambiguous identifiers that need quoting
|
||||
'File',
|
||||
]);
|
||||
|
||||
const escapeTableName = (table: string): string => {
|
||||
return BACKTICK_TABLES.has(table) ? `\`${table}\`` : table;
|
||||
};
|
||||
|
||||
// LadybugDB DELETE needs standard quoted identifiers for reserved names (e.g., File)
|
||||
const escapeTableForDelete = (table: string): string => {
|
||||
if (table === 'File') return `"${table}"`;
|
||||
return escapeTableName(table);
|
||||
};
|
||||
|
||||
/** Tables with isExported column (TypeScript/JS-native types) */
|
||||
const TABLES_WITH_EXPORTED = new Set<string>(['Function', 'Class', 'Interface', 'Method', 'CodeElement']);
|
||||
|
||||
@@ -353,20 +262,11 @@ const getCopyQuery = (table: NodeTableName, path: string): string => {
|
||||
* Execute a Cypher query against the database
|
||||
* Returns results as named objects (not tuples) for better usability
|
||||
*/
|
||||
export const executeQuery = async (cypher: string, readOnly = true): Promise<any[]> => {
|
||||
export const executeQuery = async (cypher: string): Promise<any[]> => {
|
||||
if (!conn) {
|
||||
await initLbug();
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
// Strip quoted strings before checking for write keywords, so that
|
||||
// queries like WHERE n.name CONTAINS "delete" are not blocked.
|
||||
const stripped = cypher.replace(/'[^']*'|"[^"]*"/g, '').toUpperCase();
|
||||
if (/\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|DETACH)\b/.test(stripped)) {
|
||||
throw new Error('Read-only query attempted a write operation');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await conn.query(cypher);
|
||||
|
||||
@@ -394,7 +294,7 @@ export const executeQuery = async (cypher: string, readOnly = true): Promise<any
|
||||
}
|
||||
|
||||
// Collect all rows
|
||||
const allRows = await result.getAllRows();
|
||||
const allRows = await result.getAll();
|
||||
const rows: any[] = [];
|
||||
for (const row of allRows) {
|
||||
// Convert tuple to named object if we have column names and row is array
|
||||
@@ -431,7 +331,7 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }>
|
||||
for (const tableName of NODE_TABLES) {
|
||||
try {
|
||||
const nodeResult = await conn.query(`MATCH (n:${tableName}) RETURN count(n) AS cnt`);
|
||||
const nodeRows = await nodeResult.getAllRows();
|
||||
const nodeRows = await nodeResult.getAll();
|
||||
const nodeRow = nodeRows[0];
|
||||
totalNodes += Number(nodeRow?.cnt ?? nodeRow?.[0] ?? 0);
|
||||
} catch {
|
||||
@@ -443,7 +343,7 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }>
|
||||
let totalEdges = 0;
|
||||
try {
|
||||
const edgeResult = await conn.query(`MATCH ()-[r:${REL_TABLE_NAME}]->() RETURN count(r) AS cnt`);
|
||||
const edgeRows = await edgeResult.getAllRows();
|
||||
const edgeRows = await edgeResult.getAll();
|
||||
const edgeRow = edgeRows[0];
|
||||
totalEdges = Number(edgeRow?.cnt ?? edgeRow?.[0] ?? 0);
|
||||
} catch {
|
||||
@@ -483,7 +383,6 @@ export const closeLbug = async (): Promise<void> => {
|
||||
db = null;
|
||||
}
|
||||
lbug = null;
|
||||
initPromise = null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -502,18 +401,17 @@ export const executePrepared = async (
|
||||
|
||||
try {
|
||||
const stmt = await conn.prepare(cypher);
|
||||
try {
|
||||
if (!stmt.isSuccess()) {
|
||||
const errMsg = await stmt.getErrorMessage();
|
||||
throw new Error(`Prepare failed: ${errMsg}`);
|
||||
}
|
||||
|
||||
const result = await conn.execute(stmt, params);
|
||||
const rows = await result.getAllRows();
|
||||
return rows;
|
||||
} finally {
|
||||
await stmt.close();
|
||||
if (!stmt.isSuccess()) {
|
||||
const errMsg = await stmt.getErrorMessage();
|
||||
throw new Error(`Prepare failed: ${errMsg}`);
|
||||
}
|
||||
|
||||
const result = await conn.execute(stmt, params);
|
||||
|
||||
const rows = await result.getAll();
|
||||
|
||||
await stmt.close();
|
||||
return rows;
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('Prepared query failed:', error);
|
||||
throw error;
|
||||
@@ -574,7 +472,7 @@ export const testArrayParams = async (): Promise<{ success: boolean; error?: str
|
||||
for (const tableName of NODE_TABLES) {
|
||||
try {
|
||||
const nodeResult = await conn.query(`MATCH (n:${tableName}) RETURN n.id AS id LIMIT 1`);
|
||||
const nodeRows = await nodeResult.getAllRows();
|
||||
const nodeRows = await nodeResult.getAll();
|
||||
const nodeRow = nodeRows[0];
|
||||
if (nodeRow) {
|
||||
testNodeId = nodeRow.id ?? nodeRow[0];
|
||||
@@ -607,39 +505,24 @@ export const testArrayParams = async (): Promise<{ success: boolean; error?: str
|
||||
|
||||
await stmt.close();
|
||||
|
||||
// Verify it was stored (using prepared statement to avoid injection)
|
||||
const verifyStmt = await conn.prepare(
|
||||
`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) RETURN e.embedding AS emb`
|
||||
// Verify it was stored
|
||||
const verifyResult = await conn.query(
|
||||
`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: '${testNodeId}'}) RETURN e.embedding AS emb`
|
||||
);
|
||||
try {
|
||||
if (!verifyStmt.isSuccess()) {
|
||||
const errMsg = await verifyStmt.getErrorMessage();
|
||||
return { success: false, error: `Verify prepare failed: ${errMsg}` };
|
||||
}
|
||||
const verifyResult = await conn.execute(verifyStmt, { nodeId: testNodeId });
|
||||
const verifyRows = await verifyResult.getAllRows();
|
||||
const verifyRow = verifyRows[0];
|
||||
const storedEmb = verifyRow?.emb ?? verifyRow?.[0];
|
||||
const verifyRows = await verifyResult.getAll();
|
||||
const verifyRow = verifyRows[0];
|
||||
const storedEmb = verifyRow?.emb ?? verifyRow?.[0];
|
||||
|
||||
// Clean up test embedding
|
||||
try {
|
||||
const cleanupStmt = await conn.prepare(`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) DELETE e`);
|
||||
try { await conn.execute(cleanupStmt, { nodeId: testNodeId }); } finally { await cleanupStmt.close(); }
|
||||
} catch {}
|
||||
|
||||
if (storedEmb && Array.isArray(storedEmb) && storedEmb.length === 384) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ Array params WORK! Stored embedding length:', storedEmb.length);
|
||||
}
|
||||
return { success: true };
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: `Embedding not stored correctly. Got: ${typeof storedEmb}, length: ${storedEmb?.length}`
|
||||
};
|
||||
if (storedEmb && Array.isArray(storedEmb) && storedEmb.length === 384) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ Array params WORK! Stored embedding length:', storedEmb.length);
|
||||
}
|
||||
} finally {
|
||||
await verifyStmt.close();
|
||||
return { success: true };
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: `Embedding not stored correctly. Got: ${typeof storedEmb}, length: ${storedEmb?.length}`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getQueryRows } from './query-result';
|
||||
|
||||
describe('getQueryRows', () => {
|
||||
it('prefers getAllObjects when available', async () => {
|
||||
const rows = [{ name: 'foo' }];
|
||||
const getAllObjects = vi.fn().mockResolvedValue(rows);
|
||||
const getAllRows = vi.fn().mockResolvedValue([{ name: 'bar' }]);
|
||||
const getAll = vi.fn().mockResolvedValue([['baz']]);
|
||||
|
||||
await expect(getQueryRows({ getAllObjects, getAllRows, getAll })).resolves.toEqual(rows);
|
||||
|
||||
expect(getAllObjects).toHaveBeenCalledTimes(1);
|
||||
expect(getAllRows).not.toHaveBeenCalled();
|
||||
expect(getAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to getAllRows when getAllObjects is absent', async () => {
|
||||
const rows = [{ name: 'bar' }];
|
||||
const getAllRows = vi.fn().mockResolvedValue(rows);
|
||||
const getAll = vi.fn().mockResolvedValue([['baz']]);
|
||||
|
||||
await expect(getQueryRows({ getAllRows, getAll })).resolves.toEqual(rows);
|
||||
|
||||
expect(getAllRows).toHaveBeenCalledTimes(1);
|
||||
expect(getAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to getAll as a final fallback', async () => {
|
||||
const rows = [['baz']];
|
||||
const getAll = vi.fn().mockResolvedValue(rows);
|
||||
|
||||
await expect(getQueryRows({ getAll })).resolves.toEqual(rows);
|
||||
expect(getAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws when no supported query API is exposed', async () => {
|
||||
await expect(getQueryRows({})).rejects.toThrow('Unsupported LadybugDB QueryResult shape');
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
export const getQueryRows = async (result: unknown): Promise<any[]> => {
|
||||
if (!result || typeof result !== 'object') return [];
|
||||
|
||||
const queryResult = result as {
|
||||
getAllObjects?: () => Promise<any[]>;
|
||||
getAllRows?: () => Promise<any[]>;
|
||||
getAll?: () => Promise<any[]>;
|
||||
};
|
||||
|
||||
if (typeof queryResult.getAllObjects === 'function') {
|
||||
return await queryResult.getAllObjects();
|
||||
}
|
||||
if (typeof queryResult.getAllRows === 'function') {
|
||||
return await queryResult.getAllRows();
|
||||
}
|
||||
if (typeof queryResult.getAll === 'function') {
|
||||
return await queryResult.getAll();
|
||||
}
|
||||
|
||||
throw new Error('Unsupported LadybugDB QueryResult shape');
|
||||
};
|
||||
@@ -15,13 +15,6 @@ import { tool } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
// Note: GRAPH_SCHEMA_DESCRIPTION from './types' is available if needed for additional context
|
||||
import { WebGPUNotAvailableError, embedText, embeddingToArray, initEmbedder, isEmbedderReady } from '../embeddings/embedder';
|
||||
import { NODE_TABLES, REL_TYPES } from '../lbug/schema';
|
||||
|
||||
const validLabel = (label: string): boolean =>
|
||||
(NODE_TABLES as readonly string[]).includes(label);
|
||||
|
||||
const validRelType = (t: string): boolean =>
|
||||
(REL_TYPES as readonly string[]).includes(t);
|
||||
|
||||
/**
|
||||
* Tool factory - creates tools bound to the LadybugDB query functions
|
||||
@@ -103,12 +96,11 @@ export const createGraphRAGTools = (
|
||||
if (nodeId) {
|
||||
try {
|
||||
const nodeLabel = nodeId.split(':')[0];
|
||||
if (!validLabel(nodeLabel)) throw new Error('invalid label');
|
||||
const connectionsQuery = `
|
||||
MATCH (n:${nodeLabel} {id: '${nodeId.replace(/'/g, "''")}'})
|
||||
OPTIONAL MATCH (n)-[r1:CodeRelation]->(dst)
|
||||
OPTIONAL MATCH (src)-[r2:CodeRelation]->(n)
|
||||
RETURN
|
||||
RETURN
|
||||
collect(DISTINCT {name: dst.name, type: r1.type, confidence: r1.confidence}) AS outgoing,
|
||||
collect(DISTINCT {name: src.name, type: r2.type, confidence: r2.confidence}) AS incoming
|
||||
LIMIT 1
|
||||
@@ -144,7 +136,6 @@ export const createGraphRAGTools = (
|
||||
if (nodeId) {
|
||||
try {
|
||||
const nodeLabel = nodeId.split(':')[0];
|
||||
if (!validLabel(nodeLabel)) throw new Error('invalid label');
|
||||
const clusterQuery = `
|
||||
MATCH (n:${nodeLabel} {id: '${nodeId.replace(/'/g, "''")}'})
|
||||
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||||
@@ -167,7 +158,6 @@ export const createGraphRAGTools = (
|
||||
if (nodeId) {
|
||||
try {
|
||||
const nodeLabel = nodeId.split(':')[0];
|
||||
if (!validLabel(nodeLabel)) throw new Error('invalid label');
|
||||
const processQuery = `
|
||||
MATCH (n:${nodeLabel} {id: '${nodeId.replace(/'/g, "''")}'})
|
||||
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||||
@@ -793,11 +783,7 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
||||
const name = getRowValue(symbolRow, 1, 'name');
|
||||
const filePath = getRowValue(symbolRow, 2, 'filePath');
|
||||
const nodeType = getRowValue(symbolRow, 3, 'nodeType');
|
||||
|
||||
if (!validLabel(nodeType)) {
|
||||
return `Unknown node type "${nodeType}" for symbol "${target}".`;
|
||||
}
|
||||
|
||||
|
||||
const clusterQuery = `
|
||||
MATCH (n:${nodeType} {id: '${String(nodeId).replace(/'/g, "''")}'})
|
||||
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||||
@@ -912,13 +898,10 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
||||
|
||||
// Default to usage-based relation types (exclude CONTAINS, DEFINES for impact analysis)
|
||||
const defaultRelTypes = ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
|
||||
const activeRelTypes = relationTypes && relationTypes.length > 0
|
||||
? relationTypes.filter(t => validRelType(t))
|
||||
const activeRelTypes = relationTypes && relationTypes.length > 0
|
||||
? relationTypes
|
||||
: defaultRelTypes;
|
||||
if (activeRelTypes.length === 0) {
|
||||
return `No valid relation types provided. Valid types: ${(REL_TYPES as readonly string[]).join(', ')}`;
|
||||
}
|
||||
const relTypeFilter = activeRelTypes.map(t => `'${t.replace(/'/g, "''")}'`).join(', ');
|
||||
const relTypeFilter = activeRelTypes.map(t => `'${t}'`).join(', ');
|
||||
|
||||
const directionLabel = direction === 'upstream'
|
||||
? 'Files that DEPEND ON this (breakage risk)'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react';
|
||||
import * as Comlink from 'comlink';
|
||||
import { KnowledgeGraph, GraphNode, GraphRelationship, NodeLabel } from '../core/graph/types';
|
||||
import { KnowledgeGraph, GraphNode, NodeLabel } from '../core/graph/types';
|
||||
import { PipelineProgress, PipelineResult, deserializePipelineResult } from '../types/pipeline';
|
||||
import { createKnowledgeGraph } from '../core/graph/graph';
|
||||
import { DEFAULT_VISIBLE_LABELS } from '../lib/constants';
|
||||
@@ -95,7 +95,6 @@ interface AppState {
|
||||
isAIHighlightsEnabled: boolean;
|
||||
toggleAIHighlights: () => void;
|
||||
clearAIToolHighlights: () => void;
|
||||
clearAICitationHighlights: () => void;
|
||||
clearBlastRadius: () => void;
|
||||
queryResult: QueryResult | null;
|
||||
setQueryResult: (result: QueryResult | null) => void;
|
||||
@@ -126,7 +125,6 @@ interface AppState {
|
||||
runPipelineFromFiles: (files: FileEntry[], onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise<PipelineResult>;
|
||||
runQuery: (cypher: string) => Promise<any[]>;
|
||||
isDatabaseReady: () => Promise<boolean>;
|
||||
loadServerGraph: (nodes: GraphNode[], relationships: GraphRelationship[], fileContents: Record<string, string>) => Promise<void>;
|
||||
|
||||
// Embedding state
|
||||
embeddingStatus: EmbeddingStatus;
|
||||
@@ -145,9 +143,7 @@ interface AppState {
|
||||
llmSettings: LLMSettings;
|
||||
updateLLMSettings: (updates: Partial<LLMSettings>) => void;
|
||||
isSettingsPanelOpen: boolean;
|
||||
isHelpDialogBoxOpen: boolean;
|
||||
setSettingsPanelOpen: (open: boolean) => void;
|
||||
setHelpDialogBoxOpen: (open: boolean) => void;
|
||||
isAgentReady: boolean;
|
||||
isAgentInitializing: boolean;
|
||||
agentError: string | null;
|
||||
@@ -229,10 +225,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
setAIToolHighlightedNodeIds(new Set());
|
||||
}, []);
|
||||
|
||||
const clearAICitationHighlights = useCallback(() => {
|
||||
setAICitationHighlightedNodeIds(new Set());
|
||||
}, []);
|
||||
|
||||
const clearBlastRadius = useCallback(() => {
|
||||
setBlastRadiusNodeIds(new Set());
|
||||
}, []);
|
||||
@@ -298,7 +290,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
// LLM/Agent state
|
||||
const [llmSettings, setLLMSettings] = useState<LLMSettings>(loadSettings);
|
||||
const [isSettingsPanelOpen, setSettingsPanelOpen] = useState(false);
|
||||
const [isHelpDialogBoxOpen, setHelpDialogBoxOpen] = useState(false);
|
||||
const [isAgentReady, setIsAgentReady] = useState(false);
|
||||
const [isAgentInitializing, setIsAgentInitializing] = useState(false);
|
||||
const [agentError, setAgentError] = useState<string | null>(null);
|
||||
@@ -491,16 +482,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadServerGraph = useCallback(async (
|
||||
nodes: GraphNode[],
|
||||
relationships: GraphRelationship[],
|
||||
fileContents: Record<string, string>
|
||||
): Promise<void> => {
|
||||
const api = apiRef.current;
|
||||
if (!api) throw new Error('Worker not initialized');
|
||||
await api.loadServerGraph(nodes, relationships, fileContents);
|
||||
}, []);
|
||||
|
||||
// Embedding methods
|
||||
const startEmbeddings = useCallback(async (forceDevice?: 'webgpu' | 'wasm'): Promise<void> => {
|
||||
const api = apiRef.current;
|
||||
@@ -998,13 +979,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
setProgress({ phase: 'extracting', percent: 0, message: 'Switching repository...', detail: `Loading ${repoName}` });
|
||||
setViewMode('loading');
|
||||
|
||||
setIsAgentReady(false);
|
||||
|
||||
// Clear stale graph state from previous repo (highlights, selections, blast radius)
|
||||
// Without this, sigma reducers dim ALL nodes/edges because old node IDs don't match
|
||||
setHighlightedNodeIds(new Set());
|
||||
clearAIToolHighlights();
|
||||
clearAICitationHighlights();
|
||||
clearBlastRadius();
|
||||
setSelectedNode(null);
|
||||
setQueryResult(null);
|
||||
@@ -1039,29 +1017,17 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
for (const [p, c] of Object.entries(result.fileContents)) fileMap.set(p, c);
|
||||
setFileContents(fileMap);
|
||||
|
||||
// Load graph into LadybugDB for Nexus AI queries, then init agent
|
||||
try {
|
||||
await loadServerGraph(result.nodes, result.relationships, result.fileContents);
|
||||
if (getActiveProviderConfig()) {
|
||||
await initializeAgent(pName);
|
||||
setViewMode('exploring');
|
||||
|
||||
if (getActiveProviderConfig()) initializeAgent(pName);
|
||||
|
||||
startEmbeddings().catch((err) => {
|
||||
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
|
||||
startEmbeddings('wasm').catch(console.warn);
|
||||
} else {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
}
|
||||
setViewMode('exploring');
|
||||
startEmbeddings().catch((err) => {
|
||||
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
|
||||
startEmbeddings('wasm').catch(console.warn);
|
||||
} else {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
}
|
||||
});
|
||||
setProgress(null);
|
||||
} catch (err) {
|
||||
console.warn('Failed to load graph into LadybugDB:', err);
|
||||
setIsAgentReady(false);
|
||||
await apiRef.current?.disposeAgent();
|
||||
setAgentError('Failed to load graph into LadybugDB');
|
||||
setViewMode('exploring');
|
||||
setProgress(null);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Repo switch failed:', err);
|
||||
setProgress({
|
||||
@@ -1069,11 +1035,9 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
message: 'Failed to switch repository',
|
||||
detail: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
setIsAgentReady(false);
|
||||
await apiRef.current?.disposeAgent();
|
||||
setTimeout(() => { setViewMode('exploring'); setProgress(null); }, 3000);
|
||||
}
|
||||
}, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, loadServerGraph, initializeAgent, startEmbeddings, setHighlightedNodeIds, clearAIToolHighlights, clearAICitationHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]);
|
||||
}, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, initializeAgent, startEmbeddings, setHighlightedNodeIds, clearAIToolHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]);
|
||||
|
||||
const removeCodeReference = useCallback((id: string) => {
|
||||
setCodeReferences(prev => {
|
||||
@@ -1156,7 +1120,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
isAIHighlightsEnabled,
|
||||
toggleAIHighlights,
|
||||
clearAIToolHighlights,
|
||||
clearAICitationHighlights,
|
||||
clearBlastRadius,
|
||||
queryResult,
|
||||
setQueryResult,
|
||||
@@ -1179,7 +1142,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
runPipelineFromFiles,
|
||||
runQuery,
|
||||
isDatabaseReady,
|
||||
loadServerGraph,
|
||||
// Embedding state and methods
|
||||
embeddingStatus,
|
||||
embeddingProgress,
|
||||
@@ -1194,8 +1156,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
updateLLMSettings,
|
||||
isSettingsPanelOpen,
|
||||
setSettingsPanelOpen,
|
||||
isHelpDialogBoxOpen,
|
||||
setHelpDialogBoxOpen,
|
||||
isAgentReady,
|
||||
isAgentInitializing,
|
||||
agentError,
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface ProcessData {
|
||||
steps: ProcessStep[];
|
||||
edges?: ProcessEdge[]; // CALLS edges between steps for branching
|
||||
clusters?: string[];
|
||||
rawMermaid?: string; // AI-generated mermaid code (sanitized before rendering)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-3
@@ -12,9 +12,7 @@ declare module '@ladybugdb/wasm-core' {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
export interface QueryResult {
|
||||
getAll?(): Promise<any[]>;
|
||||
getAllRows?(): Promise<any[]>;
|
||||
getAllObjects?(): Promise<any[]>;
|
||||
getAll(): Promise<any[]>;
|
||||
hasNext(): Promise<boolean>;
|
||||
getNext(): Promise<any>;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ import { isEmbedderReady, disposeEmbedder } from '../core/embeddings/embedder';
|
||||
import type { EmbeddingProgress, SemanticSearchResult } from '../core/embeddings/types';
|
||||
import type { ProviderConfig, AgentStreamChunk } from '../core/llm/types';
|
||||
import { createGraphRAGAgent, streamAgentResponse, type AgentMessage, createChatModel } from '../core/llm/agent';
|
||||
import { createKnowledgeGraph } from '../core/graph/graph';
|
||||
import type { GraphNode, GraphRelationship } from '../core/graph/types';
|
||||
import { SystemMessage } from '@langchain/core/messages';
|
||||
import { enrichClustersBatch, ClusterMemberInfo, ClusterEnrichment } from '../core/ingestion/cluster-enricher';
|
||||
import { CommunityNode } from '../core/ingestion/community-processor';
|
||||
@@ -41,61 +39,6 @@ const getLbugAdapter = async () => {
|
||||
let embeddingProgress: EmbeddingProgress | null = null;
|
||||
let isEmbeddingComplete = false;
|
||||
|
||||
/**
|
||||
* Shared post-pipeline logic: store results, build BM25 index, load LadybugDB,
|
||||
* and queue enrichment config. Used by both runPipeline and runPipelineFromFiles.
|
||||
*/
|
||||
const finalizePipeline = async (
|
||||
result: PipelineResult,
|
||||
onProgress: (progress: PipelineProgress) => void,
|
||||
clusteringConfig?: ProviderConfig
|
||||
): Promise<SerializablePipelineResult> => {
|
||||
currentGraphResult = result;
|
||||
|
||||
// Store file contents for grep/read tools (full content, not truncated)
|
||||
storedFileContents = result.fileContents;
|
||||
|
||||
// Build BM25 index for keyword search (instant, ~100ms)
|
||||
const bm25DocCount = buildBM25Index(storedFileContents);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`🔍 BM25 index built: ${bm25DocCount} documents`);
|
||||
}
|
||||
|
||||
// Load graph into LadybugDB for querying (optional - gracefully degrades)
|
||||
try {
|
||||
onProgress({
|
||||
phase: 'complete',
|
||||
percent: 98,
|
||||
message: 'Loading into LadybugDB...',
|
||||
stats: {
|
||||
filesProcessed: result.graph.nodeCount,
|
||||
totalFiles: result.graph.nodeCount,
|
||||
nodesCreated: result.graph.nodeCount,
|
||||
},
|
||||
});
|
||||
|
||||
const lbug = await getLbugAdapter();
|
||||
await lbug.loadGraphToLbug(result.graph, result.fileContents);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
const stats = await lbug.getLbugStats();
|
||||
console.log('LadybugDB loaded:', stats);
|
||||
console.log('📁 Stored', storedFileContents.size, 'files for grep/read tools');
|
||||
}
|
||||
} catch {
|
||||
// LadybugDB is optional - silently continue without it
|
||||
}
|
||||
|
||||
// Store clustering config for background enrichment (runs after graph loads)
|
||||
if (clusteringConfig) {
|
||||
pendingEnrichmentConfig = clusteringConfig;
|
||||
console.log('📋 Clustering config saved for background enrichment');
|
||||
}
|
||||
|
||||
// Convert to serializable format for transfer back to main thread
|
||||
return serializePipelineResult(result);
|
||||
};
|
||||
|
||||
// File contents state - stores full file contents for grep/read tools
|
||||
let storedFileContents: Map<string, string> = new Map();
|
||||
|
||||
@@ -176,7 +119,7 @@ const createHttpHybridSearch = (backendUrl: string, repo: string) => {
|
||||
endLine: s.endLine,
|
||||
content: s.content ?? '',
|
||||
sources: ['bm25', 'semantic'],
|
||||
score: Math.max(0, 1 - (i * 0.02)),
|
||||
score: 1 - (i * 0.02),
|
||||
}));
|
||||
|
||||
const defs: any[] = (data.definitions ?? []).map((d: any, i: number) => ({
|
||||
@@ -186,7 +129,7 @@ const createHttpHybridSearch = (backendUrl: string, repo: string) => {
|
||||
filePath: d.filePath,
|
||||
content: '',
|
||||
sources: ['bm25'],
|
||||
score: Math.max(0, 0.5 - (i * 0.02)),
|
||||
score: 0.5 - (i * 0.02),
|
||||
}));
|
||||
|
||||
return [...symbols, ...defs].slice(0, k);
|
||||
@@ -214,57 +157,54 @@ const workerApi = {
|
||||
onProgress: (progress: PipelineProgress) => void,
|
||||
clusteringConfig?: ProviderConfig
|
||||
): Promise<SerializablePipelineResult> {
|
||||
// Debug logging
|
||||
console.log('🔧 runPipeline called with clusteringConfig:', !!clusteringConfig);
|
||||
// Run the actual pipeline
|
||||
const result = await runIngestionPipeline(file, onProgress);
|
||||
return finalizePipeline(result, onProgress, clusteringConfig);
|
||||
},
|
||||
|
||||
/**
|
||||
* Load a pre-built graph from the server into LadybugDB.
|
||||
* Called when connecting via server (bypasses the WASM ingestion pipeline).
|
||||
*/
|
||||
async loadServerGraph(
|
||||
nodes: GraphNode[],
|
||||
relationships: GraphRelationship[],
|
||||
fileContents: Record<string, string>
|
||||
): Promise<void> {
|
||||
const graph = createKnowledgeGraph();
|
||||
for (const node of nodes) graph.addNode(node);
|
||||
for (const rel of relationships) graph.addRelationship(rel);
|
||||
|
||||
const fileMap = new Map<string, string>();
|
||||
for (const [path, content] of Object.entries(fileContents)) {
|
||||
fileMap.set(path, content);
|
||||
currentGraphResult = result;
|
||||
|
||||
// Store file contents for grep/read tools (full content, not truncated)
|
||||
storedFileContents = result.fileContents;
|
||||
|
||||
// Build BM25 index for keyword search (instant, ~100ms)
|
||||
const bm25DocCount = buildBM25Index(storedFileContents);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`🔍 BM25 index built: ${bm25DocCount} documents`);
|
||||
}
|
||||
|
||||
// Replace (not accumulate) stored file contents for grep/read tools
|
||||
storedFileContents = fileMap;
|
||||
|
||||
// Track graph result for downstream APIs (enrichCommunities, etc.)
|
||||
currentGraphResult = { graph, fileContents: fileMap };
|
||||
isEmbeddingComplete = false;
|
||||
embeddingProgress = null;
|
||||
|
||||
// Load graph into LadybugDB and build BM25 index (optional - gracefully degrades)
|
||||
|
||||
// Load graph into LadybugDB for querying (optional - gracefully degrades)
|
||||
try {
|
||||
const lbug = await getLbugAdapter();
|
||||
await lbug.loadGraphToLbug(graph, fileMap);
|
||||
onProgress({
|
||||
phase: 'complete',
|
||||
percent: 98,
|
||||
message: 'Loading into LadybugDB...',
|
||||
stats: {
|
||||
filesProcessed: result.graph.nodeCount,
|
||||
totalFiles: result.graph.nodeCount,
|
||||
nodesCreated: result.graph.nodeCount,
|
||||
},
|
||||
});
|
||||
|
||||
// Build BM25 index for text search
|
||||
buildBM25Index(fileMap);
|
||||
const lbug = await getLbugAdapter();
|
||||
await lbug.loadGraphToLbug(result.graph, result.fileContents);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
const stats = await lbug.getLbugStats();
|
||||
console.log('LadybugDB loaded from server:', stats);
|
||||
console.log('LadybugDB loaded:', stats);
|
||||
console.log('📁 Stored', storedFileContents.size, 'files for grep/read tools');
|
||||
}
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('LadybugDB load failed (non-fatal, continuing without it):', err);
|
||||
}
|
||||
// Still build BM25 index even if LadybugDB fails
|
||||
buildBM25Index(fileMap);
|
||||
} catch {
|
||||
// LadybugDB is optional - silently continue without it
|
||||
}
|
||||
|
||||
// Store clustering config for background enrichment (runs after graph loads)
|
||||
if (clusteringConfig) {
|
||||
pendingEnrichmentConfig = clusteringConfig;
|
||||
console.log('📋 Clustering config saved for background enrichment');
|
||||
}
|
||||
|
||||
// Convert to serializable format for transfer back to main thread
|
||||
return serializePipelineResult(result);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -323,8 +263,52 @@ const workerApi = {
|
||||
stats: { filesProcessed: 0, totalFiles: files.length, nodesCreated: 0 },
|
||||
});
|
||||
|
||||
// Run the pipeline
|
||||
const result = await runPipelineFromFiles(files, onProgress);
|
||||
return finalizePipeline(result, onProgress, clusteringConfig);
|
||||
currentGraphResult = result;
|
||||
|
||||
// Store file contents for grep/read tools (full content, not truncated)
|
||||
storedFileContents = result.fileContents;
|
||||
|
||||
// Build BM25 index for keyword search (instant, ~100ms)
|
||||
const bm25DocCount = buildBM25Index(storedFileContents);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`🔍 BM25 index built: ${bm25DocCount} documents`);
|
||||
}
|
||||
|
||||
// Load graph into LadybugDB for querying (optional - gracefully degrades)
|
||||
try {
|
||||
onProgress({
|
||||
phase: 'complete',
|
||||
percent: 98,
|
||||
message: 'Loading into LadybugDB...',
|
||||
stats: {
|
||||
filesProcessed: result.graph.nodeCount,
|
||||
totalFiles: result.graph.nodeCount,
|
||||
nodesCreated: result.graph.nodeCount,
|
||||
},
|
||||
});
|
||||
|
||||
const lbug = await getLbugAdapter();
|
||||
await lbug.loadGraphToLbug(result.graph, result.fileContents);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
const stats = await lbug.getLbugStats();
|
||||
console.log('LadybugDB loaded:', stats);
|
||||
console.log('📁 Stored', storedFileContents.size, 'files for grep/read tools');
|
||||
}
|
||||
} catch {
|
||||
// LadybugDB is optional - silently continue without it
|
||||
}
|
||||
|
||||
// Store clustering config for background enrichment (runs after graph loads)
|
||||
if (clusteringConfig) {
|
||||
pendingEnrichmentConfig = clusteringConfig;
|
||||
console.log('📋 Clustering config saved for background enrichment');
|
||||
}
|
||||
|
||||
// Convert to serializable format for transfer back to main thread
|
||||
return serializePipelineResult(result);
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
@@ -644,12 +628,6 @@ const workerApi = {
|
||||
/**
|
||||
* Initialize the Graph RAG agent in backend mode (HTTP-backed tools).
|
||||
* Uses HTTP wrappers instead of local LadybugDB for all tool queries.
|
||||
*
|
||||
* NOTE: Currently not called by any UI flow. The server-connect path
|
||||
* downloads the full graph and uses local WASM queries via initializeAgent.
|
||||
* This method is retained for future large-repo mode where downloading
|
||||
* the entire graph to the browser would be impractical.
|
||||
*
|
||||
* @param config - Provider configuration for the LLM
|
||||
* @param backendUrl - Base URL of the gitnexus serve backend
|
||||
* @param repoName - Repository name on the backend
|
||||
@@ -791,10 +769,8 @@ const workerApi = {
|
||||
throw new Error('No graph loaded. Please ingest a repository first.');
|
||||
}
|
||||
|
||||
enrichmentCancelled = false;
|
||||
|
||||
const { graph } = currentGraphResult;
|
||||
|
||||
|
||||
// Filter for community nodes
|
||||
const communityNodes = graph.nodes
|
||||
.filter(n => n.label === 'Community')
|
||||
@@ -816,22 +792,15 @@ const workerApi = {
|
||||
// Initialize map
|
||||
communityNodes.forEach(c => memberMap.set(c.id, []));
|
||||
|
||||
// Build a Map for O(1) node lookups instead of O(N) find per relationship
|
||||
const nodeById = new Map(graph.nodes.map(n => [n.id, n]));
|
||||
|
||||
// Find all MEMBER_OF edges
|
||||
for (const rel of graph.relationships) {
|
||||
if (enrichmentCancelled) {
|
||||
console.log('Enrichment cancelled, stopping');
|
||||
break;
|
||||
}
|
||||
graph.relationships.forEach(rel => {
|
||||
if (rel.type === 'MEMBER_OF') {
|
||||
const communityId = rel.targetId;
|
||||
const memberId = rel.sourceId; // MEMBER_OF goes Member -> Community
|
||||
|
||||
|
||||
if (memberMap.has(communityId)) {
|
||||
// Find member node details
|
||||
const memberNode = nodeById.get(memberId);
|
||||
const memberNode = graph.nodes.find(n => n.id === memberId);
|
||||
if (memberNode) {
|
||||
memberMap.get(communityId)?.push({
|
||||
name: memberNode.properties.name,
|
||||
@@ -841,7 +810,7 @@ const workerApi = {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create LLM client adapter for LangChain model
|
||||
const chatModel = createChatModel(providerConfig);
|
||||
@@ -879,28 +848,32 @@ const workerApi = {
|
||||
}
|
||||
});
|
||||
|
||||
// Update LadybugDB with new data using prepared statements
|
||||
// Update LadybugDB with new data
|
||||
try {
|
||||
const lbug = await getLbugAdapter();
|
||||
|
||||
|
||||
onProgress(enrichments.size, enrichments.size); // Done
|
||||
|
||||
const paramsList = Array.from(enrichments.entries()).map(([id, enrichment]) => ({
|
||||
id,
|
||||
label: enrichment.name,
|
||||
keywords: enrichment.keywords,
|
||||
description: enrichment.description,
|
||||
}));
|
||||
|
||||
const updateQuery = `
|
||||
MATCH (c:Community {id: $id})
|
||||
SET c.label = $label,
|
||||
c.keywords = $keywords,
|
||||
c.description = $description,
|
||||
c.enrichedBy = "llm"
|
||||
`;
|
||||
|
||||
await lbug.executeWithReusedStatement(updateQuery, paramsList);
|
||||
|
||||
// Update one by one via Cypher (simplest for now)
|
||||
for (const [id, enrichment] of enrichments.entries()) {
|
||||
// Escape strings for Cypher - replace backslash first, then quotes
|
||||
const escapeCypher = (str: string) => str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
|
||||
const keywordsStr = JSON.stringify(enrichment.keywords);
|
||||
const descStr = escapeCypher(enrichment.description);
|
||||
const nameStr = escapeCypher(enrichment.name);
|
||||
const escapedId = escapeCypher(id);
|
||||
|
||||
const query = `
|
||||
MATCH (c:Community {id: "${escapedId}"})
|
||||
SET c.label = "${nameStr}",
|
||||
c.keywords = ${keywordsStr},
|
||||
c.description = "${descStr}",
|
||||
c.enrichedBy = "llm"
|
||||
`;
|
||||
|
||||
await lbug.executeQuery(query);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to update LadybugDB with enrichment:', err);
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SerializablePipelineResult } from '../types/pipeline';
|
||||
import { buildPipelineResultFromSerialized, hydrateSerializedServerGraph } from './server-graph-hydration';
|
||||
|
||||
describe('server graph hydration helpers', () => {
|
||||
const serialized: SerializablePipelineResult = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'file:src/foo.ts',
|
||||
label: 'File' as const,
|
||||
properties: {
|
||||
name: 'foo.ts',
|
||||
filePath: 'src/foo.ts',
|
||||
content: 'export function foo() {}',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'func:src/foo.ts:foo',
|
||||
label: 'Function' as const,
|
||||
properties: {
|
||||
name: 'foo',
|
||||
filePath: 'src/foo.ts',
|
||||
},
|
||||
},
|
||||
],
|
||||
relationships: [
|
||||
{
|
||||
source: 'file:src/foo.ts',
|
||||
target: 'func:src/foo.ts:foo',
|
||||
type: 'CONTAINS' as const,
|
||||
properties: { type: 'CONTAINS' },
|
||||
},
|
||||
],
|
||||
fileContents: {
|
||||
'src/foo.ts': 'export function foo() {}',
|
||||
},
|
||||
};
|
||||
|
||||
it('rebuilds a graph and file map from serialized server payloads', () => {
|
||||
const result = buildPipelineResultFromSerialized(serialized);
|
||||
|
||||
expect(result.graph.nodeCount).toBe(2);
|
||||
expect(result.graph.relationshipCount).toBe(1);
|
||||
expect(result.fileContents.get('src/foo.ts')).toBe('export function foo() {}');
|
||||
});
|
||||
|
||||
it('delegates the rebuilt result to the worker-side loader', async () => {
|
||||
const loadResult = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await hydrateSerializedServerGraph(serialized, loadResult);
|
||||
|
||||
expect(loadResult).toHaveBeenCalledTimes(1);
|
||||
expect(loadResult).toHaveBeenCalledWith(result);
|
||||
expect(result.graph.nodeCount).toBe(2);
|
||||
expect(result.fileContents.size).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { createKnowledgeGraph } from '../core/graph/graph';
|
||||
import type { PipelineResult, SerializablePipelineResult } from '../types/pipeline';
|
||||
|
||||
export const buildPipelineResultFromSerialized = (
|
||||
serialized: SerializablePipelineResult,
|
||||
): PipelineResult => {
|
||||
const graph = createKnowledgeGraph();
|
||||
serialized.nodes.forEach((node) => graph.addNode(node));
|
||||
serialized.relationships.forEach((relationship) => graph.addRelationship(relationship));
|
||||
|
||||
return {
|
||||
graph,
|
||||
fileContents: new Map(Object.entries(serialized.fileContents)),
|
||||
};
|
||||
};
|
||||
|
||||
export const hydrateSerializedServerGraph = async (
|
||||
serialized: SerializablePipelineResult,
|
||||
loadResult: (result: PipelineResult) => Promise<void>,
|
||||
): Promise<PipelineResult> => {
|
||||
const result = buildPipelineResultFromSerialized(serialized);
|
||||
await loadResult(result);
|
||||
return result;
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { beforeEach } from 'vitest';
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -1,259 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph';
|
||||
|
||||
// ==========================================================================
|
||||
// PR1 Bug Fix Tests — positive and negative cases
|
||||
// Tests the data structures and logic underlying the 4 bug fixes without
|
||||
// requiring WASM (LadybugDB is skipped in test env via isTestEnv()).
|
||||
// ==========================================================================
|
||||
|
||||
describe('createKnowledgeGraph — data integrity for loadServerGraph', () => {
|
||||
// Positive: nodes and relationships are stored correctly
|
||||
it('stores nodes added via addNode', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
graph.addNode({
|
||||
id: 'Function:src/index.ts:main',
|
||||
label: 'Function',
|
||||
properties: { name: 'main', filePath: 'src/index.ts', startLine: 1, endLine: 10 },
|
||||
});
|
||||
|
||||
expect(graph.nodes).toHaveLength(1);
|
||||
expect(graph.nodes[0].id).toBe('Function:src/index.ts:main');
|
||||
expect(graph.nodes[0].label).toBe('Function');
|
||||
expect(graph.nodes[0].properties.name).toBe('main');
|
||||
});
|
||||
|
||||
it('stores relationships added via addRelationship', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
graph.addNode({
|
||||
id: 'Function:a.ts:foo',
|
||||
label: 'Function',
|
||||
properties: { name: 'foo', filePath: 'a.ts', startLine: 1, endLine: 5 },
|
||||
});
|
||||
graph.addNode({
|
||||
id: 'Function:a.ts:bar',
|
||||
label: 'Function',
|
||||
properties: { name: 'bar', filePath: 'a.ts', startLine: 10, endLine: 15 },
|
||||
});
|
||||
graph.addRelationship({
|
||||
sourceId: 'Function:a.ts:foo',
|
||||
targetId: 'Function:a.ts:bar',
|
||||
type: 'CALLS',
|
||||
properties: {},
|
||||
});
|
||||
|
||||
expect(graph.relationships).toHaveLength(1);
|
||||
expect(graph.relationships[0].type).toBe('CALLS');
|
||||
expect(graph.relationships[0].sourceId).toBe('Function:a.ts:foo');
|
||||
expect(graph.relationships[0].targetId).toBe('Function:a.ts:bar');
|
||||
});
|
||||
|
||||
// Positive: deduplication works
|
||||
it('deduplicates nodes with the same id', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
graph.addNode({
|
||||
id: 'File:src/index.ts',
|
||||
label: 'File',
|
||||
properties: { name: 'index.ts', filePath: 'src/index.ts' },
|
||||
});
|
||||
graph.addNode({
|
||||
id: 'File:src/index.ts',
|
||||
label: 'File',
|
||||
properties: { name: 'index.ts', filePath: 'src/index.ts' },
|
||||
});
|
||||
|
||||
expect(graph.nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Positive: nodeCount reflects actual count
|
||||
it('nodeCount matches number of unique nodes', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
graph.addNode({
|
||||
id: 'File:a.ts',
|
||||
label: 'File',
|
||||
properties: { name: 'a.ts', filePath: 'a.ts' },
|
||||
});
|
||||
graph.addNode({
|
||||
id: 'File:b.ts',
|
||||
label: 'File',
|
||||
properties: { name: 'b.ts', filePath: 'b.ts' },
|
||||
});
|
||||
|
||||
expect(graph.nodeCount).toBe(2);
|
||||
});
|
||||
|
||||
// Negative: empty graph has zero counts
|
||||
it('empty graph has zero nodes and relationships', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
expect(graph.nodes).toHaveLength(0);
|
||||
expect(graph.relationships).toHaveLength(0);
|
||||
expect(graph.nodeCount).toBe(0);
|
||||
});
|
||||
|
||||
// Negative: relationships with missing source/target still stored
|
||||
// (validation is upstream, graph is a dumb container)
|
||||
it('stores relationships even with non-existent node IDs', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
graph.addRelationship({
|
||||
sourceId: 'NonExistent:a',
|
||||
targetId: 'NonExistent:b',
|
||||
type: 'CALLS',
|
||||
properties: {},
|
||||
});
|
||||
|
||||
expect(graph.relationships).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadServerGraph — data flow validation', () => {
|
||||
// Positive: server data can be reconstructed into a KnowledgeGraph
|
||||
it('reconstructs graph from server node/relationship arrays', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const serverNodes = [
|
||||
{ id: 'File:src/app.ts', label: 'File' as const, properties: { name: 'app.ts', filePath: 'src/app.ts' } },
|
||||
{ id: 'Function:src/app.ts:main', label: 'Function' as const, properties: { name: 'main', filePath: 'src/app.ts', startLine: 1, endLine: 20 } },
|
||||
];
|
||||
const serverRels = [
|
||||
{ sourceId: 'File:src/app.ts', targetId: 'Function:src/app.ts:main', type: 'CONTAINS' as const, properties: {} },
|
||||
];
|
||||
|
||||
for (const node of serverNodes) graph.addNode(node);
|
||||
for (const rel of serverRels) graph.addRelationship(rel);
|
||||
|
||||
expect(graph.nodeCount).toBe(2);
|
||||
expect(graph.relationships).toHaveLength(1);
|
||||
expect(graph.relationships[0].type).toBe('CONTAINS');
|
||||
});
|
||||
|
||||
// Positive: file contents map is built correctly from server data
|
||||
it('builds fileContents Map from server object entries', () => {
|
||||
const serverFileContents: Record<string, string> = {
|
||||
'src/index.ts': 'export function main() {}',
|
||||
'src/utils.ts': 'export const helper = () => {}',
|
||||
};
|
||||
|
||||
const fileMap = new Map<string, string>();
|
||||
for (const [path, content] of Object.entries(serverFileContents)) {
|
||||
fileMap.set(path, content);
|
||||
}
|
||||
|
||||
expect(fileMap.size).toBe(2);
|
||||
expect(fileMap.get('src/index.ts')).toBe('export function main() {}');
|
||||
expect(fileMap.get('src/utils.ts')).toContain('helper');
|
||||
});
|
||||
|
||||
// Negative: empty server response produces empty graph
|
||||
it('handles empty server data gracefully', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const serverNodes: any[] = [];
|
||||
const serverRels: any[] = [];
|
||||
|
||||
for (const node of serverNodes) graph.addNode(node);
|
||||
for (const rel of serverRels) graph.addRelationship(rel);
|
||||
|
||||
expect(graph.nodeCount).toBe(0);
|
||||
expect(graph.relationships).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Negative: fileContents replaces (not accumulates) on reload
|
||||
it('fileContents map replacement prevents stale data', () => {
|
||||
// Simulates the storedFileContents = fileMap assignment in loadServerGraph
|
||||
let storedFileContents = new Map<string, string>();
|
||||
storedFileContents.set('old-file.ts', 'old content');
|
||||
|
||||
// Second load replaces the map entirely
|
||||
const newFileMap = new Map<string, string>();
|
||||
newFileMap.set('new-file.ts', 'new content');
|
||||
storedFileContents = newFileMap; // assignment, not merge
|
||||
|
||||
expect(storedFileContents.size).toBe(1);
|
||||
expect(storedFileContents.has('old-file.ts')).toBe(false);
|
||||
expect(storedFileContents.get('new-file.ts')).toBe('new content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BM25 index — argument type validation', () => {
|
||||
// Positive: Map<string, string> has the expected interface for BM25
|
||||
it('Map has entries() and size for BM25 indexing', () => {
|
||||
const fileMap = new Map<string, string>([
|
||||
['src/a.ts', 'function foo() {}'],
|
||||
['src/b.ts', 'function bar() {}'],
|
||||
]);
|
||||
|
||||
expect(fileMap.size).toBe(2);
|
||||
expect(typeof fileMap.entries).toBe('function');
|
||||
|
||||
// Verify iteration works (BM25 iterates entries)
|
||||
const entries = Array.from(fileMap.entries());
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0][0]).toBe('src/a.ts');
|
||||
});
|
||||
|
||||
// Negative: a KnowledgeGraph object does NOT have entries()
|
||||
// This was the original bug — passing graph instead of fileMap
|
||||
it('KnowledgeGraph does not have entries() (the original bug)', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
expect((graph as any).entries).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('highlight clearing — state management', () => {
|
||||
// Positive: Set operations for highlight clearing
|
||||
it('clearing a Set produces an empty set', () => {
|
||||
const highlights = new Set(['node1', 'node2', 'node3']);
|
||||
const cleared = new Set<string>();
|
||||
|
||||
expect(cleared.size).toBe(0);
|
||||
expect(highlights.size).toBe(3);
|
||||
});
|
||||
|
||||
// Positive: multiple highlight sources are independent
|
||||
it('independent highlight sets can be cleared separately', () => {
|
||||
const processHighlights = new Set(['proc_1', 'proc_2']);
|
||||
const aiToolHighlights = new Set(['Function:a.ts:foo']);
|
||||
const aiCitationHighlights = new Set(['File:b.ts']);
|
||||
const blastRadius = new Set(['Function:c.ts:bar']);
|
||||
|
||||
// Simulate "Turn off all highlights" — clear all sets
|
||||
const clearedProcess = new Set<string>();
|
||||
const clearedAITool = new Set<string>();
|
||||
const clearedAICitation = new Set<string>();
|
||||
const clearedBlast = new Set<string>();
|
||||
|
||||
expect(clearedProcess.size).toBe(0);
|
||||
expect(clearedAITool.size).toBe(0);
|
||||
expect(clearedAICitation.size).toBe(0);
|
||||
expect(clearedBlast.size).toBe(0);
|
||||
|
||||
// Original sets unchanged (React state immutability)
|
||||
expect(processHighlights.size).toBe(2);
|
||||
expect(aiToolHighlights.size).toBe(1);
|
||||
});
|
||||
|
||||
// Negative: clearing highlights doesn't affect node selection
|
||||
// (selection is a separate state — verified by checking they're independent)
|
||||
it('highlight state is independent from node selection state', () => {
|
||||
const highlights = new Set(['node1']);
|
||||
let selectedNode: { id: string } | null = { id: 'node1' };
|
||||
|
||||
// Clear highlights but keep selection
|
||||
const clearedHighlights = new Set<string>();
|
||||
expect(clearedHighlights.size).toBe(0);
|
||||
expect(selectedNode).not.toBeNull();
|
||||
|
||||
// Clear selection independently
|
||||
selectedNode = null;
|
||||
expect(selectedNode).toBeNull();
|
||||
});
|
||||
|
||||
// Negative: toggling AI highlights ON should NOT clear user query highlights
|
||||
it('AI highlight toggle does not clear process highlights', () => {
|
||||
const processHighlights = new Set(['proc_1', 'proc_2']);
|
||||
let isAIEnabled = false;
|
||||
|
||||
// Turn AI on — process highlights should survive
|
||||
isAIEnabled = true;
|
||||
expect(processHighlights.size).toBe(2);
|
||||
expect(isAIEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,225 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { NODE_TABLES, REL_TYPES } from '../../src/core/lbug/schema';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recreate the security guards locally so we can test the exact logic used in
|
||||
// production without exporting private helpers.
|
||||
//
|
||||
// Source locations:
|
||||
// validLabel / validRelType -- gitnexus-web/src/core/llm/tools.ts
|
||||
// isSafeId -- gitnexus-web/src/components/ProcessesPanel.tsx
|
||||
// readOnly guard (regex) -- gitnexus-web/src/core/lbug/lbug-adapter.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const validLabel = (label: string): boolean =>
|
||||
(NODE_TABLES as readonly string[]).includes(label);
|
||||
|
||||
const validRelType = (t: string): boolean =>
|
||||
(REL_TYPES as readonly string[]).includes(t);
|
||||
|
||||
const isSafeId = (id: string): boolean =>
|
||||
/^[a-zA-Z0-9_:.\-/@]+$/.test(id);
|
||||
|
||||
const isWriteQuery = (cypher: string): boolean => {
|
||||
const stripped = cypher.replace(/'[^']*'|"[^"]*"/g, '').toUpperCase();
|
||||
return /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|DETACH)\b/.test(stripped);
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// validLabel
|
||||
// ===========================================================================
|
||||
describe('validLabel – NODE_TABLES membership', () => {
|
||||
it.each([
|
||||
'Function', 'Class', 'File', 'Process', 'Community',
|
||||
])('accepts known label "%s"', (label) => {
|
||||
expect(validLabel(label)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'Struct', 'Enum', 'Trait', 'Impl', 'Macro', 'Typedef',
|
||||
'Union', 'Namespace', 'TypeAlias', 'Const', 'Static',
|
||||
'Property', 'Record', 'Delegate', 'Annotation',
|
||||
'Constructor', 'Template', 'Module',
|
||||
])('accepts multi-language label "%s"', (label) => {
|
||||
expect(validLabel(label)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['empty string', ''],
|
||||
['SQL keyword', 'DROP'],
|
||||
['random word', 'foo'],
|
||||
['Cypher injection', '})-[:R]->(x)'],
|
||||
['label with semicolon', 'Function;DELETE'],
|
||||
['lowercase (case matters)', 'function'],
|
||||
['lowercase class', 'class'],
|
||||
['whitespace padded', ' File '],
|
||||
['numeric', '123'],
|
||||
])('rejects invalid label: %s', (_desc, label) => {
|
||||
expect(validLabel(label)).toBe(false);
|
||||
});
|
||||
|
||||
it('NODE_TABLES contains all expected core labels', () => {
|
||||
const core = ['File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement', 'Community', 'Process'];
|
||||
for (const label of core) {
|
||||
expect((NODE_TABLES as readonly string[]).includes(label)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// validRelType
|
||||
// ===========================================================================
|
||||
describe('validRelType – REL_TYPES membership', () => {
|
||||
it.each(
|
||||
[...REL_TYPES]
|
||||
)('accepts known relation type "%s"', (relType) => {
|
||||
expect(validRelType(relType)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['empty string', ''],
|
||||
['SQL keyword', 'DROP'],
|
||||
['injection attempt', 'CALLS;DELETE'],
|
||||
['lowercase', 'calls'],
|
||||
['nonexistent type', 'FRIEND_OF'],
|
||||
['padded', ' CALLS '],
|
||||
])('rejects invalid relation type: %s', (_desc, relType) => {
|
||||
expect(validRelType(relType)).toBe(false);
|
||||
});
|
||||
|
||||
it('REL_TYPES has at least the base types', () => {
|
||||
// Guard against accidental removal of relation types
|
||||
expect(REL_TYPES.length).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// isSafeId
|
||||
// ===========================================================================
|
||||
describe('isSafeId – identifier allowlist regex', () => {
|
||||
it.each([
|
||||
['namespaced id', 'Function:myFunc'],
|
||||
['underscore id', 'proc_5'],
|
||||
['class id', 'Class:MyClass'],
|
||||
['dotted name', 'Module:path.to.thing'],
|
||||
['with hyphen', 'File:my-file.ts'],
|
||||
['community id', 'comm_5'],
|
||||
['file path id', 'File:src/index.ts'],
|
||||
['nested path id', 'Function:src/utils/helpers.ts:doStuff'],
|
||||
['scoped npm package', 'Module:@scope/pkg'],
|
||||
['angular-style id', 'Module:@angular/core'],
|
||||
])('accepts valid ID: %s', (_desc, id) => {
|
||||
expect(isSafeId(id)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['with spaces', 'Process:my process'],
|
||||
])('rejects ID with unsafe chars: %s', (_desc, id) => {
|
||||
expect(isSafeId(id)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty string', () => {
|
||||
expect(isSafeId('')).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['SQL injection', "'; DROP TABLE"],
|
||||
['command substitution', '$(command)'],
|
||||
['XSS attempt', '<script>'],
|
||||
['JSON injection', '{id: "x"}'],
|
||||
])('rejects injection attempt: %s', (_desc, id) => {
|
||||
expect(isSafeId(id)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['open paren', '('],
|
||||
['close paren', ')'],
|
||||
['open bracket', '['],
|
||||
['close bracket', ']'],
|
||||
['open brace', '{'],
|
||||
['close brace', '}'],
|
||||
['backtick', '`'],
|
||||
['double quote', '"'],
|
||||
['single quote', "'"],
|
||||
])('rejects Cypher metacharacter: %s', (_desc, ch) => {
|
||||
expect(isSafeId(ch)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['embedded paren', 'func(x)'],
|
||||
['embedded bracket', 'arr[0]'],
|
||||
['embedded brace', '{key}'],
|
||||
['embedded backtick', 'id`inject'],
|
||||
])('rejects id containing metacharacter: %s', (_desc, id) => {
|
||||
expect(isSafeId(id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// readOnly guard – write-operation regex
|
||||
// ===========================================================================
|
||||
describe('readOnly guard – write-operation detection', () => {
|
||||
describe('allows read-only queries (should NOT match)', () => {
|
||||
it.each([
|
||||
['simple match', 'MATCH (n) RETURN n'],
|
||||
['filtered match', 'MATCH (n:Function) WHERE n.name = "test" RETURN n'],
|
||||
['with relationship', 'MATCH (a)-[r:CodeRelation]->(b) RETURN a, r, b'],
|
||||
['with count', 'MATCH (n) RETURN count(n)'],
|
||||
['with ordering', 'MATCH (n) RETURN n ORDER BY n.name LIMIT 10'],
|
||||
['call procedure', 'CALL db.schema.nodeTypeProperties()'],
|
||||
])('%s', (_desc, cypher) => {
|
||||
expect(isWriteQuery(cypher)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocks write operations (should match)', () => {
|
||||
it.each([
|
||||
['DELETE node', 'MATCH (n) DELETE n'],
|
||||
['CREATE node', 'CREATE (n:Test)'],
|
||||
['SET property', 'MATCH (n) SET n.x = 1'],
|
||||
['MERGE node', 'MERGE (n:Test {id: "1"})'],
|
||||
['REMOVE property', 'MATCH (n) REMOVE n.x'],
|
||||
['DETACH DELETE', 'MATCH (n) DETACH DELETE n'],
|
||||
['DROP (DDL)', 'DROP TABLE x'],
|
||||
])('%s', (_desc, cypher) => {
|
||||
expect(isWriteQuery(cypher)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handles tricky cases', () => {
|
||||
it('detects write keyword even when embedded in longer query', () => {
|
||||
const cypher = 'MATCH (n:Function) WHERE n.name = "handler" DELETE n';
|
||||
expect(isWriteQuery(cypher)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects mixed-case write keywords via toUpperCase()', () => {
|
||||
expect(isWriteQuery('match (n) delete n')).toBe(true);
|
||||
expect(isWriteQuery('Match (n) Set n.x = 1')).toBe(true);
|
||||
});
|
||||
|
||||
// Keywords inside quoted strings are stripped before checking,
|
||||
// so they don't trigger false positives.
|
||||
it('allows "delete" inside a quoted string value', () => {
|
||||
expect(isWriteQuery('MATCH (n) WHERE n.name CONTAINS "delete" RETURN n')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows "CREATE" inside single-quoted string', () => {
|
||||
expect(isWriteQuery("MATCH (n) WHERE n.name = 'CREATE_USER' RETURN n")).toBe(false);
|
||||
});
|
||||
|
||||
it('still blocks DELETE outside quotes', () => {
|
||||
expect(isWriteQuery('MATCH (n) WHERE n.name = "foo" DELETE n')).toBe(true);
|
||||
});
|
||||
|
||||
// Verify the word-boundary prevents false positives on substrings that
|
||||
// are NOT Cypher write keywords.
|
||||
it('does not match partial keywords like "CREATED" or "SETTING"', () => {
|
||||
expect(isWriteQuery('MATCH (n) WHERE n.status = "CREATED" RETURN n')).toBe(false);
|
||||
expect(isWriteQuery('MATCH (n) WHERE n.label = "SETTING" RETURN n')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag the word "create" inside a property name like "createdAt"', () => {
|
||||
expect(isWriteQuery('MATCH (n) RETURN n.createdAt')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,5 @@
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['test/setup.ts'],
|
||||
include: ['test/**/*.test.ts'],
|
||||
exclude: ['**/node_modules/**', '**/dist/**'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
# GitNexus HTTP Embedding Configuration
|
||||
# Copy to .env and uncomment to use a remote OpenAI-compatible endpoint
|
||||
# instead of the local snowflake-arctic-embed-xs model.
|
||||
# When unset, local embeddings are used unchanged.
|
||||
|
||||
# GITNEXUS_EMBEDDING_URL=http://your-server:8080/v1
|
||||
# GITNEXUS_EMBEDDING_MODEL=BAAI/bge-large-en-v1.5
|
||||
# GITNEXUS_EMBEDDING_DIMS=1024
|
||||
# GITNEXUS_EMBEDDING_API_KEY=your-key
|
||||
|
||||
# Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI.
|
||||
# See README for details.
|
||||
@@ -2,66 +2,6 @@
|
||||
|
||||
All notable changes to GitNexus will be documented in this file.
|
||||
|
||||
## [1.4.8] - 2026-03-23
|
||||
|
||||
### Added
|
||||
- **Type resolution Milestone D — Phases 10–13** consolidated into a single milestone with full integration test coverage across 11 languages (#387)
|
||||
- Phase A/B/C: overload disambiguation via argument literal types, constructor-visible virtual dispatch via `constructorTypeMap`, `parameterTypes` extraction in `extractMethodSignature`
|
||||
- Phase 14 enhancements: single-pass seeding, Tarjan's SCC for cyclic resolution, cross-file return types
|
||||
- Optional parameter arity resolution
|
||||
- Per-language cross-file binding tests and resolver fixes
|
||||
- Store all overloads in `fileIndex` instead of last-write-wins
|
||||
- **Cross-file binding propagation** for multiple languages
|
||||
- **HTTP embedding backend** for self-hosted/remote endpoints with dynamic dimensions, batch guards, and dimension mismatch handling (#395)
|
||||
- **Markdown file indexing** — headings and cross-links as graph nodes (#399)
|
||||
- **MiniMax provider support** (#224)
|
||||
- **Codex MCP and skills support** with CLI setup flow and e2e tests
|
||||
- **HelpPanel UI** — built-in help for the web interface (#465)
|
||||
- **Section node type** registered in `NODE_TABLES` and `NODE_SCHEMA_QUERIES` (#401)
|
||||
- **Community and Process node properties** documented in cypher tool description (#411)
|
||||
- **Server-mode hydration regression tests**
|
||||
- **Pre-commit hooks** via husky for typecheck + unit tests
|
||||
|
||||
### Fixed
|
||||
- **Python import alias resolution** — `import X as Y` now routes module aliases directly to `moduleAliasMap` in import processor (#417, #461)
|
||||
- **Python module-qualified calls** resolved via `moduleAliasMap` (#337)
|
||||
- **Python module-qualified constructor calls** (Issue #337)
|
||||
- **Heritage/MRO edges** now calculate confidence per resolution tier (#412)
|
||||
- **LadybugDB lock** — retry on DB lock with session-safe cleanup (#325)
|
||||
- **CORS** — allow private/LAN network origins (#390)
|
||||
- **Analyze without git** — allow indexing folders without a `.git` directory (#384)
|
||||
- **Web: LadybugDB** — `getAllRows`, `loadServerGraph`, BM25, highlight clearing (#474)
|
||||
- **Server-mode hydration** — await server connect hydration flow (#398, #404)
|
||||
- **Embedding dimensions** — validate on every vector, not just the first; hard-throw on mismatch
|
||||
- **Timeout detection** — always-on dim validation, test hardening
|
||||
- **ONNX CUDA** — prevent uncatchable native crash when CUDA libs present but ORT lacks CUDA provider; clarify linux/x64-only
|
||||
- **CLI** — run codex mcp add via shell on Windows; write tool output to stdout via fd 1
|
||||
- **Stale progress, cross-platform prepare, DEV log** fixes
|
||||
- **Import resolution API** simplified per PR #409 review findings (P0–P3)
|
||||
- **Auto-labeling** — switched from clustering to z-score method; multi-dim aware Mahalanobis threshold
|
||||
- **PR/issue filtering** — fixed prop cutoff issue
|
||||
- **Sequential enrichment queries** + stale data detection
|
||||
- **package-lock.json** synced with `onnxruntime-node ^1.24.0`
|
||||
|
||||
### Changed
|
||||
- **Unified language dispatch** with compile-time exhaustive tables
|
||||
- **Prepare script simplified** — removed `scripts/prepare.cjs`
|
||||
- **Switched from .githooks to husky** for pre-commit hooks
|
||||
- **`@claude` workflow** restricted to maintainers and above via `author_association` check
|
||||
|
||||
### Performance
|
||||
- **O(1) per-chunk synthesis guard** using `boolean[]` instead of Set
|
||||
- **`sizeBefore` optimization** in type resolution
|
||||
- **Token truncation** improvements
|
||||
|
||||
### Chore
|
||||
- Strengthened Python module-import tests, un-skipped match/case, added perf guard
|
||||
- Added positive and negative tests for all 4 bug fixes
|
||||
- E2e tests for stale detection, sequential enrichment, stability (#396)
|
||||
- Integration tests for Milestone D across all 11 languages
|
||||
- `gitnexus-stable-ops` added to community integrations
|
||||
- `.env.example` added for embedding backend configuration
|
||||
|
||||
## [1.4.7] - 2026-03-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -158,20 +158,6 @@ gitnexus wiki [path] # Generate LLM-powered docs from knowledge grap
|
||||
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
|
||||
```
|
||||
|
||||
## Remote Embeddings
|
||||
|
||||
Set these env vars to use a remote OpenAI-compatible `/v1/embeddings` endpoint instead of the local model:
|
||||
|
||||
```bash
|
||||
export GITNEXUS_EMBEDDING_URL=http://your-server:8080/v1
|
||||
export GITNEXUS_EMBEDDING_MODEL=BAAI/bge-large-en-v1.5
|
||||
export GITNEXUS_EMBEDDING_DIMS=1024 # optional, default 384
|
||||
export GITNEXUS_EMBEDDING_API_KEY=your-key # optional, default: "unused"
|
||||
gitnexus analyze . --embeddings
|
||||
```
|
||||
|
||||
Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI. When unset, local embeddings are used unchanged.
|
||||
|
||||
## Multi-Repo Support
|
||||
|
||||
GitNexus supports indexing multiple repositories. Each `gitnexus analyze` registers the repo in a global registry (`~/.gitnexus/registry.json`). The MCP server serves all indexed repos automatically.
|
||||
|
||||
Generated
+54
-35
@@ -24,7 +24,6 @@
|
||||
"ignore": "^7.0.5",
|
||||
"lru-cache": "^11.0.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"onnxruntime-node": "^1.24.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"tree-sitter": "^0.21.0",
|
||||
"tree-sitter-c": "^0.21.0",
|
||||
@@ -50,7 +49,6 @@
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"husky": "^9.1.7",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^4.0.18"
|
||||
@@ -2405,15 +2403,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
|
||||
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
@@ -3586,22 +3575,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"version": "9.1.7",
|
||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"husky": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
@@ -4067,15 +4040,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-common": {
|
||||
"version": "1.24.3",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
|
||||
"integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
|
||||
"version": "1.21.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz",
|
||||
"integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/onnxruntime-node": {
|
||||
"version": "1.24.3",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz",
|
||||
"integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
|
||||
"version": "1.21.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz",
|
||||
"integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
@@ -4084,9 +4057,55 @@
|
||||
"linux"
|
||||
],
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"global-agent": "^3.0.0",
|
||||
"onnxruntime-common": "1.24.3"
|
||||
"onnxruntime-common": "1.21.0",
|
||||
"tar": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-node/node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-node/node_modules/minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-node/node_modules/tar": {
|
||||
"version": "7.5.7",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
|
||||
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-node/node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-web": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.4.8",
|
||||
"version": "1.4.7",
|
||||
"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",
|
||||
@@ -51,7 +51,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.0.0",
|
||||
"@ladybugdb/core": "^0.15.2",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"commander": "^12.0.0",
|
||||
@@ -61,10 +60,10 @@
|
||||
"graphology": "^0.25.4",
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"@ladybugdb/core": "^0.15.2",
|
||||
"ignore": "^7.0.5",
|
||||
"lru-cache": "^11.0.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"onnxruntime-node": "^1.24.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"tree-sitter": "^0.21.0",
|
||||
"tree-sitter-c": "^0.21.0",
|
||||
@@ -91,16 +90,10 @@
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"husky": "^9.1.7",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"overrides": {
|
||||
"@huggingface/transformers": {
|
||||
"onnxruntime-node": "$onnxruntime-node"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
|
||||
+24
-57
@@ -15,7 +15,7 @@ import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReuse
|
||||
// versions whose ABI is not yet supported by the native binary (#89).
|
||||
// disposeEmbedder intentionally not called — ONNX Runtime segfaults on cleanup (see #38)
|
||||
import { getStoragePaths, saveMeta, loadMeta, addToGitignore, registerRepo, getGlobalRegistryPath, cleanupOldKuzuFiles } from '../storage/repo-manager.js';
|
||||
import { getCurrentCommit, getGitRoot, hasGitDir } from '../storage/git.js';
|
||||
import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
|
||||
import { generateAIContextFiles } from './ai-context.js';
|
||||
import { generateSkillFiles, type GeneratedSkillInfo } from './skill-gen.js';
|
||||
import fs from 'fs/promises';
|
||||
@@ -48,8 +48,6 @@ export interface AnalyzeOptions {
|
||||
embeddings?: boolean;
|
||||
skills?: boolean;
|
||||
verbose?: boolean;
|
||||
/** Index the folder even when no .git directory is present. */
|
||||
skipGit?: boolean;
|
||||
}
|
||||
|
||||
/** Threshold: auto-skip embeddings for repos with more nodes than this */
|
||||
@@ -89,27 +87,18 @@ export const analyzeCommand = async (
|
||||
} else {
|
||||
const gitRoot = getGitRoot(process.cwd());
|
||||
if (!gitRoot) {
|
||||
if (!options?.skipGit) {
|
||||
console.log(' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// --skip-git: fall back to cwd as the root
|
||||
repoPath = path.resolve(process.cwd());
|
||||
} else {
|
||||
repoPath = gitRoot;
|
||||
console.log(' Not inside a git repository\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
repoPath = gitRoot;
|
||||
}
|
||||
|
||||
const repoHasGit = hasGitDir(repoPath);
|
||||
if (!repoHasGit && !options?.skipGit) {
|
||||
console.log(' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n');
|
||||
if (!isGitRepo(repoPath)) {
|
||||
console.log(' Not a git repository\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (!repoHasGit) {
|
||||
console.log(' Warning: no .git directory found \u2014 commit-tracking and incremental updates disabled.\n');
|
||||
}
|
||||
|
||||
const { storagePath, lbugPath } = getStoragePaths(repoPath);
|
||||
|
||||
@@ -120,15 +109,12 @@ export const analyzeCommand = async (
|
||||
console.log(' Migrating from KuzuDB to LadybugDB — rebuilding index...\n');
|
||||
}
|
||||
|
||||
const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : '';
|
||||
const currentCommit = getCurrentCommit(repoPath);
|
||||
const existingMeta = await loadMeta(storagePath);
|
||||
|
||||
if (existingMeta && !options?.force && !options?.skills && existingMeta.lastCommit === currentCommit) {
|
||||
// Non-git folders have currentCommit = '' — always rebuild since we can't detect changes
|
||||
if (currentCommit !== '') {
|
||||
console.log(' Already up to date\n');
|
||||
return;
|
||||
}
|
||||
console.log(' Already up to date\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.GITNEXUS_NO_GITIGNORE) {
|
||||
@@ -260,27 +246,17 @@ export const analyzeCommand = async (
|
||||
|
||||
// ── Phase 3.5: Re-insert cached embeddings ────────────────────────
|
||||
if (cachedEmbeddings.length > 0) {
|
||||
// Check if cached embedding dimensions match current schema
|
||||
const cachedDims = cachedEmbeddings[0].embedding.length;
|
||||
const { EMBEDDING_DIMS } = await import('../core/lbug/schema.js');
|
||||
if (cachedDims !== EMBEDDING_DIMS) {
|
||||
// Dimensions changed (e.g. switched embedding model) — discard cache and re-embed all
|
||||
console.error(`⚠️ Embedding dimensions changed (${cachedDims}d → ${EMBEDDING_DIMS}d), discarding cache`);
|
||||
cachedEmbeddings = [];
|
||||
cachedEmbeddingNodeIds = new Set();
|
||||
} else {
|
||||
updateBar(88, `Restoring ${cachedEmbeddings.length} cached embeddings...`);
|
||||
const EMBED_BATCH = 200;
|
||||
for (let i = 0; i < cachedEmbeddings.length; i += EMBED_BATCH) {
|
||||
const batch = cachedEmbeddings.slice(i, i + EMBED_BATCH);
|
||||
const paramsList = batch.map(e => ({ nodeId: e.nodeId, embedding: e.embedding }));
|
||||
try {
|
||||
await executeWithReusedStatement(
|
||||
`CREATE (e:CodeEmbedding {nodeId: $nodeId, embedding: $embedding})`,
|
||||
paramsList,
|
||||
);
|
||||
} catch { /* some may fail if node was removed, that's fine */ }
|
||||
}
|
||||
updateBar(88, `Restoring ${cachedEmbeddings.length} cached embeddings...`);
|
||||
const EMBED_BATCH = 200;
|
||||
for (let i = 0; i < cachedEmbeddings.length; i += EMBED_BATCH) {
|
||||
const batch = cachedEmbeddings.slice(i, i + EMBED_BATCH);
|
||||
const paramsList = batch.map(e => ({ nodeId: e.nodeId, embedding: e.embedding }));
|
||||
try {
|
||||
await executeWithReusedStatement(
|
||||
`CREATE (e:CodeEmbedding {nodeId: $nodeId, embedding: $embedding})`,
|
||||
paramsList,
|
||||
);
|
||||
} catch { /* some may fail if node was removed, that's fine */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,9 +275,7 @@ export const analyzeCommand = async (
|
||||
}
|
||||
|
||||
if (!embeddingSkipped) {
|
||||
const { isHttpMode } = await import('../core/embeddings/http-client.js');
|
||||
const httpMode = isHttpMode();
|
||||
updateBar(90, httpMode ? 'Connecting to embedding endpoint...' : 'Loading embedding model...');
|
||||
updateBar(90, 'Loading embedding model...');
|
||||
const t0Emb = Date.now();
|
||||
const { runEmbeddingPipeline } = await import('../core/embeddings/embedding-pipeline.js');
|
||||
await runEmbeddingPipeline(
|
||||
@@ -309,9 +283,7 @@ export const analyzeCommand = async (
|
||||
executeWithReusedStatement,
|
||||
(progress) => {
|
||||
const scaled = 90 + Math.round((progress.percent / 100) * 8);
|
||||
const label = progress.phase === 'loading-model'
|
||||
? (httpMode ? 'Connecting to embedding endpoint...' : 'Loading embedding model...')
|
||||
: `Embedding ${progress.nodesProcessed || 0}/${progress.totalNodes || '?'}`;
|
||||
const label = progress.phase === 'loading-model' ? 'Loading embedding model...' : `Embedding ${progress.nodesProcessed || 0}/${progress.totalNodes || '?'}`;
|
||||
updateBar(scaled, label);
|
||||
},
|
||||
{},
|
||||
@@ -345,12 +317,7 @@ export const analyzeCommand = async (
|
||||
};
|
||||
await saveMeta(storagePath, meta);
|
||||
await registerRepo(repoPath, meta);
|
||||
// Only attempt to update .gitignore when a .git directory is present.
|
||||
// Use hasGitDir (filesystem check) rather than git CLI subprocess
|
||||
// so we skip correctly for --skip-git folders even if git CLI is available.
|
||||
if (hasGitDir(repoPath)) {
|
||||
await addToGitignore(repoPath);
|
||||
}
|
||||
await addToGitignore(repoPath);
|
||||
|
||||
const projectName = path.basename(repoPath);
|
||||
let aggregatedClusterCount = 0;
|
||||
|
||||
@@ -28,7 +28,6 @@ program
|
||||
.option('-f, --force', 'Force full re-index even if up to date')
|
||||
.option('--embeddings', 'Enable embedding generation for semantic search (off by default)')
|
||||
.option('--skills', 'Generate repo-specific skill files from detected communities')
|
||||
.option('--skip-git', 'Index a folder without requiring a .git directory')
|
||||
.option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)')
|
||||
.addHelpText('after', '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)')
|
||||
.action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
|
||||
|
||||
@@ -1,33 +1,3 @@
|
||||
/**
|
||||
* HOW TO ADD A NEW LANGUAGE:
|
||||
*
|
||||
* 1. Add the enum member below (e.g., Scala = 'scala')
|
||||
* 2. Run `tsc --noEmit` — compiler errors guide you to every dispatch table
|
||||
* 3. Use this checklist for each file:
|
||||
*
|
||||
* FILE | WHAT TO ADD | DEFAULT (simple languages)
|
||||
* ----------------------------------|------------------------------------------|---------------------------
|
||||
* tree-sitter-queries.ts | Query string + LANGUAGE_QUERIES entry | (required)
|
||||
* export-detection.ts | ExportChecker function + table entry | (required)
|
||||
* import-resolution.ts | Resolver in importResolvers | resolveStandard(...)
|
||||
* import-resolution.ts | namedBindingExtractors entry | undefined
|
||||
* call-routing.ts | callRouters entry | noRouting
|
||||
* entry-point-scoring.ts | ENTRY_POINT_PATTERNS entry | []
|
||||
* framework-detection.ts | AST_FRAMEWORK_PATTERNS entry | []
|
||||
* type-extractors/<lang>.ts | New file + index.ts import | (required)
|
||||
* resolvers/<lang>.ts | Resolver file (if non-standard) | (only if resolveStandard insufficient)
|
||||
* named-binding-extraction.ts | Extractor (if named imports) | (only if language has named imports)
|
||||
*
|
||||
* 4. Also check these files for language-specific if-checks (no compile-time guard):
|
||||
* - mro-processor.ts (MRO strategy selection)
|
||||
* - heritage-processor.ts (extends/implements handling)
|
||||
* - parse-worker.ts (AST edge cases)
|
||||
* - parsing-processor.ts (node label normalization)
|
||||
*
|
||||
* 5. Add tree-sitter-<lang> to package.json dependencies
|
||||
* 6. Add file extension mapping in utils.ts getLanguageFromFilename()
|
||||
* 7. Run full test suite
|
||||
*/
|
||||
export enum SupportedLanguages {
|
||||
JavaScript = 'javascript',
|
||||
TypeScript = 'typescript',
|
||||
|
||||
@@ -17,54 +17,18 @@ if (!process.env.ORT_LOG_LEVEL) {
|
||||
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
|
||||
import { existsSync } from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { createRequire } from 'module';
|
||||
import { join } from 'path';
|
||||
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js';
|
||||
import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js';
|
||||
|
||||
/**
|
||||
* Check whether the onnxruntime-node package that @huggingface/transformers
|
||||
* will actually load at runtime ships the CUDA execution provider.
|
||||
*
|
||||
* Critical: we resolve from transformers' own module scope, NOT from ours.
|
||||
* npm may install two copies — a top-level 1.24.x (our dep) and a nested
|
||||
* 1.21.0 (transformers' pinned dep). The guard must inspect whichever copy
|
||||
* transformers.js will dlopen, otherwise the check is meaningless.
|
||||
*/
|
||||
function hasOrtCudaProvider(): boolean {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
// Resolve from @huggingface/transformers' scope so we find the same
|
||||
// onnxruntime-node binary that transformers.js will use at runtime
|
||||
const transformersDir = dirname(require.resolve('@huggingface/transformers/package.json'));
|
||||
const ortRequire = createRequire(join(transformersDir, 'package.json'));
|
||||
const ortPath = dirname(ortRequire.resolve('onnxruntime-node/package.json'));
|
||||
// ORT 1.24.x only ships CUDA binaries for linux/x64 (downloaded from NuGet
|
||||
// at postinstall). arm64 will correctly return false here until ORT adds support.
|
||||
const arch = process.arch;
|
||||
return existsSync(join(ortPath, 'bin', 'napi-v6', 'linux', arch, 'libonnxruntime_providers_cuda.so'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether CUDA libraries are actually available on this system.
|
||||
* ONNX Runtime's native layer crashes (uncatchable) if we attempt CUDA
|
||||
* without the required shared libraries, so we probe first.
|
||||
*
|
||||
* Checks both:
|
||||
* 1. That system CUDA libraries (libcublasLt) are present
|
||||
* 2. That onnxruntime-node ships the CUDA execution provider binary
|
||||
*
|
||||
* Both conditions must be true — system CUDA libs alone are not enough
|
||||
* if onnxruntime-node is a CPU-only build (versions < 1.24.0).
|
||||
* Checks the dynamic linker cache (ldconfig) which covers all architectures
|
||||
* and install paths, then falls back to CUDA_PATH / LD_LIBRARY_PATH env vars.
|
||||
*/
|
||||
function isCudaAvailable(): boolean {
|
||||
// First, verify onnxruntime-node has the CUDA provider binary.
|
||||
// Without this, requesting CUDA causes an uncatchable native crash.
|
||||
if (!hasOrtCudaProvider()) return false;
|
||||
|
||||
// Primary: query the dynamic linker cache — covers all architectures,
|
||||
// distro layouts, and custom install paths registered with ldconfig
|
||||
try {
|
||||
@@ -119,13 +83,6 @@ export const initEmbedder = async (
|
||||
config: Partial<EmbeddingConfig> = {},
|
||||
forceDevice?: 'dml' | 'cuda' | 'cpu' | 'wasm'
|
||||
): Promise<FeatureExtractionPipeline> => {
|
||||
if (isHttpMode()) {
|
||||
throw new Error(
|
||||
'initEmbedder() should not be called in HTTP mode. ' +
|
||||
'Use embedText()/embedBatch() which handle HTTP transparently.'
|
||||
);
|
||||
}
|
||||
|
||||
// Return existing instance if available
|
||||
if (embedderInstance) {
|
||||
return embedderInstance;
|
||||
@@ -238,27 +195,13 @@ export const initEmbedder = async (
|
||||
* Check if the embedder is initialized and ready
|
||||
*/
|
||||
export const isEmbedderReady = (): boolean => {
|
||||
return isHttpMode() || embedderInstance !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the effective embedding dimensions.
|
||||
* In HTTP mode, uses GITNEXUS_EMBEDDING_DIMS if set, otherwise the default.
|
||||
*/
|
||||
export const getEmbeddingDimensions = (): number => {
|
||||
if (isHttpMode()) {
|
||||
return getHttpDimensions() ?? DEFAULT_EMBEDDING_CONFIG.dimensions;
|
||||
}
|
||||
return DEFAULT_EMBEDDING_CONFIG.dimensions;
|
||||
return embedderInstance !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the embedder instance (throws if not initialized)
|
||||
*/
|
||||
export const getEmbedder = (): FeatureExtractionPipeline => {
|
||||
if (isHttpMode()) {
|
||||
throw new Error('getEmbedder() is not available in HTTP embedding mode. Use embedText()/embedBatch() instead.');
|
||||
}
|
||||
if (!embedderInstance) {
|
||||
throw new Error('Embedder not initialized. Call initEmbedder() first.');
|
||||
}
|
||||
@@ -269,14 +212,9 @@ export const getEmbedder = (): FeatureExtractionPipeline => {
|
||||
* Embed a single text string
|
||||
*
|
||||
* @param text - Text to embed
|
||||
* @returns Float32Array of embedding vector
|
||||
* @returns Float32Array of embedding vector (384 dimensions)
|
||||
*/
|
||||
export const embedText = async (text: string): Promise<Float32Array> => {
|
||||
if (isHttpMode()) {
|
||||
const [vec] = await httpEmbed([text]);
|
||||
return vec;
|
||||
}
|
||||
|
||||
const embedder = getEmbedder();
|
||||
|
||||
const result = await embedder(text, {
|
||||
@@ -300,10 +238,6 @@ export const embedBatch = async (texts: string[]): Promise<Float32Array[]> => {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isHttpMode()) {
|
||||
return httpEmbed(texts);
|
||||
}
|
||||
|
||||
const embedder = getEmbedder();
|
||||
|
||||
// Process batch
|
||||
|
||||
@@ -161,16 +161,14 @@ export const runEmbeddingPipeline = async (
|
||||
modelDownloadPercent: 0,
|
||||
});
|
||||
|
||||
if (!isEmbedderReady()) {
|
||||
await initEmbedder((modelProgress: ModelProgress) => {
|
||||
const downloadPercent = modelProgress.progress ?? 0;
|
||||
onProgress({
|
||||
phase: 'loading-model',
|
||||
percent: Math.round(downloadPercent * 0.2),
|
||||
modelDownloadPercent: downloadPercent,
|
||||
});
|
||||
}, finalConfig);
|
||||
}
|
||||
await initEmbedder((modelProgress: ModelProgress) => {
|
||||
const downloadPercent = modelProgress.progress ?? 0;
|
||||
onProgress({
|
||||
phase: 'loading-model',
|
||||
percent: Math.round(downloadPercent * 0.2),
|
||||
modelDownloadPercent: downloadPercent,
|
||||
});
|
||||
}, finalConfig);
|
||||
|
||||
onProgress({
|
||||
phase: 'loading-model',
|
||||
@@ -328,7 +326,7 @@ export const semanticSearch = async (
|
||||
// Query the vector index on CodeEmbedding to get nodeIds and distances
|
||||
const vectorQuery = `
|
||||
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx',
|
||||
CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${k})
|
||||
CAST(${queryVecStr} AS FLOAT[384]), ${k})
|
||||
YIELD node AS emb, distance
|
||||
WITH emb, distance
|
||||
WHERE distance < ${maxDistance}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* HTTP Embedding Client
|
||||
*
|
||||
* Shared fetch+retry logic for OpenAI-compatible /v1/embeddings endpoints.
|
||||
* Imported by both the core embedder (batch) and MCP embedder (query).
|
||||
*/
|
||||
|
||||
const HTTP_TIMEOUT_MS = 30_000;
|
||||
const HTTP_MAX_RETRIES = 2;
|
||||
const HTTP_RETRY_BACKOFF_MS = 1_000;
|
||||
const HTTP_BATCH_SIZE = 64;
|
||||
const DEFAULT_DIMS = 384;
|
||||
|
||||
interface HttpConfig {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
dimensions?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build config from the current process.env snapshot.
|
||||
* Returns null when GITNEXUS_EMBEDDING_URL + GITNEXUS_EMBEDDING_MODEL are unset.
|
||||
* Not cached — env vars are read fresh so late configuration takes effect.
|
||||
*/
|
||||
const readConfig = (): HttpConfig | null => {
|
||||
const baseUrl = process.env.GITNEXUS_EMBEDDING_URL;
|
||||
const model = process.env.GITNEXUS_EMBEDDING_MODEL;
|
||||
if (!baseUrl || !model) return null;
|
||||
|
||||
const rawDims = process.env.GITNEXUS_EMBEDDING_DIMS;
|
||||
let dimensions: number | undefined;
|
||||
if (rawDims !== undefined) {
|
||||
const parsed = parseInt(rawDims, 10);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
throw new Error(
|
||||
`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`,
|
||||
);
|
||||
}
|
||||
dimensions = parsed;
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: baseUrl.replace(/\/+$/, ''),
|
||||
model,
|
||||
apiKey: process.env.GITNEXUS_EMBEDDING_API_KEY ?? 'unused',
|
||||
dimensions,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether HTTP embedding mode is active (env vars are set).
|
||||
*/
|
||||
export const isHttpMode = (): boolean => readConfig() !== null;
|
||||
|
||||
/**
|
||||
* Return the configured embedding dimensions for HTTP mode, or undefined
|
||||
* if HTTP mode is not active or no explicit dimensions are set.
|
||||
*/
|
||||
export const getHttpDimensions = (): number | undefined => readConfig()?.dimensions;
|
||||
|
||||
/**
|
||||
* Return a safe representation of a URL for error messages.
|
||||
* Strips query string (may contain tokens) and userinfo.
|
||||
*/
|
||||
const safeUrl = (url: string): string => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return `${u.protocol}//${u.host}${u.pathname}`;
|
||||
} catch {
|
||||
return '<invalid-url>';
|
||||
}
|
||||
};
|
||||
|
||||
interface EmbeddingItem {
|
||||
embedding: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single batch of texts to the embedding endpoint with retry.
|
||||
*
|
||||
* @param url - Full endpoint URL (e.g. https://host/v1/embeddings)
|
||||
* @param batch - Texts to embed
|
||||
* @param model - Model name for the request body
|
||||
* @param apiKey - Bearer token (only used in Authorization header)
|
||||
* @param batchIndex - Logical batch number (for error context)
|
||||
* @param attempt - Current retry attempt (internal)
|
||||
*/
|
||||
const httpEmbedBatch = async (
|
||||
url: string,
|
||||
batch: string[],
|
||||
model: string,
|
||||
apiKey: string,
|
||||
batchIndex = 0,
|
||||
attempt = 0,
|
||||
): Promise<EmbeddingItem[]> => {
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ input: batch, model }),
|
||||
});
|
||||
} catch (err) {
|
||||
// Timeouts should not be retried — the server is unresponsive.
|
||||
// AbortSignal.timeout() throws DOMException with name 'TimeoutError'.
|
||||
const isTimeout = err instanceof DOMException && err.name === 'TimeoutError';
|
||||
if (isTimeout) {
|
||||
throw new Error(
|
||||
`Embedding request timed out after ${HTTP_TIMEOUT_MS}ms (${safeUrl(url)}, batch ${batchIndex})`,
|
||||
);
|
||||
}
|
||||
// DNS, connection errors — retry with backoff
|
||||
if (attempt < HTTP_MAX_RETRIES) {
|
||||
const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
return httpEmbedBatch(url, batch, model, apiKey, batchIndex, attempt + 1);
|
||||
}
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`Embedding request failed (${safeUrl(url)}, batch ${batchIndex}): ${reason}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const status = resp.status;
|
||||
if ((status === 429 || status >= 500) && attempt < HTTP_MAX_RETRIES) {
|
||||
const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
return httpEmbedBatch(url, batch, model, apiKey, batchIndex, attempt + 1);
|
||||
}
|
||||
throw new Error(
|
||||
`Embedding endpoint returned ${status} (${safeUrl(url)}, batch ${batchIndex})`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as { data: EmbeddingItem[] };
|
||||
return data.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Embed texts via the HTTP backend, splitting into batches.
|
||||
* Reads config from env vars on every call.
|
||||
*
|
||||
* @param texts - Array of texts to embed
|
||||
* @returns Array of Float32Array embedding vectors
|
||||
*/
|
||||
export const httpEmbed = async (texts: string[]): Promise<Float32Array[]> => {
|
||||
if (texts.length === 0) return [];
|
||||
|
||||
const config = readConfig();
|
||||
if (!config) throw new Error('HTTP embedding not configured');
|
||||
|
||||
const url = `${config.baseUrl}/embeddings`;
|
||||
const allVectors: Float32Array[] = [];
|
||||
|
||||
for (let i = 0; i < texts.length; i += HTTP_BATCH_SIZE) {
|
||||
const batch = texts.slice(i, i + HTTP_BATCH_SIZE);
|
||||
const batchIndex = Math.floor(i / HTTP_BATCH_SIZE);
|
||||
const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex);
|
||||
|
||||
if (items.length !== batch.length) {
|
||||
throw new Error(
|
||||
`Embedding endpoint returned ${items.length} vectors for ${batch.length} texts ` +
|
||||
`(${safeUrl(url)}, batch ${batchIndex})`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const vec = new Float32Array(item.embedding);
|
||||
// Fail fast on dimension mismatch rather than inserting bad vectors
|
||||
// into the FLOAT[N] column which would cause a cryptic Kuzu error.
|
||||
const expected = config.dimensions ?? DEFAULT_DIMS;
|
||||
if (vec.length !== expected) {
|
||||
const hint = config.dimensions
|
||||
? 'Update GITNEXUS_EMBEDDING_DIMS to match your model output.'
|
||||
: `Set GITNEXUS_EMBEDDING_DIMS=${vec.length} to match your model output.`;
|
||||
throw new Error(
|
||||
`Embedding dimension mismatch: endpoint returned ${vec.length}d vector, ` +
|
||||
`but expected ${expected}d. ${hint}`,
|
||||
);
|
||||
}
|
||||
|
||||
allVectors.push(vec);
|
||||
}
|
||||
}
|
||||
|
||||
return allVectors;
|
||||
};
|
||||
|
||||
/**
|
||||
* Embed a single query text via the HTTP backend.
|
||||
* Convenience for MCP search where only one vector is needed.
|
||||
*
|
||||
* @param text - Query text to embed
|
||||
* @returns Embedding vector as number array
|
||||
*/
|
||||
export const httpEmbedQuery = async (text: string): Promise<number[]> => {
|
||||
const config = readConfig();
|
||||
if (!config) throw new Error('HTTP embedding not configured');
|
||||
|
||||
const url = `${config.baseUrl}/embeddings`;
|
||||
const items = await httpEmbedBatch(url, [text], config.model, config.apiKey);
|
||||
if (!items.length) {
|
||||
throw new Error(`Embedding endpoint returned empty response (${safeUrl(url)})`);
|
||||
}
|
||||
|
||||
const embedding = items[0].embedding;
|
||||
// Same dimension checks as httpEmbed — catch mismatches before they
|
||||
// reach the Kuzu FLOAT[N] cast in search queries.
|
||||
const expected = config.dimensions ?? DEFAULT_DIMS;
|
||||
if (embedding.length !== expected) {
|
||||
const hint = config.dimensions
|
||||
? 'Update GITNEXUS_EMBEDDING_DIMS to match your model output.'
|
||||
: `Set GITNEXUS_EMBEDDING_DIMS=${embedding.length} to match your model output.`;
|
||||
throw new Error(
|
||||
`Embedding dimension mismatch: endpoint returned ${embedding.length}d vector, ` +
|
||||
`but expected ${expected}d. ${hint}`,
|
||||
);
|
||||
}
|
||||
return embedding;
|
||||
};
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
export * from './types.js';
|
||||
export * from './http-client.js';
|
||||
export * from './embedder.js';
|
||||
export * from './text-generator.js';
|
||||
export * from './embedding-pipeline.js';
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface EmbeddingProgress {
|
||||
* Configuration for the embedding pipeline
|
||||
*/
|
||||
export interface EmbeddingConfig {
|
||||
/** Model identifier for transformers.js (local) or the HTTP endpoint model name */
|
||||
/** Model identifier for transformers.js */
|
||||
modelId: string;
|
||||
/** Number of nodes to embed in each batch */
|
||||
batchSize: number;
|
||||
@@ -65,7 +65,6 @@ export interface EmbeddingConfig {
|
||||
maxSnippetLength: number;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Default embedding configuration
|
||||
* Uses snowflake-arctic-embed-xs for browser efficiency
|
||||
|
||||
@@ -1,710 +0,0 @@
|
||||
import type Parser from 'tree-sitter';
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
import type { NodeLabel } from '../graph/types.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { extractSimpleTypeName } from './type-extractors/shared.js';
|
||||
|
||||
/** Tree-sitter AST node. Re-exported for use across ingestion modules. */
|
||||
export type SyntaxNode = Parser.SyntaxNode;
|
||||
|
||||
/**
|
||||
* Ordered list of definition capture keys for tree-sitter query matches.
|
||||
* Used to extract the definition node from a capture map.
|
||||
*/
|
||||
export const DEFINITION_CAPTURE_KEYS = [
|
||||
'definition.function',
|
||||
'definition.class',
|
||||
'definition.interface',
|
||||
'definition.method',
|
||||
'definition.struct',
|
||||
'definition.enum',
|
||||
'definition.namespace',
|
||||
'definition.module',
|
||||
'definition.trait',
|
||||
'definition.impl',
|
||||
'definition.type',
|
||||
'definition.const',
|
||||
'definition.static',
|
||||
'definition.typedef',
|
||||
'definition.macro',
|
||||
'definition.union',
|
||||
'definition.property',
|
||||
'definition.record',
|
||||
'definition.delegate',
|
||||
'definition.annotation',
|
||||
'definition.constructor',
|
||||
'definition.template',
|
||||
] as const;
|
||||
|
||||
/** Extract the definition node from a tree-sitter query capture map. */
|
||||
export const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): SyntaxNode | null => {
|
||||
for (const key of DEFINITION_CAPTURE_KEYS) {
|
||||
if (captureMap[key]) return captureMap[key];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Node types that represent function/method definitions across languages.
|
||||
* Used to find the enclosing function for a call site.
|
||||
*/
|
||||
export const FUNCTION_NODE_TYPES = new Set([
|
||||
// TypeScript/JavaScript
|
||||
'function_declaration',
|
||||
'arrow_function',
|
||||
'function_expression',
|
||||
'method_definition',
|
||||
'generator_function_declaration',
|
||||
// Python
|
||||
'function_definition',
|
||||
// Common async variants
|
||||
'async_function_declaration',
|
||||
'async_arrow_function',
|
||||
// Java
|
||||
'method_declaration',
|
||||
'constructor_declaration',
|
||||
// C/C++
|
||||
// 'function_definition' already included above
|
||||
// Go
|
||||
// 'method_declaration' already included from Java
|
||||
// C#
|
||||
'local_function_statement',
|
||||
// Rust
|
||||
'function_item',
|
||||
'impl_item', // Methods inside impl blocks
|
||||
// PHP
|
||||
'anonymous_function',
|
||||
// Kotlin
|
||||
'lambda_literal',
|
||||
// Swift
|
||||
'init_declaration',
|
||||
'deinit_declaration',
|
||||
// Ruby
|
||||
'method', // def foo
|
||||
'singleton_method', // def self.foo
|
||||
]);
|
||||
|
||||
/**
|
||||
* Node types for standard function declarations that need C/C++ declarator handling.
|
||||
* Used by extractFunctionName to determine how to extract the function name.
|
||||
*/
|
||||
export const FUNCTION_DECLARATION_TYPES = new Set([
|
||||
'function_declaration',
|
||||
'function_definition',
|
||||
'async_function_declaration',
|
||||
'generator_function_declaration',
|
||||
'function_item',
|
||||
]);
|
||||
|
||||
/** AST node types that represent a class-like container (for HAS_METHOD edge extraction) */
|
||||
export const CLASS_CONTAINER_TYPES = new Set([
|
||||
'class_declaration', 'abstract_class_declaration',
|
||||
'interface_declaration', 'struct_declaration', 'record_declaration',
|
||||
'class_specifier', 'struct_specifier',
|
||||
'impl_item', 'trait_item', 'struct_item', 'enum_item',
|
||||
'class_definition',
|
||||
'trait_declaration',
|
||||
'protocol_declaration',
|
||||
// Ruby
|
||||
'class',
|
||||
'module',
|
||||
// Kotlin
|
||||
'object_declaration',
|
||||
'companion_object',
|
||||
]);
|
||||
|
||||
export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
|
||||
class_declaration: 'Class',
|
||||
abstract_class_declaration: 'Class',
|
||||
interface_declaration: 'Interface',
|
||||
struct_declaration: 'Struct',
|
||||
struct_specifier: 'Struct',
|
||||
class_specifier: 'Class',
|
||||
class_definition: 'Class',
|
||||
impl_item: 'Impl',
|
||||
trait_item: 'Trait',
|
||||
struct_item: 'Struct',
|
||||
enum_item: 'Enum',
|
||||
trait_declaration: 'Trait',
|
||||
record_declaration: 'Record',
|
||||
protocol_declaration: 'Interface',
|
||||
class: 'Class',
|
||||
module: 'Module',
|
||||
object_declaration: 'Class',
|
||||
companion_object: 'Class',
|
||||
};
|
||||
|
||||
/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method).
|
||||
* Kotlin grammar uses function_declaration for both top-level functions and class methods.
|
||||
* Returns true when the captured definition node has a class_body ancestor. */
|
||||
export function isKotlinClassMethod(captureNode: { parent?: any } | null | undefined): boolean {
|
||||
let ancestor = captureNode?.parent;
|
||||
while (ancestor) {
|
||||
if (ancestor.type === 'class_body') return true;
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* C/C++: check if a Function capture is inside a class/struct body.
|
||||
* If true, the function is already captured by @definition.method and should be skipped
|
||||
* to prevent double-indexing in globalIndex.
|
||||
*/
|
||||
export function isCppDuplicateClassFunction(
|
||||
functionNode: { parent?: any } | null | undefined,
|
||||
nodeLabel: string,
|
||||
language: SupportedLanguages,
|
||||
): boolean {
|
||||
if (nodeLabel !== 'Function') return false;
|
||||
if (language !== SupportedLanguages.CPlusPlus && language !== SupportedLanguages.C) return false;
|
||||
let ancestor = functionNode?.parent;
|
||||
while (ancestor) {
|
||||
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') return true;
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the graph node label from a tree-sitter capture map.
|
||||
* Handles language-specific reclassification (C/C++ duplicate skipping, Kotlin Method promotion).
|
||||
* Returns null if the capture should be skipped (import, call, C/C++ duplicate, missing name).
|
||||
*/
|
||||
export function getLabelFromCaptures(
|
||||
captureMap: Record<string, any>,
|
||||
language: SupportedLanguages,
|
||||
): NodeLabel | null {
|
||||
if (captureMap['import'] || captureMap['call']) return null;
|
||||
if (!captureMap['name'] && !captureMap['definition.constructor']) return null;
|
||||
|
||||
if (captureMap['definition.function']) {
|
||||
if (isCppDuplicateClassFunction(captureMap['definition.function'], 'Function', language)) return null;
|
||||
if (language === SupportedLanguages.Kotlin && isKotlinClassMethod(captureMap['definition.function'])) return 'Method';
|
||||
return 'Function';
|
||||
}
|
||||
if (captureMap['definition.class']) return 'Class';
|
||||
if (captureMap['definition.interface']) return 'Interface';
|
||||
if (captureMap['definition.method']) return 'Method';
|
||||
if (captureMap['definition.struct']) return 'Struct';
|
||||
if (captureMap['definition.enum']) return 'Enum';
|
||||
if (captureMap['definition.namespace']) return 'Namespace';
|
||||
if (captureMap['definition.module']) return 'Module';
|
||||
if (captureMap['definition.trait']) return 'Trait';
|
||||
if (captureMap['definition.impl']) return 'Impl';
|
||||
if (captureMap['definition.type']) return 'TypeAlias';
|
||||
if (captureMap['definition.const']) return 'Const';
|
||||
if (captureMap['definition.static']) return 'Static';
|
||||
if (captureMap['definition.typedef']) return 'Typedef';
|
||||
if (captureMap['definition.macro']) return 'Macro';
|
||||
if (captureMap['definition.union']) return 'Union';
|
||||
if (captureMap['definition.property']) return 'Property';
|
||||
if (captureMap['definition.record']) return 'Record';
|
||||
if (captureMap['definition.delegate']) return 'Delegate';
|
||||
if (captureMap['definition.annotation']) return 'Annotation';
|
||||
if (captureMap['definition.constructor']) return 'Constructor';
|
||||
if (captureMap['definition.template']) return 'Template';
|
||||
return 'CodeElement';
|
||||
}
|
||||
|
||||
/** Walk up AST to find enclosing class/struct/interface/impl, return its generateId or null.
|
||||
* For Go method_declaration nodes, extracts receiver type (e.g. `func (u *User) Save()` → User struct). */
|
||||
export const findEnclosingClassId = (node: any, filePath: string): string | null => {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
// Go: method_declaration has a receiver parameter with the struct type
|
||||
if (current.type === 'method_declaration') {
|
||||
const receiver = current.childForFieldName?.('receiver');
|
||||
if (receiver) {
|
||||
// receiver is a parameter_list: (u *User) or (u User)
|
||||
const paramDecl = receiver.namedChildren?.find?.((c: any) => c.type === 'parameter_declaration');
|
||||
if (paramDecl) {
|
||||
const typeNode = paramDecl.childForFieldName?.('type');
|
||||
if (typeNode) {
|
||||
// Unwrap pointer_type (*User → User)
|
||||
const inner = typeNode.type === 'pointer_type' ? typeNode.firstNamedChild : typeNode;
|
||||
if (inner && (inner.type === 'type_identifier' || inner.type === 'identifier')) {
|
||||
return generateId('Struct', `${filePath}:${inner.text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Go: type_declaration wrapping a struct_type (type User struct { ... })
|
||||
// field_declaration → field_declaration_list → struct_type → type_spec → type_declaration
|
||||
if (current.type === 'type_declaration') {
|
||||
const typeSpec = current.children?.find((c: any) => c.type === 'type_spec');
|
||||
if (typeSpec) {
|
||||
const typeBody = typeSpec.childForFieldName?.('type');
|
||||
if (typeBody?.type === 'struct_type' || typeBody?.type === 'interface_type') {
|
||||
const nameNode = typeSpec.childForFieldName?.('name');
|
||||
if (nameNode) {
|
||||
const label = typeBody.type === 'struct_type' ? 'Struct' : 'Interface';
|
||||
return generateId(label, `${filePath}:${nameNode.text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (CLASS_CONTAINER_TYPES.has(current.type)) {
|
||||
// Rust impl_item: for `impl Trait for Struct {}`, pick the type after `for`
|
||||
if (current.type === 'impl_item') {
|
||||
const children = current.children ?? [];
|
||||
const forIdx = children.findIndex((c: any) => c.text === 'for');
|
||||
if (forIdx !== -1) {
|
||||
const nameNode = children.slice(forIdx + 1).find((c: any) =>
|
||||
c.type === 'type_identifier' || c.type === 'identifier'
|
||||
);
|
||||
if (nameNode) {
|
||||
return generateId('Impl', `${filePath}:${nameNode.text}`);
|
||||
}
|
||||
}
|
||||
// Fall through: plain `impl Struct {}` — use first type_identifier below
|
||||
}
|
||||
const nameNode = current.childForFieldName?.('name')
|
||||
?? current.children?.find((c: any) =>
|
||||
c.type === 'type_identifier' || c.type === 'identifier' || c.type === 'name' || c.type === 'constant'
|
||||
);
|
||||
if (nameNode) {
|
||||
const label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class';
|
||||
return generateId(label, `${filePath}:${nameNode.text}`);
|
||||
}
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find a child of `childType` within a sibling node of `siblingType`.
|
||||
* Used for Kotlin AST traversal where visibility_modifier lives inside a modifiers sibling.
|
||||
*/
|
||||
export const findSiblingChild = (parent: any, siblingType: string, childType: string): any | null => {
|
||||
for (let i = 0; i < parent.childCount; i++) {
|
||||
const sibling = parent.child(i);
|
||||
if (sibling?.type === siblingType) {
|
||||
for (let j = 0; j < sibling.childCount; j++) {
|
||||
const child = sibling.child(j);
|
||||
if (child?.type === childType) return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract function name and label from a function_definition or similar AST node.
|
||||
* Handles C/C++ qualified_identifier (ClassName::MethodName) and other language patterns.
|
||||
*/
|
||||
export const extractFunctionName = (node: SyntaxNode): { funcName: string | null; label: string } => {
|
||||
let funcName: string | null = null;
|
||||
let label = 'Function';
|
||||
|
||||
// Swift init/deinit
|
||||
if (node.type === 'init_declaration' || node.type === 'deinit_declaration') {
|
||||
return {
|
||||
funcName: node.type === 'init_declaration' ? 'init' : 'deinit',
|
||||
label: 'Constructor',
|
||||
};
|
||||
}
|
||||
|
||||
if (FUNCTION_DECLARATION_TYPES.has(node.type)) {
|
||||
// C/C++: function_definition -> [pointer_declarator ->] function_declarator -> qualified_identifier/identifier
|
||||
// Unwrap pointer_declarator / reference_declarator wrappers to reach function_declarator
|
||||
let declarator = node.childForFieldName?.('declarator');
|
||||
if (!declarator) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'function_declarator') { declarator = c; break; }
|
||||
}
|
||||
}
|
||||
while (declarator && (declarator.type === 'pointer_declarator' || declarator.type === 'reference_declarator')) {
|
||||
let nextDeclarator = declarator.childForFieldName?.('declarator');
|
||||
if (!nextDeclarator) {
|
||||
for (let i = 0; i < declarator.childCount; i++) {
|
||||
const c = declarator.child(i);
|
||||
if (c?.type === 'function_declarator' || c?.type === 'pointer_declarator' || c?.type === 'reference_declarator') { nextDeclarator = c; break; }
|
||||
}
|
||||
}
|
||||
declarator = nextDeclarator;
|
||||
}
|
||||
if (declarator) {
|
||||
let innerDeclarator = declarator.childForFieldName?.('declarator');
|
||||
if (!innerDeclarator) {
|
||||
for (let i = 0; i < declarator.childCount; i++) {
|
||||
const c = declarator.child(i);
|
||||
if (c?.type === 'qualified_identifier' || c?.type === 'identifier'
|
||||
|| c?.type === 'field_identifier' || c?.type === 'parenthesized_declarator') { innerDeclarator = c; break; }
|
||||
}
|
||||
}
|
||||
|
||||
if (innerDeclarator?.type === 'qualified_identifier') {
|
||||
let nameNode = innerDeclarator.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < innerDeclarator.childCount; i++) {
|
||||
const c = innerDeclarator.child(i);
|
||||
if (c?.type === 'identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
if (nameNode?.text) {
|
||||
funcName = nameNode.text;
|
||||
label = 'Method';
|
||||
}
|
||||
} else if (innerDeclarator?.type === 'identifier' || innerDeclarator?.type === 'field_identifier') {
|
||||
// field_identifier is used for method names inside C++ class bodies
|
||||
funcName = innerDeclarator.text;
|
||||
if (innerDeclarator.type === 'field_identifier') label = 'Method';
|
||||
} else if (innerDeclarator?.type === 'parenthesized_declarator') {
|
||||
let nestedId: SyntaxNode | null = null;
|
||||
for (let i = 0; i < innerDeclarator.childCount; i++) {
|
||||
const c = innerDeclarator.child(i);
|
||||
if (c?.type === 'qualified_identifier' || c?.type === 'identifier') { nestedId = c; break; }
|
||||
}
|
||||
if (nestedId?.type === 'qualified_identifier') {
|
||||
let nameNode = nestedId.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < nestedId.childCount; i++) {
|
||||
const c = nestedId.child(i);
|
||||
if (c?.type === 'identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
if (nameNode?.text) {
|
||||
funcName = nameNode.text;
|
||||
label = 'Method';
|
||||
}
|
||||
} else if (nestedId?.type === 'identifier') {
|
||||
funcName = nestedId.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for other languages (Kotlin uses simple_identifier, Swift uses simple_identifier)
|
||||
if (!funcName) {
|
||||
let nameNode = node.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'identifier' || c?.type === 'property_identifier' || c?.type === 'simple_identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
|
||||
// Kotlin: function_declaration inside a class_body is a method, not a top-level function.
|
||||
// Must match the label assigned in parse-worker.ts for consistent generateId() output.
|
||||
if (funcName && node.type === 'function_declaration' && isKotlinClassMethod(node)) {
|
||||
label = 'Method';
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'impl_item') {
|
||||
let funcItem: SyntaxNode | null = null;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'function_item') { funcItem = c; break; }
|
||||
}
|
||||
if (funcItem) {
|
||||
let nameNode = funcItem.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < funcItem.childCount; i++) {
|
||||
const c = funcItem.child(i);
|
||||
if (c?.type === 'identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
label = 'Method';
|
||||
}
|
||||
} else if (node.type === 'method_definition') {
|
||||
let nameNode = node.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'property_identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
label = 'Method';
|
||||
} else if (node.type === 'method_declaration' || node.type === 'constructor_declaration') {
|
||||
let nameNode = node.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
label = 'Method';
|
||||
} else if (node.type === 'arrow_function' || node.type === 'function_expression') {
|
||||
const parent = node.parent;
|
||||
if (parent?.type === 'variable_declarator') {
|
||||
let nameNode = parent.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < parent.childCount; i++) {
|
||||
const c = parent.child(i);
|
||||
if (c?.type === 'identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
}
|
||||
} else if (node.type === 'method' || node.type === 'singleton_method') {
|
||||
let nameNode = node.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
label = 'Method';
|
||||
}
|
||||
|
||||
return { funcName, label };
|
||||
};
|
||||
|
||||
export interface MethodSignature {
|
||||
parameterCount: number | undefined;
|
||||
/** Number of required (non-optional, non-default) parameters.
|
||||
* Only set when fewer than parameterCount — enables range-based arity filtering.
|
||||
* undefined means all parameters are required (or metadata unavailable). */
|
||||
requiredParameterCount: number | undefined;
|
||||
/** Per-parameter type names extracted via extractSimpleTypeName.
|
||||
* Only populated for languages with method overloading (Java, Kotlin, C#, C++).
|
||||
* undefined (not []) when no types are extractable — avoids empty array allocations. */
|
||||
parameterTypes: string[] | undefined;
|
||||
returnType: string | undefined;
|
||||
}
|
||||
|
||||
/** Argument list node types shared between extractMethodSignature and countCallArguments. */
|
||||
export const CALL_ARGUMENT_LIST_TYPES = new Set([
|
||||
'arguments',
|
||||
'argument_list',
|
||||
'value_arguments',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract parameter count and return type text from an AST method/function node.
|
||||
* Works across languages by looking for common AST patterns.
|
||||
*/
|
||||
export const extractMethodSignature = (node: SyntaxNode | null | undefined): MethodSignature => {
|
||||
let parameterCount: number | undefined = 0;
|
||||
let requiredCount = 0;
|
||||
let returnType: string | undefined;
|
||||
let isVariadic = false;
|
||||
const paramTypes: string[] = [];
|
||||
|
||||
if (!node) return { parameterCount, requiredParameterCount: undefined, parameterTypes: undefined, returnType };
|
||||
|
||||
const paramListTypes = new Set([
|
||||
'formal_parameters', 'parameters', 'parameter_list',
|
||||
'function_parameters', 'method_parameters', 'function_value_parameters',
|
||||
]);
|
||||
|
||||
// Node types that indicate variadic/rest parameters
|
||||
const VARIADIC_PARAM_TYPES = new Set([
|
||||
'variadic_parameter_declaration', // Go: ...string
|
||||
'variadic_parameter', // Rust: extern "C" fn(...)
|
||||
'spread_parameter', // Java: Object... args
|
||||
'list_splat_pattern', // Python: *args
|
||||
'dictionary_splat_pattern', // Python: **kwargs
|
||||
]);
|
||||
|
||||
/** AST node types that represent parameters with default values. */
|
||||
const OPTIONAL_PARAM_TYPES = new Set([
|
||||
'optional_parameter', // TypeScript, Ruby: (x?: number), (x: number = 5), def f(x = 5)
|
||||
'default_parameter', // Python: def f(x=5)
|
||||
'typed_default_parameter', // Python: def f(x: int = 5)
|
||||
'optional_parameter_declaration', // C++: void f(int x = 5)
|
||||
]);
|
||||
|
||||
/** Check if a parameter node has a default value (handles Kotlin, C#, Swift, PHP
|
||||
* where defaults are expressed as child nodes rather than distinct node types). */
|
||||
const hasDefaultValue = (paramNode: SyntaxNode): boolean => {
|
||||
if (OPTIONAL_PARAM_TYPES.has(paramNode.type)) return true;
|
||||
// C#, Swift, PHP: check for '=' token or equals_value_clause child
|
||||
for (let i = 0; i < paramNode.childCount; i++) {
|
||||
const c = paramNode.child(i);
|
||||
if (!c) continue;
|
||||
if (c.type === '=' || c.type === 'equals_value_clause') return true;
|
||||
}
|
||||
// Kotlin: default values are siblings of the parameter node, not children.
|
||||
// The AST is: parameter, =, <literal> — all at function_value_parameters level.
|
||||
// Check if the immediately following sibling is '=' (default value separator).
|
||||
const sib = paramNode.nextSibling;
|
||||
if (sib && sib.type === '=') return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const findParameterList = (current: SyntaxNode): SyntaxNode | null => {
|
||||
for (const child of current.children) {
|
||||
if (paramListTypes.has(child.type)) return child;
|
||||
}
|
||||
for (const child of current.children) {
|
||||
const nested = findParameterList(child);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parameterList = (
|
||||
paramListTypes.has(node.type) ? node // node itself IS the parameter list (e.g. C# primary constructors)
|
||||
: node.childForFieldName?.('parameters')
|
||||
?? findParameterList(node)
|
||||
);
|
||||
|
||||
if (parameterList && paramListTypes.has(parameterList.type)) {
|
||||
for (const param of parameterList.namedChildren) {
|
||||
if (param.type === 'comment') continue;
|
||||
if (param.text === 'self' || param.text === '&self' || param.text === '&mut self' ||
|
||||
param.type === 'self_parameter') {
|
||||
continue;
|
||||
}
|
||||
// Kotlin: default values are siblings of the parameter node inside
|
||||
// function_value_parameters, so they appear as named children (e.g.
|
||||
// string_literal, integer_literal, boolean_literal, call_expression).
|
||||
// Skip any named child that isn't a parameter-like or modifier node.
|
||||
if (param.type.endsWith('_literal') || param.type === 'call_expression'
|
||||
|| param.type === 'navigation_expression' || param.type === 'prefix_expression'
|
||||
|| param.type === 'parenthesized_expression') {
|
||||
continue;
|
||||
}
|
||||
// Check for variadic parameter types
|
||||
if (VARIADIC_PARAM_TYPES.has(param.type)) {
|
||||
isVariadic = true;
|
||||
continue;
|
||||
}
|
||||
// TypeScript/JavaScript: rest parameter — required_parameter containing rest_pattern
|
||||
if (param.type === 'required_parameter' || param.type === 'optional_parameter') {
|
||||
for (const child of param.children) {
|
||||
if (child.type === 'rest_pattern') {
|
||||
isVariadic = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isVariadic) continue;
|
||||
}
|
||||
// Kotlin: vararg modifier on a regular parameter
|
||||
if (param.type === 'parameter' || param.type === 'formal_parameter') {
|
||||
const prev = param.previousSibling;
|
||||
if (prev?.type === 'parameter_modifiers' && prev.text.includes('vararg')) {
|
||||
isVariadic = true;
|
||||
}
|
||||
}
|
||||
// Extract parameter type name for overload disambiguation.
|
||||
// Works for Java (formal_parameter), Kotlin (parameter), C# (parameter),
|
||||
// C++ (parameter_declaration). Uses childForFieldName('type') which is the
|
||||
// standard tree-sitter field for typed parameters across these languages.
|
||||
// Kotlin uses positional children instead of 'type' field — fall back to
|
||||
// searching for user_type/nullable_type/predefined_type children.
|
||||
const paramTypeNode = param.childForFieldName('type');
|
||||
if (paramTypeNode) {
|
||||
const typeName = extractSimpleTypeName(paramTypeNode);
|
||||
paramTypes.push(typeName ?? 'unknown');
|
||||
} else {
|
||||
// Kotlin: parameter → [simple_identifier, user_type|nullable_type]
|
||||
let found = false;
|
||||
for (const child of param.namedChildren) {
|
||||
if (child.type === 'user_type' || child.type === 'nullable_type'
|
||||
|| child.type === 'type_identifier' || child.type === 'predefined_type') {
|
||||
const typeName = extractSimpleTypeName(child);
|
||||
paramTypes.push(typeName ?? 'unknown');
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) paramTypes.push('unknown');
|
||||
}
|
||||
if (!hasDefaultValue(param)) requiredCount++;
|
||||
parameterCount++;
|
||||
}
|
||||
// C/C++: bare `...` token in parameter list (not a named child — check all children)
|
||||
if (!isVariadic) {
|
||||
for (const child of parameterList.children) {
|
||||
if (!child.isNamed && child.text === '...') {
|
||||
isVariadic = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return type extraction — language-specific field names
|
||||
// Go: 'result' field is either a type_identifier or parameter_list (multi-return)
|
||||
const goResult = node.childForFieldName?.('result');
|
||||
if (goResult) {
|
||||
if (goResult.type === 'parameter_list') {
|
||||
// Multi-return: extract first parameter's type only (e.g. (*User, error) → *User)
|
||||
const firstParam = goResult.firstNamedChild;
|
||||
if (firstParam?.type === 'parameter_declaration') {
|
||||
const typeNode = firstParam.childForFieldName('type');
|
||||
if (typeNode) returnType = typeNode.text;
|
||||
} else if (firstParam) {
|
||||
// Unnamed return types: (string, error) — first child is a bare type node
|
||||
returnType = firstParam.text;
|
||||
}
|
||||
} else {
|
||||
returnType = goResult.text;
|
||||
}
|
||||
}
|
||||
|
||||
// Rust: 'return_type' field — the value IS the type node (e.g. primitive_type, type_identifier).
|
||||
// Skip if the node is a type_annotation (TS/Python), which is handled by the generic loop below.
|
||||
if (!returnType) {
|
||||
const rustReturn = node.childForFieldName?.('return_type');
|
||||
if (rustReturn && rustReturn.type !== 'type_annotation') {
|
||||
returnType = rustReturn.text;
|
||||
}
|
||||
}
|
||||
|
||||
// C/C++: 'type' field on function_definition
|
||||
if (!returnType) {
|
||||
const cppType = node.childForFieldName?.('type');
|
||||
if (cppType && cppType.text !== 'void') {
|
||||
returnType = cppType.text;
|
||||
}
|
||||
}
|
||||
|
||||
// C#: 'returns' field on method_declaration
|
||||
if (!returnType) {
|
||||
const csReturn = node.childForFieldName?.('returns');
|
||||
if (csReturn && csReturn.text !== 'void') {
|
||||
returnType = csReturn.text;
|
||||
}
|
||||
}
|
||||
|
||||
// TS/Rust/Python/C#/Kotlin: type_annotation or return_type child
|
||||
if (!returnType) {
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'type_annotation' || child.type === 'return_type') {
|
||||
const typeNode = child.children.find((c) => c.isNamed);
|
||||
if (typeNode) returnType = typeNode.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kotlin: fun getUser(): User — return type is a bare user_type child of
|
||||
// function_declaration. The Kotlin grammar does NOT wrap it in type_annotation
|
||||
// or return_type; it appears as a direct child after function_value_parameters.
|
||||
// Note: Kotlin uses function_value_parameters (not a field), so we find it by type.
|
||||
if (!returnType) {
|
||||
let paramsEnd = -1;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
if (child.type === 'function_value_parameters' || child.type === 'value_parameters') {
|
||||
paramsEnd = child.endIndex;
|
||||
}
|
||||
if (paramsEnd >= 0 && child.type === 'user_type' && child.startIndex > paramsEnd) {
|
||||
returnType = child.text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isVariadic) parameterCount = undefined;
|
||||
|
||||
// Only include parameterTypes when at least one type was successfully extracted.
|
||||
// Use undefined (not []) to avoid empty array allocations for untyped parameters.
|
||||
const hasTypes = paramTypes.length > 0 && paramTypes.some(t => t !== 'unknown');
|
||||
// Only set requiredParameterCount when it differs from total — saves memory on the common case.
|
||||
const requiredParameterCount = (!isVariadic && requiredCount < (parameterCount ?? 0))
|
||||
? requiredCount : undefined;
|
||||
return { parameterCount, requiredParameterCount, parameterTypes: hasTypes ? paramTypes : undefined, returnType };
|
||||
};
|
||||
|
||||
@@ -1,539 +0,0 @@
|
||||
import type { SyntaxNode } from './ast-helpers.js';
|
||||
import { CALL_ARGUMENT_LIST_TYPES } from './ast-helpers.js';
|
||||
|
||||
/** Node types representing call expressions across supported languages. */
|
||||
export const CALL_EXPRESSION_TYPES = new Set([
|
||||
'call_expression', // TS/JS/C/C++/Go/Rust
|
||||
'method_invocation', // Java
|
||||
'member_call_expression', // PHP
|
||||
'nullsafe_member_call_expression', // PHP ?.
|
||||
'call', // Python/Ruby
|
||||
'invocation_expression', // C#
|
||||
]);
|
||||
|
||||
/**
|
||||
* Hard limit on chain depth to prevent runaway recursion.
|
||||
* For `a.b().c().d()`, the chain has depth 2 (b and c before d).
|
||||
*/
|
||||
export const MAX_CHAIN_DEPTH = 3;
|
||||
|
||||
/**
|
||||
* Count direct arguments for a call expression across common tree-sitter grammars.
|
||||
* Returns undefined when the argument container cannot be located cheaply.
|
||||
*/
|
||||
export const countCallArguments = (callNode: SyntaxNode | null | undefined): number | undefined => {
|
||||
if (!callNode) return undefined;
|
||||
|
||||
// Direct field or direct child (most languages)
|
||||
let argsNode: SyntaxNode | null | undefined = callNode.childForFieldName('arguments')
|
||||
?? callNode.children.find((child) => CALL_ARGUMENT_LIST_TYPES.has(child.type));
|
||||
|
||||
// Kotlin/Swift: call_expression → call_suffix → value_arguments
|
||||
// Search one level deeper for languages that wrap arguments in a suffix node
|
||||
if (!argsNode) {
|
||||
for (const child of callNode.children) {
|
||||
if (!child.isNamed) continue;
|
||||
const nested = child.children.find((gc) => CALL_ARGUMENT_LIST_TYPES.has(gc.type));
|
||||
if (nested) { argsNode = nested; break; }
|
||||
}
|
||||
}
|
||||
|
||||
if (!argsNode) return undefined;
|
||||
|
||||
let count = 0;
|
||||
for (const child of argsNode.children) {
|
||||
if (!child.isNamed) continue;
|
||||
if (child.type === 'comment') continue;
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
// ── Call-form discrimination (Phase 1, Step D) ─────────────────────────
|
||||
|
||||
/**
|
||||
* AST node types that indicate a member-access wrapper around the callee name.
|
||||
* When nameNode.parent.type is one of these, the call is a member call.
|
||||
*/
|
||||
const MEMBER_ACCESS_NODE_TYPES = new Set([
|
||||
'member_expression', // TS/JS: obj.method()
|
||||
'attribute', // Python: obj.method()
|
||||
'member_access_expression', // C#: obj.Method()
|
||||
'field_expression', // Rust/C++: obj.method() / ptr->method()
|
||||
'selector_expression', // Go: obj.Method()
|
||||
'navigation_suffix', // Kotlin/Swift: obj.method() — nameNode sits inside navigation_suffix
|
||||
'member_binding_expression', // C#: user?.Method() — null-conditional access
|
||||
]);
|
||||
|
||||
/**
|
||||
* Call node types that are inherently constructor invocations.
|
||||
* Only includes patterns that the tree-sitter queries already capture as @call.
|
||||
*/
|
||||
const CONSTRUCTOR_CALL_NODE_TYPES = new Set([
|
||||
'constructor_invocation', // Kotlin: Foo()
|
||||
'new_expression', // TS/JS/C++: new Foo()
|
||||
'object_creation_expression', // Java/C#/PHP: new Foo()
|
||||
'implicit_object_creation_expression', // C# 9: User u = new(...)
|
||||
'composite_literal', // Go: User{...}
|
||||
'struct_expression', // Rust: User { ... }
|
||||
]);
|
||||
|
||||
/**
|
||||
* AST node types for scoped/qualified calls (e.g., Foo::new() in Rust, Foo::bar() in C++).
|
||||
*/
|
||||
const SCOPED_CALL_NODE_TYPES = new Set([
|
||||
'scoped_identifier', // Rust: Foo::new()
|
||||
'qualified_identifier', // C++: ns::func()
|
||||
]);
|
||||
|
||||
type CallForm = 'free' | 'member' | 'constructor';
|
||||
|
||||
/**
|
||||
* Infer whether a captured call site is a free call, member call, or constructor.
|
||||
* Returns undefined if the form cannot be determined.
|
||||
*
|
||||
* Works by inspecting the AST structure between callNode (@call) and nameNode (@call.name).
|
||||
* No tree-sitter query changes needed — the distinction is in the node types.
|
||||
*/
|
||||
export const inferCallForm = (
|
||||
callNode: SyntaxNode,
|
||||
nameNode: SyntaxNode,
|
||||
): CallForm | undefined => {
|
||||
// 1. Constructor: callNode itself is a constructor invocation (Kotlin)
|
||||
if (CONSTRUCTOR_CALL_NODE_TYPES.has(callNode.type)) {
|
||||
return 'constructor';
|
||||
}
|
||||
|
||||
// 2. Member call: nameNode's parent is a member-access wrapper
|
||||
const nameParent = nameNode.parent;
|
||||
if (nameParent && MEMBER_ACCESS_NODE_TYPES.has(nameParent.type)) {
|
||||
return 'member';
|
||||
}
|
||||
|
||||
// 3. PHP: the callNode itself distinguishes member vs free calls
|
||||
if (callNode.type === 'member_call_expression' || callNode.type === 'nullsafe_member_call_expression') {
|
||||
return 'member';
|
||||
}
|
||||
if (callNode.type === 'scoped_call_expression') {
|
||||
return 'member'; // static call Foo::bar()
|
||||
}
|
||||
|
||||
// 4. Java method_invocation: member if it has an 'object' field
|
||||
if (callNode.type === 'method_invocation' && callNode.childForFieldName('object')) {
|
||||
return 'member';
|
||||
}
|
||||
|
||||
// 4b. Ruby call with receiver: obj.method
|
||||
if (callNode.type === 'call' && callNode.childForFieldName('receiver')) {
|
||||
return 'member';
|
||||
}
|
||||
|
||||
// 5. Scoped calls (Rust Foo::new(), C++ ns::func()): treat as free
|
||||
// The receiver is a type, not an instance — handled differently in Phase 3
|
||||
if (nameParent && SCOPED_CALL_NODE_TYPES.has(nameParent.type)) {
|
||||
return 'free';
|
||||
}
|
||||
|
||||
// 6. Default: if nameNode is a direct child of callNode, it's a free call
|
||||
if (nameNode.parent === callNode || nameParent?.parent === callNode) {
|
||||
return 'free';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the receiver identifier for member calls.
|
||||
* Only captures simple identifiers — returns undefined for complex expressions
|
||||
* like getUser().save() or arr[0].method().
|
||||
*/
|
||||
const SIMPLE_RECEIVER_TYPES = new Set([
|
||||
'identifier',
|
||||
'simple_identifier',
|
||||
'variable_name', // PHP $variable (tree-sitter-php)
|
||||
'name', // PHP name node
|
||||
'this', // TS/JS/Java/C# this.method()
|
||||
'self', // Rust/Python self.method()
|
||||
'super', // TS/JS/Java/Kotlin/Ruby super.method()
|
||||
'super_expression', // Kotlin wraps super in super_expression
|
||||
'base', // C# base.Method()
|
||||
'parent', // PHP parent::method()
|
||||
'constant', // Ruby CONSTANT.method() (uppercase identifiers)
|
||||
]);
|
||||
|
||||
export const extractReceiverName = (
|
||||
nameNode: SyntaxNode,
|
||||
): string | undefined => {
|
||||
const parent = nameNode.parent;
|
||||
if (!parent) return undefined;
|
||||
|
||||
// PHP: member_call_expression / nullsafe_member_call_expression — receiver is on the callNode
|
||||
// Java: method_invocation — receiver is the 'object' field on callNode
|
||||
// For these, parent of nameNode is the call itself, so check the call's object field
|
||||
const callNode = parent.parent ?? parent;
|
||||
|
||||
let receiver: SyntaxNode | null = null;
|
||||
|
||||
// Try standard field names used across grammars
|
||||
receiver = parent.childForFieldName('object') // TS/JS member_expression, Python attribute, PHP, Java
|
||||
?? parent.childForFieldName('value') // Rust field_expression
|
||||
?? parent.childForFieldName('operand') // Go selector_expression
|
||||
?? parent.childForFieldName('expression') // C# member_access_expression
|
||||
?? parent.childForFieldName('argument'); // C++ field_expression
|
||||
|
||||
// Java method_invocation: 'object' field is on the callNode, not on nameNode's parent
|
||||
if (!receiver && callNode.type === 'method_invocation') {
|
||||
receiver = callNode.childForFieldName('object');
|
||||
}
|
||||
|
||||
// PHP: member_call_expression has 'object' on the call node
|
||||
if (!receiver && (callNode.type === 'member_call_expression' || callNode.type === 'nullsafe_member_call_expression')) {
|
||||
receiver = callNode.childForFieldName('object');
|
||||
}
|
||||
|
||||
// Ruby: call node has 'receiver' field
|
||||
if (!receiver && parent.type === 'call') {
|
||||
receiver = parent.childForFieldName('receiver');
|
||||
}
|
||||
|
||||
// PHP scoped_call_expression (parent::method(), self::method()):
|
||||
// nameNode's direct parent IS the scoped_call_expression (name is a direct child)
|
||||
if (!receiver && (parent.type === 'scoped_call_expression' || callNode.type === 'scoped_call_expression')) {
|
||||
const scopedCall = parent.type === 'scoped_call_expression' ? parent : callNode;
|
||||
receiver = scopedCall.childForFieldName('scope');
|
||||
// relative_scope wraps 'parent'/'self'/'static' — unwrap to get the keyword
|
||||
if (receiver?.type === 'relative_scope') {
|
||||
receiver = receiver.firstChild;
|
||||
}
|
||||
}
|
||||
|
||||
// C# null-conditional: user?.Save() → conditional_access_expression wraps member_binding_expression
|
||||
if (!receiver && parent.type === 'member_binding_expression') {
|
||||
const condAccess = parent.parent;
|
||||
if (condAccess?.type === 'conditional_access_expression') {
|
||||
receiver = condAccess.firstNamedChild;
|
||||
}
|
||||
}
|
||||
|
||||
// Kotlin/Swift: navigation_expression target is the first child
|
||||
if (!receiver && parent.type === 'navigation_suffix') {
|
||||
const navExpr = parent.parent;
|
||||
if (navExpr?.type === 'navigation_expression') {
|
||||
// First named child is the target (receiver)
|
||||
for (const child of navExpr.children) {
|
||||
if (child.isNamed && child !== parent) {
|
||||
receiver = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!receiver) return undefined;
|
||||
|
||||
// Only capture simple identifiers — refuse complex expressions
|
||||
if (SIMPLE_RECEIVER_TYPES.has(receiver.type)) {
|
||||
return receiver.text;
|
||||
}
|
||||
|
||||
// Python super().method(): receiver is a call node `super()` — extract the function name
|
||||
if (receiver.type === 'call') {
|
||||
const func = receiver.childForFieldName('function');
|
||||
if (func?.text === 'super') return 'super';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the raw receiver AST node for a member call.
|
||||
* Unlike extractReceiverName, this returns the receiver node regardless of its type —
|
||||
* including call_expression / method_invocation nodes that appear in chained calls
|
||||
* like `svc.getUser().save()`.
|
||||
*
|
||||
* Returns undefined when the call is not a member call or when no receiver node
|
||||
* can be found (e.g. top-level free calls).
|
||||
*/
|
||||
export const extractReceiverNode = (
|
||||
nameNode: SyntaxNode,
|
||||
): SyntaxNode | undefined => {
|
||||
const parent = nameNode.parent;
|
||||
if (!parent) return undefined;
|
||||
|
||||
const callNode = parent.parent ?? parent;
|
||||
|
||||
let receiver: SyntaxNode | null = null;
|
||||
|
||||
receiver = parent.childForFieldName('object')
|
||||
?? parent.childForFieldName('value')
|
||||
?? parent.childForFieldName('operand')
|
||||
?? parent.childForFieldName('expression')
|
||||
?? parent.childForFieldName('argument');
|
||||
|
||||
if (!receiver && callNode.type === 'method_invocation') {
|
||||
receiver = callNode.childForFieldName('object');
|
||||
}
|
||||
|
||||
if (!receiver && (callNode.type === 'member_call_expression' || callNode.type === 'nullsafe_member_call_expression')) {
|
||||
receiver = callNode.childForFieldName('object');
|
||||
}
|
||||
|
||||
if (!receiver && parent.type === 'call') {
|
||||
receiver = parent.childForFieldName('receiver');
|
||||
}
|
||||
|
||||
if (!receiver && (parent.type === 'scoped_call_expression' || callNode.type === 'scoped_call_expression')) {
|
||||
const scopedCall = parent.type === 'scoped_call_expression' ? parent : callNode;
|
||||
receiver = scopedCall.childForFieldName('scope');
|
||||
if (receiver?.type === 'relative_scope') {
|
||||
receiver = receiver.firstChild;
|
||||
}
|
||||
}
|
||||
|
||||
if (!receiver && parent.type === 'member_binding_expression') {
|
||||
const condAccess = parent.parent;
|
||||
if (condAccess?.type === 'conditional_access_expression') {
|
||||
receiver = condAccess.firstNamedChild;
|
||||
}
|
||||
}
|
||||
|
||||
if (!receiver && parent.type === 'navigation_suffix') {
|
||||
const navExpr = parent.parent;
|
||||
if (navExpr?.type === 'navigation_expression') {
|
||||
for (const child of navExpr.children) {
|
||||
if (child.isNamed && child !== parent) {
|
||||
receiver = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return receiver ?? undefined;
|
||||
};
|
||||
|
||||
// ── Chained-call extraction ───────────────────────────────────────────────
|
||||
|
||||
/** Node types representing member/field access across languages. */
|
||||
const FIELD_ACCESS_NODE_TYPES = new Set([
|
||||
'member_expression', // TS/JS
|
||||
'member_access_expression', // C#
|
||||
'selector_expression', // Go
|
||||
'field_expression', // Rust/C++
|
||||
'field_access', // Java
|
||||
'attribute', // Python
|
||||
'navigation_expression', // Kotlin/Swift
|
||||
'member_binding_expression', // C# null-conditional (user?.Address)
|
||||
]);
|
||||
|
||||
/** One step in a mixed receiver chain. */
|
||||
export type MixedChainStep = { kind: 'field' | 'call'; name: string };
|
||||
|
||||
/**
|
||||
* Walk a receiver AST node that is itself a call expression, accumulating the
|
||||
* chain of intermediate method names up to MAX_CHAIN_DEPTH.
|
||||
*
|
||||
* For `svc.getUser().save()`, called with the receiver of `save` (getUser() call):
|
||||
* returns { chain: ['getUser'], baseReceiverName: 'svc' }
|
||||
*
|
||||
* For `a.b().c().d()`, called with the receiver of `d` (c() call):
|
||||
* returns { chain: ['b', 'c'], baseReceiverName: 'a' }
|
||||
*/
|
||||
export function extractCallChain(
|
||||
receiverCallNode: SyntaxNode,
|
||||
): { chain: string[]; baseReceiverName: string | undefined } | undefined {
|
||||
const chain: string[] = [];
|
||||
let current: SyntaxNode = receiverCallNode;
|
||||
|
||||
while (CALL_EXPRESSION_TYPES.has(current.type) && chain.length < MAX_CHAIN_DEPTH) {
|
||||
// Extract the method name from this call node.
|
||||
const funcNode = current.childForFieldName?.('function')
|
||||
?? current.childForFieldName?.('name')
|
||||
?? current.childForFieldName?.('method'); // Ruby `call` node
|
||||
let methodName: string | undefined;
|
||||
let innerReceiver: SyntaxNode | null = null;
|
||||
if (funcNode) {
|
||||
// member_expression / attribute: last named child is the method identifier
|
||||
methodName = funcNode.lastNamedChild?.text ?? funcNode.text;
|
||||
}
|
||||
// Kotlin/Swift: call_expression exposes callee as firstNamedChild, not a field.
|
||||
// navigation_expression: method name is in navigation_suffix → simple_identifier.
|
||||
if (!funcNode && current.type === 'call_expression') {
|
||||
const callee = current.firstNamedChild;
|
||||
if (callee?.type === 'navigation_expression') {
|
||||
const suffix = callee.lastNamedChild;
|
||||
if (suffix?.type === 'navigation_suffix') {
|
||||
methodName = suffix.lastNamedChild?.text;
|
||||
// The receiver is the part of navigation_expression before the suffix
|
||||
for (let i = 0; i < callee.namedChildCount; i++) {
|
||||
const child = callee.namedChild(i);
|
||||
if (child && child.type !== 'navigation_suffix') {
|
||||
innerReceiver = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!methodName) break;
|
||||
chain.unshift(methodName); // build chain outermost-last
|
||||
|
||||
// Walk into the receiver of this call to continue the chain
|
||||
if (!innerReceiver && funcNode) {
|
||||
innerReceiver = funcNode.childForFieldName?.('object')
|
||||
?? funcNode.childForFieldName?.('value')
|
||||
?? funcNode.childForFieldName?.('operand')
|
||||
?? funcNode.childForFieldName?.('expression');
|
||||
}
|
||||
// Java method_invocation: object field is on the call node
|
||||
if (!innerReceiver && current.type === 'method_invocation') {
|
||||
innerReceiver = current.childForFieldName?.('object');
|
||||
}
|
||||
// PHP member_call_expression
|
||||
if (!innerReceiver && (current.type === 'member_call_expression' || current.type === 'nullsafe_member_call_expression')) {
|
||||
innerReceiver = current.childForFieldName?.('object');
|
||||
}
|
||||
// Ruby `call` node: receiver field is on the call node itself
|
||||
if (!innerReceiver && current.type === 'call') {
|
||||
innerReceiver = current.childForFieldName?.('receiver');
|
||||
}
|
||||
|
||||
if (!innerReceiver) break;
|
||||
|
||||
if (CALL_EXPRESSION_TYPES.has(innerReceiver.type)) {
|
||||
current = innerReceiver; // continue walking
|
||||
} else {
|
||||
// Reached a simple identifier — the base receiver
|
||||
return { chain, baseReceiverName: innerReceiver.text || undefined };
|
||||
}
|
||||
}
|
||||
|
||||
return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a receiver AST node that may interleave field accesses and method calls,
|
||||
* building a unified chain of steps up to MAX_CHAIN_DEPTH.
|
||||
*
|
||||
* For `svc.getUser().address.save()`, called with the receiver of `save`
|
||||
* (`svc.getUser().address`, a field access node):
|
||||
* returns { chain: [{ kind:'call', name:'getUser' }, { kind:'field', name:'address' }],
|
||||
* baseReceiverName: 'svc' }
|
||||
*
|
||||
* For `user.getAddress().city.getName()`, called with receiver of `getName`
|
||||
* (`user.getAddress().city`):
|
||||
* returns { chain: [{ kind:'call', name:'getAddress' }, { kind:'field', name:'city' }],
|
||||
* baseReceiverName: 'user' }
|
||||
*
|
||||
* Pure field chains and pure call chains are special cases (all steps same kind).
|
||||
*/
|
||||
export function extractMixedChain(
|
||||
receiverNode: SyntaxNode,
|
||||
): { chain: MixedChainStep[]; baseReceiverName: string | undefined } | undefined {
|
||||
const chain: MixedChainStep[] = [];
|
||||
let current: SyntaxNode = receiverNode;
|
||||
|
||||
while (chain.length < MAX_CHAIN_DEPTH) {
|
||||
if (CALL_EXPRESSION_TYPES.has(current.type)) {
|
||||
// ── Call expression: extract method name + inner receiver ────────────
|
||||
const funcNode = current.childForFieldName?.('function')
|
||||
?? current.childForFieldName?.('name')
|
||||
?? current.childForFieldName?.('method');
|
||||
let methodName: string | undefined;
|
||||
let innerReceiver: SyntaxNode | null = null;
|
||||
|
||||
if (funcNode) {
|
||||
methodName = funcNode.lastNamedChild?.text ?? funcNode.text;
|
||||
}
|
||||
// Kotlin/Swift: call_expression → navigation_expression
|
||||
if (!funcNode && current.type === 'call_expression') {
|
||||
const callee = current.firstNamedChild;
|
||||
if (callee?.type === 'navigation_expression') {
|
||||
const suffix = callee.lastNamedChild;
|
||||
if (suffix?.type === 'navigation_suffix') {
|
||||
methodName = suffix.lastNamedChild?.text;
|
||||
for (let i = 0; i < callee.namedChildCount; i++) {
|
||||
const child = callee.namedChild(i);
|
||||
if (child && child.type !== 'navigation_suffix') { innerReceiver = child; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!methodName) break;
|
||||
chain.unshift({ kind: 'call', name: methodName });
|
||||
|
||||
if (!innerReceiver && funcNode) {
|
||||
innerReceiver = funcNode.childForFieldName?.('object')
|
||||
?? funcNode.childForFieldName?.('value')
|
||||
?? funcNode.childForFieldName?.('operand')
|
||||
?? funcNode.childForFieldName?.('argument') // C/C++ field_expression
|
||||
?? funcNode.childForFieldName?.('expression')
|
||||
?? null;
|
||||
}
|
||||
if (!innerReceiver && current.type === 'method_invocation') {
|
||||
innerReceiver = current.childForFieldName?.('object') ?? null;
|
||||
}
|
||||
if (!innerReceiver && (current.type === 'member_call_expression' || current.type === 'nullsafe_member_call_expression')) {
|
||||
innerReceiver = current.childForFieldName?.('object') ?? null;
|
||||
}
|
||||
if (!innerReceiver && current.type === 'call') {
|
||||
innerReceiver = current.childForFieldName?.('receiver') ?? null;
|
||||
}
|
||||
if (!innerReceiver) break;
|
||||
|
||||
if (CALL_EXPRESSION_TYPES.has(innerReceiver.type) || FIELD_ACCESS_NODE_TYPES.has(innerReceiver.type)) {
|
||||
current = innerReceiver;
|
||||
} else {
|
||||
return { chain, baseReceiverName: innerReceiver.text || undefined };
|
||||
}
|
||||
} else if (FIELD_ACCESS_NODE_TYPES.has(current.type)) {
|
||||
// ── Field/member access: extract property name + inner object ─────────
|
||||
let propertyName: string | undefined;
|
||||
let innerObject: SyntaxNode | null = null;
|
||||
|
||||
if (current.type === 'navigation_expression') {
|
||||
for (const child of current.children ?? []) {
|
||||
if (child.type === 'navigation_suffix') {
|
||||
for (const sc of child.children ?? []) {
|
||||
if (sc.isNamed && sc.type !== '.') { propertyName = sc.text; break; }
|
||||
}
|
||||
} else if (child.isNamed && !innerObject) {
|
||||
innerObject = child;
|
||||
}
|
||||
}
|
||||
} else if (current.type === 'attribute') {
|
||||
innerObject = current.childForFieldName?.('object') ?? null;
|
||||
propertyName = current.childForFieldName?.('attribute')?.text;
|
||||
} else {
|
||||
innerObject = current.childForFieldName?.('object')
|
||||
?? current.childForFieldName?.('value')
|
||||
?? current.childForFieldName?.('operand')
|
||||
?? current.childForFieldName?.('argument') // C/C++ field_expression
|
||||
?? current.childForFieldName?.('expression')
|
||||
?? null;
|
||||
propertyName = (current.childForFieldName?.('property')
|
||||
?? current.childForFieldName?.('field')
|
||||
?? current.childForFieldName?.('name'))?.text;
|
||||
}
|
||||
|
||||
if (!propertyName) break;
|
||||
chain.unshift({ kind: 'field', name: propertyName });
|
||||
|
||||
if (!innerObject) break;
|
||||
|
||||
if (CALL_EXPRESSION_TYPES.has(innerObject.type) || FIELD_ACCESS_NODE_TYPES.has(innerObject.type)) {
|
||||
current = innerObject;
|
||||
} else {
|
||||
return { chain, baseReceiverName: innerObject.text || undefined };
|
||||
}
|
||||
} else {
|
||||
// Simple identifier — this is the base receiver
|
||||
return chain.length > 0
|
||||
? { chain, baseReceiverName: current.text || undefined }
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import { ASTCache } from './ast-cache.js';
|
||||
import type { SymbolDefinition, SymbolTable } from './symbol-table.js';
|
||||
import type { SymbolDefinition } from './symbol-table.js';
|
||||
import Parser from 'tree-sitter';
|
||||
import type { ResolutionContext } from './resolution-context.js';
|
||||
import { TIER_CONFIDENCE, type ResolutionTier } from './resolution-context.js';
|
||||
@@ -33,145 +33,6 @@ import { typeConfigs } from './type-extractors/index.js';
|
||||
import type { LiteralTypeInferrer } from './type-extractors/types.js';
|
||||
import type { SyntaxNode } from './utils.js';
|
||||
|
||||
/** Per-file resolved type bindings for exported symbols.
|
||||
* Populated during call processing, consumed by Phase 14 re-resolution pass. */
|
||||
export type ExportedTypeMap = Map<string, Map<string, string>>;
|
||||
|
||||
const MAX_EXPORTS_PER_FILE = 500;
|
||||
const MAX_TYPE_NAME_LENGTH = 256;
|
||||
|
||||
/** Build a map of imported callee names → return types for cross-file call-result binding.
|
||||
* Consulted ONLY when SymbolTable has no unambiguous local match (local-first principle). */
|
||||
export function buildImportedReturnTypes(
|
||||
filePath: string,
|
||||
namedImportMap: ReadonlyMap<string, ReadonlyMap<string, { sourcePath: string; exportedName: string }>>,
|
||||
symbolTable: { lookupExactFull(filePath: string, name: string): { returnType?: string } | undefined },
|
||||
): ReadonlyMap<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
const fileImports = namedImportMap.get(filePath);
|
||||
if (!fileImports) return result;
|
||||
|
||||
for (const [localName, binding] of fileImports) {
|
||||
const def = symbolTable.lookupExactFull(binding.sourcePath, binding.exportedName);
|
||||
if (!def?.returnType) continue;
|
||||
const simpleReturn = extractReturnTypeName(def.returnType);
|
||||
if (simpleReturn) result.set(localName, simpleReturn);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Build cross-file RAW return types for imported callables.
|
||||
* Unlike buildImportedReturnTypes (which stores extractReturnTypeName output),
|
||||
* this stores the raw declared return type string (e.g., 'User[]', 'List<User>').
|
||||
* Used by lookupRawReturnType for for-loop element extraction via extractElementTypeFromString. */
|
||||
export function buildImportedRawReturnTypes(
|
||||
filePath: string,
|
||||
namedImportMap: ReadonlyMap<string, ReadonlyMap<string, { sourcePath: string; exportedName: string }>>,
|
||||
symbolTable: { lookupExactFull(filePath: string, name: string): { returnType?: string } | undefined },
|
||||
): ReadonlyMap<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
const fileImports = namedImportMap.get(filePath);
|
||||
if (!fileImports) return result;
|
||||
|
||||
for (const [localName, binding] of fileImports) {
|
||||
const def = symbolTable.lookupExactFull(binding.sourcePath, binding.exportedName);
|
||||
if (!def?.returnType) continue;
|
||||
result.set(localName, def.returnType);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Collect resolved type bindings for exported file-scope symbols.
|
||||
* Uses graph node isExported flag — does NOT require isExported on SymbolDefinition. */
|
||||
function collectExportedBindings(
|
||||
typeEnv: { readonly env: ReadonlyMap<string, ReadonlyMap<string, string>> },
|
||||
filePath: string,
|
||||
symbolTable: { lookupExact(filePath: string, name: string): string | undefined },
|
||||
graph: { getNode(id: string): { properties?: { isExported?: boolean } } | undefined },
|
||||
): Map<string, string> | null {
|
||||
const fileScope = typeEnv.env.get('');
|
||||
if (!fileScope || fileScope.size === 0) return null;
|
||||
|
||||
const exported = new Map<string, string>();
|
||||
for (const [varName, typeName] of fileScope) {
|
||||
if (exported.size >= MAX_EXPORTS_PER_FILE) break;
|
||||
if (!typeName || typeName.length > MAX_TYPE_NAME_LENGTH) continue;
|
||||
const nodeId = symbolTable.lookupExact(filePath, varName);
|
||||
if (!nodeId) continue;
|
||||
const node = graph.getNode(nodeId);
|
||||
if (node?.properties?.isExported) {
|
||||
exported.set(varName, typeName);
|
||||
}
|
||||
}
|
||||
return exported.size > 0 ? exported : null;
|
||||
}
|
||||
|
||||
/** Build ExportedTypeMap from graph nodes — used for worker path where TypeEnv
|
||||
* is not available in the main thread. Collects returnType/declaredType from
|
||||
* exported symbols that have callables with known return types. */
|
||||
export function buildExportedTypeMapFromGraph(
|
||||
graph: KnowledgeGraph,
|
||||
symbolTable: SymbolTable,
|
||||
): ExportedTypeMap {
|
||||
const result: ExportedTypeMap = new Map();
|
||||
graph.forEachNode(node => {
|
||||
if (!node.properties?.isExported) return;
|
||||
if (!node.properties?.filePath || !node.properties?.name) return;
|
||||
const filePath = node.properties.filePath as string;
|
||||
const name = node.properties.name as string;
|
||||
if (!name || name.length > MAX_TYPE_NAME_LENGTH) return;
|
||||
// For callable symbols, use returnType; for properties/variables, use declaredType
|
||||
const def = symbolTable.lookupExactFull(filePath, name);
|
||||
if (!def) return;
|
||||
const typeName = def.returnType ?? def.declaredType;
|
||||
if (!typeName || typeName.length > MAX_TYPE_NAME_LENGTH) return;
|
||||
// Extract simple type name (strip Promise<>, etc.) — reuse shared utility
|
||||
const simpleType = extractReturnTypeName(typeName) ?? typeName;
|
||||
if (!simpleType) return;
|
||||
let fileExports = result.get(filePath);
|
||||
if (!fileExports) { fileExports = new Map(); result.set(filePath, fileExports); }
|
||||
if (fileExports.size < MAX_EXPORTS_PER_FILE) {
|
||||
fileExports.set(name, simpleType);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Seed cross-file receiver types into pre-extracted call records.
|
||||
* Fills missing receiverTypeName for single-hop imported variables
|
||||
* using ExportedTypeMap + namedImportMap — zero disk I/O, zero AST re-parsing.
|
||||
* Mutates calls in-place. Runs BEFORE processCallsFromExtracted. */
|
||||
export function seedCrossFileReceiverTypes(
|
||||
calls: ExtractedCall[],
|
||||
namedImportMap: ReadonlyMap<string, ReadonlyMap<string, { sourcePath: string; exportedName: string }>>,
|
||||
exportedTypeMap: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
): { enrichedCount: number } {
|
||||
if (namedImportMap.size === 0 || exportedTypeMap.size === 0) {
|
||||
return { enrichedCount: 0 };
|
||||
}
|
||||
let enrichedCount = 0;
|
||||
for (const call of calls) {
|
||||
if (call.receiverTypeName || !call.receiverName) continue;
|
||||
if (call.callForm !== 'member') continue;
|
||||
|
||||
const fileImports = namedImportMap.get(call.filePath);
|
||||
if (!fileImports) continue;
|
||||
|
||||
const binding = fileImports.get(call.receiverName);
|
||||
if (!binding) continue;
|
||||
|
||||
const upstream = exportedTypeMap.get(binding.sourcePath);
|
||||
if (!upstream) continue;
|
||||
|
||||
const type = upstream.get(binding.exportedName);
|
||||
if (type) {
|
||||
call.receiverTypeName = type;
|
||||
enrichedCount++;
|
||||
}
|
||||
}
|
||||
return { enrichedCount };
|
||||
}
|
||||
|
||||
// Stdlib methods that preserve the receiver's type identity. When TypeEnv already
|
||||
// strips nullable wrappers (Option<User> → User), these chain steps are no-ops
|
||||
// for type resolution — the current type passes through unchanged.
|
||||
@@ -187,7 +48,7 @@ const TYPE_PRESERVING_METHODS = new Set([
|
||||
* Returns null if the call is at module/file level (top-level code).
|
||||
*/
|
||||
const findEnclosingFunction = (
|
||||
node: SyntaxNode,
|
||||
node: any,
|
||||
filePath: string,
|
||||
ctx: ResolutionContext
|
||||
): string | null => {
|
||||
@@ -277,14 +138,6 @@ export const processCalls = async (
|
||||
astCache: ASTCache,
|
||||
ctx: ResolutionContext,
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
exportedTypeMap?: ExportedTypeMap,
|
||||
/** Phase 14: pre-resolved cross-file bindings to seed into buildTypeEnv. Keyed by filePath → Map<localName, typeName>. */
|
||||
importedBindingsMap?: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
/** Phase 14 E3: cross-file return types for imported callables. Keyed by filePath → Map<calleeName, returnType>.
|
||||
* Consulted ONLY when SymbolTable has no unambiguous match (local-first principle). */
|
||||
importedReturnTypesMap?: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
/** Phase 14 E3: cross-file RAW return types for for-loop element extraction. Keyed by filePath → Map<calleeName, rawReturnType>. */
|
||||
importedRawReturnTypesMap?: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
): Promise<ExtractedHeritage[]> => {
|
||||
const parser = await loadParser();
|
||||
const collectedHeritage: ExtractedHeritage[] = [];
|
||||
@@ -370,14 +223,7 @@ export const processCalls = async (
|
||||
}
|
||||
}
|
||||
|
||||
const importedBindings = importedBindingsMap?.get(file.path);
|
||||
const importedReturnTypes = importedReturnTypesMap?.get(file.path);
|
||||
const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path);
|
||||
const typeEnv = lang ? buildTypeEnv(tree, lang, { symbolTable: ctx.symbols, parentMap, importedBindings, importedReturnTypes, importedRawReturnTypes }) : null;
|
||||
if (typeEnv && exportedTypeMap) {
|
||||
const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph);
|
||||
if (fileExports) exportedTypeMap.set(file.path, fileExports);
|
||||
}
|
||||
const typeEnv = lang ? buildTypeEnv(tree, lang, { symbolTable: ctx.symbols, parentMap }) : null;
|
||||
const callRouter = callRouters[language];
|
||||
|
||||
const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0
|
||||
@@ -600,7 +446,6 @@ export const processCalls = async (
|
||||
argCount: countCallArguments(callNode),
|
||||
callForm,
|
||||
receiverTypeName,
|
||||
receiverName,
|
||||
}, file.path, ctx, hints);
|
||||
|
||||
if (!resolved) return;
|
||||
@@ -820,7 +665,7 @@ const tryOverloadDisambiguation = (
|
||||
* If filtering still leaves multiple candidates, refuse to emit a CALLS edge.
|
||||
*/
|
||||
const resolveCallTarget = (
|
||||
call: Pick<ExtractedCall, 'calledName' | 'argCount' | 'callForm' | 'receiverTypeName' | 'receiverName'>,
|
||||
call: Pick<ExtractedCall, 'calledName' | 'argCount' | 'callForm' | 'receiverTypeName'>,
|
||||
currentFile: string,
|
||||
ctx: ResolutionContext,
|
||||
overloadHints?: OverloadHints,
|
||||
@@ -828,29 +673,7 @@ const resolveCallTarget = (
|
||||
const tiered = ctx.resolve(call.calledName, currentFile);
|
||||
if (!tiered) return null;
|
||||
|
||||
let filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm);
|
||||
|
||||
// Module-qualified constructor pattern: e.g. Python `import models; models.User()`.
|
||||
// The attribute access gives callForm='member', but the callee may be a Class — a valid
|
||||
// constructor target. Re-try with constructor-form filtering so that `module.ClassName()`
|
||||
// emits a CALLS edge to the class node.
|
||||
if (filteredCandidates.length === 0 && call.callForm === 'member') {
|
||||
filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, 'constructor');
|
||||
}
|
||||
|
||||
// Module-alias disambiguation: Python `import auth; auth.User()` — when both models.py and
|
||||
// auth.py export User, receiverName='auth' selects auth.py via moduleAliasMap.
|
||||
// Runs when multiple candidates survive filtering and the receiver is a known module alias.
|
||||
if (filteredCandidates.length > 1 && call.callForm === 'member' && call.receiverName) {
|
||||
const aliasMap = ctx.moduleAliasMap?.get(currentFile);
|
||||
if (aliasMap) {
|
||||
const moduleFile = aliasMap.get(call.receiverName);
|
||||
if (moduleFile) {
|
||||
const aliasFiltered = filteredCandidates.filter(c => c.filePath === moduleFile);
|
||||
if (aliasFiltered.length > 0) filteredCandidates = aliasFiltered;
|
||||
}
|
||||
}
|
||||
}
|
||||
const filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm);
|
||||
|
||||
// D. Receiver-type filtering: for member calls with a known receiver type,
|
||||
// resolve the type through the same tiered import infrastructure, then
|
||||
|
||||
@@ -18,12 +18,6 @@ import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
/** null = this call was not routed; fall through to default call handling */
|
||||
export type CallRoutingResult = RubyCallRouting | null;
|
||||
|
||||
/**
|
||||
* Per-language call router.
|
||||
* IMPORTANT: Call-routed imports bypass preprocessImportPath(), so any router that
|
||||
* returns an importPath MUST validate it independently (length cap, control-char
|
||||
* rejection). See routeRubyCall for the reference implementation.
|
||||
*/
|
||||
export type CallRouter = (
|
||||
calledName: string,
|
||||
callNode: any,
|
||||
|
||||
@@ -14,33 +14,30 @@ import { detectFrameworkFromPath } from './framework-detection.js';
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
|
||||
// ============================================================================
|
||||
// NAME PATTERNS - All 13 supported languages
|
||||
// NAME PATTERNS - All 11 supported languages
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Common entry point naming patterns by language.
|
||||
* These patterns indicate functions that are likely feature entry points.
|
||||
*
|
||||
* Universal patterns are separated from per-language patterns so the per-language
|
||||
* table can use `satisfies Record<SupportedLanguages, RegExp[]>` for compile-time
|
||||
* exhaustiveness — the compiler catches any missing language entry.
|
||||
* Common entry point naming patterns by language
|
||||
* These patterns indicate functions that are likely feature entry points
|
||||
*/
|
||||
const UNIVERSAL_ENTRY_POINT_PATTERNS: RegExp[] = [
|
||||
/^(main|init|bootstrap|start|run|setup|configure)$/i,
|
||||
/^handle[A-Z]/, // handleLogin, handleSubmit
|
||||
/^on[A-Z]/, // onClick, onSubmit
|
||||
/Handler$/, // RequestHandler
|
||||
/Controller$/, // UserController
|
||||
/^process[A-Z]/, // processPayment
|
||||
/^execute[A-Z]/, // executeQuery
|
||||
/^perform[A-Z]/, // performAction
|
||||
/^dispatch[A-Z]/, // dispatchEvent
|
||||
/^trigger[A-Z]/, // triggerAction
|
||||
/^fire[A-Z]/, // fireEvent
|
||||
/^emit[A-Z]/, // emitEvent
|
||||
];
|
||||
|
||||
const ENTRY_POINT_PATTERNS = {
|
||||
const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
|
||||
// Universal patterns (apply to all languages)
|
||||
'*': [
|
||||
/^(main|init|bootstrap|start|run|setup|configure)$/i,
|
||||
/^handle[A-Z]/, // handleLogin, handleSubmit
|
||||
/^on[A-Z]/, // onClick, onSubmit
|
||||
/Handler$/, // RequestHandler
|
||||
/Controller$/, // UserController
|
||||
/^process[A-Z]/, // processPayment
|
||||
/^execute[A-Z]/, // executeQuery
|
||||
/^perform[A-Z]/, // performAction
|
||||
/^dispatch[A-Z]/, // dispatchEvent
|
||||
/^trigger[A-Z]/, // triggerAction
|
||||
/^fire[A-Z]/, // fireEvent
|
||||
/^emit[A-Z]/, // emitEvent
|
||||
],
|
||||
|
||||
// JavaScript/TypeScript
|
||||
[SupportedLanguages.JavaScript]: [
|
||||
/^use[A-Z]/, // React hooks (useEffect, etc.)
|
||||
@@ -65,17 +62,6 @@ const ENTRY_POINT_PATTERNS = {
|
||||
/Service$/, // UserService
|
||||
],
|
||||
|
||||
// Kotlin
|
||||
[SupportedLanguages.Kotlin]: [
|
||||
/^on(Create|Start|Resume|Pause|Stop|Destroy)$/, // Android lifecycle
|
||||
/^do[A-Z]/, // doGet, doPost (shared JVM Servlet pattern)
|
||||
/^create[A-Z]/, // Factory patterns
|
||||
/^build[A-Z]/, // Builder patterns
|
||||
/ViewModel$/, // MVVM pattern (Android)
|
||||
/^module$/, // Ktor module entry point
|
||||
/Service$/, // Service classes
|
||||
],
|
||||
|
||||
// C#
|
||||
[SupportedLanguages.CSharp]: [
|
||||
/^(Get|Post|Put|Delete|Patch)/, // ASP.NET action methods
|
||||
@@ -91,7 +77,7 @@ const ENTRY_POINT_PATTERNS = {
|
||||
/Service$/, // Service classes
|
||||
/^Seed/, // Database seeding
|
||||
],
|
||||
|
||||
|
||||
// Go
|
||||
[SupportedLanguages.Go]: [
|
||||
/Handler$/, // http.Handler pattern
|
||||
@@ -99,7 +85,7 @@ const ENTRY_POINT_PATTERNS = {
|
||||
/^New[A-Z]/, // Constructor pattern (returns new instance)
|
||||
/^Make[A-Z]/, // Make functions
|
||||
],
|
||||
|
||||
|
||||
// Rust
|
||||
[SupportedLanguages.Rust]: [
|
||||
/^(get|post|put|delete)_handler$/i,
|
||||
@@ -108,7 +94,7 @@ const ENTRY_POINT_PATTERNS = {
|
||||
/^run$/, // run entry point
|
||||
/^spawn/, // Async spawn
|
||||
],
|
||||
|
||||
|
||||
// C - explicit main() boost plus common C entry point conventions
|
||||
[SupportedLanguages.C]: [
|
||||
/^main$/, // THE entry point
|
||||
@@ -212,15 +198,15 @@ const ENTRY_POINT_PATTERNS = {
|
||||
/^perform$/, // Background jobs (Sidekiq, ActiveJob)
|
||||
/^execute$/, // Command pattern
|
||||
],
|
||||
} satisfies Record<SupportedLanguages, RegExp[]>;
|
||||
};
|
||||
|
||||
/** Pre-computed merged patterns (universal + language-specific) to avoid per-call array allocation. */
|
||||
const MERGED_ENTRY_POINT_PATTERNS = Object.fromEntries(
|
||||
(Object.keys(ENTRY_POINT_PATTERNS) as SupportedLanguages[]).map(lang => [
|
||||
lang,
|
||||
[...UNIVERSAL_ENTRY_POINT_PATTERNS, ...ENTRY_POINT_PATTERNS[lang]],
|
||||
])
|
||||
) as Record<SupportedLanguages, RegExp[]>;
|
||||
const MERGED_ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {};
|
||||
const UNIVERSAL_PATTERNS = ENTRY_POINT_PATTERNS['*'] || [];
|
||||
for (const [lang, patterns] of Object.entries(ENTRY_POINT_PATTERNS)) {
|
||||
if (lang === '*') continue;
|
||||
MERGED_ENTRY_POINT_PATTERNS[lang] = [...UNIVERSAL_PATTERNS, ...patterns];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UTILITY PATTERNS - Functions that should be penalized
|
||||
@@ -309,7 +295,7 @@ export function calculateEntryPointScore(
|
||||
reasons.push('utility-pattern');
|
||||
} else {
|
||||
// Check positive patterns
|
||||
const allPatterns = MERGED_ENTRY_POINT_PATTERNS[language];
|
||||
const allPatterns = MERGED_ENTRY_POINT_PATTERNS[language] || UNIVERSAL_PATTERNS;
|
||||
|
||||
if (allPatterns.some(p => p.test(name))) {
|
||||
nameMultiplier = 1.5; // Bonus for matching entry point pattern
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
* (no bonus, no penalty) - same behavior as before this feature.
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
@@ -236,8 +234,8 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
|
||||
return { framework: 'go-mvc', entryPointMultiplier: 2.5, reason: 'go-controller' };
|
||||
}
|
||||
|
||||
// Go main.go files (THE entry point) — only match main.go, not arbitrary .go files under cmd/
|
||||
if (p.endsWith('/main.go')) {
|
||||
// Go main.go files (THE entry point)
|
||||
if (p.endsWith('/main.go') || p.endsWith('/cmd/') && p.endsWith('.go')) {
|
||||
return { framework: 'go', entryPointMultiplier: 3.0, reason: 'go-main' };
|
||||
}
|
||||
|
||||
@@ -433,37 +431,26 @@ export const FRAMEWORK_AST_PATTERNS = {
|
||||
'blazor': ['@page', '[Parameter]', '@inject'],
|
||||
'efcore': ['DbContext', 'DbSet<', 'OnModelCreating'],
|
||||
|
||||
// Go patterns (function signatures include framework types)
|
||||
'go-http': ['http.Handler', 'http.HandlerFunc', 'ServeHTTP', 'http.ResponseWriter', 'http.Request'],
|
||||
'gin': ['gin.Context', 'gin.Default', 'gin.New'],
|
||||
'echo': ['echo.Context', 'echo.New'],
|
||||
'fiber': ['fiber.Ctx', 'fiber.New', 'fiber.App'],
|
||||
'go-grpc': ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'],
|
||||
// Go patterns (function signatures)
|
||||
'go-http': ['http.Handler', 'http.HandlerFunc', 'ServeHTTP'],
|
||||
|
||||
// PHP/Laravel
|
||||
'laravel': ['Route::get', 'Route::post', 'Route::put', 'Route::delete',
|
||||
'Route::resource', 'Route::apiResource', '#[Route('],
|
||||
|
||||
// Rust macros (proc-macro attributes in definition text)
|
||||
'actix': ['#[get', '#[post', '#[put', '#[delete', '#[actix_web', 'HttpRequest', 'HttpResponse'],
|
||||
'axum': ['Router::new', 'axum::extract', 'axum::routing'],
|
||||
'rocket': ['#[get', '#[post', '#[launch', 'rocket::'],
|
||||
'tokio': ['#[tokio::main]', '#[tokio::test]'],
|
||||
|
||||
// C++ patterns (Qt, Boost)
|
||||
'qt': ['Q_OBJECT', 'Q_INVOKABLE', 'Q_PROPERTY', 'Q_SIGNALS', 'Q_SLOTS', 'Q_SIGNAL', 'Q_SLOT', 'QWidget', 'QApplication'],
|
||||
// Rust macros
|
||||
'actix': ['#[get', '#[post', '#[put', '#[delete'],
|
||||
'axum': ['Router::new'],
|
||||
'rocket': ['#[get', '#[post'],
|
||||
|
||||
// Swift/iOS
|
||||
'uikit': ['viewDidLoad', 'viewWillAppear', 'viewDidAppear', 'UIViewController', '@IBOutlet', '@IBAction', '@objc'],
|
||||
'swiftui': ['@main', 'WindowGroup', 'ContentView', '@StateObject', '@ObservedObject', '@EnvironmentObject', '@Published'],
|
||||
'vapor': ['app.get', 'app.post', 'req.content.decode', 'Vapor'],
|
||||
|
||||
// Ruby patterns (class-level macros in definition text)
|
||||
'rails': ['ApplicationController', 'ApplicationRecord', 'ActiveRecord::Base',
|
||||
'before_action', 'after_action', 'has_many', 'belongs_to', 'has_one', 'validates'],
|
||||
'sinatra': ['Sinatra::Base', 'Sinatra::Application'],
|
||||
'uikit': ['viewDidLoad', 'viewWillAppear', 'viewDidAppear', 'UIViewController'],
|
||||
'swiftui': ['@main', 'WindowGroup', 'ContentView', '@StateObject', '@ObservedObject'],
|
||||
'combine': ['sink', 'assign', 'Publisher', 'Subscriber'],
|
||||
};
|
||||
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
|
||||
interface AstFrameworkPatternConfig {
|
||||
framework: string;
|
||||
entryPointMultiplier: number;
|
||||
@@ -471,7 +458,7 @@ interface AstFrameworkPatternConfig {
|
||||
patterns: string[];
|
||||
}
|
||||
|
||||
const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = {
|
||||
const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record<string, AstFrameworkPatternConfig[]> = {
|
||||
[SupportedLanguages.JavaScript]: [
|
||||
{ framework: 'nestjs', entryPointMultiplier: 3.2, reason: 'nestjs-decorator', patterns: FRAMEWORK_AST_PATTERNS.nestjs },
|
||||
],
|
||||
@@ -501,33 +488,7 @@ const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = {
|
||||
[SupportedLanguages.PHP]: [
|
||||
{ framework: 'laravel', entryPointMultiplier: 3.0, reason: 'php-route-attribute', patterns: FRAMEWORK_AST_PATTERNS.laravel },
|
||||
],
|
||||
[SupportedLanguages.Go]: [
|
||||
{ framework: 'go-http', entryPointMultiplier: 2.5, reason: 'go-http-handler', patterns: FRAMEWORK_AST_PATTERNS['go-http'] },
|
||||
{ framework: 'gin', entryPointMultiplier: 3.0, reason: 'gin-handler', patterns: FRAMEWORK_AST_PATTERNS.gin },
|
||||
{ framework: 'echo', entryPointMultiplier: 3.0, reason: 'echo-handler', patterns: FRAMEWORK_AST_PATTERNS.echo },
|
||||
{ framework: 'fiber', entryPointMultiplier: 3.0, reason: 'fiber-handler', patterns: FRAMEWORK_AST_PATTERNS.fiber },
|
||||
{ framework: 'go-grpc', entryPointMultiplier: 2.8, reason: 'grpc-service', patterns: FRAMEWORK_AST_PATTERNS['go-grpc'] },
|
||||
],
|
||||
[SupportedLanguages.Rust]: [
|
||||
{ framework: 'actix-web', entryPointMultiplier: 3.0, reason: 'actix-attribute', patterns: FRAMEWORK_AST_PATTERNS.actix },
|
||||
{ framework: 'axum', entryPointMultiplier: 3.0, reason: 'axum-routing', patterns: FRAMEWORK_AST_PATTERNS.axum },
|
||||
{ framework: 'rocket', entryPointMultiplier: 3.0, reason: 'rocket-attribute', patterns: FRAMEWORK_AST_PATTERNS.rocket },
|
||||
{ framework: 'tokio', entryPointMultiplier: 2.5, reason: 'tokio-runtime', patterns: FRAMEWORK_AST_PATTERNS.tokio },
|
||||
],
|
||||
[SupportedLanguages.C]: [], // C has no framework-specific AST patterns (POSIX/socket patterns are in entry-point-scoring)
|
||||
[SupportedLanguages.CPlusPlus]: [
|
||||
{ framework: 'qt', entryPointMultiplier: 2.8, reason: 'qt-macro', patterns: FRAMEWORK_AST_PATTERNS.qt },
|
||||
],
|
||||
[SupportedLanguages.Swift]: [
|
||||
{ framework: 'uikit', entryPointMultiplier: 2.5, reason: 'uikit-lifecycle', patterns: FRAMEWORK_AST_PATTERNS.uikit },
|
||||
{ framework: 'swiftui', entryPointMultiplier: 2.8, reason: 'swiftui-pattern', patterns: FRAMEWORK_AST_PATTERNS.swiftui },
|
||||
{ framework: 'vapor', entryPointMultiplier: 3.0, reason: 'vapor-routing', patterns: FRAMEWORK_AST_PATTERNS.vapor },
|
||||
],
|
||||
[SupportedLanguages.Ruby]: [
|
||||
{ framework: 'rails', entryPointMultiplier: 3.0, reason: 'rails-pattern', patterns: FRAMEWORK_AST_PATTERNS.rails },
|
||||
{ framework: 'sinatra', entryPointMultiplier: 2.8, reason: 'sinatra-pattern', patterns: FRAMEWORK_AST_PATTERNS.sinatra },
|
||||
],
|
||||
} satisfies Record<SupportedLanguages, AstFrameworkPatternConfig[]>;
|
||||
};
|
||||
|
||||
/** Pre-lowercased patterns for O(1) pattern matching at runtime */
|
||||
const AST_PATTERNS_LOWERED: Record<string, Array<{ framework: string; entryPointMultiplier: number; reason: string; patterns: string[] }>> =
|
||||
|
||||
@@ -25,7 +25,6 @@ import { SupportedLanguages } from '../../config/supported-languages.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';
|
||||
|
||||
/** C#/Java convention: interfaces start with I followed by an uppercase letter */
|
||||
const INTERFACE_NAME_RE = /^I[A-Z]/;
|
||||
@@ -67,28 +66,22 @@ const resolveExtendsType = (
|
||||
* Resolve a symbol ID for heritage, with fallback to generated ID.
|
||||
* Uses ctx.resolve() → pick first candidate's nodeId → generate synthetic ID.
|
||||
*/
|
||||
interface ResolvedHeritage {
|
||||
readonly id: string;
|
||||
readonly confidence: number;
|
||||
}
|
||||
|
||||
const resolveHeritageId = (
|
||||
name: string,
|
||||
filePath: string,
|
||||
ctx: ResolutionContext,
|
||||
fallbackLabel: string,
|
||||
fallbackKey?: string,
|
||||
): ResolvedHeritage => {
|
||||
): string => {
|
||||
const resolved = ctx.resolve(name, filePath);
|
||||
if (resolved && resolved.candidates.length > 0) {
|
||||
// For global with multiple candidates, refuse (a wrong edge is worse than no edge)
|
||||
if (resolved.tier === 'global' && resolved.candidates.length > 1) {
|
||||
return { id: generateId(fallbackLabel, fallbackKey ?? name), confidence: TIER_CONFIDENCE['global'] };
|
||||
return generateId(fallbackLabel, fallbackKey ?? name);
|
||||
}
|
||||
return { id: resolved.candidates[0].nodeId, confidence: TIER_CONFIDENCE[resolved.tier] };
|
||||
return resolved.candidates[0].nodeId;
|
||||
}
|
||||
// Unresolved: use global-tier confidence as fallback
|
||||
return { id: generateId(fallbackLabel, fallbackKey ?? name), confidence: TIER_CONFIDENCE['global'] };
|
||||
return generateId(fallbackLabel, fallbackKey ?? name);
|
||||
};
|
||||
|
||||
export const processHeritage = async (
|
||||
@@ -170,16 +163,16 @@ export const processHeritage = async (
|
||||
|
||||
const { type: relType, idPrefix } = resolveExtendsType(parentClassName, file.path, ctx, language);
|
||||
|
||||
const child = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`);
|
||||
const parent = resolveHeritageId(parentClassName, file.path, ctx, idPrefix);
|
||||
const childId = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`);
|
||||
const parentId = resolveHeritageId(parentClassName, file.path, ctx, idPrefix);
|
||||
|
||||
if (child.id && parent.id && child.id !== parent.id) {
|
||||
if (childId && parentId && childId !== parentId) {
|
||||
graph.addRelationship({
|
||||
id: generateId(relType, `${child.id}->${parent.id}`),
|
||||
sourceId: child.id,
|
||||
targetId: parent.id,
|
||||
id: generateId(relType, `${childId}->${parentId}`),
|
||||
sourceId: childId,
|
||||
targetId: parentId,
|
||||
type: relType,
|
||||
confidence: Math.sqrt(child.confidence * parent.confidence),
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
@@ -190,16 +183,16 @@ export const processHeritage = async (
|
||||
const className = captureMap['heritage.class'].text;
|
||||
const interfaceName = captureMap['heritage.implements'].text;
|
||||
|
||||
const cls = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`);
|
||||
const iface = resolveHeritageId(interfaceName, file.path, ctx, 'Interface');
|
||||
const classId = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`);
|
||||
const interfaceId = resolveHeritageId(interfaceName, file.path, ctx, 'Interface');
|
||||
|
||||
if (cls.id && iface.id) {
|
||||
if (classId && interfaceId) {
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', `${cls.id}->${iface.id}`),
|
||||
sourceId: cls.id,
|
||||
targetId: iface.id,
|
||||
id: generateId('IMPLEMENTS', `${classId}->${interfaceId}`),
|
||||
sourceId: classId,
|
||||
targetId: interfaceId,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: Math.sqrt(cls.confidence * iface.confidence),
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
@@ -210,16 +203,16 @@ export const processHeritage = async (
|
||||
const structName = captureMap['heritage.class'].text;
|
||||
const traitName = captureMap['heritage.trait'].text;
|
||||
|
||||
const strct = resolveHeritageId(structName, file.path, ctx, 'Struct', `${file.path}:${structName}`);
|
||||
const trait = resolveHeritageId(traitName, file.path, ctx, 'Trait');
|
||||
const structId = resolveHeritageId(structName, file.path, ctx, 'Struct', `${file.path}:${structName}`);
|
||||
const traitId = resolveHeritageId(traitName, file.path, ctx, 'Trait');
|
||||
|
||||
if (strct.id && trait.id) {
|
||||
if (structId && traitId) {
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', `${strct.id}->${trait.id}`),
|
||||
sourceId: strct.id,
|
||||
targetId: trait.id,
|
||||
id: generateId('IMPLEMENTS', `${structId}->${traitId}`),
|
||||
sourceId: structId,
|
||||
targetId: traitId,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: Math.sqrt(strct.confidence * trait.confidence),
|
||||
confidence: 1.0,
|
||||
reason: 'trait-impl',
|
||||
});
|
||||
}
|
||||
@@ -263,44 +256,44 @@ export const processHeritageFromExtracted = async (
|
||||
if (!fileLanguage) continue;
|
||||
const { type: relType, idPrefix } = resolveExtendsType(h.parentName, h.filePath, ctx, fileLanguage);
|
||||
|
||||
const child = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`);
|
||||
const parent = resolveHeritageId(h.parentName, h.filePath, ctx, idPrefix);
|
||||
const childId = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`);
|
||||
const parentId = resolveHeritageId(h.parentName, h.filePath, ctx, idPrefix);
|
||||
|
||||
if (child.id && parent.id && child.id !== parent.id) {
|
||||
if (childId && parentId && childId !== parentId) {
|
||||
graph.addRelationship({
|
||||
id: generateId(relType, `${child.id}->${parent.id}`),
|
||||
sourceId: child.id,
|
||||
targetId: parent.id,
|
||||
id: generateId(relType, `${childId}->${parentId}`),
|
||||
sourceId: childId,
|
||||
targetId: parentId,
|
||||
type: relType,
|
||||
confidence: Math.sqrt(child.confidence * parent.confidence),
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
} else if (h.kind === 'implements') {
|
||||
const cls = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`);
|
||||
const iface = resolveHeritageId(h.parentName, h.filePath, ctx, 'Interface');
|
||||
const classId = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`);
|
||||
const interfaceId = resolveHeritageId(h.parentName, h.filePath, ctx, 'Interface');
|
||||
|
||||
if (cls.id && iface.id) {
|
||||
if (classId && interfaceId) {
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', `${cls.id}->${iface.id}`),
|
||||
sourceId: cls.id,
|
||||
targetId: iface.id,
|
||||
id: generateId('IMPLEMENTS', `${classId}->${interfaceId}`),
|
||||
sourceId: classId,
|
||||
targetId: interfaceId,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: Math.sqrt(cls.confidence * iface.confidence),
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
} else if (h.kind === 'trait-impl' || h.kind === 'include' || h.kind === 'extend' || h.kind === 'prepend') {
|
||||
const strct = resolveHeritageId(h.className, h.filePath, ctx, 'Struct', `${h.filePath}:${h.className}`);
|
||||
const trait = resolveHeritageId(h.parentName, h.filePath, ctx, 'Trait');
|
||||
const structId = resolveHeritageId(h.className, h.filePath, ctx, 'Struct', `${h.filePath}:${h.className}`);
|
||||
const traitId = resolveHeritageId(h.parentName, h.filePath, ctx, 'Trait');
|
||||
|
||||
if (strct.id && trait.id) {
|
||||
if (structId && traitId) {
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', `${strct.id}->${trait.id}:${h.kind}`),
|
||||
sourceId: strct.id,
|
||||
targetId: trait.id,
|
||||
id: generateId('IMPLEMENTS', `${structId}->${traitId}:${h.kind}`),
|
||||
sourceId: structId,
|
||||
targetId: traitId,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: Math.sqrt(strct.confidence * trait.confidence),
|
||||
confidence: 1.0,
|
||||
reason: h.kind,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,15 +5,43 @@ import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/pa
|
||||
import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { getLanguageFromFilename, isVerboseIngestionEnabled, yieldToEventLoop } from './utils.js';
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
import { extractNamedBindings } from './named-binding-extraction.js';
|
||||
import type { ExtractedImport } from './workers/parse-worker.js';
|
||||
import { getTreeSitterBufferSize } from './constants.js';
|
||||
import { loadImportConfigs } from './language-config.js';
|
||||
import { buildSuffixIndex } from './resolvers/index.js';
|
||||
import {
|
||||
loadTsconfigPaths,
|
||||
loadGoModulePath,
|
||||
loadComposerConfig,
|
||||
loadCSharpProjectConfig,
|
||||
loadSwiftPackageConfig,
|
||||
type SwiftPackageConfig,
|
||||
} from './language-config.js';
|
||||
import {
|
||||
buildSuffixIndex,
|
||||
resolveImportPath,
|
||||
appendKotlinWildcard,
|
||||
KOTLIN_EXTENSIONS,
|
||||
resolveJvmWildcard,
|
||||
resolveJvmMemberImport,
|
||||
resolveGoPackageDir,
|
||||
resolveGoPackage,
|
||||
resolveCSharpImport,
|
||||
resolveCSharpNamespaceDir,
|
||||
resolvePhpImport,
|
||||
resolveRustImport,
|
||||
resolveRubyImport,
|
||||
resolvePythonImport,
|
||||
} from './resolvers/index.js';
|
||||
import { callRouters } from './call-routing.js';
|
||||
import type { ResolutionContext, ModuleAliasMap } from './resolution-context.js';
|
||||
import type { SuffixIndex } from './resolvers/index.js';
|
||||
import { importResolvers, namedBindingExtractors, preprocessImportPath } from './import-resolution.js';
|
||||
import type { ImportResult, ResolveCtx, NamedBinding } from './import-resolution.js';
|
||||
import type { ResolutionContext } from './resolution-context.js';
|
||||
import type {
|
||||
SuffixIndex,
|
||||
TsconfigPaths,
|
||||
GoModuleConfig,
|
||||
CSharpProjectConfig,
|
||||
ComposerConfig
|
||||
} from './resolvers/index.js';
|
||||
|
||||
// Re-export resolver types for consumers
|
||||
export type {
|
||||
@@ -61,7 +89,7 @@ export interface ImportResolutionContext {
|
||||
allFilePaths: Set<string>;
|
||||
allFileList: string[];
|
||||
normalizedFileList: string[];
|
||||
index: SuffixIndex;
|
||||
suffixIndex: SuffixIndex;
|
||||
resolveCache: Map<string, string | null>;
|
||||
}
|
||||
|
||||
@@ -69,39 +97,175 @@ export function buildImportResolutionContext(allPaths: string[]): ImportResoluti
|
||||
const allFileList = allPaths;
|
||||
const normalizedFileList = allFileList.map(p => p.replace(/\\/g, '/'));
|
||||
const allFilePaths = new Set(allFileList);
|
||||
const index = buildSuffixIndex(normalizedFileList, allFileList);
|
||||
return { allFilePaths, allFileList, normalizedFileList, index, resolveCache: new Map() };
|
||||
const suffixIndex = buildSuffixIndex(normalizedFileList, allFileList);
|
||||
return { allFilePaths, allFileList, normalizedFileList, suffixIndex, resolveCache: new Map() };
|
||||
}
|
||||
|
||||
// Config loaders extracted to ./language-config.ts (Phase 2 refactor)
|
||||
// Resolver dispatch tables are in ./import-resolution.ts — imported above
|
||||
// Resolver functions are in ./resolvers/ — imported above
|
||||
|
||||
/** Create IMPORTS edge helpers that share a resolved-count tracker. */
|
||||
function createImportEdgeHelpers(graph: KnowledgeGraph, importMap: ImportMap) {
|
||||
let totalImportsResolved = 0;
|
||||
// ============================================================================
|
||||
// SHARED LANGUAGE DISPATCH
|
||||
// ============================================================================
|
||||
|
||||
const addImportGraphEdge = (filePath: string, resolvedPath: string) => {
|
||||
const sourceId = generateId('File', filePath);
|
||||
const targetId = generateId('File', resolvedPath);
|
||||
const relId = generateId('IMPORTS', `${filePath}->${resolvedPath}`);
|
||||
totalImportsResolved++;
|
||||
graph.addRelationship({ id: relId, sourceId, targetId, type: 'IMPORTS', confidence: 1.0, reason: '' });
|
||||
};
|
||||
/** Bundled language-specific configs loaded once per ingestion run. */
|
||||
interface LanguageConfigs {
|
||||
tsconfigPaths: TsconfigPaths | null;
|
||||
goModule: GoModuleConfig | null;
|
||||
composerConfig: ComposerConfig | null;
|
||||
swiftPackageConfig: SwiftPackageConfig | null;
|
||||
csharpConfigs: CSharpProjectConfig[];
|
||||
}
|
||||
|
||||
const addImportEdge = (filePath: string, resolvedPath: string) => {
|
||||
addImportGraphEdge(filePath, resolvedPath);
|
||||
if (!importMap.has(filePath)) importMap.set(filePath, new Set());
|
||||
importMap.get(filePath)!.add(resolvedPath);
|
||||
};
|
||||
/** Context for import path resolution (file lists, indexes, cache). */
|
||||
interface ResolveCtx {
|
||||
allFilePaths: Set<string>;
|
||||
allFileList: string[];
|
||||
normalizedFileList: string[];
|
||||
index: SuffixIndex;
|
||||
resolveCache: Map<string, string | null>;
|
||||
}
|
||||
|
||||
return { addImportEdge, addImportGraphEdge, getResolvedCount: () => totalImportsResolved };
|
||||
/**
|
||||
* Result of resolving an import via language-specific dispatch.
|
||||
* - 'files': resolved to one or more files → add to ImportMap
|
||||
* - 'package': resolved to a directory → add graph edges + store dirSuffix in PackageMap
|
||||
* - null: no resolution (external dependency, etc.)
|
||||
*/
|
||||
type ImportResult =
|
||||
| { kind: 'files'; files: string[] }
|
||||
| { kind: 'package'; files: string[]; dirSuffix: string }
|
||||
| null;
|
||||
|
||||
/**
|
||||
* Shared language dispatch for import resolution.
|
||||
* Used by both processImports and processImportsFromExtracted.
|
||||
*/
|
||||
function resolveLanguageImport(
|
||||
filePath: string,
|
||||
rawImportPath: string,
|
||||
language: SupportedLanguages,
|
||||
configs: LanguageConfigs,
|
||||
ctx: ResolveCtx,
|
||||
): ImportResult {
|
||||
const { allFilePaths, allFileList, normalizedFileList, index, resolveCache } = ctx;
|
||||
const { tsconfigPaths, goModule, composerConfig, swiftPackageConfig, csharpConfigs } = configs;
|
||||
|
||||
// JVM languages (Java + Kotlin): handle wildcards and member imports
|
||||
if (language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin) {
|
||||
const exts = language === SupportedLanguages.Java ? ['.java'] : KOTLIN_EXTENSIONS;
|
||||
|
||||
if (rawImportPath.endsWith('.*')) {
|
||||
const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index);
|
||||
if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) {
|
||||
const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
|
||||
if (javaMatches.length > 0) return { kind: 'files', files: javaMatches };
|
||||
}
|
||||
if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles };
|
||||
// Fall through to standard resolution
|
||||
} else {
|
||||
let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index);
|
||||
if (!memberResolved && language === SupportedLanguages.Kotlin) {
|
||||
memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
|
||||
}
|
||||
if (memberResolved) return { kind: 'files', files: [memberResolved] };
|
||||
// Fall through to standard resolution
|
||||
}
|
||||
}
|
||||
|
||||
// Go: handle package-level imports
|
||||
if (language === SupportedLanguages.Go && goModule && rawImportPath.startsWith(goModule.modulePath)) {
|
||||
const pkgSuffix = resolveGoPackageDir(rawImportPath, goModule);
|
||||
if (pkgSuffix) {
|
||||
const pkgFiles = resolveGoPackage(rawImportPath, goModule, normalizedFileList, allFileList);
|
||||
if (pkgFiles.length > 0) {
|
||||
return { kind: 'package', files: pkgFiles, dirSuffix: pkgSuffix };
|
||||
}
|
||||
}
|
||||
// Fall through if no files found (package might be external)
|
||||
}
|
||||
|
||||
// C#: handle namespace-based imports (using directives)
|
||||
if (language === SupportedLanguages.CSharp && csharpConfigs.length > 0) {
|
||||
const resolvedFiles = resolveCSharpImport(rawImportPath, csharpConfigs, normalizedFileList, allFileList, index);
|
||||
if (resolvedFiles.length > 1) {
|
||||
const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs);
|
||||
if (dirSuffix) {
|
||||
return { kind: 'package', files: resolvedFiles, dirSuffix };
|
||||
}
|
||||
}
|
||||
if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles };
|
||||
return null;
|
||||
}
|
||||
|
||||
// PHP: handle namespace-based imports (use statements)
|
||||
if (language === SupportedLanguages.PHP) {
|
||||
const resolved = resolvePhpImport(rawImportPath, composerConfig, allFilePaths, normalizedFileList, allFileList, index);
|
||||
return resolved ? { kind: 'files', files: [resolved] } : null;
|
||||
}
|
||||
|
||||
// Swift: handle module imports
|
||||
if (language === SupportedLanguages.Swift && swiftPackageConfig) {
|
||||
const targetDir = swiftPackageConfig.targets.get(rawImportPath);
|
||||
if (targetDir) {
|
||||
const dirPrefix = targetDir + '/';
|
||||
const files: string[] = [];
|
||||
for (let i = 0; i < normalizedFileList.length; i++) {
|
||||
if (normalizedFileList[i].startsWith(dirPrefix) && normalizedFileList[i].endsWith('.swift')) {
|
||||
files.push(allFileList[i]);
|
||||
}
|
||||
}
|
||||
if (files.length > 0) return { kind: 'files', files };
|
||||
}
|
||||
return null; // External framework (Foundation, UIKit, etc.)
|
||||
}
|
||||
|
||||
// Python: relative imports (PEP 328) + proximity-based bare imports
|
||||
// Falls through to standard suffix resolution when proximity finds no match.
|
||||
if (language === SupportedLanguages.Python) {
|
||||
const resolved = resolvePythonImport(filePath, rawImportPath, allFilePaths);
|
||||
if (resolved) return { kind: 'files', files: [resolved] };
|
||||
if (rawImportPath.startsWith('.')) return null; // relative but unresolved — don't suffix-match
|
||||
}
|
||||
|
||||
// Ruby: require / require_relative
|
||||
if (language === SupportedLanguages.Ruby) {
|
||||
const resolved = resolveRubyImport(rawImportPath, normalizedFileList, allFileList, index);
|
||||
return resolved ? { kind: 'files', files: [resolved] } : null;
|
||||
}
|
||||
|
||||
// Rust: expand top-level grouped imports: use {crate::a, crate::b}
|
||||
if (language === SupportedLanguages.Rust && rawImportPath.startsWith('{') && rawImportPath.endsWith('}')) {
|
||||
const inner = rawImportPath.slice(1, -1);
|
||||
const parts = inner.split(',').map(p => p.trim()).filter(Boolean);
|
||||
const resolved: string[] = [];
|
||||
for (const part of parts) {
|
||||
const r = resolveRustImport(filePath, part, allFilePaths);
|
||||
if (r) resolved.push(r);
|
||||
}
|
||||
return resolved.length > 0 ? { kind: 'files', files: resolved } : null;
|
||||
}
|
||||
|
||||
// Standard single-file resolution
|
||||
const resolvedPath = resolveImportPath(
|
||||
filePath,
|
||||
rawImportPath,
|
||||
allFilePaths,
|
||||
allFileList,
|
||||
normalizedFileList,
|
||||
resolveCache,
|
||||
language,
|
||||
tsconfigPaths,
|
||||
index,
|
||||
);
|
||||
|
||||
return resolvedPath ? { kind: 'files', files: [resolvedPath] } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an ImportResult: emit graph edges and update ImportMap/PackageMap.
|
||||
* If namedBindings are provided and the import resolves to a single file,
|
||||
* also populate the NamedImportMap for precise Tier 2a resolution.
|
||||
* Bindings tagged with `isModuleAlias` are routed to moduleAliasMap instead.
|
||||
*/
|
||||
function applyImportResult(
|
||||
result: ImportResult,
|
||||
@@ -110,9 +274,8 @@ function applyImportResult(
|
||||
packageMap: PackageMap | undefined,
|
||||
addImportEdge: (from: string, to: string) => void,
|
||||
addImportGraphEdge: (from: string, to: string) => void,
|
||||
namedBindings?: NamedBinding[],
|
||||
namedBindings?: { local: string; exported: string }[],
|
||||
namedImportMap?: NamedImportMap,
|
||||
moduleAliasMap?: ModuleAliasMap,
|
||||
): void {
|
||||
if (!result) return;
|
||||
|
||||
@@ -130,61 +293,13 @@ function applyImportResult(
|
||||
addImportEdge(filePath, resolvedFile);
|
||||
}
|
||||
|
||||
// Route module aliases (import X as Y) directly to moduleAliasMap.
|
||||
// These are module-level aliases, not symbol bindings — they don't belong in namedImportMap.
|
||||
if (namedBindings && moduleAliasMap && files.length === 1) {
|
||||
// Record named bindings for precise Tier 2a resolution
|
||||
if (namedBindings && namedImportMap && files.length === 1) {
|
||||
const resolvedFile = files[0];
|
||||
for (const binding of namedBindings) {
|
||||
if (!binding.isModuleAlias) continue;
|
||||
let aliasMap = moduleAliasMap.get(filePath);
|
||||
if (!aliasMap) {
|
||||
aliasMap = new Map();
|
||||
moduleAliasMap.set(filePath, aliasMap);
|
||||
}
|
||||
aliasMap.set(binding.local, resolvedFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Record named bindings for precise Tier 2a resolution.
|
||||
// If the same local name is imported from multiple files (e.g., Java static imports
|
||||
// of overloaded methods), remove the entry so resolution falls through to Tier 2a
|
||||
// import-scoped which sees all candidates and can apply arity narrowing.
|
||||
if (namedBindings && namedImportMap) {
|
||||
if (!namedImportMap.has(filePath)) namedImportMap.set(filePath, new Map());
|
||||
const fileBindings = namedImportMap.get(filePath)!;
|
||||
|
||||
if (files.length === 1) {
|
||||
const resolvedFile = files[0];
|
||||
for (const binding of namedBindings) {
|
||||
if (binding.isModuleAlias) continue; // already routed to moduleAliasMap
|
||||
const existing = fileBindings.get(binding.local);
|
||||
if (existing && existing.sourcePath !== resolvedFile) {
|
||||
fileBindings.delete(binding.local);
|
||||
} else {
|
||||
fileBindings.set(binding.local, { sourcePath: resolvedFile, exportedName: binding.exported });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multi-file resolution (e.g., Rust `use crate::models::{User, Repo}`).
|
||||
// Match each binding to a resolved file by comparing the lowercase binding name
|
||||
// to the file's basename (without extension). If no match, skip the binding.
|
||||
for (const binding of namedBindings) {
|
||||
if (binding.isModuleAlias) continue;
|
||||
const lowerName = binding.exported.toLowerCase();
|
||||
const matchedFile = files.find(f => {
|
||||
const base = f.replace(/\\/g, '/').split('/').pop() ?? '';
|
||||
const nameWithoutExt = base.substring(0, base.lastIndexOf('.')).toLowerCase();
|
||||
return nameWithoutExt === lowerName;
|
||||
});
|
||||
if (matchedFile) {
|
||||
const existing = fileBindings.get(binding.local);
|
||||
if (existing && existing.sourcePath !== matchedFile) {
|
||||
fileBindings.delete(binding.local);
|
||||
} else {
|
||||
fileBindings.set(binding.local, { sourcePath: matchedFile, exportedName: binding.exported });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const binding of namedBindings) {
|
||||
fileBindings.set(binding.local, { sourcePath: resolvedFile, exportedName: binding.exported });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,7 +321,6 @@ export const processImports = async (
|
||||
const importMap = ctx.importMap;
|
||||
const packageMap = ctx.packageMap;
|
||||
const namedImportMap = ctx.namedImportMap;
|
||||
const moduleAliasMap = ctx.moduleAliasMap;
|
||||
// Use allPaths (full repo) when available for cross-chunk resolution, else fall back to chunk files
|
||||
const allFileList = allPaths ?? files.map(f => f.path);
|
||||
const allFilePaths = new Set(allFileList);
|
||||
@@ -221,11 +335,46 @@ export const processImports = async (
|
||||
|
||||
// Track import statistics
|
||||
let totalImportsFound = 0;
|
||||
let totalImportsResolved = 0;
|
||||
|
||||
// Load language-specific configs once before the file loop
|
||||
const configs = await loadImportConfigs(repoRoot || '');
|
||||
const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache, configs };
|
||||
const { addImportEdge, addImportGraphEdge, getResolvedCount } = createImportEdgeHelpers(graph, importMap);
|
||||
const effectiveRoot = repoRoot || '';
|
||||
const configs: LanguageConfigs = {
|
||||
tsconfigPaths: await loadTsconfigPaths(effectiveRoot),
|
||||
goModule: await loadGoModulePath(effectiveRoot),
|
||||
composerConfig: await loadComposerConfig(effectiveRoot),
|
||||
swiftPackageConfig: await loadSwiftPackageConfig(effectiveRoot),
|
||||
csharpConfigs: await loadCSharpProjectConfig(effectiveRoot),
|
||||
};
|
||||
const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache };
|
||||
|
||||
// Helper: add an IMPORTS edge to the graph only (no ImportMap update)
|
||||
const addImportGraphEdge = (filePath: string, resolvedPath: string) => {
|
||||
const sourceId = generateId('File', filePath);
|
||||
const targetId = generateId('File', resolvedPath);
|
||||
const relId = generateId('IMPORTS', `${filePath}->${resolvedPath}`);
|
||||
|
||||
totalImportsResolved++;
|
||||
|
||||
graph.addRelationship({
|
||||
id: relId,
|
||||
sourceId,
|
||||
targetId,
|
||||
type: 'IMPORTS',
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
};
|
||||
|
||||
// Helper: add an IMPORTS edge + update import map
|
||||
const addImportEdge = (filePath: string, resolvedPath: string) => {
|
||||
addImportGraphEdge(filePath, resolvedPath);
|
||||
|
||||
if (!importMap.has(filePath)) {
|
||||
importMap.set(filePath, new Set());
|
||||
}
|
||||
importMap.get(filePath)!.add(resolvedPath);
|
||||
};
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
@@ -298,14 +447,15 @@ export const processImports = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const rawImportPath = preprocessImportPath(sourceNode.text, captureMap['import'], language);
|
||||
if (!rawImportPath) return;
|
||||
// Clean path (remove quotes and angle brackets for C/C++ includes)
|
||||
const rawImportPath = language === SupportedLanguages.Kotlin
|
||||
? appendKotlinWildcard(sourceNode.text.replace(/['"<>]/g, ''), captureMap['import'])
|
||||
: sourceNode.text.replace(/['"<>]/g, '');
|
||||
totalImportsFound++;
|
||||
|
||||
const result = importResolvers[language](rawImportPath, file.path, resolveCtx);
|
||||
const extractor = namedBindingExtractors[language];
|
||||
const bindings = namedImportMap && extractor ? extractor(captureMap['import']) : undefined;
|
||||
applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge, bindings, namedImportMap, moduleAliasMap);
|
||||
const result = resolveLanguageImport(file.path, rawImportPath, language, configs, resolveCtx);
|
||||
const bindings = namedImportMap ? extractNamedBindings(captureMap['import'], language) : undefined;
|
||||
applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge, bindings, namedImportMap);
|
||||
}
|
||||
|
||||
// ---- Language-specific call-as-import routing (Ruby require, etc.) ----
|
||||
@@ -316,7 +466,7 @@ export const processImports = async (
|
||||
const routed = callRouter(callNameNode.text, captureMap['call']);
|
||||
if (routed && routed.kind === 'import') {
|
||||
totalImportsFound++;
|
||||
const result = importResolvers[language](routed.importPath, file.path, resolveCtx);
|
||||
const result = resolveLanguageImport(file.path, routed.importPath, language, configs, resolveCtx);
|
||||
applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge);
|
||||
}
|
||||
}
|
||||
@@ -335,7 +485,7 @@ export const processImports = async (
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
console.log(`📊 Import processing complete: ${getResolvedCount()}/${totalImportsFound} imports resolved to graph edges`);
|
||||
console.log(`📊 Import processing complete: ${totalImportsResolved}/${totalImportsFound} imports resolved to graph edges`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -355,15 +505,48 @@ export const processImportsFromExtracted = async (
|
||||
const importMap = ctx.importMap;
|
||||
const packageMap = ctx.packageMap;
|
||||
const namedImportMap = ctx.namedImportMap;
|
||||
const moduleAliasMap = ctx.moduleAliasMap;
|
||||
const importCtx = prebuiltCtx ?? buildImportResolutionContext(files.map(f => f.path));
|
||||
const { allFilePaths, allFileList, normalizedFileList, index, resolveCache } = importCtx;
|
||||
const { allFilePaths, allFileList, normalizedFileList, suffixIndex: index, resolveCache } = importCtx;
|
||||
|
||||
let totalImportsFound = 0;
|
||||
let totalImportsResolved = 0;
|
||||
|
||||
const configs = await loadImportConfigs(repoRoot || '');
|
||||
const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache, configs };
|
||||
const { addImportEdge, addImportGraphEdge, getResolvedCount } = createImportEdgeHelpers(graph, importMap);
|
||||
const effectiveRoot = repoRoot || '';
|
||||
const configs: LanguageConfigs = {
|
||||
tsconfigPaths: await loadTsconfigPaths(effectiveRoot),
|
||||
goModule: await loadGoModulePath(effectiveRoot),
|
||||
composerConfig: await loadComposerConfig(effectiveRoot),
|
||||
swiftPackageConfig: await loadSwiftPackageConfig(effectiveRoot),
|
||||
csharpConfigs: await loadCSharpProjectConfig(effectiveRoot),
|
||||
};
|
||||
const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache };
|
||||
|
||||
// Helper: add an IMPORTS edge to the graph only (no ImportMap update)
|
||||
const addImportGraphEdge = (filePath: string, resolvedPath: string) => {
|
||||
const sourceId = generateId('File', filePath);
|
||||
const targetId = generateId('File', resolvedPath);
|
||||
const relId = generateId('IMPORTS', `${filePath}->${resolvedPath}`);
|
||||
|
||||
totalImportsResolved++;
|
||||
|
||||
graph.addRelationship({
|
||||
id: relId,
|
||||
sourceId,
|
||||
targetId,
|
||||
type: 'IMPORTS',
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
};
|
||||
|
||||
const addImportEdge = (filePath: string, resolvedPath: string) => {
|
||||
addImportGraphEdge(filePath, resolvedPath);
|
||||
|
||||
if (!importMap.has(filePath)) {
|
||||
importMap.set(filePath, new Set());
|
||||
}
|
||||
importMap.get(filePath)!.add(resolvedPath);
|
||||
};
|
||||
|
||||
// Group by file for progress reporting (users see file count, not import count)
|
||||
const importsByFile = new Map<string, ExtractedImport[]>();
|
||||
@@ -389,14 +572,14 @@ export const processImportsFromExtracted = async (
|
||||
for (const imp of fileImports) {
|
||||
totalImportsFound++;
|
||||
|
||||
const result = importResolvers[imp.language](imp.rawImportPath, filePath, resolveCtx);
|
||||
applyImportResult(result, filePath, importMap, packageMap, addImportEdge, addImportGraphEdge, imp.namedBindings, namedImportMap, moduleAliasMap);
|
||||
const result = resolveLanguageImport(filePath, imp.rawImportPath, imp.language, configs, resolveCtx);
|
||||
applyImportResult(result, filePath, importMap, packageMap, addImportEdge, addImportGraphEdge, imp.namedBindings, namedImportMap);
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.(totalFiles, totalFiles);
|
||||
|
||||
if (isDev) {
|
||||
console.log(`📊 Import processing (fast path): ${getResolvedCount()}/${totalImportsFound} imports resolved to graph edges`);
|
||||
console.log(`📊 Import processing (fast path): ${totalImportsResolved}/${totalImportsFound} imports resolved to graph edges`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
/**
|
||||
* Import Resolution Dispatch
|
||||
*
|
||||
* Per-language dispatch table for import resolution and named binding extraction.
|
||||
* Replaces the 120-line if-chain in resolveLanguageImport() and the 7-branch
|
||||
* dispatch in extractNamedBindings() with a single table lookup each.
|
||||
*
|
||||
* Follows the existing ExportChecker / CallRouter pattern:
|
||||
* - Function aliases (not interfaces) to avoid megamorphic inline-cache issues
|
||||
* - `satisfies Record<SupportedLanguages, ...>` for compile-time exhaustiveness
|
||||
* - Const dispatch table — configs are accessed via ctx.configs at call time
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
import type { SyntaxNode } from './utils.js';
|
||||
import {
|
||||
KOTLIN_EXTENSIONS,
|
||||
appendKotlinWildcard,
|
||||
resolveJvmWildcard,
|
||||
resolveJvmMemberImport,
|
||||
resolveGoPackageDir,
|
||||
resolveGoPackage,
|
||||
resolveCSharpImport as resolveCSharpImportHelper,
|
||||
resolveCSharpNamespaceDir,
|
||||
resolvePhpImport as resolvePhpImportHelper,
|
||||
resolveRustImport as resolveRustImportHelper,
|
||||
resolveRubyImport as resolveRubyImportHelper,
|
||||
resolvePythonImport as resolvePythonImportHelper,
|
||||
resolveImportPath,
|
||||
} from './resolvers/index.js';
|
||||
import type {
|
||||
SuffixIndex,
|
||||
TsconfigPaths,
|
||||
GoModuleConfig,
|
||||
CSharpProjectConfig,
|
||||
ComposerConfig,
|
||||
} from './resolvers/index.js';
|
||||
import type { SwiftPackageConfig } from './language-config.js';
|
||||
import {
|
||||
extractTsNamedBindings,
|
||||
extractPythonNamedBindings,
|
||||
extractKotlinNamedBindings,
|
||||
extractRustNamedBindings,
|
||||
extractPhpNamedBindings,
|
||||
extractCsharpNamedBindings,
|
||||
extractJavaNamedBindings,
|
||||
} from './named-binding-extraction.js';
|
||||
import type { ImportResolutionContext } from './import-processor.js';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Result of resolving an import via language-specific dispatch.
|
||||
* - 'files': resolved to one or more files -> add to ImportMap
|
||||
* - 'package': resolved to a directory -> add graph edges + store dirSuffix in PackageMap
|
||||
* - null: no resolution (external dependency, etc.)
|
||||
*/
|
||||
export type ImportResult =
|
||||
| { kind: 'files'; files: string[] }
|
||||
| { kind: 'package'; files: string[]; dirSuffix: string }
|
||||
| null;
|
||||
|
||||
/** Bundled language-specific configs loaded once per ingestion run. */
|
||||
export interface ImportConfigs {
|
||||
tsconfigPaths: TsconfigPaths | null;
|
||||
goModule: GoModuleConfig | null;
|
||||
composerConfig: ComposerConfig | null;
|
||||
swiftPackageConfig: SwiftPackageConfig | null;
|
||||
csharpConfigs: CSharpProjectConfig[];
|
||||
}
|
||||
|
||||
/** Full context for import resolution: file lookups + language configs. */
|
||||
export interface ResolveCtx extends ImportResolutionContext {
|
||||
configs: ImportConfigs;
|
||||
}
|
||||
|
||||
/** Per-language import resolver -- function alias matching ExportChecker/CallRouter pattern. */
|
||||
export type ImportResolverFn = (
|
||||
rawImportPath: string,
|
||||
filePath: string,
|
||||
resolveCtx: ResolveCtx,
|
||||
) => ImportResult;
|
||||
|
||||
/** A single named import binding: local name in the importing file and exported name from the source.
|
||||
* When `isModuleAlias` is true, the binding represents a Python `import X as Y` module alias
|
||||
* and is routed to moduleAliasMap instead of namedImportMap during import processing. */
|
||||
export interface NamedBinding { local: string; exported: string; isModuleAlias?: boolean }
|
||||
|
||||
/** Per-language named binding extractor -- optional (returns undefined if language has no named imports). */
|
||||
type NamedBindingExtractorFn = (importNode: SyntaxNode) => NamedBinding[] | undefined;
|
||||
|
||||
// ============================================================================
|
||||
// Import path preprocessing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Clean and preprocess a raw import source text into a resolved import path.
|
||||
* Strips quotes/angle brackets (universal) and applies language-specific
|
||||
* transformations (currently only Kotlin wildcard import detection).
|
||||
*/
|
||||
export function preprocessImportPath(
|
||||
sourceText: string,
|
||||
importNode: SyntaxNode,
|
||||
language: SupportedLanguages,
|
||||
): string | null {
|
||||
const cleaned = sourceText.replace(/['"<>]/g, '');
|
||||
// Defense-in-depth: reject null bytes and control characters (matches Ruby call-routing pattern)
|
||||
if (!cleaned || cleaned.length > 2048 || /[\x00-\x1f]/.test(cleaned)) return null;
|
||||
if (language === SupportedLanguages.Kotlin) {
|
||||
return appendKotlinWildcard(cleaned, importNode);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Per-language resolver functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Standard single-file resolution (TS/JS/C/C++ and fallback for other languages).
|
||||
* Handles relative imports, tsconfig path aliases, and suffix matching.
|
||||
*/
|
||||
function resolveStandard(
|
||||
rawImportPath: string,
|
||||
filePath: string,
|
||||
ctx: ResolveCtx,
|
||||
language: SupportedLanguages,
|
||||
): ImportResult {
|
||||
const resolvedPath = resolveImportPath(
|
||||
filePath,
|
||||
rawImportPath,
|
||||
ctx.allFilePaths,
|
||||
ctx.allFileList,
|
||||
ctx.normalizedFileList,
|
||||
ctx.resolveCache,
|
||||
language,
|
||||
ctx.configs.tsconfigPaths,
|
||||
ctx.index,
|
||||
);
|
||||
return resolvedPath ? { kind: 'files', files: [resolvedPath] } : null;
|
||||
}
|
||||
|
||||
/** Java: JVM wildcard -> member import -> standard fallthrough */
|
||||
function resolveJavaImport(
|
||||
rawImportPath: string,
|
||||
filePath: string,
|
||||
ctx: ResolveCtx,
|
||||
): ImportResult {
|
||||
if (rawImportPath.endsWith('.*')) {
|
||||
const matchedFiles = resolveJvmWildcard(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index);
|
||||
if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles };
|
||||
} else {
|
||||
const memberResolved = resolveJvmMemberImport(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index);
|
||||
if (memberResolved) return { kind: 'files', files: [memberResolved] };
|
||||
}
|
||||
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Java);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kotlin: JVM wildcard/member with Java-interop fallback -> top-level function imports -> standard.
|
||||
* Kotlin can import from .kt/.kts files OR from .java files (Java interop).
|
||||
*/
|
||||
function resolveKotlinImport(
|
||||
rawImportPath: string,
|
||||
filePath: string,
|
||||
ctx: ResolveCtx,
|
||||
): ImportResult {
|
||||
if (rawImportPath.endsWith('.*')) {
|
||||
const matchedFiles = resolveJvmWildcard(rawImportPath, ctx.normalizedFileList, ctx.allFileList, KOTLIN_EXTENSIONS, ctx.index);
|
||||
if (matchedFiles.length === 0) {
|
||||
const javaMatches = resolveJvmWildcard(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index);
|
||||
if (javaMatches.length > 0) return { kind: 'files', files: javaMatches };
|
||||
}
|
||||
if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles };
|
||||
} else {
|
||||
let memberResolved = resolveJvmMemberImport(rawImportPath, ctx.normalizedFileList, ctx.allFileList, KOTLIN_EXTENSIONS, ctx.index);
|
||||
if (!memberResolved) {
|
||||
memberResolved = resolveJvmMemberImport(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index);
|
||||
}
|
||||
if (memberResolved) return { kind: 'files', files: [memberResolved] };
|
||||
|
||||
// Kotlin: top-level function imports (e.g. import models.getUser) have only 2 segments,
|
||||
// which resolveJvmMemberImport skips (requires >=3). Fall back to package-directory scan
|
||||
// for lowercase last segments (function/property imports). Uppercase last segments
|
||||
// (class imports like models.User) fall through to standard suffix resolution.
|
||||
const segments = rawImportPath.split('.');
|
||||
const lastSeg = segments[segments.length - 1];
|
||||
if (segments.length >= 2 && lastSeg[0] && lastSeg[0] === lastSeg[0].toLowerCase()) {
|
||||
const pkgWildcard = segments.slice(0, -1).join('.') + '.*';
|
||||
let dirFiles = resolveJvmWildcard(pkgWildcard, ctx.normalizedFileList, ctx.allFileList, KOTLIN_EXTENSIONS, ctx.index);
|
||||
if (dirFiles.length === 0) {
|
||||
dirFiles = resolveJvmWildcard(pkgWildcard, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index);
|
||||
}
|
||||
if (dirFiles.length > 0) return { kind: 'files', files: dirFiles };
|
||||
}
|
||||
}
|
||||
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Kotlin);
|
||||
}
|
||||
|
||||
/** Go: package-level imports via go.mod module path. */
|
||||
function resolveGoImport(
|
||||
rawImportPath: string,
|
||||
filePath: string,
|
||||
ctx: ResolveCtx,
|
||||
): ImportResult {
|
||||
const goModule = ctx.configs.goModule;
|
||||
if (goModule && rawImportPath.startsWith(goModule.modulePath)) {
|
||||
const pkgSuffix = resolveGoPackageDir(rawImportPath, goModule);
|
||||
if (pkgSuffix) {
|
||||
const pkgFiles = resolveGoPackage(rawImportPath, goModule, ctx.normalizedFileList, ctx.allFileList);
|
||||
if (pkgFiles.length > 0) {
|
||||
return { kind: 'package', files: pkgFiles, dirSuffix: pkgSuffix };
|
||||
}
|
||||
}
|
||||
// Fall through if no files found (package might be external)
|
||||
}
|
||||
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Go);
|
||||
}
|
||||
|
||||
/** C#: namespace-based resolution via .csproj configs, with suffix-match fallback. */
|
||||
function resolveCSharpImportDispatch(
|
||||
rawImportPath: string,
|
||||
filePath: string,
|
||||
ctx: ResolveCtx,
|
||||
): ImportResult {
|
||||
const csharpConfigs = ctx.configs.csharpConfigs;
|
||||
if (csharpConfigs.length > 0) {
|
||||
const resolvedFiles = resolveCSharpImportHelper(rawImportPath, csharpConfigs, ctx.normalizedFileList, ctx.allFileList, ctx.index);
|
||||
if (resolvedFiles.length > 1) {
|
||||
const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs);
|
||||
if (dirSuffix) {
|
||||
return { kind: 'package', files: resolvedFiles, dirSuffix };
|
||||
}
|
||||
}
|
||||
if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles };
|
||||
}
|
||||
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.CSharp);
|
||||
}
|
||||
|
||||
/** PHP: namespace-based resolution via composer.json PSR-4. */
|
||||
function resolvePhpImportDispatch(
|
||||
rawImportPath: string,
|
||||
_filePath: string,
|
||||
ctx: ResolveCtx,
|
||||
): ImportResult {
|
||||
const resolved = resolvePhpImportHelper(rawImportPath, ctx.configs.composerConfig, ctx.allFilePaths, ctx.normalizedFileList, ctx.allFileList, ctx.index);
|
||||
return resolved ? { kind: 'files', files: [resolved] } : null;
|
||||
}
|
||||
|
||||
/** Swift: module imports via Package.swift target map. */
|
||||
function resolveSwiftImportDispatch(
|
||||
rawImportPath: string,
|
||||
_filePath: string,
|
||||
ctx: ResolveCtx,
|
||||
): ImportResult {
|
||||
const swiftPackageConfig = ctx.configs.swiftPackageConfig;
|
||||
if (swiftPackageConfig) {
|
||||
const targetDir = swiftPackageConfig.targets.get(rawImportPath);
|
||||
if (targetDir) {
|
||||
const dirPrefix = targetDir + '/';
|
||||
const files: string[] = [];
|
||||
for (let i = 0; i < ctx.normalizedFileList.length; i++) {
|
||||
if (ctx.normalizedFileList[i].startsWith(dirPrefix) && ctx.normalizedFileList[i].endsWith('.swift')) {
|
||||
files.push(ctx.allFileList[i]);
|
||||
}
|
||||
}
|
||||
if (files.length > 0) return { kind: 'files', files };
|
||||
}
|
||||
}
|
||||
return null; // External framework (Foundation, UIKit, etc.)
|
||||
}
|
||||
|
||||
/**
|
||||
* Python: relative imports (PEP 328) + proximity-based bare imports.
|
||||
* Falls through to standard suffix resolution when proximity finds no match.
|
||||
*/
|
||||
function resolvePythonImportDispatch(
|
||||
rawImportPath: string,
|
||||
filePath: string,
|
||||
ctx: ResolveCtx,
|
||||
): ImportResult {
|
||||
const resolved = resolvePythonImportHelper(filePath, rawImportPath, ctx.allFilePaths);
|
||||
if (resolved) return { kind: 'files', files: [resolved] };
|
||||
if (rawImportPath.startsWith('.')) return null; // relative but unresolved -- don't suffix-match
|
||||
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Python);
|
||||
}
|
||||
|
||||
/** Ruby: require / require_relative. */
|
||||
function resolveRubyImportDispatch(
|
||||
rawImportPath: string,
|
||||
_filePath: string,
|
||||
ctx: ResolveCtx,
|
||||
): ImportResult {
|
||||
const resolved = resolveRubyImportHelper(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ctx.index);
|
||||
return resolved ? { kind: 'files', files: [resolved] } : null;
|
||||
}
|
||||
|
||||
/** Rust: expand grouped imports: use {crate::a, crate::b} and use crate::models::{User, Repo}. */
|
||||
function resolveRustImportDispatch(
|
||||
rawImportPath: string,
|
||||
filePath: string,
|
||||
ctx: ResolveCtx,
|
||||
): ImportResult {
|
||||
// Top-level grouped: use {crate::a, crate::b}
|
||||
if (rawImportPath.startsWith('{') && rawImportPath.endsWith('}')) {
|
||||
const inner = rawImportPath.slice(1, -1);
|
||||
const parts = inner.split(',').map(p => p.trim()).filter(Boolean);
|
||||
const resolved: string[] = [];
|
||||
for (const part of parts) {
|
||||
const r = resolveRustImportHelper(filePath, part, ctx.allFilePaths);
|
||||
if (r) resolved.push(r);
|
||||
}
|
||||
return resolved.length > 0 ? { kind: 'files', files: resolved } : null;
|
||||
}
|
||||
|
||||
// Scoped grouped: use crate::models::{User, Repo}
|
||||
const braceIdx = rawImportPath.indexOf('::{');
|
||||
if (braceIdx !== -1 && rawImportPath.endsWith('}')) {
|
||||
const pathPrefix = rawImportPath.substring(0, braceIdx);
|
||||
const braceContent = rawImportPath.substring(braceIdx + 3, rawImportPath.length - 1);
|
||||
const items = braceContent.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const resolved: string[] = [];
|
||||
for (const item of items) {
|
||||
// Handle `use crate::models::{User, Repo as R}` — strip alias for resolution
|
||||
const itemName = item.includes(' as ') ? item.split(' as ')[0].trim() : item;
|
||||
const r = resolveRustImportHelper(filePath, `${pathPrefix}::${itemName}`, ctx.allFilePaths);
|
||||
if (r) resolved.push(r);
|
||||
}
|
||||
if (resolved.length > 0) return { kind: 'files', files: resolved };
|
||||
// Fallback: resolve the prefix path itself (e.g. crate::models -> models.rs)
|
||||
const prefixResult = resolveRustImportHelper(filePath, pathPrefix, ctx.allFilePaths);
|
||||
if (prefixResult) return { kind: 'files', files: [prefixResult] };
|
||||
}
|
||||
|
||||
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Rust);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dispatch tables
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Per-language import resolver dispatch table.
|
||||
* Configs are accessed via ctx.configs at call time — no factory closure needed.
|
||||
* Each resolver encapsulates the full resolution flow for its language, including
|
||||
* fallthrough to standard resolution where appropriate.
|
||||
*/
|
||||
export const importResolvers = {
|
||||
[SupportedLanguages.JavaScript]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.JavaScript),
|
||||
[SupportedLanguages.TypeScript]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.TypeScript),
|
||||
[SupportedLanguages.Python]: (raw, fp, ctx) => resolvePythonImportDispatch(raw, fp, ctx),
|
||||
[SupportedLanguages.Java]: (raw, fp, ctx) => resolveJavaImport(raw, fp, ctx),
|
||||
[SupportedLanguages.C]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.C),
|
||||
[SupportedLanguages.CPlusPlus]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.CPlusPlus),
|
||||
[SupportedLanguages.CSharp]: (raw, fp, ctx) => resolveCSharpImportDispatch(raw, fp, ctx),
|
||||
[SupportedLanguages.Go]: (raw, fp, ctx) => resolveGoImport(raw, fp, ctx),
|
||||
[SupportedLanguages.Ruby]: (raw, fp, ctx) => resolveRubyImportDispatch(raw, fp, ctx),
|
||||
[SupportedLanguages.Rust]: (raw, fp, ctx) => resolveRustImportDispatch(raw, fp, ctx),
|
||||
[SupportedLanguages.PHP]: (raw, fp, ctx) => resolvePhpImportDispatch(raw, fp, ctx),
|
||||
[SupportedLanguages.Kotlin]: (raw, fp, ctx) => resolveKotlinImport(raw, fp, ctx),
|
||||
[SupportedLanguages.Swift]: (raw, fp, ctx) => resolveSwiftImportDispatch(raw, fp, ctx),
|
||||
} satisfies Record<SupportedLanguages, ImportResolverFn>;
|
||||
|
||||
/**
|
||||
* Per-language named binding extractor dispatch table.
|
||||
* Languages with whole-module import semantics (Go, Ruby, C/C++, Swift) return undefined --
|
||||
* their bindings are synthesized post-parse by synthesizeWildcardImportBindings() in pipeline.ts.
|
||||
*/
|
||||
export const namedBindingExtractors = {
|
||||
[SupportedLanguages.JavaScript]: extractTsNamedBindings,
|
||||
[SupportedLanguages.TypeScript]: extractTsNamedBindings,
|
||||
[SupportedLanguages.Python]: extractPythonNamedBindings,
|
||||
[SupportedLanguages.Java]: extractJavaNamedBindings,
|
||||
[SupportedLanguages.C]: undefined,
|
||||
[SupportedLanguages.CPlusPlus]: undefined,
|
||||
[SupportedLanguages.CSharp]: extractCsharpNamedBindings,
|
||||
[SupportedLanguages.Go]: undefined,
|
||||
[SupportedLanguages.Ruby]: undefined,
|
||||
[SupportedLanguages.Rust]: extractRustNamedBindings,
|
||||
[SupportedLanguages.PHP]: extractPhpNamedBindings,
|
||||
[SupportedLanguages.Kotlin]: extractKotlinNamedBindings,
|
||||
[SupportedLanguages.Swift]: undefined,
|
||||
} satisfies Record<SupportedLanguages, NamedBindingExtractorFn | undefined>;
|
||||
@@ -1,6 +1,5 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import type { ImportConfigs } from './import-resolution.js';
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
@@ -214,18 +213,3 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BUNDLED CONFIG LOADER
|
||||
// ============================================================================
|
||||
|
||||
/** Load all language-specific configs once for an ingestion run. */
|
||||
export async function loadImportConfigs(repoRoot: string): Promise<ImportConfigs> {
|
||||
return {
|
||||
tsconfigPaths: await loadTsconfigPaths(repoRoot),
|
||||
goModule: await loadGoModulePath(repoRoot),
|
||||
composerConfig: await loadComposerConfig(repoRoot),
|
||||
swiftPackageConfig: await loadSwiftPackageConfig(repoRoot),
|
||||
csharpConfigs: await loadCSharpProjectConfig(repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ function c3Linearize(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MethodDef = { classId: string; className: string; methodId: string };
|
||||
type Resolution = { resolvedTo: string | null; reason: string; confidence: number };
|
||||
type Resolution = { resolvedTo: string | null; reason: string };
|
||||
|
||||
/** Resolve by MRO order — first ancestor in linearized order wins. */
|
||||
function resolveByMroOrder(
|
||||
@@ -224,11 +224,10 @@ function resolveByMroOrder(
|
||||
return {
|
||||
resolvedTo: match.methodId,
|
||||
reason: `${reasonPrefix}: ${match.className}::${methodName}`,
|
||||
confidence: 0.9, // MRO-ordered resolution
|
||||
};
|
||||
}
|
||||
}
|
||||
return { resolvedTo: defs[0].methodId, reason: `${reasonPrefix} fallback: first definition`, confidence: 0.7 };
|
||||
return { resolvedTo: defs[0].methodId, reason: `${reasonPrefix} fallback: first definition` };
|
||||
}
|
||||
|
||||
function resolveCsharpJava(
|
||||
@@ -252,7 +251,6 @@ function resolveCsharpJava(
|
||||
return {
|
||||
resolvedTo: classDefs[0].methodId,
|
||||
reason: `class method wins: ${classDefs[0].className}::${methodName}`,
|
||||
confidence: 0.95, // Class method is authoritative
|
||||
};
|
||||
}
|
||||
|
||||
@@ -260,7 +258,6 @@ function resolveCsharpJava(
|
||||
return {
|
||||
resolvedTo: null,
|
||||
reason: `ambiguous: ${methodName} defined in multiple interfaces: ${interfaceDefs.map(d => d.className).join(', ')}`,
|
||||
confidence: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -268,11 +265,10 @@ function resolveCsharpJava(
|
||||
return {
|
||||
resolvedTo: interfaceDefs[0].methodId,
|
||||
reason: `single interface default: ${interfaceDefs[0].className}::${methodName}`,
|
||||
confidence: 0.85, // Single interface, unambiguous
|
||||
};
|
||||
}
|
||||
|
||||
return { resolvedTo: null, reason: 'no resolution found', confidence: 0.5 };
|
||||
return { resolvedTo: null, reason: 'no resolution found' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -380,7 +376,6 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
|
||||
resolution = {
|
||||
resolvedTo: null,
|
||||
reason: `Rust requires qualified syntax: <Type as Trait>::${methodName}()`,
|
||||
confidence: 0.5,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
@@ -407,7 +402,7 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
|
||||
sourceId: classId,
|
||||
targetId: resolution.resolvedTo,
|
||||
type: 'OVERRIDES',
|
||||
confidence: resolution.confidence,
|
||||
confidence: 1.0,
|
||||
reason: resolution.reason,
|
||||
});
|
||||
overrideEdges++;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
import type { SymbolTable, SymbolDefinition } from './symbol-table.js';
|
||||
import type { NamedImportMap } from './import-processor.js';
|
||||
import type { NamedBinding } from './import-resolution.js';
|
||||
import type { SyntaxNode } from './utils.js';
|
||||
import { findChild } from './resolvers/utils.js';
|
||||
|
||||
/**
|
||||
* Walk a named-binding re-export chain through NamedImportMap.
|
||||
@@ -56,14 +54,52 @@ export function walkBindingChain(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractTsNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
|
||||
/**
|
||||
* Extract named bindings from an import AST node.
|
||||
* Returns undefined if the import is not a named import (e.g., import * or default).
|
||||
*
|
||||
* TS: import { User, Repo as R } from './models'
|
||||
* → [{local:'User', exported:'User'}, {local:'R', exported:'Repo'}]
|
||||
*
|
||||
* Python: from models import User, Repo as R
|
||||
* → [{local:'User', exported:'User'}, {local:'R', exported:'Repo'}]
|
||||
*/
|
||||
export function extractNamedBindings(
|
||||
importNode: any,
|
||||
language: SupportedLanguages,
|
||||
): { local: string; exported: string }[] | undefined {
|
||||
if (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) {
|
||||
return extractTsNamedBindings(importNode);
|
||||
}
|
||||
if (language === SupportedLanguages.Python) {
|
||||
return extractPythonNamedBindings(importNode);
|
||||
}
|
||||
if (language === SupportedLanguages.Kotlin) {
|
||||
return extractKotlinNamedBindings(importNode);
|
||||
}
|
||||
if (language === SupportedLanguages.Rust) {
|
||||
return extractRustNamedBindings(importNode);
|
||||
}
|
||||
if (language === SupportedLanguages.PHP) {
|
||||
return extractPhpNamedBindings(importNode);
|
||||
}
|
||||
if (language === SupportedLanguages.CSharp) {
|
||||
return extractCsharpNamedBindings(importNode);
|
||||
}
|
||||
if (language === SupportedLanguages.Java) {
|
||||
return extractJavaNamedBindings(importNode);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractTsNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
|
||||
// import_statement > import_clause > named_imports > import_specifier*
|
||||
const importClause = findChild(importNode, 'import_clause');
|
||||
if (importClause) {
|
||||
const namedImports = findChild(importClause, 'named_imports');
|
||||
if (!namedImports) return undefined; // default import, namespace import, or side-effect
|
||||
|
||||
const bindings: NamedBinding[] = [];
|
||||
const bindings: { local: string; exported: string }[] = [];
|
||||
for (let i = 0; i < namedImports.namedChildCount; i++) {
|
||||
const specifier = namedImports.namedChild(i);
|
||||
if (specifier?.type !== 'import_specifier') continue;
|
||||
@@ -87,7 +123,7 @@ export function extractTsNamedBindings(importNode: SyntaxNode): NamedBinding[] |
|
||||
// Re-export: export { X } from './y' → export_statement > export_clause > export_specifier
|
||||
const exportClause = findChild(importNode, 'export_clause');
|
||||
if (exportClause) {
|
||||
const bindings: NamedBinding[] = [];
|
||||
const bindings: { local: string; exported: string }[] = [];
|
||||
for (let i = 0; i < exportClause.namedChildCount; i++) {
|
||||
const specifier = exportClause.namedChild(i);
|
||||
if (specifier?.type !== 'export_specifier') continue;
|
||||
@@ -114,60 +150,39 @@ export function extractTsNamedBindings(importNode: SyntaxNode): NamedBinding[] |
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractPythonNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
|
||||
// Handle: from x import User, Repo as R
|
||||
if (importNode.type === 'import_from_statement') {
|
||||
const bindings: NamedBinding[] = [];
|
||||
for (let i = 0; i < importNode.namedChildCount; i++) {
|
||||
const child = importNode.namedChild(i);
|
||||
if (!child) continue;
|
||||
export function extractPythonNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
|
||||
// Only from import_from_statement, not plain import_statement
|
||||
if (importNode.type !== 'import_from_statement') return undefined;
|
||||
|
||||
if (child.type === 'dotted_name') {
|
||||
// Skip the module_name (first dotted_name is the source module)
|
||||
const fieldName = importNode.childForFieldName?.('module_name');
|
||||
if (fieldName && child.startIndex === fieldName.startIndex) continue;
|
||||
const bindings: { local: string; exported: string }[] = [];
|
||||
for (let i = 0; i < importNode.namedChildCount; i++) {
|
||||
const child = importNode.namedChild(i);
|
||||
if (!child) continue;
|
||||
|
||||
// This is an imported name: from x import User
|
||||
const name = child.text;
|
||||
if (name) bindings.push({ local: name, exported: name });
|
||||
}
|
||||
if (child.type === 'dotted_name') {
|
||||
// Skip the module_name (first dotted_name is the source module)
|
||||
const fieldName = importNode.childForFieldName?.('module_name');
|
||||
if (fieldName && child.startIndex === fieldName.startIndex) continue;
|
||||
|
||||
if (child.type === 'aliased_import') {
|
||||
// from x import Repo as R
|
||||
const dottedName = findChild(child, 'dotted_name');
|
||||
const aliasIdent = findChild(child, 'identifier');
|
||||
if (dottedName && aliasIdent) {
|
||||
bindings.push({ local: aliasIdent.text, exported: dottedName.text });
|
||||
}
|
||||
}
|
||||
// This is an imported name: from x import User
|
||||
const name = child.text;
|
||||
if (name) bindings.push({ local: name, exported: name });
|
||||
}
|
||||
|
||||
return bindings.length > 0 ? bindings : undefined;
|
||||
}
|
||||
|
||||
// Handle: import numpy as np (import_statement with aliased_import child)
|
||||
// Tagged with isModuleAlias so applyImportResult routes these directly to
|
||||
// moduleAliasMap (e.g. "np" → "numpy.py") instead of namedImportMap.
|
||||
if (importNode.type === 'import_statement') {
|
||||
const bindings: NamedBinding[] = [];
|
||||
for (let i = 0; i < importNode.namedChildCount; i++) {
|
||||
const child = importNode.namedChild(i);
|
||||
if (!child || child.type !== 'aliased_import') continue;
|
||||
|
||||
if (child.type === 'aliased_import') {
|
||||
// from x import Repo as R
|
||||
const dottedName = findChild(child, 'dotted_name');
|
||||
const aliasIdent = findChild(child, 'identifier');
|
||||
if (dottedName && aliasIdent) {
|
||||
bindings.push({ local: aliasIdent.text, exported: dottedName.text, isModuleAlias: true });
|
||||
bindings.push({ local: aliasIdent.text, exported: dottedName.text });
|
||||
}
|
||||
}
|
||||
|
||||
return bindings.length > 0 ? bindings : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return bindings.length > 0 ? bindings : undefined;
|
||||
}
|
||||
|
||||
export function extractKotlinNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
|
||||
export function extractKotlinNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
|
||||
// import_header > identifier + import_alias > simple_identifier
|
||||
if (importNode.type !== 'import_header') return undefined;
|
||||
|
||||
@@ -186,32 +201,26 @@ export function extractKotlinNamedBindings(importNode: SyntaxNode): NamedBinding
|
||||
}
|
||||
|
||||
// Non-aliased: import com.example.User → local="User", exported="User"
|
||||
// Also handles top-level function imports: import models.getUser → local="getUser"
|
||||
// Skip wildcard imports (ending in *)
|
||||
if (fullText.endsWith('.*') || fullText.endsWith('*')) return undefined;
|
||||
// Skip class-member imports (e.g., import util.OneArg.writeAudit) where the
|
||||
// second-to-last segment is PascalCase (a class name). Multiple member imports
|
||||
// Skip lowercase last segments — those are member/function imports (e.g.,
|
||||
// import util.OneArg.writeAudit), not class imports. Multiple member imports
|
||||
// with the same function name would collide in NamedImportMap, breaking
|
||||
// arity-based disambiguation. Top-level function imports (import models.getUser)
|
||||
// and class imports (import models.User) have package-only prefixes.
|
||||
const segments = fullText.split('.');
|
||||
if (segments.length >= 3) {
|
||||
const parentSegment = segments[segments.length - 2];
|
||||
if (parentSegment[0] && parentSegment[0] === parentSegment[0].toUpperCase()) return undefined;
|
||||
}
|
||||
// arity-based disambiguation.
|
||||
if (exportedName[0] && exportedName[0] === exportedName[0].toLowerCase()) return undefined;
|
||||
return [{ local: exportedName, exported: exportedName }];
|
||||
}
|
||||
|
||||
export function extractRustNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
|
||||
export function extractRustNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
|
||||
// use_declaration may contain use_as_clause at any depth
|
||||
if (importNode.type !== 'use_declaration') return undefined;
|
||||
|
||||
const bindings: NamedBinding[] = [];
|
||||
const bindings: { local: string; exported: string }[] = [];
|
||||
collectRustBindings(importNode, bindings);
|
||||
return bindings.length > 0 ? bindings : undefined;
|
||||
}
|
||||
|
||||
function collectRustBindings(node: SyntaxNode, bindings: NamedBinding[]): void {
|
||||
function collectRustBindings(node: any, bindings: { local: string; exported: string }[]): void {
|
||||
if (node.type === 'use_as_clause') {
|
||||
// First identifier = exported name, second identifier = local alias
|
||||
const idents: string[] = [];
|
||||
@@ -269,22 +278,15 @@ function collectRustBindings(node: SyntaxNode, bindings: NamedBinding[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function extractPhpNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
|
||||
export function extractPhpNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
|
||||
// namespace_use_declaration > namespace_use_clause* (flat)
|
||||
// namespace_use_declaration > namespace_use_group > namespace_use_clause* (grouped)
|
||||
if (importNode.type !== 'namespace_use_declaration') return undefined;
|
||||
|
||||
// Skip 'use function' and 'use const' declarations — these import callables/constants,
|
||||
// not class types, and should not be added to namedImportMap as type bindings.
|
||||
const useTypeNode = importNode.childForFieldName?.('type');
|
||||
if (useTypeNode && (useTypeNode.text === 'function' || useTypeNode.text === 'const')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bindings: NamedBinding[] = [];
|
||||
const bindings: { local: string; exported: string }[] = [];
|
||||
|
||||
// Collect all clauses — from direct children AND from namespace_use_group
|
||||
const clauses: SyntaxNode[] = [];
|
||||
const clauses: any[] = [];
|
||||
for (let i = 0; i < importNode.namedChildCount; i++) {
|
||||
const child = importNode.namedChild(i);
|
||||
if (child?.type === 'namespace_use_clause') {
|
||||
@@ -299,8 +301,8 @@ export function extractPhpNamedBindings(importNode: SyntaxNode): NamedBinding[]
|
||||
|
||||
for (const clause of clauses) {
|
||||
// Flat imports: qualified_name + name (alias)
|
||||
let qualifiedName: SyntaxNode | null = null;
|
||||
const names: SyntaxNode[] = [];
|
||||
let qualifiedName: any = null;
|
||||
const names: any[] = [];
|
||||
for (let j = 0; j < clause.namedChildCount; j++) {
|
||||
const child = clause.namedChild(j);
|
||||
if (child?.type === 'qualified_name') qualifiedName = child;
|
||||
@@ -328,55 +330,35 @@ export function extractPhpNamedBindings(importNode: SyntaxNode): NamedBinding[]
|
||||
return bindings.length > 0 ? bindings : undefined;
|
||||
}
|
||||
|
||||
export function extractCsharpNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
|
||||
// using_directive — three forms:
|
||||
// using Alias = NS.Type; → aliasIdent + qualifiedName
|
||||
// using static NS.Type; → static + qualifiedName (no alias)
|
||||
// using NS; → qualifiedName only (namespace, not capturable)
|
||||
export function extractCsharpNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
|
||||
// using_directive with identifier (alias) + qualified_name (target)
|
||||
if (importNode.type !== 'using_directive') return undefined;
|
||||
|
||||
let aliasIdent: SyntaxNode | null = null;
|
||||
let qualifiedName: SyntaxNode | null = null;
|
||||
let isStatic = false;
|
||||
for (let i = 0; i < importNode.childCount; i++) {
|
||||
const child = importNode.child(i);
|
||||
if (child?.text === 'static') isStatic = true;
|
||||
}
|
||||
let aliasIdent: any = null;
|
||||
let qualifiedName: any = null;
|
||||
for (let i = 0; i < importNode.namedChildCount; i++) {
|
||||
const child = importNode.namedChild(i);
|
||||
if (child?.type === 'identifier' && !aliasIdent) aliasIdent = child;
|
||||
else if (child?.type === 'qualified_name') qualifiedName = child;
|
||||
}
|
||||
|
||||
// Form 1: using Alias = NS.Type;
|
||||
if (aliasIdent && qualifiedName) {
|
||||
const fullText = qualifiedName.text;
|
||||
const exportedName = fullText.includes('.') ? fullText.split('.').pop()! : fullText;
|
||||
return [{ local: aliasIdent.text, exported: exportedName }];
|
||||
}
|
||||
if (!aliasIdent || !qualifiedName) return undefined;
|
||||
|
||||
// Form 2: using static NS.Type; — last segment is the class name
|
||||
if (isStatic && qualifiedName) {
|
||||
const fullText = qualifiedName.text;
|
||||
const lastSegment = fullText.includes('.') ? fullText.split('.').pop()! : fullText;
|
||||
return [{ local: lastSegment, exported: lastSegment }];
|
||||
}
|
||||
const fullText = qualifiedName.text;
|
||||
const exportedName = fullText.includes('.') ? fullText.split('.').pop()! : fullText;
|
||||
|
||||
// Form 3: using NS; — namespace import, can't resolve to per-symbol bindings
|
||||
return undefined;
|
||||
return [{ local: aliasIdent.text, exported: exportedName }];
|
||||
}
|
||||
|
||||
export function extractJavaNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
|
||||
export function extractJavaNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
|
||||
// import_declaration > scoped_identifier "com.example.models.User"
|
||||
// Wildcard imports (.*) don't produce named bindings
|
||||
if (importNode.type !== 'import_declaration') return undefined;
|
||||
|
||||
// Check for asterisk (wildcard import) and static modifier
|
||||
let isStatic = false;
|
||||
// Check for asterisk (wildcard import) — skip those
|
||||
for (let i = 0; i < importNode.childCount; i++) {
|
||||
const child = importNode.child(i);
|
||||
if (child?.type === 'asterisk') return undefined;
|
||||
if (child?.text === 'static') isStatic = true;
|
||||
}
|
||||
|
||||
const scopedId = findChild(importNode, 'scoped_identifier');
|
||||
@@ -386,11 +368,17 @@ export function extractJavaNamedBindings(importNode: SyntaxNode): NamedBinding[]
|
||||
const lastDot = fullText.lastIndexOf('.');
|
||||
if (lastDot === -1) return undefined;
|
||||
|
||||
const name = fullText.slice(lastDot + 1);
|
||||
// Non-static: skip lowercase names — those are package imports, not class imports.
|
||||
// Static: allow lowercase — `import static models.UserFactory.getUser` imports a method.
|
||||
if (!isStatic && name[0] && name[0] === name[0].toLowerCase()) return undefined;
|
||||
const className = fullText.slice(lastDot + 1);
|
||||
// Skip lowercase names — those are package imports, not class imports
|
||||
if (className[0] && className[0] === className[0].toLowerCase()) return undefined;
|
||||
|
||||
return [{ local: name, exported: name }];
|
||||
return [{ local: className, exported: className }];
|
||||
}
|
||||
|
||||
function findChild(node: any, type: string): any {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === type) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { SymbolTable } from './symbol-table.js';
|
||||
import { ASTCache } from './ast-cache.js';
|
||||
import { getLanguageFromFilename, yieldToEventLoop, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature, getLabelFromCaptures } from './utils.js';
|
||||
import { getLanguageFromFilename, yieldToEventLoop, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature } from './utils.js';
|
||||
import { extractPropertyDeclaredType } from './type-extractors/shared.js';
|
||||
import { isNodeExported } from './export-detection.js';
|
||||
import { detectFrameworkFromAST } from './framework-detection.js';
|
||||
import { typeConfigs } from './type-extractors/index.js';
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
import { WorkerPool } from './workers/worker-pool.js';
|
||||
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings, FileTypeEnvBindings } from './workers/parse-worker.js';
|
||||
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
|
||||
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';
|
||||
|
||||
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
|
||||
@@ -23,9 +24,12 @@ export interface WorkerExtractedData {
|
||||
heritage: ExtractedHeritage[];
|
||||
routes: ExtractedRoute[];
|
||||
constructorBindings: FileConstructorBindings[];
|
||||
typeEnvBindings: FileTypeEnvBindings[];
|
||||
}
|
||||
|
||||
// isNodeExported imported from ./export-detection.js (shared module)
|
||||
// Re-export for backward compatibility with any external consumers
|
||||
export { isNodeExported } from './export-detection.js';
|
||||
|
||||
// ============================================================================
|
||||
// Worker-based parallel parsing
|
||||
// ============================================================================
|
||||
@@ -45,7 +49,7 @@ const processParsingWithWorkers = async (
|
||||
if (lang) parseableFiles.push({ path: file.path, content: file.content });
|
||||
}
|
||||
|
||||
if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], typeEnvBindings: [] };
|
||||
if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [] };
|
||||
|
||||
const total = files.length;
|
||||
|
||||
@@ -64,7 +68,6 @@ const processParsingWithWorkers = async (
|
||||
const allHeritage: ExtractedHeritage[] = [];
|
||||
const allRoutes: ExtractedRoute[] = [];
|
||||
const allConstructorBindings: FileConstructorBindings[] = [];
|
||||
const allTypeEnvBindings: FileTypeEnvBindings[] = [];
|
||||
for (const result of chunkResults) {
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode({
|
||||
@@ -95,7 +98,6 @@ const processParsingWithWorkers = async (
|
||||
allHeritage.push(...result.heritage);
|
||||
allRoutes.push(...result.routes);
|
||||
allConstructorBindings.push(...result.constructorBindings);
|
||||
allTypeEnvBindings.push(...result.typeEnvBindings);
|
||||
}
|
||||
|
||||
// Merge and log skipped languages from workers
|
||||
@@ -114,7 +116,7 @@ const processParsingWithWorkers = async (
|
||||
|
||||
// Final progress
|
||||
onFileProgress?.(total, total, 'done');
|
||||
return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings, typeEnvBindings: allTypeEnvBindings };
|
||||
return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -191,14 +193,59 @@ const processParsingSequential = async (
|
||||
captureMap[c.name] = c.node;
|
||||
});
|
||||
|
||||
const nodeLabel = getLabelFromCaptures(captureMap, language);
|
||||
if (!nodeLabel) return;
|
||||
if (captureMap['import']) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (captureMap['call']) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameNode = captureMap['name'];
|
||||
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
|
||||
if (!nameNode && nodeLabel !== 'Constructor') return;
|
||||
if (!nameNode && !captureMap['definition.constructor']) return;
|
||||
const nodeName = nameNode ? nameNode.text : 'init';
|
||||
|
||||
let nodeLabel: NodeLabel = 'CodeElement';
|
||||
|
||||
if (captureMap['definition.function']) {
|
||||
// C/C++: @definition.function is broad and also matches inline class methods (inside
|
||||
// a class/struct body). Those are already captured by @definition.method, so skip
|
||||
// the duplicate Function entry to prevent double-indexing in globalIndex.
|
||||
if (language === SupportedLanguages.CPlusPlus || language === SupportedLanguages.C) {
|
||||
let ancestor = captureMap['definition.function']?.parent;
|
||||
while (ancestor) {
|
||||
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') {
|
||||
break;
|
||||
}
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
if (ancestor) return; // inside a class body — handled by @definition.method
|
||||
}
|
||||
nodeLabel = 'Function';
|
||||
}
|
||||
else if (captureMap['definition.class']) nodeLabel = 'Class';
|
||||
else if (captureMap['definition.interface']) nodeLabel = 'Interface';
|
||||
else if (captureMap['definition.method']) nodeLabel = 'Method';
|
||||
else if (captureMap['definition.struct']) nodeLabel = 'Struct';
|
||||
else if (captureMap['definition.enum']) nodeLabel = 'Enum';
|
||||
else if (captureMap['definition.namespace']) nodeLabel = 'Namespace';
|
||||
else if (captureMap['definition.module']) nodeLabel = 'Module';
|
||||
else if (captureMap['definition.trait']) nodeLabel = 'Trait';
|
||||
else if (captureMap['definition.impl']) nodeLabel = 'Impl';
|
||||
else if (captureMap['definition.type']) nodeLabel = 'TypeAlias';
|
||||
else if (captureMap['definition.const']) nodeLabel = 'Const';
|
||||
else if (captureMap['definition.static']) nodeLabel = 'Static';
|
||||
else if (captureMap['definition.typedef']) nodeLabel = 'Typedef';
|
||||
else if (captureMap['definition.macro']) nodeLabel = 'Macro';
|
||||
else if (captureMap['definition.union']) nodeLabel = 'Union';
|
||||
else if (captureMap['definition.property']) nodeLabel = 'Property';
|
||||
else if (captureMap['definition.record']) nodeLabel = 'Record';
|
||||
else if (captureMap['definition.delegate']) nodeLabel = 'Delegate';
|
||||
else if (captureMap['definition.annotation']) nodeLabel = 'Annotation';
|
||||
else if (captureMap['definition.constructor']) nodeLabel = 'Constructor';
|
||||
else if (captureMap['definition.template']) nodeLabel = 'Template';
|
||||
|
||||
const definitionNodeForRange = getDefinitionNodeFromCaptures(captureMap);
|
||||
const startLine = definitionNodeForRange ? definitionNodeForRange.startPosition.row : (nameNode ? nameNode.startPosition.row : 0);
|
||||
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
processImportsFromExtracted,
|
||||
buildImportResolutionContext
|
||||
} from './import-processor.js';
|
||||
import { EMPTY_INDEX } from './resolvers/index.js';
|
||||
import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted, seedCrossFileReceiverTypes, buildImportedReturnTypes, buildImportedRawReturnTypes, type ExportedTypeMap, buildExportedTypeMapFromGraph } from './call-processor.js';
|
||||
import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted } from './call-processor.js';
|
||||
import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js';
|
||||
import { computeMRO } from './mro-processor.js';
|
||||
import { processCommunities } from './community-processor.js';
|
||||
@@ -19,7 +18,6 @@ import { PipelineProgress, PipelineResult } from '../../types/pipeline.js';
|
||||
import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js';
|
||||
import { getLanguageFromFilename } from './utils.js';
|
||||
import { isLanguageAvailable } from '../tree-sitter/parser-loader.js';
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
import { createWorkerPool, WorkerPool } from './workers/worker-pool.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -27,62 +25,6 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
/** A group of files with no mutual dependencies, safe to process in parallel. */
|
||||
type IndependentFileGroup = readonly string[];
|
||||
|
||||
/** Kahn's algorithm: returns files grouped by topological level.
|
||||
* Files in the same level have no mutual dependencies — safe to process in parallel.
|
||||
* Files in cycles are returned as a final group (no cross-cycle propagation). */
|
||||
export function topologicalLevelSort(
|
||||
importMap: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
): { levels: readonly IndependentFileGroup[]; cycleCount: number } {
|
||||
// Build in-degree map and reverse dependency map
|
||||
const inDegree = new Map<string, number>();
|
||||
const reverseDeps = new Map<string, string[]>();
|
||||
|
||||
for (const [file, deps] of importMap) {
|
||||
if (!inDegree.has(file)) inDegree.set(file, 0);
|
||||
for (const dep of deps) {
|
||||
if (!inDegree.has(dep)) inDegree.set(dep, 0);
|
||||
// file imports dep, so dep must be processed before file
|
||||
// In Kahn's terms: dep → file (dep is a prerequisite of file)
|
||||
inDegree.set(file, (inDegree.get(file) ?? 0) + 1);
|
||||
let rev = reverseDeps.get(dep);
|
||||
if (!rev) { rev = []; reverseDeps.set(dep, rev); }
|
||||
rev.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// BFS from zero-in-degree nodes, grouping by level
|
||||
const levels: string[][] = [];
|
||||
let currentLevel = [...inDegree.entries()]
|
||||
.filter(([, d]) => d === 0)
|
||||
.map(([f]) => f);
|
||||
|
||||
while (currentLevel.length > 0) {
|
||||
levels.push(currentLevel);
|
||||
const nextLevel: string[] = [];
|
||||
for (const file of currentLevel) {
|
||||
for (const dependent of reverseDeps.get(file) ?? []) {
|
||||
const newDeg = (inDegree.get(dependent) ?? 1) - 1;
|
||||
inDegree.set(dependent, newDeg);
|
||||
if (newDeg === 0) nextLevel.push(dependent);
|
||||
}
|
||||
}
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
// Files still with positive in-degree are in cycles — add as final group
|
||||
const cycleFiles = [...inDegree.entries()]
|
||||
.filter(([, d]) => d > 0)
|
||||
.map(([f]) => f);
|
||||
if (cycleFiles.length > 0) {
|
||||
levels.push(cycleFiles);
|
||||
}
|
||||
|
||||
return { levels, cycleCount: cycleFiles.length };
|
||||
}
|
||||
|
||||
/** Max bytes of source content to load per parse chunk. Each chunk's source +
|
||||
* parsed ASTs + extracted records + worker serialization overhead all live in
|
||||
* memory simultaneously, so this must be conservative. 20MB source ≈ 200-400MB
|
||||
@@ -92,295 +34,6 @@ const CHUNK_BYTE_BUDGET = 20 * 1024 * 1024; // 20MB
|
||||
/** Max AST trees to keep in LRU cache */
|
||||
const AST_CACHE_CAP = 50;
|
||||
|
||||
/** Minimum percentage of files that must benefit from cross-file seeding to justify the re-resolution pass. */
|
||||
const CROSS_FILE_SKIP_THRESHOLD = 0.03;
|
||||
/** Hard cap on files re-processed during cross-file propagation. */
|
||||
const MAX_CROSS_FILE_REPROCESS = 2000;
|
||||
|
||||
/** Node labels that represent top-level importable symbols.
|
||||
* Excludes Method, Property, Constructor (accessed via receiver, not directly imported),
|
||||
* and structural labels (File, Folder, Package, Module, Project, etc.). */
|
||||
const IMPORTABLE_SYMBOL_LABELS = new Set([
|
||||
'Function', 'Class', 'Interface', 'Struct', 'Enum', 'Trait',
|
||||
'TypeAlias', 'Const', 'Static', 'Record', 'Union', 'Typedef', 'Macro',
|
||||
]);
|
||||
|
||||
/** Max synthetic bindings per importing file — prevents memory bloat for
|
||||
* C/C++ files that include many large headers. */
|
||||
const MAX_SYNTHETIC_BINDINGS_PER_FILE = 1000;
|
||||
|
||||
/** Languages with whole-module import semantics (no per-symbol named imports).
|
||||
* For these languages, namedImportMap entries are synthesized from graph-exported
|
||||
* symbols after parsing, enabling Phase 14 cross-file binding propagation.
|
||||
*
|
||||
* Note: Python is intentionally excluded here. `import models` is a namespace import
|
||||
* (not wildcard symbol expansion) — expanding all exported symbols produces ambiguous
|
||||
* bindings when multiple modules export the same name (e.g. models.User vs auth.User).
|
||||
* Python module aliases are built in synthesizeWildcardImportBindings via moduleAliasMap. */
|
||||
const WILDCARD_IMPORT_LANGUAGES = new Set([
|
||||
SupportedLanguages.Go,
|
||||
SupportedLanguages.Ruby,
|
||||
SupportedLanguages.C,
|
||||
SupportedLanguages.CPlusPlus,
|
||||
SupportedLanguages.Swift,
|
||||
]);
|
||||
|
||||
/** Languages that require synthesizeWildcardImportBindings to run before call resolution.
|
||||
* Superset of WILDCARD_IMPORT_LANGUAGES — includes Python for moduleAliasMap building. */
|
||||
const SYNTHESIS_LANGUAGES = new Set([...WILDCARD_IMPORT_LANGUAGES, SupportedLanguages.Python]);
|
||||
|
||||
/** Synthesize namedImportMap entries for languages with whole-module imports.
|
||||
* These languages (Go, Ruby, C/C++, Swift, Python) import all exported symbols from a
|
||||
* file, not specific named symbols. After parsing, we know which symbols each file
|
||||
* exports (via graph isExported), so we can expand ImportMap edges into per-symbol
|
||||
* bindings that Phase 14 can use for cross-file type propagation. */
|
||||
function synthesizeWildcardImportBindings(
|
||||
graph: ReturnType<typeof createKnowledgeGraph>,
|
||||
ctx: ReturnType<typeof createResolutionContext>,
|
||||
): number {
|
||||
// Pre-compute exported symbols per file from graph (single pass)
|
||||
const exportedSymbolsByFile = new Map<string, { name: string; filePath: string }[]>();
|
||||
graph.forEachNode(node => {
|
||||
if (!node.properties?.isExported) return;
|
||||
if (!IMPORTABLE_SYMBOL_LABELS.has(node.label)) return;
|
||||
const fp = node.properties.filePath;
|
||||
const name = node.properties.name;
|
||||
if (!fp || !name) return;
|
||||
let symbols = exportedSymbolsByFile.get(fp);
|
||||
if (!symbols) { symbols = []; exportedSymbolsByFile.set(fp, symbols); }
|
||||
symbols.push({ name, filePath: fp });
|
||||
});
|
||||
|
||||
if (exportedSymbolsByFile.size === 0) return 0;
|
||||
|
||||
// Build a merged import map: ctx.importMap has file-based imports (Ruby, C/C++),
|
||||
// but Go/C# package imports use graph IMPORTS edges + PackageMap instead.
|
||||
// Collect graph-level IMPORTS edges for wildcard languages missing from ctx.importMap.
|
||||
const FILE_PREFIX = 'File:';
|
||||
const graphImports = new Map<string, Set<string>>();
|
||||
graph.forEachRelationship(rel => {
|
||||
if (rel.type !== 'IMPORTS') return;
|
||||
if (!rel.sourceId.startsWith(FILE_PREFIX) || !rel.targetId.startsWith(FILE_PREFIX)) return;
|
||||
const srcFile = rel.sourceId.slice(FILE_PREFIX.length);
|
||||
const tgtFile = rel.targetId.slice(FILE_PREFIX.length);
|
||||
const lang = getLanguageFromFilename(srcFile);
|
||||
if (!lang || !WILDCARD_IMPORT_LANGUAGES.has(lang)) return;
|
||||
// Only add if not already in ctx.importMap (avoid duplicates)
|
||||
if (ctx.importMap.get(srcFile)?.has(tgtFile)) return;
|
||||
let set = graphImports.get(srcFile);
|
||||
if (!set) { set = new Set(); graphImports.set(srcFile, set); }
|
||||
set.add(tgtFile);
|
||||
});
|
||||
|
||||
let totalSynthesized = 0;
|
||||
|
||||
// Helper: synthesize bindings for a file given its imported files
|
||||
const synthesizeForFile = (filePath: string, importedFiles: Iterable<string>) => {
|
||||
let fileBindings = ctx.namedImportMap.get(filePath);
|
||||
let fileCount = fileBindings?.size ?? 0;
|
||||
|
||||
for (const importedFile of importedFiles) {
|
||||
const exportedSymbols = exportedSymbolsByFile.get(importedFile);
|
||||
if (!exportedSymbols) continue;
|
||||
|
||||
for (const sym of exportedSymbols) {
|
||||
if (fileCount >= MAX_SYNTHETIC_BINDINGS_PER_FILE) return;
|
||||
if (fileBindings?.has(sym.name)) continue;
|
||||
|
||||
if (!fileBindings) {
|
||||
fileBindings = new Map();
|
||||
ctx.namedImportMap.set(filePath, fileBindings);
|
||||
}
|
||||
fileBindings.set(sym.name, {
|
||||
sourcePath: importedFile,
|
||||
exportedName: sym.name,
|
||||
});
|
||||
fileCount++;
|
||||
totalSynthesized++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Process files from ctx.importMap (Ruby, C/C++, Swift file-based imports)
|
||||
for (const [filePath, importedFiles] of ctx.importMap) {
|
||||
const lang = getLanguageFromFilename(filePath);
|
||||
if (!lang || !WILDCARD_IMPORT_LANGUAGES.has(lang)) continue;
|
||||
synthesizeForFile(filePath, importedFiles);
|
||||
}
|
||||
|
||||
// Process files from graph IMPORTS edges (Go and other wildcard-import languages)
|
||||
for (const [filePath, importedFiles] of graphImports) {
|
||||
synthesizeForFile(filePath, importedFiles);
|
||||
}
|
||||
|
||||
// Build module alias map for Python namespace imports.
|
||||
// `import models` in app.py → ctx.moduleAliasMap['app.py']['models'] = 'models.py'
|
||||
// Enables `models.User()` to resolve to models.py:User without ambiguous symbol expansion.
|
||||
const buildPythonModuleAliasForFile = (callerFile: string, importedFiles: Iterable<string>) => {
|
||||
let aliasMap = ctx.moduleAliasMap.get(callerFile);
|
||||
for (const importedFile of importedFiles) {
|
||||
// Derive the module alias from the imported filename stem (e.g. "models.py" → "models")
|
||||
const lastSlash = importedFile.lastIndexOf('/');
|
||||
const base = lastSlash >= 0 ? importedFile.slice(lastSlash + 1) : importedFile;
|
||||
const dot = base.lastIndexOf('.');
|
||||
const stem = dot >= 0 ? base.slice(0, dot) : base;
|
||||
if (!stem) continue;
|
||||
if (!aliasMap) {
|
||||
aliasMap = new Map();
|
||||
ctx.moduleAliasMap.set(callerFile, aliasMap);
|
||||
}
|
||||
aliasMap.set(stem, importedFile);
|
||||
}
|
||||
};
|
||||
|
||||
for (const [filePath, importedFiles] of ctx.importMap) {
|
||||
if (getLanguageFromFilename(filePath) !== SupportedLanguages.Python) continue;
|
||||
buildPythonModuleAliasForFile(filePath, importedFiles);
|
||||
}
|
||||
|
||||
return totalSynthesized;
|
||||
}
|
||||
|
||||
/** Phase 14: Cross-file binding propagation.
|
||||
* Seeds downstream files with resolved type bindings from upstream exports.
|
||||
* Files are processed in topological import order so upstream bindings are
|
||||
* available when downstream files are re-resolved. */
|
||||
async function runCrossFileBindingPropagation(
|
||||
graph: ReturnType<typeof createKnowledgeGraph>,
|
||||
ctx: ReturnType<typeof createResolutionContext>,
|
||||
exportedTypeMap: ExportedTypeMap,
|
||||
allPaths: string[],
|
||||
totalFiles: number,
|
||||
repoPath: string,
|
||||
pipelineStart: number,
|
||||
onProgress: (progress: PipelineProgress) => void,
|
||||
): Promise<void> {
|
||||
// For the worker path, buildTypeEnv runs inside workers without SymbolTable,
|
||||
// so exported bindings must be collected from graph + SymbolTable in main thread.
|
||||
if (exportedTypeMap.size === 0 && graph.nodeCount > 0) {
|
||||
const graphExports = buildExportedTypeMapFromGraph(graph, ctx.symbols);
|
||||
for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports);
|
||||
}
|
||||
|
||||
if (exportedTypeMap.size === 0 || ctx.namedImportMap.size === 0) return;
|
||||
|
||||
const allPathSet = new Set(allPaths);
|
||||
const { levels, cycleCount } = topologicalLevelSort(ctx.importMap);
|
||||
|
||||
// Cycle diagnostic: only log when actual cycles detected (cycleCount from Kahn's BFS)
|
||||
if (isDev && cycleCount > 0) {
|
||||
console.log(`🔄 ${cycleCount} files in import cycles (skipped for cross-file propagation)`);
|
||||
}
|
||||
|
||||
// Quick count of files with cross-file binding gaps (early exit once threshold exceeded)
|
||||
let filesWithGaps = 0;
|
||||
const gapThreshold = Math.max(1, Math.ceil(totalFiles * CROSS_FILE_SKIP_THRESHOLD));
|
||||
outer: for (const level of levels) {
|
||||
for (const filePath of level) {
|
||||
const imports = ctx.namedImportMap.get(filePath);
|
||||
if (!imports) continue;
|
||||
for (const [, binding] of imports) {
|
||||
const upstream = exportedTypeMap.get(binding.sourcePath);
|
||||
if (upstream?.has(binding.exportedName)) { filesWithGaps++; break; }
|
||||
const def = ctx.symbols.lookupExactFull(binding.sourcePath, binding.exportedName);
|
||||
if (def?.returnType) { filesWithGaps++; break; }
|
||||
}
|
||||
if (filesWithGaps >= gapThreshold) break outer;
|
||||
}
|
||||
}
|
||||
|
||||
const gapRatio = totalFiles > 0 ? filesWithGaps / totalFiles : 0;
|
||||
if (gapRatio < CROSS_FILE_SKIP_THRESHOLD && filesWithGaps < gapThreshold) {
|
||||
if (isDev) {
|
||||
console.log(`⏭️ Cross-file re-resolution skipped (${filesWithGaps}/${totalFiles} files, ${(gapRatio * 100).toFixed(1)}% < ${CROSS_FILE_SKIP_THRESHOLD * 100}% threshold)`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 82,
|
||||
message: `Cross-file type propagation (${filesWithGaps}+ files)...`,
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
let crossFileResolved = 0;
|
||||
const crossFileStart = Date.now();
|
||||
let astCache = createASTCache(AST_CACHE_CAP);
|
||||
|
||||
for (const level of levels) {
|
||||
const levelCandidates: { filePath: string; seeded: Map<string, string>; importedReturns: ReadonlyMap<string, string>; importedRawReturns: ReadonlyMap<string, string> }[] = [];
|
||||
for (const filePath of level) {
|
||||
if (crossFileResolved + levelCandidates.length >= MAX_CROSS_FILE_REPROCESS) break;
|
||||
const imports = ctx.namedImportMap.get(filePath);
|
||||
if (!imports) continue;
|
||||
|
||||
const seeded = new Map<string, string>();
|
||||
for (const [localName, binding] of imports) {
|
||||
const upstream = exportedTypeMap.get(binding.sourcePath);
|
||||
if (upstream) {
|
||||
const type = upstream.get(binding.exportedName);
|
||||
if (type) seeded.set(localName, type);
|
||||
}
|
||||
}
|
||||
|
||||
const importedReturns = buildImportedReturnTypes(filePath, ctx.namedImportMap, ctx.symbols);
|
||||
const importedRawReturns = buildImportedRawReturnTypes(filePath, ctx.namedImportMap, ctx.symbols);
|
||||
if (seeded.size === 0 && importedReturns.size === 0) continue;
|
||||
if (!allPathSet.has(filePath)) continue;
|
||||
|
||||
const lang = getLanguageFromFilename(filePath);
|
||||
if (!lang || !isLanguageAvailable(lang)) continue;
|
||||
|
||||
levelCandidates.push({ filePath, seeded, importedReturns, importedRawReturns });
|
||||
}
|
||||
|
||||
if (levelCandidates.length === 0) continue;
|
||||
|
||||
const levelPaths = levelCandidates.map(c => c.filePath);
|
||||
const contentMap = await readFileContents(repoPath, levelPaths);
|
||||
|
||||
for (const { filePath, seeded, importedReturns, importedRawReturns } of levelCandidates) {
|
||||
const content = contentMap.get(filePath);
|
||||
if (!content) continue;
|
||||
|
||||
const reFile = [{ path: filePath, content }];
|
||||
const bindings = new Map<string, ReadonlyMap<string, string>>();
|
||||
if (seeded.size > 0) bindings.set(filePath, seeded);
|
||||
|
||||
const importedReturnTypesMap = new Map<string, ReadonlyMap<string, string>>();
|
||||
if (importedReturns.size > 0) {
|
||||
importedReturnTypesMap.set(filePath, importedReturns);
|
||||
}
|
||||
|
||||
const importedRawReturnTypesMap = new Map<string, ReadonlyMap<string, string>>();
|
||||
if (importedRawReturns.size > 0) {
|
||||
importedRawReturnTypesMap.set(filePath, importedRawReturns);
|
||||
}
|
||||
|
||||
await processCalls(graph, reFile, astCache, ctx, undefined, exportedTypeMap, bindings.size > 0 ? bindings : undefined, importedReturnTypesMap.size > 0 ? importedReturnTypesMap : undefined, importedRawReturnTypesMap.size > 0 ? importedRawReturnTypesMap : undefined);
|
||||
crossFileResolved++;
|
||||
}
|
||||
|
||||
if (crossFileResolved >= MAX_CROSS_FILE_REPROCESS) {
|
||||
if (isDev) console.log(`⚠️ Cross-file re-resolution capped at ${MAX_CROSS_FILE_REPROCESS} files`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
astCache.clear();
|
||||
|
||||
if (isDev) {
|
||||
const elapsed = Date.now() - crossFileStart;
|
||||
const totalElapsed = Date.now() - pipelineStart;
|
||||
const reResolutionPct = totalElapsed > 0 ? ((elapsed / totalElapsed) * 100).toFixed(1) : '0';
|
||||
console.log(
|
||||
`🔗 Cross-file re-resolution: ${crossFileResolved} candidates re-processed` +
|
||||
` in ${elapsed}ms (${reResolutionPct}% of total ingestion time so far)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface PipelineOptions {
|
||||
/** Skip MRO, community detection, and process extraction for faster test runs. */
|
||||
skipGraphPhases?: boolean;
|
||||
@@ -395,7 +48,6 @@ export const runPipelineFromRepo = async (
|
||||
const ctx = createResolutionContext();
|
||||
const symbolTable = ctx.symbols;
|
||||
let astCache = createASTCache(AST_CACHE_CAP);
|
||||
const pipelineStart = Date.now();
|
||||
|
||||
const cleanup = () => {
|
||||
astCache.clear();
|
||||
@@ -565,17 +217,6 @@ export const runPipelineFromRepo = async (
|
||||
// are already registered). This trades ~5% cross-chunk resolution accuracy for
|
||||
// 200-400MB less memory — critical for Linux-kernel-scale repos.
|
||||
const sequentialChunkPaths: string[][] = [];
|
||||
// Pre-compute which chunks need synthesis — O(1) lookup per chunk.
|
||||
const chunkNeedsSynthesis = chunks.map(paths =>
|
||||
paths.some(p => {
|
||||
const lang = getLanguageFromFilename(p);
|
||||
return lang != null && SYNTHESIS_LANGUAGES.has(lang);
|
||||
}),
|
||||
);
|
||||
// Phase 14: Collect exported type bindings for cross-file propagation
|
||||
const exportedTypeMap: ExportedTypeMap = new Map();
|
||||
// Accumulate file-scope TypeEnv bindings from workers (closes worker/sequential quality gap)
|
||||
const workerTypeEnvBindings: { filePath: string; bindings: [string, string][] }[] = [];
|
||||
|
||||
try {
|
||||
for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) {
|
||||
@@ -617,24 +258,6 @@ export const runPipelineFromRepo = async (
|
||||
stats: { filesProcessed: filesParsedSoFar, totalFiles: totalParseable, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
}, repoPath, importCtx);
|
||||
// ── Wildcard-import synthesis (Ruby / C/C++ / Swift / Go) + Python module aliases ─
|
||||
// Synthesize namedImportMap entries for wildcard-import languages and build
|
||||
// moduleAliasMap for Python namespace imports. Must run after imports are resolved
|
||||
// (importMap is populated) but BEFORE call resolution.
|
||||
if (chunkNeedsSynthesis[chunkIdx]) synthesizeWildcardImportBindings(graph, ctx);
|
||||
// Phase 14 E1: Seed cross-file receiver types from ExportedTypeMap
|
||||
// before call resolution — eliminates re-parse for single-hop imported receivers.
|
||||
// NOTE: In the worker path, exportedTypeMap is empty during chunk processing
|
||||
// (populated later in runCrossFileBindingPropagation). This block is latent —
|
||||
// it activates only if incremental export collection is added per-chunk.
|
||||
if (exportedTypeMap.size > 0 && ctx.namedImportMap.size > 0) {
|
||||
const { enrichedCount } = seedCrossFileReceiverTypes(
|
||||
chunkWorkerData.calls, ctx.namedImportMap, exportedTypeMap,
|
||||
);
|
||||
if (isDev && enrichedCount > 0) {
|
||||
console.log(`🔗 E1: Seeded ${enrichedCount} cross-file receiver types (chunk ${chunkIdx + 1})`);
|
||||
}
|
||||
}
|
||||
// Calls + Heritage + Routes — resolve in parallel (no shared mutable state between them)
|
||||
// This is safe because each writes disjoint relationship types into idempotent id-keyed Maps,
|
||||
// and the single-threaded event loop prevents races between synchronous addRelationship calls.
|
||||
@@ -687,10 +310,6 @@ export const runPipelineFromRepo = async (
|
||||
if (chunkWorkerData.assignments?.length) {
|
||||
processAssignmentsFromExtracted(graph, chunkWorkerData.assignments, ctx, chunkWorkerData.constructorBindings);
|
||||
}
|
||||
// Collect TypeEnv file-scope bindings for exported type enrichment
|
||||
if (chunkWorkerData.typeEnvBindings?.length) {
|
||||
workerTypeEnvBindings.push(...chunkWorkerData.typeEnvBindings);
|
||||
}
|
||||
} else {
|
||||
await processImports(graph, chunkFiles, astCache, ctx, undefined, repoPath, allPaths);
|
||||
sequentialChunkPaths.push(chunkPaths);
|
||||
@@ -707,16 +326,13 @@ export const runPipelineFromRepo = async (
|
||||
}
|
||||
|
||||
// Sequential fallback chunks: re-read source for call/heritage resolution
|
||||
// Synthesize wildcard import bindings once after ALL imports are processed,
|
||||
// before any call resolution — same rationale as the worker-path inline synthesis.
|
||||
if (sequentialChunkPaths.length > 0) synthesizeWildcardImportBindings(graph, ctx);
|
||||
for (const chunkPaths of sequentialChunkPaths) {
|
||||
const chunkContents = await readFileContents(repoPath, chunkPaths);
|
||||
const chunkFiles = chunkPaths
|
||||
.filter(p => chunkContents.has(p))
|
||||
.map(p => ({ path: p, content: chunkContents.get(p)! }));
|
||||
astCache = createASTCache(chunkFiles.length);
|
||||
const rubyHeritage = await processCalls(graph, chunkFiles, astCache, ctx, undefined, exportedTypeMap);
|
||||
const rubyHeritage = await processCalls(graph, chunkFiles, astCache, ctx);
|
||||
await processHeritage(graph, chunkFiles, astCache, ctx);
|
||||
if (rubyHeritage.length > 0) {
|
||||
await processHeritageFromExtracted(graph, rubyHeritage, ctx);
|
||||
@@ -732,55 +348,12 @@ export const runPipelineFromRepo = async (
|
||||
console.log(`🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`);
|
||||
}
|
||||
|
||||
// ── Worker path quality enrichment: merge TypeEnv file-scope bindings into ExportedTypeMap ──
|
||||
// Workers return file-scope bindings from their TypeEnv fixpoint (includes inferred types
|
||||
// like `const config = getConfig()` → Config). Filter by graph isExported to match
|
||||
// the sequential path's collectExportedBindings behavior.
|
||||
if (workerTypeEnvBindings.length > 0) {
|
||||
let enriched = 0;
|
||||
for (const { filePath, bindings } of workerTypeEnvBindings) {
|
||||
for (const [name, type] of bindings) {
|
||||
// Verify the symbol is exported via graph node
|
||||
const nodeId = `Function:${filePath}:${name}`;
|
||||
const varNodeId = `Variable:${filePath}:${name}`;
|
||||
const constNodeId = `Const:${filePath}:${name}`;
|
||||
const node = graph.getNode(nodeId) ?? graph.getNode(varNodeId) ?? graph.getNode(constNodeId);
|
||||
if (!node?.properties?.isExported) continue;
|
||||
|
||||
let fileExports = exportedTypeMap.get(filePath);
|
||||
if (!fileExports) { fileExports = new Map(); exportedTypeMap.set(filePath, fileExports); }
|
||||
// Don't overwrite existing entries (Tier 0 from SymbolTable is authoritative)
|
||||
if (!fileExports.has(name)) {
|
||||
fileExports.set(name, type);
|
||||
enriched++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isDev && enriched > 0) {
|
||||
console.log(`🔗 Worker TypeEnv enrichment: ${enriched} fixpoint-inferred exports added to ExportedTypeMap`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 14 pre-pass: Final synthesis pass for whole-module-import languages ──
|
||||
// Per-chunk synthesis (above) already ran incrementally. This final pass ensures
|
||||
// any remaining files whose imports were not covered inline are also synthesized,
|
||||
// and that Phase 14 type propagation has complete namedImportMap data.
|
||||
const synthesized = synthesizeWildcardImportBindings(graph, ctx);
|
||||
if (isDev && synthesized > 0) {
|
||||
console.log(`🔗 Synthesized ${synthesized} additional wildcard import bindings (Go/Ruby/C++/Swift/Python)`);
|
||||
}
|
||||
|
||||
// ── Phase 14: Cross-file binding propagation ──────────────────────
|
||||
await runCrossFileBindingPropagation(
|
||||
graph, ctx, exportedTypeMap, allPaths, totalFiles, repoPath, pipelineStart, onProgress,
|
||||
);
|
||||
|
||||
// Free import resolution context — suffix index + resolve cache no longer needed
|
||||
// (allPathObjects and importCtx hold ~94MB+ for large repos)
|
||||
allPathObjects.length = 0;
|
||||
importCtx.resolveCache.clear();
|
||||
importCtx.index = EMPTY_INDEX; // Release suffix index memory (~30MB for large repos)
|
||||
importCtx.normalizedFileList = [];
|
||||
(importCtx as any).suffixIndex = null;
|
||||
(importCtx as any).normalizedFileList = null;
|
||||
|
||||
let communityResult: Awaited<ReturnType<typeof processCommunities>> | undefined;
|
||||
let processResult: Awaited<ReturnType<typeof processProcesses>> | undefined;
|
||||
|
||||
@@ -39,9 +39,6 @@ export const TIER_CONFIDENCE: Record<ResolutionTier, number> = {
|
||||
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 {
|
||||
/**
|
||||
@@ -59,8 +56,6 @@ export interface ResolutionContext {
|
||||
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;
|
||||
@@ -76,7 +71,6 @@ export const createResolutionContext = (): ResolutionContext => {
|
||||
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;
|
||||
@@ -179,7 +173,6 @@ export const createResolutionContext = (): ResolutionContext => {
|
||||
importMap.clear();
|
||||
packageMap.clear();
|
||||
namedImportMap.clear();
|
||||
moduleAliasMap.clear();
|
||||
clearCache();
|
||||
cacheHits = 0;
|
||||
cacheMisses = 0;
|
||||
@@ -191,7 +184,6 @@ export const createResolutionContext = (): ResolutionContext => {
|
||||
importMap,
|
||||
packageMap,
|
||||
namedImportMap,
|
||||
moduleAliasMap,
|
||||
enableCache,
|
||||
clearCache,
|
||||
getStats,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Extracted from import-processor.ts for maintainability.
|
||||
*/
|
||||
|
||||
export { EXTENSIONS, tryResolveWithExtensions, buildSuffixIndex, suffixResolve, EMPTY_INDEX } from './utils.js';
|
||||
export { EXTENSIONS, tryResolveWithExtensions, buildSuffixIndex, suffixResolve } from './utils.js';
|
||||
export type { SuffixIndex } from './utils.js';
|
||||
|
||||
export { KOTLIN_EXTENSIONS, appendKotlinWildcard, resolveJvmWildcard, resolveJvmMemberImport } from './jvm.js';
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
import type { SuffixIndex } from './utils.js';
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
|
||||
/** Kotlin file extensions for JVM resolver reuse */
|
||||
export const KOTLIN_EXTENSIONS: readonly string[] = ['.kt', '.kts'];
|
||||
@@ -13,7 +12,7 @@ export const KOTLIN_EXTENSIONS: readonly string[] = ['.kt', '.kts'];
|
||||
* Append .* to a Kotlin import path if the AST has a wildcard_import sibling node.
|
||||
* Pure function — returns a new string without mutating the input.
|
||||
*/
|
||||
export const appendKotlinWildcard = (importPath: string, importNode: SyntaxNode): string => {
|
||||
export const appendKotlinWildcard = (importPath: string, importNode: any): string => {
|
||||
for (let i = 0; i < importNode.childCount; i++) {
|
||||
if (importNode.child(i)?.type === 'wildcard_import') {
|
||||
return importPath.endsWith('.*') ? importPath : `${importPath}.*`;
|
||||
@@ -40,39 +39,26 @@ export function resolveJvmWildcard(
|
||||
const candidates = extensions.flatMap(ext => index.getFilesInDir(packagePath, ext));
|
||||
// Filter to only direct children (no subdirectories)
|
||||
const packageSuffix = '/' + packagePath + '/';
|
||||
const packagePrefix = packagePath + '/';
|
||||
return candidates.filter(f => {
|
||||
const normalized = f.replace(/\\/g, '/');
|
||||
// Match both nested (src/models/User.kt) and root-level (models/User.kt) packages
|
||||
let afterPkg: string;
|
||||
const idx = normalized.lastIndexOf(packageSuffix);
|
||||
if (idx >= 0) {
|
||||
afterPkg = normalized.substring(idx + packageSuffix.length);
|
||||
} else if (normalized.startsWith(packagePrefix)) {
|
||||
afterPkg = normalized.substring(packagePrefix.length);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
const idx = normalized.indexOf(packageSuffix);
|
||||
if (idx < 0) return false;
|
||||
const afterPkg = normalized.substring(idx + packageSuffix.length);
|
||||
return !afterPkg.includes('/');
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: linear scan
|
||||
const packageSuffix = '/' + packagePath + '/';
|
||||
const packagePrefix = packagePath + '/';
|
||||
const matches: string[] = [];
|
||||
for (let i = 0; i < normalizedFileList.length; i++) {
|
||||
const normalized = normalizedFileList[i];
|
||||
if (!extensions.some(ext => normalized.endsWith(ext))) continue;
|
||||
// Match both nested (src/models/User.kt) and root-level (models/User.kt) packages
|
||||
let afterPackage: string | null = null;
|
||||
if (normalized.includes(packageSuffix)) {
|
||||
afterPackage = normalized.substring(normalized.lastIndexOf(packageSuffix) + packageSuffix.length);
|
||||
} else if (normalized.startsWith(packagePrefix)) {
|
||||
afterPackage = normalized.substring(packagePrefix.length);
|
||||
}
|
||||
if (afterPackage !== null && !afterPackage.includes('/')) {
|
||||
matches.push(allFileList[i]);
|
||||
if (normalized.includes(packageSuffix) &&
|
||||
extensions.some(ext => normalized.endsWith(ext))) {
|
||||
const afterPackage = normalized.substring(normalized.indexOf(packageSuffix) + packageSuffix.length);
|
||||
if (!afterPackage.includes('/')) {
|
||||
matches.push(allFileList[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
|
||||
@@ -10,34 +10,11 @@ import { suffixResolve } from './utils.js';
|
||||
export interface ComposerConfig {
|
||||
/** Map of namespace prefix -> directory (e.g., "App\\" -> "app/") */
|
||||
psr4: Map<string, string>;
|
||||
/** PSR-4 entries sorted by namespace length descending (longest match wins).
|
||||
* Cached once at config load time to avoid re-sorting on every import. */
|
||||
psr4Sorted?: readonly [string, string][];
|
||||
}
|
||||
|
||||
/** Get or compute the sorted PSR-4 entries (cached after first call). */
|
||||
function getSortedPsr4(config: ComposerConfig): readonly [string, string][] {
|
||||
if (!config.psr4Sorted) {
|
||||
const sorted = [...config.psr4.entries()].sort((a, b) => b[0].length - a[0].length);
|
||||
config.psr4Sorted = sorted;
|
||||
}
|
||||
return config.psr4Sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PHP use-statement import path using PSR-4 mappings.
|
||||
* e.g. "App\Http\Controllers\UserController" -> "app/Http/Controllers/UserController.php"
|
||||
*
|
||||
* For function/constant imports (use function App\Models\getUser), the last
|
||||
* segment is the symbol name, not a class name, so it may not map directly to
|
||||
* a file. When PSR-4 class-style resolution fails, we fall back to scanning
|
||||
* .php files in the namespace directory.
|
||||
*
|
||||
* NOTE: The function-import fallback returns the first matching .php file in the
|
||||
* namespace directory. When multiple files exist in the same namespace directory,
|
||||
* resolution is non-deterministic (depends on Set/index iteration order). This is
|
||||
* a known limitation — PHP function imports cannot be resolved to a specific file
|
||||
* without parsing all candidate files.
|
||||
*/
|
||||
export function resolvePhpImport(
|
||||
importPath: string,
|
||||
@@ -50,44 +27,20 @@ export function resolvePhpImport(
|
||||
// Normalize: replace backslashes with forward slashes
|
||||
const normalized = importPath.replace(/\\/g, '/');
|
||||
|
||||
// Reject path traversal attempts (defense-in-depth — walker whitelist also prevents this)
|
||||
if (normalized.includes('..')) return null;
|
||||
|
||||
// Try PSR-4 resolution if composer.json was found
|
||||
if (composerConfig) {
|
||||
const sorted = getSortedPsr4(composerConfig);
|
||||
// Sort namespaces by length descending (longest match wins)
|
||||
const sorted = [...composerConfig.psr4.entries()].sort((a, b) => b[0].length - a[0].length);
|
||||
for (const [nsPrefix, dirPrefix] of sorted) {
|
||||
const nsPrefixSlash = nsPrefix.replace(/\\/g, '/');
|
||||
if (normalized.startsWith(nsPrefixSlash + '/') || normalized === nsPrefixSlash) {
|
||||
const remainder = normalized.slice(nsPrefixSlash.length).replace(/^\//, '');
|
||||
|
||||
// 1. Try class-style PSR-4: full path → file (e.g. App\Models\User → app/Models/User.php)
|
||||
const filePath = dirPrefix + (remainder ? '/' + remainder : '') + '.php';
|
||||
if (allFiles.has(filePath)) return filePath;
|
||||
if (index) {
|
||||
const result = index.getInsensitive(filePath);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
// 2. Function/constant fallback: strip last segment (symbol name), scan namespace directory.
|
||||
// e.g. App\Models\getUser → directory app/Models/, find first .php file in that dir.
|
||||
const lastSlash = remainder.lastIndexOf('/');
|
||||
const nsDir = lastSlash >= 0
|
||||
? dirPrefix + '/' + remainder.slice(0, lastSlash)
|
||||
: dirPrefix;
|
||||
|
||||
// Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan
|
||||
if (index) {
|
||||
const candidates = index.getFilesInDir(nsDir, '.php');
|
||||
if (candidates.length > 0) return candidates[0];
|
||||
}
|
||||
|
||||
// Fallback: linear scan (only when SuffixIndex unavailable)
|
||||
const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/';
|
||||
for (const f of allFiles) {
|
||||
if (f.startsWith(nsDirPrefix) && f.endsWith('.php') && !f.slice(nsDirPrefix.length).includes('/')) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Extracted from import-processor.ts to reduce file size.
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
|
||||
/** All file extensions to try during resolution */
|
||||
export const EXTENSIONS = [
|
||||
'',
|
||||
@@ -65,15 +63,6 @@ export interface SuffixIndex {
|
||||
getFilesInDir(dirSuffix: string, extension: string): string[];
|
||||
}
|
||||
|
||||
const FROZEN_EMPTY_ARRAY: string[] = Object.freeze([]) as string[];
|
||||
|
||||
/** Sentinel index that returns no results. Used to release memory after import resolution. */
|
||||
export const EMPTY_INDEX: SuffixIndex = Object.freeze({
|
||||
get: () => undefined,
|
||||
getInsensitive: () => undefined,
|
||||
getFilesInDir: () => FROZEN_EMPTY_ARRAY,
|
||||
});
|
||||
|
||||
export function buildSuffixIndex(normalizedFileList: string[], allFileList: string[]): SuffixIndex {
|
||||
// Map: normalized suffix -> original file path
|
||||
const exactMap = new Map<string, string>();
|
||||
@@ -167,12 +156,3 @@ export function suffixResolve(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Find the first direct named child of a tree-sitter node matching the given type. */
|
||||
export function findChild(node: SyntaxNode, type: string): SyntaxNode | null {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === type) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -196,12 +196,6 @@ export const PYTHON_QUERIES = `
|
||||
(import_statement
|
||||
name: (dotted_name) @import.source) @import
|
||||
|
||||
; import numpy as np → aliased_import captures the module name so the
|
||||
; import path is resolved and named-binding extraction stores "np" → "numpy".
|
||||
(import_statement
|
||||
name: (aliased_import
|
||||
name: (dotted_name) @import.source)) @import
|
||||
|
||||
(import_from_statement
|
||||
module_name: (dotted_name) @import.source) @import
|
||||
|
||||
@@ -426,15 +420,8 @@ export const CPP_QUERIES = `
|
||||
declarator: (reference_declarator
|
||||
(field_identifier) @name)) @definition.property
|
||||
|
||||
; Inline class method declarations (inside class body, no body: void save();)
|
||||
; tree-sitter-cpp uses field_identifier (not identifier) for names inside class bodies
|
||||
(field_declaration declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name)) @definition.method
|
||||
|
||||
; Inline class method declarations returning a pointer (User* lookup();)
|
||||
(field_declaration declarator: (pointer_declarator declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method
|
||||
|
||||
; Inline class method declarations returning a reference (User& lookup();)
|
||||
(field_declaration declarator: (reference_declarator (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method
|
||||
; Inline class method declarations (inside class body, no body: void Foo();)
|
||||
(field_declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.method
|
||||
|
||||
; Inline class method definitions (inside class body, with body: void Foo() { ... })
|
||||
(field_declaration_list
|
||||
|
||||
@@ -647,34 +647,6 @@ const resolveFixpointBindings = (
|
||||
export interface BuildTypeEnvOptions {
|
||||
symbolTable?: SymbolTable;
|
||||
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.
|
||||
* Local declarations always take precedence (first-writer-wins). */
|
||||
importedBindings?: ReadonlyMap<string, string>;
|
||||
/** Cross-file return type fallback for imported callables (Phase 14 E3).
|
||||
* Consulted ONLY when SymbolTable has no unambiguous match.
|
||||
* Local definitions always take precedence (local-first principle). */
|
||||
importedReturnTypes?: ReadonlyMap<string, string>;
|
||||
/** Cross-file RAW return types for imported callables (Phase 14 E3).
|
||||
* Stores raw declared return type strings (e.g., 'User[]', 'List<User>').
|
||||
* Used by lookupRawReturnType for for-loop element extraction. */
|
||||
importedRawReturnTypes?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/** Seed cross-file type bindings into the file scope.
|
||||
* MUST be called AFTER walk() completes so that local declarations
|
||||
* (Tier 0/1) always take precedence over imported bindings (first-writer-wins). */
|
||||
function seedImportedBindings(
|
||||
env: TypeEnv,
|
||||
importedBindings: ReadonlyMap<string, string>,
|
||||
): void {
|
||||
let fileEnv = env.get(FILE_SCOPE);
|
||||
if (!fileEnv) { fileEnv = new Map(); env.set(FILE_SCOPE, fileEnv); }
|
||||
for (const [name, type] of importedBindings) {
|
||||
if (!fileEnv.has(name)) {
|
||||
fileEnv.set(name, type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const buildTypeEnv = (
|
||||
@@ -695,36 +667,24 @@ export const buildTypeEnv = (
|
||||
const config = typeConfigs[language];
|
||||
const bindings: ConstructorBinding[] = [];
|
||||
|
||||
// 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).
|
||||
// Build ReturnTypeLookup from optional SymbolTable.
|
||||
// Conservative: returns undefined when callee is ambiguous (0 or 2+ matches).
|
||||
const returnTypeLookup: ReturnTypeLookup = {
|
||||
lookupReturnType(callee: string): string | undefined {
|
||||
// SymbolTable is authoritative when it has an unambiguous match
|
||||
if (symbolTable) {
|
||||
if (isBuiltInOrNoise(callee)) return undefined;
|
||||
const callables = symbolTable.lookupFuzzyCallable(callee);
|
||||
if (callables.length === 1) {
|
||||
const rawReturn = callables[0].returnType;
|
||||
if (rawReturn) return extractReturnTypeName(rawReturn);
|
||||
}
|
||||
// Ambiguous (2+) → return undefined (conservative, no cross-file fallback)
|
||||
if (callables.length > 1) return undefined;
|
||||
}
|
||||
// No match (0 results or no symbolTable) → fall back to cross-file
|
||||
return options?.importedReturnTypes?.get(callee);
|
||||
if (!symbolTable) return undefined;
|
||||
if (isBuiltInOrNoise(callee)) return undefined;
|
||||
const callables = symbolTable.lookupFuzzyCallable(callee);
|
||||
if (callables.length !== 1) return undefined;
|
||||
const rawReturn = callables[0].returnType;
|
||||
if (!rawReturn) return undefined;
|
||||
return extractReturnTypeName(rawReturn);
|
||||
},
|
||||
lookupRawReturnType(callee: string): string | undefined {
|
||||
if (symbolTable) {
|
||||
if (isBuiltInOrNoise(callee)) return undefined;
|
||||
const callables = symbolTable.lookupFuzzyCallable(callee);
|
||||
if (callables.length === 1) return callables[0].returnType;
|
||||
// Ambiguous (2+) → return undefined (conservative, no cross-file fallback)
|
||||
if (callables.length > 1) return undefined;
|
||||
}
|
||||
// Cross-file fallback uses importedRawReturnTypes (raw declared types, e.g., 'User[]')
|
||||
// NOT importedReturnTypes (which contains processed/simple types via extractReturnTypeName)
|
||||
return options?.importedRawReturnTypes?.get(callee);
|
||||
if (!symbolTable) return undefined;
|
||||
if (isBuiltInOrNoise(callee)) return undefined;
|
||||
const callables = symbolTable.lookupFuzzyCallable(callee);
|
||||
if (callables.length !== 1) return undefined;
|
||||
return callables[0].returnType;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1036,12 +996,6 @@ export const buildTypeEnv = (
|
||||
|
||||
walk(tree.rootNode, FILE_SCOPE);
|
||||
|
||||
// Phase 14: Seed cross-file bindings from upstream files AFTER walk
|
||||
// (local declarations from walk() take precedence — first-writer-wins)
|
||||
if (options?.importedBindings && options.importedBindings.size > 0) {
|
||||
seedImportedBindings(env, options.importedBindings);
|
||||
}
|
||||
|
||||
resolveFixpointBindings(pendingItems, env, returnTypeLookup, symbolTable, parentMap);
|
||||
|
||||
// Post-fixpoint for-loop replay (Phase 10 / ex-9B loop-fixpoint bridge):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor, PatternBindingExtractor, LiteralTypeInferrer } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, unwrapAwait, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
|
||||
import { findChild } from '../resolvers/utils.js';
|
||||
import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
|
||||
|
||||
/** Known container property accessors that operate on the container itself (e.g., dict.Keys, dict.Values) */
|
||||
const KNOWN_CONTAINER_PROPS: ReadonlySet<string> = new Set(['Keys', 'Values']);
|
||||
@@ -51,8 +50,8 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<str
|
||||
// tree-sitter-c-sharp may put object_creation_expression as direct child
|
||||
// or inside equals_value_clause depending on grammar version
|
||||
if (declarators.length === 1) {
|
||||
const initializer = findChild(declarators[0], 'object_creation_expression')
|
||||
?? findChild(declarators[0], 'equals_value_clause')?.firstNamedChild;
|
||||
const initializer = findChildByType(declarators[0], 'object_creation_expression')
|
||||
?? findChildByType(declarators[0], 'equals_value_clause')?.firstNamedChild;
|
||||
if (initializer?.type === 'object_creation_expression') {
|
||||
const ctorType = initializer.childForFieldName('type');
|
||||
if (ctorType) typeName = extractSimpleTypeName(ctorType);
|
||||
@@ -143,7 +142,7 @@ const extractCSharpElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArg
|
||||
// generic_name: List<User>, IEnumerable<User>, Dictionary<string, User>
|
||||
// C# uses generic_name (not generic_type)
|
||||
if (typeNode.type === 'generic_name') {
|
||||
const argList = findChild(typeNode, 'type_argument_list');
|
||||
const argList = findChildByType(typeNode, 'type_argument_list');
|
||||
if (argList && argList.namedChildCount >= 1) {
|
||||
if (pos === 'first') {
|
||||
const firstArg = argList.namedChild(0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
|
||||
import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, extractGenericTypeArgs, findChildByType, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
|
||||
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'var_declaration',
|
||||
|
||||
@@ -47,5 +47,6 @@ export {
|
||||
extractSimpleTypeName,
|
||||
extractGenericTypeArgs,
|
||||
extractVarName,
|
||||
findChildByType,
|
||||
extractRubyConstructorAssignment
|
||||
} from './shared.js';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor, PatternBindingExtractor, LiteralTypeInferrer, ConstructorTypeDetector } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
|
||||
import { findChild } from '../resolvers/utils.js';
|
||||
import { extractSimpleTypeName, extractVarName, findChildByType, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
|
||||
|
||||
// ── Java ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -74,7 +73,7 @@ const scanJavaConstructorBinding: ConstructorBindingScanner = (node) => {
|
||||
const typeNode = node.childForFieldName('type');
|
||||
if (!typeNode) return undefined;
|
||||
if (typeNode.text !== 'var') return undefined;
|
||||
const declarator = findChild(node, 'variable_declarator');
|
||||
const declarator = findChildByType(node, 'variable_declarator');
|
||||
if (!declarator) return undefined;
|
||||
const nameNode = declarator.childForFieldName('name');
|
||||
const value = declarator.childForFieldName('value');
|
||||
@@ -326,11 +325,11 @@ const KOTLIN_DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
const extractKotlinDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
|
||||
if (node.type === 'property_declaration') {
|
||||
// Kotlin property_declaration: name/type are inside a variable_declaration child
|
||||
const varDecl = findChild(node, 'variable_declaration');
|
||||
const varDecl = findChildByType(node, 'variable_declaration');
|
||||
if (varDecl) {
|
||||
const nameNode = findChild(varDecl, 'simple_identifier');
|
||||
const typeNode = findChild(varDecl, 'user_type')
|
||||
?? findChild(varDecl, 'nullable_type');
|
||||
const nameNode = findChildByType(varDecl, 'simple_identifier');
|
||||
const typeNode = findChildByType(varDecl, 'user_type')
|
||||
?? findChildByType(varDecl, 'nullable_type');
|
||||
if (!nameNode || !typeNode) return;
|
||||
const varName = extractVarName(nameNode);
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
@@ -339,17 +338,17 @@ const extractKotlinDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: M
|
||||
}
|
||||
// Fallback: try direct fields
|
||||
const nameNode = node.childForFieldName('name')
|
||||
?? findChild(node, 'simple_identifier');
|
||||
?? findChildByType(node, 'simple_identifier');
|
||||
const typeNode = node.childForFieldName('type')
|
||||
?? findChild(node, 'user_type');
|
||||
?? findChildByType(node, 'user_type');
|
||||
if (!nameNode || !typeNode) return;
|
||||
const varName = extractVarName(nameNode);
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
if (varName && typeName) env.set(varName, typeName);
|
||||
} else if (node.type === 'variable_declaration') {
|
||||
// variable_declaration directly inside functions
|
||||
const nameNode = findChild(node, 'simple_identifier');
|
||||
const typeNode = findChild(node, 'user_type');
|
||||
const nameNode = findChildByType(node, 'simple_identifier');
|
||||
const typeNode = findChildByType(node, 'user_type');
|
||||
if (nameNode && typeNode) {
|
||||
const varName = extractVarName(nameNode);
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
@@ -361,7 +360,7 @@ const extractKotlinDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: M
|
||||
/** Kotlin: parameter / formal_parameter → type name.
|
||||
* Kotlin's tree-sitter grammar uses positional children (simple_identifier, user_type)
|
||||
* rather than named fields (name, type) on `parameter` nodes, so we fall back to
|
||||
* findChild when childForFieldName returns null. */
|
||||
* findChildByType when childForFieldName returns null. */
|
||||
const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
|
||||
let nameNode: SyntaxNode | null = null;
|
||||
let typeNode: SyntaxNode | null = null;
|
||||
@@ -375,9 +374,9 @@ const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map<s
|
||||
}
|
||||
|
||||
// Fallback: Kotlin `parameter` nodes use positional children, not named fields
|
||||
if (!nameNode) nameNode = findChild(node, 'simple_identifier');
|
||||
if (!typeNode) typeNode = findChild(node, 'user_type')
|
||||
?? findChild(node, 'nullable_type');
|
||||
if (!nameNode) nameNode = findChildByType(node, 'simple_identifier');
|
||||
if (!typeNode) typeNode = findChildByType(node, 'user_type')
|
||||
?? findChildByType(node, 'nullable_type');
|
||||
|
||||
if (!nameNode || !typeNode) return;
|
||||
const varName = extractVarName(nameNode);
|
||||
@@ -390,7 +389,7 @@ const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map<s
|
||||
const findKotlinConstructorCallee = (node: SyntaxNode, classNames: ClassNameLookup): string | undefined => {
|
||||
if (node.type !== 'property_declaration') return undefined;
|
||||
const value = node.childForFieldName('value')
|
||||
?? findChild(node, 'call_expression');
|
||||
?? findChildByType(node, 'call_expression');
|
||||
if (!value || value.type !== 'call_expression') return undefined;
|
||||
const callee = value.firstNamedChild;
|
||||
if (!callee || callee.type !== 'simple_identifier') return undefined;
|
||||
@@ -404,16 +403,16 @@ const findKotlinConstructorCallee = (node: SyntaxNode, classNames: ClassNameLook
|
||||
* against classNames (which may include cross-file SymbolTable lookups). */
|
||||
const extractKotlinInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, classNames: ClassNameLookup): void => {
|
||||
// Skip if there's an explicit type annotation — Tier 0 already handled it
|
||||
const varDecl = findChild(node, 'variable_declaration');
|
||||
if (varDecl && findChild(varDecl, 'user_type')) return;
|
||||
const varDecl = findChildByType(node, 'variable_declaration');
|
||||
if (varDecl && findChildByType(varDecl, 'user_type')) return;
|
||||
|
||||
const calleeName = findKotlinConstructorCallee(node, classNames);
|
||||
if (!calleeName) return;
|
||||
|
||||
// Extract the variable name from the variable_declaration inside property_declaration
|
||||
const nameNode = varDecl
|
||||
? findChild(varDecl, 'simple_identifier')
|
||||
: findChild(node, 'simple_identifier');
|
||||
? findChildByType(varDecl, 'simple_identifier')
|
||||
: findChildByType(node, 'simple_identifier');
|
||||
if (!nameNode) return;
|
||||
|
||||
const varName = extractVarName(nameNode);
|
||||
@@ -431,10 +430,10 @@ const detectKotlinConstructorType: ConstructorTypeDetector = (node, classNames)
|
||||
/** Kotlin: val x = User(...) — constructor binding for property_declaration with call_expression */
|
||||
const scanKotlinConstructorBinding: ConstructorBindingScanner = (node) => {
|
||||
if (node.type !== 'property_declaration') return undefined;
|
||||
const varDecl = findChild(node, 'variable_declaration');
|
||||
const varDecl = findChildByType(node, 'variable_declaration');
|
||||
if (!varDecl) return undefined;
|
||||
if (findChild(varDecl, 'user_type')) return undefined;
|
||||
const callExpr = findChild(node, 'call_expression');
|
||||
if (findChildByType(varDecl, 'user_type')) return undefined;
|
||||
const callExpr = findChildByType(node, 'call_expression');
|
||||
if (!callExpr) return undefined;
|
||||
const callee = callExpr.firstNamedChild;
|
||||
if (!callee) return undefined;
|
||||
@@ -453,7 +452,7 @@ const scanKotlinConstructorBinding: ConstructorBindingScanner = (node) => {
|
||||
}
|
||||
}
|
||||
if (!calleeName) return undefined;
|
||||
const nameNode = findChild(varDecl, 'simple_identifier');
|
||||
const nameNode = findChildByType(varDecl, 'simple_identifier');
|
||||
if (!nameNode) return undefined;
|
||||
return { varName: nameNode.text, calleeName };
|
||||
};
|
||||
@@ -467,7 +466,7 @@ const KOTLIN_FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
* Handles the type_projection wrapper that Kotlin uses for generic type arguments. */
|
||||
const extractKotlinElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
|
||||
if (typeNode.type === 'user_type') {
|
||||
const argsNode = findChild(typeNode, 'type_arguments');
|
||||
const argsNode = findChildByType(typeNode, 'type_arguments');
|
||||
if (argsNode && argsNode.namedChildCount >= 1) {
|
||||
const targetArg = pos === 'first'
|
||||
? argsNode.namedChild(0)
|
||||
@@ -489,14 +488,14 @@ const findKotlinParamElementType = (iterableName: string, startNode: SyntaxNode,
|
||||
let current: SyntaxNode | null = startNode.parent;
|
||||
while (current) {
|
||||
if (current.type === 'function_declaration') {
|
||||
const paramsNode = findChild(current, 'function_value_parameters');
|
||||
const paramsNode = findChildByType(current, 'function_value_parameters');
|
||||
if (paramsNode) {
|
||||
for (let i = 0; i < paramsNode.namedChildCount; i++) {
|
||||
const param = paramsNode.namedChild(i);
|
||||
if (!param || param.type !== 'parameter') continue;
|
||||
const nameNode = findChild(param, 'simple_identifier');
|
||||
const nameNode = findChildByType(param, 'simple_identifier');
|
||||
if (nameNode?.text !== iterableName) continue;
|
||||
const typeNode = findChild(param, 'user_type');
|
||||
const typeNode = findChildByType(param, 'user_type');
|
||||
if (typeNode) return extractKotlinElementTypeFromTypeNode(typeNode, pos);
|
||||
}
|
||||
}
|
||||
@@ -511,15 +510,15 @@ const findKotlinParamElementType = (iterableName: string, startNode: SyntaxNode,
|
||||
* Tier 1c: for `for (user in users)` without annotation, resolves from iterable. */
|
||||
const extractKotlinForLoopBinding: ForLoopExtractor = (node, ctx): void => {
|
||||
const { scopeEnv, declarationTypeNodes, scope, returnTypeLookup } = ctx;
|
||||
const varDecl = findChild(node, 'variable_declaration');
|
||||
const varDecl = findChildByType(node, 'variable_declaration');
|
||||
if (!varDecl) return;
|
||||
const nameNode = findChild(varDecl, 'simple_identifier');
|
||||
const nameNode = findChildByType(varDecl, 'simple_identifier');
|
||||
if (!nameNode) return;
|
||||
const varName = extractVarName(nameNode);
|
||||
if (!varName) return;
|
||||
|
||||
// Explicit type annotation (existing behavior): for (user: User in users)
|
||||
const typeNode = findChild(varDecl, 'user_type');
|
||||
const typeNode = findChildByType(varDecl, 'user_type');
|
||||
if (typeNode) {
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
if (typeName) scopeEnv.set(varName, typeName);
|
||||
@@ -545,9 +544,9 @@ const extractKotlinForLoopBinding: ForLoopExtractor = (node, ctx): void => {
|
||||
if (child.type === 'navigation_expression') {
|
||||
// data.keys → navigation_expression > simple_identifier(data) + navigation_suffix > simple_identifier(keys)
|
||||
const obj = child.firstNamedChild;
|
||||
const suffix = findChild(child, 'navigation_suffix');
|
||||
const prop = suffix ? findChild(suffix, 'simple_identifier') : null;
|
||||
const hasCallSuffix = suffix ? findChild(suffix, 'call_suffix') !== null : false;
|
||||
const suffix = findChildByType(child, 'navigation_suffix');
|
||||
const prop = suffix ? findChildByType(suffix, 'simple_identifier') : null;
|
||||
const hasCallSuffix = suffix ? findChildByType(suffix, 'call_suffix') !== null : false;
|
||||
// Always try object as iterable + property as method first (handles data.values, data.keys).
|
||||
// For bare property access without call_suffix, also save property as fallback
|
||||
// (handles this.users, repo.items where the property IS the iterable).
|
||||
@@ -564,9 +563,9 @@ const extractKotlinForLoopBinding: ForLoopExtractor = (node, ctx): void => {
|
||||
if (callee?.type === 'navigation_expression') {
|
||||
const obj = callee.firstNamedChild;
|
||||
if (obj?.type === 'simple_identifier') iterableName = obj.text;
|
||||
const suffix = findChild(callee, 'navigation_suffix');
|
||||
const suffix = findChildByType(callee, 'navigation_suffix');
|
||||
if (suffix) {
|
||||
const prop = findChild(suffix, 'simple_identifier');
|
||||
const prop = findChildByType(suffix, 'simple_identifier');
|
||||
if (prop) methodName = prop.text;
|
||||
}
|
||||
} else if (callee?.type === 'simple_identifier') {
|
||||
@@ -608,7 +607,7 @@ const extractKotlinForLoopBinding: ForLoopExtractor = (node, ctx): void => {
|
||||
const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
|
||||
if (node.type === 'property_declaration') {
|
||||
// Find the variable name from variable_declaration child
|
||||
const varDecl = findChild(node, 'variable_declaration');
|
||||
const varDecl = findChildByType(node, 'variable_declaration');
|
||||
if (!varDecl) return undefined;
|
||||
const nameNode = varDecl.firstNamedChild;
|
||||
if (!nameNode || nameNode.type !== 'simple_identifier') return undefined;
|
||||
@@ -654,7 +653,7 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
|
||||
|
||||
if (node.type === 'variable_declaration') {
|
||||
// variable_declaration directly inside functions: simple_identifier children
|
||||
const nameNode = findChild(node, 'simple_identifier');
|
||||
const nameNode = findChildByType(node, 'simple_identifier');
|
||||
if (!nameNode) return undefined;
|
||||
const lhs = nameNode.text;
|
||||
if (scopeEnv.has(lhs)) return undefined;
|
||||
|
||||
@@ -497,6 +497,15 @@ export const extractCalleeName = (callNode: SyntaxNode): string | undefined => {
|
||||
return extractSimpleTypeName(func);
|
||||
};
|
||||
|
||||
/** Find the first named child with the given node type */
|
||||
export const findChildByType = (node: SyntaxNode, type: string): SyntaxNode | null => {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === type) return child;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Internal helper: extract the first comma-separated argument from a string,
|
||||
// respecting nested angle-bracket and square-bracket depth.
|
||||
function extractFirstArg(args: string): string {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation } from './shared.js';
|
||||
import { findChild } from '../resolvers/utils.js';
|
||||
import { extractSimpleTypeName, extractVarName, findChildByType, hasTypeAnnotation } from './shared.js';
|
||||
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'property_declaration',
|
||||
@@ -11,9 +10,9 @@ const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
|
||||
// Swift property_declaration has pattern and type_annotation
|
||||
const pattern = node.childForFieldName('pattern')
|
||||
?? findChild(node, 'pattern');
|
||||
?? findChildByType(node, 'pattern');
|
||||
const typeAnnotation = node.childForFieldName('type')
|
||||
?? findChild(node, 'type_annotation');
|
||||
?? findChildByType(node, 'type_annotation');
|
||||
if (!pattern || !typeAnnotation) return;
|
||||
const varName = extractVarName(pattern) ?? pattern.text;
|
||||
const typeName = extractSimpleTypeName(typeAnnotation);
|
||||
@@ -46,14 +45,14 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
|
||||
const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, classNames: ClassNameLookup): void => {
|
||||
if (node.type !== 'property_declaration') return;
|
||||
// Skip if has type annotation — extractDeclaration handled it
|
||||
if (node.childForFieldName('type') || findChild(node, 'type_annotation')) return;
|
||||
if (node.childForFieldName('type') || findChildByType(node, 'type_annotation')) return;
|
||||
// Find pattern (variable name)
|
||||
const pattern = node.childForFieldName('pattern') ?? findChild(node, 'pattern');
|
||||
const pattern = node.childForFieldName('pattern') ?? findChildByType(node, 'pattern');
|
||||
if (!pattern) return;
|
||||
const varName = extractVarName(pattern) ?? pattern.text;
|
||||
if (!varName || env.has(varName)) return;
|
||||
// Find call_expression in the value
|
||||
const callExpr = findChild(node, 'call_expression');
|
||||
const callExpr = findChildByType(node, 'call_expression');
|
||||
if (!callExpr) return;
|
||||
const callee = callExpr.firstNamedChild;
|
||||
if (!callee) return;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,6 @@ import {
|
||||
isBuiltInOrNoise,
|
||||
getDefinitionNodeFromCaptures,
|
||||
findEnclosingClassId,
|
||||
getLabelFromCaptures,
|
||||
extractMethodSignature,
|
||||
countCallArguments,
|
||||
inferCallForm,
|
||||
@@ -46,8 +45,8 @@ import { isNodeExported } from '../export-detection.js';
|
||||
import { detectFrameworkFromAST } from '../framework-detection.js';
|
||||
import { typeConfigs } from '../type-extractors/index.js';
|
||||
import { generateId } from '../../../lib/utils.js';
|
||||
import { namedBindingExtractors, preprocessImportPath } from '../import-resolution.js';
|
||||
import type { NamedBinding } from '../import-resolution.js';
|
||||
import { extractNamedBindings } from '../named-binding-extraction.js';
|
||||
import { appendKotlinWildcard } from '../resolvers/index.js';
|
||||
import { callRouters } from '../call-routing.js';
|
||||
import { extractPropertyDeclaredType } from '../type-extractors/shared.js';
|
||||
import type { NodeLabel } from '../../graph/types.js';
|
||||
@@ -102,7 +101,7 @@ export interface ExtractedImport {
|
||||
rawImportPath: string;
|
||||
language: SupportedLanguages;
|
||||
/** Named bindings from the import (e.g., import {User as U} → [{local:'U', exported:'User'}]) */
|
||||
namedBindings?: NamedBinding[];
|
||||
namedBindings?: { local: string; exported: string }[];
|
||||
}
|
||||
|
||||
export interface ExtractedCall {
|
||||
@@ -165,13 +164,6 @@ export interface FileConstructorBindings {
|
||||
bindings: ConstructorBinding[];
|
||||
}
|
||||
|
||||
/** File-scope type bindings from TypeEnv fixpoint — used for cross-file ExportedTypeMap. */
|
||||
export interface FileTypeEnvBindings {
|
||||
filePath: string;
|
||||
/** [varName, typeName] pairs from file scope (scope = '') */
|
||||
bindings: [string, string][];
|
||||
}
|
||||
|
||||
export interface ParseWorkerResult {
|
||||
nodes: ParsedNode[];
|
||||
relationships: ParsedRelationship[];
|
||||
@@ -182,8 +174,6 @@ export interface ParseWorkerResult {
|
||||
heritage: ExtractedHeritage[];
|
||||
routes: ExtractedRoute[];
|
||||
constructorBindings: FileConstructorBindings[];
|
||||
/** File-scope type bindings from TypeEnv fixpoint for exported symbol collection. */
|
||||
typeEnvBindings: FileTypeEnvBindings[];
|
||||
skippedLanguages: Record<string, number>;
|
||||
fileCount: number;
|
||||
}
|
||||
@@ -259,7 +249,39 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
|
||||
return null;
|
||||
};
|
||||
|
||||
// Label detection moved to shared getLabelFromCaptures in utils.ts
|
||||
// ============================================================================
|
||||
// Label detection from capture map
|
||||
// ============================================================================
|
||||
|
||||
const getLabelFromCaptures = (captureMap: Record<string, any>): NodeLabel | null => {
|
||||
// Skip imports (handled separately) and calls
|
||||
if (captureMap['import'] || captureMap['call']) return null;
|
||||
if (!captureMap['name']) return null;
|
||||
|
||||
if (captureMap['definition.function']) return 'Function';
|
||||
if (captureMap['definition.class']) return 'Class';
|
||||
if (captureMap['definition.interface']) return 'Interface';
|
||||
if (captureMap['definition.method']) return 'Method';
|
||||
if (captureMap['definition.struct']) return 'Struct';
|
||||
if (captureMap['definition.enum']) return 'Enum';
|
||||
if (captureMap['definition.namespace']) return 'Namespace';
|
||||
if (captureMap['definition.module']) return 'Module';
|
||||
if (captureMap['definition.trait']) return 'Trait';
|
||||
if (captureMap['definition.impl']) return 'Impl';
|
||||
if (captureMap['definition.type']) return 'TypeAlias';
|
||||
if (captureMap['definition.const']) return 'Const';
|
||||
if (captureMap['definition.static']) return 'Static';
|
||||
if (captureMap['definition.typedef']) return 'Typedef';
|
||||
if (captureMap['definition.macro']) return 'Macro';
|
||||
if (captureMap['definition.union']) return 'Union';
|
||||
if (captureMap['definition.property']) return 'Property';
|
||||
if (captureMap['definition.record']) return 'Record';
|
||||
if (captureMap['definition.delegate']) return 'Delegate';
|
||||
if (captureMap['definition.annotation']) return 'Annotation';
|
||||
if (captureMap['definition.constructor']) return 'Constructor';
|
||||
if (captureMap['definition.template']) return 'Template';
|
||||
return 'CodeElement';
|
||||
};
|
||||
|
||||
// DEFINITION_CAPTURE_KEYS and getDefinitionNodeFromCaptures imported from ../utils.js
|
||||
|
||||
@@ -279,7 +301,6 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n
|
||||
heritage: [],
|
||||
routes: [],
|
||||
constructorBindings: [],
|
||||
typeEnvBindings: [],
|
||||
skippedLanguages: {},
|
||||
fileCount: 0,
|
||||
};
|
||||
@@ -913,16 +934,6 @@ const processFileGroup = (
|
||||
result.constructorBindings.push({ filePath: file.path, bindings: [...typeEnv.constructorBindings] });
|
||||
}
|
||||
|
||||
// Extract file-scope bindings for ExportedTypeMap (closes worker/sequential quality gap).
|
||||
// Sequential path uses collectExportedBindings(typeEnv) directly; worker path serializes
|
||||
// these bindings so the main thread can merge them into ExportedTypeMap.
|
||||
const fileScope = typeEnv.env.get('');
|
||||
if (fileScope && fileScope.size > 0) {
|
||||
const bindings: [string, string][] = [];
|
||||
for (const [name, type] of fileScope) bindings.push([name, type]);
|
||||
result.typeEnvBindings.push({ filePath: file.path, bindings });
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
const captureMap: Record<string, any> = {};
|
||||
for (const c of match.captures) {
|
||||
@@ -931,10 +942,10 @@ const processFileGroup = (
|
||||
|
||||
// Extract import paths before skipping
|
||||
if (captureMap['import'] && captureMap['import.source']) {
|
||||
const rawImportPath = preprocessImportPath(captureMap['import.source'].text, captureMap['import'], language);
|
||||
if (!rawImportPath) continue;
|
||||
const extractor = namedBindingExtractors[language];
|
||||
const namedBindings = extractor ? extractor(captureMap['import']) : undefined;
|
||||
const rawImportPath = language === SupportedLanguages.Kotlin
|
||||
? appendKotlinWildcard(captureMap['import.source'].text.replace(/['"<>]/g, ''), captureMap['import'])
|
||||
: captureMap['import.source'].text.replace(/['"<>]/g, '');
|
||||
const namedBindings = extractNamedBindings(captureMap['import'], language);
|
||||
result.imports.push({
|
||||
filePath: file.path,
|
||||
rawImportPath,
|
||||
@@ -1133,9 +1144,26 @@ const processFileGroup = (
|
||||
}
|
||||
}
|
||||
|
||||
const nodeLabel = getLabelFromCaptures(captureMap, language);
|
||||
const nodeLabel = getLabelFromCaptures(captureMap);
|
||||
if (!nodeLabel) continue;
|
||||
|
||||
// C/C++: @definition.function is broad and also matches inline class methods (inside
|
||||
// a class/struct body). Those are already captured by @definition.method, so skip
|
||||
// the duplicate Function entry to prevent double-indexing in globalIndex.
|
||||
if (
|
||||
(language === SupportedLanguages.CPlusPlus || language === SupportedLanguages.C) &&
|
||||
nodeLabel === 'Function'
|
||||
) {
|
||||
let ancestor = captureMap['definition.function']?.parent;
|
||||
while (ancestor) {
|
||||
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') {
|
||||
break; // inside a class body — duplicate of @definition.method
|
||||
}
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
if (ancestor) continue; // found a class/struct ancestor → skip
|
||||
}
|
||||
|
||||
const nameNode = captureMap['name'];
|
||||
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
|
||||
if (!nameNode && nodeLabel !== 'Constructor') continue;
|
||||
@@ -1264,7 +1292,7 @@ const processFileGroup = (
|
||||
/** Accumulated result across sub-batches */
|
||||
let accumulated: ParseWorkerResult = {
|
||||
nodes: [], relationships: [], symbols: [],
|
||||
imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0,
|
||||
imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0,
|
||||
};
|
||||
let cumulativeProcessed = 0;
|
||||
|
||||
@@ -1278,7 +1306,6 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
|
||||
target.heritage.push(...src.heritage);
|
||||
target.routes.push(...src.routes);
|
||||
target.constructorBindings.push(...src.constructorBindings);
|
||||
target.typeEnvBindings.push(...src.typeEnvBindings);
|
||||
for (const [lang, count] of Object.entries(src.skippedLanguages)) {
|
||||
target.skippedLanguages[lang] = (target.skippedLanguages[lang] || 0) + count;
|
||||
}
|
||||
@@ -1303,7 +1330,7 @@ parentPort!.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'flush') {
|
||||
parentPort!.postMessage({ type: 'result', data: accumulated });
|
||||
// Reset for potential reuse
|
||||
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0 };
|
||||
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0 };
|
||||
cumulativeProcessed = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,26 +25,6 @@ export const getDatabase = (): lbug.Database | null => db;
|
||||
// This guarantees no DB switch can happen while an operation is running.
|
||||
let sessionLock: Promise<void> = Promise.resolve();
|
||||
|
||||
/** Number of times to retry on a BUSY / lock-held error before giving up. */
|
||||
const DB_LOCK_RETRY_ATTEMPTS = 3;
|
||||
/** Base back-off in ms between BUSY retries (multiplied by attempt number). */
|
||||
const DB_LOCK_RETRY_DELAY_MS = 500;
|
||||
|
||||
/**
|
||||
* Return true when the error message indicates that another process holds
|
||||
* an exclusive lock on the LadybugDB file (e.g. `gitnexus analyze` or
|
||||
* `gitnexus serve` running at the same time).
|
||||
*/
|
||||
export const isDbBusyError = (err: unknown): boolean => {
|
||||
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||
return (
|
||||
msg.includes('busy')
|
||||
|| msg.includes('lock')
|
||||
|| msg.includes('already in use')
|
||||
|| msg.includes('could not set lock')
|
||||
);
|
||||
};
|
||||
|
||||
const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const previous = sessionLock;
|
||||
let release: (() => void) | null = null;
|
||||
@@ -69,41 +49,12 @@ export const initLbug = async (dbPath: string) => {
|
||||
/**
|
||||
* Execute multiple queries against one repo DB atomically.
|
||||
* While the callback runs, no other request can switch the active DB.
|
||||
*
|
||||
* Automatically retries up to DB_LOCK_RETRY_ATTEMPTS times when the
|
||||
* database is busy (e.g. `gitnexus analyze` holds the write lock).
|
||||
* Each retry waits DB_LOCK_RETRY_DELAY_MS * attempt milliseconds.
|
||||
*/
|
||||
export const withLbugDb = async <T>(dbPath: string, operation: () => Promise<T>): Promise<T> => {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= DB_LOCK_RETRY_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await runWithSessionLock(async () => {
|
||||
await ensureLbugInitialized(dbPath);
|
||||
return operation();
|
||||
});
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (!isDbBusyError(err) || attempt === DB_LOCK_RETRY_ATTEMPTS) {
|
||||
throw err;
|
||||
}
|
||||
// Close stale connection inside the session lock to prevent race conditions
|
||||
// with concurrent operations that might acquire the lock between cleanup steps
|
||||
await runWithSessionLock(async () => {
|
||||
try { if (conn) await conn.close(); } catch { /* best-effort */ }
|
||||
try { if (db) await db.close(); } catch { /* best-effort */ }
|
||||
conn = null;
|
||||
db = null;
|
||||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
});
|
||||
// Sleep outside the lock — no need to block others while waiting
|
||||
await new Promise(resolve => setTimeout(resolve, DB_LOCK_RETRY_DELAY_MS * attempt));
|
||||
}
|
||||
}
|
||||
// This line is unreachable — the loop either returns or throws inside,
|
||||
// but TypeScript needs an explicit throw to satisfy the return type.
|
||||
throw lastError;
|
||||
return runWithSessionLock(async () => {
|
||||
await ensureLbugInitialized(dbPath);
|
||||
return operation();
|
||||
});
|
||||
};
|
||||
|
||||
const ensureLbugInitialized = async (dbPath: string) => {
|
||||
|
||||
@@ -414,19 +414,10 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
||||
// Separate table for vector storage to avoid copy-on-write overhead
|
||||
// ============================================================================
|
||||
|
||||
/** Embedding vector dimensions. Default 384 (snowflake-arctic-embed-xs). */
|
||||
const _rawDims = parseInt(process.env.GITNEXUS_EMBEDDING_DIMS ?? '384', 10);
|
||||
if (Number.isNaN(_rawDims) || _rawDims <= 0) {
|
||||
throw new Error(
|
||||
`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${process.env.GITNEXUS_EMBEDDING_DIMS}"`,
|
||||
);
|
||||
}
|
||||
export const EMBEDDING_DIMS = _rawDims;
|
||||
|
||||
export const EMBEDDING_SCHEMA = `
|
||||
CREATE NODE TABLE ${EMBEDDING_TABLE_NAME} (
|
||||
nodeId STRING,
|
||||
embedding FLOAT[${EMBEDDING_DIMS}],
|
||||
embedding FLOAT[384],
|
||||
PRIMARY KEY (nodeId)
|
||||
)`;
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
*/
|
||||
|
||||
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
|
||||
import { isHttpMode, getHttpDimensions, httpEmbedQuery } from '../../core/embeddings/http-client.js';
|
||||
|
||||
// Model config
|
||||
const MODEL_ID = 'Snowflake/snowflake-arctic-embed-xs';
|
||||
const EMBEDDING_DIMS = 384;
|
||||
|
||||
// Module-level state for singleton pattern
|
||||
let embedderInstance: FeatureExtractionPipeline | null = null;
|
||||
@@ -20,10 +20,6 @@ let initPromise: Promise<FeatureExtractionPipeline> | null = null;
|
||||
* Initialize the embedding model (lazy, on first search)
|
||||
*/
|
||||
export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
|
||||
if (isHttpMode()) {
|
||||
throw new Error('initEmbedder() should not be called in HTTP mode.');
|
||||
}
|
||||
|
||||
if (embedderInstance) {
|
||||
return embedderInstance;
|
||||
}
|
||||
@@ -91,16 +87,12 @@ export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
|
||||
/**
|
||||
* Check if embedder is ready
|
||||
*/
|
||||
export const isEmbedderReady = (): boolean => isHttpMode() || embedderInstance !== null;
|
||||
export const isEmbedderReady = (): boolean => embedderInstance !== null;
|
||||
|
||||
/**
|
||||
* Embed a query text for semantic search
|
||||
*/
|
||||
export const embedQuery = async (query: string): Promise<number[]> => {
|
||||
if (isHttpMode()) {
|
||||
return httpEmbedQuery(query);
|
||||
}
|
||||
|
||||
const embedder = await initEmbedder();
|
||||
|
||||
const result = await embedder(query, {
|
||||
@@ -114,9 +106,7 @@ export const embedQuery = async (query: string): Promise<number[]> => {
|
||||
/**
|
||||
* Get embedding dimensions
|
||||
*/
|
||||
export const getEmbeddingDims = (): number => {
|
||||
return getHttpDimensions() ?? 384;
|
||||
};
|
||||
export const getEmbeddingDims = (): number => EMBEDDING_DIMS;
|
||||
|
||||
/**
|
||||
* Cleanup embedder
|
||||
|
||||
@@ -49,44 +49,6 @@ export const VALID_NODE_LABELS = new Set([
|
||||
/** Valid relation types for impact analysis filtering */
|
||||
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']);
|
||||
|
||||
/**
|
||||
* Per-relation-type confidence floor for impact analysis.
|
||||
*
|
||||
* When the graph stores a relation with a confidence value, that stored
|
||||
* value is used as-is (it reflects resolution-tier accuracy from analysis
|
||||
* time). This map provides the floor for each edge type when no stored
|
||||
* confidence is available, and is also used for display / tooltip hints.
|
||||
*
|
||||
* Rationale:
|
||||
* CALLS / IMPORTS – direct, strongly-typed references → 0.9
|
||||
* EXTENDS – class hierarchy, statically verifiable → 0.85
|
||||
* IMPLEMENTS – interface contract, statically verifiable → 0.85
|
||||
* OVERRIDES – method override, statically verifiable → 0.85
|
||||
* HAS_METHOD – structural containment → 0.95
|
||||
* HAS_PROPERTY – structural containment → 0.95
|
||||
* ACCESSES – field read/write, may be indirect → 0.8
|
||||
* CONTAINS – folder/file containment → 0.95
|
||||
* (unknown type) – conservative fallback → 0.5
|
||||
*/
|
||||
export const IMPACT_RELATION_CONFIDENCE: Readonly<Record<string, number>> = {
|
||||
CALLS: 0.9,
|
||||
IMPORTS: 0.9,
|
||||
EXTENDS: 0.85,
|
||||
IMPLEMENTS: 0.85,
|
||||
OVERRIDES: 0.85,
|
||||
HAS_METHOD: 0.95,
|
||||
HAS_PROPERTY: 0.95,
|
||||
ACCESSES: 0.8,
|
||||
CONTAINS: 0.95,
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the confidence floor for a given relation type.
|
||||
* Falls back to 0.5 for unknown types so they are not silently elevated.
|
||||
*/
|
||||
const confidenceForRelType = (relType: string | undefined): number =>
|
||||
IMPACT_RELATION_CONFIDENCE[relType ?? ''] ?? 0.5;
|
||||
|
||||
/** Regex to detect write operations in user-supplied Cypher queries */
|
||||
export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;
|
||||
|
||||
@@ -1483,22 +1445,14 @@ export class LocalBackend {
|
||||
if (!visited.has(relId)) {
|
||||
visited.add(relId);
|
||||
nextFrontier.push(relId);
|
||||
const storedConfidence = rel.confidence ?? rel[6];
|
||||
const relationType = rel.relType || rel[5];
|
||||
// Prefer the stored confidence from the graph (set at analysis time);
|
||||
// fall back to the per-type floor for edges without a stored value.
|
||||
const effectiveConfidence =
|
||||
typeof storedConfidence === 'number' && storedConfidence > 0
|
||||
? storedConfidence
|
||||
: confidenceForRelType(relationType);
|
||||
impacted.push({
|
||||
depth,
|
||||
id: relId,
|
||||
name: rel.name || rel[2],
|
||||
type: rel.type || rel[3],
|
||||
filePath,
|
||||
relationType,
|
||||
confidence: effectiveConfidence,
|
||||
relationType: rel.relType || rel[5],
|
||||
confidence: rel.confidence || rel[6] || 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,8 +327,6 @@ node_properties:
|
||||
Function: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL)"
|
||||
Property: "declaredType (STRING) — the field's type annotation (e.g., 'Address', 'City'). Used for field-access chain resolution."
|
||||
Constructor: "parameterCount (INT32)"
|
||||
Community: "heuristicLabel (STRING), cohesion (DOUBLE), symbolCount (INT32), keywords (STRING[]), description (STRING), enrichedBy (STRING)"
|
||||
Process: "heuristicLabel (STRING), processType (STRING — 'intra_community' or 'cross_community'), stepCount (INT32), communities (STRING[]), entryPointId (STRING), terminalId (STRING)"
|
||||
|
||||
relationships:
|
||||
- CONTAINS: File/Folder contains child
|
||||
|
||||
@@ -110,8 +110,8 @@ OUTPUT: Returns { markdown, row_count } — results formatted as a Markdown tabl
|
||||
|
||||
TIPS:
|
||||
- All relationships use single CodeRelation table — filter with {type: 'CALLS'} etc.
|
||||
- Community = auto-detected functional area (Leiden algorithm). Properties: heuristicLabel, cohesion, symbolCount, keywords, description, enrichedBy
|
||||
- Process = execution flow trace from entry point to terminal. Properties: heuristicLabel, processType, stepCount, communities, entryPointId, terminalId
|
||||
- Community = auto-detected functional area (Leiden algorithm)
|
||||
- Process = execution flow trace from entry point to terminal
|
||||
- Use heuristicLabel (not label) for human-readable community/process names`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Also hosts the MCP server over StreamableHTTP for remote AI tool access.
|
||||
*
|
||||
* Security: binds to 127.0.0.1 by default (use --host to override).
|
||||
* CORS is restricted to localhost, private/LAN networks, and the deployed site.
|
||||
* CORS is restricted to localhost and the deployed site.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
@@ -23,74 +23,6 @@ import { hybridSearch } from '../core/search/hybrid-search.js';
|
||||
import { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
import { mountMCPEndpoints } from './mcp-http.js';
|
||||
|
||||
/**
|
||||
* Determine whether an HTTP Origin header value is allowed by CORS policy.
|
||||
*
|
||||
* Permitted origins:
|
||||
* - No origin (non-browser requests such as curl or server-to-server calls)
|
||||
* - http://localhost:<port> — local development
|
||||
* - http://127.0.0.1:<port> — loopback alias
|
||||
* - RFC 1918 private/LAN networks (any port):
|
||||
* 10.0.0.0/8 → 10.x.x.x
|
||||
* 172.16.0.0/12 → 172.16.x.x – 172.31.x.x
|
||||
* 192.168.0.0/16 → 192.168.x.x
|
||||
* - https://gitnexus.vercel.app — the deployed GitNexus web UI
|
||||
*
|
||||
* @param origin - The value of the HTTP `Origin` request header, or `undefined`
|
||||
* when the header is absent (non-browser request).
|
||||
* @returns `true` if the origin is allowed, `false` otherwise.
|
||||
*/
|
||||
export const isAllowedOrigin = (origin: string | undefined): boolean => {
|
||||
if (origin === undefined) {
|
||||
// Non-browser requests (curl, server-to-server) have no Origin header
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
origin.startsWith('http://localhost:')
|
||||
|| origin === 'http://localhost'
|
||||
|| origin.startsWith('http://127.0.0.1:')
|
||||
|| origin === 'http://127.0.0.1'
|
||||
|| origin.startsWith('http://[::1]:')
|
||||
|| origin === 'http://[::1]'
|
||||
|| origin === 'https://gitnexus.vercel.app'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// RFC 1918 private network ranges — allow any port on these hosts.
|
||||
// We parse the hostname out of the origin URL and check against each range.
|
||||
let hostname: string;
|
||||
let protocol: string;
|
||||
try {
|
||||
const parsed = new URL(origin);
|
||||
hostname = parsed.hostname;
|
||||
protocol = parsed.protocol;
|
||||
} catch {
|
||||
// Malformed origin — reject
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only allow HTTP(S) origins — reject ftp://, file://, etc.
|
||||
if (protocol !== 'http:' && protocol !== 'https:') return false;
|
||||
|
||||
const octets = hostname.split('.').map(Number);
|
||||
if (octets.length !== 4 || octets.some(o => !Number.isInteger(o) || o < 0 || o > 255)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [a, b] = octets;
|
||||
|
||||
// 10.0.0.0/8
|
||||
if (a === 10) return true;
|
||||
// 172.16.0.0/12 → 172.16.x.x – 172.31.x.x
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
// 192.168.0.0/16
|
||||
if (a === 192 && b === 168) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const buildGraph = async (): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
|
||||
const nodes: GraphNode[] = [];
|
||||
for (const table of NODE_TABLES) {
|
||||
@@ -175,11 +107,16 @@ const requestedRepo = (req: express.Request): string | undefined => {
|
||||
export const createServer = async (port: number, host: string = '127.0.0.1') => {
|
||||
const app = express();
|
||||
|
||||
// CORS: allow localhost, private/LAN networks, and the deployed site.
|
||||
// CORS: only allow localhost origins and the deployed site.
|
||||
// Non-browser requests (curl, server-to-server) have no origin and are allowed.
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
if (isAllowedOrigin(origin)) {
|
||||
if (
|
||||
!origin
|
||||
|| origin.startsWith('http://localhost:')
|
||||
|| origin.startsWith('http://127.0.0.1:')
|
||||
|| origin === 'https://gitnexus.vercel.app'
|
||||
) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { statSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Git utilities for repository detection, commit tracking, and diff analysis
|
||||
@@ -35,22 +34,3 @@ export const getGitRoot = (fromPath: string): string | null => {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Check whether a directory contains a .git entry (file or folder).
|
||||
*
|
||||
* This is intentionally a simple filesystem check rather than running
|
||||
* `git rev-parse`, so it works even when git is not installed or when
|
||||
* the directory is a git-worktree root (which has a .git file, not a
|
||||
* directory). Use `isGitRepo` for a definitive git answer.
|
||||
*
|
||||
* @param dirPath - Absolute path to the directory to inspect.
|
||||
* @returns `true` when `.git` is present, `false` otherwise.
|
||||
*/
|
||||
export const hasGitDir = (dirPath: string): boolean => {
|
||||
try {
|
||||
statSync(path.join(dirPath, '.git'));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
#include "../models/user_factory.h"
|
||||
|
||||
void process() {
|
||||
User user = get_user();
|
||||
user.save();
|
||||
user.get_name();
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
#include "user.h"
|
||||
|
||||
void User::save() {}
|
||||
|
||||
std::string User::get_name() {
|
||||
return "";
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
class User {
|
||||
public:
|
||||
void save();
|
||||
std::string get_name();
|
||||
};
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
#include "user_factory.h"
|
||||
|
||||
User get_user() {
|
||||
return User();
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "user.h"
|
||||
|
||||
User get_user();
|
||||
@@ -1,14 +0,0 @@
|
||||
using static CrossFile.Models.UserFactory;
|
||||
|
||||
namespace CrossFile.App
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var u = GetUser();
|
||||
u.Save();
|
||||
u.GetName();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<RootNamespace>CrossFile</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace CrossFile.Models
|
||||
{
|
||||
public class User
|
||||
{
|
||||
public void Save() {}
|
||||
public string GetName() { return ""; }
|
||||
}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
namespace CrossFile.Models
|
||||
{
|
||||
public static class UserFactory
|
||||
{
|
||||
public static User GetUser() { return new User(); }
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package main
|
||||
|
||||
import "go-cross-file/models"
|
||||
|
||||
func main() {
|
||||
user := models.GetUser()
|
||||
user.Save()
|
||||
user.GetName()
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module go-cross-file
|
||||
|
||||
go 1.21
|
||||
@@ -1,5 +0,0 @@
|
||||
package models
|
||||
|
||||
func GetUser() User {
|
||||
return User{}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package models
|
||||
|
||||
type User struct{}
|
||||
|
||||
func (u User) Save() {}
|
||||
|
||||
func (u User) GetName() string { return "" }
|
||||
@@ -1,11 +0,0 @@
|
||||
package app;
|
||||
|
||||
import static models.UserFactory.getUser;
|
||||
|
||||
public class App {
|
||||
public void run() {
|
||||
var user = getUser();
|
||||
user.save();
|
||||
user.getName();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user