Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6878dcec6 | ||
|
|
519dc3eccc | ||
|
|
ffabe857a3 | ||
|
|
57e087de49 | ||
|
|
fc6c114570 | ||
|
|
43d866c802 | ||
|
|
6b0c566392 | ||
|
|
96181fba05 | ||
|
|
208ade9b11 | ||
|
|
9f14edc226 | ||
|
|
cb1293b718 | ||
|
|
6c9b6eb0f6 | ||
|
|
3558cb8a7f | ||
|
|
9f2d1780d5 | ||
|
|
a57550815f | ||
|
|
ee78ebe64a | ||
|
|
141f864181 | ||
|
|
01fa5bf98e | ||
|
|
bbd95457df | ||
|
|
d43cc691f0 | ||
|
|
f90aabf9a8 | ||
|
|
beb5574d38 | ||
|
|
d48903cce6 | ||
|
|
a3fac2f672 | ||
|
|
9954f6fdfd | ||
|
|
90ca43f851 | ||
|
|
0a200a51cb | ||
|
|
c87cb81892 | ||
|
|
b8ab23a838 | ||
|
|
e70a6d80c0 | ||
|
|
cd1c0ff7dc | ||
|
|
907440cf0b | ||
|
|
843c561e9b | ||
|
|
4c8be50cb1 | ||
|
|
d07a69c3b1 | ||
|
|
d206bf6772 | ||
|
|
03156935cb | ||
|
|
22b5fce19e | ||
|
|
09e3609376 | ||
|
|
f0f384aab7 | ||
|
|
ef8b252bcc | ||
|
|
92a1086697 | ||
|
|
b272c6864c | ||
|
|
4031562f6b | ||
|
|
4dffd81b12 | ||
|
|
b75e380bd0 | ||
|
|
24421c2db1 | ||
|
|
e6eaf08382 | ||
|
|
f409fb7525 | ||
|
|
cc2b13e332 | ||
|
|
b909bbff70 | ||
|
|
56356b71db | ||
|
|
2c9e887a80 | ||
|
|
8fbfd09081 | ||
|
|
6f4281c946 | ||
|
|
60265c1d0d | ||
|
|
fa765beec1 | ||
|
|
61f2f6d954 | ||
|
|
2428e72bd2 | ||
|
|
76ed0fa53b | ||
|
|
2c17a4642c | ||
|
|
4f2e4ac24d | ||
|
|
1af9c028c5 | ||
|
|
fb20a3c752 | ||
|
|
f1fbe643df | ||
|
|
89a24d866e | ||
|
|
9baef90ae2 | ||
|
|
11575cf6c8 | ||
|
|
c2c694ac42 | ||
|
|
01ff2a7540 | ||
|
|
13b22e9879 | ||
|
|
c8480f899d | ||
|
|
1f7764c49b | ||
|
|
00758b102a | ||
|
|
abeb52e5e5 | ||
|
|
6c972079e0 | ||
|
|
cbfdae0303 | ||
|
|
66c1ffa370 | ||
|
|
88e0034771 | ||
|
|
0736bb23bc | ||
|
|
2ff4d93314 | ||
|
|
fff716dd92 | ||
|
|
56bc226a1c | ||
|
|
a6a1004e82 | ||
|
|
5b012c3351 | ||
|
|
862fdf9185 | ||
|
|
228c993bb7 | ||
|
|
c3a2815186 | ||
|
|
893f77ae89 | ||
|
|
d49c76ddc5 | ||
|
|
7dcafb647b | ||
|
|
5dcc567870 | ||
|
|
999fbf5b11 | ||
|
|
c79c717790 | ||
|
|
9dc00e47a7 | ||
|
|
c14a78a341 | ||
|
|
7a0a83e45c | ||
|
|
f2edbb4f82 | ||
|
|
1d27ad09a2 | ||
|
|
68d4c48aba | ||
|
|
bc771574d8 | ||
|
|
5d6e15eea3 | ||
|
|
eb74eb8590 | ||
|
|
700c9d16e4 | ||
|
|
19ff84fa31 | ||
|
|
2fe03d2a21 | ||
|
|
3e29f4e4b9 | ||
|
|
71353512a1 | ||
|
|
00c5126b24 | ||
|
|
06994e474a | ||
|
|
c507b4b197 | ||
|
|
b6947b0c02 | ||
|
|
e9ccec1a52 | ||
|
|
0122d9e694 | ||
|
|
00e2476eca | ||
|
|
217efcf015 | ||
|
|
7c72cefd8d | ||
|
|
a7b8c302d4 | ||
|
|
b0f25e216d | ||
|
|
84f07e83ee | ||
|
|
f83ef56ccb | ||
|
|
530b4be9ee | ||
|
|
2915e60630 | ||
|
|
984316d260 |
@@ -0,0 +1,257 @@
|
||||
"""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
|
||||
@@ -0,0 +1,4 @@
|
||||
fastembed>=0.5.0
|
||||
numpy>=1.26.0
|
||||
scikit-learn>=1.4.0
|
||||
scipy>=1.10.0
|
||||
@@ -0,0 +1,600 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,468 @@
|
||||
"""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
|
||||
@@ -0,0 +1,873 @@
|
||||
"""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
|
||||
@@ -0,0 +1,348 @@
|
||||
name: CI Report
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI']
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pr-report:
|
||||
name: PR Report
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion != 'cancelled'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Download PR metadata
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }},
|
||||
});
|
||||
|
||||
const meta = artifacts.data.artifacts.find(a => a.name === 'pr-meta');
|
||||
if (!meta) {
|
||||
core.setFailed('pr-meta artifact not found — skipping report');
|
||||
return;
|
||||
}
|
||||
|
||||
const zip = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: meta.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
|
||||
const dest = path.join(process.env.RUNNER_TEMP, 'pr-meta');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, 'pr-meta.zip'), Buffer.from(zip.data));
|
||||
|
||||
- name: Extract PR metadata
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
cd "$RUNNER_TEMP/pr-meta"
|
||||
unzip -o pr-meta.zip
|
||||
|
||||
PR_NUMBER=$(cat pr-number | tr -d '[:space:]')
|
||||
if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::Invalid PR number: '$PR_NUMBER'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
echo "quality=$(cat quality-result | tr -d '[:space:]')" >> "$GITHUB_OUTPUT"
|
||||
echo "tests=$(cat tests-result | tr -d '[:space:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download test reports
|
||||
id: download-test-reports
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }},
|
||||
});
|
||||
|
||||
const reports = artifacts.data.artifacts.find(a => a.name === 'test-reports');
|
||||
if (!reports) {
|
||||
core.warning('test-reports artifact not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const zip = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: reports.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
|
||||
const dest = path.join(process.env.RUNNER_TEMP, 'test-reports');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, 'test-reports.zip'), Buffer.from(zip.data));
|
||||
|
||||
- name: Extract test reports
|
||||
if: steps.download-test-reports.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
cd "$RUNNER_TEMP/test-reports"
|
||||
unzip -o test-reports.zip || true
|
||||
|
||||
- name: Fetch cross-platform job results
|
||||
id: jobs
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const jobs = await github.rest.actions.listJobsForWorkflowRun({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }},
|
||||
per_page: 50,
|
||||
});
|
||||
|
||||
const results = {};
|
||||
for (const job of jobs.data.jobs) {
|
||||
if (job.name.includes('ubuntu')) results.ubuntu = job.conclusion || 'pending';
|
||||
else if (job.name.includes('windows')) results.windows = job.conclusion || 'pending';
|
||||
else if (job.name.includes('macos')) results.macos = job.conclusion || 'pending';
|
||||
}
|
||||
core.setOutput('ubuntu', results.ubuntu || 'unknown');
|
||||
core.setOutput('windows', results.windows || 'unknown');
|
||||
core.setOutput('macos', results.macos || 'unknown');
|
||||
|
||||
- name: Fetch base branch coverage
|
||||
id: base-coverage
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'ci.yml',
|
||||
branch: 'main',
|
||||
status: 'success',
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
if (runs.data.workflow_runs.length === 0) {
|
||||
core.setOutput('found', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
const mainRunId = runs.data.workflow_runs[0].id;
|
||||
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: mainRunId,
|
||||
});
|
||||
|
||||
const testReports = artifacts.data.artifacts.find(a => a.name === 'test-reports');
|
||||
if (!testReports) {
|
||||
core.setOutput('found', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
const zip = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: testReports.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
|
||||
const dest = path.join(process.env.RUNNER_TEMP, 'base-coverage');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, 'base.zip'), Buffer.from(zip.data));
|
||||
core.setOutput('found', 'true');
|
||||
core.setOutput('dir', dest);
|
||||
|
||||
- name: Extract base coverage
|
||||
if: steps.base-coverage.outputs.found == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
cd "${{ steps.base-coverage.outputs.dir }}"
|
||||
unzip -o base.zip -d base
|
||||
|
||||
- name: Build and post report
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.meta.outputs.pr-number }}
|
||||
QUALITY: ${{ steps.meta.outputs.quality }}
|
||||
TESTS: ${{ steps.meta.outputs.tests }}
|
||||
UBUNTU: ${{ steps.jobs.outputs.ubuntu }}
|
||||
WINDOWS: ${{ steps.jobs.outputs.windows }}
|
||||
MACOS: ${{ steps.jobs.outputs.macos }}
|
||||
BASE_FOUND: ${{ steps.base-coverage.outputs.found }}
|
||||
BASE_DIR: ${{ steps.base-coverage.outputs.dir }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const icon = (s) => ({ success: '✅', failure: '❌', cancelled: '⏭️' }[s] || '❓');
|
||||
const temp = process.env.RUNNER_TEMP;
|
||||
|
||||
// ── Read coverage ──
|
||||
function readCov(dir) {
|
||||
const out = { stmts: 'N/A', branch: 'N/A', funcs: 'N/A', lines: 'N/A',
|
||||
stmtsCov: '', branchCov: '', funcsCov: '', linesCov: '' };
|
||||
try {
|
||||
const files = require('child_process')
|
||||
.execSync(`find "${dir}" -name coverage-summary.json -type f`, { encoding: 'utf8' })
|
||||
.trim().split('\n').filter(Boolean);
|
||||
if (!files.length) return out;
|
||||
const d = JSON.parse(fs.readFileSync(files[0], 'utf8')).total;
|
||||
out.stmts = d.statements.pct; out.branch = d.branches.pct;
|
||||
out.funcs = d.functions.pct; out.lines = d.lines.pct;
|
||||
out.stmtsCov = `${d.statements.covered}/${d.statements.total}`;
|
||||
out.branchCov = `${d.branches.covered}/${d.branches.total}`;
|
||||
out.funcsCov = `${d.functions.covered}/${d.functions.total}`;
|
||||
out.linesCov = `${d.lines.covered}/${d.lines.total}`;
|
||||
} catch {}
|
||||
return out;
|
||||
}
|
||||
|
||||
const cov = readCov(path.join(temp, 'test-reports'));
|
||||
const base = process.env.BASE_FOUND === 'true'
|
||||
? readCov(path.join(process.env.BASE_DIR, 'base'))
|
||||
: { stmts: 'N/A', branch: 'N/A', funcs: 'N/A', lines: 'N/A' };
|
||||
|
||||
// ── Read test results ──
|
||||
let total = 0, passed = 0, failed = 0, skipped = 0, suites = 0, duration = '0s';
|
||||
let skippedTests = [];
|
||||
try {
|
||||
const files = require('child_process')
|
||||
.execSync(`find "${path.join(temp, 'test-reports')}" -name test-results.json -type f`, { encoding: 'utf8' })
|
||||
.trim().split('\n').filter(Boolean);
|
||||
if (files.length) {
|
||||
const r = JSON.parse(fs.readFileSync(files[0], 'utf8'));
|
||||
total = r.numTotalTests || 0;
|
||||
passed = r.numPassedTests || 0;
|
||||
failed = r.numFailedTests || 0;
|
||||
skipped = r.numPendingTests || 0;
|
||||
suites = r.numTotalTestSuites || 0;
|
||||
const durS = Math.floor((Math.max(...r.testResults.map(t => t.endTime)) - r.startTime) / 1000);
|
||||
duration = durS >= 60 ? `${Math.floor(durS / 60)}m ${durS % 60}s` : `${durS}s`;
|
||||
// Collect skipped test names
|
||||
for (const suite of r.testResults) {
|
||||
for (const t of (suite.assertionResults || [])) {
|
||||
if (t.status === 'pending' || t.status === 'skipped') {
|
||||
skippedTests.push(`- ${t.ancestorTitles.join(' > ')} > ${t.title}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// ── Coverage delta ──
|
||||
function delta(pct, basePct) {
|
||||
if (pct === 'N/A' || basePct === 'N/A') return '—';
|
||||
const d = (pct - basePct).toFixed(1);
|
||||
const dNum = parseFloat(d);
|
||||
if (dNum > 0) return `📈 +${d}%`;
|
||||
if (dNum < 0) return `📉 ${d}%`;
|
||||
return '=';
|
||||
}
|
||||
|
||||
// ── Build markdown ──
|
||||
const { PR_NUMBER, QUALITY, TESTS, UBUNTU, WINDOWS, MACOS, RUN_ID, HEAD_SHA } = process.env;
|
||||
const prNumber = parseInt(PR_NUMBER, 10);
|
||||
const overall = (QUALITY === 'success' && TESTS === 'success')
|
||||
? '✅ **All checks passed**' : '❌ **Some checks failed**';
|
||||
const sha = HEAD_SHA.slice(0, 7);
|
||||
|
||||
let body = `## CI Report\n\n${overall}   \`${sha}\`\n\n`;
|
||||
|
||||
body += `### Pipeline\n\n`;
|
||||
body += `| Stage | Status | Ubuntu | Windows | macOS |\n`;
|
||||
body += `|-------|--------|--------|---------|-------|\n`;
|
||||
body += `| Typecheck | ${icon(QUALITY)} \`${QUALITY}\` | — | — | — |\n`;
|
||||
body += `| Tests | ${icon(TESTS)} \`${TESTS}\` | ${icon(UBUNTU)} | ${icon(WINDOWS)} | ${icon(MACOS)} |\n\n`;
|
||||
|
||||
if (total > 0) {
|
||||
body += `### Tests\n\n`;
|
||||
body += `| Metric | Value |\n|--------|-------|\n`;
|
||||
body += `| Total | **${total}** |\n`;
|
||||
body += `| Passed | **${passed}** |\n`;
|
||||
if (failed > 0) body += `| Failed | **${failed}** |\n`;
|
||||
if (skipped > 0) body += `| Skipped | ${skipped} |\n`;
|
||||
body += `| Files | ${suites} |\n`;
|
||||
body += `| Duration | ${duration} |\n\n`;
|
||||
|
||||
if (failed === 0) {
|
||||
body += `✅ All **${passed}** tests passed across **${suites}** files\n`;
|
||||
} else {
|
||||
body += `❌ **${failed}** failed / **${passed}** passed\n`;
|
||||
}
|
||||
|
||||
if (skippedTests.length > 0) {
|
||||
body += `\n<details>\n<summary>${skipped} test(s) skipped</summary>\n\n`;
|
||||
body += skippedTests.join('\n') + '\n\n</details>\n';
|
||||
}
|
||||
body += '\n';
|
||||
}
|
||||
|
||||
if (cov.stmts !== 'N/A') {
|
||||
body += `### Coverage\n\n`;
|
||||
body += `| Metric | Coverage | Covered | Base (main) | Delta |\n`;
|
||||
body += `|--------|----------|---------|-------------|-------|\n`;
|
||||
body += `| Statements | **${cov.stmts}%** | ${cov.stmtsCov} | ${base.stmts}% | ${delta(cov.stmts, base.stmts)} |\n`;
|
||||
body += `| Branches | **${cov.branch}%** | ${cov.branchCov} | ${base.branch}% | ${delta(cov.branch, base.branch)} |\n`;
|
||||
body += `| Functions | **${cov.funcs}%** | ${cov.funcsCov} | ${base.funcs}% | ${delta(cov.funcs, base.funcs)} |\n`;
|
||||
body += `| Lines | **${cov.lines}%** | ${cov.linesCov} | ${base.lines}% | ${delta(cov.lines, base.lines)} |\n\n`;
|
||||
} else {
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${RUN_ID}`;
|
||||
body += `### Coverage\n\n⚠️ Coverage data unavailable — check the [test job](${runUrl}) for details.\n\n`;
|
||||
}
|
||||
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${RUN_ID}`;
|
||||
body += `---\n<sub>📋 [Full run](${runUrl}) · Coverage from Ubuntu · Generated by CI</sub>`;
|
||||
|
||||
// ── Post sticky comment ──
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
direction: 'desc',
|
||||
});
|
||||
|
||||
const marker = '<!-- ci-report -->';
|
||||
const existing = comments.find(c => c.body?.includes(marker));
|
||||
const fullBody = marker + '\n' + body;
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body: fullBody,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: fullBody,
|
||||
});
|
||||
}
|
||||
+19
-250
@@ -17,9 +17,7 @@ concurrency:
|
||||
# Each concern lives in its own workflow file for maintainability:
|
||||
# ci-quality.yml — typecheck (tsc --noEmit)
|
||||
# ci-tests.yml — all tests with coverage (ubuntu) + cross-platform
|
||||
#
|
||||
# The PR report runs inline (not via workflow_run) so it uses the
|
||||
# PR branch's code instead of main's — avoids stale report templates.
|
||||
# ci-report.yml — PR comment (workflow_run trigger for fork write access)
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
@@ -55,260 +53,31 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── PR Report ────────────────────────────────────────────────────
|
||||
# Posts a sticky comment with test results, coverage, and
|
||||
# per-platform status. Runs inline so it uses the PR branch's
|
||||
# report template (not main's stale version via workflow_run).
|
||||
pr-report:
|
||||
name: PR Report
|
||||
# ── PR metadata for ci-report.yml ────────────────────────────────
|
||||
# Saves PR number and job results so the workflow_run-triggered
|
||||
# report can post comments with a write token (works for forks).
|
||||
save-pr-meta:
|
||||
name: Save PR Metadata
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
needs: [quality, tests]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Download test reports
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: test-reports
|
||||
path: ${{ runner.temp }}/test-reports
|
||||
continue-on-error: true
|
||||
|
||||
- name: Fetch cross-platform job results
|
||||
id: jobs
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const jobs = await github.rest.actions.listJobsForWorkflowRun({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: context.runId,
|
||||
per_page: 50,
|
||||
});
|
||||
|
||||
const results = {};
|
||||
for (const job of jobs.data.jobs) {
|
||||
if (job.name.includes('ubuntu')) results.ubuntu = job.conclusion || 'pending';
|
||||
else if (job.name.includes('windows')) results.windows = job.conclusion || 'pending';
|
||||
else if (job.name.includes('macos')) results.macos = job.conclusion || 'pending';
|
||||
}
|
||||
core.setOutput('ubuntu', results.ubuntu || 'unknown');
|
||||
core.setOutput('windows', results.windows || 'unknown');
|
||||
core.setOutput('macos', results.macos || 'unknown');
|
||||
|
||||
- name: Fetch base branch coverage
|
||||
id: base-coverage
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'ci.yml',
|
||||
branch: 'main',
|
||||
status: 'success',
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
if (runs.data.workflow_runs.length === 0) {
|
||||
core.setOutput('found', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
const mainRunId = runs.data.workflow_runs[0].id;
|
||||
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: mainRunId,
|
||||
});
|
||||
|
||||
const testReports = artifacts.data.artifacts.find(a => a.name === 'test-reports');
|
||||
if (!testReports) {
|
||||
core.setOutput('found', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
const zip = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: testReports.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
|
||||
const dest = path.join(process.env.RUNNER_TEMP, 'base-coverage');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, 'base.zip'), Buffer.from(zip.data));
|
||||
core.setOutput('found', 'true');
|
||||
core.setOutput('dir', dest);
|
||||
|
||||
- name: Extract base coverage
|
||||
if: steps.base-coverage.outputs.found == 'true'
|
||||
- name: Write PR metadata
|
||||
shell: bash
|
||||
run: |
|
||||
cd "${{ steps.base-coverage.outputs.dir }}"
|
||||
unzip -o base.zip -d base
|
||||
|
||||
- name: Build and post report
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
QUALITY: ${{ needs.quality.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
UBUNTU: ${{ steps.jobs.outputs.ubuntu }}
|
||||
WINDOWS: ${{ steps.jobs.outputs.windows }}
|
||||
MACOS: ${{ steps.jobs.outputs.macos }}
|
||||
BASE_FOUND: ${{ steps.base-coverage.outputs.found }}
|
||||
BASE_DIR: ${{ steps.base-coverage.outputs.dir }}
|
||||
run: |
|
||||
mkdir -p pr-meta
|
||||
echo "$PR_NUMBER" > pr-meta/pr-number
|
||||
echo "$QUALITY" > pr-meta/quality-result
|
||||
echo "$TESTS" > pr-meta/tests-result
|
||||
|
||||
- name: Upload PR metadata
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const icon = (s) => ({ success: '✅', failure: '❌', cancelled: '⏭️' }[s] || '❓');
|
||||
const temp = process.env.RUNNER_TEMP;
|
||||
|
||||
// ── Read coverage ──
|
||||
function readCov(dir) {
|
||||
const out = { stmts: 'N/A', branch: 'N/A', funcs: 'N/A', lines: 'N/A',
|
||||
stmtsCov: '', branchCov: '', funcsCov: '', linesCov: '' };
|
||||
try {
|
||||
const files = require('child_process')
|
||||
.execSync(`find "${dir}" -name coverage-summary.json -type f`, { encoding: 'utf8' })
|
||||
.trim().split('\n').filter(Boolean);
|
||||
if (!files.length) return out;
|
||||
const d = JSON.parse(fs.readFileSync(files[0], 'utf8')).total;
|
||||
out.stmts = d.statements.pct; out.branch = d.branches.pct;
|
||||
out.funcs = d.functions.pct; out.lines = d.lines.pct;
|
||||
out.stmtsCov = `${d.statements.covered}/${d.statements.total}`;
|
||||
out.branchCov = `${d.branches.covered}/${d.branches.total}`;
|
||||
out.funcsCov = `${d.functions.covered}/${d.functions.total}`;
|
||||
out.linesCov = `${d.lines.covered}/${d.lines.total}`;
|
||||
} catch {}
|
||||
return out;
|
||||
}
|
||||
|
||||
const cov = readCov(path.join(temp, 'test-reports'));
|
||||
const base = process.env.BASE_FOUND === 'true'
|
||||
? readCov(path.join(process.env.BASE_DIR, 'base'))
|
||||
: { stmts: 'N/A', branch: 'N/A', funcs: 'N/A', lines: 'N/A' };
|
||||
|
||||
// ── Read test results ──
|
||||
let total = 0, passed = 0, failed = 0, skipped = 0, suites = 0, duration = '0s';
|
||||
let skippedTests = [];
|
||||
try {
|
||||
const files = require('child_process')
|
||||
.execSync(`find "${path.join(temp, 'test-reports')}" -name test-results.json -type f`, { encoding: 'utf8' })
|
||||
.trim().split('\n').filter(Boolean);
|
||||
if (files.length) {
|
||||
const r = JSON.parse(fs.readFileSync(files[0], 'utf8'));
|
||||
total = r.numTotalTests || 0;
|
||||
passed = r.numPassedTests || 0;
|
||||
failed = r.numFailedTests || 0;
|
||||
skipped = r.numPendingTests || 0;
|
||||
suites = r.numTotalTestSuites || 0;
|
||||
const durS = Math.floor((Math.max(...r.testResults.map(t => t.endTime)) - r.startTime) / 1000);
|
||||
duration = durS >= 60 ? `${Math.floor(durS / 60)}m ${durS % 60}s` : `${durS}s`;
|
||||
// Collect skipped test names
|
||||
for (const suite of r.testResults) {
|
||||
for (const t of (suite.assertionResults || [])) {
|
||||
if (t.status === 'pending' || t.status === 'skipped') {
|
||||
skippedTests.push(`- ${t.ancestorTitles.join(' > ')} > ${t.title}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// ── Coverage delta ──
|
||||
function delta(pct, basePct) {
|
||||
if (pct === 'N/A' || basePct === 'N/A') return '—';
|
||||
const d = (pct - basePct).toFixed(1);
|
||||
if (d > 0) return `📈 +${d}%`;
|
||||
if (d < 0) return `📉 ${d}%`;
|
||||
return '=';
|
||||
}
|
||||
|
||||
// ── Build markdown ──
|
||||
const { QUALITY, TESTS, UBUNTU, WINDOWS, MACOS } = process.env;
|
||||
const overall = (QUALITY === 'success' && TESTS === 'success')
|
||||
? '✅ **All checks passed**' : '❌ **Some checks failed**';
|
||||
const sha = context.sha.slice(0, 7);
|
||||
|
||||
let body = `## CI Report\n\n${overall}   \`${sha}\`\n\n`;
|
||||
|
||||
body += `### Pipeline\n\n`;
|
||||
body += `| Stage | Status | Ubuntu | Windows | macOS |\n`;
|
||||
body += `|-------|--------|--------|---------|-------|\n`;
|
||||
body += `| Typecheck | ${icon(QUALITY)} \`${QUALITY}\` | — | — | — |\n`;
|
||||
body += `| Tests | ${icon(TESTS)} \`${TESTS}\` | ${icon(UBUNTU)} | ${icon(WINDOWS)} | ${icon(MACOS)} |\n\n`;
|
||||
|
||||
if (total > 0) {
|
||||
body += `### Tests\n\n`;
|
||||
body += `| Metric | Value |\n|--------|-------|\n`;
|
||||
body += `| Total | **${total}** |\n`;
|
||||
body += `| Passed | **${passed}** |\n`;
|
||||
if (failed > 0) body += `| Failed | **${failed}** |\n`;
|
||||
if (skipped > 0) body += `| Skipped | ${skipped} |\n`;
|
||||
body += `| Files | ${suites} |\n`;
|
||||
body += `| Duration | ${duration} |\n\n`;
|
||||
|
||||
if (failed === 0) {
|
||||
body += `✅ All **${passed}** tests passed across **${suites}** files\n`;
|
||||
} else {
|
||||
body += `❌ **${failed}** failed / **${passed}** passed\n`;
|
||||
}
|
||||
|
||||
if (skippedTests.length > 0) {
|
||||
body += `\n<details>\n<summary>${skipped} test(s) skipped</summary>\n\n`;
|
||||
body += skippedTests.join('\n') + '\n\n</details>\n';
|
||||
}
|
||||
body += '\n';
|
||||
}
|
||||
|
||||
if (cov.stmts !== 'N/A') {
|
||||
body += `### Coverage\n\n`;
|
||||
body += `| Metric | Coverage | Covered | Base (main) | Delta |\n`;
|
||||
body += `|--------|----------|---------|-------------|-------|\n`;
|
||||
body += `| Statements | **${cov.stmts}%** | ${cov.stmtsCov} | ${base.stmts}% | ${delta(cov.stmts, base.stmts)} |\n`;
|
||||
body += `| Branches | **${cov.branch}%** | ${cov.branchCov} | ${base.branch}% | ${delta(cov.branch, base.branch)} |\n`;
|
||||
body += `| Functions | **${cov.funcs}%** | ${cov.funcsCov} | ${base.funcs}% | ${delta(cov.funcs, base.funcs)} |\n`;
|
||||
body += `| Lines | **${cov.lines}%** | ${cov.linesCov} | ${base.lines}% | ${delta(cov.lines, base.lines)} |\n\n`;
|
||||
} else {
|
||||
body += `### Coverage\n\n⚠️ Coverage data unavailable — check the [test job](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for details.\n\n`;
|
||||
}
|
||||
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
body += `---\n<sub>📋 [Full run](${runUrl}) · Coverage from Ubuntu · Generated by CI</sub>`;
|
||||
|
||||
// ── Post sticky comment ──
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const marker = '<!-- ci-report -->';
|
||||
const existing = comments.find(c => c.body?.includes(marker));
|
||||
const fullBody = marker + '\n' + body;
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body: fullBody,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: fullBody,
|
||||
});
|
||||
}
|
||||
name: pr-meta
|
||||
path: pr-meta/
|
||||
retention-days: 1
|
||||
|
||||
@@ -2,8 +2,9 @@ name: Claude Code Review
|
||||
|
||||
# Uses pull_request_target so the workflow runs as defined on the default branch,
|
||||
# which allows access to secrets for posting review comments on fork PRs.
|
||||
# SECURITY: The checkout below uses the PR head SHA to review the correct code.
|
||||
# The claude-code-action sandboxes execution — it does NOT run arbitrary code
|
||||
# SECURITY: The checkout pins the fork's HEAD SHA (not the branch name) to
|
||||
# prevent TOCTOU races (force-push between trigger and checkout). The
|
||||
# claude-code-action sandboxes execution — it does NOT run arbitrary code
|
||||
# from the checked-out source.
|
||||
|
||||
on:
|
||||
@@ -15,8 +16,7 @@ on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
# Serialize per-PR so concurrent @claude comments don't race on the
|
||||
# temporary fork branch push/delete.
|
||||
# Serialize per-PR to avoid racing review comments.
|
||||
concurrency:
|
||||
group: claude-review-${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
@@ -47,13 +47,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write # needed to create fork branch ref via API
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
# For issue_comment triggers, resolve the PR number, head SHA, and branch name
|
||||
# For issue_comment triggers, resolve the PR number, head SHA, and fork repo
|
||||
- name: Resolve PR context
|
||||
id: pr
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
@@ -72,52 +72,24 @@ jobs:
|
||||
}
|
||||
core.setOutput('number', pr.number);
|
||||
core.setOutput('sha', pr.head.sha);
|
||||
core.setOutput('repo', pr.head.repo.full_name);
|
||||
core.setOutput('branch', pr.head.ref);
|
||||
core.setOutput('is_fork', String(pr.head.repo.full_name !== pr.base.repo.full_name));
|
||||
|
||||
- name: Checkout PR head
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.repo }}
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
# claude-code-action fetches branches by name from origin, which fails
|
||||
# for fork PRs. Create a temporary branch ref via the API so the action
|
||||
# can find it. Using the API (not git push) avoids the GITHUB_TOKEN
|
||||
# restriction that blocks pushing commits containing workflow file changes.
|
||||
# Use a prefixed temporary branch name to avoid overwriting real branches
|
||||
# (e.g. a fork branch named "main" would overwrite origin/main).
|
||||
- name: Create fork branch ref on origin
|
||||
id: push-fork
|
||||
if: steps.pr.outputs.is_fork == 'true'
|
||||
env:
|
||||
FORK_BRANCH: claude-tmp/fork-pr-${{ steps.pr.outputs.number }}
|
||||
FORK_SHA: ${{ steps.pr.outputs.sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "FORK_BRANCH=$FORK_BRANCH" >> "$GITHUB_ENV"
|
||||
gh api "repos/${{ github.repository }}/git/refs" \
|
||||
--method POST \
|
||||
-f ref="refs/heads/$FORK_BRANCH" \
|
||||
-f sha="$FORK_SHA" \
|
||||
|| gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" \
|
||||
--method PATCH \
|
||||
-f sha="$FORK_SHA" \
|
||||
-F force=true
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: '*'
|
||||
show_full_output: true
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
|
||||
|
||||
# Clean up the temporary branch ref we created for fork PRs.
|
||||
# Only delete if the create step actually succeeded.
|
||||
- name: Delete fork branch ref from origin
|
||||
if: always() && steps.push-fork.outcome == 'success'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" --method DELETE || true
|
||||
|
||||
@@ -10,8 +10,7 @@ on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
# Serialize per-PR so concurrent @claude comments don't race on the
|
||||
# temporary fork branch push/delete.
|
||||
# Serialize per-PR/issue to avoid racing comments.
|
||||
concurrency:
|
||||
group: claude-code-${{ github.event.issue.number || github.event.pull_request.number || github.event.issue.id }}
|
||||
cancel-in-progress: false
|
||||
@@ -19,21 +18,44 @@ concurrency:
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_review_comment' &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.body, '@claude') &&
|
||||
(github.event.review.author_association == 'OWNER' ||
|
||||
github.event.review.author_association == 'MEMBER' ||
|
||||
github.event.review.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'issues' &&
|
||||
(contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
|
||||
(github.event.issue.author_association == 'OWNER' ||
|
||||
github.event.issue.author_association == 'MEMBER' ||
|
||||
github.event.issue.author_association == 'COLLABORATOR')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write # needed to create fork branch ref via API
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read # required for Claude to read CI results on PRs
|
||||
steps:
|
||||
# For PR-related triggers, resolve fork context so we can create a
|
||||
# temporary branch ref (claude-code-action fetches by branch name).
|
||||
# For PR-related triggers, resolve the fork repo so we can checkout correctly.
|
||||
- name: Resolve PR context
|
||||
id: pr
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
@@ -51,7 +73,6 @@ jobs:
|
||||
|
||||
if (!prNumber) {
|
||||
core.setOutput('is_pr', 'false');
|
||||
core.setOutput('is_fork', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,58 +82,29 @@ jobs:
|
||||
pull_number: prNumber,
|
||||
});
|
||||
const pr = resp.data;
|
||||
const isFork = pr.head.repo.full_name !== pr.base.repo.full_name;
|
||||
|
||||
core.setOutput('is_pr', 'true');
|
||||
core.setOutput('number', String(prNumber));
|
||||
core.setOutput('is_fork', String(isFork));
|
||||
core.setOutput('branch', pr.head.ref);
|
||||
core.setOutput('sha', pr.head.sha);
|
||||
core.setOutput('repo', pr.head.repo.full_name);
|
||||
core.setOutput('branch', pr.head.ref);
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.is_fork == 'true' && steps.pr.outputs.sha || '' }}
|
||||
repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }}
|
||||
ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }}
|
||||
fetch-depth: 1
|
||||
|
||||
# claude-code-action fetches branches by name from origin, which fails
|
||||
# for fork PRs. Create a temporary branch ref via the API so the action
|
||||
# can find it. Using the API (not git push) avoids the GITHUB_TOKEN
|
||||
# restriction that blocks pushing commits containing workflow file changes.
|
||||
# Use a prefixed temporary branch name to avoid overwriting real branches
|
||||
# (e.g. a fork branch named "main" would overwrite origin/main).
|
||||
- name: Create fork branch ref on origin
|
||||
id: push-fork
|
||||
if: steps.pr.outputs.is_fork == 'true'
|
||||
env:
|
||||
FORK_BRANCH: claude-tmp/fork-pr-${{ steps.pr.outputs.number }}
|
||||
FORK_SHA: ${{ steps.pr.outputs.sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "FORK_BRANCH=$FORK_BRANCH" >> "$GITHUB_ENV"
|
||||
gh api "repos/${{ github.repository }}/git/refs" \
|
||||
--method POST \
|
||||
-f ref="refs/heads/$FORK_BRANCH" \
|
||||
-f sha="$FORK_SHA" \
|
||||
|| gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" \
|
||||
--method PATCH \
|
||||
-f sha="$FORK_SHA" \
|
||||
-F force=true
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: '*'
|
||||
show_full_output: true
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Clean up the temporary branch ref we created for fork PRs.
|
||||
# Only delete if the create step actually succeeded.
|
||||
- name: Delete fork branch ref from origin
|
||||
if: always() && steps.push-fork.outcome == 'success'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" --method DELETE || true
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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)`);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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,3 +70,5 @@ gitnexus/test/fixtures/lang-resolution/**/bin
|
||||
GitNexus.sln
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
/github/scripts/triage/__pycache__/
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/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** (2094 symbols, 4982 relationships, 159 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
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.
|
||||
|
||||
> 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** (2094 symbols, 4982 relationships, 159 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
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.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
||||
|
||||
> *Like DeepWiki, but deeper.* DeepWiki helps you *understand* code. GitNexus lets you *analyze* it — because a knowledge graph tracks every relationship, not just descriptions.
|
||||
|
||||
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with goliath models.
|
||||
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with goliath models.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,7 +48,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
||||
| | **CLI + MCP** | **Web UI** |
|
||||
| ----------------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser |
|
||||
| **For** | Daily development with Cursor, Claude Code, Windsurf, OpenCode, Codex | Quick exploration, demos, one-off analysis |
|
||||
| **For** | Daily development with Cursor, Claude Code, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
|
||||
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
|
||||
| **Install** | `npm install -g gitnexus` | No install —[gitnexus.vercel.app](https://gitnexus.vercel.app) |
|
||||
| **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) |
|
||||
@@ -84,17 +84,23 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
||||
| --------------------- | --- | ------ | -------------------- | -------------- |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | — | MCP + Skills |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
| **Codex** | Yes | — | — | MCP |
|
||||
|
||||
> **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
|
||||
|
||||
| Agent | Install | Source |
|
||||
|-------|---------|--------|
|
||||
| [pi](https://pi.dev) | `pi install npm:pi-gitnexus` | [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) |
|
||||
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!
|
||||
|
||||
If you prefer manual configuration:
|
||||
|
||||
@@ -104,6 +110,12 @@ If you prefer manual configuration:
|
||||
claude mcp add gitnexus -- npx -y gitnexus@latest mcp
|
||||
```
|
||||
|
||||
**Codex** (full support — MCP + skills):
|
||||
|
||||
```bash
|
||||
codex mcp add gitnexus -- npx -y gitnexus@latest mcp
|
||||
```
|
||||
|
||||
**Cursor** (`~/.cursor/mcp.json` — global, works for all projects):
|
||||
|
||||
```json
|
||||
@@ -280,7 +292,7 @@ The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAs
|
||||
|
||||
## The Problem GitNexus Solves
|
||||
|
||||
Tools like **Cursor**, **Claude Code**, **Cline**, **Roo Code**, and **Windsurf** are powerful — but they don't truly know your codebase structure.
|
||||
Tools like **Cursor**, **Claude Code**, **Codex**, **Cline**, **Roo Code**, and **Windsurf** are powerful — but they don't truly know your codebase structure.
|
||||
|
||||
**What happens:**
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
MCP Bridge for GitNexus
|
||||
|
||||
Starts the GitNexus MCP server as a subprocess and provides a Python interface
|
||||
to call MCP tools. Used by the bash wrapper scripts and the augmentation layer.
|
||||
to call MCP tools. Used by the bash wrapper scripts and the augmentation layer..
|
||||
|
||||
The bridge communicates with the MCP server via stdio using the JSON-RPC protocol.
|
||||
"""
|
||||
|
||||
Generated
+1079
-8
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.0.0",
|
||||
@@ -65,6 +66,7 @@
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.0",
|
||||
"vite-plugin-static-copy": "^3.1.4"
|
||||
"vite-plugin-static-copy": "^3.1.4",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
+47
-31
@@ -13,6 +13,7 @@ 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 {
|
||||
@@ -28,6 +29,8 @@ const AppContent = () => {
|
||||
runPipelineFromFiles,
|
||||
isSettingsPanelOpen,
|
||||
setSettingsPanelOpen,
|
||||
isHelpDialogBoxOpen,
|
||||
setHelpDialogBoxOpen,
|
||||
refreshLLMSettings,
|
||||
initializeAgent,
|
||||
startEmbeddings,
|
||||
@@ -40,6 +43,8 @@ const AppContent = () => {
|
||||
availableRepos,
|
||||
setAvailableRepos,
|
||||
switchRepo,
|
||||
loadServerGraph,
|
||||
graph
|
||||
} = useAppState();
|
||||
|
||||
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
|
||||
@@ -132,13 +137,13 @@ const AppContent = () => {
|
||||
}
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings, initializeAgent]);
|
||||
|
||||
const handleServerConnect = useCallback((result: ConnectToServerResult) => {
|
||||
const handleServerConnect = useCallback((result: ConnectToServerResult): Promise<void> => {
|
||||
// Extract project name from repoPath
|
||||
const repoPath = result.repoInfo.repoPath;
|
||||
const projectName = repoPath.split('/').pop() || 'server-project';
|
||||
setProjectName(projectName);
|
||||
|
||||
// Build KnowledgeGraph from server data (bypasses WASM pipeline entirely)
|
||||
// Build KnowledgeGraph from server data for visualization
|
||||
const graph = createKnowledgeGraph();
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode(node);
|
||||
@@ -158,20 +163,30 @@ const AppContent = () => {
|
||||
// Transition directly to exploring view
|
||||
setViewMode('exploring');
|
||||
|
||||
// Initialize agent if LLM is configured
|
||||
if (getActiveProviderConfig()) {
|
||||
initializeAgent(projectName);
|
||||
}
|
||||
// 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
|
||||
});
|
||||
|
||||
// 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]);
|
||||
return loadGraphPromise;
|
||||
}, [setViewMode, setGraph, setFileContents, setProjectName, loadServerGraph, initializeAgent, startEmbeddings]);
|
||||
|
||||
// Auto-connect when ?server query param is present (bookmarkable shortcut)
|
||||
const autoConnectRan = useRef(false);
|
||||
@@ -203,16 +218,12 @@ const AppContent = () => {
|
||||
setProgress({ phase: 'extracting', percent: 97, message: 'Processing...', detail: 'Extracting file contents' });
|
||||
}
|
||||
}).then(async (result) => {
|
||||
handleServerConnect(result);
|
||||
|
||||
// Store server URL and fetch available repos for the repo switcher
|
||||
await handleServerConnect(result);
|
||||
setProgress(null);
|
||||
setServerBaseUrl(baseUrl);
|
||||
try {
|
||||
const repos = await fetchRepos(baseUrl);
|
||||
setAvailableRepos(repos);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch repo list:', e);
|
||||
}
|
||||
fetchRepos(baseUrl)
|
||||
.then((repos) => setAvailableRepos(repos))
|
||||
.catch((e) => console.warn('Failed to fetch repo list:', e));
|
||||
}).catch((err) => {
|
||||
console.error('Auto-connect failed:', err);
|
||||
setProgress({
|
||||
@@ -246,16 +257,14 @@ const AppContent = () => {
|
||||
onFileSelect={handleFileSelect}
|
||||
onGitClone={handleGitClone}
|
||||
onServerConnect={async (result, serverUrl) => {
|
||||
handleServerConnect(result);
|
||||
await handleServerConnect(result);
|
||||
setProgress(null);
|
||||
if (serverUrl) {
|
||||
const baseUrl = normalizeServerUrl(serverUrl);
|
||||
setServerBaseUrl(baseUrl);
|
||||
try {
|
||||
const repos = await fetchRepos(baseUrl);
|
||||
setAvailableRepos(repos);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch repo list:', e);
|
||||
}
|
||||
fetchRepos(baseUrl)
|
||||
.then((repos) => setAvailableRepos(repos))
|
||||
.catch((e) => console.warn('Failed to fetch repo list:', e));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -300,6 +309,13 @@ const AppContent = () => {
|
||||
onSettingsSaved={handleSettingsSaved}
|
||||
/>
|
||||
|
||||
<HelpPanel
|
||||
isOpen={isHelpDialogBoxOpen}
|
||||
onClose={() => setHelpDialogBoxOpen(false)}
|
||||
nodeCount={graph!.nodes.length}
|
||||
edgeCount={graph!.relationships.length}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,6 +26,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
blastRadiusNodeIds,
|
||||
isAIHighlightsEnabled,
|
||||
toggleAIHighlights,
|
||||
clearAIToolHighlights,
|
||||
clearAICitationHighlights,
|
||||
clearBlastRadius,
|
||||
animatedNodes,
|
||||
} = useAppState();
|
||||
const [hoveredNodeName, setHoveredNodeName] = useState<string | null>(null);
|
||||
@@ -305,9 +308,13 @@ 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) {
|
||||
setHighlightedNodeIds(new Set());
|
||||
// Turning off — clear AI highlights and selection (preserve user query highlights)
|
||||
clearAIToolHighlights();
|
||||
clearAICitationHighlights();
|
||||
clearBlastRadius();
|
||||
setSelectedNode(null);
|
||||
setSigmaSelectedNode(null);
|
||||
}
|
||||
toggleAIHighlights();
|
||||
}}
|
||||
|
||||
@@ -32,6 +32,7 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
|
||||
isRightPanelOpen,
|
||||
rightPanelTab,
|
||||
setSettingsPanelOpen,
|
||||
setHelpDialogBoxOpen
|
||||
} = useAppState();
|
||||
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
|
||||
const repoDropdownRef = useRef<HTMLDivElement>(null);
|
||||
@@ -266,10 +267,13 @@ 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-[18px] h-[18px]" />
|
||||
<Settings className="w-4.5 h-4.5" />
|
||||
</button>
|
||||
<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
|
||||
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>
|
||||
|
||||
{/* AI Button */}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
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,5 +1,6 @@
|
||||
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';
|
||||
@@ -140,7 +141,7 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex items-center justify-center p-4 overflow-auto max-h-[400px]"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
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 {
|
||||
@@ -90,6 +91,7 @@ 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 === '_') {
|
||||
@@ -136,8 +138,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 as any).rawMermaid
|
||||
? (process as any).rawMermaid
|
||||
const mermaidCode = process.rawMermaid
|
||||
? process.rawMermaid
|
||||
: generateProcessMermaid(process);
|
||||
const id = `mermaid-${Date.now()}`;
|
||||
|
||||
@@ -145,7 +147,8 @@ export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScree
|
||||
diagramRef.current!.innerHTML = '';
|
||||
|
||||
const { svg } = await mermaid.render(id, mermaidCode);
|
||||
diagramRef.current!.innerHTML = svg;
|
||||
if (!diagramRef.current) return;
|
||||
diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } });
|
||||
} catch (error) {
|
||||
console.error('Mermaid render error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -11,6 +11,9 @@ 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('');
|
||||
@@ -79,7 +82,7 @@ export const ProcessesPanel = () => {
|
||||
setLoadingProcess('all');
|
||||
|
||||
try {
|
||||
const allProcessIds = [...processes.cross, ...processes.intra].map(p => p.id);
|
||||
const allProcessIds = [...processes.cross, ...processes.intra].map(p => p.id).filter(isSafeId);
|
||||
|
||||
if (allProcessIds.length === 0) return;
|
||||
|
||||
@@ -110,7 +113,7 @@ export const ProcessesPanel = () => {
|
||||
}
|
||||
|
||||
const allSteps = Array.from(allStepsMap.values());
|
||||
const stepIds = allSteps.map(s => s.id);
|
||||
const stepIds = allSteps.map(s => s.id).filter(isSafeId);
|
||||
|
||||
// Query for all CALLS edges between the combined steps
|
||||
if (stepIds.length > 0) {
|
||||
@@ -155,6 +158,7 @@ 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 {
|
||||
@@ -175,7 +179,7 @@ export const ProcessesPanel = () => {
|
||||
}));
|
||||
|
||||
// Get step IDs for edge query
|
||||
const stepIds = steps.map(s => s.id);
|
||||
const stepIds = steps.map(s => s.id).filter(isSafeId);
|
||||
|
||||
// Query for CALLS edges between the steps in this process
|
||||
let edges: Array<{ from: string; to: string; type: string }> = [];
|
||||
@@ -228,6 +232,7 @@ 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());
|
||||
|
||||
@@ -281,7 +281,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const providers: LLMProvider[] = ['openai', 'gemini', 'anthropic', 'azure-openai', 'ollama', 'openrouter'];
|
||||
const providers: LLMProvider[] = ['openai', 'gemini', 'anthropic', 'azure-openai', 'ollama', 'openrouter', 'minimax'];
|
||||
|
||||
|
||||
return (
|
||||
@@ -366,7 +366,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
w-8 h-8 rounded-lg flex items-center justify-center text-lg
|
||||
${settings.activeProvider === provider ? 'bg-accent/20' : 'bg-surface'}
|
||||
`}>
|
||||
{provider === 'openai' ? '🤖' : provider === 'gemini' ? '💎' : provider === 'anthropic' ? '🧠' : provider === 'ollama' ? '🦙' : provider === 'openrouter' ? '🌐' : '☁️'}
|
||||
{provider === 'openai' ? '🤖' : provider === 'gemini' ? '💎' : provider === 'anthropic' ? '🧠' : provider === 'ollama' ? '🦙' : provider === 'openrouter' ? '🌐' : provider === 'minimax' ? '⚡' : '☁️'}
|
||||
</div>
|
||||
<span className="font-medium">{getProviderDisplayName(provider)}</span>
|
||||
</button>
|
||||
@@ -814,7 +814,64 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MiniMax Settings */}
|
||||
{settings.activeProvider === 'minimax' && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Key className="w-4 h-4" />
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey['minimax'] ? 'text' : 'password'}
|
||||
value={settings.minimax?.apiKey ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
minimax: { ...prev.minimax!, apiKey: e.target.value }
|
||||
}))}
|
||||
placeholder="Enter your MiniMax API key"
|
||||
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleApiKeyVisibility('minimax')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
{showApiKey['minimax'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Get your API key from{' '}
|
||||
<a
|
||||
href="https://platform.minimax.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
MiniMax Platform
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.minimax?.model ?? 'MiniMax-M2.5'}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
minimax: { ...prev.minimax!, model: e.target.value }
|
||||
}))}
|
||||
placeholder="e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Available models: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Privacy Note */}
|
||||
<div className="p-4 bg-elevated/50 border border-border-subtle rounded-xl">
|
||||
|
||||
@@ -21,49 +21,57 @@ 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 (conn) return { db, conn, lbug };
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
if (import.meta.env.DEV) console.log('🚀 Initializing LadybugDB...');
|
||||
|
||||
try {
|
||||
if (import.meta.env.DEV) console.log('🚀 Initializing LadybugDB...');
|
||||
// 1. Dynamic Import (Fixes the "not a function" bundler issue)
|
||||
const lbugModule = await import('@ladybugdb/wasm-core');
|
||||
|
||||
// 1. Dynamic Import (Fixes the "not a function" bundler issue)
|
||||
const lbugModule = await import('@ladybugdb/wasm-core');
|
||||
// 2. Handle Vite/Webpack "default" wrapping
|
||||
lbug = lbugModule.default || lbugModule;
|
||||
|
||||
// 2. Handle Vite/Webpack "default" wrapping
|
||||
lbug = lbugModule.default || lbugModule;
|
||||
// 3. Initialize WASM
|
||||
await lbug.init();
|
||||
|
||||
// 3. Initialize WASM
|
||||
await lbug.init();
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
if (import.meta.env.DEV) console.log('✅ LadybugDB WASM Initialized');
|
||||
|
||||
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);
|
||||
// 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 Multi-Table Schema Created');
|
||||
|
||||
return { db, conn, lbug };
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('❌ LadybugDB Initialization Failed:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) console.log('✅ LadybugDB Multi-Table Schema Created');
|
||||
|
||||
return { db, conn, lbug };
|
||||
})();
|
||||
try {
|
||||
return await initPromise;
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('❌ LadybugDB Initialization Failed:', error);
|
||||
initPromise = null; // Reset on failure so retry is possible
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -72,11 +80,47 @@ 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>
|
||||
) => {
|
||||
const { conn, lbug } = await initLbug();
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (import.meta.env.DEV) console.log(`LadybugDB: Generating CSVs for ${graph.nodeCount} nodes...`);
|
||||
@@ -130,47 +174,86 @@ 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) {
|
||||
try {
|
||||
// Format: "from","to","type",confidence,"reason",step
|
||||
const match = line.match(/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)",([0-9-]+)/);
|
||||
if (!match) continue;
|
||||
const match = line.match(csvRegex);
|
||||
if (!match) continue;
|
||||
|
||||
const [, fromId, toId, relType, confidenceStr, reason, stepStr] = match;
|
||||
// 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 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++;
|
||||
// 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();
|
||||
continue;
|
||||
}
|
||||
|
||||
const confidence = parseFloat(confidenceStr) || 1.0;
|
||||
const step = parseInt(stepStr) || 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)}`);
|
||||
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();
|
||||
}
|
||||
|
||||
// Yield to event loop between sub-batches
|
||||
if (i + SUB_BATCH_SIZE < rels.length) {
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,7 +273,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.getAll();
|
||||
const countRows = await countRes.getAllRows();
|
||||
const countRow = countRows[0];
|
||||
const count = countRow ? (countRow.cnt ?? countRow[0] ?? 0) : 0;
|
||||
totalNodes += Number(count);
|
||||
@@ -224,12 +307,20 @@ 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']);
|
||||
|
||||
@@ -262,11 +353,20 @@ 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): Promise<any[]> => {
|
||||
export const executeQuery = async (cypher: string, readOnly = true): 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);
|
||||
|
||||
@@ -294,7 +394,7 @@ export const executeQuery = async (cypher: string): Promise<any[]> => {
|
||||
}
|
||||
|
||||
// Collect all rows
|
||||
const allRows = await result.getAll();
|
||||
const allRows = await result.getAllRows();
|
||||
const rows: any[] = [];
|
||||
for (const row of allRows) {
|
||||
// Convert tuple to named object if we have column names and row is array
|
||||
@@ -331,7 +431,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.getAll();
|
||||
const nodeRows = await nodeResult.getAllRows();
|
||||
const nodeRow = nodeRows[0];
|
||||
totalNodes += Number(nodeRow?.cnt ?? nodeRow?.[0] ?? 0);
|
||||
} catch {
|
||||
@@ -343,7 +443,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.getAll();
|
||||
const edgeRows = await edgeResult.getAllRows();
|
||||
const edgeRow = edgeRows[0];
|
||||
totalEdges = Number(edgeRow?.cnt ?? edgeRow?.[0] ?? 0);
|
||||
} catch {
|
||||
@@ -383,6 +483,7 @@ export const closeLbug = async (): Promise<void> => {
|
||||
db = null;
|
||||
}
|
||||
lbug = null;
|
||||
initPromise = null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -401,17 +502,18 @@ export const executePrepared = async (
|
||||
|
||||
try {
|
||||
const stmt = await conn.prepare(cypher);
|
||||
if (!stmt.isSuccess()) {
|
||||
const errMsg = await stmt.getErrorMessage();
|
||||
throw new Error(`Prepare failed: ${errMsg}`);
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -472,7 +574,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.getAll();
|
||||
const nodeRows = await nodeResult.getAllRows();
|
||||
const nodeRow = nodeRows[0];
|
||||
if (nodeRow) {
|
||||
testNodeId = nodeRow.id ?? nodeRow[0];
|
||||
@@ -505,24 +607,39 @@ export const testArrayParams = async (): Promise<{ success: boolean; error?: str
|
||||
|
||||
await stmt.close();
|
||||
|
||||
// Verify it was stored
|
||||
const verifyResult = await conn.query(
|
||||
`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: '${testNodeId}'}) RETURN e.embedding AS emb`
|
||||
// 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`
|
||||
);
|
||||
const verifyRows = await verifyResult.getAll();
|
||||
const verifyRow = verifyRows[0];
|
||||
const storedEmb = verifyRow?.emb ?? verifyRow?.[0];
|
||||
|
||||
if (storedEmb && Array.isArray(storedEmb) && storedEmb.length === 384) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ Array params WORK! Stored embedding length:', storedEmb.length);
|
||||
try {
|
||||
if (!verifyStmt.isSuccess()) {
|
||||
const errMsg = await verifyStmt.getErrorMessage();
|
||||
return { success: false, error: `Verify prepare failed: ${errMsg}` };
|
||||
}
|
||||
return { success: true };
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: `Embedding not stored correctly. Got: ${typeof storedEmb}, length: ${storedEmb?.length}`
|
||||
};
|
||||
const verifyResult = await conn.execute(verifyStmt, { nodeId: testNodeId });
|
||||
const verifyRows = await verifyResult.getAllRows();
|
||||
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}`
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
await verifyStmt.close();
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
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');
|
||||
};
|
||||
@@ -13,14 +13,15 @@ import { ChatAnthropic } from '@langchain/anthropic';
|
||||
import { ChatOllama } from '@langchain/ollama';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { createGraphRAGTools } from './tools';
|
||||
import type {
|
||||
ProviderConfig,
|
||||
import type {
|
||||
ProviderConfig,
|
||||
OpenAIConfig,
|
||||
AzureOpenAIConfig,
|
||||
AzureOpenAIConfig,
|
||||
GeminiConfig,
|
||||
AnthropicConfig,
|
||||
OllamaConfig,
|
||||
OpenRouterConfig,
|
||||
MiniMaxConfig,
|
||||
AgentStreamChunk,
|
||||
} from './types';
|
||||
import {
|
||||
@@ -197,7 +198,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
||||
|
||||
case 'openrouter': {
|
||||
const openRouterConfig = config as OpenRouterConfig;
|
||||
|
||||
|
||||
// Debug logging
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🌐 OpenRouter config:', {
|
||||
@@ -207,11 +208,11 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
||||
baseUrl: openRouterConfig.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (!openRouterConfig.apiKey || openRouterConfig.apiKey.trim() === '') {
|
||||
throw new Error('OpenRouter API key is required but was not provided');
|
||||
}
|
||||
|
||||
|
||||
return new ChatOpenAI({
|
||||
openAIApiKey: openRouterConfig.apiKey,
|
||||
apiKey: openRouterConfig.apiKey, // Fallback for some versions
|
||||
@@ -225,7 +226,26 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
||||
streaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
case 'minimax': {
|
||||
const minimaxConfig = config as MiniMaxConfig;
|
||||
|
||||
if (!minimaxConfig.apiKey || minimaxConfig.apiKey.trim() === '') {
|
||||
throw new Error('MiniMax API key is required but was not provided');
|
||||
}
|
||||
|
||||
return new ChatAnthropic({
|
||||
anthropicApiKey: minimaxConfig.apiKey,
|
||||
model: minimaxConfig.model,
|
||||
temperature: minimaxConfig.temperature ?? 0.1,
|
||||
maxTokens: minimaxConfig.maxTokens ?? 8192,
|
||||
streaming: true,
|
||||
clientOptions: {
|
||||
baseURL: 'https://api.minimax.io/anthropic',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${(config as any).provider}`);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
* All API keys are stored locally - never sent to any server except the LLM provider.
|
||||
*/
|
||||
|
||||
import {
|
||||
LLMSettings,
|
||||
DEFAULT_LLM_SETTINGS,
|
||||
import {
|
||||
LLMSettings,
|
||||
DEFAULT_LLM_SETTINGS,
|
||||
LLMProvider,
|
||||
OpenAIConfig,
|
||||
AzureOpenAIConfig,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
AnthropicConfig,
|
||||
OllamaConfig,
|
||||
OpenRouterConfig,
|
||||
MiniMaxConfig,
|
||||
ProviderConfig,
|
||||
} from './types';
|
||||
|
||||
@@ -60,6 +61,10 @@ export const loadSettings = (): LLMSettings => {
|
||||
...DEFAULT_LLM_SETTINGS.openrouter,
|
||||
...parsed.openrouter,
|
||||
},
|
||||
minimax: {
|
||||
...DEFAULT_LLM_SETTINGS.minimax,
|
||||
...parsed.minimax,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to load LLM settings:', error);
|
||||
@@ -89,6 +94,7 @@ export const updateProviderSettings = <T extends LLMProvider>(
|
||||
T extends 'gemini' ? Partial<Omit<GeminiConfig, 'provider'>> :
|
||||
T extends 'anthropic' ? Partial<Omit<AnthropicConfig, 'provider'>> :
|
||||
T extends 'ollama' ? Partial<Omit<OllamaConfig, 'provider'>> :
|
||||
T extends 'minimax' ? Partial<Omit<MiniMaxConfig, 'provider'>> :
|
||||
never
|
||||
>
|
||||
): LLMSettings => {
|
||||
@@ -162,6 +168,17 @@ export const updateProviderSettings = <T extends LLMProvider>(
|
||||
saveSettings(updated);
|
||||
return updated;
|
||||
}
|
||||
case 'minimax': {
|
||||
const updated: LLMSettings = {
|
||||
...current,
|
||||
minimax: {
|
||||
...(current.minimax ?? {}),
|
||||
...(updates as Partial<Omit<MiniMaxConfig, 'provider'>>),
|
||||
},
|
||||
};
|
||||
saveSettings(updated);
|
||||
return updated;
|
||||
}
|
||||
default: {
|
||||
// Should be unreachable due to T extends LLMProvider, but keep a safe fallback
|
||||
const updated: LLMSettings = { ...current };
|
||||
@@ -245,7 +262,16 @@ export const getActiveProviderConfig = (): ProviderConfig | null => {
|
||||
temperature: settings.openrouter.temperature,
|
||||
maxTokens: settings.openrouter.maxTokens,
|
||||
} as OpenRouterConfig;
|
||||
|
||||
|
||||
case 'minimax':
|
||||
if (!settings.minimax?.apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: 'minimax',
|
||||
...settings.minimax,
|
||||
} as MiniMaxConfig;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -282,6 +308,8 @@ export const getProviderDisplayName = (provider: LLMProvider): string => {
|
||||
return 'Ollama (Local)';
|
||||
case 'openrouter':
|
||||
return 'OpenRouter';
|
||||
case 'minimax':
|
||||
return 'MiniMax';
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
@@ -303,6 +331,8 @@ export const getAvailableModels = (provider: LLMProvider): string[] => {
|
||||
return ['claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'];
|
||||
case 'ollama':
|
||||
return ['llama3.2', 'llama3.1', 'mistral', 'codellama', 'deepseek-coder'];
|
||||
case 'minimax':
|
||||
return ['MiniMax-M2.5', 'MiniMax-M2.5-highspeed'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ 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
|
||||
@@ -96,11 +103,12 @@ 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
|
||||
@@ -136,6 +144,7 @@ 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)
|
||||
@@ -158,6 +167,7 @@ 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)
|
||||
@@ -783,7 +793,11 @@ 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)
|
||||
@@ -898,10 +912,13 @@ 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
|
||||
const activeRelTypes = relationTypes && relationTypes.length > 0
|
||||
? relationTypes.filter(t => validRelType(t))
|
||||
: defaultRelTypes;
|
||||
const relTypeFilter = activeRelTypes.map(t => `'${t}'`).join(', ');
|
||||
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 directionLabel = direction === 'upstream'
|
||||
? 'Files that DEPEND ON this (breakage risk)'
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
/**
|
||||
* Supported LLM providers
|
||||
*/
|
||||
export type LLMProvider = 'openai' | 'azure-openai' | 'gemini' | 'anthropic' | 'ollama' | 'openrouter';
|
||||
export type LLMProvider = 'openai' | 'azure-openai' | 'gemini' | 'anthropic' | 'ollama' | 'openrouter' | 'minimax';
|
||||
|
||||
/**
|
||||
* Base configuration shared by all providers
|
||||
@@ -78,10 +78,19 @@ export interface OpenRouterConfig extends BaseProviderConfig {
|
||||
baseUrl?: string; // defaults to https://openrouter.ai/api/v1
|
||||
}
|
||||
|
||||
/**
|
||||
* MiniMax configuration (Anthropic-compatible API)
|
||||
*/
|
||||
export interface MiniMaxConfig extends BaseProviderConfig {
|
||||
provider: 'minimax';
|
||||
apiKey: string;
|
||||
model: string; // e.g., 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all provider configurations
|
||||
*/
|
||||
export type ProviderConfig = OpenAIConfig | AzureOpenAIConfig | GeminiConfig | AnthropicConfig | OllamaConfig | OpenRouterConfig;
|
||||
export type ProviderConfig = OpenAIConfig | AzureOpenAIConfig | GeminiConfig | AnthropicConfig | OllamaConfig | OpenRouterConfig | MiniMaxConfig;
|
||||
|
||||
/**
|
||||
* Stored settings (what goes to localStorage)
|
||||
@@ -98,6 +107,7 @@ export interface LLMSettings {
|
||||
anthropic?: Partial<Omit<AnthropicConfig, 'provider'>>;
|
||||
ollama?: Partial<Omit<OllamaConfig, 'provider'>>;
|
||||
openrouter?: Partial<Omit<OpenRouterConfig, 'provider'>>;
|
||||
minimax?: Partial<Omit<MiniMaxConfig, 'provider'>>;
|
||||
|
||||
// Intelligent Clustering Settings
|
||||
intelligentClustering: boolean;
|
||||
@@ -148,6 +158,11 @@ export const DEFAULT_LLM_SETTINGS: LLMSettings = {
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
temperature: 0.1,
|
||||
},
|
||||
minimax: {
|
||||
apiKey: '',
|
||||
model: 'MiniMax-M2.5',
|
||||
temperature: 0.1,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react';
|
||||
import * as Comlink from 'comlink';
|
||||
import { KnowledgeGraph, GraphNode, NodeLabel } from '../core/graph/types';
|
||||
import { KnowledgeGraph, GraphNode, GraphRelationship, 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,6 +95,7 @@ interface AppState {
|
||||
isAIHighlightsEnabled: boolean;
|
||||
toggleAIHighlights: () => void;
|
||||
clearAIToolHighlights: () => void;
|
||||
clearAICitationHighlights: () => void;
|
||||
clearBlastRadius: () => void;
|
||||
queryResult: QueryResult | null;
|
||||
setQueryResult: (result: QueryResult | null) => void;
|
||||
@@ -125,6 +126,7 @@ 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;
|
||||
@@ -143,7 +145,9 @@ 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;
|
||||
@@ -225,6 +229,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
setAIToolHighlightedNodeIds(new Set());
|
||||
}, []);
|
||||
|
||||
const clearAICitationHighlights = useCallback(() => {
|
||||
setAICitationHighlightedNodeIds(new Set());
|
||||
}, []);
|
||||
|
||||
const clearBlastRadius = useCallback(() => {
|
||||
setBlastRadiusNodeIds(new Set());
|
||||
}, []);
|
||||
@@ -290,6 +298,7 @@ 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);
|
||||
@@ -482,6 +491,16 @@ 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;
|
||||
@@ -979,10 +998,13 @@ 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);
|
||||
@@ -1017,17 +1039,29 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
for (const [p, c] of Object.entries(result.fileContents)) fileMap.set(p, c);
|
||||
setFileContents(fileMap);
|
||||
|
||||
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);
|
||||
// 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');
|
||||
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({
|
||||
@@ -1035,9 +1069,11 @@ 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, initializeAgent, startEmbeddings, setHighlightedNodeIds, clearAIToolHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]);
|
||||
}, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, loadServerGraph, initializeAgent, startEmbeddings, setHighlightedNodeIds, clearAIToolHighlights, clearAICitationHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]);
|
||||
|
||||
const removeCodeReference = useCallback((id: string) => {
|
||||
setCodeReferences(prev => {
|
||||
@@ -1120,6 +1156,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
isAIHighlightsEnabled,
|
||||
toggleAIHighlights,
|
||||
clearAIToolHighlights,
|
||||
clearAICitationHighlights,
|
||||
clearBlastRadius,
|
||||
queryResult,
|
||||
setQueryResult,
|
||||
@@ -1142,6 +1179,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
runPipelineFromFiles,
|
||||
runQuery,
|
||||
isDatabaseReady,
|
||||
loadServerGraph,
|
||||
// Embedding state and methods
|
||||
embeddingStatus,
|
||||
embeddingProgress,
|
||||
@@ -1156,6 +1194,8 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
updateLLMSettings,
|
||||
isSettingsPanelOpen,
|
||||
setSettingsPanelOpen,
|
||||
isHelpDialogBoxOpen,
|
||||
setHelpDialogBoxOpen,
|
||||
isAgentReady,
|
||||
isAgentInitializing,
|
||||
agentError,
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface ProcessData {
|
||||
steps: ProcessStep[];
|
||||
edges?: ProcessEdge[]; // CALLS edges between steps for branching
|
||||
clusters?: string[];
|
||||
rawMermaid?: string; // AI-generated mermaid code (sanitized before rendering)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-1
@@ -12,7 +12,9 @@ declare module '@ladybugdb/wasm-core' {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
export interface QueryResult {
|
||||
getAll(): Promise<any[]>;
|
||||
getAll?(): Promise<any[]>;
|
||||
getAllRows?(): Promise<any[]>;
|
||||
getAllObjects?(): Promise<any[]>;
|
||||
hasNext(): Promise<boolean>;
|
||||
getNext(): Promise<any>;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ 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';
|
||||
@@ -39,6 +41,61 @@ 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();
|
||||
|
||||
@@ -119,7 +176,7 @@ const createHttpHybridSearch = (backendUrl: string, repo: string) => {
|
||||
endLine: s.endLine,
|
||||
content: s.content ?? '',
|
||||
sources: ['bm25', 'semantic'],
|
||||
score: 1 - (i * 0.02),
|
||||
score: Math.max(0, 1 - (i * 0.02)),
|
||||
}));
|
||||
|
||||
const defs: any[] = (data.definitions ?? []).map((d: any, i: number) => ({
|
||||
@@ -129,7 +186,7 @@ const createHttpHybridSearch = (backendUrl: string, repo: string) => {
|
||||
filePath: d.filePath,
|
||||
content: '',
|
||||
sources: ['bm25'],
|
||||
score: 0.5 - (i * 0.02),
|
||||
score: Math.max(0, 0.5 - (i * 0.02)),
|
||||
}));
|
||||
|
||||
return [...symbols, ...defs].slice(0, k);
|
||||
@@ -157,54 +214,57 @@ 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);
|
||||
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,
|
||||
},
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
// 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)
|
||||
try {
|
||||
const lbug = await getLbugAdapter();
|
||||
await lbug.loadGraphToLbug(result.graph, result.fileContents);
|
||||
await lbug.loadGraphToLbug(graph, fileMap);
|
||||
|
||||
// Build BM25 index for text search
|
||||
buildBM25Index(fileMap);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
const stats = await lbug.getLbugStats();
|
||||
console.log('LadybugDB loaded:', stats);
|
||||
console.log('LadybugDB loaded from server:', stats);
|
||||
console.log('📁 Stored', storedFileContents.size, 'files for grep/read tools');
|
||||
}
|
||||
} catch {
|
||||
// LadybugDB is optional - silently continue without it
|
||||
} 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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -263,52 +323,8 @@ const workerApi = {
|
||||
stats: { filesProcessed: 0, totalFiles: files.length, nodesCreated: 0 },
|
||||
});
|
||||
|
||||
// Run the pipeline
|
||||
const result = await runPipelineFromFiles(files, onProgress);
|
||||
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);
|
||||
return finalizePipeline(result, onProgress, clusteringConfig);
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
@@ -628,6 +644,12 @@ 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
|
||||
@@ -769,8 +791,10 @@ 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')
|
||||
@@ -792,15 +816,22 @@ 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
|
||||
graph.relationships.forEach(rel => {
|
||||
for (const rel of graph.relationships) {
|
||||
if (enrichmentCancelled) {
|
||||
console.log('Enrichment cancelled, stopping');
|
||||
break;
|
||||
}
|
||||
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 = graph.nodes.find(n => n.id === memberId);
|
||||
const memberNode = nodeById.get(memberId);
|
||||
if (memberNode) {
|
||||
memberMap.get(communityId)?.push({
|
||||
name: memberNode.properties.name,
|
||||
@@ -810,7 +841,7 @@ const workerApi = {
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create LLM client adapter for LangChain model
|
||||
const chatModel = createChatModel(providerConfig);
|
||||
@@ -848,32 +879,28 @@ const workerApi = {
|
||||
}
|
||||
});
|
||||
|
||||
// Update LadybugDB with new data
|
||||
// Update LadybugDB with new data using prepared statements
|
||||
try {
|
||||
const lbug = await getLbugAdapter();
|
||||
|
||||
|
||||
onProgress(enrichments.size, enrichments.size); // Done
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to update LadybugDB with enrichment:', err);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { beforeEach } from 'vitest';
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
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,5 +20,6 @@
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
# 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,6 +2,66 @@
|
||||
|
||||
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
|
||||
|
||||
+22
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Graph-powered code intelligence for AI agents.** Index any codebase into a knowledge graph, then query it via MCP or CLI.
|
||||
|
||||
Works with **Cursor**, **Claude Code**, **Windsurf**, **Cline**, **OpenCode**, and any MCP-compatible tool.
|
||||
Works with **Cursor**, **Claude Code**, **Codex**, **Windsurf**, **Cline**, **OpenCode**, and any MCP-compatible tool.
|
||||
|
||||
[](https://www.npmjs.com/package/gitnexus)
|
||||
[](https://polyformproject.org/licenses/noncommercial/1.0.0/)
|
||||
@@ -34,6 +34,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
||||
|--------|-----|--------|---------------------|---------|
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | — | MCP + Skills |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
|
||||
@@ -55,6 +56,12 @@ If you prefer to configure manually instead of using `gitnexus setup`:
|
||||
claude mcp add gitnexus -- npx -y gitnexus@latest mcp
|
||||
```
|
||||
|
||||
### Codex (full support — MCP + skills)
|
||||
|
||||
```bash
|
||||
codex mcp add gitnexus -- npx -y gitnexus@latest mcp
|
||||
```
|
||||
|
||||
### Cursor / Windsurf
|
||||
|
||||
Add to `~/.cursor/mcp.json` (global — works for all projects):
|
||||
@@ -151,6 +158,20 @@ 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
+35
-54
@@ -24,6 +24,7 @@
|
||||
"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",
|
||||
@@ -49,6 +50,7 @@
|
||||
"@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"
|
||||
@@ -2403,6 +2405,15 @@
|
||||
"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",
|
||||
@@ -3575,6 +3586,22 @@
|
||||
"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",
|
||||
@@ -4040,15 +4067,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-common": {
|
||||
"version": "1.21.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz",
|
||||
"integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==",
|
||||
"version": "1.24.3",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
|
||||
"integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/onnxruntime-node": {
|
||||
"version": "1.21.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz",
|
||||
"integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==",
|
||||
"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==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
@@ -4057,55 +4084,9 @@
|
||||
"linux"
|
||||
],
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"global-agent": "^3.0.0",
|
||||
"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"
|
||||
"onnxruntime-common": "1.24.3"
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-web": {
|
||||
|
||||
+10
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.4.7",
|
||||
"version": "1.4.8",
|
||||
"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",
|
||||
@@ -20,6 +20,7 @@
|
||||
"knowledge-graph",
|
||||
"cursor",
|
||||
"claude",
|
||||
"codex",
|
||||
"ai-agent",
|
||||
"gitnexus",
|
||||
"static-analysis",
|
||||
@@ -50,6 +51,7 @@
|
||||
},
|
||||
"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",
|
||||
@@ -59,10 +61,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",
|
||||
@@ -89,10 +91,16 @@
|
||||
"@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"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* AI Context Generator
|
||||
*
|
||||
* Creates AGENTS.md and CLAUDE.md with full inline GitNexus context.
|
||||
* AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Cline, etc.
|
||||
* AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, etc.
|
||||
* CLAUDE.md is for Claude Code which only reads that file.
|
||||
*/
|
||||
|
||||
@@ -308,4 +308,3 @@ export async function generateAIContextFiles(
|
||||
|
||||
return { files: createdFiles };
|
||||
}
|
||||
|
||||
|
||||
+57
-24
@@ -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, isGitRepo, getGitRoot } from '../storage/git.js';
|
||||
import { getCurrentCommit, getGitRoot, hasGitDir } from '../storage/git.js';
|
||||
import { generateAIContextFiles } from './ai-context.js';
|
||||
import { generateSkillFiles, type GeneratedSkillInfo } from './skill-gen.js';
|
||||
import fs from 'fs/promises';
|
||||
@@ -48,6 +48,8 @@ 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 */
|
||||
@@ -87,18 +89,27 @@ export const analyzeCommand = async (
|
||||
} else {
|
||||
const gitRoot = getGitRoot(process.cwd());
|
||||
if (!gitRoot) {
|
||||
console.log(' Not inside a git repository\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
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;
|
||||
}
|
||||
repoPath = gitRoot;
|
||||
}
|
||||
|
||||
if (!isGitRepo(repoPath)) {
|
||||
console.log(' Not a git repository\n');
|
||||
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');
|
||||
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);
|
||||
|
||||
@@ -109,12 +120,15 @@ export const analyzeCommand = async (
|
||||
console.log(' Migrating from KuzuDB to LadybugDB — rebuilding index...\n');
|
||||
}
|
||||
|
||||
const currentCommit = getCurrentCommit(repoPath);
|
||||
const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : '';
|
||||
const existingMeta = await loadMeta(storagePath);
|
||||
|
||||
if (existingMeta && !options?.force && !options?.skills && existingMeta.lastCommit === currentCommit) {
|
||||
console.log(' Already up to date\n');
|
||||
return;
|
||||
// Non-git folders have currentCommit = '' — always rebuild since we can't detect changes
|
||||
if (currentCommit !== '') {
|
||||
console.log(' Already up to date\n');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.GITNEXUS_NO_GITIGNORE) {
|
||||
@@ -246,17 +260,27 @@ export const analyzeCommand = async (
|
||||
|
||||
// ── Phase 3.5: Re-insert cached embeddings ────────────────────────
|
||||
if (cachedEmbeddings.length > 0) {
|
||||
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 */ }
|
||||
// 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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +299,9 @@ export const analyzeCommand = async (
|
||||
}
|
||||
|
||||
if (!embeddingSkipped) {
|
||||
updateBar(90, 'Loading embedding model...');
|
||||
const { isHttpMode } = await import('../core/embeddings/http-client.js');
|
||||
const httpMode = isHttpMode();
|
||||
updateBar(90, httpMode ? 'Connecting to embedding endpoint...' : 'Loading embedding model...');
|
||||
const t0Emb = Date.now();
|
||||
const { runEmbeddingPipeline } = await import('../core/embeddings/embedding-pipeline.js');
|
||||
await runEmbeddingPipeline(
|
||||
@@ -283,7 +309,9 @@ export const analyzeCommand = async (
|
||||
executeWithReusedStatement,
|
||||
(progress) => {
|
||||
const scaled = 90 + Math.round((progress.percent / 100) * 8);
|
||||
const label = progress.phase === 'loading-model' ? 'Loading embedding model...' : `Embedding ${progress.nodesProcessed || 0}/${progress.totalNodes || '?'}`;
|
||||
const label = progress.phase === 'loading-model'
|
||||
? (httpMode ? 'Connecting to embedding endpoint...' : 'Loading embedding model...')
|
||||
: `Embedding ${progress.nodesProcessed || 0}/${progress.totalNodes || '?'}`;
|
||||
updateBar(scaled, label);
|
||||
},
|
||||
{},
|
||||
@@ -317,7 +345,12 @@ export const analyzeCommand = async (
|
||||
};
|
||||
await saveMeta(storagePath, meta);
|
||||
await registerRepo(repoPath, meta);
|
||||
await addToGitignore(repoPath);
|
||||
// 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);
|
||||
}
|
||||
|
||||
const projectName = path.basename(repoPath);
|
||||
let aggregatedClusterCount = 0;
|
||||
|
||||
@@ -18,15 +18,17 @@ program
|
||||
|
||||
program
|
||||
.command('setup')
|
||||
.description('One-time setup: configure MCP for Cursor, Claude Code, OpenCode')
|
||||
.description('One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex')
|
||||
.action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
|
||||
|
||||
|
||||
program
|
||||
.command('analyze [path]')
|
||||
.description('Index a repository (full analysis)')
|
||||
.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'));
|
||||
|
||||
@@ -9,12 +9,15 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { glob } from 'glob';
|
||||
import { getGlobalDir } from '../storage/repo-manager.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface SetupResult {
|
||||
configured: string[];
|
||||
@@ -239,12 +242,75 @@ async function setupOpenCode(result: SetupResult): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a TOML section for Codex MCP config (~/.codex/config.toml).
|
||||
*/
|
||||
function getCodexMcpTomlSection(): string {
|
||||
const entry = getMcpEntry();
|
||||
const command = JSON.stringify(entry.command);
|
||||
const args = `[${entry.args.map(arg => JSON.stringify(arg)).join(', ')}]`;
|
||||
return `[mcp_servers.gitnexus]\ncommand = ${command}\nargs = ${args}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append GitNexus MCP server config to Codex's config.toml if missing.
|
||||
*/
|
||||
async function upsertCodexConfigToml(configPath: string): Promise<void> {
|
||||
let existing = '';
|
||||
try {
|
||||
existing = await fs.readFile(configPath, 'utf-8');
|
||||
} catch {
|
||||
existing = '';
|
||||
}
|
||||
|
||||
if (existing.includes('[mcp_servers.gitnexus]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const section = getCodexMcpTomlSection();
|
||||
const nextContent = existing.trim().length > 0
|
||||
? `${existing.trimEnd()}\n\n${section}`
|
||||
: section;
|
||||
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(configPath, `${nextContent.trimEnd()}\n`, 'utf-8');
|
||||
}
|
||||
|
||||
async function setupCodex(result: SetupResult): Promise<void> {
|
||||
const codexDir = path.join(os.homedir(), '.codex');
|
||||
if (!(await dirExists(codexDir))) {
|
||||
result.skipped.push('Codex (not installed)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = getMcpEntry();
|
||||
await execFileAsync(
|
||||
'codex',
|
||||
['mcp', 'add', 'gitnexus', '--', entry.command, ...entry.args],
|
||||
{ shell: process.platform === 'win32' }
|
||||
);
|
||||
result.configured.push('Codex');
|
||||
return;
|
||||
} catch {
|
||||
// Fallback for environments where `codex` binary isn't on PATH.
|
||||
}
|
||||
|
||||
try {
|
||||
const configPath = path.join(codexDir, 'config.toml');
|
||||
await upsertCodexConfigToml(configPath);
|
||||
result.configured.push('Codex (MCP added to ~/.codex/config.toml)');
|
||||
} catch (err: any) {
|
||||
result.errors.push(`Codex: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Skill Installation ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Install GitNexus skills to a target directory.
|
||||
* Each skill is installed as {targetDir}/gitnexus-{skillName}/SKILL.md
|
||||
* following the Agent Skills standard (both Cursor and Claude Code).
|
||||
* following the Agent Skills standard (Cursor, Claude Code, and Codex).
|
||||
*
|
||||
* Supports two source layouts:
|
||||
* - Flat file: skills/{name}.md → copied as SKILL.md
|
||||
@@ -353,6 +419,24 @@ async function installOpenCodeSkills(result: SetupResult): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install global Codex skills to ~/.agents/skills/gitnexus/
|
||||
*/
|
||||
async function installCodexSkills(result: SetupResult): Promise<void> {
|
||||
const codexDir = path.join(os.homedir(), '.codex');
|
||||
if (!(await dirExists(codexDir))) return;
|
||||
|
||||
const skillsDir = path.join(os.homedir(), '.agents', 'skills');
|
||||
try {
|
||||
const installed = await installSkillsTo(skillsDir);
|
||||
if (installed.length > 0) {
|
||||
result.configured.push(`Codex skills (${installed.length} skills → ~/.agents/skills/)`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
result.errors.push(`Codex skills: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main command ──────────────────────────────────────────────────
|
||||
|
||||
export const setupCommand = async () => {
|
||||
@@ -375,12 +459,14 @@ export const setupCommand = async () => {
|
||||
await setupCursor(result);
|
||||
await setupClaudeCode(result);
|
||||
await setupOpenCode(result);
|
||||
await setupCodex(result);
|
||||
|
||||
// Install global skills for platforms that support them
|
||||
await installClaudeCodeSkills(result);
|
||||
await installClaudeCodeHooks(result);
|
||||
await installCursorSkills(result);
|
||||
await installOpenCodeSkills(result);
|
||||
await installCodexSkills(result);
|
||||
|
||||
// Print results
|
||||
if (result.configured.length > 0) {
|
||||
|
||||
@@ -1,3 +1,33 @@
|
||||
/**
|
||||
* 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,18 +17,54 @@ 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 } from 'path';
|
||||
import { join, dirname } from 'path';
|
||||
import { createRequire } from 'module';
|
||||
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 the dynamic linker cache (ldconfig) which covers all architectures
|
||||
* and install paths, then falls back to CUDA_PATH / LD_LIBRARY_PATH env vars.
|
||||
* 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).
|
||||
*/
|
||||
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 {
|
||||
@@ -83,6 +119,13 @@ 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;
|
||||
@@ -195,13 +238,27 @@ export const initEmbedder = async (
|
||||
* Check if the embedder is initialized and ready
|
||||
*/
|
||||
export const isEmbedderReady = (): boolean => {
|
||||
return embedderInstance !== null;
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.');
|
||||
}
|
||||
@@ -212,9 +269,14 @@ export const getEmbedder = (): FeatureExtractionPipeline => {
|
||||
* Embed a single text string
|
||||
*
|
||||
* @param text - Text to embed
|
||||
* @returns Float32Array of embedding vector (384 dimensions)
|
||||
* @returns Float32Array of embedding vector
|
||||
*/
|
||||
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, {
|
||||
@@ -238,6 +300,10 @@ export const embedBatch = async (texts: string[]): Promise<Float32Array[]> => {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isHttpMode()) {
|
||||
return httpEmbed(texts);
|
||||
}
|
||||
|
||||
const embedder = getEmbedder();
|
||||
|
||||
// Process batch
|
||||
|
||||
@@ -161,14 +161,16 @@ export const runEmbeddingPipeline = async (
|
||||
modelDownloadPercent: 0,
|
||||
});
|
||||
|
||||
await initEmbedder((modelProgress: ModelProgress) => {
|
||||
const downloadPercent = modelProgress.progress ?? 0;
|
||||
onProgress({
|
||||
phase: 'loading-model',
|
||||
percent: Math.round(downloadPercent * 0.2),
|
||||
modelDownloadPercent: downloadPercent,
|
||||
});
|
||||
}, finalConfig);
|
||||
if (!isEmbedderReady()) {
|
||||
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',
|
||||
@@ -326,7 +328,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[384]), ${k})
|
||||
CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${k})
|
||||
YIELD node AS emb, distance
|
||||
WITH emb, distance
|
||||
WHERE distance < ${maxDistance}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 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,6 +5,7 @@
|
||||
*/
|
||||
|
||||
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 */
|
||||
/** Model identifier for transformers.js (local) or the HTTP endpoint model name */
|
||||
modelId: string;
|
||||
/** Number of nodes to embed in each batch */
|
||||
batchSize: number;
|
||||
@@ -65,6 +65,7 @@ export interface EmbeddingConfig {
|
||||
maxSnippetLength: number;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Default embedding configuration
|
||||
* Uses snowflake-arctic-embed-xs for browser efficiency
|
||||
|
||||
@@ -32,7 +32,8 @@ export type NodeLabel =
|
||||
| 'Delegate'
|
||||
| 'Annotation'
|
||||
| 'Constructor'
|
||||
| 'Template';
|
||||
| 'Template'
|
||||
| 'Section';
|
||||
|
||||
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
@@ -65,6 +66,8 @@ export type NodeProperties = {
|
||||
entryPointReason?: string,
|
||||
// Method signature (for MRO disambiguation)
|
||||
parameterCount?: number,
|
||||
// Section-specific (markdown heading level, 1-6)
|
||||
level?: number,
|
||||
returnType?: string,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
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 } from './symbol-table.js';
|
||||
import type { SymbolDefinition, SymbolTable } 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';
|
||||
@@ -23,12 +23,154 @@ import {
|
||||
extractMixedChain,
|
||||
type MixedChainStep,
|
||||
} from './utils.js';
|
||||
import { buildTypeEnv } from './type-env.js';
|
||||
import { buildTypeEnv, isSubclassOf } from './type-env.js';
|
||||
import type { ConstructorBinding } from './type-env.js';
|
||||
import { getTreeSitterBufferSize } from './constants.js';
|
||||
import type { ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
|
||||
import { callRouters } from './call-routing.js';
|
||||
import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js';
|
||||
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
|
||||
@@ -45,7 +187,7 @@ const TYPE_PRESERVING_METHODS = new Set([
|
||||
* Returns null if the call is at module/file level (top-level code).
|
||||
*/
|
||||
const findEnclosingFunction = (
|
||||
node: any,
|
||||
node: SyntaxNode,
|
||||
filePath: string,
|
||||
ctx: ResolutionContext
|
||||
): string | null => {
|
||||
@@ -135,10 +277,24 @@ 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[] = [];
|
||||
const pendingWrites: { receiverTypeName: string; propertyName: string; filePath: string; srcId: string }[] = [];
|
||||
// Phase P cross-file: accumulate heritage across files for cross-file isSubclassOf.
|
||||
// Used as a secondary check when per-file parentMap lacks the relationship — helps
|
||||
// when the heritage-declaring file is processed before the call site file.
|
||||
// For remaining cases (reverse file order), the SymbolTable class-type fallback applies.
|
||||
const globalParentMap = new Map<string, string[]>();
|
||||
const globalParentSeen = new Map<string, Set<string>>();
|
||||
const logSkipped = isVerboseIngestionEnabled();
|
||||
const skippedByLang = logSkipped ? new Map<string, number>() : null;
|
||||
|
||||
@@ -183,7 +339,45 @@ export const processCalls = async (
|
||||
}
|
||||
|
||||
const lang = getLanguageFromFilename(file.path);
|
||||
const typeEnv = lang ? buildTypeEnv(tree, lang, ctx.symbols) : null;
|
||||
|
||||
// Pre-pass: extract heritage from query matches to build parentMap for buildTypeEnv.
|
||||
// Heritage-processor runs in PARALLEL, so graph edges don't exist when buildTypeEnv runs.
|
||||
const fileParentMap = new Map<string, string[]>();
|
||||
for (const match of matches) {
|
||||
const captureMap: Record<string, any> = {};
|
||||
match.captures.forEach(c => captureMap[c.name] = c.node);
|
||||
if (captureMap['heritage.class'] && captureMap['heritage.extends']) {
|
||||
const className: string = captureMap['heritage.class'].text;
|
||||
const parentName: string = captureMap['heritage.extends'].text;
|
||||
const extendsNode = captureMap['heritage.extends'];
|
||||
const fieldDecl = extendsNode.parent;
|
||||
if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) continue;
|
||||
let parents = fileParentMap.get(className);
|
||||
if (!parents) { parents = []; fileParentMap.set(className, parents); }
|
||||
if (!parents.includes(parentName)) parents.push(parentName);
|
||||
}
|
||||
}
|
||||
const parentMap: ReadonlyMap<string, readonly string[]> = fileParentMap;
|
||||
// Merge per-file heritage into globalParentMap for cross-file isSubclassOf lookups.
|
||||
// Uses a parallel Set (globalParentSeen) for O(1) deduplication instead of O(n) includes().
|
||||
for (const [cls, parents] of fileParentMap) {
|
||||
let global = globalParentMap.get(cls);
|
||||
let seen = globalParentSeen.get(cls);
|
||||
if (!global) { global = []; globalParentMap.set(cls, global); }
|
||||
if (!seen) { seen = new Set(); globalParentSeen.set(cls, seen); }
|
||||
for (const p of parents) {
|
||||
if (!seen.has(p)) { seen.add(p); global.push(p); }
|
||||
}
|
||||
}
|
||||
|
||||
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 callRouter = callRouters[language];
|
||||
|
||||
const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0
|
||||
@@ -305,6 +499,41 @@ export const processCalls = async (
|
||||
const callForm = inferCallForm(callNode, nameNode);
|
||||
const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined;
|
||||
let receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined;
|
||||
// Phase P: virtual dispatch override — when the declared type is a base class but
|
||||
// the constructor created a known subclass, prefer the more specific type.
|
||||
// Checks per-file parentMap first, then falls back to globalParentMap for
|
||||
// cross-file heritage (e.g. Dog extends Animal declared in a different file).
|
||||
// Reconstructs the exact scope key (funcName@startIndex\0varName) from the
|
||||
// enclosing function AST node for a correct, O(1) map lookup.
|
||||
if (receiverTypeName && receiverName && typeEnv && typeEnv.constructorTypeMap.size > 0) {
|
||||
// Reconstruct scope key to match constructorTypeMap's scope\0varName format
|
||||
let scope = '';
|
||||
let p = callNode.parent;
|
||||
while (p) {
|
||||
if (FUNCTION_NODE_TYPES.has(p.type)) {
|
||||
const { funcName } = extractFunctionName(p);
|
||||
if (funcName) { scope = `${funcName}@${p.startIndex}`; break; }
|
||||
}
|
||||
p = p.parent;
|
||||
}
|
||||
const ctorType = typeEnv.constructorTypeMap.get(`${scope}\0${receiverName}`);
|
||||
if (ctorType && ctorType !== receiverTypeName) {
|
||||
// Verify subclass relationship: per-file parentMap first, then cross-file
|
||||
// globalParentMap, then fall back to SymbolTable class verification.
|
||||
// The SymbolTable fallback handles cross-file cases where heritage is declared
|
||||
// in a file not yet processed (e.g. Dog extends Animal in models/Dog.kt when
|
||||
// processing services/App.kt). Since constructorTypeMap only records entries
|
||||
// when a type annotation AND constructor are both present (val x: Base = Sub()),
|
||||
// confirming both are class-like types is sufficient — the original code would
|
||||
// not compile if Sub didn't extend Base.
|
||||
if (isSubclassOf(ctorType, receiverTypeName, parentMap)
|
||||
|| isSubclassOf(ctorType, receiverTypeName, globalParentMap)
|
||||
|| (ctx.symbols.lookupFuzzy(ctorType).some(d => d.type === 'Class' || d.type === 'Struct')
|
||||
&& ctx.symbols.lookupFuzzy(receiverTypeName).some(d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'))) {
|
||||
receiverTypeName = ctorType;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to verified constructor bindings for return type inference
|
||||
if (!receiverTypeName && receiverName && receiverIndex.size > 0) {
|
||||
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
|
||||
@@ -359,12 +588,20 @@ export const processCalls = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Build overload hints for languages with inferLiteralType (Java/Kotlin/C#/C++).
|
||||
// Only used when multiple candidates survive arity filtering — ~1-3% of calls.
|
||||
const langConfig = lang ? typeConfigs[lang as keyof typeof typeConfigs] : undefined;
|
||||
const hints: OverloadHints | undefined = langConfig?.inferLiteralType
|
||||
? { callNode, inferLiteralType: langConfig.inferLiteralType }
|
||||
: undefined;
|
||||
|
||||
const resolved = resolveCallTarget({
|
||||
calledName,
|
||||
argCount: countCallArguments(callNode),
|
||||
callForm,
|
||||
receiverTypeName,
|
||||
}, file.path, ctx);
|
||||
receiverName,
|
||||
}, file.path, ctx, hints);
|
||||
|
||||
if (!resolved) return;
|
||||
const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`);
|
||||
@@ -455,7 +692,9 @@ const filterCallableCandidates = (
|
||||
if (!hasParameterMetadata) return kindFiltered;
|
||||
|
||||
return kindFiltered.filter(candidate =>
|
||||
candidate.parameterCount === undefined || candidate.parameterCount === argCount
|
||||
candidate.parameterCount === undefined
|
||||
|| (argCount >= (candidate.requiredParameterCount ?? candidate.parameterCount)
|
||||
&& argCount <= candidate.parameterCount)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -470,24 +709,148 @@ const toResolveResult = (
|
||||
});
|
||||
|
||||
|
||||
/** Optional hints for overload disambiguation via argument literal types.
|
||||
* Only available on the sequential path (has AST); worker path passes undefined. */
|
||||
interface OverloadHints {
|
||||
callNode: SyntaxNode;
|
||||
inferLiteralType: LiteralTypeInferrer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kotlin (and JVM in general) uses boxed type names in parameter declarations
|
||||
* (e.g. `Int`, `Long`, `Boolean`) while inferJvmLiteralType returns unboxed
|
||||
* primitives (`int`, `long`, `boolean`). Normalise both sides to lowercase so
|
||||
* that the comparison `'Int' === 'int'` does not fail.
|
||||
*
|
||||
* Only applied to single-word identifiers that look like a JVM primitive alias;
|
||||
* multi-word or qualified names are left untouched.
|
||||
*/
|
||||
const KOTLIN_BOXED_TO_PRIMITIVE: Readonly<Record<string, string>> = {
|
||||
Int: 'int',
|
||||
Long: 'long',
|
||||
Short: 'short',
|
||||
Byte: 'byte',
|
||||
Float: 'float',
|
||||
Double: 'double',
|
||||
Boolean: 'boolean',
|
||||
Char: 'char',
|
||||
};
|
||||
|
||||
const normalizeJvmTypeName = (name: string): string =>
|
||||
KOTLIN_BOXED_TO_PRIMITIVE[name] ?? name;
|
||||
|
||||
/**
|
||||
* Try to disambiguate overloaded candidates using argument literal types.
|
||||
* Only invoked when filteredCandidates.length > 1 and at least one has parameterTypes.
|
||||
* Returns the single matching candidate, or null if ambiguous/inconclusive.
|
||||
*/
|
||||
const tryOverloadDisambiguation = (
|
||||
candidates: SymbolDefinition[],
|
||||
hints: OverloadHints,
|
||||
): SymbolDefinition | null => {
|
||||
if (!candidates.some(c => c.parameterTypes)) return null;
|
||||
|
||||
// Find the argument list node in the call expression.
|
||||
// Kotlin wraps value_arguments inside a call_suffix child, so we must also
|
||||
// search one level deeper when a direct match is not found.
|
||||
let argList: any = hints.callNode.childForFieldName?.('arguments')
|
||||
?? hints.callNode.children.find((c: any) =>
|
||||
c.type === 'arguments' || c.type === 'argument_list' || c.type === 'value_arguments'
|
||||
);
|
||||
if (!argList) {
|
||||
// Kotlin: call_expression → call_suffix → value_arguments
|
||||
const callSuffix = hints.callNode.children.find((c: any) => c.type === 'call_suffix');
|
||||
if (callSuffix) {
|
||||
argList = callSuffix.children.find((c: any) => c.type === 'value_arguments');
|
||||
}
|
||||
}
|
||||
if (!argList) return null;
|
||||
|
||||
const argTypes: (string | undefined)[] = [];
|
||||
for (const arg of argList.namedChildren) {
|
||||
if (arg.type === 'comment') continue;
|
||||
// Unwrap argument wrapper nodes before passing to inferLiteralType:
|
||||
// - Kotlin value_argument: has 'value' field containing the literal
|
||||
// - C# argument: has 'expression' field (handles named args like `name: "alice"`
|
||||
// where firstNamedChild would return name_colon instead of the value)
|
||||
// - Java/others: arg IS the literal directly (no unwrapping needed)
|
||||
const valueNode = arg.childForFieldName?.('value')
|
||||
?? arg.childForFieldName?.('expression')
|
||||
?? (arg.type === 'argument' || arg.type === 'value_argument'
|
||||
? arg.firstNamedChild ?? arg
|
||||
: arg);
|
||||
argTypes.push(hints.inferLiteralType(valueNode));
|
||||
}
|
||||
|
||||
// If no literal types could be inferred, can't disambiguate
|
||||
if (argTypes.every(t => t === undefined)) return null;
|
||||
|
||||
const matched = candidates.filter(c => {
|
||||
// Keep candidates without type info — conservative: partially-annotated codebases
|
||||
// (e.g. C++ with some missing declarations) may have mixed typed/untyped overloads.
|
||||
// If one typed and one untyped both survive, matched.length > 1 → returns null (no edge).
|
||||
if (!c.parameterTypes) return true;
|
||||
return c.parameterTypes.every((pType, i) => {
|
||||
if (i >= argTypes.length || !argTypes[i]) return true;
|
||||
// Normalise Kotlin boxed type names (Int→int, Boolean→boolean, etc.) so
|
||||
// that the stored declaration type matches the inferred literal type.
|
||||
return normalizeJvmTypeName(pType) === argTypes[i];
|
||||
});
|
||||
});
|
||||
|
||||
if (matched.length === 1) return matched[0];
|
||||
// Multiple survivors may share the same nodeId (e.g. TypeScript overload signatures +
|
||||
// implementation body all collide via generateId). Deduplicate by nodeId — if all
|
||||
// matched candidates resolve to the same graph node, disambiguation succeeded.
|
||||
if (matched.length > 1) {
|
||||
const uniqueIds = new Set(matched.map(c => c.nodeId));
|
||||
if (uniqueIds.size === 1) return matched[0];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a function call to its target node ID using priority strategy:
|
||||
* A. Narrow candidates by scope tier via ctx.resolve()
|
||||
* B. Filter to callable symbol kinds (constructor-aware when callForm is set)
|
||||
* C. Apply arity filtering when parameter metadata is available
|
||||
* D. Apply receiver-type filtering for member calls with typed receivers
|
||||
* E. Apply overload disambiguation via argument literal types (when available)
|
||||
*
|
||||
* If filtering still leaves multiple candidates, refuse to emit a CALLS edge.
|
||||
*/
|
||||
const resolveCallTarget = (
|
||||
call: Pick<ExtractedCall, 'calledName' | 'argCount' | 'callForm' | 'receiverTypeName'>,
|
||||
call: Pick<ExtractedCall, 'calledName' | 'argCount' | 'callForm' | 'receiverTypeName' | 'receiverName'>,
|
||||
currentFile: string,
|
||||
ctx: ResolutionContext,
|
||||
overloadHints?: OverloadHints,
|
||||
): ResolveResult | null => {
|
||||
const tiered = ctx.resolve(call.calledName, currentFile);
|
||||
if (!tiered) return null;
|
||||
|
||||
const filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// D. Receiver-type filtering: for member calls with a known receiver type,
|
||||
// resolve the type through the same tiered import infrastructure, then
|
||||
@@ -523,10 +886,24 @@ const resolveCallTarget = (
|
||||
if (ownerFiltered.length === 1) {
|
||||
return toResolveResult(ownerFiltered[0], tiered.tier);
|
||||
}
|
||||
// E. Try overload disambiguation on the narrowed pool
|
||||
if ((fileFiltered.length > 1 || ownerFiltered.length > 1) && overloadHints) {
|
||||
const overloadPool = ownerFiltered.length > 1 ? ownerFiltered : fileFiltered;
|
||||
const disambiguated = tryOverloadDisambiguation(overloadPool, overloadHints);
|
||||
if (disambiguated) return toResolveResult(disambiguated, tiered.tier);
|
||||
}
|
||||
if (fileFiltered.length > 1 || ownerFiltered.length > 1) return null;
|
||||
}
|
||||
}
|
||||
|
||||
// E. Overload disambiguation: when multiple candidates survive arity + receiver filtering,
|
||||
// try matching argument literal types against parameter types (Phase P).
|
||||
// Only available on sequential path (has AST); worker path falls through gracefully.
|
||||
if (filteredCandidates.length > 1 && overloadHints) {
|
||||
const disambiguated = tryOverloadDisambiguation(filteredCandidates, overloadHints);
|
||||
if (disambiguated) return toResolveResult(disambiguated, tiered.tier);
|
||||
}
|
||||
|
||||
if (filteredCandidates.length !== 1) return null;
|
||||
|
||||
return toResolveResult(filteredCandidates[0], tiered.tier);
|
||||
|
||||
@@ -18,6 +18,12 @@ 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,30 +14,33 @@ import { detectFrameworkFromPath } from './framework-detection.js';
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
|
||||
// ============================================================================
|
||||
// NAME PATTERNS - All 11 supported languages
|
||||
// NAME PATTERNS - All 13 supported languages
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Common entry point naming patterns by language
|
||||
* These patterns indicate functions that are likely feature entry points
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
],
|
||||
|
||||
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 = {
|
||||
// JavaScript/TypeScript
|
||||
[SupportedLanguages.JavaScript]: [
|
||||
/^use[A-Z]/, // React hooks (useEffect, etc.)
|
||||
@@ -62,6 +65,17 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
|
||||
/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
|
||||
@@ -77,7 +91,7 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
|
||||
/Service$/, // Service classes
|
||||
/^Seed/, // Database seeding
|
||||
],
|
||||
|
||||
|
||||
// Go
|
||||
[SupportedLanguages.Go]: [
|
||||
/Handler$/, // http.Handler pattern
|
||||
@@ -85,7 +99,7 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
|
||||
/^New[A-Z]/, // Constructor pattern (returns new instance)
|
||||
/^Make[A-Z]/, // Make functions
|
||||
],
|
||||
|
||||
|
||||
// Rust
|
||||
[SupportedLanguages.Rust]: [
|
||||
/^(get|post|put|delete)_handler$/i,
|
||||
@@ -94,7 +108,7 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
|
||||
/^run$/, // run entry point
|
||||
/^spawn/, // Async spawn
|
||||
],
|
||||
|
||||
|
||||
// C - explicit main() boost plus common C entry point conventions
|
||||
[SupportedLanguages.C]: [
|
||||
/^main$/, // THE entry point
|
||||
@@ -198,15 +212,15 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
|
||||
/^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: 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];
|
||||
}
|
||||
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[]>;
|
||||
|
||||
// ============================================================================
|
||||
// UTILITY PATTERNS - Functions that should be penalized
|
||||
@@ -295,7 +309,7 @@ export function calculateEntryPointScore(
|
||||
reasons.push('utility-pattern');
|
||||
} else {
|
||||
// Check positive patterns
|
||||
const allPatterns = MERGED_ENTRY_POINT_PATTERNS[language] || UNIVERSAL_PATTERNS;
|
||||
const allPatterns = MERGED_ENTRY_POINT_PATTERNS[language];
|
||||
|
||||
if (allPatterns.some(p => p.test(name))) {
|
||||
nameMultiplier = 1.5; // Bonus for matching entry point pattern
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* (no bonus, no penalty) - same behavior as before this feature.
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
@@ -234,8 +236,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)
|
||||
if (p.endsWith('/main.go') || p.endsWith('/cmd/') && p.endsWith('.go')) {
|
||||
// Go main.go files (THE entry point) — only match main.go, not arbitrary .go files under cmd/
|
||||
if (p.endsWith('/main.go')) {
|
||||
return { framework: 'go', entryPointMultiplier: 3.0, reason: 'go-main' };
|
||||
}
|
||||
|
||||
@@ -431,25 +433,36 @@ export const FRAMEWORK_AST_PATTERNS = {
|
||||
'blazor': ['@page', '[Parameter]', '@inject'],
|
||||
'efcore': ['DbContext', 'DbSet<', 'OnModelCreating'],
|
||||
|
||||
// Go patterns (function signatures)
|
||||
'go-http': ['http.Handler', 'http.HandlerFunc', 'ServeHTTP'],
|
||||
// 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'],
|
||||
|
||||
// PHP/Laravel
|
||||
'laravel': ['Route::get', 'Route::post', 'Route::put', 'Route::delete',
|
||||
'Route::resource', 'Route::apiResource', '#[Route('],
|
||||
|
||||
// Rust macros
|
||||
'actix': ['#[get', '#[post', '#[put', '#[delete'],
|
||||
'axum': ['Router::new'],
|
||||
'rocket': ['#[get', '#[post'],
|
||||
// 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'],
|
||||
|
||||
// Swift/iOS
|
||||
'uikit': ['viewDidLoad', 'viewWillAppear', 'viewDidAppear', 'UIViewController'],
|
||||
'swiftui': ['@main', 'WindowGroup', 'ContentView', '@StateObject', '@ObservedObject'],
|
||||
'combine': ['sink', 'assign', 'Publisher', 'Subscriber'],
|
||||
};
|
||||
'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'],
|
||||
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
// 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'],
|
||||
};
|
||||
|
||||
interface AstFrameworkPatternConfig {
|
||||
framework: string;
|
||||
@@ -458,7 +471,7 @@ interface AstFrameworkPatternConfig {
|
||||
patterns: string[];
|
||||
}
|
||||
|
||||
const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record<string, AstFrameworkPatternConfig[]> = {
|
||||
const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = {
|
||||
[SupportedLanguages.JavaScript]: [
|
||||
{ framework: 'nestjs', entryPointMultiplier: 3.2, reason: 'nestjs-decorator', patterns: FRAMEWORK_AST_PATTERNS.nestjs },
|
||||
],
|
||||
@@ -488,7 +501,33 @@ const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record<string, AstFrameworkPatternConf
|
||||
[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,6 +25,7 @@ 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]/;
|
||||
@@ -66,22 +67,28 @@ 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,
|
||||
): string => {
|
||||
): ResolvedHeritage => {
|
||||
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 generateId(fallbackLabel, fallbackKey ?? name);
|
||||
return { id: generateId(fallbackLabel, fallbackKey ?? name), confidence: TIER_CONFIDENCE['global'] };
|
||||
}
|
||||
return resolved.candidates[0].nodeId;
|
||||
return { id: resolved.candidates[0].nodeId, confidence: TIER_CONFIDENCE[resolved.tier] };
|
||||
}
|
||||
return generateId(fallbackLabel, fallbackKey ?? name);
|
||||
// Unresolved: use global-tier confidence as fallback
|
||||
return { id: generateId(fallbackLabel, fallbackKey ?? name), confidence: TIER_CONFIDENCE['global'] };
|
||||
};
|
||||
|
||||
export const processHeritage = async (
|
||||
@@ -163,16 +170,16 @@ export const processHeritage = async (
|
||||
|
||||
const { type: relType, idPrefix } = resolveExtendsType(parentClassName, file.path, ctx, language);
|
||||
|
||||
const childId = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`);
|
||||
const parentId = resolveHeritageId(parentClassName, file.path, ctx, idPrefix);
|
||||
const child = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`);
|
||||
const parent = resolveHeritageId(parentClassName, file.path, ctx, idPrefix);
|
||||
|
||||
if (childId && parentId && childId !== parentId) {
|
||||
if (child.id && parent.id && child.id !== parent.id) {
|
||||
graph.addRelationship({
|
||||
id: generateId(relType, `${childId}->${parentId}`),
|
||||
sourceId: childId,
|
||||
targetId: parentId,
|
||||
id: generateId(relType, `${child.id}->${parent.id}`),
|
||||
sourceId: child.id,
|
||||
targetId: parent.id,
|
||||
type: relType,
|
||||
confidence: 1.0,
|
||||
confidence: Math.sqrt(child.confidence * parent.confidence),
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
@@ -183,16 +190,16 @@ export const processHeritage = async (
|
||||
const className = captureMap['heritage.class'].text;
|
||||
const interfaceName = captureMap['heritage.implements'].text;
|
||||
|
||||
const classId = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`);
|
||||
const interfaceId = resolveHeritageId(interfaceName, file.path, ctx, 'Interface');
|
||||
const cls = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`);
|
||||
const iface = resolveHeritageId(interfaceName, file.path, ctx, 'Interface');
|
||||
|
||||
if (classId && interfaceId) {
|
||||
if (cls.id && iface.id) {
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', `${classId}->${interfaceId}`),
|
||||
sourceId: classId,
|
||||
targetId: interfaceId,
|
||||
id: generateId('IMPLEMENTS', `${cls.id}->${iface.id}`),
|
||||
sourceId: cls.id,
|
||||
targetId: iface.id,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: 1.0,
|
||||
confidence: Math.sqrt(cls.confidence * iface.confidence),
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
@@ -203,16 +210,16 @@ export const processHeritage = async (
|
||||
const structName = captureMap['heritage.class'].text;
|
||||
const traitName = captureMap['heritage.trait'].text;
|
||||
|
||||
const structId = resolveHeritageId(structName, file.path, ctx, 'Struct', `${file.path}:${structName}`);
|
||||
const traitId = resolveHeritageId(traitName, file.path, ctx, 'Trait');
|
||||
const strct = resolveHeritageId(structName, file.path, ctx, 'Struct', `${file.path}:${structName}`);
|
||||
const trait = resolveHeritageId(traitName, file.path, ctx, 'Trait');
|
||||
|
||||
if (structId && traitId) {
|
||||
if (strct.id && trait.id) {
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', `${structId}->${traitId}`),
|
||||
sourceId: structId,
|
||||
targetId: traitId,
|
||||
id: generateId('IMPLEMENTS', `${strct.id}->${trait.id}`),
|
||||
sourceId: strct.id,
|
||||
targetId: trait.id,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: 1.0,
|
||||
confidence: Math.sqrt(strct.confidence * trait.confidence),
|
||||
reason: 'trait-impl',
|
||||
});
|
||||
}
|
||||
@@ -256,44 +263,44 @@ export const processHeritageFromExtracted = async (
|
||||
if (!fileLanguage) continue;
|
||||
const { type: relType, idPrefix } = resolveExtendsType(h.parentName, h.filePath, ctx, fileLanguage);
|
||||
|
||||
const childId = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`);
|
||||
const parentId = resolveHeritageId(h.parentName, h.filePath, ctx, idPrefix);
|
||||
const child = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`);
|
||||
const parent = resolveHeritageId(h.parentName, h.filePath, ctx, idPrefix);
|
||||
|
||||
if (childId && parentId && childId !== parentId) {
|
||||
if (child.id && parent.id && child.id !== parent.id) {
|
||||
graph.addRelationship({
|
||||
id: generateId(relType, `${childId}->${parentId}`),
|
||||
sourceId: childId,
|
||||
targetId: parentId,
|
||||
id: generateId(relType, `${child.id}->${parent.id}`),
|
||||
sourceId: child.id,
|
||||
targetId: parent.id,
|
||||
type: relType,
|
||||
confidence: 1.0,
|
||||
confidence: Math.sqrt(child.confidence * parent.confidence),
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
} else if (h.kind === 'implements') {
|
||||
const classId = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`);
|
||||
const interfaceId = resolveHeritageId(h.parentName, h.filePath, ctx, 'Interface');
|
||||
const cls = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`);
|
||||
const iface = resolveHeritageId(h.parentName, h.filePath, ctx, 'Interface');
|
||||
|
||||
if (classId && interfaceId) {
|
||||
if (cls.id && iface.id) {
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', `${classId}->${interfaceId}`),
|
||||
sourceId: classId,
|
||||
targetId: interfaceId,
|
||||
id: generateId('IMPLEMENTS', `${cls.id}->${iface.id}`),
|
||||
sourceId: cls.id,
|
||||
targetId: iface.id,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: 1.0,
|
||||
confidence: Math.sqrt(cls.confidence * iface.confidence),
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
} else if (h.kind === 'trait-impl' || h.kind === 'include' || h.kind === 'extend' || h.kind === 'prepend') {
|
||||
const structId = resolveHeritageId(h.className, h.filePath, ctx, 'Struct', `${h.filePath}:${h.className}`);
|
||||
const traitId = resolveHeritageId(h.parentName, h.filePath, ctx, 'Trait');
|
||||
const strct = resolveHeritageId(h.className, h.filePath, ctx, 'Struct', `${h.filePath}:${h.className}`);
|
||||
const trait = resolveHeritageId(h.parentName, h.filePath, ctx, 'Trait');
|
||||
|
||||
if (structId && traitId) {
|
||||
if (strct.id && trait.id) {
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', `${structId}->${traitId}:${h.kind}`),
|
||||
sourceId: structId,
|
||||
targetId: traitId,
|
||||
id: generateId('IMPLEMENTS', `${strct.id}->${trait.id}:${h.kind}`),
|
||||
sourceId: strct.id,
|
||||
targetId: trait.id,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: 1.0,
|
||||
confidence: Math.sqrt(strct.confidence * trait.confidence),
|
||||
reason: h.kind,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,43 +5,15 @@ 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 {
|
||||
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 { loadImportConfigs } from './language-config.js';
|
||||
import { buildSuffixIndex } from './resolvers/index.js';
|
||||
import { callRouters } from './call-routing.js';
|
||||
import type { ResolutionContext } from './resolution-context.js';
|
||||
import type {
|
||||
SuffixIndex,
|
||||
TsconfigPaths,
|
||||
GoModuleConfig,
|
||||
CSharpProjectConfig,
|
||||
ComposerConfig
|
||||
} from './resolvers/index.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';
|
||||
|
||||
// Re-export resolver types for consumers
|
||||
export type {
|
||||
@@ -89,7 +61,7 @@ export interface ImportResolutionContext {
|
||||
allFilePaths: Set<string>;
|
||||
allFileList: string[];
|
||||
normalizedFileList: string[];
|
||||
suffixIndex: SuffixIndex;
|
||||
index: SuffixIndex;
|
||||
resolveCache: Map<string, string | null>;
|
||||
}
|
||||
|
||||
@@ -97,175 +69,39 @@ export function buildImportResolutionContext(allPaths: string[]): ImportResoluti
|
||||
const allFileList = allPaths;
|
||||
const normalizedFileList = allFileList.map(p => p.replace(/\\/g, '/'));
|
||||
const allFilePaths = new Set(allFileList);
|
||||
const suffixIndex = buildSuffixIndex(normalizedFileList, allFileList);
|
||||
return { allFilePaths, allFileList, normalizedFileList, suffixIndex, resolveCache: new Map() };
|
||||
const index = buildSuffixIndex(normalizedFileList, allFileList);
|
||||
return { allFilePaths, allFileList, normalizedFileList, index, resolveCache: new Map() };
|
||||
}
|
||||
|
||||
// Config loaders extracted to ./language-config.ts (Phase 2 refactor)
|
||||
// Resolver functions are in ./resolvers/ — imported above
|
||||
// Resolver dispatch tables are in ./import-resolution.ts — imported above
|
||||
|
||||
// ============================================================================
|
||||
// SHARED LANGUAGE DISPATCH
|
||||
// ============================================================================
|
||||
/** Create IMPORTS edge helpers that share a resolved-count tracker. */
|
||||
function createImportEdgeHelpers(graph: KnowledgeGraph, importMap: ImportMap) {
|
||||
let totalImportsResolved = 0;
|
||||
|
||||
/** 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 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: '' });
|
||||
};
|
||||
|
||||
/** 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>;
|
||||
}
|
||||
const addImportEdge = (filePath: string, resolvedPath: string) => {
|
||||
addImportGraphEdge(filePath, resolvedPath);
|
||||
if (!importMap.has(filePath)) importMap.set(filePath, new Set());
|
||||
importMap.get(filePath)!.add(resolvedPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return { addImportEdge, addImportGraphEdge, getResolvedCount: () => totalImportsResolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -274,8 +110,9 @@ function applyImportResult(
|
||||
packageMap: PackageMap | undefined,
|
||||
addImportEdge: (from: string, to: string) => void,
|
||||
addImportGraphEdge: (from: string, to: string) => void,
|
||||
namedBindings?: { local: string; exported: string }[],
|
||||
namedBindings?: NamedBinding[],
|
||||
namedImportMap?: NamedImportMap,
|
||||
moduleAliasMap?: ModuleAliasMap,
|
||||
): void {
|
||||
if (!result) return;
|
||||
|
||||
@@ -293,13 +130,61 @@ function applyImportResult(
|
||||
addImportEdge(filePath, resolvedFile);
|
||||
}
|
||||
|
||||
// Record named bindings for precise Tier 2a resolution
|
||||
if (namedBindings && namedImportMap && files.length === 1) {
|
||||
// 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) {
|
||||
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)!;
|
||||
for (const binding of namedBindings) {
|
||||
fileBindings.set(binding.local, { sourcePath: resolvedFile, exportedName: binding.exported });
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,6 +206,7 @@ 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);
|
||||
@@ -335,46 +221,11 @@ export const processImports = async (
|
||||
|
||||
// Track import statistics
|
||||
let totalImportsFound = 0;
|
||||
let totalImportsResolved = 0;
|
||||
|
||||
// Load language-specific configs once before the file loop
|
||||
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);
|
||||
};
|
||||
const configs = await loadImportConfigs(repoRoot || '');
|
||||
const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache, configs };
|
||||
const { addImportEdge, addImportGraphEdge, getResolvedCount } = createImportEdgeHelpers(graph, importMap);
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
@@ -447,15 +298,14 @@ export const processImports = async (
|
||||
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, '');
|
||||
const rawImportPath = preprocessImportPath(sourceNode.text, captureMap['import'], language);
|
||||
if (!rawImportPath) return;
|
||||
totalImportsFound++;
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
// ---- Language-specific call-as-import routing (Ruby require, etc.) ----
|
||||
@@ -466,7 +316,7 @@ export const processImports = async (
|
||||
const routed = callRouter(callNameNode.text, captureMap['call']);
|
||||
if (routed && routed.kind === 'import') {
|
||||
totalImportsFound++;
|
||||
const result = resolveLanguageImport(file.path, routed.importPath, language, configs, resolveCtx);
|
||||
const result = importResolvers[language](routed.importPath, file.path, resolveCtx);
|
||||
applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge);
|
||||
}
|
||||
}
|
||||
@@ -485,7 +335,7 @@ export const processImports = async (
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
console.log(`📊 Import processing complete: ${totalImportsResolved}/${totalImportsFound} imports resolved to graph edges`);
|
||||
console.log(`📊 Import processing complete: ${getResolvedCount()}/${totalImportsFound} imports resolved to graph edges`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -505,48 +355,15 @@ 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, suffixIndex: index, resolveCache } = importCtx;
|
||||
const { allFilePaths, allFileList, normalizedFileList, index, resolveCache } = importCtx;
|
||||
|
||||
let totalImportsFound = 0;
|
||||
let totalImportsResolved = 0;
|
||||
|
||||
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);
|
||||
};
|
||||
const configs = await loadImportConfigs(repoRoot || '');
|
||||
const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache, configs };
|
||||
const { addImportEdge, addImportGraphEdge, getResolvedCount } = createImportEdgeHelpers(graph, importMap);
|
||||
|
||||
// Group by file for progress reporting (users see file count, not import count)
|
||||
const importsByFile = new Map<string, ExtractedImport[]>();
|
||||
@@ -572,14 +389,14 @@ export const processImportsFromExtracted = async (
|
||||
for (const imp of fileImports) {
|
||||
totalImportsFound++;
|
||||
|
||||
const result = resolveLanguageImport(filePath, imp.rawImportPath, imp.language, configs, resolveCtx);
|
||||
applyImportResult(result, filePath, importMap, packageMap, addImportEdge, addImportGraphEdge, imp.namedBindings, namedImportMap);
|
||||
const result = importResolvers[imp.language](imp.rawImportPath, filePath, resolveCtx);
|
||||
applyImportResult(result, filePath, importMap, packageMap, addImportEdge, addImportGraphEdge, imp.namedBindings, namedImportMap, moduleAliasMap);
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.(totalFiles, totalFiles);
|
||||
|
||||
if (isDev) {
|
||||
console.log(`📊 Import processing (fast path): ${totalImportsResolved}/${totalImportsFound} imports resolved to graph edges`);
|
||||
console.log(`📊 Import processing (fast path): ${getResolvedCount()}/${totalImportsFound} imports resolved to graph edges`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* 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,5 +1,6 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import type { ImportConfigs } from './import-resolution.js';
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
@@ -213,3 +214,18 @@ 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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Markdown Processor
|
||||
*
|
||||
* Extracts structure from .md files using regex (no tree-sitter dependency).
|
||||
* Creates Section nodes for headings with hierarchy, and IMPORTS edges for
|
||||
* cross-file links.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { KnowledgeGraph, GraphNode, GraphRelationship } from '../graph/types.js';
|
||||
|
||||
const HEADING_RE = /^(#{1,6})\s+(.+)$/;
|
||||
const LINK_RE = /\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const MD_EXTENSIONS = new Set(['.md', '.mdx']);
|
||||
|
||||
interface MdFile {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const processMarkdown = (
|
||||
graph: KnowledgeGraph,
|
||||
files: MdFile[],
|
||||
allPathSet: Set<string>,
|
||||
): { sections: number; links: number } => {
|
||||
let totalSections = 0;
|
||||
let totalLinks = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file.path).toLowerCase();
|
||||
if (!MD_EXTENSIONS.has(ext)) continue;
|
||||
|
||||
const fileNodeId = generateId('File', file.path);
|
||||
// Skip if file node doesn't exist (shouldn't happen, structure-processor creates it)
|
||||
if (!graph.getNode(fileNodeId)) continue;
|
||||
|
||||
const lines = file.content.split('\n');
|
||||
|
||||
// --- Extract headings and build hierarchy ---
|
||||
// First pass: collect all heading positions so we can compute endLine spans
|
||||
const headings: { level: number; heading: string; lineNum: number }[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(HEADING_RE);
|
||||
if (!match) continue;
|
||||
|
||||
headings.push({
|
||||
level: match[1].length,
|
||||
heading: match[2].trim(),
|
||||
lineNum: i + 1, // 1-indexed
|
||||
});
|
||||
}
|
||||
|
||||
// Second pass: create nodes with proper endLine spans
|
||||
const sectionStack: { level: number; id: string }[] = [];
|
||||
|
||||
for (let h = 0; h < headings.length; h++) {
|
||||
const { level, heading, lineNum } = headings[h];
|
||||
|
||||
// endLine = line before next heading at same or higher level, or EOF
|
||||
let endLine = lines.length;
|
||||
for (let j = h + 1; j < headings.length; j++) {
|
||||
if (headings[j].level <= level) {
|
||||
endLine = headings[j].lineNum - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const sectionId = generateId('Section', `${file.path}:L${lineNum}:${heading}`);
|
||||
|
||||
const node: GraphNode = {
|
||||
id: sectionId,
|
||||
label: 'Section',
|
||||
properties: {
|
||||
name: heading,
|
||||
filePath: file.path,
|
||||
startLine: lineNum,
|
||||
endLine,
|
||||
level,
|
||||
description: `h${level}`,
|
||||
},
|
||||
};
|
||||
graph.addNode(node);
|
||||
totalSections++;
|
||||
|
||||
// Find parent: pop stack until we find a level strictly less than current
|
||||
while (sectionStack.length > 0 && sectionStack[sectionStack.length - 1].level >= level) {
|
||||
sectionStack.pop();
|
||||
}
|
||||
|
||||
const parentId = sectionStack.length > 0
|
||||
? sectionStack[sectionStack.length - 1].id
|
||||
: fileNodeId;
|
||||
|
||||
graph.addRelationship({
|
||||
id: generateId('CONTAINS', `${parentId}->${sectionId}`),
|
||||
type: 'CONTAINS',
|
||||
sourceId: parentId,
|
||||
targetId: sectionId,
|
||||
confidence: 1.0,
|
||||
reason: 'markdown-heading',
|
||||
});
|
||||
|
||||
sectionStack.push({ level, id: sectionId });
|
||||
}
|
||||
|
||||
// --- Extract links to other files in the repo ---
|
||||
const fileDir = path.dirname(file.path);
|
||||
const seenLinks = new Set<string>();
|
||||
let linkMatch: RegExpExecArray | null;
|
||||
LINK_RE.lastIndex = 0;
|
||||
|
||||
while ((linkMatch = LINK_RE.exec(file.content)) !== null) {
|
||||
const href = linkMatch[2];
|
||||
|
||||
// Skip external URLs, anchors, and mailto
|
||||
if (href.startsWith('http://') || href.startsWith('https://') ||
|
||||
href.startsWith('#') || href.startsWith('mailto:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip anchor fragments from local links
|
||||
const cleanHref = href.split('#')[0];
|
||||
if (!cleanHref) continue;
|
||||
|
||||
// Resolve relative to the file's directory, then normalize
|
||||
const resolved = path.posix.normalize(path.posix.join(fileDir, cleanHref));
|
||||
|
||||
if (allPathSet.has(resolved)) {
|
||||
const targetFileId = generateId('File', resolved);
|
||||
|
||||
// Skip if target file node doesn't exist
|
||||
if (!graph.getNode(targetFileId)) continue;
|
||||
|
||||
// Dedup: skip if we've already linked this file pair
|
||||
const linkKey = `${fileNodeId}->${targetFileId}`;
|
||||
if (seenLinks.has(linkKey)) continue;
|
||||
seenLinks.add(linkKey);
|
||||
|
||||
const relId = generateId('IMPORTS', linkKey);
|
||||
|
||||
graph.addRelationship({
|
||||
id: relId,
|
||||
type: 'IMPORTS',
|
||||
sourceId: fileNodeId,
|
||||
targetId: targetFileId,
|
||||
confidence: 0.8,
|
||||
reason: 'markdown-link',
|
||||
});
|
||||
totalLinks++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { sections: totalSections, links: totalLinks };
|
||||
};
|
||||
@@ -209,7 +209,7 @@ function c3Linearize(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MethodDef = { classId: string; className: string; methodId: string };
|
||||
type Resolution = { resolvedTo: string | null; reason: string };
|
||||
type Resolution = { resolvedTo: string | null; reason: string; confidence: number };
|
||||
|
||||
/** Resolve by MRO order — first ancestor in linearized order wins. */
|
||||
function resolveByMroOrder(
|
||||
@@ -224,10 +224,11 @@ 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` };
|
||||
return { resolvedTo: defs[0].methodId, reason: `${reasonPrefix} fallback: first definition`, confidence: 0.7 };
|
||||
}
|
||||
|
||||
function resolveCsharpJava(
|
||||
@@ -251,6 +252,7 @@ function resolveCsharpJava(
|
||||
return {
|
||||
resolvedTo: classDefs[0].methodId,
|
||||
reason: `class method wins: ${classDefs[0].className}::${methodName}`,
|
||||
confidence: 0.95, // Class method is authoritative
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,6 +260,7 @@ function resolveCsharpJava(
|
||||
return {
|
||||
resolvedTo: null,
|
||||
reason: `ambiguous: ${methodName} defined in multiple interfaces: ${interfaceDefs.map(d => d.className).join(', ')}`,
|
||||
confidence: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -265,10 +268,11 @@ 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' };
|
||||
return { resolvedTo: null, reason: 'no resolution found', confidence: 0.5 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -376,6 +380,7 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
|
||||
resolution = {
|
||||
resolvedTo: null,
|
||||
reason: `Rust requires qualified syntax: <Type as Trait>::${methodName}()`,
|
||||
confidence: 0.5,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
@@ -402,7 +407,7 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
|
||||
sourceId: classId,
|
||||
targetId: resolution.resolvedTo,
|
||||
type: 'OVERRIDES',
|
||||
confidence: 1.0,
|
||||
confidence: resolution.confidence,
|
||||
reason: resolution.reason,
|
||||
});
|
||||
overrideEdges++;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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.
|
||||
@@ -54,52 +56,14 @@ export function walkBindingChain(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
export function extractTsNamedBindings(importNode: SyntaxNode): NamedBinding[] | 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: { local: string; exported: string }[] = [];
|
||||
const bindings: NamedBinding[] = [];
|
||||
for (let i = 0; i < namedImports.namedChildCount; i++) {
|
||||
const specifier = namedImports.namedChild(i);
|
||||
if (specifier?.type !== 'import_specifier') continue;
|
||||
@@ -123,7 +87,7 @@ export function extractTsNamedBindings(importNode: any): { local: string; export
|
||||
// Re-export: export { X } from './y' → export_statement > export_clause > export_specifier
|
||||
const exportClause = findChild(importNode, 'export_clause');
|
||||
if (exportClause) {
|
||||
const bindings: { local: string; exported: string }[] = [];
|
||||
const bindings: NamedBinding[] = [];
|
||||
for (let i = 0; i < exportClause.namedChildCount; i++) {
|
||||
const specifier = exportClause.namedChild(i);
|
||||
if (specifier?.type !== 'export_specifier') continue;
|
||||
@@ -150,39 +114,60 @@ export function extractTsNamedBindings(importNode: any): { local: string; export
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
const bindings: { local: string; exported: string }[] = [];
|
||||
for (let i = 0; i < importNode.namedChildCount; i++) {
|
||||
const child = importNode.namedChild(i);
|
||||
if (!child) continue;
|
||||
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 === '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;
|
||||
// This is an imported name: from x import User
|
||||
const name = child.text;
|
||||
if (name) bindings.push({ local: name, exported: name });
|
||||
}
|
||||
|
||||
// This is an imported name: from x import User
|
||||
const name = child.text;
|
||||
if (name) bindings.push({ local: name, exported: name });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (child.type === 'aliased_import') {
|
||||
// from x import Repo as R
|
||||
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;
|
||||
|
||||
const dottedName = findChild(child, 'dotted_name');
|
||||
const aliasIdent = findChild(child, 'identifier');
|
||||
if (dottedName && aliasIdent) {
|
||||
bindings.push({ local: aliasIdent.text, exported: dottedName.text });
|
||||
bindings.push({ local: aliasIdent.text, exported: dottedName.text, isModuleAlias: true });
|
||||
}
|
||||
}
|
||||
|
||||
return bindings.length > 0 ? bindings : undefined;
|
||||
}
|
||||
|
||||
return bindings.length > 0 ? bindings : undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractKotlinNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
|
||||
export function extractKotlinNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
|
||||
// import_header > identifier + import_alias > simple_identifier
|
||||
if (importNode.type !== 'import_header') return undefined;
|
||||
|
||||
@@ -201,26 +186,32 @@ export function extractKotlinNamedBindings(importNode: any): { local: string; ex
|
||||
}
|
||||
|
||||
// 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 lowercase last segments — those are member/function imports (e.g.,
|
||||
// import util.OneArg.writeAudit), not class imports. Multiple member imports
|
||||
// Skip class-member imports (e.g., import util.OneArg.writeAudit) where the
|
||||
// second-to-last segment is PascalCase (a class name). Multiple member imports
|
||||
// with the same function name would collide in NamedImportMap, breaking
|
||||
// arity-based disambiguation.
|
||||
if (exportedName[0] && exportedName[0] === exportedName[0].toLowerCase()) return undefined;
|
||||
// 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;
|
||||
}
|
||||
return [{ local: exportedName, exported: exportedName }];
|
||||
}
|
||||
|
||||
export function extractRustNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
|
||||
export function extractRustNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
|
||||
// use_declaration may contain use_as_clause at any depth
|
||||
if (importNode.type !== 'use_declaration') return undefined;
|
||||
|
||||
const bindings: { local: string; exported: string }[] = [];
|
||||
const bindings: NamedBinding[] = [];
|
||||
collectRustBindings(importNode, bindings);
|
||||
return bindings.length > 0 ? bindings : undefined;
|
||||
}
|
||||
|
||||
function collectRustBindings(node: any, bindings: { local: string; exported: string }[]): void {
|
||||
function collectRustBindings(node: SyntaxNode, bindings: NamedBinding[]): void {
|
||||
if (node.type === 'use_as_clause') {
|
||||
// First identifier = exported name, second identifier = local alias
|
||||
const idents: string[] = [];
|
||||
@@ -278,15 +269,22 @@ function collectRustBindings(node: any, bindings: { local: string; exported: str
|
||||
}
|
||||
}
|
||||
|
||||
export function extractPhpNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
|
||||
export function extractPhpNamedBindings(importNode: SyntaxNode): NamedBinding[] | 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;
|
||||
|
||||
const bindings: { local: string; exported: string }[] = [];
|
||||
// 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[] = [];
|
||||
|
||||
// Collect all clauses — from direct children AND from namespace_use_group
|
||||
const clauses: any[] = [];
|
||||
const clauses: SyntaxNode[] = [];
|
||||
for (let i = 0; i < importNode.namedChildCount; i++) {
|
||||
const child = importNode.namedChild(i);
|
||||
if (child?.type === 'namespace_use_clause') {
|
||||
@@ -301,8 +299,8 @@ export function extractPhpNamedBindings(importNode: any): { local: string; expor
|
||||
|
||||
for (const clause of clauses) {
|
||||
// Flat imports: qualified_name + name (alias)
|
||||
let qualifiedName: any = null;
|
||||
const names: any[] = [];
|
||||
let qualifiedName: SyntaxNode | null = null;
|
||||
const names: SyntaxNode[] = [];
|
||||
for (let j = 0; j < clause.namedChildCount; j++) {
|
||||
const child = clause.namedChild(j);
|
||||
if (child?.type === 'qualified_name') qualifiedName = child;
|
||||
@@ -330,35 +328,55 @@ export function extractPhpNamedBindings(importNode: any): { local: string; expor
|
||||
return bindings.length > 0 ? bindings : undefined;
|
||||
}
|
||||
|
||||
export function extractCsharpNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
|
||||
// using_directive with identifier (alias) + qualified_name (target)
|
||||
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)
|
||||
if (importNode.type !== 'using_directive') return undefined;
|
||||
|
||||
let aliasIdent: any = null;
|
||||
let qualifiedName: any = null;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
if (!aliasIdent || !qualifiedName) return undefined;
|
||||
// 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 }];
|
||||
}
|
||||
|
||||
const fullText = qualifiedName.text;
|
||||
const exportedName = fullText.includes('.') ? fullText.split('.').pop()! : fullText;
|
||||
// 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 }];
|
||||
}
|
||||
|
||||
return [{ local: aliasIdent.text, exported: exportedName }];
|
||||
// Form 3: using NS; — namespace import, can't resolve to per-symbol bindings
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractJavaNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
|
||||
export function extractJavaNamedBindings(importNode: SyntaxNode): NamedBinding[] | 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) — skip those
|
||||
// Check for asterisk (wildcard import) and static modifier
|
||||
let isStatic = false;
|
||||
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');
|
||||
@@ -368,17 +386,11 @@ export function extractJavaNamedBindings(importNode: any): { local: string; expo
|
||||
const lastDot = fullText.lastIndexOf('.');
|
||||
if (lastDot === -1) 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;
|
||||
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;
|
||||
|
||||
return [{ local: className, exported: className }];
|
||||
return [{ local: name, exported: name }];
|
||||
}
|
||||
|
||||
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,14 +5,13 @@ 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 } from './utils.js';
|
||||
import { getLanguageFromFilename, yieldToEventLoop, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature, getLabelFromCaptures } 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 } from './workers/parse-worker.js';
|
||||
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings, FileTypeEnvBindings } from './workers/parse-worker.js';
|
||||
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';
|
||||
|
||||
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
|
||||
@@ -24,12 +23,9 @@ 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
|
||||
// ============================================================================
|
||||
@@ -49,7 +45,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: [] };
|
||||
if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], typeEnvBindings: [] };
|
||||
|
||||
const total = files.length;
|
||||
|
||||
@@ -68,6 +64,7 @@ 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({
|
||||
@@ -84,6 +81,8 @@ const processParsingWithWorkers = async (
|
||||
for (const sym of result.symbols) {
|
||||
symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, {
|
||||
parameterCount: sym.parameterCount,
|
||||
requiredParameterCount: sym.requiredParameterCount,
|
||||
parameterTypes: sym.parameterTypes,
|
||||
returnType: sym.returnType,
|
||||
declaredType: sym.declaredType,
|
||||
ownerId: sym.ownerId,
|
||||
@@ -96,6 +95,7 @@ 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 +114,7 @@ const processParsingWithWorkers = async (
|
||||
|
||||
// Final progress
|
||||
onFileProgress?.(total, total, 'done');
|
||||
return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings };
|
||||
return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings, typeEnvBindings: allTypeEnvBindings };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -191,59 +191,14 @@ const processParsingSequential = async (
|
||||
captureMap[c.name] = c.node;
|
||||
});
|
||||
|
||||
if (captureMap['import']) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (captureMap['call']) {
|
||||
return;
|
||||
}
|
||||
const nodeLabel = getLabelFromCaptures(captureMap, language);
|
||||
if (!nodeLabel) return;
|
||||
|
||||
const nameNode = captureMap['name'];
|
||||
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
|
||||
if (!nameNode && !captureMap['definition.constructor']) return;
|
||||
if (!nameNode && nodeLabel !== '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}`);
|
||||
@@ -284,6 +239,8 @@ const processParsingSequential = async (
|
||||
} : {}),
|
||||
...(methodSig ? {
|
||||
parameterCount: methodSig.parameterCount,
|
||||
...(methodSig.requiredParameterCount !== undefined ? { requiredParameterCount: methodSig.requiredParameterCount } : {}),
|
||||
...(methodSig.parameterTypes ? { parameterTypes: methodSig.parameterTypes } : {}),
|
||||
returnType: methodSig.returnType,
|
||||
} : {}),
|
||||
},
|
||||
@@ -303,6 +260,8 @@ const processParsingSequential = async (
|
||||
|
||||
symbolTable.add(file.path, nodeName, nodeId, nodeLabel, {
|
||||
parameterCount: methodSig?.parameterCount,
|
||||
requiredParameterCount: methodSig?.requiredParameterCount,
|
||||
parameterTypes: methodSig?.parameterTypes,
|
||||
returnType: methodSig?.returnType,
|
||||
declaredType,
|
||||
ownerId: enclosingClassId ?? undefined,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { createKnowledgeGraph } from '../graph/graph.js';
|
||||
import { processStructure } from './structure-processor.js';
|
||||
import { processMarkdown } from './markdown-processor.js';
|
||||
import { processParsing } from './parsing-processor.js';
|
||||
import {
|
||||
processImports,
|
||||
processImportsFromExtracted,
|
||||
buildImportResolutionContext
|
||||
} from './import-processor.js';
|
||||
import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted } from './call-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 { processHeritage, processHeritageFromExtracted } from './heritage-processor.js';
|
||||
import { computeMRO } from './mro-processor.js';
|
||||
import { processCommunities } from './community-processor.js';
|
||||
@@ -17,6 +19,7 @@ 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';
|
||||
@@ -24,6 +27,62 @@ 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
|
||||
@@ -33,6 +92,295 @@ 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;
|
||||
@@ -47,6 +395,7 @@ 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();
|
||||
@@ -99,6 +448,21 @@ export const runPipelineFromRepo = async (
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
|
||||
// ── Phase 2.5: Markdown processing (headings + cross-links) ────────
|
||||
const mdScanned = scannedFiles.filter(f => f.path.endsWith('.md') || f.path.endsWith('.mdx'));
|
||||
if (mdScanned.length > 0) {
|
||||
const mdContents = await readFileContents(repoPath, mdScanned.map(f => f.path));
|
||||
const mdFiles = mdScanned
|
||||
.filter(f => mdContents.has(f.path))
|
||||
.map(f => ({ path: f.path, content: mdContents.get(f.path)! }));
|
||||
const allPathSet = new Set(allPaths);
|
||||
const mdResult = processMarkdown(graph, mdFiles, allPathSet);
|
||||
if (isDev) {
|
||||
console.log(` Markdown: ${mdResult.sections} sections, ${mdResult.links} cross-links from ${mdFiles.length} files`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 3+4: Chunked read + parse ────────────────────────────────
|
||||
// Group parseable files into byte-budget chunks so only ~20MB of source
|
||||
// is in memory at a time. Each chunk is: read → parse → extract → free.
|
||||
@@ -201,6 +565,17 @@ 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++) {
|
||||
@@ -242,6 +617,24 @@ 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.
|
||||
@@ -294,6 +687,10 @@ 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);
|
||||
@@ -310,13 +707,16 @@ 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);
|
||||
const rubyHeritage = await processCalls(graph, chunkFiles, astCache, ctx, undefined, exportedTypeMap);
|
||||
await processHeritage(graph, chunkFiles, astCache, ctx);
|
||||
if (rubyHeritage.length > 0) {
|
||||
await processHeritageFromExtracted(graph, rubyHeritage, ctx);
|
||||
@@ -332,12 +732,55 @@ 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 as any).suffixIndex = null;
|
||||
(importCtx as any).normalizedFileList = null;
|
||||
importCtx.index = EMPTY_INDEX; // Release suffix index memory (~30MB for large repos)
|
||||
importCtx.normalizedFileList = [];
|
||||
|
||||
let communityResult: Awaited<ReturnType<typeof processCommunities>> | undefined;
|
||||
let processResult: Awaited<ReturnType<typeof processProcesses>> | undefined;
|
||||
|
||||
@@ -39,6 +39,9 @@ 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 {
|
||||
/**
|
||||
@@ -56,6 +59,8 @@ 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;
|
||||
@@ -71,6 +76,7 @@ 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;
|
||||
@@ -81,10 +87,10 @@ export const createResolutionContext = (): ResolutionContext => {
|
||||
// --- Core resolution (single implementation of tier logic) ---
|
||||
|
||||
const resolveUncached = (name: string, fromFile: string): TieredCandidates | null => {
|
||||
// Tier 1: Same file — authoritative match
|
||||
const localDef = symbols.lookupExactFull(fromFile, name);
|
||||
if (localDef) {
|
||||
return { candidates: [localDef], tier: 'same-file' };
|
||||
// Tier 1: Same file — authoritative match (returns all overloads)
|
||||
const localDefs = symbols.lookupExactAll(fromFile, name);
|
||||
if (localDefs.length > 0) {
|
||||
return { candidates: localDefs, tier: 'same-file' };
|
||||
}
|
||||
|
||||
// Get all global definitions for subsequent tiers
|
||||
@@ -173,6 +179,7 @@ export const createResolutionContext = (): ResolutionContext => {
|
||||
importMap.clear();
|
||||
packageMap.clear();
|
||||
namedImportMap.clear();
|
||||
moduleAliasMap.clear();
|
||||
clearCache();
|
||||
cacheHits = 0;
|
||||
cacheMisses = 0;
|
||||
@@ -184,6 +191,7 @@ 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 } from './utils.js';
|
||||
export { EXTENSIONS, tryResolveWithExtensions, buildSuffixIndex, suffixResolve, EMPTY_INDEX } from './utils.js';
|
||||
export type { SuffixIndex } from './utils.js';
|
||||
|
||||
export { KOTLIN_EXTENSIONS, appendKotlinWildcard, resolveJvmWildcard, resolveJvmMemberImport } from './jvm.js';
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
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'];
|
||||
@@ -12,7 +13,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: any): string => {
|
||||
export const appendKotlinWildcard = (importPath: string, importNode: SyntaxNode): string => {
|
||||
for (let i = 0; i < importNode.childCount; i++) {
|
||||
if (importNode.child(i)?.type === 'wildcard_import') {
|
||||
return importPath.endsWith('.*') ? importPath : `${importPath}.*`;
|
||||
@@ -39,26 +40,39 @@ 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, '/');
|
||||
const idx = normalized.indexOf(packageSuffix);
|
||||
if (idx < 0) return false;
|
||||
const afterPkg = normalized.substring(idx + packageSuffix.length);
|
||||
// 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;
|
||||
}
|
||||
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 (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]);
|
||||
}
|
||||
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]);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
|
||||
@@ -10,11 +10,34 @@ 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,
|
||||
@@ -27,20 +50,44 @@ export function resolvePhpImport(
|
||||
// Normalize: replace backslashes with forward slashes
|
||||
const normalized = importPath.replace(/\\/g, '/');
|
||||
|
||||
// Try PSR-4 resolution if composer.json was found
|
||||
// Reject path traversal attempts (defense-in-depth — walker whitelist also prevents this)
|
||||
if (normalized.includes('..')) return null;
|
||||
|
||||
if (composerConfig) {
|
||||
// Sort namespaces by length descending (longest match wins)
|
||||
const sorted = [...composerConfig.psr4.entries()].sort((a, b) => b[0].length - a[0].length);
|
||||
const sorted = getSortedPsr4(composerConfig);
|
||||
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,6 +3,8 @@
|
||||
* 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 = [
|
||||
'',
|
||||
@@ -63,6 +65,15 @@ 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>();
|
||||
@@ -156,3 +167,12 @@ 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;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@ export interface SymbolDefinition {
|
||||
filePath: string;
|
||||
type: NodeLabel;
|
||||
parameterCount?: number;
|
||||
/** Number of required (non-optional, non-default) parameters.
|
||||
* Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */
|
||||
requiredParameterCount?: number;
|
||||
/** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']).
|
||||
* Populated when parameter types are resolvable from AST (any typed language).
|
||||
* Used for disambiguation in overloading languages (Java, Kotlin, C#, C++). */
|
||||
parameterTypes?: string[];
|
||||
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
|
||||
returnType?: string;
|
||||
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
|
||||
@@ -22,9 +29,9 @@ export interface SymbolTable {
|
||||
name: string,
|
||||
nodeId: string,
|
||||
type: NodeLabel,
|
||||
metadata?: { parameterCount?: number; returnType?: string; declaredType?: string; ownerId?: string }
|
||||
metadata?: { parameterCount?: number; requiredParameterCount?: number; parameterTypes?: string[]; returnType?: string; declaredType?: string; ownerId?: string }
|
||||
) => void;
|
||||
|
||||
|
||||
/**
|
||||
* High Confidence: Look for a symbol specifically inside a file
|
||||
* Returns the Node ID if found
|
||||
@@ -34,9 +41,17 @@ export interface SymbolTable {
|
||||
/**
|
||||
* High Confidence: Look for a symbol in a specific file, returning full definition.
|
||||
* Includes type information needed for heritage resolution (Class vs Interface).
|
||||
* Returns first matching definition — use lookupExactAll for overloaded methods.
|
||||
*/
|
||||
lookupExactFull: (filePath: string, name: string) => SymbolDefinition | undefined;
|
||||
|
||||
/**
|
||||
* High Confidence: Look for ALL symbols with this name in a specific file.
|
||||
* Returns all definitions, including overloaded methods with the same name.
|
||||
* Used by resolution-context to pass all same-file overloads to candidate filtering.
|
||||
*/
|
||||
lookupExactAll: (filePath: string, name: string) => SymbolDefinition[];
|
||||
|
||||
/**
|
||||
* Low Confidence: Look for a symbol anywhere in the project
|
||||
* Used when imports are missing or for framework magic
|
||||
@@ -69,9 +84,10 @@ export interface SymbolTable {
|
||||
}
|
||||
|
||||
export const createSymbolTable = (): SymbolTable => {
|
||||
// 1. File-Specific Index — stores full SymbolDefinition for O(1) lookupExactFull
|
||||
// Structure: FilePath -> (SymbolName -> SymbolDefinition)
|
||||
const fileIndex = new Map<string, Map<string, SymbolDefinition>>();
|
||||
// 1. File-Specific Index — stores full SymbolDefinition(s) for O(1) lookup.
|
||||
// Structure: FilePath -> (SymbolName -> SymbolDefinition[])
|
||||
// Array allows overloaded methods (same name, different signatures) to coexist.
|
||||
const fileIndex = new Map<string, Map<string, SymbolDefinition[]>>();
|
||||
|
||||
// 2. Global Reverse Index (The "Backup")
|
||||
// Structure: SymbolName -> [List of Definitions]
|
||||
@@ -93,13 +109,15 @@ export const createSymbolTable = (): SymbolTable => {
|
||||
name: string,
|
||||
nodeId: string,
|
||||
type: NodeLabel,
|
||||
metadata?: { parameterCount?: number; returnType?: string; declaredType?: string; ownerId?: string }
|
||||
metadata?: { parameterCount?: number; requiredParameterCount?: number; parameterTypes?: string[]; returnType?: string; declaredType?: string; ownerId?: string }
|
||||
) => {
|
||||
const def: SymbolDefinition = {
|
||||
nodeId,
|
||||
filePath,
|
||||
type,
|
||||
...(metadata?.parameterCount !== undefined ? { parameterCount: metadata.parameterCount } : {}),
|
||||
...(metadata?.requiredParameterCount !== undefined ? { requiredParameterCount: metadata.requiredParameterCount } : {}),
|
||||
...(metadata?.parameterTypes !== undefined ? { parameterTypes: metadata.parameterTypes } : {}),
|
||||
...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}),
|
||||
...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}),
|
||||
...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}),
|
||||
@@ -109,7 +127,12 @@ export const createSymbolTable = (): SymbolTable => {
|
||||
if (!fileIndex.has(filePath)) {
|
||||
fileIndex.set(filePath, new Map());
|
||||
}
|
||||
fileIndex.get(filePath)!.set(name, def);
|
||||
const fileMap = fileIndex.get(filePath)!;
|
||||
if (!fileMap.has(name)) {
|
||||
fileMap.set(name, [def]);
|
||||
} else {
|
||||
fileMap.get(name)!.push(def);
|
||||
}
|
||||
|
||||
// B. Properties go to fieldByOwner index only — skip globalIndex to prevent
|
||||
// namespace pollution for common names like 'id', 'name', 'type'.
|
||||
@@ -134,11 +157,17 @@ export const createSymbolTable = (): SymbolTable => {
|
||||
};
|
||||
|
||||
const lookupExact = (filePath: string, name: string): string | undefined => {
|
||||
return fileIndex.get(filePath)?.get(name)?.nodeId;
|
||||
const defs = fileIndex.get(filePath)?.get(name);
|
||||
return defs?.[0]?.nodeId;
|
||||
};
|
||||
|
||||
const lookupExactFull = (filePath: string, name: string): SymbolDefinition | undefined => {
|
||||
return fileIndex.get(filePath)?.get(name);
|
||||
const defs = fileIndex.get(filePath)?.get(name);
|
||||
return defs?.[0];
|
||||
};
|
||||
|
||||
const lookupExactAll = (filePath: string, name: string): SymbolDefinition[] => {
|
||||
return fileIndex.get(filePath)?.get(name) ?? [];
|
||||
};
|
||||
|
||||
const lookupFuzzy = (name: string): SymbolDefinition[] => {
|
||||
@@ -173,5 +202,5 @@ export const createSymbolTable = (): SymbolTable => {
|
||||
fieldByOwner.clear();
|
||||
};
|
||||
|
||||
return { add, lookupExact, lookupExactFull, lookupFuzzy, lookupFuzzyCallable, lookupFieldByOwner, getStats, clear };
|
||||
return { add, lookupExact, lookupExactFull, lookupExactAll, lookupFuzzy, lookupFuzzyCallable, lookupFieldByOwner, getStats, clear };
|
||||
};
|
||||
|
||||
@@ -19,6 +19,10 @@ export const TYPESCRIPT_QUERIES = `
|
||||
(function_declaration
|
||||
name: (identifier) @name) @definition.function
|
||||
|
||||
; TypeScript overload signatures (function_signature is a separate node type from function_declaration)
|
||||
(function_signature
|
||||
name: (identifier) @name) @definition.function
|
||||
|
||||
(method_definition
|
||||
name: (property_identifier) @name) @definition.method
|
||||
|
||||
@@ -192,6 +196,12 @@ 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
|
||||
|
||||
@@ -350,6 +360,16 @@ export const GO_QUERIES = `
|
||||
operand: (_) @assignment.receiver
|
||||
field: (field_identifier) @assignment.property))
|
||||
right: (_)) @assignment
|
||||
|
||||
; Write access: obj.field++ / obj.field--
|
||||
(inc_statement
|
||||
(selector_expression
|
||||
operand: (_) @assignment.receiver
|
||||
field: (field_identifier) @assignment.property)) @assignment
|
||||
(dec_statement
|
||||
(selector_expression
|
||||
operand: (_) @assignment.receiver
|
||||
field: (field_identifier) @assignment.property)) @assignment
|
||||
`;
|
||||
|
||||
// C++ queries - works with tree-sitter-cpp
|
||||
@@ -406,8 +426,15 @@ export const CPP_QUERIES = `
|
||||
declarator: (reference_declarator
|
||||
(field_identifier) @name)) @definition.property
|
||||
|
||||
; Inline class method declarations (inside class body, no body: void Foo();)
|
||||
(field_declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.method
|
||||
; 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 definitions (inside class body, with body: void Foo() { ... })
|
||||
(field_declaration_list
|
||||
@@ -415,6 +442,20 @@ export const CPP_QUERIES = `
|
||||
declarator: (function_declarator
|
||||
declarator: [(field_identifier) (identifier) (operator_name) (destructor_name)] @name)) @definition.method)
|
||||
|
||||
; Inline class methods returning a pointer type (User* lookup(int id) { ... })
|
||||
(field_declaration_list
|
||||
(function_definition
|
||||
declarator: (pointer_declarator
|
||||
declarator: (function_declarator
|
||||
declarator: [(field_identifier) (identifier) (operator_name)] @name))) @definition.method)
|
||||
|
||||
; Inline class methods returning a reference type (User& lookup(int id) { ... })
|
||||
(field_declaration_list
|
||||
(function_definition
|
||||
declarator: (reference_declarator
|
||||
(function_declarator
|
||||
declarator: [(field_identifier) (identifier) (operator_name)] @name))) @definition.method)
|
||||
|
||||
; Templates
|
||||
(template_declaration (class_specifier name: (type_identifier) @name)) @definition.template
|
||||
(template_declaration (function_definition declarator: (function_declarator declarator: (identifier) @name))) @definition.template
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SyntaxNode } from './utils.js';
|
||||
import { FUNCTION_NODE_TYPES, extractFunctionName, CLASS_CONTAINER_TYPES, isBuiltInOrNoise } from './utils.js';
|
||||
import { FUNCTION_NODE_TYPES, extractFunctionName, CLASS_CONTAINER_TYPES, CALL_EXPRESSION_TYPES, isBuiltInOrNoise } from './utils.js';
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
import { typeConfigs, TYPED_PARAMETER_TYPES } from './type-extractors/index.js';
|
||||
import type { ClassNameLookup, ReturnTypeLookup, ForLoopExtractorContext, PendingAssignment } from './type-extractors/types.js';
|
||||
@@ -46,6 +46,10 @@ export interface TypeEnvironment {
|
||||
readonly constructorBindings: readonly ConstructorBinding[];
|
||||
/** Raw per-scope type bindings — for testing and debugging. */
|
||||
readonly env: TypeEnv;
|
||||
/** Maps `scope\0varName` → constructor type for virtual dispatch override.
|
||||
* Populated when a variable has BOTH a declared base type AND a more specific
|
||||
* constructor type (e.g., `Animal a = new Dog()` → key maps to 'Dog'). */
|
||||
readonly constructorTypeMap: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,17 +65,22 @@ interface PatternOverride {
|
||||
/** scope → varName → overrides (checked in order, first range match wins) */
|
||||
type PatternOverrides = Map<string, Map<string, PatternOverride[]>>;
|
||||
|
||||
/** AST node types that represent mutually exclusive branch containers for pattern bindings. */
|
||||
const PATTERN_BRANCH_TYPES = new Set([
|
||||
/** AST node types that represent mutually exclusive branch containers for pattern bindings.
|
||||
* Includes both multi-arm pattern-match branches AND if-statement bodies for null-check narrowing. */
|
||||
const NARROWING_BRANCH_TYPES = new Set([
|
||||
'when_entry', // Kotlin when
|
||||
'switch_block_label', // Java switch (enhanced)
|
||||
'if_statement', // TS/JS, Java, C/C++
|
||||
'if_expression', // Kotlin (if is an expression)
|
||||
'statement_block', // TS/JS: { ... } body of if
|
||||
'control_structure_body', // Kotlin: body of if
|
||||
]);
|
||||
|
||||
/** Walk up the AST from a pattern node to find the enclosing branch container. */
|
||||
const findPatternBranchScope = (node: SyntaxNode): SyntaxNode | undefined => {
|
||||
const findNarrowingBranchScope = (node: SyntaxNode): SyntaxNode | undefined => {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
if (PATTERN_BRANCH_TYPES.has(current.type)) return current;
|
||||
if (NARROWING_BRANCH_TYPES.has(current.type)) return current;
|
||||
if (FUNCTION_NODE_TYPES.has(current.type)) return undefined;
|
||||
current = current.parent;
|
||||
}
|
||||
@@ -161,6 +170,23 @@ const findEnclosingClassName = (node: SyntaxNode): string | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Keywords that refer to the current instance across languages. */
|
||||
const THIS_RECEIVERS = new Set(['this', 'self', '$this', 'Me']);
|
||||
|
||||
/**
|
||||
* If a pending assignment's receiver is this/self/$this/Me, substitute the
|
||||
* enclosing class name. Returns the item unchanged for non-receiver kinds
|
||||
* or when the receiver is not a this-keyword. Properties are readonly in the
|
||||
* discriminated union, so a new object is returned when substitution occurs.
|
||||
*/
|
||||
const substituteThisReceiver = (item: PendingAssignment, node: SyntaxNode): PendingAssignment => {
|
||||
if (item.kind !== 'fieldAccess' && item.kind !== 'methodCallResult') return item;
|
||||
if (!THIS_RECEIVERS.has(item.receiver)) return item;
|
||||
const className = findEnclosingClassName(node);
|
||||
if (!className) return item;
|
||||
return { ...item, receiver: className };
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up the AST to find the enclosing class, then extract its parent class name
|
||||
* from the heritage/superclass AST node. Used to resolve `super`/`base`/`parent`.
|
||||
@@ -366,75 +392,339 @@ const SKIP_SUBTREE_TYPES = new Set([
|
||||
|
||||
const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface']);
|
||||
|
||||
/** Memoize class definition lookups during fixpoint iteration.
|
||||
* SymbolTable is immutable during type resolution, so results never change.
|
||||
* Eliminates redundant array allocations + filter scans across iterations. */
|
||||
const createClassDefCache = (symbolTable?: SymbolTable) => {
|
||||
const cache = new Map<string, Array<{ nodeId: string; type: string }>>();
|
||||
return (typeName: string) => {
|
||||
let result = cache.get(typeName);
|
||||
if (result === undefined) {
|
||||
result = symbolTable
|
||||
? symbolTable.lookupFuzzy(typeName).filter(d => CLASS_LIKE_TYPES.has(d.type))
|
||||
: [];
|
||||
cache.set(typeName, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
/** AST node types representing constructor expressions across languages.
|
||||
* Note: C# also has `implicit_object_creation_expression` (`new()` with type
|
||||
* inference) which is NOT captured — the type is inferred, not explicit.
|
||||
* Kotlin constructors use `call_expression` (no `new` keyword) — not detected. */
|
||||
const CONSTRUCTOR_EXPR_TYPES = new Set([
|
||||
'new_expression', // TS/JS/C++: new Dog()
|
||||
'object_creation_expression', // Java/C#: new Dog()
|
||||
]);
|
||||
|
||||
/** Extract the constructor class name from a declaration node's initializer.
|
||||
* Searches for new_expression / object_creation_expression in the node's subtree.
|
||||
* Returns the class name or undefined if no constructor is found.
|
||||
* Depth-limited to 5 to avoid expensive traversals. */
|
||||
const extractConstructorTypeName = (node: SyntaxNode, depth = 0): string | undefined => {
|
||||
if (depth > 5) return undefined;
|
||||
if (CONSTRUCTOR_EXPR_TYPES.has(node.type)) {
|
||||
// Java/C#: object_creation_expression has 'type' field
|
||||
const typeField = node.childForFieldName('type');
|
||||
if (typeField) return extractSimpleTypeName(typeField);
|
||||
// TS/JS: new_expression has 'constructor' field (but tree-sitter often just has identifier child)
|
||||
const ctorField = node.childForFieldName('constructor');
|
||||
if (ctorField) return extractSimpleTypeName(ctorField);
|
||||
// Fallback: first named child is often the class identifier
|
||||
if (node.firstNamedChild) return extractSimpleTypeName(node.firstNamedChild);
|
||||
}
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child) continue;
|
||||
// Don't descend into nested functions/classes or call expressions (prevents
|
||||
// finding constructor args inside method calls, e.g. processAll(new Dog()))
|
||||
if (FUNCTION_NODE_TYPES.has(child.type) || CLASS_CONTAINER_TYPES.has(child.type)
|
||||
|| CALL_EXPRESSION_TYPES.has(child.type)) continue;
|
||||
const result = extractConstructorTypeName(child, depth + 1);
|
||||
if (result) return result;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Max depth for MRO parent chain walking. Real-world inheritance rarely exceeds 3-4 levels. */
|
||||
const MAX_MRO_DEPTH = 5;
|
||||
|
||||
/** Check if `child` is a subclass of `parent` using the parentMap.
|
||||
* BFS up from child, depth-limited (5), cycle-safe. */
|
||||
export const isSubclassOf = (
|
||||
child: string, parent: string,
|
||||
parentMap: ReadonlyMap<string, readonly string[]> | undefined,
|
||||
): boolean => {
|
||||
if (!parentMap || child === parent) return false;
|
||||
const visited = new Set<string>([child]);
|
||||
let current = [child];
|
||||
for (let depth = 0; depth < MAX_MRO_DEPTH && current.length > 0; depth++) {
|
||||
const next: string[] = [];
|
||||
for (const cls of current) {
|
||||
const parents = parentMap.get(cls);
|
||||
if (!parents) continue;
|
||||
for (const p of parents) {
|
||||
if (p === parent) return true;
|
||||
if (!visited.has(p)) { visited.add(p); next.push(p); }
|
||||
}
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/** Walk up the parent class chain to find a field or method on an ancestor.
|
||||
* BFS-like traversal with depth limit and cycle detection. First match wins.
|
||||
* Used by resolveFieldType and resolveMethodReturnType when direct lookup fails. */
|
||||
const walkParentChain = <T>(
|
||||
typeName: string,
|
||||
parentMap: ReadonlyMap<string, readonly string[]> | undefined,
|
||||
getClassDefs: (name: string) => Array<{ nodeId: string; type: string }>,
|
||||
lookupOnClass: (nodeId: string) => T | undefined,
|
||||
): T | undefined => {
|
||||
if (!parentMap) return undefined;
|
||||
const visited = new Set<string>([typeName]);
|
||||
let current = [typeName];
|
||||
for (let depth = 0; depth < MAX_MRO_DEPTH && current.length > 0; depth++) {
|
||||
const next: string[] = [];
|
||||
for (const cls of current) {
|
||||
const parents = parentMap.get(cls);
|
||||
if (!parents) continue;
|
||||
for (const parent of parents) {
|
||||
if (visited.has(parent)) continue;
|
||||
visited.add(parent);
|
||||
const parentDefs = getClassDefs(parent);
|
||||
if (parentDefs.length === 1) {
|
||||
const result = lookupOnClass(parentDefs[0].nodeId);
|
||||
if (result !== undefined) return result;
|
||||
}
|
||||
next.push(parent);
|
||||
}
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Resolve a field's declared type given a receiver variable and field name.
|
||||
* Uses SymbolTable to find the class nodeId for the receiver's type, then
|
||||
* looks up the field via the eagerly-populated fieldByOwner index. */
|
||||
* looks up the field via the eagerly-populated fieldByOwner index.
|
||||
* Falls back to MRO parent chain walking if direct lookup fails (Phase 11A). */
|
||||
const resolveFieldType = (
|
||||
receiver: string, field: string,
|
||||
scopeEnv: ReadonlyMap<string, string>, symbolTable?: SymbolTable,
|
||||
getClassDefs?: (typeName: string) => Array<{ nodeId: string; type: string }>,
|
||||
parentMap?: ReadonlyMap<string, readonly string[]>,
|
||||
): string | undefined => {
|
||||
if (!symbolTable) return undefined;
|
||||
const receiverType = scopeEnv.get(receiver);
|
||||
if (!receiverType) return undefined;
|
||||
const classDefs = symbolTable.lookupFuzzy(receiverType)
|
||||
.filter(d => CLASS_LIKE_TYPES.has(d.type));
|
||||
const lookup = getClassDefs
|
||||
?? ((name: string) => symbolTable.lookupFuzzy(name).filter(d => CLASS_LIKE_TYPES.has(d.type)));
|
||||
const classDefs = lookup(receiverType);
|
||||
if (classDefs.length !== 1) return undefined;
|
||||
// Direct lookup first
|
||||
const fieldDef = symbolTable.lookupFieldByOwner(classDefs[0].nodeId, field);
|
||||
if (!fieldDef?.declaredType) return undefined;
|
||||
return extractReturnTypeName(fieldDef.declaredType);
|
||||
if (fieldDef?.declaredType) return extractReturnTypeName(fieldDef.declaredType);
|
||||
// MRO parent chain walking on miss
|
||||
const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => {
|
||||
const f = symbolTable.lookupFieldByOwner(nodeId, field);
|
||||
return f?.declaredType ? extractReturnTypeName(f.declaredType) : undefined;
|
||||
});
|
||||
return inherited;
|
||||
};
|
||||
|
||||
/** Resolve a method's return type given a receiver variable and method name.
|
||||
* Uses SymbolTable to find class nodeIds for the receiver's type, then
|
||||
* looks up the method via lookupFuzzyCallable filtered by ownerId. */
|
||||
* looks up the method via lookupFuzzyCallable filtered by ownerId.
|
||||
* Falls back to MRO parent chain walking if direct lookup fails (Phase 11A). */
|
||||
const resolveMethodReturnType = (
|
||||
receiver: string, method: string,
|
||||
scopeEnv: ReadonlyMap<string, string>, symbolTable?: SymbolTable,
|
||||
getClassDefs?: (typeName: string) => Array<{ nodeId: string; type: string }>,
|
||||
parentMap?: ReadonlyMap<string, readonly string[]>,
|
||||
): string | undefined => {
|
||||
if (!symbolTable) return undefined;
|
||||
const receiverType = scopeEnv.get(receiver);
|
||||
if (!receiverType) return undefined;
|
||||
const classDefs = symbolTable.lookupFuzzy(receiverType)
|
||||
.filter(d => CLASS_LIKE_TYPES.has(d.type));
|
||||
const lookup = getClassDefs
|
||||
?? ((name: string) => symbolTable.lookupFuzzy(name).filter(d => CLASS_LIKE_TYPES.has(d.type)));
|
||||
const classDefs = lookup(receiverType);
|
||||
if (classDefs.length === 0) return undefined;
|
||||
// Direct lookup first
|
||||
const classNodeIds = new Set(classDefs.map(d => d.nodeId));
|
||||
const methods = symbolTable.lookupFuzzyCallable(method)
|
||||
.filter(d => d.ownerId && classNodeIds.has(d.ownerId));
|
||||
if (methods.length !== 1) return undefined;
|
||||
if (!methods[0].returnType) return undefined;
|
||||
return extractReturnTypeName(methods[0].returnType);
|
||||
if (methods.length === 1 && methods[0].returnType) {
|
||||
return extractReturnTypeName(methods[0].returnType);
|
||||
}
|
||||
// MRO parent chain walking on miss
|
||||
if (methods.length === 0) {
|
||||
const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => {
|
||||
const parentMethods = symbolTable.lookupFuzzyCallable(method)
|
||||
.filter(d => d.ownerId === nodeId);
|
||||
if (parentMethods.length !== 1 || !parentMethods[0].returnType) return undefined;
|
||||
return extractReturnTypeName(parentMethods[0].returnType);
|
||||
});
|
||||
return inherited;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified fixpoint propagation: iterate over ALL pending items (copy, callResult,
|
||||
* fieldAccess, methodCallResult) until no new bindings are produced.
|
||||
* Handles arbitrary-depth mixed chains:
|
||||
* const user = getUser(); // callResult → User
|
||||
* const addr = user.address; // fieldAccess → Address (depends on user)
|
||||
* const city = addr.getCity(); // methodCallResult → City (depends on addr)
|
||||
* const alias = city; // copy → City (depends on city)
|
||||
* Data flow: SymbolTable (immutable) + scopeEnv → resolve → scopeEnv.
|
||||
* Termination: finite entries, each bound at most once (first-writer-wins), max 10 iterations.
|
||||
*/
|
||||
const MAX_FIXPOINT_ITERATIONS = 10;
|
||||
|
||||
const resolveFixpointBindings = (
|
||||
pendingItems: Array<{ scope: string } & PendingAssignment>,
|
||||
env: TypeEnv,
|
||||
returnTypeLookup: ReturnTypeLookup,
|
||||
symbolTable?: SymbolTable,
|
||||
parentMap?: ReadonlyMap<string, readonly string[]>,
|
||||
): void => {
|
||||
if (pendingItems.length === 0) return;
|
||||
const getClassDefs = createClassDefCache(symbolTable);
|
||||
const resolved = new Set<number>();
|
||||
for (let iter = 0; iter < MAX_FIXPOINT_ITERATIONS; iter++) {
|
||||
let changed = false;
|
||||
for (let i = 0; i < pendingItems.length; i++) {
|
||||
if (resolved.has(i)) continue;
|
||||
const item = pendingItems[i];
|
||||
const scopeEnv = env.get(item.scope);
|
||||
if (!scopeEnv || scopeEnv.has(item.lhs)) { resolved.add(i); continue; }
|
||||
|
||||
let typeName: string | undefined;
|
||||
switch (item.kind) {
|
||||
case 'callResult':
|
||||
typeName = returnTypeLookup.lookupReturnType(item.callee);
|
||||
break;
|
||||
case 'copy':
|
||||
typeName = scopeEnv.get(item.rhs) ?? env.get(FILE_SCOPE)?.get(item.rhs);
|
||||
break;
|
||||
case 'fieldAccess':
|
||||
typeName = resolveFieldType(item.receiver, item.field, scopeEnv, symbolTable, getClassDefs, parentMap);
|
||||
break;
|
||||
case 'methodCallResult':
|
||||
typeName = resolveMethodReturnType(item.receiver, item.method, scopeEnv, symbolTable, getClassDefs, parentMap);
|
||||
break;
|
||||
default: {
|
||||
// Exhaustive check: TypeScript will error here if a new PendingAssignment
|
||||
// kind is added without handling it in the switch.
|
||||
const _exhaustive: never = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (typeName) {
|
||||
scopeEnv.set(item.lhs, typeName);
|
||||
resolved.add(i);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
if (iter === MAX_FIXPOINT_ITERATIONS - 1 && process.env.GITNEXUS_DEBUG) {
|
||||
const unresolved = pendingItems.length - resolved.size;
|
||||
if (unresolved > 0) {
|
||||
console.warn(`[type-env] fixpoint hit iteration cap (${MAX_FIXPOINT_ITERATIONS}), ${unresolved} items unresolved`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for buildTypeEnv.
|
||||
* Uses an options object to allow future extensions without positional parameter sprawl.
|
||||
*/
|
||||
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 = (
|
||||
tree: { rootNode: SyntaxNode },
|
||||
language: SupportedLanguages,
|
||||
symbolTable?: SymbolTable,
|
||||
options?: BuildTypeEnvOptions,
|
||||
): TypeEnvironment => {
|
||||
const symbolTable = options?.symbolTable;
|
||||
const parentMap = options?.parentMap;
|
||||
const env: TypeEnv = new Map();
|
||||
const patternOverrides: PatternOverrides = new Map();
|
||||
// Phase P: maps `scope\0varName` → constructor type when a declaration has BOTH
|
||||
// a base type annotation AND a more specific constructor initializer.
|
||||
// e.g., `Animal a = new Dog()` → constructorTypeMap.set('func@42\0a', 'Dog')
|
||||
const constructorTypeMap = new Map<string, string>();
|
||||
const localClassNames = new Set<string>();
|
||||
const classNames = createClassNameLookup(localClassNames, symbolTable);
|
||||
const config = typeConfigs[language];
|
||||
const bindings: ConstructorBinding[] = [];
|
||||
|
||||
// Build ReturnTypeLookup from optional SymbolTable.
|
||||
// Conservative: returns undefined when callee is ambiguous (0 or 2+ matches).
|
||||
// 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).
|
||||
const returnTypeLookup: ReturnTypeLookup = {
|
||||
lookupReturnType(callee: string): string | undefined {
|
||||
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);
|
||||
// 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);
|
||||
},
|
||||
lookupRawReturnType(callee: string): string | undefined {
|
||||
if (!symbolTable) return undefined;
|
||||
if (isBuiltInOrNoise(callee)) return undefined;
|
||||
const callables = symbolTable.lookupFuzzyCallable(callee);
|
||||
if (callables.length !== 1) return undefined;
|
||||
return callables[0].returnType;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -448,6 +738,9 @@ export const buildTypeEnv = (
|
||||
// methodCallResult items during walk(), then iterates until no new bindings are produced.
|
||||
// Handles arbitrary-depth mixed chains: callResult → fieldAccess → methodCallResult → copy.
|
||||
const pendingItems: Array<{ scope: string } & PendingAssignment> = [];
|
||||
// For-loop nodes whose iterable was unresolved at walk-time. Replayed after the fixpoint
|
||||
// resolves the iterable's type, bridging the walk-time/fixpoint gap (Phase 10 / ex-9B).
|
||||
const pendingForLoops: Array<{ node: SyntaxNode; scope: string }> = [];
|
||||
// Maps `scope\0varName` → the type annotation AST node from the original declaration.
|
||||
// Allows pattern extractors to navigate back to the declaration's generic type arguments
|
||||
// (e.g., to extract T from Result<T, E> for `if let Ok(x) = res`).
|
||||
@@ -498,7 +791,8 @@ export const buildTypeEnv = (
|
||||
fallbackName = child;
|
||||
}
|
||||
if (!fallbackType && (child.type === 'user_type' || child.type === 'type_identifier'
|
||||
|| child.type === 'generic_type' || child.type === 'parameterized_type')) {
|
||||
|| child.type === 'generic_type' || child.type === 'parameterized_type'
|
||||
|| child.type === 'nullable_type')) {
|
||||
fallbackType = child;
|
||||
}
|
||||
}
|
||||
@@ -516,8 +810,14 @@ export const buildTypeEnv = (
|
||||
// Checked before declarationNodeTypes — loop variables are not declarations.
|
||||
if (config.forLoopNodeTypes?.has(node.type)) {
|
||||
if (config.extractForLoopBinding) {
|
||||
const sizeBefore = scopeEnv.size;
|
||||
const forLoopCtx: ForLoopExtractorContext = { scopeEnv, declarationTypeNodes, scope, returnTypeLookup };
|
||||
config.extractForLoopBinding(node, forLoopCtx);
|
||||
// If no new binding was produced, the iterable's type may not yet be resolved.
|
||||
// Store for post-fixpoint replay (Phase 10 / ex-9B loop-fixpoint bridge).
|
||||
if (scopeEnv.size === sizeBefore) {
|
||||
pendingForLoops.push({ node, scope });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -540,7 +840,20 @@ export const buildTypeEnv = (
|
||||
if (c?.type === 'variable_declaration') { wrapped = c; break; }
|
||||
}
|
||||
}
|
||||
if (wrapped) typeNode = wrapped.childForFieldName('type');
|
||||
if (wrapped) {
|
||||
typeNode = wrapped.childForFieldName('type');
|
||||
// Kotlin: variable_declaration stores the type as user_type / nullable_type
|
||||
// child rather than a named 'type' field.
|
||||
if (!typeNode) {
|
||||
for (let i = 0; i < wrapped.namedChildCount; i++) {
|
||||
const c = wrapped.namedChild(i);
|
||||
if (c && (c.type === 'user_type' || c.type === 'nullable_type')) {
|
||||
typeNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeNode) {
|
||||
const nameNode = node.childForFieldName('name')
|
||||
@@ -554,13 +867,17 @@ export const buildTypeEnv = (
|
||||
}
|
||||
}
|
||||
// Run the language-specific declaration extractor (may or may not add to scopeEnv).
|
||||
const keysBefore = typeNode ? new Set(scopeEnv.keys()) : undefined;
|
||||
const sizeBefore = typeNode ? scopeEnv.size : -1;
|
||||
config.extractDeclaration(node, scopeEnv);
|
||||
// Fallback: for multi-declarator languages (TS, C#, Java) where the type field
|
||||
// is on variable_declarator children, capture via keysBefore/keysAfter diff.
|
||||
if (typeNode && keysBefore) {
|
||||
// is on variable_declarator children, capture newly-added keys.
|
||||
// Map preserves insertion order, so new keys are always at the end —
|
||||
// skip the first sizeBefore entries to find only newly-added variables.
|
||||
if (sizeBefore >= 0 && scopeEnv.size > sizeBefore) {
|
||||
let skip = sizeBefore;
|
||||
for (const varName of scopeEnv.keys()) {
|
||||
if (!keysBefore.has(varName) && !declarationTypeNodes.has(`${scope}\0${varName}`)) {
|
||||
if (skip > 0) { skip--; continue; }
|
||||
if (!declarationTypeNodes.has(`${scope}\0${varName}`)) {
|
||||
declarationTypeNodes.set(`${scope}\0${varName}`, typeNode);
|
||||
}
|
||||
}
|
||||
@@ -572,6 +889,31 @@ export const buildTypeEnv = (
|
||||
if (config.extractInitializer) {
|
||||
config.extractInitializer(node, scopeEnv, classNames);
|
||||
}
|
||||
|
||||
// Phase P: detect constructor-visible virtual dispatch.
|
||||
// When a declaration has BOTH a type annotation AND a constructor initializer,
|
||||
// record the constructor type for receiver override at call resolution time.
|
||||
// e.g., `Animal a = new Dog()` → constructorTypeMap.set('scope\0a', 'Dog')
|
||||
if (sizeBefore >= 0 && scopeEnv.size > sizeBefore) {
|
||||
let ctorSkip = sizeBefore;
|
||||
for (const varName of scopeEnv.keys()) {
|
||||
if (ctorSkip > 0) { ctorSkip--; continue; }
|
||||
const declaredType = scopeEnv.get(varName);
|
||||
if (!declaredType) continue;
|
||||
const ctorType = extractConstructorTypeName(node)
|
||||
?? config.detectConstructorType?.(node, classNames);
|
||||
if (!ctorType || ctorType === declaredType) continue;
|
||||
// Unwrap wrapper types (e.g., C++ shared_ptr<Animal> → Animal) for an
|
||||
// accurate isSubclassOf comparison. Language-specific via config hook.
|
||||
const declTypeNode = declarationTypeNodes.get(`${scope}\0${varName}`);
|
||||
const effectiveDeclaredType = (declTypeNode && config.unwrapDeclaredType)
|
||||
? (config.unwrapDeclaredType(declaredType, declTypeNode) ?? declaredType)
|
||||
: declaredType;
|
||||
if (ctorType !== effectiveDeclaredType) {
|
||||
constructorTypeMap.set(`${scope}\0${varName}`, ctorType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -605,7 +947,8 @@ export const buildTypeEnv = (
|
||||
}
|
||||
|
||||
// Pattern binding extraction: handles constructs that introduce NEW typed variables
|
||||
// via pattern matching (e.g. `if let Some(x) = opt`, `x instanceof T t`).
|
||||
// via pattern matching (e.g. `if let Some(x) = opt`, `x instanceof T t`)
|
||||
// or narrow existing variables within a branch (null-check narrowing).
|
||||
// Runs after Tier 0/1 so scopeEnv already contains the source variable's type.
|
||||
// Conservative: extractor returns undefined when source type is unknown.
|
||||
if (config.extractPatternBinding && (!config.patternBindingNodeTypes || config.patternBindingNodeTypes.has(node.type))) {
|
||||
@@ -614,11 +957,22 @@ export const buildTypeEnv = (
|
||||
const scopeEnv = env.get(scope)!;
|
||||
const patternBinding = config.extractPatternBinding(node, scopeEnv, declarationTypeNodes, scope);
|
||||
if (patternBinding) {
|
||||
if (config.allowPatternBindingOverwrite) {
|
||||
if (patternBinding.narrowingRange) {
|
||||
// Explicit narrowing range (null-check narrowing): always store in patternOverrides
|
||||
// using the extractor-provided range (typically the if-body block).
|
||||
if (!patternOverrides.has(scope)) patternOverrides.set(scope, new Map());
|
||||
const varMap = patternOverrides.get(scope)!;
|
||||
if (!varMap.has(patternBinding.varName)) varMap.set(patternBinding.varName, []);
|
||||
varMap.get(patternBinding.varName)!.push({
|
||||
rangeStart: patternBinding.narrowingRange.startIndex,
|
||||
rangeEnd: patternBinding.narrowingRange.endIndex,
|
||||
typeName: patternBinding.typeName,
|
||||
});
|
||||
} else if (config.allowPatternBindingOverwrite) {
|
||||
// Position-indexed: store per-branch binding for smart-cast narrowing.
|
||||
// Each when arm / switch case gets its own type for the variable,
|
||||
// preventing cross-arm contamination (e.g., Kotlin when/is).
|
||||
const branchNode = findPatternBranchScope(node);
|
||||
const branchNode = findNarrowingBranchScope(node);
|
||||
if (branchNode) {
|
||||
if (!patternOverrides.has(scope)) patternOverrides.set(scope, new Map());
|
||||
const varMap = patternOverrides.get(scope)!;
|
||||
@@ -643,6 +997,7 @@ export const buildTypeEnv = (
|
||||
// Delegates to per-language extractPendingAssignment — AST shapes differ widely
|
||||
// (JS uses variable_declarator/name/value, Rust uses let_declaration/pattern/value,
|
||||
// Python uses assignment/left/right, Go uses short_var_declaration/expression_list).
|
||||
// May return a single item or an array (for destructuring: N fieldAccess items).
|
||||
if (config.extractPendingAssignment && config.declarationNodeTypes.has(node.type)) {
|
||||
// scopeEnv is guaranteed to exist here because declarationNodeTypes is a subset
|
||||
// of interestingNodeTypes, so extractTypeBinding already created the scope map above.
|
||||
@@ -650,7 +1005,12 @@ export const buildTypeEnv = (
|
||||
if (scopeEnv) {
|
||||
const pending = config.extractPendingAssignment(node, scopeEnv);
|
||||
if (pending) {
|
||||
pendingItems.push({ scope, ...pending });
|
||||
const items = Array.isArray(pending) ? pending : [pending];
|
||||
for (const item of items) {
|
||||
// Substitute this/self/$this/Me receivers with enclosing class name
|
||||
const resolved = substituteThisReceiver(item, node);
|
||||
pendingItems.push({ scope, ...resolved });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -676,53 +1036,43 @@ export const buildTypeEnv = (
|
||||
|
||||
walk(tree.rootNode, FILE_SCOPE);
|
||||
|
||||
// Unified fixpoint propagation: iterate over ALL pending items (copy, callResult,
|
||||
// fieldAccess, methodCallResult) until no new bindings are produced.
|
||||
// Handles arbitrary-depth mixed chains:
|
||||
// const user = getUser(); // callResult → User
|
||||
// const addr = user.address; // fieldAccess → Address (depends on user)
|
||||
// const city = addr.getCity(); // methodCallResult → City (depends on addr)
|
||||
// const alias = city; // copy → City (depends on city)
|
||||
// Data flow: SymbolTable (immutable) + scopeEnv → resolve → scopeEnv.
|
||||
// Termination: finite entries, each bound at most once (first-writer-wins), max 10 iterations.
|
||||
const MAX_FIXPOINT_ITERATIONS = 10;
|
||||
const resolved = new Set<number>();
|
||||
for (let iter = 0; iter < MAX_FIXPOINT_ITERATIONS; iter++) {
|
||||
let changed = false;
|
||||
for (let i = 0; i < pendingItems.length; i++) {
|
||||
if (resolved.has(i)) continue;
|
||||
const item = pendingItems[i];
|
||||
const scopeEnv = env.get(item.scope);
|
||||
if (!scopeEnv || scopeEnv.has(item.lhs)) { resolved.add(i); continue; }
|
||||
// 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);
|
||||
}
|
||||
|
||||
let typeName: string | undefined;
|
||||
switch (item.kind) {
|
||||
case 'callResult':
|
||||
typeName = returnTypeLookup.lookupReturnType(item.callee);
|
||||
break;
|
||||
case 'copy':
|
||||
typeName = scopeEnv.get(item.rhs) ?? env.get(FILE_SCOPE)?.get(item.rhs);
|
||||
break;
|
||||
case 'fieldAccess':
|
||||
typeName = resolveFieldType(item.receiver, item.field, scopeEnv, symbolTable);
|
||||
break;
|
||||
case 'methodCallResult':
|
||||
typeName = resolveMethodReturnType(item.receiver, item.method, scopeEnv, symbolTable);
|
||||
break;
|
||||
}
|
||||
if (typeName) {
|
||||
scopeEnv.set(item.lhs, typeName);
|
||||
resolved.add(i);
|
||||
changed = true;
|
||||
}
|
||||
resolveFixpointBindings(pendingItems, env, returnTypeLookup, symbolTable, parentMap);
|
||||
|
||||
// Post-fixpoint for-loop replay (Phase 10 / ex-9B loop-fixpoint bridge):
|
||||
// For-loop nodes whose iterables were unresolved at walk-time may now be
|
||||
// resolvable because the fixpoint bound the iterable's type.
|
||||
// Example: `const users = getUsers(); for (const u of users) { u.save(); }`
|
||||
// - walk-time: users untyped → u unresolved
|
||||
// - fixpoint: users → User[]
|
||||
// - replay: users now typed → u → User
|
||||
if (pendingForLoops.length > 0 && config.extractForLoopBinding) {
|
||||
for (const { node, scope } of pendingForLoops) {
|
||||
if (!env.has(scope)) env.set(scope, new Map());
|
||||
const scopeEnv = env.get(scope)!;
|
||||
config.extractForLoopBinding(node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup });
|
||||
}
|
||||
// Re-run the main fixpoint to resolve items that depended on loop variables.
|
||||
// Only needed if replay actually produced new bindings.
|
||||
const unresolvedBefore = pendingItems.filter((item) => {
|
||||
const scopeEnv = env.get(item.scope);
|
||||
return scopeEnv && !scopeEnv.has(item.lhs);
|
||||
});
|
||||
if (unresolvedBefore.length > 0) {
|
||||
resolveFixpointBindings(unresolvedBefore, env, returnTypeLookup, symbolTable);
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
|
||||
return {
|
||||
lookup: (varName, callNode) => lookupInEnv(env, varName, callNode, patternOverrides),
|
||||
constructorBindings: bindings,
|
||||
env,
|
||||
constructorTypeMap,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, ForLoopExtractor } from './types.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, ForLoopExtractor, LiteralTypeInferrer, ConstructorTypeDetector, DeclaredTypeUnwrapper } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
|
||||
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'declaration',
|
||||
]);
|
||||
|
||||
/** Smart pointer factory function names that create a typed object. */
|
||||
const SMART_PTR_FACTORIES = new Set([
|
||||
'make_shared', 'make_unique', 'make_shared_for_overwrite',
|
||||
]);
|
||||
|
||||
/** Smart pointer wrapper type names. When the declared type is a smart pointer,
|
||||
* the inner template type is extracted for virtual dispatch comparison. */
|
||||
const SMART_PTR_WRAPPERS = new Set(['shared_ptr', 'unique_ptr', 'weak_ptr']);
|
||||
|
||||
/** Extract the first type name from a template_argument_list child.
|
||||
* Unwraps type_descriptor wrappers common in tree-sitter-cpp ASTs.
|
||||
* Returns undefined if no template arguments or no type found. */
|
||||
export const extractFirstTemplateTypeArg = (parentNode: SyntaxNode): string | undefined => {
|
||||
const templateArgs = parentNode.children.find((c: any) => c.type === 'template_argument_list');
|
||||
if (!templateArgs?.firstNamedChild) return undefined;
|
||||
let argNode: any = templateArgs.firstNamedChild;
|
||||
if (argNode.type === 'type_descriptor') {
|
||||
const inner = argNode.childForFieldName('type');
|
||||
if (inner) argNode = inner;
|
||||
}
|
||||
return extractSimpleTypeName(argNode) ?? undefined;
|
||||
};
|
||||
|
||||
/** C++: Type x = ...; Type* x; Type& x; */
|
||||
const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
|
||||
const typeNode = node.childForFieldName('type');
|
||||
@@ -88,6 +111,27 @@ const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<str
|
||||
} else if (func.type === 'identifier') {
|
||||
const text = func.text;
|
||||
if (text && classNames.has(text)) env.set(varName, text);
|
||||
} else {
|
||||
// auto x = std::make_shared<Dog>() — smart pointer factory via template_function.
|
||||
// AST: call_expression > function: qualified_identifier > template_function
|
||||
// or: call_expression > function: template_function (unqualified)
|
||||
const templateFunc = func.type === 'template_function'
|
||||
? func
|
||||
: (func.type === 'qualified_identifier' || func.type === 'scoped_identifier')
|
||||
? func.namedChildren.find((c: any) => c.type === 'template_function') ?? null
|
||||
: null;
|
||||
if (templateFunc) {
|
||||
const nameNode = templateFunc.firstNamedChild;
|
||||
if (nameNode) {
|
||||
const funcName = (nameNode.type === 'qualified_identifier' || nameNode.type === 'scoped_identifier')
|
||||
? nameNode.lastNamedChild?.text ?? ''
|
||||
: nameNode.text;
|
||||
if (SMART_PTR_FACTORIES.has(funcName)) {
|
||||
const typeName = extractFirstTemplateTypeArg(templateFunc);
|
||||
if (typeName) env.set(varName, typeName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -371,6 +415,72 @@ const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTy
|
||||
if (elementType) scopeEnv.set(varName, elementType);
|
||||
};
|
||||
|
||||
/** Infer the type of a literal AST node for C++ overload disambiguation. */
|
||||
const inferLiteralType: LiteralTypeInferrer = (node) => {
|
||||
switch (node.type) {
|
||||
case 'number_literal': {
|
||||
const t = node.text;
|
||||
// Float suffixes
|
||||
if (t.endsWith('f') || t.endsWith('F')) return 'float';
|
||||
if (t.includes('.') || t.includes('e') || t.includes('E')) return 'double';
|
||||
// Long suffix
|
||||
if (t.endsWith('L') || t.endsWith('l') || t.endsWith('LL') || t.endsWith('ll')) return 'long';
|
||||
return 'int';
|
||||
}
|
||||
case 'string_literal':
|
||||
case 'raw_string_literal':
|
||||
case 'concatenated_string':
|
||||
return 'string';
|
||||
case 'char_literal':
|
||||
return 'char';
|
||||
case 'true':
|
||||
case 'false':
|
||||
return 'bool';
|
||||
case 'null':
|
||||
case 'nullptr':
|
||||
return 'null';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** C++: detect constructor type from smart pointer factory calls (make_shared<Dog>()).
|
||||
* Extracts the template type argument as the constructor type for virtual dispatch. */
|
||||
const detectCppConstructorType: ConstructorTypeDetector = (node, classNames) => {
|
||||
// Navigate to the initializer value in the declaration
|
||||
const declarator = node.childForFieldName('declarator');
|
||||
const initDecl = declarator?.type === 'init_declarator' ? declarator : undefined;
|
||||
if (!initDecl) return undefined;
|
||||
const value = initDecl.childForFieldName('value');
|
||||
if (!value || value.type !== 'call_expression') return undefined;
|
||||
|
||||
// Check for template_function pattern: make_shared<Dog>()
|
||||
const func = value.childForFieldName('function');
|
||||
if (!func || func.type !== 'template_function') return undefined;
|
||||
|
||||
// Extract function name (possibly qualified: std::make_shared)
|
||||
const nameNode = func.firstNamedChild;
|
||||
if (!nameNode) return undefined;
|
||||
let funcName: string;
|
||||
if (nameNode.type === 'qualified_identifier' || nameNode.type === 'scoped_identifier') {
|
||||
funcName = nameNode.lastNamedChild?.text ?? '';
|
||||
} else {
|
||||
funcName = nameNode.text;
|
||||
}
|
||||
if (!SMART_PTR_FACTORIES.has(funcName)) return undefined;
|
||||
|
||||
// Extract template type argument
|
||||
return extractFirstTemplateTypeArg(func);
|
||||
};
|
||||
|
||||
/** Unwrap a C++ smart pointer declared type to its inner template type.
|
||||
* E.g., shared_ptr<Animal> → Animal. Returns the original name if not a smart pointer. */
|
||||
const unwrapCppDeclaredType: DeclaredTypeUnwrapper = (declaredType, typeNode) => {
|
||||
if (!SMART_PTR_WRAPPERS.has(declaredType)) return declaredType;
|
||||
if (typeNode.type !== 'template_type') return declaredType;
|
||||
return extractFirstTemplateTypeArg(typeNode) ?? declaredType;
|
||||
};
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: DECLARATION_NODE_TYPES,
|
||||
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
|
||||
@@ -380,4 +490,7 @@ export const typeConfig: LanguageTypeConfig = {
|
||||
scanConstructorBinding,
|
||||
extractForLoopBinding,
|
||||
extractPendingAssignment,
|
||||
inferLiteralType,
|
||||
detectConstructorType: detectCppConstructorType,
|
||||
unwrapDeclaredType: unwrapCppDeclaredType,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.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';
|
||||
|
||||
/** 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']);
|
||||
@@ -50,8 +51,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 = findChildByType(declarators[0], 'object_creation_expression')
|
||||
?? findChildByType(declarators[0], 'equals_value_clause')?.firstNamedChild;
|
||||
const initializer = findChild(declarators[0], 'object_creation_expression')
|
||||
?? findChild(declarators[0], 'equals_value_clause')?.firstNamedChild;
|
||||
if (initializer?.type === 'object_creation_expression') {
|
||||
const ctorType = initializer.childForFieldName('type');
|
||||
if (ctorType) typeName = extractSimpleTypeName(ctorType);
|
||||
@@ -142,7 +143,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 = findChildByType(typeNode, 'type_argument_list');
|
||||
const argList = findChild(typeNode, 'type_argument_list');
|
||||
if (argList && argList.namedChildCount >= 1) {
|
||||
if (pos === 'first') {
|
||||
const firstArg = argList.namedChild(0);
|
||||
@@ -278,18 +279,82 @@ const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTy
|
||||
* declaration_pattern, or when the type/name cannot be extracted.
|
||||
* No scopeEnv lookup is needed — the pattern explicitly declares the new variable's type.
|
||||
*/
|
||||
const extractPatternBinding: PatternBindingExtractor = (node) => {
|
||||
/**
|
||||
* Find the if-body (consequence) block for a C# null-check.
|
||||
* Walks up from the expression to find the enclosing if_statement,
|
||||
* then returns its first block child (the truthy branch body).
|
||||
*/
|
||||
const findCSharpIfConsequenceBlock = (expr: SyntaxNode): SyntaxNode | undefined => {
|
||||
let current = expr.parent;
|
||||
while (current) {
|
||||
if (current.type === 'if_statement') {
|
||||
// C# if_statement consequence is the 'consequence' field or first block child
|
||||
const consequence = current.childForFieldName('consequence');
|
||||
if (consequence) return consequence;
|
||||
for (let i = 0; i < current.childCount; i++) {
|
||||
const child = current.child(i);
|
||||
if (child?.type === 'block') return child;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (current.type === 'block' || current.type === 'method_declaration'
|
||||
|| current.type === 'constructor_declaration' || current.type === 'local_function_statement'
|
||||
|| current.type === 'lambda_expression') return undefined;
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Check if a C# declaration type node represents a nullable type.
|
||||
* Checks for nullable_type AST node or '?' in the type text (e.g., User?). */
|
||||
const isCSharpNullableDecl = (declTypeNode: SyntaxNode): boolean => {
|
||||
if (declTypeNode.type === 'nullable_type') return true;
|
||||
return declTypeNode.text.includes('?');
|
||||
};
|
||||
|
||||
const extractPatternBinding: PatternBindingExtractor = (node, scopeEnv, declarationTypeNodes, scope) => {
|
||||
// is_pattern_expression: `obj is User user` — has a declaration_pattern child
|
||||
// Also handles `x is not null` for null-check narrowing
|
||||
if (node.type === 'is_pattern_expression') {
|
||||
const pattern = node.childForFieldName('pattern');
|
||||
if (pattern?.type !== 'declaration_pattern' && pattern?.type !== 'recursive_pattern') return undefined;
|
||||
const typeNode = pattern.childForFieldName('type');
|
||||
const nameNode = pattern.childForFieldName('name');
|
||||
if (!typeNode || !nameNode) return undefined;
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
const varName = extractVarName(nameNode);
|
||||
if (!typeName || !varName) return undefined;
|
||||
return { varName, typeName };
|
||||
if (!pattern) return undefined;
|
||||
|
||||
// Standard type pattern: `obj is User user`
|
||||
if (pattern.type === 'declaration_pattern' || pattern.type === 'recursive_pattern') {
|
||||
const typeNode = pattern.childForFieldName('type');
|
||||
const nameNode = pattern.childForFieldName('name');
|
||||
if (!typeNode || !nameNode) return undefined;
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
const varName = extractVarName(nameNode);
|
||||
if (!typeName || !varName) return undefined;
|
||||
return { varName, typeName };
|
||||
}
|
||||
|
||||
// Null-check: `x is not null` — negated_pattern > constant_pattern > null_literal
|
||||
if (pattern.type === 'negated_pattern') {
|
||||
const inner = pattern.firstNamedChild;
|
||||
if (inner?.type === 'constant_pattern') {
|
||||
const literal = inner.firstNamedChild ?? inner.firstChild;
|
||||
if (literal?.type === 'null_literal' || literal?.text === 'null') {
|
||||
const expr = node.childForFieldName('expression');
|
||||
if (!expr || expr.type !== 'identifier') return undefined;
|
||||
const varName = expr.text;
|
||||
const resolvedType = scopeEnv.get(varName);
|
||||
if (!resolvedType) return undefined;
|
||||
// Verify the original declaration was nullable
|
||||
const declTypeNode = declarationTypeNodes.get(`${scope}\0${varName}`);
|
||||
if (!declTypeNode || !isCSharpNullableDecl(declTypeNode)) return undefined;
|
||||
const ifBody = findCSharpIfConsequenceBlock(node);
|
||||
if (!ifBody) return undefined;
|
||||
return {
|
||||
varName,
|
||||
typeName: resolvedType,
|
||||
narrowingRange: { startIndex: ifBody.startIndex, endIndex: ifBody.endIndex },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
// declaration_pattern / recursive_pattern: standalone in switch statements and switch expressions
|
||||
// `case User u:` or `User u =>` or `User { Name: "Alice" } u =>`
|
||||
@@ -303,6 +368,34 @@ const extractPatternBinding: PatternBindingExtractor = (node) => {
|
||||
if (!typeName || !varName) return undefined;
|
||||
return { varName, typeName };
|
||||
}
|
||||
// Null-check: `x != null` — binary_expression with != operator
|
||||
if (node.type === 'binary_expression') {
|
||||
const op = node.children.find(c => !c.isNamed && c.text === '!=');
|
||||
if (!op) return undefined;
|
||||
const left = node.namedChild(0);
|
||||
const right = node.namedChild(1);
|
||||
if (!left || !right) return undefined;
|
||||
let varNode: SyntaxNode | undefined;
|
||||
if (left.type === 'identifier' && (right.type === 'null_literal' || right.text === 'null')) {
|
||||
varNode = left;
|
||||
} else if (right.type === 'identifier' && (left.type === 'null_literal' || left.text === 'null')) {
|
||||
varNode = right;
|
||||
}
|
||||
if (!varNode) return undefined;
|
||||
const varName = varNode.text;
|
||||
const resolvedType = scopeEnv.get(varName);
|
||||
if (!resolvedType) return undefined;
|
||||
// Verify the original declaration was nullable
|
||||
const declTypeNode = declarationTypeNodes.get(`${scope}\0${varName}`);
|
||||
if (!declTypeNode || !isCSharpNullableDecl(declTypeNode)) return undefined;
|
||||
const ifBody = findCSharpIfConsequenceBlock(node);
|
||||
if (!ifBody) return undefined;
|
||||
return {
|
||||
varName,
|
||||
typeName: resolvedType,
|
||||
narrowingRange: { startIndex: ifBody.startIndex, endIndex: ifBody.endIndex },
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -371,14 +464,41 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Infer the type of a literal AST node for C# overload disambiguation. */
|
||||
const inferLiteralType: LiteralTypeInferrer = (node) => {
|
||||
switch (node.type) {
|
||||
case 'integer_literal':
|
||||
if (node.text.endsWith('L') || node.text.endsWith('l')) return 'long';
|
||||
return 'int';
|
||||
case 'real_literal':
|
||||
if (node.text.endsWith('f') || node.text.endsWith('F')) return 'float';
|
||||
if (node.text.endsWith('m') || node.text.endsWith('M')) return 'decimal';
|
||||
return 'double';
|
||||
case 'string_literal':
|
||||
case 'verbatim_string_literal':
|
||||
case 'raw_string_literal':
|
||||
case 'interpolated_string_expression':
|
||||
return 'string';
|
||||
case 'character_literal':
|
||||
return 'char';
|
||||
case 'boolean_literal':
|
||||
return 'bool';
|
||||
case 'null_literal':
|
||||
return 'null';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: DECLARATION_NODE_TYPES,
|
||||
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
|
||||
patternBindingNodeTypes: new Set(['is_pattern_expression', 'declaration_pattern', 'recursive_pattern']),
|
||||
patternBindingNodeTypes: new Set(['is_pattern_expression', 'declaration_pattern', 'recursive_pattern', 'binary_expression']),
|
||||
extractDeclaration,
|
||||
extractParameter,
|
||||
scanConstructorBinding,
|
||||
extractForLoopBinding,
|
||||
extractPendingAssignment,
|
||||
extractPatternBinding,
|
||||
inferLiteralType,
|
||||
};
|
||||
|
||||
@@ -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, findChildByType, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
|
||||
import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
|
||||
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'var_declaration',
|
||||
|
||||
@@ -47,6 +47,5 @@ export {
|
||||
extractSimpleTypeName,
|
||||
extractGenericTypeArgs,
|
||||
extractVarName,
|
||||
findChildByType,
|
||||
extractRubyConstructorAssignment
|
||||
} from './shared.js';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, findChildByType, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.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';
|
||||
|
||||
// ── Java ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -73,7 +74,7 @@ const scanJavaConstructorBinding: ConstructorBindingScanner = (node) => {
|
||||
const typeNode = node.childForFieldName('type');
|
||||
if (!typeNode) return undefined;
|
||||
if (typeNode.text !== 'var') return undefined;
|
||||
const declarator = findChildByType(node, 'variable_declarator');
|
||||
const declarator = findChild(node, 'variable_declarator');
|
||||
if (!declarator) return undefined;
|
||||
const nameNode = declarator.childForFieldName('name');
|
||||
const value = declarator.childForFieldName('value');
|
||||
@@ -268,6 +269,38 @@ const extractJavaPatternBinding: PatternBindingExtractor = (node) => {
|
||||
return { varName, typeName };
|
||||
};
|
||||
|
||||
/** Infer the type of a literal AST node for Java/Kotlin overload disambiguation. */
|
||||
const inferJvmLiteralType: LiteralTypeInferrer = (node) => {
|
||||
switch (node.type) {
|
||||
case 'decimal_integer_literal':
|
||||
case 'integer_literal':
|
||||
case 'hex_integer_literal':
|
||||
case 'octal_integer_literal':
|
||||
case 'binary_integer_literal':
|
||||
// Check for long suffix
|
||||
if (node.text.endsWith('L') || node.text.endsWith('l')) return 'long';
|
||||
return 'int';
|
||||
case 'decimal_floating_point_literal':
|
||||
case 'real_literal':
|
||||
if (node.text.endsWith('f') || node.text.endsWith('F')) return 'float';
|
||||
return 'double';
|
||||
case 'string_literal':
|
||||
case 'line_string_literal':
|
||||
case 'multi_line_string_literal':
|
||||
return 'String';
|
||||
case 'character_literal':
|
||||
return 'char';
|
||||
case 'true':
|
||||
case 'false':
|
||||
case 'boolean_literal':
|
||||
return 'boolean';
|
||||
case 'null_literal':
|
||||
return 'null';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const javaTypeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: JAVA_DECLARATION_NODE_TYPES,
|
||||
forLoopNodeTypes: JAVA_FOR_LOOP_NODE_TYPES,
|
||||
@@ -279,6 +312,7 @@ export const javaTypeConfig: LanguageTypeConfig = {
|
||||
extractForLoopBinding: extractJavaForLoopBinding,
|
||||
extractPendingAssignment: extractJavaPendingAssignment,
|
||||
extractPatternBinding: extractJavaPatternBinding,
|
||||
inferLiteralType: inferJvmLiteralType,
|
||||
};
|
||||
|
||||
// ── Kotlin ────────────────────────────────────────────────────────────────
|
||||
@@ -292,10 +326,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 = findChildByType(node, 'variable_declaration');
|
||||
const varDecl = findChild(node, 'variable_declaration');
|
||||
if (varDecl) {
|
||||
const nameNode = findChildByType(varDecl, 'simple_identifier');
|
||||
const typeNode = findChildByType(varDecl, 'user_type');
|
||||
const nameNode = findChild(varDecl, 'simple_identifier');
|
||||
const typeNode = findChild(varDecl, 'user_type')
|
||||
?? findChild(varDecl, 'nullable_type');
|
||||
if (!nameNode || !typeNode) return;
|
||||
const varName = extractVarName(nameNode);
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
@@ -304,17 +339,17 @@ const extractKotlinDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: M
|
||||
}
|
||||
// Fallback: try direct fields
|
||||
const nameNode = node.childForFieldName('name')
|
||||
?? findChildByType(node, 'simple_identifier');
|
||||
?? findChild(node, 'simple_identifier');
|
||||
const typeNode = node.childForFieldName('type')
|
||||
?? findChildByType(node, 'user_type');
|
||||
?? findChild(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 = findChildByType(node, 'simple_identifier');
|
||||
const typeNode = findChildByType(node, 'user_type');
|
||||
const nameNode = findChild(node, 'simple_identifier');
|
||||
const typeNode = findChild(node, 'user_type');
|
||||
if (nameNode && typeNode) {
|
||||
const varName = extractVarName(nameNode);
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
@@ -326,7 +361,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
|
||||
* findChildByType when childForFieldName returns null. */
|
||||
* findChild when childForFieldName returns null. */
|
||||
const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
|
||||
let nameNode: SyntaxNode | null = null;
|
||||
let typeNode: SyntaxNode | null = null;
|
||||
@@ -340,8 +375,9 @@ const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map<s
|
||||
}
|
||||
|
||||
// Fallback: Kotlin `parameter` nodes use positional children, not named fields
|
||||
if (!nameNode) nameNode = findChildByType(node, 'simple_identifier');
|
||||
if (!typeNode) typeNode = findChildByType(node, 'user_type');
|
||||
if (!nameNode) nameNode = findChild(node, 'simple_identifier');
|
||||
if (!typeNode) typeNode = findChild(node, 'user_type')
|
||||
?? findChild(node, 'nullable_type');
|
||||
|
||||
if (!nameNode || !typeNode) return;
|
||||
const varName = extractVarName(nameNode);
|
||||
@@ -349,44 +385,56 @@ const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map<s
|
||||
if (varName && typeName) env.set(varName, typeName);
|
||||
};
|
||||
|
||||
/** Find the constructor callee name in a Kotlin property_declaration's initializer.
|
||||
* Returns the class name if the callee is a verified class constructor, undefined otherwise. */
|
||||
const findKotlinConstructorCallee = (node: SyntaxNode, classNames: ClassNameLookup): string | undefined => {
|
||||
if (node.type !== 'property_declaration') return undefined;
|
||||
const value = node.childForFieldName('value')
|
||||
?? findChild(node, 'call_expression');
|
||||
if (!value || value.type !== 'call_expression') return undefined;
|
||||
const callee = value.firstNamedChild;
|
||||
if (!callee || callee.type !== 'simple_identifier') return undefined;
|
||||
const calleeName = callee.text;
|
||||
if (!calleeName || !classNames.has(calleeName)) return undefined;
|
||||
return calleeName;
|
||||
};
|
||||
|
||||
/** Kotlin: val user = User() — infer type from call_expression when callee is a known class.
|
||||
* Kotlin constructors are syntactically identical to function calls, so we verify
|
||||
* against classNames (which may include cross-file SymbolTable lookups). */
|
||||
const extractKotlinInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, classNames: ClassNameLookup): void => {
|
||||
if (node.type !== 'property_declaration') return;
|
||||
// Skip if there's an explicit type annotation — Tier 0 already handled it
|
||||
const varDecl = findChildByType(node, 'variable_declaration');
|
||||
if (varDecl && findChildByType(varDecl, 'user_type')) return;
|
||||
const varDecl = findChild(node, 'variable_declaration');
|
||||
if (varDecl && findChild(varDecl, 'user_type')) return;
|
||||
|
||||
// Get the initializer value — the call_expression after '='
|
||||
const value = node.childForFieldName('value')
|
||||
?? findChildByType(node, 'call_expression');
|
||||
if (!value || value.type !== 'call_expression') return;
|
||||
|
||||
// The callee is the first child of call_expression (simple_identifier for direct calls)
|
||||
const callee = value.firstNamedChild;
|
||||
if (!callee || callee.type !== 'simple_identifier') return;
|
||||
|
||||
const calleeName = callee.text;
|
||||
if (!calleeName || !classNames.has(calleeName)) return;
|
||||
const calleeName = findKotlinConstructorCallee(node, classNames);
|
||||
if (!calleeName) return;
|
||||
|
||||
// Extract the variable name from the variable_declaration inside property_declaration
|
||||
const nameNode = varDecl
|
||||
? findChildByType(varDecl, 'simple_identifier')
|
||||
: findChildByType(node, 'simple_identifier');
|
||||
? findChild(varDecl, 'simple_identifier')
|
||||
: findChild(node, 'simple_identifier');
|
||||
if (!nameNode) return;
|
||||
|
||||
const varName = extractVarName(nameNode);
|
||||
if (varName) env.set(varName, calleeName);
|
||||
};
|
||||
|
||||
/** Kotlin: detect constructor type from call_expression in typed declarations.
|
||||
* Unlike extractKotlinInitializer (which SKIPS typed declarations), this detects
|
||||
* the constructor type EVEN when a type annotation exists, enabling virtual dispatch
|
||||
* for patterns like `val a: Animal = Dog()`. */
|
||||
const detectKotlinConstructorType: ConstructorTypeDetector = (node, classNames) => {
|
||||
return findKotlinConstructorCallee(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 = findChildByType(node, 'variable_declaration');
|
||||
const varDecl = findChild(node, 'variable_declaration');
|
||||
if (!varDecl) return undefined;
|
||||
if (findChildByType(varDecl, 'user_type')) return undefined;
|
||||
const callExpr = findChildByType(node, 'call_expression');
|
||||
if (findChild(varDecl, 'user_type')) return undefined;
|
||||
const callExpr = findChild(node, 'call_expression');
|
||||
if (!callExpr) return undefined;
|
||||
const callee = callExpr.firstNamedChild;
|
||||
if (!callee) return undefined;
|
||||
@@ -405,7 +453,7 @@ const scanKotlinConstructorBinding: ConstructorBindingScanner = (node) => {
|
||||
}
|
||||
}
|
||||
if (!calleeName) return undefined;
|
||||
const nameNode = findChildByType(varDecl, 'simple_identifier');
|
||||
const nameNode = findChild(varDecl, 'simple_identifier');
|
||||
if (!nameNode) return undefined;
|
||||
return { varName: nameNode.text, calleeName };
|
||||
};
|
||||
@@ -419,7 +467,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 = findChildByType(typeNode, 'type_arguments');
|
||||
const argsNode = findChild(typeNode, 'type_arguments');
|
||||
if (argsNode && argsNode.namedChildCount >= 1) {
|
||||
const targetArg = pos === 'first'
|
||||
? argsNode.namedChild(0)
|
||||
@@ -441,14 +489,14 @@ const findKotlinParamElementType = (iterableName: string, startNode: SyntaxNode,
|
||||
let current: SyntaxNode | null = startNode.parent;
|
||||
while (current) {
|
||||
if (current.type === 'function_declaration') {
|
||||
const paramsNode = findChildByType(current, 'function_value_parameters');
|
||||
const paramsNode = findChild(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 = findChildByType(param, 'simple_identifier');
|
||||
const nameNode = findChild(param, 'simple_identifier');
|
||||
if (nameNode?.text !== iterableName) continue;
|
||||
const typeNode = findChildByType(param, 'user_type');
|
||||
const typeNode = findChild(param, 'user_type');
|
||||
if (typeNode) return extractKotlinElementTypeFromTypeNode(typeNode, pos);
|
||||
}
|
||||
}
|
||||
@@ -463,15 +511,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 = findChildByType(node, 'variable_declaration');
|
||||
const varDecl = findChild(node, 'variable_declaration');
|
||||
if (!varDecl) return;
|
||||
const nameNode = findChildByType(varDecl, 'simple_identifier');
|
||||
const nameNode = findChild(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 = findChildByType(varDecl, 'user_type');
|
||||
const typeNode = findChild(varDecl, 'user_type');
|
||||
if (typeNode) {
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
if (typeName) scopeEnv.set(varName, typeName);
|
||||
@@ -497,9 +545,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 = findChildByType(child, 'navigation_suffix');
|
||||
const prop = suffix ? findChildByType(suffix, 'simple_identifier') : null;
|
||||
const hasCallSuffix = suffix ? findChildByType(suffix, 'call_suffix') !== null : false;
|
||||
const suffix = findChild(child, 'navigation_suffix');
|
||||
const prop = suffix ? findChild(suffix, 'simple_identifier') : null;
|
||||
const hasCallSuffix = suffix ? findChild(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).
|
||||
@@ -516,9 +564,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 = findChildByType(callee, 'navigation_suffix');
|
||||
const suffix = findChild(callee, 'navigation_suffix');
|
||||
if (suffix) {
|
||||
const prop = findChildByType(suffix, 'simple_identifier');
|
||||
const prop = findChild(suffix, 'simple_identifier');
|
||||
if (prop) methodName = prop.text;
|
||||
}
|
||||
} else if (callee?.type === 'simple_identifier') {
|
||||
@@ -560,7 +608,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 = findChildByType(node, 'variable_declaration');
|
||||
const varDecl = findChild(node, 'variable_declaration');
|
||||
if (!varDecl) return undefined;
|
||||
const nameNode = varDecl.firstNamedChild;
|
||||
if (!nameNode || nameNode.type !== 'simple_identifier') return undefined;
|
||||
@@ -606,7 +654,7 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
|
||||
|
||||
if (node.type === 'variable_declaration') {
|
||||
// variable_declaration directly inside functions: simple_identifier children
|
||||
const nameNode = findChildByType(node, 'simple_identifier');
|
||||
const nameNode = findChild(node, 'simple_identifier');
|
||||
if (!nameNode) return undefined;
|
||||
const lhs = nameNode.text;
|
||||
if (scopeEnv.has(lhs)) return undefined;
|
||||
@@ -660,27 +708,76 @@ const findAncestorByType = (node: SyntaxNode, type: string): SyntaxNode | undefi
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractKotlinPatternBinding: PatternBindingExtractor = (node) => {
|
||||
if (node.type !== 'type_test') return undefined;
|
||||
const typeNode = node.lastNamedChild;
|
||||
if (!typeNode) return undefined;
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
if (!typeName) return undefined;
|
||||
const whenExpr = findAncestorByType(node, 'when_expression');
|
||||
if (!whenExpr) return undefined;
|
||||
const whenSubject = whenExpr.namedChild(0);
|
||||
const subject = whenSubject?.firstNamedChild ?? whenSubject;
|
||||
if (!subject) return undefined;
|
||||
const varName = extractVarName(subject);
|
||||
if (!varName) return undefined;
|
||||
return { varName, typeName };
|
||||
const extractKotlinPatternBinding: PatternBindingExtractor = (node, scopeEnv, declarationTypeNodes, scope) => {
|
||||
// Kotlin when/is smart casts (existing behavior)
|
||||
if (node.type === 'type_test') {
|
||||
const typeNode = node.lastNamedChild;
|
||||
if (!typeNode) return undefined;
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
if (!typeName) return undefined;
|
||||
const whenExpr = findAncestorByType(node, 'when_expression');
|
||||
if (!whenExpr) return undefined;
|
||||
const whenSubject = whenExpr.namedChild(0);
|
||||
const subject = whenSubject?.firstNamedChild ?? whenSubject;
|
||||
if (!subject) return undefined;
|
||||
const varName = extractVarName(subject);
|
||||
if (!varName) return undefined;
|
||||
return { varName, typeName };
|
||||
}
|
||||
|
||||
// Null-check narrowing: if (x != null) { ... }
|
||||
// Kotlin AST: equality_expression > simple_identifier, "!=" [anon], "null" [anon]
|
||||
// Note: `null` is an anonymous node in tree-sitter-kotlin, not `null_literal`.
|
||||
if (node.type === 'equality_expression') {
|
||||
const op = node.children.find(c => !c.isNamed && c.text === '!=');
|
||||
if (!op) return undefined;
|
||||
|
||||
// `null` is anonymous in Kotlin grammar — use positional child scan
|
||||
let varNode: SyntaxNode | undefined;
|
||||
let hasNull = false;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (!c) continue;
|
||||
if (c.type === 'simple_identifier') varNode = c;
|
||||
if (!c.isNamed && c.text === 'null') hasNull = true;
|
||||
}
|
||||
if (!varNode || !hasNull) return undefined;
|
||||
|
||||
const varName = varNode.text;
|
||||
const resolvedType = scopeEnv.get(varName);
|
||||
if (!resolvedType) return undefined;
|
||||
|
||||
// Check if the original declaration type was nullable (ends with ?)
|
||||
const declTypeNode = declarationTypeNodes.get(`${scope}\0${varName}`);
|
||||
if (!declTypeNode) return undefined;
|
||||
const declText = declTypeNode.text;
|
||||
if (!declText.includes('?') && !declText.includes('null')) return undefined;
|
||||
|
||||
// Find the if-body: walk up to if_expression, then find control_structure_body
|
||||
const ifExpr = findAncestorByType(node, 'if_expression');
|
||||
if (!ifExpr) return undefined;
|
||||
// The consequence is the first control_structure_body child
|
||||
for (let i = 0; i < ifExpr.childCount; i++) {
|
||||
const child = ifExpr.child(i);
|
||||
if (child?.type === 'control_structure_body') {
|
||||
return {
|
||||
varName,
|
||||
typeName: resolvedType,
|
||||
narrowingRange: { startIndex: child.startIndex, endIndex: child.endIndex },
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const kotlinTypeConfig: LanguageTypeConfig = {
|
||||
allowPatternBindingOverwrite: true,
|
||||
declarationNodeTypes: KOTLIN_DECLARATION_NODE_TYPES,
|
||||
forLoopNodeTypes: KOTLIN_FOR_LOOP_NODE_TYPES,
|
||||
patternBindingNodeTypes: new Set(['type_test']),
|
||||
patternBindingNodeTypes: new Set(['type_test', 'equality_expression']),
|
||||
extractDeclaration: extractKotlinDeclaration,
|
||||
extractParameter: extractKotlinParameter,
|
||||
extractInitializer: extractKotlinInitializer,
|
||||
@@ -688,4 +785,6 @@ export const kotlinTypeConfig: LanguageTypeConfig = {
|
||||
extractForLoopBinding: extractKotlinForLoopBinding,
|
||||
extractPendingAssignment: extractKotlinPendingAssignment,
|
||||
extractPatternBinding: extractKotlinPatternBinding,
|
||||
inferLiteralType: inferJvmLiteralType,
|
||||
detectConstructorType: detectKotlinConstructorType,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, PatternBindingExtractor, ForLoopExtractor } from './types.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, PendingAssignment, PatternBindingExtractor, ForLoopExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
|
||||
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
@@ -189,12 +189,45 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
|
||||
return { varName: patternNode.text, calleeName };
|
||||
};
|
||||
|
||||
/** Rust: let alias = u; → let_declaration with pattern + value fields */
|
||||
/** Rust: let alias = u; → let_declaration with pattern + value fields.
|
||||
* Also handles struct destructuring: `let Point { x, y } = p` → N fieldAccess items. */
|
||||
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
|
||||
if (node.type !== 'let_declaration') return undefined;
|
||||
const pattern = node.childForFieldName('pattern');
|
||||
const value = node.childForFieldName('value');
|
||||
if (!pattern || !value) return undefined;
|
||||
|
||||
// Struct pattern destructuring: `let Point { x, y } = receiver`
|
||||
// struct_pattern has a type child (struct name) and field_pattern children
|
||||
if (pattern.type === 'struct_pattern' && value.type === 'identifier') {
|
||||
const receiver = value.text;
|
||||
const items: PendingAssignment[] = [];
|
||||
for (let j = 0; j < pattern.namedChildCount; j++) {
|
||||
const field = pattern.namedChild(j);
|
||||
if (!field) continue;
|
||||
if (field.type === 'field_pattern') {
|
||||
// `Point { x: local_x }` → field_pattern with name + pattern children
|
||||
const nameNode = field.childForFieldName('name');
|
||||
const patNode = field.childForFieldName('pattern');
|
||||
if (nameNode && patNode) {
|
||||
const fieldName = nameNode.text;
|
||||
const varName = extractVarName(patNode);
|
||||
if (varName && !scopeEnv.has(varName)) {
|
||||
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: fieldName });
|
||||
}
|
||||
} else if (nameNode) {
|
||||
// Shorthand: `Point { x }` → field_pattern with only name (varName = fieldName)
|
||||
const varName = nameNode.text;
|
||||
if (!scopeEnv.has(varName)) {
|
||||
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: varName });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (items.length > 0) return items;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lhs = extractVarName(pattern);
|
||||
if (!lhs || scopeEnv.has(lhs)) return undefined;
|
||||
// Unwrap Rust .await: `let user = get_user().await` → call_expression
|
||||
|
||||
@@ -275,7 +275,11 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode, depth = 0): string |
|
||||
|
||||
// Primitive/predefined types: string, int, float, bool, number, unknown, any
|
||||
// PHP: primitive_type; TS/JS: predefined_type
|
||||
if (typeNode.type === 'primitive_type' || typeNode.type === 'predefined_type') {
|
||||
// Java: integral_type (int/long/short/byte), floating_point_type (float/double),
|
||||
// boolean_type (boolean), void_type (void)
|
||||
if (typeNode.type === 'primitive_type' || typeNode.type === 'predefined_type'
|
||||
|| typeNode.type === 'integral_type' || typeNode.type === 'floating_point_type'
|
||||
|| typeNode.type === 'boolean_type' || typeNode.type === 'void_type') {
|
||||
return typeNode.text;
|
||||
}
|
||||
|
||||
@@ -493,15 +497,6 @@ 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,6 +1,7 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, findChildByType, hasTypeAnnotation } from './shared.js';
|
||||
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation } from './shared.js';
|
||||
import { findChild } from '../resolvers/utils.js';
|
||||
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'property_declaration',
|
||||
@@ -10,9 +11,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')
|
||||
?? findChildByType(node, 'pattern');
|
||||
?? findChild(node, 'pattern');
|
||||
const typeAnnotation = node.childForFieldName('type')
|
||||
?? findChildByType(node, 'type_annotation');
|
||||
?? findChild(node, 'type_annotation');
|
||||
if (!pattern || !typeAnnotation) return;
|
||||
const varName = extractVarName(pattern) ?? pattern.text;
|
||||
const typeName = extractSimpleTypeName(typeAnnotation);
|
||||
@@ -45,14 +46,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') || findChildByType(node, 'type_annotation')) return;
|
||||
if (node.childForFieldName('type') || findChild(node, 'type_annotation')) return;
|
||||
// Find pattern (variable name)
|
||||
const pattern = node.childForFieldName('pattern') ?? findChildByType(node, 'pattern');
|
||||
const pattern = node.childForFieldName('pattern') ?? findChild(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 = findChildByType(node, 'call_expression');
|
||||
const callExpr = findChild(node, 'call_expression');
|
||||
if (!callExpr) return;
|
||||
const callee = callExpr.firstNamedChild;
|
||||
if (!callee) return;
|
||||
|
||||
@@ -24,6 +24,23 @@ export type ConstructorBindingScanner = (node: SyntaxNode) => { varName: string;
|
||||
* rather than in AST fields. Returns undefined if no return type can be determined. */
|
||||
export type ReturnTypeExtractor = (node: SyntaxNode) => string | undefined;
|
||||
|
||||
/** Infer the type name of a literal AST node for overload disambiguation.
|
||||
* Returns the canonical type name (e.g. 'int', 'String', 'boolean') or undefined
|
||||
* for non-literal nodes. Only used when resolveCallTarget has multiple candidates
|
||||
* with parameterTypes — ~1-3% of call sites. */
|
||||
export type LiteralTypeInferrer = (node: SyntaxNode) => string | undefined;
|
||||
|
||||
/** Detect constructor-style call expressions that don't use `new` keyword.
|
||||
* Returns the constructor class name if the node's initializer is a constructor call,
|
||||
* or undefined otherwise. Used for virtual dispatch in languages like Kotlin
|
||||
* where constructors are syntactically identical to function calls, and C++
|
||||
* where smart pointer factory functions (make_shared/make_unique) wrap constructors. */
|
||||
export type ConstructorTypeDetector = (node: SyntaxNode, classNames: ClassNameLookup) => string | undefined;
|
||||
|
||||
/** Unwrap a declared type name to its inner type for virtual dispatch comparison.
|
||||
* E.g., C++ shared_ptr<Animal> → Animal. Returns undefined if no unwrapping applies. */
|
||||
export type DeclaredTypeUnwrapper = (declaredType: string, typeNode: SyntaxNode) => string | undefined;
|
||||
|
||||
/** Narrow lookup interface for resolving a callee name → return type name.
|
||||
* Backed by SymbolTable.lookupFuzzyCallable; passed via ForLoopExtractorContext.
|
||||
* Conservative: returns undefined when the callee is ambiguous (0 or 2+ matches). */
|
||||
@@ -67,15 +84,29 @@ export type PendingAssignment =
|
||||
* Returns a PendingAssignment when the RHS is a bare identifier (`copy`), a
|
||||
* call expression (`callResult`), a field access (`fieldAccess`), or a
|
||||
* method call with receiver (`methodCallResult`) and the LHS has no resolved type yet.
|
||||
* May return an array of PendingAssignment items for destructuring patterns
|
||||
* (e.g., `const { a, b } = obj` emits N fieldAccess items).
|
||||
* Returns undefined if the node is not a matching assignment. */
|
||||
export type PendingAssignmentExtractor = (
|
||||
node: SyntaxNode,
|
||||
scopeEnv: ReadonlyMap<string, string>,
|
||||
) => PendingAssignment | undefined;
|
||||
) => PendingAssignment | PendingAssignment[] | undefined;
|
||||
|
||||
/** Result of a pattern binding extraction. */
|
||||
export interface PatternBindingResult {
|
||||
varName: string;
|
||||
typeName: string;
|
||||
/** Optional: AST node whose position range should be used for the patternOverride.
|
||||
* When present, the override uses this node's range instead of the auto-detected
|
||||
* branch scope. Used by null-check narrowing to target the if-body specifically. */
|
||||
narrowingRange?: { startIndex: number; endIndex: number };
|
||||
}
|
||||
|
||||
/** Extracts a typed variable binding from a pattern-matching construct.
|
||||
* Returns { varName, typeName } for patterns that introduce NEW variables.
|
||||
* Examples: `if let Some(user) = opt` (Rust), `x instanceof User user` (Java).
|
||||
* Returns { varName, typeName } for patterns that introduce NEW variables
|
||||
* or narrow existing variables (null-check narrowing).
|
||||
* Examples: `if let Some(user) = opt` (Rust), `x instanceof User user` (Java),
|
||||
* `if (x != null)` (null-check narrowing in TS/Kotlin/C#).
|
||||
* Conservative: returns undefined when the source variable's type is unknown.
|
||||
*
|
||||
* @param scopeEnv Read-only view of already-resolved type bindings in the current scope.
|
||||
@@ -88,7 +119,7 @@ export type PatternBindingExtractor = (
|
||||
scopeEnv: ReadonlyMap<string, string>,
|
||||
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
|
||||
scope: string,
|
||||
) => { varName: string; typeName: string } | undefined;
|
||||
) => PatternBindingResult | undefined;
|
||||
|
||||
/** Per-language type extraction configuration */
|
||||
export interface LanguageTypeConfig {
|
||||
@@ -135,4 +166,7 @@ export interface LanguageTypeConfig {
|
||||
* The extractor receives the current scope's resolved bindings (read-only) to look up the
|
||||
* source variable's type. Returns undefined for non-matching nodes or unknown source types. */
|
||||
extractPatternBinding?: PatternBindingExtractor;
|
||||
inferLiteralType?: LiteralTypeInferrer;
|
||||
detectConstructorType?: ConstructorTypeDetector;
|
||||
unwrapDeclaredType?: DeclaredTypeUnwrapper;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, ForLoopExtractor, PatternBindingExtractor } from './types.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, PendingAssignment, ForLoopExtractor, PatternBindingExtractor, LiteralTypeInferrer } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractCalleeName, extractElementTypeFromString, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
|
||||
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
@@ -429,7 +429,8 @@ const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTy
|
||||
if (loopVarName) scopeEnv.set(loopVarName, elementType);
|
||||
};
|
||||
|
||||
/** TS/JS: const alias = u → variable_declarator with name/value fields */
|
||||
/** TS/JS: const alias = u → variable_declarator with name/value fields.
|
||||
* Also handles destructuring: `const { a, b } = obj` → N fieldAccess items. */
|
||||
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
@@ -437,6 +438,38 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
||||
const nameNode = child.childForFieldName('name');
|
||||
const valueNode = child.childForFieldName('value');
|
||||
if (!nameNode || !valueNode) continue;
|
||||
|
||||
// Object destructuring: `const { address, name } = user`
|
||||
// Emits N fieldAccess items — one per destructured binding.
|
||||
if (nameNode.type === 'object_pattern' && valueNode.type === 'identifier') {
|
||||
const receiver = valueNode.text;
|
||||
const items: PendingAssignment[] = [];
|
||||
for (let j = 0; j < nameNode.namedChildCount; j++) {
|
||||
const prop = nameNode.namedChild(j);
|
||||
if (!prop) continue;
|
||||
if (prop.type === 'shorthand_property_identifier_pattern') {
|
||||
// `const { name } = user` → shorthand: varName = fieldName
|
||||
const varName = prop.text;
|
||||
if (!scopeEnv.has(varName)) {
|
||||
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: varName });
|
||||
}
|
||||
} else if (prop.type === 'pair_pattern') {
|
||||
// `const { address: addr } = user` → pair_pattern: key=field, value=varName
|
||||
const keyNode = prop.childForFieldName('key');
|
||||
const valNode = prop.childForFieldName('value');
|
||||
if (keyNode && valNode) {
|
||||
const fieldName = keyNode.text;
|
||||
const varName = valNode.text;
|
||||
if (!scopeEnv.has(varName)) {
|
||||
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: fieldName });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (items.length > 0) return items;
|
||||
continue;
|
||||
}
|
||||
|
||||
const lhs = nameNode.text;
|
||||
if (scopeEnv.has(lhs)) continue;
|
||||
if (valueNode.type === 'identifier') return { kind: 'copy', lhs, rhs: valueNode.text };
|
||||
@@ -472,19 +505,118 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Null-check keywords that indicate a null-comparison in binary expressions. */
|
||||
const NULL_CHECK_KEYWORDS = new Set(['null', 'undefined']);
|
||||
|
||||
/**
|
||||
* Find the if-body (consequence) block for a null-check binary_expression.
|
||||
* Walks up from the binary_expression through parenthesized_expression to if_statement,
|
||||
* then returns the consequence block (statement_block).
|
||||
*
|
||||
* AST structure: if_statement > parenthesized_expression > binary_expression
|
||||
* if_statement > statement_block (consequence)
|
||||
*/
|
||||
const findIfConsequenceBlock = (binaryExpr: SyntaxNode): SyntaxNode | undefined => {
|
||||
// Walk up to find the if_statement (typically: binary_expression > parenthesized_expression > if_statement)
|
||||
let current = binaryExpr.parent;
|
||||
while (current) {
|
||||
if (current.type === 'if_statement') {
|
||||
// The consequence is the first statement_block child of if_statement
|
||||
for (let i = 0; i < current.childCount; i++) {
|
||||
const child = current.child(i);
|
||||
if (child?.type === 'statement_block') return child;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
// Stop climbing at function/block boundaries — don't cross scope
|
||||
if (current.type === 'function_declaration' || current.type === 'function_expression'
|
||||
|| current.type === 'arrow_function' || current.type === 'method_definition') return undefined;
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** TS instanceof narrowing: `x instanceof User` → bind x to User.
|
||||
* Only works when x has no prior type binding (e.g. x: unknown, untyped params).
|
||||
* Typed params (x: Animal) are blocked by the !scopeEnv.has() guard in buildTypeEnv.
|
||||
* Uses first-writer-wins, same as Rust match arm bindings. */
|
||||
const extractPatternBinding: PatternBindingExtractor = (node) => {
|
||||
* Also handles null-check narrowing: `x !== null`, `x != undefined` etc.
|
||||
* instanceof: first-writer-wins (no prior type binding).
|
||||
* null-check: position-indexed narrowing via narrowingRange. */
|
||||
const extractPatternBinding: PatternBindingExtractor = (node, scopeEnv, declarationTypeNodes, scope) => {
|
||||
if (node.type !== 'binary_expression') return undefined;
|
||||
const op = node.children.find(c => !c.isNamed && c.text === 'instanceof');
|
||||
|
||||
// Check for instanceof first (existing behavior)
|
||||
const instanceofOp = node.children.find(c => !c.isNamed && c.text === 'instanceof');
|
||||
if (instanceofOp) {
|
||||
const left = node.namedChild(0);
|
||||
const right = node.namedChild(1);
|
||||
if (left?.type !== 'identifier' || right?.type !== 'identifier') return undefined;
|
||||
return { varName: left.text, typeName: right.text };
|
||||
}
|
||||
|
||||
// Null-check narrowing: x !== null, x != null, x !== undefined, x != undefined
|
||||
const op = node.children.find(c => !c.isNamed && (c.text === '!==' || c.text === '!='));
|
||||
if (!op) return undefined;
|
||||
// binary_expression children are positional — no left/right fields
|
||||
|
||||
const left = node.namedChild(0);
|
||||
const right = node.namedChild(1);
|
||||
if (left?.type !== 'identifier' || right?.type !== 'identifier') return undefined;
|
||||
return { varName: left.text, typeName: right.text };
|
||||
if (!left || !right) return undefined;
|
||||
|
||||
// Determine which side is the variable and which is null/undefined
|
||||
let varNode: SyntaxNode | undefined;
|
||||
let isNullCheck = false;
|
||||
if (left.type === 'identifier' && NULL_CHECK_KEYWORDS.has(right.text)) {
|
||||
varNode = left;
|
||||
isNullCheck = true;
|
||||
} else if (right.type === 'identifier' && NULL_CHECK_KEYWORDS.has(left.text)) {
|
||||
varNode = right;
|
||||
isNullCheck = true;
|
||||
}
|
||||
if (!isNullCheck || !varNode) return undefined;
|
||||
|
||||
const varName = varNode.text;
|
||||
// Look up the variable's resolved type (already stripped of nullable by extractSimpleTypeName)
|
||||
const resolvedType = scopeEnv.get(varName);
|
||||
if (!resolvedType) return undefined;
|
||||
|
||||
// Check if the original declaration type was nullable by looking at the raw AST type node.
|
||||
// extractSimpleTypeName already strips nullable markers, so we need the original to know
|
||||
// if narrowing is meaningful (i.e., the variable was declared as nullable).
|
||||
const declTypeNode = declarationTypeNodes.get(`${scope}\0${varName}`);
|
||||
if (!declTypeNode) return undefined;
|
||||
const declText = declTypeNode.text;
|
||||
// Only narrow if the original declaration was nullable
|
||||
if (!declText.includes('null') && !declText.includes('undefined')) return undefined;
|
||||
|
||||
// Find the if-body block to scope the narrowing
|
||||
const ifBody = findIfConsequenceBlock(node);
|
||||
if (!ifBody) return undefined;
|
||||
|
||||
return {
|
||||
varName,
|
||||
typeName: resolvedType,
|
||||
narrowingRange: { startIndex: ifBody.startIndex, endIndex: ifBody.endIndex },
|
||||
};
|
||||
};
|
||||
|
||||
/** Infer the type of a literal AST node for TypeScript overload disambiguation. */
|
||||
const inferTsLiteralType: LiteralTypeInferrer = (node) => {
|
||||
switch (node.type) {
|
||||
case 'number':
|
||||
return 'number';
|
||||
case 'string':
|
||||
case 'template_string':
|
||||
return 'string';
|
||||
case 'true':
|
||||
case 'false':
|
||||
return 'boolean';
|
||||
case 'null':
|
||||
return 'null';
|
||||
case 'undefined':
|
||||
return 'undefined';
|
||||
case 'regex':
|
||||
return 'RegExp';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
@@ -499,4 +631,5 @@ export const typeConfig: LanguageTypeConfig = {
|
||||
extractForLoopBinding,
|
||||
extractPendingAssignment,
|
||||
extractPatternBinding,
|
||||
inferLiteralType: inferTsLiteralType,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ import {
|
||||
isBuiltInOrNoise,
|
||||
getDefinitionNodeFromCaptures,
|
||||
findEnclosingClassId,
|
||||
getLabelFromCaptures,
|
||||
extractMethodSignature,
|
||||
countCallArguments,
|
||||
inferCallForm,
|
||||
@@ -45,8 +46,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 { extractNamedBindings } from '../named-binding-extraction.js';
|
||||
import { appendKotlinWildcard } from '../resolvers/index.js';
|
||||
import { namedBindingExtractors, preprocessImportPath } from '../import-resolution.js';
|
||||
import type { NamedBinding } from '../import-resolution.js';
|
||||
import { callRouters } from '../call-routing.js';
|
||||
import { extractPropertyDeclaredType } from '../type-extractors/shared.js';
|
||||
import type { NodeLabel } from '../../graph/types.js';
|
||||
@@ -69,6 +70,7 @@ interface ParsedNode {
|
||||
astFrameworkReason?: string;
|
||||
description?: string;
|
||||
parameterCount?: number;
|
||||
requiredParameterCount?: number;
|
||||
returnType?: string;
|
||||
};
|
||||
}
|
||||
@@ -88,6 +90,8 @@ interface ParsedSymbol {
|
||||
nodeId: string;
|
||||
type: NodeLabel;
|
||||
parameterCount?: number;
|
||||
requiredParameterCount?: number;
|
||||
parameterTypes?: string[];
|
||||
returnType?: string;
|
||||
declaredType?: string;
|
||||
ownerId?: string;
|
||||
@@ -98,7 +102,7 @@ export interface ExtractedImport {
|
||||
rawImportPath: string;
|
||||
language: SupportedLanguages;
|
||||
/** Named bindings from the import (e.g., import {User as U} → [{local:'U', exported:'User'}]) */
|
||||
namedBindings?: { local: string; exported: string }[];
|
||||
namedBindings?: NamedBinding[];
|
||||
}
|
||||
|
||||
export interface ExtractedCall {
|
||||
@@ -161,6 +165,13 @@ 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[];
|
||||
@@ -171,6 +182,8 @@ 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;
|
||||
}
|
||||
@@ -246,39 +259,7 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
|
||||
return null;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 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';
|
||||
};
|
||||
// Label detection moved to shared getLabelFromCaptures in utils.ts
|
||||
|
||||
// DEFINITION_CAPTURE_KEYS and getDefinitionNodeFromCaptures imported from ../utils.js
|
||||
|
||||
@@ -298,6 +279,7 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n
|
||||
heritage: [],
|
||||
routes: [],
|
||||
constructorBindings: [],
|
||||
typeEnvBindings: [],
|
||||
skippedLanguages: {},
|
||||
fileCount: 0,
|
||||
};
|
||||
@@ -890,15 +872,6 @@ const processFileGroup = (
|
||||
result.fileCount++;
|
||||
onFileProcessed?.();
|
||||
|
||||
// Build per-file type environment + constructor bindings in a single AST walk.
|
||||
// Constructor bindings are verified against the SymbolTable in processCallsFromExtracted.
|
||||
const typeEnv = buildTypeEnv(tree, language);
|
||||
const callRouter = callRouters[language];
|
||||
|
||||
if (typeEnv.constructorBindings.length > 0) {
|
||||
result.constructorBindings.push({ filePath: file.path, bindings: [...typeEnv.constructorBindings] });
|
||||
}
|
||||
|
||||
let matches;
|
||||
try {
|
||||
matches = query.matches(tree.rootNode);
|
||||
@@ -907,6 +880,49 @@ const processFileGroup = (
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pre-pass: extract heritage from query matches to build parentMap for buildTypeEnv.
|
||||
// Heritage edges (EXTENDS/IMPLEMENTS) are created by heritage-processor which runs
|
||||
// in PARALLEL with call-processor, so the graph edges don't exist when buildTypeEnv
|
||||
// runs. This pre-pass makes parent class information available for type resolution.
|
||||
const fileParentMap = new Map<string, string[]>();
|
||||
for (const match of matches) {
|
||||
const captureMap: Record<string, any> = {};
|
||||
for (const c of match.captures) {
|
||||
captureMap[c.name] = c.node;
|
||||
}
|
||||
if (captureMap['heritage.class'] && captureMap['heritage.extends']) {
|
||||
const className: string = captureMap['heritage.class'].text;
|
||||
const parentName: string = captureMap['heritage.extends'].text;
|
||||
// Skip Go named fields (only anonymous fields are struct embedding)
|
||||
const extendsNode = captureMap['heritage.extends'];
|
||||
const fieldDecl = extendsNode.parent;
|
||||
if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) continue;
|
||||
let parents = fileParentMap.get(className);
|
||||
if (!parents) { parents = []; fileParentMap.set(className, parents); }
|
||||
if (!parents.includes(parentName)) parents.push(parentName);
|
||||
}
|
||||
}
|
||||
|
||||
// Build per-file type environment + constructor bindings in a single AST walk.
|
||||
// Constructor bindings are verified against the SymbolTable in processCallsFromExtracted.
|
||||
const parentMap: ReadonlyMap<string, readonly string[]> = fileParentMap;
|
||||
const typeEnv = buildTypeEnv(tree, language, { parentMap });
|
||||
const callRouter = callRouters[language];
|
||||
|
||||
if (typeEnv.constructorBindings.length > 0) {
|
||||
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) {
|
||||
@@ -915,10 +931,10 @@ const processFileGroup = (
|
||||
|
||||
// Extract import paths before skipping
|
||||
if (captureMap['import'] && captureMap['import.source']) {
|
||||
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);
|
||||
const rawImportPath = preprocessImportPath(captureMap['import.source'].text, captureMap['import'], language);
|
||||
if (!rawImportPath) continue;
|
||||
const extractor = namedBindingExtractors[language];
|
||||
const namedBindings = extractor ? extractor(captureMap['import']) : undefined;
|
||||
result.imports.push({
|
||||
filePath: file.path,
|
||||
rawImportPath,
|
||||
@@ -1117,26 +1133,9 @@ const processFileGroup = (
|
||||
}
|
||||
}
|
||||
|
||||
const nodeLabel = getLabelFromCaptures(captureMap);
|
||||
const nodeLabel = getLabelFromCaptures(captureMap, language);
|
||||
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;
|
||||
@@ -1159,11 +1158,15 @@ const processFileGroup = (
|
||||
: null;
|
||||
|
||||
let parameterCount: number | undefined;
|
||||
let requiredParameterCount: number | undefined;
|
||||
let parameterTypes: string[] | undefined;
|
||||
let returnType: string | undefined;
|
||||
let declaredType: string | undefined;
|
||||
if (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') {
|
||||
const sig = extractMethodSignature(definitionNode);
|
||||
parameterCount = sig.parameterCount;
|
||||
requiredParameterCount = sig.requiredParameterCount;
|
||||
parameterTypes = sig.parameterTypes;
|
||||
returnType = sig.returnType;
|
||||
|
||||
// Language-specific return type fallback (e.g. Ruby YARD @return [Type])
|
||||
@@ -1197,6 +1200,8 @@ const processFileGroup = (
|
||||
} : {}),
|
||||
...(description !== undefined ? { description } : {}),
|
||||
...(parameterCount !== undefined ? { parameterCount } : {}),
|
||||
...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}),
|
||||
...(parameterTypes !== undefined ? { parameterTypes } : {}),
|
||||
...(returnType !== undefined ? { returnType } : {}),
|
||||
},
|
||||
});
|
||||
@@ -1212,6 +1217,8 @@ const processFileGroup = (
|
||||
nodeId,
|
||||
type: nodeLabel,
|
||||
...(parameterCount !== undefined ? { parameterCount } : {}),
|
||||
...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}),
|
||||
...(parameterTypes !== undefined ? { parameterTypes } : {}),
|
||||
...(returnType !== undefined ? { returnType } : {}),
|
||||
...(declaredType !== undefined ? { declaredType } : {}),
|
||||
...(enclosingClassId ? { ownerId: enclosingClassId } : {}),
|
||||
@@ -1257,7 +1264,7 @@ const processFileGroup = (
|
||||
/** Accumulated result across sub-batches */
|
||||
let accumulated: ParseWorkerResult = {
|
||||
nodes: [], relationships: [], symbols: [],
|
||||
imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0,
|
||||
imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0,
|
||||
};
|
||||
let cumulativeProcessed = 0;
|
||||
|
||||
@@ -1271,6 +1278,7 @@ 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;
|
||||
}
|
||||
@@ -1295,7 +1303,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: [], skippedLanguages: {}, fileCount: 0 };
|
||||
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0 };
|
||||
cumulativeProcessed = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -238,6 +238,9 @@ export const streamAllCSVsToDisk = async (
|
||||
const communityWriter = new BufferedCSVWriter(path.join(csvDir, 'community.csv'), 'id,label,heuristicLabel,keywords,description,enrichedBy,cohesion,symbolCount');
|
||||
const processWriter = new BufferedCSVWriter(path.join(csvDir, 'process.csv'), 'id,label,heuristicLabel,processType,stepCount,communities,entryPointId,terminalId');
|
||||
|
||||
// Section nodes have an extra 'level' column
|
||||
const sectionWriter = new BufferedCSVWriter(path.join(csvDir, 'section.csv'), 'id,name,filePath,startLine,endLine,level,content,description');
|
||||
|
||||
// Multi-language node types share the same CSV shape (no isExported column)
|
||||
const multiLangHeader = 'id,name,filePath,startLine,endLine,content,description';
|
||||
const MULTI_LANG_TYPES = ['Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl',
|
||||
@@ -324,6 +327,20 @@ export const streamAllCSVsToDisk = async (
|
||||
].join(','));
|
||||
break;
|
||||
}
|
||||
case 'Section': {
|
||||
const content = await extractContent(node, contentCache);
|
||||
await sectionWriter.addRow([
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVNumber(node.properties.startLine, -1),
|
||||
escapeCSVNumber(node.properties.endLine, -1),
|
||||
escapeCSVNumber((node.properties as any).level, 1),
|
||||
escapeCSVField(content),
|
||||
escapeCSVField((node.properties as any).description || ''),
|
||||
].join(','));
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Code element nodes (Function, Class, Interface, CodeElement)
|
||||
const writer = codeWriterMap[node.label];
|
||||
@@ -361,7 +378,7 @@ export const streamAllCSVsToDisk = async (
|
||||
}
|
||||
|
||||
// Finish all node writers
|
||||
const allWriters = [fileWriter, folderWriter, functionWriter, classWriter, interfaceWriter, methodWriter, codeElemWriter, communityWriter, processWriter, ...multiLangWriters.values()];
|
||||
const allWriters = [fileWriter, folderWriter, functionWriter, classWriter, interfaceWriter, methodWriter, codeElemWriter, communityWriter, processWriter, sectionWriter, ...multiLangWriters.values()];
|
||||
await Promise.all(allWriters.map(w => w.finish()));
|
||||
|
||||
// --- Stream relationship CSV ---
|
||||
@@ -387,6 +404,7 @@ export const streamAllCSVsToDisk = async (
|
||||
['Interface', interfaceWriter], ['Method', methodWriter],
|
||||
['CodeElement', codeElemWriter],
|
||||
['Community', communityWriter], ['Process', processWriter],
|
||||
['Section' as NodeTableName, sectionWriter],
|
||||
...Array.from(multiLangWriters.entries()).map(([name, w]) => [name as NodeTableName, w] as [NodeTableName, BufferedCSVWriter]),
|
||||
];
|
||||
for (const [name, writer] of tableMap) {
|
||||
|
||||
@@ -25,6 +25,26 @@ 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;
|
||||
@@ -49,12 +69,41 @@ 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> => {
|
||||
return runWithSessionLock(async () => {
|
||||
await ensureLbugInitialized(dbPath);
|
||||
return operation();
|
||||
});
|
||||
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;
|
||||
};
|
||||
|
||||
const ensureLbugInitialized = async (dbPath: string) => {
|
||||
@@ -336,6 +385,9 @@ const getCopyQuery = (table: NodeTableName, filePath: string): string => {
|
||||
if (table === 'Process') {
|
||||
return `COPY ${t}(id, label, heuristicLabel, processType, stepCount, communities, entryPointId, terminalId) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
if (table === 'Section') {
|
||||
return `COPY ${t}(id, name, filePath, startLine, endLine, level, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
if (table === 'Method') {
|
||||
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
@@ -380,6 +432,9 @@ export const insertNodeToLbug = async (
|
||||
query = `CREATE (n:File {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, content: ${escapeValue(properties.content || '')}})`;
|
||||
} else if (label === 'Folder') {
|
||||
query = `CREATE (n:Folder {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}})`;
|
||||
} else if (label === 'Section') {
|
||||
const descPart = properties.description ? `, description: ${escapeValue(properties.description)}` : '';
|
||||
query = `CREATE (n:Section {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, level: ${properties.level || 1}, content: ${escapeValue(properties.content || '')}${descPart}})`;
|
||||
} else if (TABLES_WITH_EXPORTED.has(label)) {
|
||||
const descPart = properties.description ? `, description: ${escapeValue(properties.description)}` : '';
|
||||
query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${escapeValue(properties.content || '')}${descPart}})`;
|
||||
@@ -451,6 +506,9 @@ export const batchInsertNodesToLbug = async (
|
||||
query = `MERGE (n:File {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.content = ${escapeValue(properties.content || '')}`;
|
||||
} else if (label === 'Folder') {
|
||||
query = `MERGE (n:Folder {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}`;
|
||||
} else if (label === 'Section') {
|
||||
const descPart = properties.description ? `, n.description = ${escapeValue(properties.description)}` : '';
|
||||
query = `MERGE (n:Section {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.level = ${properties.level || 1}, n.content = ${escapeValue(properties.content || '')}${descPart}`;
|
||||
} else if (TABLES_WITH_EXPORTED.has(label)) {
|
||||
const descPart = properties.description ? `, n.description = ${escapeValue(properties.description)}` : '';
|
||||
query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${escapeValue(properties.content || '')}${descPart}`;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// NODE TABLE NAMES
|
||||
// ============================================================================
|
||||
export const NODE_TABLES = [
|
||||
'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement', 'Community', 'Process',
|
||||
'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement', 'Community', 'Process', 'Section',
|
||||
// Multi-language support
|
||||
'Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl',
|
||||
'TypeAlias', 'Const', 'Static', 'Property', 'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module'
|
||||
@@ -192,6 +192,19 @@ export const ANNOTATION_SCHEMA = CODE_ELEMENT_BASE('Annotation');
|
||||
export const CONSTRUCTOR_SCHEMA = CODE_ELEMENT_BASE('Constructor');
|
||||
export const TEMPLATE_SCHEMA = CODE_ELEMENT_BASE('Template');
|
||||
export const MODULE_SCHEMA = CODE_ELEMENT_BASE('Module');
|
||||
// Markdown heading sections
|
||||
export const SECTION_SCHEMA = `
|
||||
CREATE NODE TABLE Section (
|
||||
id STRING,
|
||||
name STRING,
|
||||
filePath STRING,
|
||||
startLine INT64,
|
||||
endLine INT64,
|
||||
level INT64,
|
||||
content STRING,
|
||||
description STRING,
|
||||
PRIMARY KEY (id)
|
||||
)`;
|
||||
|
||||
// ============================================================================
|
||||
// RELATION TABLE SCHEMA
|
||||
@@ -225,6 +238,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
||||
FROM File TO \`Constructor\`,
|
||||
FROM File TO \`Template\`,
|
||||
FROM File TO \`Module\`,
|
||||
FROM File TO Section,
|
||||
FROM Folder TO Folder,
|
||||
FROM Folder TO File,
|
||||
FROM Function TO Function,
|
||||
@@ -289,6 +303,8 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
||||
FROM \`Template\` TO Interface,
|
||||
FROM \`Template\` TO \`Constructor\`,
|
||||
FROM \`Module\` TO \`Module\`,
|
||||
FROM Section TO Section,
|
||||
FROM Section TO File,
|
||||
FROM CodeElement TO Community,
|
||||
FROM Interface TO Community,
|
||||
FROM Interface TO Function,
|
||||
@@ -398,10 +414,19 @@ 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[384],
|
||||
embedding FLOAT[${EMBEDDING_DIMS}],
|
||||
PRIMARY KEY (nodeId)
|
||||
)`;
|
||||
|
||||
@@ -447,6 +472,8 @@ export const NODE_SCHEMA_QUERIES = [
|
||||
CONSTRUCTOR_SCHEMA,
|
||||
TEMPLATE_SCHEMA,
|
||||
MODULE_SCHEMA,
|
||||
// Markdown support
|
||||
SECTION_SCHEMA,
|
||||
];
|
||||
|
||||
export const REL_SCHEMA_QUERIES = [
|
||||
|
||||
@@ -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,6 +20,10 @@ 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;
|
||||
}
|
||||
@@ -87,12 +91,16 @@ export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
|
||||
/**
|
||||
* Check if embedder is ready
|
||||
*/
|
||||
export const isEmbedderReady = (): boolean => embedderInstance !== null;
|
||||
export const isEmbedderReady = (): boolean => isHttpMode() || 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, {
|
||||
@@ -106,7 +114,9 @@ export const embedQuery = async (query: string): Promise<number[]> => {
|
||||
/**
|
||||
* Get embedding dimensions
|
||||
*/
|
||||
export const getEmbeddingDims = (): number => EMBEDDING_DIMS;
|
||||
export const getEmbeddingDims = (): number => {
|
||||
return getHttpDimensions() ?? 384;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleanup embedder
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user