Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88c4857a1f | ||
|
|
1bf9fb4ef1 | ||
|
|
a9a5e1c388 | ||
|
|
8cf9ae0e0d | ||
|
|
ac148612ab | ||
|
|
5d76dbcfa2 | ||
|
|
56e32b310b | ||
|
|
ac2012e5ed | ||
|
|
f73389eac3 | ||
|
|
22f0beb057 | ||
|
|
af1d278a7e | ||
|
|
afc0a8b6c5 | ||
|
|
d9da7d6692 | ||
|
|
131d411ae4 | ||
|
|
b8875b9c80 | ||
|
|
969b4623ca | ||
|
|
018e0e6b14 | ||
|
|
040bb7a489 | ||
|
|
725ed3fe66 | ||
|
|
509185b8f9 | ||
|
|
4988feec94 | ||
|
|
ef953beca9 | ||
|
|
0b5381695f | ||
|
|
30292d7179 | ||
|
|
7a98a01ad5 | ||
|
|
94cba48b6d | ||
|
|
925460ab5b | ||
|
|
08b4505197 | ||
|
|
d5225e699f | ||
|
|
0a3b9120a0 | ||
|
|
5544350e30 | ||
|
|
dfa449ef41 | ||
|
|
daca8360bf | ||
|
|
77a13113ea | ||
|
|
02739085d2 | ||
|
|
43098784cf | ||
|
|
f221f93341 | ||
|
|
a32f5b6adb | ||
|
|
ed5a4220dd | ||
|
|
03821faf58 | ||
|
|
06f18ada15 | ||
|
|
d024119b33 | ||
|
|
0a4b31b3c5 | ||
|
|
54d02fcc22 | ||
|
|
d50a523837 | ||
|
|
54c7e45a2f | ||
|
|
e947a82a18 | ||
|
|
978187b34a | ||
|
|
185cec70a1 | ||
|
|
1d0fb782a3 | ||
|
|
7001e8e4b4 | ||
|
|
ed07c18b8d | ||
|
|
df429ea60c | ||
|
|
b43cb5ee55 | ||
|
|
fec06b823c | ||
|
|
eb0d9c51a0 | ||
|
|
7a5ab57bd3 | ||
|
|
3fd4346bcb | ||
|
|
c2734cd25e | ||
|
|
8cb2f278cc | ||
|
|
f3df8ab7ba | ||
|
|
93cdfb27fa | ||
|
|
109a3c6946 | ||
|
|
1df79c2eab | ||
|
|
32c9ddaf32 | ||
|
|
385ee037bd | ||
|
|
28ddbe5d54 | ||
|
|
c100577e5e | ||
|
|
baf3f9e37d | ||
|
|
b340c5d87a |
@@ -0,0 +1 @@
|
||||
plans/
|
||||
@@ -0,0 +1,19 @@
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
dist
|
||||
**/dist
|
||||
coverage
|
||||
**/coverage
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
.gitnexus
|
||||
gitnexus-web/playwright-report
|
||||
gitnexus-web/test-results
|
||||
@@ -0,0 +1,3 @@
|
||||
IMAGE_NAME=ghcr.io/abhigyanpatwari/gitnexus:latest
|
||||
CONTAINER_NAME=gitnexus
|
||||
HOST_PORT=4173
|
||||
@@ -0,0 +1,75 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Keep third-party Actions SHA pins current. See CONTRIBUTING.md — when
|
||||
# reviewing these bumps, verify the SHA corresponds to the claimed tag by
|
||||
# running `gh api repos/<owner>/<action>/git/refs/tags/<tag>` before merge.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
- ci
|
||||
|
||||
# Gitnexus npm deps — tree-sitter grammars checked daily so we catch
|
||||
# new releases that unblock the tree-sitter 0.25 upgrade ASAP. Grammars
|
||||
# are grouped so lockstep bumps produce a single PR. The tree-sitter
|
||||
# RUNTIME is pinned — upgrade deliberately via the drift check workflow.
|
||||
# See .github/scripts/check-tree-sitter-upgrade-readiness.py for
|
||||
# the upgrade readiness tracker.
|
||||
- package-ecosystem: npm
|
||||
directory: /gitnexus
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
groups:
|
||||
tree-sitter-grammars:
|
||||
patterns:
|
||||
- tree-sitter-*
|
||||
exclude-patterns:
|
||||
- tree-sitter
|
||||
- tree-sitter-cli
|
||||
ignore:
|
||||
# Pin the tree-sitter runtime at 0.21.x until the drift check
|
||||
# reports all grammars are peer-dep compatible with 0.25.
|
||||
- dependency-name: tree-sitter
|
||||
update-types:
|
||||
- version-update:semver-major
|
||||
- version-update:semver-minor
|
||||
# tree-sitter-cli follows the runtime's version cadence. Bump when
|
||||
# regenerating vendor/tree-sitter-proto/src/parser.c, not on a schedule.
|
||||
- dependency-name: tree-sitter-cli
|
||||
|
||||
# gitnexus-web (thin frontend client).
|
||||
- package-ecosystem: npm
|
||||
directory: /gitnexus-web
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
- frontend
|
||||
|
||||
# Shared types package.
|
||||
- package-ecosystem: npm
|
||||
directory: /gitnexus-shared
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
@@ -0,0 +1,53 @@
|
||||
# release-drafter config — used only for PR autolabeling by
|
||||
# `.github/workflows/pr-labeler.yml` (the workflow passes `disable-releaser: true`,
|
||||
# so the draft-release side of release-drafter never runs).
|
||||
#
|
||||
# The labels applied here are the same ones `.github/release.yml` maps to
|
||||
# categorized release-notes sections.
|
||||
#
|
||||
# `sync-labels: true` removes managed autolabels that no longer match the PR —
|
||||
# critical for the breaking-change case: if a PR title drops the `!` or the body
|
||||
# drops `BREAKING CHANGE:`, the `breaking` label is pulled off automatically.
|
||||
|
||||
# Required by release-drafter; not used because releaser is disabled.
|
||||
name-template: 'unused'
|
||||
tag-template: 'unused'
|
||||
template: |
|
||||
$CHANGES
|
||||
|
||||
sync-labels: true
|
||||
|
||||
autolabeler:
|
||||
- label: enhancement
|
||||
title:
|
||||
- '/^feat(\([^)]+\))?!?:/i'
|
||||
- label: bug
|
||||
title:
|
||||
- '/^fix(\([^)]+\))?!?:/i'
|
||||
- label: performance
|
||||
title:
|
||||
- '/^perf(\([^)]+\))?!?:/i'
|
||||
- label: refactor
|
||||
title:
|
||||
- '/^refactor(\([^)]+\))?!?:/i'
|
||||
- label: documentation
|
||||
title:
|
||||
- '/^docs(\([^)]+\))?!?:/i'
|
||||
- label: test
|
||||
title:
|
||||
- '/^test(\([^)]+\))?!?:/i'
|
||||
- label: ci
|
||||
title:
|
||||
- '/^ci(\([^)]+\))?!?:/i'
|
||||
- label: dependencies
|
||||
title:
|
||||
- '/^(build|deps)(\([^)]+\))?!?:/i'
|
||||
- label: chore
|
||||
title:
|
||||
- '/^(chore|revert)(\([^)]+\))?!?:/i'
|
||||
# Breaking-change marker: either `!` in the type prefix or `BREAKING CHANGE:` in body.
|
||||
- label: breaking
|
||||
title:
|
||||
- '/^[a-z]+(\([^)]+\))?!:/i'
|
||||
body:
|
||||
- '/BREAKING[ -]CHANGE:/i'
|
||||
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Monitor tree-sitter 0.25 upgrade readiness.
|
||||
|
||||
Tracks two things Dependabot cannot see:
|
||||
|
||||
1. Peer-dep compatibility. Each tree-sitter-* grammar declares a peer
|
||||
dependency on the tree-sitter runtime. We want to know when every
|
||||
grammar's *latest npm release* satisfies tree-sitter@0.25.0 so we
|
||||
can upgrade without --legacy-peer-deps.
|
||||
|
||||
2. Vendored upstream drift. vendor/tree-sitter-proto/ is a snapshot of
|
||||
coder3101/tree-sitter-proto's parser.c. When upstream moves, we want
|
||||
to know whether we can pick it up.
|
||||
|
||||
Invoked from .github/workflows/tree-sitter-upgrade-readiness.yml daily.
|
||||
Runs locally too:
|
||||
|
||||
python3 .github/scripts/check-tree-sitter-upgrade-readiness.py
|
||||
|
||||
Outputs Markdown to stdout. Exit 0 when every grammar is upgrade-ready
|
||||
and the vendored proto is in sync. Exit 1 when blockers remain (the
|
||||
workflow uses this to open or update a tracking issue).
|
||||
|
||||
No external deps -- stdlib only, so it runs on any vanilla runner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
GITNEXUS_DIR = REPO_ROOT / "gitnexus"
|
||||
VENDOR_PROTO_DIR = GITNEXUS_DIR / "vendor" / "tree-sitter-proto"
|
||||
|
||||
# ── Upgrade target ──────────────────────────────────────────────────────
|
||||
# The runtime version we want to upgrade TO. Update this when the goal
|
||||
# changes (e.g. once 0.25 lands and we target 0.26).
|
||||
TARGET_RUNTIME = "0.25.0"
|
||||
TARGET_RUNTIME_MAJOR_MINOR = ".".join(TARGET_RUNTIME.split(".")[:2])
|
||||
|
||||
# Tree-sitter runtime -> (min_abi, max_abi) it can load. Only the current
|
||||
# and target entries matter; extend when changing TARGET_RUNTIME.
|
||||
RUNTIME_ABI_RANGES: dict[str, tuple[int, int]] = {
|
||||
"0.21": (13, 14),
|
||||
"0.25": (13, 15),
|
||||
}
|
||||
|
||||
assert TARGET_RUNTIME_MAJOR_MINOR in RUNTIME_ABI_RANGES, (
|
||||
f"RUNTIME_ABI_RANGES has no entry for {TARGET_RUNTIME_MAJOR_MINOR!r}. "
|
||||
f"Add the ABI range after auditing the upstream release notes."
|
||||
)
|
||||
|
||||
# Grammars we use. Values are the upstream GitHub repos to check for
|
||||
# unreleased ABI bumps (owner/repo, branch, parser.c path).
|
||||
GRAMMARS: dict[str, tuple[str, str, str]] = {
|
||||
"tree-sitter-c": ("tree-sitter/tree-sitter-c", "master", "src/parser.c"),
|
||||
"tree-sitter-c-sharp": ("tree-sitter/tree-sitter-c-sharp", "master", "src/parser.c"),
|
||||
"tree-sitter-cpp": ("tree-sitter/tree-sitter-cpp", "master", "src/parser.c"),
|
||||
"tree-sitter-dart": ("UserNobody14/tree-sitter-dart", "master", "src/parser.c"),
|
||||
"tree-sitter-go": ("tree-sitter/tree-sitter-go", "master", "src/parser.c"),
|
||||
"tree-sitter-java": ("tree-sitter/tree-sitter-java", "master", "src/parser.c"),
|
||||
"tree-sitter-javascript": ("tree-sitter/tree-sitter-javascript", "master", "src/parser.c"),
|
||||
"tree-sitter-kotlin": ("fwcd/tree-sitter-kotlin", "main", "src/parser.c"),
|
||||
"tree-sitter-php": ("tree-sitter/tree-sitter-php", "master", "php/src/parser.c"),
|
||||
"tree-sitter-python": ("tree-sitter/tree-sitter-python", "master", "src/parser.c"),
|
||||
"tree-sitter-ruby": ("tree-sitter/tree-sitter-ruby", "master", "src/parser.c"),
|
||||
"tree-sitter-rust": ("tree-sitter/tree-sitter-rust", "master", "src/parser.c"),
|
||||
"tree-sitter-swift": ("alex-pinkus/tree-sitter-swift", "main", "src/parser.c"),
|
||||
"tree-sitter-typescript": ("tree-sitter/tree-sitter-typescript", "master", "typescript/src/parser.c"),
|
||||
}
|
||||
|
||||
UPSTREAM_PROTO_OWNER = "coder3101"
|
||||
UPSTREAM_PROTO_REPO = "tree-sitter-proto"
|
||||
UPSTREAM_PROTO_BRANCH = "main"
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def read_current_runtime() -> str:
|
||||
"""Return the tree-sitter runtime version pinned in package.json (e.g. '0.21')."""
|
||||
pkg = json.loads((GITNEXUS_DIR / "package.json").read_text())
|
||||
raw = pkg["dependencies"]["tree-sitter"]
|
||||
match = re.search(r"(\d+)\.(\d+)", raw)
|
||||
if not match:
|
||||
raise SystemExit(f"could not parse tree-sitter version: {raw!r}")
|
||||
return f"{match.group(1)}.{match.group(2)}"
|
||||
|
||||
|
||||
def npm_view_json(pkg: str) -> dict | None:
|
||||
"""Fetch package metadata from the npm registry via HTTPS.
|
||||
|
||||
Uses the registry API directly so we don't depend on the npm CLI
|
||||
being available (it's a batch file on Windows which complicates
|
||||
subprocess calls).
|
||||
"""
|
||||
url = f"https://registry.npmjs.org/{pkg}/latest"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def satisfies_target(peer_range: str | None, target: str) -> bool:
|
||||
"""Check if a semver range like '^0.22.4' or '^0.25.0' satisfies the target.
|
||||
|
||||
Simple heuristic: extract the minimum version from the range and check
|
||||
if target >= min. For caret ranges (^X.Y.Z), the upper bound is the
|
||||
next major (for X>0) or next minor (for X==0). We check both bounds.
|
||||
"""
|
||||
if peer_range is None:
|
||||
# No peer dep declared = no constraint = compatible.
|
||||
return True
|
||||
match = re.search(r"(\d+)\.(\d+)\.(\d+)", peer_range)
|
||||
if not match:
|
||||
return False
|
||||
min_major, min_minor, min_patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||||
|
||||
t_match = re.search(r"(\d+)\.(\d+)\.(\d+)", target)
|
||||
if not t_match:
|
||||
return False
|
||||
t_major, t_minor, t_patch = int(t_match.group(1)), int(t_match.group(2)), int(t_match.group(3))
|
||||
|
||||
# Target must be >= minimum.
|
||||
target_tuple = (t_major, t_minor, t_patch)
|
||||
min_tuple = (min_major, min_minor, min_patch)
|
||||
if target_tuple < min_tuple:
|
||||
return False
|
||||
|
||||
# For caret ranges with major 0: ^0.X.Y allows [0.X.Y, 0.(X+1).0).
|
||||
if peer_range.startswith("^") and min_major == 0:
|
||||
if t_major != 0 or t_minor >= min_minor + 1:
|
||||
return False
|
||||
# For caret ranges with major >0: ^X.Y.Z allows [X.Y.Z, (X+1).0.0).
|
||||
elif peer_range.startswith("^") and min_major > 0:
|
||||
if t_major >= min_major + 1:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
_GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN")
|
||||
|
||||
|
||||
def fetch_text(url: str, timeout: int = 8) -> str | None:
|
||||
"""Fetch a URL and return its text, or None on failure.
|
||||
|
||||
Adds an Authorization header for github.com URLs when GITHUB_TOKEN is
|
||||
set (raises the rate limit from 60 to 5 000 requests/hour).
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
if _GITHUB_TOKEN and ("github.com" in url or "githubusercontent.com" in url):
|
||||
headers["Authorization"] = f"Bearer {_GITHUB_TOKEN}"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.read().decode("utf-8", errors="ignore")
|
||||
except (urllib.error.URLError, urllib.error.HTTPError):
|
||||
return None
|
||||
|
||||
|
||||
def extract_abi_from_text(text: str) -> int | None:
|
||||
"""Extract LANGUAGE_VERSION from parser.c text."""
|
||||
match = re.search(r"#define\s+LANGUAGE_VERSION\s+(\d+)", text[:4096])
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def extract_language_version(parser_c: pathlib.Path) -> int | None:
|
||||
"""Return the LANGUAGE_VERSION defined in a parser.c, or None if absent."""
|
||||
if not parser_c.is_file():
|
||||
return None
|
||||
with parser_c.open("r", encoding="utf-8", errors="ignore") as fh:
|
||||
head = fh.read(4096)
|
||||
return extract_abi_from_text(head)
|
||||
|
||||
|
||||
def md_h(text: str, level: int = 2) -> str:
|
||||
return f"{'#' * level} {text}\n"
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> int:
|
||||
blockers: dict[str, str] = {}
|
||||
lines: list[str] = []
|
||||
lines.append(md_h("Tree-sitter 0.25 upgrade readiness", 1))
|
||||
lines.append("")
|
||||
|
||||
current_runtime = read_current_runtime()
|
||||
current_abi_range = RUNTIME_ABI_RANGES.get(current_runtime, (0, 0))
|
||||
target_abi_range = RUNTIME_ABI_RANGES.get(TARGET_RUNTIME_MAJOR_MINOR, (0, 0))
|
||||
|
||||
lines.append(f"- Current runtime: `tree-sitter@{current_runtime}.x` (ABI {current_abi_range[0]}..{current_abi_range[1]})")
|
||||
lines.append(f"- Target runtime: `tree-sitter@{TARGET_RUNTIME}` (ABI {target_abi_range[0]}..{target_abi_range[1]})")
|
||||
lines.append("")
|
||||
|
||||
# ── Grammar peer-dep compatibility ───────────────────────────────
|
||||
lines.append(md_h("Grammar compatibility", 2))
|
||||
lines.append("| Grammar | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |")
|
||||
lines.append("|---|---|---|---|---|---|---|")
|
||||
|
||||
ready_count = 0
|
||||
total_count = len(GRAMMARS)
|
||||
|
||||
for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()):
|
||||
# Fetch latest npm metadata.
|
||||
info = npm_view_json(name)
|
||||
fetch_failed = info is None
|
||||
npm_version = "?"
|
||||
peer_range = None
|
||||
peer_optional = True
|
||||
if info:
|
||||
npm_version = info.get("version", "?")
|
||||
peers = info.get("peerDependencies") or {}
|
||||
peer_range = peers.get("tree-sitter")
|
||||
meta = info.get("peerDependenciesMeta") or {}
|
||||
ts_meta = meta.get("tree-sitter") or {}
|
||||
peer_optional = ts_meta.get("optional", False) if peer_range else True
|
||||
|
||||
if fetch_failed:
|
||||
peer_display = "? (fetch failed)"
|
||||
compatible = False
|
||||
else:
|
||||
peer_display = peer_range or "none"
|
||||
if peer_range and not peer_optional:
|
||||
peer_display += " (required)"
|
||||
compatible = satisfies_target(peer_range, TARGET_RUNTIME)
|
||||
|
||||
# Check installed ABI using the same parser_path from GRAMMARS.
|
||||
installed_parser = GITNEXUS_DIR / "node_modules" / name / parser_path
|
||||
if not installed_parser.is_file():
|
||||
# Fallback to default location.
|
||||
installed_parser = GITNEXUS_DIR / "node_modules" / name / "src" / "parser.c"
|
||||
installed_abi = extract_language_version(installed_parser)
|
||||
abi_display = str(installed_abi) if installed_abi else "?"
|
||||
|
||||
# Check upstream (main/master branch) ABI for unreleased work.
|
||||
upstream_url = (
|
||||
f"https://raw.githubusercontent.com/{upstream_repo}/"
|
||||
f"{upstream_branch}/{parser_path}"
|
||||
)
|
||||
upstream_text = fetch_text(upstream_url)
|
||||
upstream_abi = extract_abi_from_text(upstream_text) if upstream_text else None
|
||||
upstream_abi_display = str(upstream_abi) if upstream_abi else "?"
|
||||
|
||||
# Determine status.
|
||||
if fetch_failed:
|
||||
status = "Unknown (fetch failed)"
|
||||
blockers[name] = f"`{name}`: npm registry fetch failed — could not verify peer dep"
|
||||
elif compatible:
|
||||
status = "Ready"
|
||||
ready_count += 1
|
||||
elif upstream_abi and upstream_abi >= 15:
|
||||
status = "Unreleased (ABI 15 on main)"
|
||||
blockers[name] = f"`{name}`: ABI 15 on `{upstream_repo}` main but not published to npm"
|
||||
else:
|
||||
status = "Blocking"
|
||||
blockers[name] = f"`{name}@{npm_version}`: peer `{peer_display}` incompatible with 0.25"
|
||||
|
||||
# Also check upstream package.json for relaxed peer dep.
|
||||
if not compatible and not fetch_failed:
|
||||
upstream_pkg_url = (
|
||||
f"https://raw.githubusercontent.com/{upstream_repo}/"
|
||||
f"{upstream_branch}/package.json"
|
||||
)
|
||||
upstream_pkg_text = fetch_text(upstream_pkg_url)
|
||||
if upstream_pkg_text:
|
||||
try:
|
||||
upstream_pkg = json.loads(upstream_pkg_text)
|
||||
upstream_peer = (upstream_pkg.get("peerDependencies") or {}).get("tree-sitter")
|
||||
if upstream_peer and satisfies_target(upstream_peer, TARGET_RUNTIME):
|
||||
status = "Unreleased (peer relaxed on main)"
|
||||
blockers[name] = f"`{name}`: peer dep relaxed on `{upstream_repo}` main but not published to npm"
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
compat_icon = "Yes" if compatible else "**No**"
|
||||
lines.append(
|
||||
f"| `{name}` | {npm_version} | {peer_display} | {compat_icon} | {abi_display} | {upstream_abi_display} | {status} |"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"**{ready_count}/{total_count}** grammars ready for `tree-sitter@{TARGET_RUNTIME}`.")
|
||||
lines.append("")
|
||||
|
||||
# ── Vendored proto drift ─────────────────────────────────────────
|
||||
lines.append(md_h("Vendored tree-sitter-proto", 2))
|
||||
vendored_abi = extract_language_version(VENDOR_PROTO_DIR / "src" / "parser.c")
|
||||
|
||||
upstream_proto_url = (
|
||||
f"https://raw.githubusercontent.com/{UPSTREAM_PROTO_OWNER}/"
|
||||
f"{UPSTREAM_PROTO_REPO}/{UPSTREAM_PROTO_BRANCH}/src/parser.c"
|
||||
)
|
||||
upstream_proto_text = fetch_text(upstream_proto_url)
|
||||
upstream_proto_abi = extract_abi_from_text(upstream_proto_text) if upstream_proto_text else None
|
||||
|
||||
sha_url = (
|
||||
f"https://api.github.com/repos/{UPSTREAM_PROTO_OWNER}/"
|
||||
f"{UPSTREAM_PROTO_REPO}/commits/{UPSTREAM_PROTO_BRANCH}"
|
||||
)
|
||||
sha_text = fetch_text(sha_url)
|
||||
upstream_sha = "?"
|
||||
if sha_text:
|
||||
try:
|
||||
upstream_sha = json.loads(sha_text).get("sha", "?")[:12]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
local_proto_path = VENDOR_PROTO_DIR / "src" / "parser.c"
|
||||
local_proto_text = local_proto_path.read_text(encoding="utf-8", errors="ignore") if local_proto_path.is_file() else ""
|
||||
in_sync = bool(
|
||||
upstream_proto_text
|
||||
and local_proto_text.replace("\r\n", "\n")
|
||||
== upstream_proto_text.replace("\r\n", "\n")
|
||||
)
|
||||
|
||||
lines.append(f"- Upstream: `{UPSTREAM_PROTO_OWNER}/{UPSTREAM_PROTO_REPO}@{UPSTREAM_PROTO_BRANCH}` (HEAD `{upstream_sha}`)")
|
||||
lines.append(f"- Upstream ABI: **{upstream_proto_abi}**")
|
||||
lines.append(f"- Vendored ABI: **{vendored_abi}**")
|
||||
lines.append(f"- In sync: {'yes' if in_sync else 'no — upstream has diverged'}")
|
||||
|
||||
if upstream_proto_abi and vendored_abi and upstream_proto_abi > vendored_abi:
|
||||
can_upgrade = upstream_proto_abi <= target_abi_range[1]
|
||||
lines.append(f"- Upstream ABI {upstream_proto_abi} {'is' if can_upgrade else 'is NOT'} within target runtime range ({target_abi_range[0]}..{target_abi_range[1]})")
|
||||
if can_upgrade:
|
||||
lines.append(f"- **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, regenerate vendored parser.c from upstream `{upstream_sha}`")
|
||||
else:
|
||||
lines.append(f"- **Action:** wait for runtime upgrade beyond {TARGET_RUNTIME} that supports ABI {upstream_proto_abi}")
|
||||
blockers["vendored-proto-abi"] = f"vendored tree-sitter-proto: upstream ABI {upstream_proto_abi} outside target range"
|
||||
elif not in_sync:
|
||||
lines.append("- **Action:** review upstream changes; vendored copy may need updating")
|
||||
blockers["vendored-proto-sync"] = "vendored tree-sitter-proto: out of sync with upstream"
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────
|
||||
lines.append("")
|
||||
lines.append(md_h("Summary", 2))
|
||||
if blockers:
|
||||
lines.append(f"**{len(blockers)} blocker(s) remaining:**\n")
|
||||
for b in blockers.values():
|
||||
lines.append(f"- {b}")
|
||||
lines.append("")
|
||||
lines.append("Upgrade to `tree-sitter@0.25` is **blocked**.")
|
||||
else:
|
||||
lines.append("All grammars are compatible. Upgrade to `tree-sitter@0.25` is **ready**.")
|
||||
|
||||
print("\n".join(lines))
|
||||
return 1 if blockers else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enforce the GitHub Actions concurrency convention.
|
||||
|
||||
See CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention" for the rules.
|
||||
|
||||
Invoked from .github/workflows/ci-quality.yml. Runs locally too:
|
||||
python3 .github/scripts/check-workflow-concurrency.py .github/workflows
|
||||
|
||||
Rules:
|
||||
1. Every entry-point (non-reusable) workflow declares a top-level
|
||||
`concurrency:` block.
|
||||
2. Reusable workflows (on: workflow_call ONLY) do NOT declare one.
|
||||
3. The `concurrency.group` expression MUST reference either
|
||||
`${{ github.workflow }}` or a literal `CI-` prefix (the documented
|
||||
ci.yml reusable-workflow-safe exception). This is checked by substring
|
||||
containment rather than prefix match because ci.yml's group is a
|
||||
conditional expression that resolves to a `CI-…` literal at runtime.
|
||||
|
||||
We deliberately do not use a YAML library — keeps the script dependency-free
|
||||
on any vanilla runner. `on:` block parsing is line-based and handles both the
|
||||
flat (`on: workflow_call`) and mapping (`on:\n workflow_call:`) forms.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-")
|
||||
|
||||
|
||||
def is_reusable(lines: list[str]) -> bool:
|
||||
"""Return True iff the workflow's `on:` block names only `workflow_call`."""
|
||||
in_on = False
|
||||
on_indent: int | None = None
|
||||
keys: list[str] = []
|
||||
|
||||
for raw in lines:
|
||||
# Skip blank lines and comments
|
||||
stripped = raw.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
indent = len(raw) - len(raw.lstrip(" "))
|
||||
|
||||
if not in_on:
|
||||
if raw.startswith("on:"):
|
||||
remainder = raw[len("on:"):].strip()
|
||||
if not remainder:
|
||||
# `on:` followed by indented mapping on next lines
|
||||
in_on = True
|
||||
on_indent = indent
|
||||
continue
|
||||
if remainder.startswith("[") and remainder.endswith("]"):
|
||||
# Flow-style list: on: [workflow_call]
|
||||
items = [
|
||||
item.strip() for item in remainder.strip("[]").split(",")
|
||||
]
|
||||
return items == ["workflow_call"]
|
||||
# Scalar form: on: workflow_call (or a single other event)
|
||||
return remainder == "workflow_call"
|
||||
continue
|
||||
|
||||
# Inside the `on:` block; stop when indentation returns to <= on_indent
|
||||
if on_indent is not None and indent <= on_indent:
|
||||
break
|
||||
|
||||
# Only consider keys at on_indent + indentation step (anything deeper
|
||||
# is nested config like `types:`)
|
||||
if ":" not in stripped:
|
||||
continue
|
||||
# Heuristic: first-level event keys are those with indent == on_indent + 2
|
||||
# (the canonical step for a 2-space YAML doc). We collect all first-level
|
||||
# keys by tracking the smallest indent seen inside the block.
|
||||
keys.append((indent, stripped.split(":", 1)[0].strip()))
|
||||
|
||||
if not keys:
|
||||
return False
|
||||
|
||||
# Take only the outermost-indented keys as the event list
|
||||
min_indent = min(i for i, _ in keys)
|
||||
events = [name for i, name in keys if i == min_indent]
|
||||
return events == ["workflow_call"]
|
||||
|
||||
|
||||
CONCURRENCY_RE = re.compile(r"^concurrency:\s*$")
|
||||
GROUP_RE = re.compile(r"^\s+group:\s*(.+?)\s*$")
|
||||
|
||||
|
||||
def extract_group_key(lines: list[str]) -> str | None:
|
||||
"""Return the `group:` value of the top-level `concurrency:` block, or None."""
|
||||
for idx, raw in enumerate(lines):
|
||||
if CONCURRENCY_RE.match(raw):
|
||||
# Scan forward until we leave the concurrency block (next top-level key
|
||||
# is at column 0 and ends with `:`).
|
||||
for follow in lines[idx + 1:]:
|
||||
if follow and not follow.startswith(" ") and follow.rstrip().endswith(":"):
|
||||
break
|
||||
m = GROUP_RE.match(follow)
|
||||
if m:
|
||||
return m.group(1).strip().strip("'").strip('"')
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def has_top_level_concurrency(lines: list[str]) -> bool:
|
||||
return any(CONCURRENCY_RE.match(raw) for raw in lines)
|
||||
|
||||
|
||||
def check(workflows_dir: pathlib.Path) -> int:
|
||||
fail = 0
|
||||
files = sorted(
|
||||
list(workflows_dir.glob("*.yml")) + list(workflows_dir.glob("*.yaml"))
|
||||
)
|
||||
for path in files:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
reusable = is_reusable(lines)
|
||||
has_conc = has_top_level_concurrency(lines)
|
||||
|
||||
if reusable:
|
||||
if has_conc:
|
||||
print(
|
||||
f"::error file={path}::Reusable workflow (on: workflow_call) "
|
||||
"must NOT declare its own concurrency block — it inherits "
|
||||
"from the caller. See CONTRIBUTING.md -> GitHub Actions — "
|
||||
"Concurrency Convention."
|
||||
)
|
||||
fail = 1
|
||||
continue
|
||||
|
||||
if not has_conc:
|
||||
print(
|
||||
f"::error file={path}::Missing top-level concurrency block. "
|
||||
"See CONTRIBUTING.md -> GitHub Actions — Concurrency Convention."
|
||||
)
|
||||
fail = 1
|
||||
continue
|
||||
|
||||
group = extract_group_key(lines)
|
||||
if group is None:
|
||||
print(
|
||||
f"::error file={path}::concurrency block is missing a "
|
||||
"`group:` key."
|
||||
)
|
||||
fail = 1
|
||||
continue
|
||||
|
||||
if not any(token in group for token in REQUIRED_TOKENS):
|
||||
print(
|
||||
f"::error file={path}::concurrency.group `{group}` must "
|
||||
f"reference one of {REQUIRED_TOKENS}. See CONTRIBUTING.md -> "
|
||||
"GitHub Actions — Concurrency Convention."
|
||||
)
|
||||
fail = 1
|
||||
|
||||
return fail
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 2:
|
||||
print(f"usage: {argv[0]} <workflows-dir>", file=sys.stderr)
|
||||
return 2
|
||||
workflows_dir = pathlib.Path(argv[1])
|
||||
if not workflows_dir.is_dir():
|
||||
print(f"not a directory: {workflows_dir}", file=sys.stderr)
|
||||
return 2
|
||||
return check(workflows_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -11,8 +11,8 @@ jobs:
|
||||
outputs:
|
||||
web_changed: ${{ steps.filter.outputs.web }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: ./.github/actions/setup-gitnexus-web
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-results
|
||||
path: |
|
||||
|
||||
@@ -8,8 +8,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
@@ -21,8 +21,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- run: npx tsc --noEmit
|
||||
working-directory: gitnexus
|
||||
@@ -43,7 +43,30 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus-web
|
||||
- run: npx tsc -b --noEmit
|
||||
working-directory: gitnexus-web
|
||||
|
||||
# Enforces the convention documented in CONTRIBUTING.md → "GitHub Actions —
|
||||
# Concurrency Convention":
|
||||
# 1. Every entry-point (non-reusable) workflow declares a top-level
|
||||
# `concurrency:` block.
|
||||
# 2. Reusable workflows (`on: workflow_call` only) do NOT declare one —
|
||||
# they inherit concurrency from the caller.
|
||||
# 3. The concurrency group key starts with `${{ github.workflow }}` or
|
||||
# the literal `CI-` prefix (the documented ci.yml exception for
|
||||
# reusable-workflow-safe grouping).
|
||||
# Reusability is detected by parsing each workflow's `on:` block, not an
|
||||
# allowlist, so new reusable workflows never produce false positives.
|
||||
workflow-convention:
|
||||
name: Workflow concurrency convention
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Validate workflow concurrency convention
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 .github/scripts/check-workflow-concurrency.py .github/workflows
|
||||
|
||||
@@ -14,6 +14,16 @@ permissions:
|
||||
contents: read # needed for sparse checkout of vitest.config.ts
|
||||
pull-requests: write # needed to post sticky PR comment
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Serialize sticky-comment writes per PR so two rapid CI completions don't race.
|
||||
# Internal PRs surface in `pull_requests[0].number`. Fork PRs leave that array empty,
|
||||
# so we fall back to `<head-repo-full-name>/<head-branch>`, which is stable across
|
||||
# reruns and subsequent pushes for the same fork PR (unlike `workflow_run.id` which
|
||||
# is unique per run and therefore does not serialize anything).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
pr-report:
|
||||
name: PR Report
|
||||
@@ -26,7 +36,7 @@ jobs:
|
||||
steps:
|
||||
# ── Download artifacts from the CI run ────────────────────────
|
||||
- name: Download artifacts
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
@@ -113,7 +123,7 @@ jobs:
|
||||
|
||||
- name: Checkout (for vitest config)
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: gitnexus/vitest.config.ts
|
||||
sparse-checkout-cone-mode: false
|
||||
@@ -122,7 +132,7 @@ jobs:
|
||||
- name: Fetch base branch coverage
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
id: base-coverage
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
@@ -406,7 +416,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v2
|
||||
with:
|
||||
header: ci-report
|
||||
number: ${{ steps.meta.outputs.pr_number }}
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
@@ -41,9 +41,12 @@ jobs:
|
||||
--outputFile=web-test-results.json
|
||||
working-directory: gitnexus-web
|
||||
|
||||
- name: Run docker-server integration tests
|
||||
run: node --test docker-server.test.mjs
|
||||
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-reports
|
||||
path: |
|
||||
@@ -63,7 +66,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
@@ -9,9 +9,20 @@ on:
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
workflow_call:
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Hardcoded `CI-` prefix (not `${{ github.workflow }}`) because this workflow is
|
||||
# invoked as a reusable workflow from publish.yml and release-candidate.yml. In
|
||||
# called-workflow context `github.workflow` evaluation is ambiguous across GitHub
|
||||
# Actions versions, and a prefix that could resolve to the caller's name would
|
||||
# share a concurrency group with the caller → deadlock. A literal prefix is
|
||||
# immune. Direct `push`/`pull_request` invocations use `CI-<ref>`; invocations
|
||||
# from a reusable-workflow caller fall into a per-run-unique group that never
|
||||
# serializes with the caller.
|
||||
# cancel-in-progress is event-aware: cancel superseded PR runs, queue every other
|
||||
# event (push to main, workflow_call from publish.yml, etc.).
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && format('CI-{0}', github.ref) || format('CI-nested-{0}', github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# ── Reusable workflow orchestration ─────────────────────────────────
|
||||
# Each concern lives in its own workflow file for maintainability:
|
||||
@@ -74,7 +85,7 @@ jobs:
|
||||
cp pr-meta/e2e_result pr-meta/e2e-result
|
||||
|
||||
- name: Upload PR metadata
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: pr-meta
|
||||
path: pr-meta/
|
||||
@@ -96,9 +107,9 @@ jobs:
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
E2E: ${{ needs.e2e.result }}
|
||||
run: |
|
||||
echo "Quality: $QUALITY"
|
||||
echo "Tests: $TESTS"
|
||||
echo "E2E: $E2E"
|
||||
echo "Quality: $QUALITY"
|
||||
echo "Tests: $TESTS"
|
||||
echo "E2E: $E2E"
|
||||
if [[ "$QUALITY" != "success" ]] ||
|
||||
[[ "$TESTS" != "success" ]]; then
|
||||
echo "::error::Quality or test jobs failed"
|
||||
|
||||
@@ -16,9 +16,10 @@ on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Serialize per-PR to avoid racing review comments.
|
||||
concurrency:
|
||||
group: claude-review-${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -56,7 +57,7 @@ jobs:
|
||||
# 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
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
|
||||
with:
|
||||
script: |
|
||||
let pr;
|
||||
@@ -76,7 +77,7 @@ jobs:
|
||||
core.setOutput('branch', pr.head.ref);
|
||||
|
||||
- name: Checkout PR head
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.repo }}
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
|
||||
@@ -10,9 +10,10 @@ on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# 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 }}
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.issue.id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -58,7 +59,7 @@ jobs:
|
||||
# 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
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
|
||||
with:
|
||||
script: |
|
||||
// Determine if this event is PR-related
|
||||
@@ -90,7 +91,7 @@ jobs:
|
||||
core.setOutput('branch', pr.head.ref);
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }}
|
||||
ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Docker Build & Push
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
branches:
|
||||
- main
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
workflow_dispatch:
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Tag refs are unique per release — distinct tags run in parallel.
|
||||
# Pushes to main serialize; cancel superseded runs.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
name: Build & Push image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# Required for multi-platform (linux/arm64) emulation.
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Computes image tags and labels from Git metadata:
|
||||
# v* tag → ghcr.io/<owner>/<repo>:<semver> (e.g. 1.2.3, 1.2, 1)
|
||||
# main push → ghcr.io/<owner>/<repo>:latest
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
BUILDPLATFORM=${{ runner.os == 'Linux' && 'linux/amd64' || 'linux/amd64' }}
|
||||
@@ -8,8 +8,9 @@ on:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
concurrency:
|
||||
group: pr-desc-${{ github.event.pull_request.number }}
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -18,7 +19,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check PR description quality
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const MIN_BODY_LENGTH = 50;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
name: PR Conventional Labeler
|
||||
|
||||
# Two workflows in one file with different triggers, matched to the minimum
|
||||
# privilege each needs:
|
||||
#
|
||||
# validate-title (on: pull_request)
|
||||
# Fork-safe. Runs with the PR-head's read-only GITHUB_TOKEN. Uses
|
||||
# `amannn/action-semantic-pull-request` to fail the check when the PR
|
||||
# title doesn't follow the conventional-commit format. Because the
|
||||
# action only reads the event payload, no fork-controlled code runs.
|
||||
#
|
||||
# autolabel (on: pull_request_target)
|
||||
# Needs `pull-requests: write` to apply labels, so must be
|
||||
# pull_request_target. Uses `release-drafter/release-drafter` with
|
||||
# `dry-run: true` to only run the autolabeler against the
|
||||
# `.github/release-drafter.yml` config from the BASE ref (release-
|
||||
# drafter reads the config from the repository's default branch, NOT
|
||||
# the PR head — verify with `gh api repos/release-drafter/release-drafter/contents/...`
|
||||
# or a fork-test PR before merging if the repo is high-value).
|
||||
# `sync-labels: true` in the config removes managed autolabels that no
|
||||
# longer match (e.g. when `!` or `BREAKING CHANGE:` is dropped).
|
||||
#
|
||||
# Title format: <type>[(scope)][!]: <subject>
|
||||
# Allowed types: feat, fix, perf, refactor, docs, test, ci, build, chore, revert, deps
|
||||
# Trailing `!` on the type marks a breaking change.
|
||||
# See CONTRIBUTING.md → "Pull request titles".
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# Title-only changes fire `edited`. `opened` and `reopened` cover creation.
|
||||
# `synchronize` (push to the PR branch) is intentionally excluded — titles
|
||||
# don't change on push, so it only wastes CI minutes and broadens the
|
||||
# privileged-token exposure window on the autolabel job.
|
||||
types: [opened, edited, reopened]
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened]
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Include `github.event_name` so `pull_request` (validate-title) and
|
||||
# `pull_request_target` (autolabel) runs for the same PR do NOT share a slot
|
||||
# and therefore cannot cancel each other — a cancelled required-check would
|
||||
# permanently block merge until the next title edit.
|
||||
# Within each trigger the latest title edit still supersedes the prior run.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate-title:
|
||||
# Fork-safe job — only runs on `pull_request` (not `pull_request_target`).
|
||||
# Token is read-only; writes a commit status that branch protection can
|
||||
# require before merge.
|
||||
name: Validate PR title
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
# Pinned to v6.1.1. Verify SHA via:
|
||||
# gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v6.1.1
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
perf
|
||||
refactor
|
||||
docs
|
||||
test
|
||||
ci
|
||||
build
|
||||
chore
|
||||
revert
|
||||
deps
|
||||
requireScope: false
|
||||
# Subject must be non-empty. We DO allow capitalized proper nouns
|
||||
# (MCP, GitHub, API, etc.) — the old `^(?![A-Z]).+$` pattern
|
||||
# rejected legitimate titles like `fix: MCP tool schema`.
|
||||
subjectPattern: ^\S.{2,}$
|
||||
subjectPatternError: |
|
||||
The subject "{subject}" in PR title "{title}" is invalid.
|
||||
Subjects must be at least 3 characters and must not start with whitespace.
|
||||
wip: false
|
||||
|
||||
autolabel:
|
||||
# Privileged job — runs only on `pull_request_target` so it can write labels.
|
||||
# Never checks out fork code, never executes fork-controlled input; only
|
||||
# reads the PR metadata (title, body, labels) and calls the GitHub API.
|
||||
name: Apply conventional label
|
||||
if: github.event_name == 'pull_request_target'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
# `contents: read` is required — release-drafter's context.config() reads
|
||||
# `.github/release-drafter.yml` from the repo's default branch via the
|
||||
# repo-contents API. Without it the job silently 403s and no labels are
|
||||
# applied. Job-level permissions nullify all unlisted scopes, so an
|
||||
# explicit grant is necessary here.
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
# Pinned to v7.2.0. Verify SHA via:
|
||||
# gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0
|
||||
# v7 removed `disable-releaser`; use `dry-run: true` to only autolabel.
|
||||
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
dry-run: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -7,13 +7,22 @@ on:
|
||||
|
||||
# No workflow-level permissions — scoped per job below.
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Tag refs are unique per release, so distinct tags run in parallel. Re-pushes of the
|
||||
# same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
# No pull-requests:write — `ci.yml`'s save-pr-meta job is gated on
|
||||
# `github.event_name == 'pull_request'`, so it never runs during a
|
||||
# tag-triggered publish. Least-privilege for release-critical paths.
|
||||
|
||||
publish:
|
||||
needs: ci
|
||||
@@ -23,8 +32,8 @@ jobs:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org
|
||||
@@ -82,7 +91,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2
|
||||
with:
|
||||
body_path: ${{ steps.changelog.outputs.fallback == 'false' && '/tmp/release-notes.md' || '' }}
|
||||
generate_release_notes: ${{ steps.changelog.outputs.fallback == 'true' }}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
name: Release Candidate
|
||||
|
||||
on:
|
||||
# Publish a release-candidate build whenever a merge/commit lands on main.
|
||||
# Docs/README-only changes are filtered out so prose updates don't
|
||||
# cut a release.
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
- 'LICENSE'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: >-
|
||||
Cycle policy. 'auto' (default) continues the active rc cycle on
|
||||
this branch if there is one, otherwise bumps patch from latest.
|
||||
Choose 'patch' / 'minor' / 'major' to explicitly start or reset
|
||||
an rc cycle.
|
||||
required: false
|
||||
default: 'auto'
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
force:
|
||||
description: 'Publish even when HEAD already has an rc marker'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: choice
|
||||
options:
|
||||
- 'false'
|
||||
- 'true'
|
||||
|
||||
# No workflow-level permissions — scoped per job below.
|
||||
permissions: {}
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Serialize all runs on the same ref (push + workflow_dispatch) to prevent two publishes
|
||||
# racing on the rc counter. cancel-in-progress: false — the earlier merge publishes first.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# ── Skip when HEAD already has an rc marker (retry / duplicate dispatch) ──
|
||||
# The marker is a lightweight tag `rc/<HEAD_SHA>` pushed *before* `npm
|
||||
# publish`, so a failed publish leaves the marker in place and the guard
|
||||
# refuses to re-publish. Recovery path after a partial failure:
|
||||
# git push --delete origin rc/<HEAD_SHA> v<RC_VERSION>
|
||||
# then redispatch with force=true.
|
||||
guard:
|
||||
name: Check if release candidate should run
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should_run: ${{ steps.decide.outputs.should_run }}
|
||||
head_sha: ${{ steps.decide.outputs.head_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Decide
|
||||
id: decide
|
||||
shell: bash
|
||||
env:
|
||||
FORCE: ${{ inputs.force }}
|
||||
BUMP_INPUT: ${{ inputs.bump }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$FORCE" = "true" ]; then
|
||||
echo "Force flag set — running regardless of marker tag."
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# An explicit cycle reset on dispatch (bump != auto) also bypasses
|
||||
# the dedup guard — the maintainer is deliberately asking for a
|
||||
# new rc from the same commit.
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
|
||||
&& [ -n "${BUMP_INPUT:-}" ] \
|
||||
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
|
||||
echo "Explicit bump=$BUMP_INPUT — bypassing marker dedup."
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Dedup: is there already an rc/<HEAD_SHA> marker pointing at HEAD?
|
||||
MARKER="rc/${HEAD_SHA}"
|
||||
if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then
|
||||
echo "HEAD already has marker $MARKER — skipping."
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No marker on HEAD — proceeding."
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ── Reuse the stable CI workflow ─────────────────────────────────────
|
||||
ci:
|
||||
needs: guard
|
||||
if: needs.guard.outputs.should_run == 'true'
|
||||
uses: ./.github/workflows/ci.yml
|
||||
permissions:
|
||||
contents: read
|
||||
secrets: inherit
|
||||
|
||||
# ── Publish the rc build to npm + create GitHub prerelease ───────────
|
||||
publish:
|
||||
name: Publish release candidate to npm
|
||||
needs: [guard, ci]
|
||||
if: needs.guard.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write # push rc tag + marker
|
||||
id-token: write # npm provenance
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
|
||||
- name: Build gitnexus-shared
|
||||
run: npm install && npm run build
|
||||
working-directory: gitnexus-shared
|
||||
|
||||
- name: Install gitnexus dependencies
|
||||
run: npm ci
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Resolve rc version
|
||||
id: version
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
BUMP_INPUT: ${{ inputs.bump }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PKG_NAME: gitnexus
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 1. Current published `latest` — the floor for any new rc base.
|
||||
# Only E404 ("never published") falls back to package.json; any
|
||||
# other error (network, auth, malformed response) fails fast.
|
||||
NPM_STDERR_LATEST="$(mktemp)"
|
||||
if CURRENT_LATEST="$(npm view "$PKG_NAME" version 2>"$NPM_STDERR_LATEST")"; then
|
||||
:
|
||||
else
|
||||
if grep -q 'E404' "$NPM_STDERR_LATEST"; then
|
||||
CURRENT_LATEST="$(node -p "require('./package.json').version")"
|
||||
echo "Package not on registry (E404) — seeding from package.json: $CURRENT_LATEST"
|
||||
else
|
||||
echo "::error::npm registry unreachable for 'view version':" >&2
|
||||
cat "$NPM_STDERR_LATEST" >&2
|
||||
rm -f "$NPM_STDERR_LATEST"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
rm -f "$NPM_STDERR_LATEST"
|
||||
CURRENT_LATEST_CLEAN="${CURRENT_LATEST%%-*}"
|
||||
|
||||
# 2. Full version list — needed for the counter and for active-cycle
|
||||
# inference. Same E404-only fallback.
|
||||
NPM_STDERR_VERSIONS="$(mktemp)"
|
||||
if VERSIONS_JSON="$(npm view "$PKG_NAME" versions --json 2>"$NPM_STDERR_VERSIONS")"; then
|
||||
:
|
||||
else
|
||||
if grep -q 'E404' "$NPM_STDERR_VERSIONS"; then
|
||||
VERSIONS_JSON='[]'
|
||||
echo "No published versions for $PKG_NAME yet (E404)."
|
||||
else
|
||||
echo "::error::npm registry unreachable for 'view versions':" >&2
|
||||
cat "$NPM_STDERR_VERSIONS" >&2
|
||||
rm -f "$NPM_STDERR_VERSIONS"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
rm -f "$NPM_STDERR_VERSIONS"
|
||||
|
||||
# 3. Base selection.
|
||||
# - workflow_dispatch + bump ∈ {patch,minor,major} → explicit cycle
|
||||
# reset from latest.
|
||||
# - Everything else (push, or dispatch with bump=auto) → continue
|
||||
# the highest active rc base > latest if one exists; else
|
||||
# default to patch from latest.
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
|
||||
&& [ -n "${BUMP_INPUT:-}" ] \
|
||||
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
|
||||
BASE="$(npx --yes -p semver@7 semver -i "$BUMP_INPUT" "$CURRENT_LATEST_CLEAN")"
|
||||
echo "Explicit bump=$BUMP_INPUT → BASE=$BASE"
|
||||
else
|
||||
cat > /tmp/active_base.mjs <<'NODESCRIPT'
|
||||
const latest = process.env.LATEST;
|
||||
let v;
|
||||
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
|
||||
if (!Array.isArray(v)) v = [v];
|
||||
const parse = s => s.split(".").map(n => parseInt(n, 10));
|
||||
const gt = (a, b) => {
|
||||
const [A, B] = [parse(a), parse(b)];
|
||||
for (let i = 0; i < 3; i++) if (A[i] !== B[i]) return A[i] > B[i];
|
||||
return false;
|
||||
};
|
||||
const bases = new Set();
|
||||
for (const s of v) {
|
||||
const m = /^(\d+\.\d+\.\d+)-rc\.\d+$/.exec(s);
|
||||
if (m && gt(m[1], latest)) bases.add(m[1]);
|
||||
}
|
||||
if (!bases.size) { process.stdout.write(""); process.exit(0); }
|
||||
const sorted = [...bases].sort((a, b) => gt(a, b) ? 1 : -1);
|
||||
process.stdout.write(sorted[sorted.length - 1]);
|
||||
NODESCRIPT
|
||||
ACTIVE_BASE="$(LATEST="$CURRENT_LATEST_CLEAN" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/active_base.mjs)"
|
||||
if [ -n "$ACTIVE_BASE" ]; then
|
||||
BASE="$ACTIVE_BASE"
|
||||
echo "Continuing active rc cycle → BASE=$BASE"
|
||||
else
|
||||
BASE="$(npx --yes -p semver@7 semver -i patch "$CURRENT_LATEST_CLEAN")"
|
||||
echo "No active rc cycle → patch bump from latest → BASE=$BASE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Counter: 1 + max existing N for `${BASE}-rc.*`, else 1.
|
||||
cat > /tmp/next_rc.mjs <<'NODESCRIPT'
|
||||
const base = process.env.BASE;
|
||||
const prefix = base + "-rc.";
|
||||
let v;
|
||||
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
|
||||
if (!Array.isArray(v)) v = [v];
|
||||
const ns = v
|
||||
.filter(s => typeof s === "string" && s.startsWith(prefix))
|
||||
.map(s => parseInt(s.slice(prefix.length), 10))
|
||||
.filter(n => Number.isInteger(n) && n >= 0);
|
||||
process.stdout.write(String(ns.length ? Math.max(...ns) + 1 : 1));
|
||||
NODESCRIPT
|
||||
NEXT_N="$(BASE="$BASE" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/next_rc.mjs)"
|
||||
RC_VERSION="${BASE}-rc.${NEXT_N}"
|
||||
echo "Computed rc: $RC_VERSION"
|
||||
|
||||
# 5. Defensive: if the exact version already exists on the registry
|
||||
# (e.g., race with another run), abort before re-publishing.
|
||||
# Same E404-only pattern used above — a transient network
|
||||
# failure must fail loudly, not pretend the version is missing.
|
||||
NPM_STDERR_EXISTS="$(mktemp)"
|
||||
if npm view "$PKG_NAME@$RC_VERSION" version 2>"$NPM_STDERR_EXISTS" >/dev/null; then
|
||||
rm -f "$NPM_STDERR_EXISTS"
|
||||
echo "::error::Version $RC_VERSION already exists on npm — aborting."
|
||||
exit 1
|
||||
else
|
||||
if grep -qiE 'E404|not found' "$NPM_STDERR_EXISTS"; then
|
||||
rm -f "$NPM_STDERR_EXISTS"
|
||||
# Version doesn't exist — safe to proceed.
|
||||
else
|
||||
echo "::error::npm registry unreachable for existence check:" >&2
|
||||
cat "$NPM_STDERR_EXISTS" >&2
|
||||
rm -f "$NPM_STDERR_EXISTS"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "base=$BASE" >> "$GITHUB_OUTPUT"
|
||||
echo "rc_n=$NEXT_N" >> "$GITHUB_OUTPUT"
|
||||
echo "rc_version=$RC_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Apply rc version in-CI
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm version "${{ steps.version.outputs.rc_version }}" \
|
||||
--no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build gitnexus
|
||||
run: npm run build
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Dry-run publish
|
||||
run: npm publish --dry-run --tag rc
|
||||
working-directory: gitnexus
|
||||
|
||||
# ── Acquire the "rc lock" BEFORE publishing (fixes idempotency) ─────
|
||||
# We create two tags and push them atomically:
|
||||
# v<RC_VERSION> → annotated tag on a detached release commit
|
||||
# whose tree contains the rewritten package.json
|
||||
# (so the tag's source matches the npm tarball)
|
||||
# rc/<HEAD_SHA> → lightweight tag on HEAD; the guard's dedup key
|
||||
# If this push fails, nothing is published — safe.
|
||||
# If this push succeeds but npm publish fails, the marker stays on
|
||||
# the remote and blocks retries until an operator manually cleans up.
|
||||
- name: Create and push rc tags
|
||||
id: reltag
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
RC_VERSION: ${{ steps.version.outputs.rc_version }}
|
||||
HEAD_SHA: ${{ needs.guard.outputs.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VTAG="v${RC_VERSION}"
|
||||
MARKER="rc/${HEAD_SHA}"
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
|
||||
# Detached release commit with the version bump — keeps `main`
|
||||
# pristine but gives the v-tag a tree that matches the published
|
||||
# package contents exactly (fixes release-integrity gap).
|
||||
git add package.json package-lock.json 2>/dev/null || git add package.json
|
||||
git commit -m "release: ${VTAG}" --allow-empty
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
echo "Detached release commit: $RELEASE_SHA"
|
||||
|
||||
# Annotated release tag on the release commit.
|
||||
git tag -a "$VTAG" "$RELEASE_SHA" -m "$VTAG"
|
||||
# Lightweight marker on the user-visible HEAD for the guard.
|
||||
git tag "$MARKER" "$HEAD_SHA"
|
||||
|
||||
# Atomic push of both refs. If either would clobber an existing
|
||||
# remote ref, the push fails and we stop before npm publish.
|
||||
git push --atomic origin "refs/tags/$VTAG" "refs/tags/$MARKER"
|
||||
|
||||
echo "vtag=$VTAG" >> "$GITHUB_OUTPUT"
|
||||
echo "marker=$MARKER" >> "$GITHUB_OUTPUT"
|
||||
echo "release_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish to npm (rc dist-tag)
|
||||
run: npm publish --provenance --access public --tag rc
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Create GitHub prerelease
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2
|
||||
with:
|
||||
tag_name: ${{ steps.reltag.outputs.vtag }}
|
||||
name: Release Candidate ${{ steps.reltag.outputs.vtag }}
|
||||
prerelease: true
|
||||
make_latest: 'false'
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
Automated release candidate build from `main`.
|
||||
|
||||
**npm:** `npm install gitnexus@rc`
|
||||
**Version:** `${{ steps.version.outputs.rc_version }}`
|
||||
**Target base:** `${{ steps.version.outputs.base }}` (rc #${{ steps.version.outputs.rc_n }})
|
||||
**Source commit (main):** ${{ needs.guard.outputs.head_sha }}
|
||||
**Release commit (versioned tree):** ${{ steps.reltag.outputs.release_sha }}
|
||||
|
||||
Release candidates are pre-stable builds intended for early testing.
|
||||
Stable releases remain on the `latest` dist-tag.
|
||||
@@ -0,0 +1,185 @@
|
||||
name: Tree-sitter Upgrade Readiness
|
||||
|
||||
# Monitors readiness for upgrading tree-sitter to 0.25.x. Tracks:
|
||||
# 1. Peer-dep compatibility — can each grammar install cleanly with
|
||||
# tree-sitter@0.25.0 without --legacy-peer-deps?
|
||||
# 2. Vendored proto drift — has coder3101/tree-sitter-proto moved
|
||||
# ahead of our vendored snapshot?
|
||||
# See .github/scripts/check-tree-sitter-upgrade-readiness.py for the logic.
|
||||
#
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Daily at 09:00 UTC. Matches Dependabot's daily cadence so drift
|
||||
# and dep PRs surface together.
|
||||
- cron: '0 9 * * *'
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/scripts/check-tree-sitter-upgrade-readiness.py'
|
||||
- '.github/workflows/tree-sitter-upgrade-readiness.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
readiness:
|
||||
name: Check upgrade readiness
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed to open/update the tracking issue on scheduled runs.
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'false'
|
||||
|
||||
- name: Run upgrade readiness check
|
||||
id: readiness
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
python3 .github/scripts/check-tree-sitter-upgrade-readiness.py > drift-report.md
|
||||
code=$?
|
||||
set -e
|
||||
echo "exit_code=$code" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo 'report<<DRIFT_EOF'
|
||||
cat drift-report.md
|
||||
echo 'DRIFT_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "=== Report ==="
|
||||
cat drift-report.md
|
||||
|
||||
# On PR runs, the script validates that it runs correctly. Blockers
|
||||
# are informational — the scheduled run opens a tracking issue.
|
||||
- name: Annotate PR with readiness status
|
||||
if: github.event_name == 'pull_request' && steps.readiness.outputs.exit_code != '0'
|
||||
run: |
|
||||
echo "::warning::Tree-sitter 0.25 upgrade has blockers. See job output for the full readiness report."
|
||||
|
||||
- name: Upsert tracking issue on scheduled runs
|
||||
if: >
|
||||
github.event_name == 'schedule' &&
|
||||
steps.readiness.outputs.exit_code != '0'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
REPORT: ${{ steps.readiness.outputs.report }}
|
||||
with:
|
||||
script: |
|
||||
const title = 'Tree-sitter 0.25 upgrade readiness';
|
||||
const report = process.env.REPORT;
|
||||
const body = report + '\n\n' +
|
||||
'<sub>Generated daily by `.github/workflows/tree-sitter-upgrade-readiness.yml`. ' +
|
||||
'Closes automatically when all blockers are resolved.</sub>';
|
||||
const { data: open } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
labels: 'tree-sitter-drift',
|
||||
per_page: 10,
|
||||
});
|
||||
const existing = open.find(i => i.title === title);
|
||||
if (existing) {
|
||||
// Extract ready/total count for the changelog comment.
|
||||
const readyMatch = report.match(/\*\*(\d+)\/(\d+)\*\* grammars ready/);
|
||||
const blockerMatch = report.match(/\*\*(\d+) blocker/);
|
||||
const ready = readyMatch ? readyMatch[1] : '?';
|
||||
const total = readyMatch ? readyMatch[2] : '?';
|
||||
const blockers = blockerMatch ? blockerMatch[1] : '?';
|
||||
|
||||
// Find grammars whose status changed by diffing the old and
|
||||
// new table rows. Each row looks like:
|
||||
// | `tree-sitter-foo` | ... | Ready |
|
||||
// | `tree-sitter-foo` | ... | Blocking |
|
||||
const parseRows = (md) => {
|
||||
const map = {};
|
||||
for (const m of md.matchAll(/\| `(tree-sitter-[^`]+)` \|.*?\| (\S+(?:\s\S+)*?) \|$/gm)) {
|
||||
map[m[1]] = m[2].trim();
|
||||
}
|
||||
return map;
|
||||
};
|
||||
const oldRows = parseRows(existing.body || '');
|
||||
const newRows = parseRows(report);
|
||||
const changes = [];
|
||||
for (const [name, newStatus] of Object.entries(newRows)) {
|
||||
const oldStatus = oldRows[name];
|
||||
if (oldStatus && oldStatus !== newStatus) {
|
||||
changes.push(`\`${name}\`: ${oldStatus} → ${newStatus}`);
|
||||
}
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
let comment = `**${today}:** ${ready}/${total} ready. ${blockers} blocker(s) remaining.`;
|
||||
if (changes.length > 0) {
|
||||
comment += '\n\nChanges:\n' + changes.map(c => `- ${c}`).join('\n');
|
||||
} else {
|
||||
comment += ' No changes from previous run.';
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: existing.number,
|
||||
body: comment,
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: existing.number,
|
||||
body,
|
||||
});
|
||||
core.info(`Updated existing issue #${existing.number}`);
|
||||
} else {
|
||||
const { data: created } = await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title,
|
||||
body,
|
||||
labels: ['tree-sitter-drift', 'dependencies'],
|
||||
});
|
||||
core.info(`Opened issue #${created.number}`);
|
||||
}
|
||||
|
||||
- name: Close tracking issue on clean scheduled runs
|
||||
if: >
|
||||
github.event_name == 'schedule' &&
|
||||
steps.readiness.outputs.exit_code == '0'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const title = 'Tree-sitter 0.25 upgrade readiness';
|
||||
const { data: open } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
labels: 'tree-sitter-drift',
|
||||
per_page: 10,
|
||||
});
|
||||
const existing = open.find(i => i.title === title);
|
||||
if (existing) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: existing.number,
|
||||
body: 'All grammars are now compatible with tree-sitter@0.25. Upgrade is ready! Closing automatically.',
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: existing.number,
|
||||
state: 'closed',
|
||||
});
|
||||
core.info(`Closed issue #${existing.number}`);
|
||||
}
|
||||
@@ -47,8 +47,10 @@ permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Single global slot — newest manual dispatch supersedes any in-flight run.
|
||||
concurrency:
|
||||
group: triage-sweep
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -74,7 +76,7 @@ jobs:
|
||||
run: pip install -r .github/scripts/triage/requirements.txt
|
||||
|
||||
- name: Cache FastEmbed model weights
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/.fastembed_cache
|
||||
key: fastembed-bge-small-en-v1.5
|
||||
|
||||
+8
-1
@@ -23,6 +23,7 @@ Thumbs.db
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
docker/.env
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -81,6 +82,11 @@ GitNexus.sln
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Vendored tree-sitter grammar build artifacts (created at install time,
|
||||
# never committed). See docs/plans/2026-04-15-002-fix-tree-sitter-proto-vendor-deps-plan.md
|
||||
gitnexus/vendor/**/build/
|
||||
gitnexus/vendor/**/node_modules/
|
||||
|
||||
/github/scripts/triage/__pycache__/
|
||||
|
||||
.claude-flow/
|
||||
@@ -95,4 +101,5 @@ GitNexus.sln
|
||||
|
||||
.swarm/
|
||||
|
||||
local_docs/
|
||||
local_docs/
|
||||
|
||||
|
||||
@@ -1,117 +1,122 @@
|
||||
<!-- version: 1.3.0 -->
|
||||
<!--
|
||||
Metadata: version, last reviewed, scope, model policy, reference docs, changelog.
|
||||
Last updated: 2026-03-22
|
||||
-->
|
||||
<!-- version: 1.4.0 -->
|
||||
<!-- Last updated: 2026-04-16 -->
|
||||
|
||||
Last reviewed: 2026-04-13
|
||||
Last reviewed: 2026-04-16
|
||||
|
||||
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
|
||||
|
||||
This file uses a standard agent header (version, scope, model policy, reference docs, changelog), adapted for this **TypeScript/JavaScript monorepo**.
|
||||
|
||||
## Scope
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Reads** | Repository tree as needed for the task: `gitnexus/`, `gitnexus-web/`, `eval/`, plugin packages, `.github/`, `.gitnexus/` when present, and docs. |
|
||||
| **Writes** | Only paths required for the requested change; keep diffs minimal. Update lockfiles when dependencies change. |
|
||||
| **Executes** | `npm`, `npx`, `node` under `gitnexus/` and `gitnexus-web/`; `uv run` for Python under `eval/` when applicable; shell utilities for documented CI/dev workflows. |
|
||||
| **Off-limits** | User secrets (e.g. real `.env`), production deployment credentials, unrelated repositories, destructive git history operations without explicit human confirmation. |
|
||||
| Boundary | Rule |
|
||||
|----------|------|
|
||||
| **Reads** | `gitnexus/`, `gitnexus-web/`, `eval/`, plugin packages, `.github/`, `.gitnexus/`, docs. |
|
||||
| **Writes** | Only paths required for the change; keep diffs minimal. Update lockfiles when deps change. |
|
||||
| **Executes** | `npm`, `npx`, `node` under `gitnexus/` and `gitnexus-web/`; `uv run` for Python under `eval/`; documented CI/dev workflows. |
|
||||
| **Off-limits** | Real `.env` / secrets, production credentials, unrelated repos, destructive git ops without confirmation. |
|
||||
|
||||
## Model Configuration
|
||||
|
||||
- **Primary:** Pin in **Cursor** (Settings → model). Use a **named** model (e.g. GPT-5.2, Claude Sonnet 4.x). Avoid relying on **Auto** when reproducibility or audit trail matters.
|
||||
- **Fallback:** As configured in Cursor or your organization (do not encode `latest` or wildcards in automation configs).
|
||||
- **Notes:** The open-source GitNexus CLI indexer does not call an LLM. Optional Nexus AI in the web UI uses end-user provider keys and models.
|
||||
- **Primary:** Use a named model (e.g. Claude Sonnet 4.x). Avoid `Auto` or unversioned `latest` when reproducibility matters.
|
||||
- **Notes:** The GitNexus CLI indexer does not call an LLM.
|
||||
|
||||
## Execution Sequence (complex tasks)
|
||||
|
||||
Long sessions dilute instructions. For **multi-step** work, state up front:
|
||||
|
||||
For multi-step work, state up front:
|
||||
1. Which rules in this file and **[GUARDRAILS.md](GUARDRAILS.md)** apply (and any relevant Signs).
|
||||
2. Current **Scope** boundaries (Reads / Writes / Off-limits).
|
||||
3. Which **validation commands** you will run (e.g. `cd gitnexus && npm test`, `npx tsc --noEmit`).
|
||||
2. Current **Scope** boundaries.
|
||||
3. Which **validation commands** you will run (`cd gitnexus && npm test`, `npx tsc --noEmit`).
|
||||
|
||||
On very long threads, the human may add *“Remember: apply all AGENTS.md rules”* to re-weight rule tokens against context dilution.
|
||||
On long threads, *"Remember: apply all AGENTS.md rules"* re-weights these instructions against context dilution.
|
||||
|
||||
## Claude Code hooks
|
||||
|
||||
Hooks enforce gates that prompts cannot. In **Claude Code**, **PreToolUse** hooks can block tools such as `git_commit` until checks pass. Adapt to this repo: e.g. `cd gitnexus && npm test` before commit.
|
||||
**PreToolUse** hooks can block tools (e.g. `git_commit`) until checks pass. Adapt to this repo: `cd gitnexus && npm test` before commit.
|
||||
|
||||
## Context budget (Cursor / standards)
|
||||
## Context budget
|
||||
|
||||
Generic “core standards” playbooks are often long and stack-specific. For this monorepo, commands and gotchas live under **Cursor Cloud specific instructions** below and in **[CONTRIBUTING.md](CONTRIBUTING.md)**. If always-on rules grow, split domain rules into **`.cursor/rules/*.mdc`** (globs). **Cursor:** project-wide rules live in **`.cursor/index.mdc`** (YAML frontmatter with `alwaysApply: true`). **Claude Code:** optionally load a **`STANDARDS.md`** only when needed (e.g. *“When writing new code, read STANDARDS.md”*) to save context.
|
||||
Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.md](CONTRIBUTING.md)**. If always-on rules grow, split into **`.cursor/rules/*.mdc`** (globs). **Cursor:** project-wide rules in `.cursor/index.mdc`. **Claude Code:** load `STANDARDS.md` only when needed.
|
||||
|
||||
## Reference Documentation
|
||||
## Reference docs
|
||||
|
||||
- **This repository:** **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**.
|
||||
- **Cursor:** `.cursor/index.mdc` (always-on rules); optional `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` is deprecated — see `.cursor/index.mdc`.
|
||||
- **Optional local files:** `NOTES.md` (short vendor-neutral project snapshot). For handoffs, keep notes local (e.g., a scratch file outside the repo) rather than committing `HANDOFF.md`.
|
||||
- **GitNexus:** skills under `.claude/skills/gitnexus/`; machine-oriented rules in the `gitnexus:start` … `gitnexus:end` block below.
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
|
||||
- **Call-resolution DAG:** See ARCHITECTURE.md § Call-Resolution DAG. Typed 6-stage DAG inside the `parse` phase; language-specific behavior behind `inferImplicitReceiver` / `selectDispatch` hooks on `LanguageProvider`. Shared code in `gitnexus/src/core/ingestion/` must not name languages. Types: `gitnexus/src/core/ingestion/call-types.ts`.
|
||||
- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated.
|
||||
- **GitNexus:** skills in `.claude/skills/gitnexus/`; MCP rules in `gitnexus:start` block below.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Version | Change |
|
||||
|------|---------|--------|
|
||||
| 2026-04-16 | 1.4.0 | Fixed: web UI description, pre-commit behavior, MCP tools (7->16), added gitnexus-shared, removed stale vite-plugin-wasm gotcha. |
|
||||
| 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. |
|
||||
| 2026-03-24 | 1.2.0 | Fixed gitnexus:start block duplication (was inlined in Reference Docs bullet). |
|
||||
| 2026-03-23 | 1.1.0 | Updated agent instructions (sections, references, Cursor layout). |
|
||||
| 2026-03-22 | 1.0.0 | Added structured agent header and changelog. |
|
||||
| 2026-03-24 | 1.2.0 | Fixed gitnexus:start block duplication. |
|
||||
| 2026-03-23 | 1.1.0 | Updated agent instructions, references, Cursor layout. |
|
||||
| 2026-03-22 | 1.0.0 | Initial structured header and changelog. |
|
||||
|
||||
---
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
Indexed as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use 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.
|
||||
> If any tool warns the index is stale, run `npx gitnexus analyze` first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
- **MUST run impact analysis before editing any symbol.** `gitnexus_impact({target: "symbolName", direction: "upstream"})` — report blast radius to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** — verify only expected symbols and flows are affected.
|
||||
- **MUST warn the user** if impact returns HIGH or CRITICAL risk.
|
||||
- Explore unfamiliar code with `gitnexus_query({query: "concept"})` (process-grouped, ranked) instead of grepping.
|
||||
- Full context on a symbol: `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find related execution flows
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — callers, callees, process participation
|
||||
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace flow step by step
|
||||
4. Regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})`
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
- **Rename:** `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Graph edits are safe; text_search edits need manual review.
|
||||
- **Extract/Split:** `gitnexus_context` (incoming/outgoing refs) then `gitnexus_impact` (upstream callers) before moving code.
|
||||
- **After any refactor:** `gitnexus_detect_changes({scope: "all"})` to verify scope.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
- Edit a symbol without running `gitnexus_impact` first.
|
||||
- Ignore HIGH/CRITICAL risk warnings.
|
||||
- Rename with find-and-replace — use `gitnexus_rename`.
|
||||
- Commit without `gitnexus_detect_changes()`.
|
||||
- Add language-specific behavior to shared ingestion code (`gitnexus/src/core/ingestion/`) — use a `LanguageProvider` hook. Seeing `provider.mroStrategy === 'xxx'` or an import from `languages/xxx.ts` in shared code means stop and add a hook.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
| Tool | When to use | Example |
|
||||
|------|-------------|---------|
|
||||
| `list_repos` | Discover indexed repos | `gitnexus_list_repos({})` |
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
| `api_impact` | Pre-change API route impact | `gitnexus_api_impact({route: "/api/users", method: "GET"})` |
|
||||
| `route_map` | Route → handler → consumer map | `gitnexus_route_map({})` |
|
||||
| `tool_map` | MCP/RPC tool definitions | `gitnexus_tool_map({})` |
|
||||
| `shape_check` | Response shape vs consumer access | `gitnexus_shape_check({route: "/api/users"})` |
|
||||
| `group_list` | List repo groups | `gitnexus_group_list({})` |
|
||||
| `group_query` | Cross-repo search in a group | `gitnexus_group_query({name: "myGroup", query: "auth"})` |
|
||||
| `group_sync` | Rebuild group Contract Registry | `gitnexus_group_sync({name: "myGroup"})` |
|
||||
| `group_contracts` | Inspect group contracts | `gitnexus_group_contracts({name: "myGroup"})` |
|
||||
| `group_status` | Group staleness report | `gitnexus_group_status({name: "myGroup"})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
@@ -119,87 +124,80 @@ This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relatio
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
2. No HIGH/CRITICAL warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms expected scope
|
||||
4. All d=1 dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
npx gitnexus analyze # basic refresh
|
||||
npx gitnexus analyze --embeddings # preserve embeddings
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). Running without `--embeddings` deletes existing vectors.
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
> Claude Code: PostToolUse hook handles this after `git commit` and `git merge`.
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
## CLI Skills
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
| Task | Skill file |
|
||||
|------|-----------|
|
||||
| Architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Debugging / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Refactoring | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools/resources/schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| CLI commands (index, status, clean, wiki) | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
## Repo reference
|
||||
|
||||
### Repository structure
|
||||
### Packages
|
||||
|
||||
This is a monorepo with two main products and supporting config packages:
|
||||
|
||||
| Component | Path | Purpose |
|
||||
|-----------|------|---------|
|
||||
| **GitNexus CLI/Core** | `gitnexus/` | Main product — TypeScript CLI, indexing pipeline, MCP server. Published to npm. |
|
||||
| **GitNexus Web UI** | `gitnexus-web/` | React/Vite browser app — graph explorer + AI chat. Runs entirely in WASM. |
|
||||
| Claude Plugin | `gitnexus-claude-plugin/` | Static config for Claude marketplace (no build). |
|
||||
| Cursor Integration | `gitnexus-cursor-integration/` | Static config for Cursor editor (no build). |
|
||||
| SWE-bench Eval | `eval/` | Python evaluation harness (optional; needs Docker + LLM API keys). |
|
||||
| Package | Path | Purpose |
|
||||
|---------|------|---------|
|
||||
| **CLI/Core** | `gitnexus/` | TypeScript CLI, indexing pipeline, MCP server. Published to npm. |
|
||||
| **Web UI** | `gitnexus-web/` | React/Vite thin client. All queries via `gitnexus serve` HTTP API. |
|
||||
| **Shared** | `gitnexus-shared/` | Shared TypeScript types and constants. |
|
||||
| Claude Plugin | `gitnexus-claude-plugin/` | Static config for Claude marketplace. |
|
||||
| Cursor Integration | `gitnexus-cursor-integration/` | Static config for Cursor editor. |
|
||||
| Eval | `eval/` | Python evaluation harness (Docker + LLM API keys). |
|
||||
|
||||
### Running services
|
||||
|
||||
- **CLI/Core**: `cd gitnexus && npm run dev` (tsx watch mode) or `npm run build && node dist/cli/index.js <command>`
|
||||
- **Web UI**: `cd gitnexus-web && npm run dev` (Vite on port 5173)
|
||||
- **Backend mode**: `cd <indexed-repo> && node /workspace/gitnexus/dist/cli/index.js serve` (HTTP API on port 3741 by default)
|
||||
```bash
|
||||
cd gitnexus && npm run dev # CLI: tsx watch mode
|
||||
cd gitnexus-web && npm run dev # Web UI: Vite on port 5173
|
||||
npx gitnexus serve # HTTP API on port 4747 (from any indexed repo)
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
**CLI / Core (`gitnexus/`)**
|
||||
- **Unit tests**: `cd gitnexus && npm test` (vitest, ~2000 tests)
|
||||
- **Integration tests**: `cd gitnexus && npm run test:integration` (vitest, ~1850 tests). Two LadybugDB file-locking tests (`lbug-core-adapter`, `search-core`) may fail in containerized environments due to `/tmp` locking limitations — this is a known environment issue, not a code bug.
|
||||
- **TypeScript check**: `cd gitnexus && npx tsc --noEmit`
|
||||
- `npm test` — full vitest suite (~2000 tests)
|
||||
- `npm run test:unit` — unit tests only
|
||||
- `npm run test:integration` — integration (~1850 tests). LadybugDB file-locking tests may fail in containers (known env issue).
|
||||
- `npx tsc --noEmit` — typecheck
|
||||
|
||||
**Web UI (`gitnexus-web/`)**
|
||||
- **Unit tests**: `cd gitnexus-web && npm test` (vitest, ~200 tests)
|
||||
- **E2E tests**: `cd gitnexus-web && E2E=1 npx playwright test` (Playwright, 5 tests — requires `gitnexus serve` + `npm run dev` running)
|
||||
- **TypeScript check**: `cd gitnexus-web && npx tsc -b --noEmit`
|
||||
- `npm test` — vitest (~200 tests)
|
||||
- `npm run test:e2e` — Playwright (7 spec files; requires `gitnexus serve` + `npm run dev`)
|
||||
- `npx tsc -b --noEmit` — typecheck
|
||||
|
||||
No separate lint command is configured; TypeScript strict checking serves as the primary static analysis.
|
||||
**Pre-commit hook** (`.husky/pre-commit`): formatting (prettier via lint-staged) + typecheck for staged packages. Tests do **not** run in pre-commit — CI only.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (patches tree-sitter-swift). Native tree-sitter bindings require `python3`, `make`, and `g++` to be present.
|
||||
- `tree-sitter-kotlin` and `tree-sitter-swift` are optional dependencies — install warnings for these are expected and non-blocking.
|
||||
- The Web UI uses `vite-plugin-wasm` and requires `Cross-Origin-Opener-Policy`/`Cross-Origin-Embedder-Policy` headers for `SharedArrayBuffer` (handled automatically by Vite dev server).
|
||||
- There is no ESLint/Prettier configuration in this repo.
|
||||
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (patches tree-sitter-swift, builds tree-sitter-proto). Native bindings need `python3`, `make`, `g++`.
|
||||
- `tree-sitter-kotlin` and `tree-sitter-swift` are optional — install warnings expected.
|
||||
- ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`.
|
||||
|
||||
+300
-116
@@ -1,99 +1,129 @@
|
||||
# Architecture — GitNexus
|
||||
|
||||
This repository is a **monorepo** with two main products: the **CLI / MCP package** (`gitnexus/`) and the **browser UI** (`gitnexus-web/`). Supporting folders ship editor integrations and plugins without changing the core graph engine.
|
||||
Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `gitnexus/` | Published npm package `gitnexus`: CLI, MCP server (stdio), local HTTP API for bridge mode, ingestion pipeline, LadybugDB graph, embeddings (optional). |
|
||||
| `gitnexus-web/` | Vite + React UI: in-browser indexing (WASM), graph visualization, optional connection to `gitnexus serve`. |
|
||||
| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Packaged **skills** and plugin metadata so agents discover the same workflows as documented in `AGENTS.md`. |
|
||||
| `eval/` | Evaluation harnesses and docs for benchmarking tool usage. |
|
||||
| `.github/` | CI workflows (quality, unit, integration, E2E) and composite actions. |
|
||||
| `gitnexus/` | npm package `gitnexus`: CLI, MCP server (stdio), HTTP API, ingestion pipeline, LadybugDB graph, embeddings. |
|
||||
| `gitnexus-web/` | Vite + React thin client: graph explorer + AI chat. All queries via `gitnexus serve` HTTP API. |
|
||||
| `gitnexus-shared/` | Shared TypeScript types and constants (consumed by CLI and Web). |
|
||||
| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Agent skills and plugin metadata. |
|
||||
| `eval/` | Evaluation harnesses for benchmarking tool usage. |
|
||||
| `.github/` | CI workflows + composite actions (`setup-gitnexus/`, `setup-gitnexus-web/`). |
|
||||
|
||||
## End-to-end flow: index → graph → tools
|
||||
|
||||
1. **Ingestion** (`gitnexus analyze`)
|
||||
- Entry: `gitnexus/src/cli/analyze.ts` → `runPipelineFromRepo` in `gitnexus/src/core/ingestion/pipeline.ts`.
|
||||
- The pipeline is structured as a **DAG (Directed Acyclic Graph)** of named phases (see [Pipeline Phase DAG](#pipeline-phase-dag) below).
|
||||
- Output is loaded into **LadybugDB** under **`.gitnexus/`** at the repo root (`lbug/`, `meta.json`, etc.). Optional **FTS** indexes and **embeddings** attach to the same store.
|
||||
- The repo is registered in **`~/.gitnexus/registry.json`** so MCP can find it from any working directory.
|
||||
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 12 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
|
||||
|
||||
2. **Persistence & metadata**
|
||||
- `gitnexus/src/storage/repo-manager.ts` — paths, registry, cleanup of legacy Kuzu artifacts.
|
||||
- `gitnexus/src/core/lbug/lbug-adapter.ts` — graph load, queries, embedding restore batches.
|
||||
2. **Persistence** — `repo-manager.ts` (paths, registry, KuzuDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches).
|
||||
|
||||
3. **Query & agents**
|
||||
- **MCP (stdio):** `gitnexus/src/cli/mcp.ts` → `startMCPServer` → `LocalBackend` (`gitnexus/src/mcp/local/local-backend.ts`) opens registered repos and serves **tools** from `gitnexus/src/mcp/tools.ts` and **resources** from `gitnexus/src/mcp/resources.ts`.
|
||||
- **Bridge HTTP:** `gitnexus/src/cli/serve.ts` → Express app in `gitnexus/src/server/api.ts` (CORS-limited) exposes REST + MCP-over-HTTP for the web UI.
|
||||
- **CLI tools (no MCP):** `gitnexus query`, `context`, `impact`, `cypher` in `gitnexus/src/cli/tool.ts` call the same backend for scripts and CI.
|
||||
3. **Query layer** — three interfaces to the same backend:
|
||||
- **MCP (stdio):** `mcp.ts` → `LocalBackend` → tools (`tools.ts`) + resources (`resources.ts`)
|
||||
- **HTTP bridge:** `serve.ts` → Express (`api.ts`, `mcp-http.ts`) for web UI
|
||||
- **CLI direct:** `gitnexus query|context|impact|cypher` in `tool.ts`
|
||||
|
||||
4. **Staleness**
|
||||
- `gitnexus/src/mcp/staleness.ts` compares indexed `lastCommit` to `HEAD` and surfaces hints when the graph is behind git.
|
||||
4. **Staleness** — `staleness.ts` compares indexed `lastCommit` to `HEAD`, surfaces hints.
|
||||
|
||||
## MCP tools (summary)
|
||||
## MCP tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `list_repos` | Discover indexed repositories when more than one is registered. |
|
||||
| `query` | Natural-language / keyword search over the graph (hybrid BM25 + optional vectors). |
|
||||
| `cypher` | Ad hoc **Cypher** against the schema (see resource `gitnexus://repo/{name}/schema`). |
|
||||
| `context` | Callers, callees, processes for one symbol (with disambiguation). |
|
||||
| `impact` | Blast radius (upstream/downstream) with depth and risk summary. |
|
||||
| `detect_changes` | Map git diffs to affected symbols and processes. |
|
||||
| `rename` | Graph-assisted rename with `dry_run` preview (`graph` vs `text_search` confidence). |
|
||||
| `list_repos` | Discover indexed repos |
|
||||
| `query` | Hybrid BM25 + vector search over the graph |
|
||||
| `cypher` | Ad hoc Cypher against the schema |
|
||||
| `context` | Callers, callees, processes for one symbol |
|
||||
| `impact` | Blast radius (upstream/downstream) with risk summary |
|
||||
| `detect_changes` | Map git diffs to affected symbols and processes |
|
||||
| `rename` | Graph-assisted multi-file rename with `dry_run` preview |
|
||||
| `api_impact` | Pre-change impact report for an API route handler |
|
||||
| `route_map` | API route → handler → consumer mappings |
|
||||
| `tool_map` | MCP/RPC tool definitions and handlers |
|
||||
| `shape_check` | Response shape vs consumer property access mismatches |
|
||||
| `group_list` | List repo groups or details for one group |
|
||||
| `group_query` | Cross-repo search in a group (reciprocal rank fusion) |
|
||||
| `group_sync` | Rebuild group Contract Registry (`contracts.json`) |
|
||||
| `group_contracts` | Inspect group contracts and cross-links |
|
||||
| `group_status` | Index and Contract Registry staleness per repo in a group |
|
||||
|
||||
## Where to change what
|
||||
|
||||
| If you are changing… | Start in… |
|
||||
|----------------------|-----------|
|
||||
| CLI commands / flags | `gitnexus/src/cli/` (`index.ts`, per-command modules). |
|
||||
| Parsing or graph construction | `gitnexus/src/core/ingestion/pipeline-phases/` (individual phase files), `pipeline.ts` (orchestrator). |
|
||||
| Graph schema / DB access | `gitnexus/src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`), `gitnexus/src/mcp/core/lbug-adapter.ts` if MCP-specific. |
|
||||
| MCP protocol, tools, resources | `gitnexus/src/mcp/server.ts`, `tools.ts`, `resources.ts`. |
|
||||
| Search ranking | `gitnexus/src/core/search/` (BM25, hybrid fusion). |
|
||||
| Embeddings | `gitnexus/src/core/embeddings/`, phases in `analyze.ts`. |
|
||||
| Wiki generation | `gitnexus/src/core/wiki/`. |
|
||||
| Web UI behavior | `gitnexus-web/src/` (components, workers, graph client). |
|
||||
| CI | `.github/workflows/*.yml`, `.github/actions/setup-gitnexus/`. |
|
||||
| Concern | Start in |
|
||||
|---------|----------|
|
||||
| CLI commands/flags | `src/cli/` (`index.ts`, per-command modules) |
|
||||
| Parsing/graph construction | `src/core/ingestion/pipeline-phases/` + `pipeline.ts` |
|
||||
| Graph schema/DB | `src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`) |
|
||||
| MCP tools/resources | `src/mcp/server.ts`, `tools.ts`, `resources.ts` |
|
||||
| Search ranking | `src/core/search/` (BM25, hybrid fusion) |
|
||||
| Embeddings | `src/core/embeddings/` + `src/core/run-analyze.ts` |
|
||||
| Wiki generation | `src/core/wiki/` |
|
||||
| Language support | `src/core/ingestion/languages/` + `tree-sitter-queries.ts` + `gitnexus-shared/src/languages.ts` |
|
||||
| Import resolution | `src/core/ingestion/import-processor.ts` + `import-resolvers/configs/` + `model/resolution-context.ts` |
|
||||
| Call resolution/MRO | `src/core/ingestion/call-processor.ts` + `model/resolve.ts` |
|
||||
| Type extraction | `src/core/ingestion/type-extractors/` |
|
||||
| Worker pool | `src/core/ingestion/workers/` |
|
||||
| Web UI | `gitnexus-web/src/` |
|
||||
| CI | `.github/workflows/*.yml`, `.github/actions/` |
|
||||
|
||||
> Paths above are relative to `gitnexus/` unless they start with `gitnexus-web/` or `.github/`.
|
||||
|
||||
---
|
||||
|
||||
## Pipeline Phase DAG
|
||||
|
||||
The ingestion pipeline is a DAG of named phases. Each phase is defined in its own file under `gitnexus/src/core/ingestion/pipeline-phases/` with explicit dependencies, typed inputs, and typed outputs.
|
||||
12 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
|
||||
|
||||
```
|
||||
scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
||||
→ crossFile → mro → communities → processes
|
||||
```
|
||||
|
||||
### Phase files
|
||||
| Phase | File | Deps | Output |
|
||||
|-------|------|------|--------|
|
||||
| `scan` | `scan.ts` | (root) | File paths + sizes |
|
||||
| `structure` | `structure.ts` | `scan` | File/Folder nodes, CONTAINS edges, `allPathSet` |
|
||||
| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx |
|
||||
| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) |
|
||||
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries |
|
||||
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators) |
|
||||
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
|
||||
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
|
||||
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order |
|
||||
| `mro` | `mro.ts` | `crossFile`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
|
||||
| `communities` | `communities.ts` | `mro`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
|
||||
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `structure` | Process nodes + STEP_IN_PROCESS edges |
|
||||
|
||||
| Phase | File | Dependencies | What it does |
|
||||
|-------|------|-------------|--------------|
|
||||
| `scan` | `scan.ts` | (root) | Walk repo filesystem, collect paths + sizes |
|
||||
| `structure` | `structure.ts` | `scan` | Build File/Folder nodes + CONTAINS edges |
|
||||
| `markdown` | `markdown.ts` | `structure` | Extract headings and cross-links from .md/.mdx |
|
||||
| `cobol` | `cobol.ts` | `structure` | Regex-based COBOL/JCL extraction |
|
||||
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Chunked tree-sitter parse, import/call/heritage resolution |
|
||||
| `routes` | `routes.ts` | `parse` | Route registry (Next.js, Expo, PHP, decorator-based) |
|
||||
| `tools` | `tools.ts` | `parse` | MCP/RPC tool detection |
|
||||
| `orm` | `orm.ts` | `parse` | Prisma/Supabase ORM query edges |
|
||||
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological order |
|
||||
| `mro` | `mro.ts` | `crossFile` | Method Resolution Order, METHOD_OVERRIDES edges |
|
||||
| `communities` | `communities.ts` | `mro` | Leiden community detection |
|
||||
| `processes` | `processes.ts` | `communities`, `routes`, `tools` | Execution flow detection, Route/Tool → Process links |
|
||||
**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `orm-extraction.ts` (sequential ORM fallback), `types.ts`, `runner.ts`, `index.ts`.
|
||||
|
||||
### DAG runner
|
||||
|
||||
`runner.ts` — static phase graph, no plugins, compile-time type safety.
|
||||
|
||||
1. **Validation** — Kahn's topological sort. Rejects on: duplicate names, missing deps, cycles (DFS traces the concrete cycle path, e.g., `A -> B -> C -> A`, plus count of transitively blocked dependents).
|
||||
|
||||
2. **Execution** — sequential in topological order. Each phase receives:
|
||||
- `ctx: PipelineContext` — shared mutable `KnowledgeGraph`, `repoPath`, progress callback, options
|
||||
- `deps: ReadonlyMap<string, PhaseResult>` — **declared deps only** (runner filters the results map to prevent hidden coupling)
|
||||
|
||||
3. **Error handling** — wraps phase errors with the phase name, emits terminal `error` progress event, swallows progress handler errors to preserve the original cause.
|
||||
|
||||
4. **Timing** — per-phase `durationMs` in `PhaseResult`, dev-mode console logging.
|
||||
|
||||
**Design patterns:**
|
||||
- **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output.
|
||||
- **Typed phase access** — `getPhaseOutput<T>(deps, 'name')` for type-safe upstream results.
|
||||
- **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership.
|
||||
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests). `skipWorkers` forces sequential parsing.
|
||||
|
||||
### How to add a new phase
|
||||
|
||||
1. Create a new file in `pipeline-phases/` (e.g. `my-phase.ts`)
|
||||
2. Define a `PipelinePhase<MyOutput>` object with `name`, `deps`, and `execute(ctx, deps)`
|
||||
3. Export it from `pipeline-phases/index.ts`
|
||||
4. Add it to the `buildPhaseList()` function in `pipeline.ts`
|
||||
1. Create `pipeline-phases/my-phase.ts` with a `PipelinePhase<MyOutput>` (name, deps, execute)
|
||||
2. Export from `pipeline-phases/index.ts`
|
||||
3. Add to `buildPhaseList()` in `pipeline.ts`
|
||||
|
||||
```typescript
|
||||
// pipeline-phases/my-phase.ts
|
||||
import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
|
||||
import type { PipelinePhase, PhaseResult } from './types.js';
|
||||
import { getPhaseOutput } from './types.js';
|
||||
import type { ParseOutput } from './parse.js';
|
||||
|
||||
@@ -101,81 +131,235 @@ export interface MyPhaseOutput { /* ... */ }
|
||||
|
||||
export const myPhase: PipelinePhase<MyPhaseOutput> = {
|
||||
name: 'myPhase',
|
||||
deps: ['parse'], // runs after parse completes
|
||||
deps: ['parse'],
|
||||
async execute(ctx, deps) {
|
||||
const { allPaths } = getPhaseOutput<ParseOutput>(deps, 'parse');
|
||||
// ... do work, write to ctx.graph ...
|
||||
// ... write to ctx.graph ...
|
||||
return { /* typed output */ };
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### DAG runner
|
||||
---
|
||||
|
||||
The runner (`pipeline-phases/runner.ts`) validates the DAG at startup (detects cycles and missing deps via topological sort), then executes phases in dependency order. Each phase receives:
|
||||
- `ctx: PipelineContext` — shared graph, repoPath, progress callback
|
||||
- `deps: Map<string, PhaseResult>` — outputs from all upstream phases
|
||||
## Call-Resolution DAG
|
||||
|
||||
Typed 6-stage pipeline in `call-processor.ts` (inside the `parse` phase) that resolves method/function calls and emits CALLS edges. Language behavior plugs in at two `LanguageProvider` hook points (stages 3–4); shared code names no languages. Scope: call resolution only — import resolution, type extraction, heritage, and symbol-table population live in other phases.
|
||||
|
||||
### Stages
|
||||
|
||||
```
|
||||
extract-call ──▶ classify-form ──▶ infer-receiver ──▶ select-dispatch ──▶ resolve-target ──▶ emit-edge
|
||||
(1) (2) (3) [hook] (4) [hook] (5) (6)
|
||||
```
|
||||
|
||||
| Stage | Produces | Location |
|
||||
|-------|----------|----------|
|
||||
| **extract-call** | `ExtractedCallSite` (name, form, receiver, argCount) | `call-extractors/` (per-language); runs in worker |
|
||||
| **classify-form** | callForm (`free`/`member`/`constructor`) + arity | `call-analysis.ts` → `inferCallForm`; shared, runs in worker |
|
||||
| **infer-receiver** | `ReceiverEnriched` (receiver type finalized) | `call-processor.ts`; shared default chain, then `inferImplicitReceiver` hook |
|
||||
| **select-dispatch** | `DispatchDecision` (primary, fallback, ancestryView) | `selectDispatch` hook, falls back to shared default |
|
||||
| **resolve-target** | `TieredCandidates` | `model/resolve.ts` → `lookupMethodByOwnerWithMRO` (MRO walk) |
|
||||
| **emit-edge** | CALLS edge in graph | `call-processor.ts`; writes edge with confidence tier |
|
||||
|
||||
### Provider hooks
|
||||
|
||||
Both hooks are optional on `LanguageProvider`. Ruby is the only current implementer.
|
||||
|
||||
**`inferImplicitReceiver`** — called after shared infer-receiver defaults. Returns `ImplicitReceiverOverride | null`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `callNode` (AST), `filePath` |
|
||||
| Non-null fields | `callForm`, `receiverName`, `receiverTypeName` (required); `receiverSource: 'implicit-self'` (fixed); `hint?` (opaque, passed to `selectDispatch`) |
|
||||
| Null | Keep existing `ReceiverEnriched` state |
|
||||
|
||||
**`selectDispatch`** — called after infer-receiver (including hook). Returns `DispatchDecision | null`; null uses shared default (constructor → `primary:'constructor'`; typed receiver → `primary:'owner-scoped'`; else → `primary:'free'`).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `receiverSource`, `hint` |
|
||||
| Non-null fields | `primary: 'owner-scoped' \| 'free' \| 'constructor'`; `fallback?: 'free-arity-narrowed'`; `ancestryView?: 'instance' \| 'singleton'`; `hint?` |
|
||||
|
||||
**`DispatchDecision` field semantics:**
|
||||
- `primary: 'owner-scoped'` — MRO walk from receiver's type; used when receiver type is known.
|
||||
- `fallback: 'free-arity-narrowed'` — after owner-scoped miss, search free-call candidates by arity only (Ruby uses this for implicit-self calls that miss their owner's MRO).
|
||||
- `ancestryView: 'singleton'` — walk singleton/class ancestry instead of instance ancestry (Ruby `def self.foo` bodies, so `extend`-ed methods are found).
|
||||
|
||||
### Adding language behavior
|
||||
|
||||
1. **Implicit receivers** — implement `inferImplicitReceiver`: return null if call already has a receiver; otherwise use `findEnclosingClassInfo` (`ast-helpers.ts`) to find the enclosing context, return `ImplicitReceiverOverride` with `receiverSource: 'implicit-self'`, and optionally set `hint` for `selectDispatch`.
|
||||
2. **Custom dispatch** — implement `selectDispatch`: inspect `receiverSource` and `hint`, return `DispatchDecision` with `primary`, optional `fallback`, optional `ancestryView`; return null to keep shared defaults.
|
||||
3. **MRO strategy** — confirm `mroStrategy` is `'first-wins'`, `'c3'`, `'ruby-mixin'`, or `'none'`; consumed by `lookupMethodByOwnerWithMRO`.
|
||||
|
||||
**Ruby example** (`languages/ruby.ts` + `utils/ruby-self-call.ts`): `inferImplicitReceiver` rewrites bare-identifier calls to `self.method` and sets `hint` to `'instance'`/`'singleton'`; `selectDispatch` uses hint for `ancestryView` and adds `fallback: 'free-arity-narrowed'` for implicit-self calls.
|
||||
|
||||
### Code references
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `core/ingestion/call-types.ts` | DAG types: `ReceiverEnriched`, `DispatchDecision`, `ImplicitReceiverOverride` |
|
||||
| `core/ingestion/language-provider.ts` | Hook signatures: `inferImplicitReceiver`, `selectDispatch` |
|
||||
| `core/ingestion/call-processor.ts` | `processCalls`: stages 3–6 |
|
||||
| `core/ingestion/model/resolve.ts` | `lookupMethodByOwnerWithMRO`: stage 5 MRO walk |
|
||||
| `core/ingestion/languages/ruby.ts` | Both hooks + `mroStrategy: 'ruby-mixin'` |
|
||||
| `core/ingestion/utils/ruby-self-call.ts` | Bare-call rewrite for `inferImplicitReceiver` |
|
||||
|
||||
---
|
||||
|
||||
## Language-agnostic graph feeding
|
||||
|
||||
16 languages → single unified graph. Four abstraction layers:
|
||||
|
||||
```
|
||||
Unified Graph Schema (44 node types, 21 relationship types)
|
||||
↑
|
||||
Unified Resolution (3-tier name lookup + MRO walk)
|
||||
↑
|
||||
Language Providers (import semantics, type config, export checker, MRO strategy)
|
||||
↑
|
||||
Tree-Sitter Queries (per-language S-expressions, unified capture tags)
|
||||
```
|
||||
|
||||
### Language providers
|
||||
|
||||
Each language implements `LanguageProvider` (`language-provider.ts`). Key fields:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `id`, `extensions` | Language identity and file matching |
|
||||
| `treeSitterQueries` | S-expression queries for AST extraction |
|
||||
| `importSemantics` | `named` / `wildcard-leaf` / `wildcard-transitive` / `namespace` |
|
||||
| `importResolver` | Language-specific path → file resolution |
|
||||
| `exportChecker` | Public/exported symbol detection |
|
||||
| `typeConfig` | Type annotation extraction rules |
|
||||
| `mroStrategy` | `first-wins` / `c3` / `none` |
|
||||
|
||||
16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
|
||||
|
||||
### Unified capture tags
|
||||
|
||||
Per-language tree-sitter queries use different AST node names but produce the **same semantic capture tags**: `@definition.class`, `@definition.function`, `@call.name`, `@import.source`, `@heritage.extends`. Downstream extraction needs no language branching. Defined in `tree-sitter-queries.ts`.
|
||||
|
||||
### Import resolution
|
||||
|
||||
Per-language import resolution uses the **configs + factory** pattern (like call/method/class extractors). Each language declares an `ImportResolutionConfig` in `import-resolvers/configs/`, listing an ordered chain of `ImportResolverStrategy` functions. `createImportResolver()` (in `resolver-factory.ts`) composes them: first non-null result wins. Low-level helpers shared across strategies live alongside the configs in `import-resolvers/` (e.g. `go.ts`, `rust.ts`, `python.ts`).
|
||||
|
||||
Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSemantics` controls which tier activates:
|
||||
|
||||
| Tier | Confidence | Mechanism |
|
||||
|------|-----------|-----------|
|
||||
| 1 — same-file | 0.95 | Symbol table for caller's file |
|
||||
| 2 — import-scoped | 0.9 | `NamedImportMap` chains (named) or all files in `importMap` (wildcard) |
|
||||
| 3 — global | 0.5 | O(1) index lookups: class, impl, callable. Fallback only |
|
||||
|
||||
| Import strategy | Languages | Behavior |
|
||||
|----------------|-----------|----------|
|
||||
| `named` | TS, JS, Java, C#, Rust, PHP, Kotlin | Only explicitly imported names visible |
|
||||
| `wildcard-leaf` | Go, Ruby, Swift, Dart | Whole-package import, no transitive re-exports |
|
||||
| `wildcard-transitive` | C, C++ | `#include` closure chains through re-exports |
|
||||
| `namespace` | Python | Module aliases resolved at call site |
|
||||
|
||||
### Chunked parse-and-resolve
|
||||
|
||||
`parse` processes files in ~20 MB byte-budget chunks to bound memory. Per chunk:
|
||||
1. Worker pool dispatches files (or sequential fallback via `skipWorkers`)
|
||||
2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult`
|
||||
3. Synthesize wildcard bindings (`wildcard-synthesis.ts`)
|
||||
4. Resolve imports and heritage
|
||||
5. Collect `BindingAccumulator` entries for cross-file propagation
|
||||
|
||||
Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`.
|
||||
|
||||
### Heritage and MRO
|
||||
|
||||
All languages emit unified `ExtractedHeritage` (child, parent, `EXTENDS`/`IMPLEMENTS`). MRO phase walks the heritage graph using per-language strategy:
|
||||
- **`first-wins`** — Java, C#, C++, TS, Ruby, Go
|
||||
- **`c3`** — Python (C3 linearization)
|
||||
- **`none`** — single-inheritance languages
|
||||
|
||||
Unified walk: `lookupMethodByOwnerWithMRO()` in `model/resolve.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Full analysis flow
|
||||
|
||||
`runFullAnalysis` in `run-analyze.ts` orchestrates everything around the pipeline:
|
||||
|
||||
```
|
||||
CLI (analyze.ts) → runFullAnalysis(repoPath, options, callbacks)
|
||||
1. Early exit if lastCommit == HEAD (unless --force) [0%]
|
||||
2. Cache existing embeddings from prior index [0%]
|
||||
3. runPipelineFromRepo() → KnowledgeGraph [0-60%]
|
||||
4. Clean up legacy KuzuDB files [60%]
|
||||
5. initLbug() → loadGraphToLbug() via CSV streaming [60-85%]
|
||||
6. Create FTS indexes (File, Function, Class, Method...) [85-90%]
|
||||
7. Restore cached embeddings (batch insert) [88%]
|
||||
8. Generate new embeddings if --embeddings [90-98%]
|
||||
9. Save metadata + register repo + update .gitignore [98-100%]
|
||||
10. Generate AI context files (AGENTS.md, CLAUDE.md) [100%]
|
||||
```
|
||||
|
||||
**Options:** `--force` (rebuild regardless), `--embeddings` (opt-in, skipped if >50k nodes), `--skipGit`, `--noStats`.
|
||||
|
||||
## Storage
|
||||
|
||||
```
|
||||
<repo>/.gitnexus/
|
||||
├── lbug # LadybugDB database
|
||||
├── lbug.wal # Write-ahead log
|
||||
├── lbug.lock # Single-writer lock
|
||||
└── meta.json # lastCommit, indexedAt, stats
|
||||
|
||||
~/.gitnexus/
|
||||
└── registry.json # Global repo registry (MCP discovery)
|
||||
```
|
||||
|
||||
Managed by `repo-manager.ts`.
|
||||
|
||||
## LadybugDB schema
|
||||
|
||||
Defined in `lbug/schema.ts`. Separate node tables per type, single `CodeRelation` table.
|
||||
|
||||
**Node tables:** File, Folder, Function, Class, Interface, Method, Constructor, CodeElement, Struct, Enum, Macro, Typedef, Union, Namespace, Trait, Impl, TypeAlias, Const, Static, Property, Record, Delegate, Annotation, Template, Module, Community, Process, Route, Tool, Section, Embedding.
|
||||
|
||||
**Relation types** (`CodeRelation.type`): CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF.
|
||||
|
||||
## Embeddings and search
|
||||
|
||||
**Embeddings** (`src/core/embeddings/`): Snowflake arctic-embed-xs (384D). Embeddable: File, Function, Class, Method, Interface. Incremental via SHA1 content hash. Separate `Embedding` table.
|
||||
|
||||
**Search** (`src/core/search/`): Hybrid BM25 + semantic vector, merged via Reciprocal Rank Fusion (K=60).
|
||||
|
||||
## Known limitations
|
||||
|
||||
### Overloaded method resolution
|
||||
|
||||
Method and Constructor node IDs include an arity suffix (`#<paramCount>`) to
|
||||
disambiguate overloaded methods. Two overloads with different parameter counts
|
||||
produce distinct graph nodes: `Method:file:Class.method#1` vs
|
||||
`Method:file:Class.method#2`.
|
||||
Node IDs use arity suffix (`#<paramCount>`): `Method:file:Class.method#1` vs `#2`.
|
||||
|
||||
**Same-arity overload disambiguation:** When two overloads share the same
|
||||
parameter count but differ in types (e.g. `save(int)` vs `save(String)`), a
|
||||
type-hash suffix `~type1,type2` is appended to produce distinct node IDs:
|
||||
`Method:file:Class.save#1~int` vs `Method:file:Class.save#1~String`. The suffix
|
||||
is only added when a same-arity collision is detected within a class and all
|
||||
parameters have non-null type annotations. Languages without type info (Python,
|
||||
Ruby, JS) fall back to arity-only IDs. TypeScript/JavaScript overload signatures
|
||||
are intentionally excluded from type-hashing because they are declaration-only
|
||||
contracts that should collapse to the implementation body's node ID. See issue
|
||||
\#651.
|
||||
**Same-arity disambiguation:** type-hash suffix `~type1,type2` when collision detected and type annotations present. Languages without types (Python, Ruby, JS) use arity-only. TS/JS overload signatures excluded (collapse to implementation body). See #651.
|
||||
|
||||
**C++ const-qualified overload disambiguation:** Methods overloaded by const
|
||||
qualification (e.g. `begin()` vs `begin() const`) are disambiguated via an
|
||||
`isConst` property and a `$const` ID suffix appended to the const-qualified
|
||||
variant when a non-const collision exists. The `$const` suffix appears after the
|
||||
type-hash suffix: e.g. `Method:file:Container.begin#0$const`.
|
||||
**C++ const-qualified:** `$const` suffix after type-hash when non-const collision exists: `Method:file:Container.begin#0$const`.
|
||||
|
||||
**Generic/template type preservation in type-hash:** The type-hash suffix uses
|
||||
`rawType` (full AST text including generic/template args) rather than the
|
||||
simplified `type` from `extractSimpleTypeName`. This means C++ template overloads
|
||||
like `process(vector<int>)` vs `process(vector<string>)` produce distinct IDs:
|
||||
`~vector<int>` vs `~vector<std::string>`. Java generic overloads like
|
||||
`process(List<String>)` vs `process(List<Integer>)` are a compile error due to
|
||||
type erasure, so this gap is theoretical for Java.
|
||||
**Generic/template types:** type-hash uses `rawType` (full AST text including generics): `~vector<int>` vs `~vector<std::string>`.
|
||||
|
||||
**ID stability on first overload:** Type and const tags are collision-only. When
|
||||
a class has `save(int)` as its only `save` method, the ID is `save#1` (no tag).
|
||||
Adding `save(String)` changes the original to `save#1~int`. This is correct for
|
||||
fresh analysis but means IDs are not stable across overload additions. Future
|
||||
incremental re-analysis should account for this.
|
||||
**ID stability:** collision-only tags mean IDs change when overloads are added. `save#1` becomes `save#1~int` when `save(String)` is added.
|
||||
|
||||
**Variadic method matching:** When one side is variadic (`parameterCount`
|
||||
undefined) and the other has a fixed count, `METHOD_IMPLEMENTS` edges are
|
||||
emitted with confidence 0.7 instead of 1.0. Variadic methods like
|
||||
`foo(String... args)` may superficially match `foo(String s)` by type but
|
||||
are not guaranteed to be interchangeable across all languages (Java/Kotlin
|
||||
accept this via varargs sugar; TypeScript, C#, Rust do not).
|
||||
**Variadic matching:** confidence 0.7 when one side is variadic and the other has fixed count.
|
||||
|
||||
**Confidence tiering** for `METHOD_IMPLEMENTS` edges:
|
||||
**METHOD_IMPLEMENTS confidence tiering:**
|
||||
|
||||
| Match quality | Confidence | When |
|
||||
|---|---|---|
|
||||
| Exact parameter types match | 1.0 | Both sides have `parameterTypes` arrays and they match |
|
||||
| Arity (count) matches | 1.0 | Both sides have `parameterCount`, types unavailable |
|
||||
| Variadic vs fixed | 0.7 | One side is variadic, other has fixed count |
|
||||
| Lenient (insufficient info) | 0.7 | One or both sides lack type and count data |
|
||||
| Match quality | Confidence |
|
||||
|---|---|
|
||||
| Exact parameter types match | 1.0 |
|
||||
| Arity match, types unavailable | 1.0 |
|
||||
| Variadic vs fixed | 0.7 |
|
||||
| Insufficient info | 0.7 |
|
||||
|
||||
## Related docs
|
||||
|
||||
- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance.
|
||||
- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery.
|
||||
- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents.
|
||||
- [TESTING.md](TESTING.md) — how to run tests.
|
||||
- `AGENTS.md` / `CLAUDE.md` — agent workflows and tool usage expectations for **this** repo when indexed by GitNexus.
|
||||
- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance
|
||||
- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery
|
||||
- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents
|
||||
- [TESTING.md](TESTING.md) — how to run tests
|
||||
- `AGENTS.md` / `CLAUDE.md` — agent workflows and tool usage
|
||||
|
||||
@@ -35,6 +35,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
|
||||
## Reference Documentation
|
||||
|
||||
- **This repository:** [AGENTS.md](AGENTS.md) (Cursor + monorepo notes), [ARCHITECTURE.md](ARCHITECTURE.md), [CONTRIBUTING.md](CONTRIBUTING.md), [GUARDRAILS.md](GUARDRAILS.md).
|
||||
- **Call-resolution DAG:** See ARCHITECTURE.md § Call-Resolution DAG. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` hooks instead (see AGENTS.md).
|
||||
- **GitNexus:** `.claude/skills/gitnexus/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start` … `gitnexus:end`). See **GitNexus rules** below.
|
||||
|
||||
## Changelog
|
||||
@@ -50,206 +51,4 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
|
||||
|
||||
## GitNexus rules
|
||||
|
||||
GitNexus MCP rules are in the `<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relationships, 300 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.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index.
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (3298 symbols, 7954 relationships, 185 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.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** for the canonical MCP tools, impact analysis rules, and index instructions.
|
||||
|
||||
+108
-11
@@ -21,30 +21,127 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro
|
||||
## Branch and pull requests
|
||||
|
||||
- Use short-lived branches off the default branch of the repo you are targeting.
|
||||
- Prefer **conventional commits** (short prefix + description), for example:
|
||||
|
||||
```text
|
||||
feat: add graph export option
|
||||
fix: correct MCP tool schema for query
|
||||
test: cover cluster merge edge case
|
||||
docs: clarify analyze flags
|
||||
```
|
||||
|
||||
- **PR title:** `[area] Short description` (e.g. `[cli] Fix index refresh race`).
|
||||
- **PR titles MUST follow the conventional-commit format** — `pr-labeler.yml` enforces this on every PR and auto-applies the matching label so release notes group the change correctly.
|
||||
- **PR description:** what changed, why, how to verify (commands), and any risk or rollback notes.
|
||||
|
||||
### Pull request titles
|
||||
|
||||
Format: `<type>[(scope)][!]: <subject>`
|
||||
|
||||
Allowed types and the release-notes section each one lands in (defined in `.github/release.yml`):
|
||||
|
||||
| Type | Label applied | Release-notes section |
|
||||
|------|---------------|-----------------------|
|
||||
| `feat` | `enhancement` | 🚀 Features |
|
||||
| `fix` | `bug` | 🐛 Bug Fixes |
|
||||
| `perf` | `performance` | 🏎️ Performance |
|
||||
| `refactor` | `refactor` | 🔄 Refactoring |
|
||||
| `test` | `test` | 🧪 Tests |
|
||||
| `ci` | `ci` | 👷 CI/CD |
|
||||
| `build` / `deps` | `dependencies` | 📦 Dependencies |
|
||||
| `docs` | `documentation` | (grouped under Other Changes unless a Docs section is added) |
|
||||
| `chore` / `revert` | `chore` | (excluded from release notes) |
|
||||
|
||||
Append `!` to the type (e.g. `feat(api)!: drop /v1 endpoint`) or include `BREAKING CHANGE:` in the PR body to flag a breaking change — the labeler then adds the `breaking` label and the 💥 Breaking Changes section is rendered first.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
feat(web): add smart chat scroll
|
||||
fix(extractors): resolve silent contract mis-resolution
|
||||
perf: avoid O(n²) traversal in heritage walker
|
||||
chore(deps): bump vitest to 3.0.0
|
||||
ci: standardize workflow concurrency
|
||||
```
|
||||
|
||||
Commits within a PR may use any style — only the **merged PR title** shows up in release notes, so that's the one the convention applies to.
|
||||
|
||||
## Before you open a PR
|
||||
|
||||
- [ ] Tests pass for the packages you touched (`gitnexus` and/or `gitnexus-web`).
|
||||
- [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`.
|
||||
- [ ] No secrets, tokens, or machine-specific paths committed.
|
||||
- [ ] Documentation updated if behavior or public CLI/MCP contract changes.
|
||||
- [ ] Pre-commit hook runs clean (`.husky/pre-commit` — typecheck + unit tests for staged packages).
|
||||
- [ ] Pre-commit hook runs clean (`.husky/pre-commit` — formatting via lint-staged + typecheck for staged packages; tests run in CI only).
|
||||
|
||||
## Code review
|
||||
|
||||
Maintainers may request changes for correctness, tests, performance, or consistency with existing patterns. Keeping diffs focused makes review faster.
|
||||
|
||||
## GitHub Actions — Concurrency Convention
|
||||
|
||||
Every workflow under `.github/workflows/` MUST declare a top-level `concurrency:` block using this convention:
|
||||
|
||||
- **Group key** starts with `${{ github.workflow }}` so no two workflows can collide on the same group name. The discriminator that follows is chosen per event shape:
|
||||
- Branch/tag scope: `${{ github.workflow }}-${{ github.ref }}`
|
||||
- Per-PR scope (for `issue_comment`, `pull_request_review*`, `pull_request` meta events): `${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}`
|
||||
- `workflow_run` scope (e.g. `ci-report.yml`): `${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}` — the fork fallback must be stable across reruns (never `workflow_run.id`, which is per-run-unique and defeats serialization).
|
||||
- Global single-slot (manual dispatch utilities): `${{ github.workflow }}`
|
||||
- **Reusable workflows invoked via `workflow_call`:** do NOT use `${{ github.workflow }}` in the group key — in called-workflow context its evaluation is ambiguous and can resolve to the caller's name, which would deadlock against the caller's own group. Use a hardcoded literal prefix and a `github.event_name`-aware expression that falls through to `github.run_id` for reusable invocations (see `ci.yml` for the canonical form).
|
||||
- **Merge queue (`merge_group`)**: when this event is added, use `${{ github.workflow }}-${{ github.event.merge_group.head_ref }}` with `cancel-in-progress: false` (every queue entry is a distinct ref; never cancel).
|
||||
- **`cancel-in-progress` policy:**
|
||||
|
||||
| Event | `cancel-in-progress` | Why |
|
||||
|-------|----------------------|-----|
|
||||
| `pull_request` CI run | `true` | New push supersedes old run |
|
||||
| `push` to `main` | `false` | Every main commit gets validated |
|
||||
| Tag push (`v*` publish) | `false` | Never cancel mid-publish |
|
||||
| `push` to `main` for release-candidate | `false` | Never cancel mid-RC publish |
|
||||
| `workflow_dispatch` (release/publish) | `false` | Manual runs are intentional |
|
||||
| `workflow_run` (sticky-comment reports) | `false` | Serialize, don't race |
|
||||
| Per-PR bot workflows (`@claude`, review) | `false` | Serialize comments per PR |
|
||||
| PR-meta re-checks (pr-description-check) | `true` | Cheap, latest wins |
|
||||
| Single-slot utilities (triage sweep) | `true` | Latest dispatch supersedes |
|
||||
|
||||
- For workflows that serve multiple events at once (e.g. `ci.yml` handles `pull_request`, `push`, and `workflow_call`), make `cancel-in-progress` event-aware:
|
||||
|
||||
```yaml
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
```
|
||||
|
||||
- When adding a new workflow, copy the concurrency block from an existing workflow of the same event shape.
|
||||
|
||||
## AI-assisted contributions
|
||||
|
||||
If you use coding agents, follow project context files (e.g. `AGENTS.md`, `CLAUDE.md`) and avoid drive-by refactors unrelated to the issue. Prefer incremental, test-backed changes.
|
||||
|
||||
## Releases
|
||||
|
||||
Two publish workflows ship `gitnexus` to npm:
|
||||
|
||||
- **Stable** (`.github/workflows/publish.yml`) — triggered by pushing any `v*`
|
||||
tag. Publishes to the `latest` dist-tag with a changelog-backed GitHub
|
||||
release. Maintainers are expected to tag from `main` as a convention; the
|
||||
workflow itself does not enforce branch reachability.
|
||||
- **Release Candidate** (`.github/workflows/release-candidate.yml`) — runs on
|
||||
every push to `main` (typically a merged PR) plus manual dispatch. Docs-only
|
||||
changes are skipped via `paths-ignore`. Publishes to the `rc` dist-tag with
|
||||
version `X.Y.Z-rc.N` and a GitHub prerelease, where:
|
||||
- `X.Y.Z` is selected automatically. On push (and on dispatch with
|
||||
`bump: auto`, the default) the workflow **continues the active rc cycle**:
|
||||
if the registry already has `X.Y.Z-rc.*` versions with `X.Y.Z` > current
|
||||
`latest`, it reuses the highest such base; otherwise it patch-bumps
|
||||
from `latest`. Dispatching with `bump: patch|minor|major` **resets**
|
||||
the cycle from `latest`.
|
||||
- `N` is auto-incremented against existing `X.Y.Z-rc.*` entries on the
|
||||
registry. First rc for a given base is `rc.1`.
|
||||
|
||||
Idempotency: the workflow pushes an `rc/<HEAD_SHA>` marker tag and a
|
||||
`v<RC>` release tag **atomically, before** calling `npm publish`. The guard
|
||||
refuses to re-run once the marker exists, so a post-publish failure will
|
||||
not mint a duplicate rc for the same commit. The `v<RC>` tag points at a
|
||||
detached release commit whose `package.json` matches the npm tarball
|
||||
exactly (traceable releases). Recovery after a partial failure:
|
||||
|
||||
```bash
|
||||
git push --delete origin rc/<HEAD_SHA> v<RC>
|
||||
# then redispatch the workflow with force: true
|
||||
```
|
||||
|
||||
The rc workflow never moves `latest`. To verify after a change, inspect dist-tags:
|
||||
|
||||
```bash
|
||||
npm view gitnexus dist-tags
|
||||
```
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
|
||||
RUN npm ci --prefix gitnexus-shared
|
||||
|
||||
COPY gitnexus-shared ./gitnexus-shared
|
||||
RUN npm run build --prefix gitnexus-shared
|
||||
|
||||
COPY gitnexus/package.json ./gitnexus/package.json
|
||||
COPY gitnexus-web/package.json gitnexus-web/package-lock.json ./gitnexus-web/
|
||||
RUN npm ci --prefix gitnexus-web
|
||||
|
||||
COPY gitnexus-web ./gitnexus-web
|
||||
RUN npm run build --prefix gitnexus-web
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/gitnexus-web/dist ./dist
|
||||
COPY docker-server.mjs ./docker-server.mjs
|
||||
|
||||
RUN chown -R node:node /app
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 4173
|
||||
|
||||
CMD ["node", "docker-server.mjs"]
|
||||
+43
-46
@@ -1,72 +1,69 @@
|
||||
# Guardrails — GitNexus (repo + agents)
|
||||
# Guardrails — GitNexus
|
||||
|
||||
Rules for **human contributors** and **AI agents** working on this codebase or publishing artifacts. These complement `AGENTS.md` / `CLAUDE.md` (which focus on GitNexus-in-GitNexus workflows).
|
||||
Rules for **human contributors** and **AI agents**. Complements `AGENTS.md` (workflows) and `CONTRIBUTING.md` (PR process).
|
||||
|
||||
## Scope (typical agent session)
|
||||
## Scope (least privilege)
|
||||
|
||||
When automating changes in this repository, treat scope as **least privilege**:
|
||||
- **Read:** Source, tests, docs, public config as needed.
|
||||
- **Write:** Only files required for the fix or feature; no unrelated formatting or refactors.
|
||||
- **Execute:** Tests, typecheck, documented CLI commands. No destructive commands on user data without approval.
|
||||
- **Off-limits:** Other people's machines, production deployments you don't own, credentials you lack permission to use.
|
||||
|
||||
- **Read:** Source, tests, docs, public config as needed for the task.
|
||||
- **Write:** Only files required for the requested fix or feature; avoid unrelated formatting or refactors.
|
||||
- **Execute:** Tests, typecheck, and documented CLI commands; do not run destructive commands on user data outside the repo without explicit approval.
|
||||
- **Off-limits:** Other people’s machines, production deployments you don’t own, and credentials you didn’t receive permission to use.
|
||||
|
||||
Adjust explicitly if the maintainer defines a different scope for a task.
|
||||
Maintainer may widen scope per task.
|
||||
|
||||
---
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
1. **Never commit secrets** — API keys, tokens, `.env` with real values, private URLs, or session cookies. Use `.env.example` with placeholders only.
|
||||
2. **Never rename symbols with blind find-and-replace** when working in a GitNexus-indexed project — use the **`rename` MCP tool** with **`dry_run: true` first**, then review `graph` vs `text_search` edits. (There is no separate `gitnexus rename` CLI; renaming goes through MCP or editor integration.)
|
||||
3. **Run impact analysis before editing shared symbols** — use **`impact`** (upstream) for functions/classes/methods others call; do not ignore **HIGH** / **CRITICAL** risk without maintainer sign-off.
|
||||
4. **Prefer `detect_changes` before commit** — confirm diffs map to expected symbols/processes when the graph is available.
|
||||
5. **Preserve embeddings** — if `.gitnexus/meta.json` shows embeddings, run `npx gitnexus analyze --embeddings` when refreshing the index; plain `analyze` can drop them.
|
||||
1. **Never commit secrets** — API keys, tokens, real `.env` values, private URLs, session cookies. Use `.env.example` with placeholders.
|
||||
2. **Never rename with find-and-replace** in GitNexus-indexed projects — use `rename` MCP tool with `dry_run: true` first, review `graph` vs `text_search` edits. No separate `gitnexus rename` CLI exists.
|
||||
3. **Run impact analysis before editing shared symbols** — `impact` (upstream) for functions/classes/methods others call. Do not ignore HIGH/CRITICAL without maintainer sign-off.
|
||||
4. **Run `detect_changes` before commit** — confirm diffs map to expected symbols/processes when the graph is available.
|
||||
5. **Preserve embeddings** — if `.gitnexus/meta.json` shows embeddings, use `npx gitnexus analyze --embeddings`; plain `analyze` drops them.
|
||||
|
||||
---
|
||||
|
||||
## Signs (recurring failure patterns)
|
||||
|
||||
Use this format: **Trigger → Instruction → Reason**.
|
||||
Append new Signs here when the same mistake repeats (e.g. CI broken twice the same way).
|
||||
Format: **Trigger → Instruction → Reason**. Append new Signs when the same mistake repeats.
|
||||
|
||||
### Sign: Stale graph after edits
|
||||
### Stale graph after edits
|
||||
|
||||
- **Trigger:** MCP or resources warn the index is behind `HEAD`, or code search doesn’t match latest commit.
|
||||
- **Instruction:** Run `npx gitnexus analyze` from the repo root (plus `--embeddings` if the project used them).
|
||||
- **Reason:** Tools query LadybugDB built at last analyze; git changes are invisible until re-indexed.
|
||||
- **Trigger:** MCP warns index is behind `HEAD`, or search doesn't match latest commit.
|
||||
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used).
|
||||
- **Why:** Tools query LadybugDB from last analyze; git changes are invisible until re-indexed.
|
||||
|
||||
### Sign: Embeddings vanished after analyze
|
||||
### Embeddings vanished after analyze
|
||||
|
||||
- **Trigger:** Semantic search quality drops; `stats.embeddings` in `.gitnexus/meta.json` is 0 after a refresh.
|
||||
- **Instruction:** Re-run `npx gitnexus analyze --embeddings` and confirm `meta.json` reflects stored embeddings.
|
||||
- **Reason:** Embedding generation is opt-in; analyze without the flag does not preserve prior vectors.
|
||||
- **Trigger:** Semantic search quality drops; `stats.embeddings` in `meta.json` is 0 after refresh.
|
||||
- **Do:** `npx gitnexus analyze --embeddings`, confirm `meta.json` reflects stored embeddings.
|
||||
- **Why:** Embedding generation is opt-in; analyze without the flag does not preserve prior vectors.
|
||||
|
||||
### Sign: MCP lists no repos
|
||||
### MCP lists no repos
|
||||
|
||||
- **Trigger:** MCP stderr says no indexed repos.
|
||||
- **Instruction:** Run `npx gitnexus analyze` in the target repository; verify `npx gitnexus list` shows it.
|
||||
- **Reason:** The MCP server discovers repos via `~/.gitnexus/registry.json`, populated by analyze.
|
||||
- **Trigger:** MCP stderr says no indexed repos.
|
||||
- **Do:** `npx gitnexus analyze` in the target repo; verify `npx gitnexus list` shows it.
|
||||
- **Why:** MCP discovers repos via `~/.gitnexus/registry.json`, populated by analyze.
|
||||
|
||||
### Sign: Wrong repo in multi-repo setups
|
||||
### Wrong repo in multi-repo setups
|
||||
|
||||
- **Trigger:** Query/impact results clearly belong to another project.
|
||||
- **Instruction:** Call `list_repos`, then pass **`repo`** on subsequent tools (or use per-workspace MCP config).
|
||||
- **Reason:** Default target may be ambiguous when multiple repos are registered.
|
||||
- **Trigger:** Query/impact results belong to another project.
|
||||
- **Do:** Call `list_repos`, then pass `repo` on subsequent tools.
|
||||
- **Why:** Default target is ambiguous when multiple repos are registered.
|
||||
|
||||
### Sign: LadybugDB lock / “database busy”
|
||||
### LadybugDB lock / "database busy"
|
||||
|
||||
- **Trigger:** Errors opening `.gitnexus/lbug` while MCP and analyze both run.
|
||||
- **Instruction:** Stop overlapping processes; one writer at a time. Retry analyze or restart MCP.
|
||||
- **Reason:** Embedded DB expects single-process ownership of the store.
|
||||
- **Trigger:** Errors opening `.gitnexus/lbug` while MCP and analyze both run.
|
||||
- **Do:** Stop overlapping processes (one writer at a time). Retry analyze or restart MCP.
|
||||
- **Why:** Embedded DB expects single-process ownership.
|
||||
|
||||
---
|
||||
|
||||
## Publishing & supply chain
|
||||
|
||||
- **npm:** Do not publish from unreviewed automation; follow maintainer release process. Bump version intentionally; tag releases to match `package.json`.
|
||||
- **Dependencies:** Prefer minimal, auditable changes to `package.json`; run tests and CI after lockfile updates.
|
||||
- **License:** This project ships under **PolyForm Noncommercial 1.0.0** — do not relicense or imply a different license in docs or metadata without maintainer approval.
|
||||
- **npm:** Do not publish from unreviewed automation. Bump version intentionally; tag releases to match `package.json`.
|
||||
- **Dependencies:** Minimal, auditable `package.json` changes; run tests and CI after lockfile updates.
|
||||
- **License:** PolyForm Noncommercial 1.0.0 — do not relicense without maintainer approval.
|
||||
|
||||
---
|
||||
|
||||
@@ -74,15 +71,15 @@ Append new Signs here when the same mistake repeats (e.g. CI broken twice the sa
|
||||
|
||||
Stop and ask a **human maintainer** when:
|
||||
|
||||
- Impact analysis shows **HIGH** / **CRITICAL** risk and the task still requires the change.
|
||||
- You need to alter **CI**, **release**, or **security-sensitive** config.
|
||||
- Requirements conflict (e.g. “speed up analyze” vs “must keep all embeddings on huge repo”).
|
||||
- Impact analysis shows HIGH/CRITICAL risk and the task still requires the change.
|
||||
- You need to alter CI, release, or security-sensitive config.
|
||||
- Requirements conflict (e.g. "speed up analyze" vs "must keep all embeddings on huge repo").
|
||||
- You are unsure whether data loss is acceptable (`clean`, forced migrations, schema changes).
|
||||
|
||||
---
|
||||
|
||||
## Related docs
|
||||
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — components and data flow.
|
||||
- [RUNBOOK.md](RUNBOOK.md) — commands for recovery.
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — PR and commit expectations.
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — components and data flow
|
||||
- [RUNBOOK.md](RUNBOOK.md) — commands for recovery
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — PR and commit expectations
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
# Migration Guide
|
||||
|
||||
## `impact` tool may now return `{ status: 'ambiguous' }` (PR #888, issue #470)
|
||||
|
||||
Before this change the `impact` MCP tool silently picked the first match
|
||||
when the `target` name hit multiple symbols (Class → Interface → Function
|
||||
→ Method → Constructor priority UNION). This often produced analysis for
|
||||
the wrong symbol with no signal back to the caller.
|
||||
|
||||
After this change, when the resolver finds more than one viable match
|
||||
and the caller supplied none of `target_uid` / `file_path` / `kind`,
|
||||
`impact` returns a disambiguation response shaped like:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ambiguous",
|
||||
"message": "Found N symbols matching '<target>'. Use target_uid, file_path, or kind to disambiguate.",
|
||||
"target": { "name": "<target>" },
|
||||
"direction": "upstream",
|
||||
"impactedCount": 0,
|
||||
"risk": "UNKNOWN",
|
||||
"candidates": [
|
||||
{ "uid": "...", "name": "...", "kind": "Function", "filePath": "...", "line": 42, "score": 0.76 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Do I need to migrate?
|
||||
|
||||
**Probably not, but check for assumptions.** Callers that unconditionally
|
||||
read `result.byDepth` / `result.summary` / `result.affected_processes`
|
||||
without first checking `result.status` will now see `undefined` in the
|
||||
ambiguous case. The fix is to branch on `result.status === 'ambiguous'`
|
||||
first and follow up with `target_uid` (preferred) or `file_path` / `kind`.
|
||||
|
||||
The `context` tool's ambiguous response is a strict superset of the
|
||||
existing shape — every candidate gains a `score` field, no existing field
|
||||
has changed. No migration required for `context` callers.
|
||||
|
||||
### What happens on re-index?
|
||||
|
||||
Nothing — this is an MCP-surface change only. The graph schema, indexer,
|
||||
and stored data are untouched.
|
||||
|
||||
---
|
||||
|
||||
## OVERRIDES → METHOD_OVERRIDES (PR #642)
|
||||
|
||||
The `OVERRIDES` relationship type has been renamed to `METHOD_OVERRIDES` for
|
||||
|
||||
@@ -335,6 +335,42 @@ cd ../gitnexus-web && npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
--name gitnexus \
|
||||
-p 4173:4173 \
|
||||
ghcr.io/abhigyanpatwari/gitnexus:latest
|
||||
```
|
||||
|
||||
Or with Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Optional env file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
```
|
||||
|
||||
Docker files:
|
||||
|
||||
- [Dockerfile](Dockerfile) is the source for the published `gitnexus` image. It builds `gitnexus-shared` and `gitnexus-web`, then serves the production frontend.
|
||||
- [docker-compose.yaml](docker-compose.yaml) starts the published image with Docker Compose.
|
||||
- [.env.example](.env.example) sets the image name, container name, and exposed port for the example commands.
|
||||
|
||||
Notes:
|
||||
|
||||
- The published image serves the production frontend only. It does not start `gitnexus serve`.
|
||||
- In backend mode, the app still defaults to `http://localhost:4747` unless you change the server URL in the UI.
|
||||
- If you do not want an env file, the defaults are `ghcr.io/abhigyanpatwari/gitnexus:latest`, container name `gitnexus`, and port `4173`.
|
||||
|
||||
The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, LadybugDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos.
|
||||
|
||||
**Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically.
|
||||
|
||||
+8
-5
@@ -20,9 +20,9 @@ From repository root, unless noted:
|
||||
cd gitnexus
|
||||
npm install
|
||||
npm run build
|
||||
npm test # unit: vitest run test/unit
|
||||
npm test # full suite: vitest run
|
||||
npm run test:unit # unit only: vitest run test/unit
|
||||
npm run test:integration # integration suite
|
||||
npm run test:all
|
||||
npm run test:coverage
|
||||
npx tsc --noEmit # typecheck (matches CI)
|
||||
```
|
||||
@@ -42,8 +42,11 @@ npm run test:e2e # Playwright (requires gitnexus serve + npm run dev)
|
||||
|
||||
A husky pre-commit hook (`.husky/pre-commit`) runs automatically on every `git commit`:
|
||||
|
||||
- **`gitnexus-web/` files staged** → `tsc -b --noEmit` + `vitest run`
|
||||
- **`gitnexus/` files staged** → `tsc --noEmit` + `vitest run --project default`
|
||||
1. **Formatting** — `lint-staged` runs prettier on staged files
|
||||
2. **`gitnexus-web/` files staged** → `tsc -b --noEmit`
|
||||
3. **`gitnexus/` files staged** → `tsc --noEmit`
|
||||
|
||||
Tests do **not** run in the pre-commit hook — they run in CI (`ci-tests.yml`) only.
|
||||
|
||||
Skip with `git commit --no-verify` (use sparingly).
|
||||
|
||||
@@ -77,7 +80,7 @@ Re-run the full relevant suite when:
|
||||
|
||||
GitHub Actions (`.github/workflows/ci.yml`) orchestrate:
|
||||
|
||||
- **`ci-quality.yml`** — `tsc --noEmit` for `gitnexus/` + `tsc -b --noEmit` for `gitnexus-web/`
|
||||
- **`ci-quality.yml`** — prettier format check, eslint lint, `tsc --noEmit` for `gitnexus/`, `tsc -b --noEmit` for `gitnexus-web/`
|
||||
- **`ci-tests.yml`** — `vitest run` with coverage (ubuntu) + cross-platform (macOS, Windows)
|
||||
- **`ci-e2e.yml`** — Playwright E2E tests, gated on `gitnexus-web/**` changes
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
gitnexus:
|
||||
image: ${IMAGE_NAME:-ghcr.io/brainifii/gitnexus:latest}
|
||||
container_name: ${CONTAINER_NAME:-gitnexus}
|
||||
ports:
|
||||
- '${HOST_PORT:-4173}:4173'
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:4173/']
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { extname, join, normalize, sep } from 'node:path';
|
||||
|
||||
const host = '0.0.0.0';
|
||||
const port = Number(process.env.PORT || '4173');
|
||||
const root = join(process.cwd(), 'dist');
|
||||
|
||||
const contentTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
function resolvePath(urlPath) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = decodeURIComponent(urlPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (decoded.includes('\0')) return null;
|
||||
const cleanPath = normalize(decoded.replace(/^\/+/, ''));
|
||||
const candidate = join(root, cleanPath);
|
||||
if (candidate !== root && !candidate.startsWith(root + sep)) return null;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const requestPath = req.url?.split('?')[0] || '/';
|
||||
let filePath = resolvePath(requestPath);
|
||||
|
||||
if (!filePath) {
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStat = await stat(filePath).catch(() => null);
|
||||
if (fileStat?.isDirectory()) {
|
||||
filePath = join(filePath, 'index.html');
|
||||
} else if (!fileStat?.isFile()) {
|
||||
filePath = join(root, 'index.html');
|
||||
}
|
||||
|
||||
const finalStat = await stat(filePath).catch(() => null);
|
||||
if (!finalStat?.isFile()) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Cache-Control': filePath.includes('/assets/')
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-cache',
|
||||
'Content-Type': contentTypes[extname(filePath)] || 'application/octet-stream',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
});
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on('error', () => res.destroy());
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
res.writeHead(500);
|
||||
res.end(error instanceof Error ? error.message : 'Internal server error');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`gitnexus-web listening on http://${host}:${port}`);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises';
|
||||
import http, { createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { after, before, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const serverScript = join(__dirname, 'docker-server.mjs');
|
||||
|
||||
function getFreePort() {
|
||||
return new Promise((resolve) => {
|
||||
const s = createServer();
|
||||
s.listen(0, '127.0.0.1', () => {
|
||||
const { port } = s.address();
|
||||
s.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function rawGet(port, path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ host: '127.0.0.1', port, path }, (res) => {
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(port, retries = 30) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
await rawGet(port, '/');
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
throw new Error('Server did not start in time');
|
||||
}
|
||||
|
||||
let tmpDir, serverPort, child;
|
||||
|
||||
before(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'gitnexus-docker-test-'));
|
||||
const distDir = join(tmpDir, 'dist');
|
||||
const assetsDir = join(distDir, 'assets');
|
||||
await mkdir(assetsDir, { recursive: true });
|
||||
await writeFile(join(distDir, 'index.html'), '<html><body>spa</body></html>');
|
||||
await writeFile(join(assetsDir, 'app.abc123.js'), 'console.log("app")');
|
||||
|
||||
serverPort = await getFreePort();
|
||||
child = spawn(process.execPath, [serverScript], {
|
||||
cwd: tmpDir,
|
||||
env: { ...process.env, PORT: String(serverPort) },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
await waitForServer(serverPort);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
child?.kill();
|
||||
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('serves a valid asset with immutable cache header', async () => {
|
||||
const res = await rawGet(serverPort, '/assets/app.abc123.js');
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers['cache-control'], /immutable/);
|
||||
assert.equal(res.headers['cross-origin-opener-policy'], 'same-origin');
|
||||
assert.equal(res.headers['cross-origin-embedder-policy'], 'require-corp');
|
||||
});
|
||||
|
||||
it('serves SPA fallback for unknown routes', async () => {
|
||||
const res = await rawGet(serverPort, '/some/unknown/route');
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.body, /spa/);
|
||||
assert.match(res.headers['cache-control'], /no-cache/);
|
||||
});
|
||||
|
||||
it('rejects path traversal with 400', async () => {
|
||||
const res = await rawGet(serverPort, '/../../../etc/passwd');
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('rejects percent-encoded null bytes with 400', async () => {
|
||||
const res = await rawGet(serverPort, '/foo%00bar');
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('returns 404 when dist/index.html is missing', async () => {
|
||||
await unlink(join(tmpDir, 'dist', 'index.html'));
|
||||
const res = await rawGet(serverPort, '/nonexistent-page');
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
@@ -23,3 +23,121 @@ export type { MroStrategy } from './mro-strategy.js';
|
||||
|
||||
// Pipeline progress
|
||||
export type { PipelinePhase, PipelineProgress } from './pipeline.js';
|
||||
|
||||
// ─── Scope-based resolution — RFC #909 (Ring 1 #910) ────────────────────────
|
||||
// Data model (RFC §2)
|
||||
export type { SymbolDefinition } from './scope-resolution/symbol-definition.js';
|
||||
export type {
|
||||
ScopeId,
|
||||
DefId,
|
||||
ScopeKind,
|
||||
Range,
|
||||
Capture,
|
||||
CaptureMatch,
|
||||
BindingRef,
|
||||
ImportEdge,
|
||||
TypeRef,
|
||||
Scope,
|
||||
ResolutionEvidence,
|
||||
Resolution,
|
||||
Reference,
|
||||
ReferenceIndex,
|
||||
LookupParams,
|
||||
RegistryContributor,
|
||||
ParsedImport,
|
||||
ParsedTypeBinding,
|
||||
WorkspaceIndex,
|
||||
Callsite,
|
||||
} from './scope-resolution/types.js';
|
||||
|
||||
// Evidence + tie-break constants (RFC Appendix A, Appendix B)
|
||||
export { EvidenceWeights, typeBindingWeightAtDepth } from './scope-resolution/evidence-weights.js';
|
||||
export { ORIGIN_PRIORITY } from './scope-resolution/origin-priority.js';
|
||||
export type { OriginForTieBreak } from './scope-resolution/origin-priority.js';
|
||||
|
||||
// Language classification (RFC §6.1 Ring 3/4 governance)
|
||||
export {
|
||||
LanguageClassifications,
|
||||
isProductionLanguage,
|
||||
} from './scope-resolution/language-classification.js';
|
||||
export type { LanguageClassification } from './scope-resolution/language-classification.js';
|
||||
|
||||
// Core indexes over per-file artifacts (RFC §3.1; Ring 2 SHARED #913)
|
||||
export { buildDefIndex } from './scope-resolution/def-index.js';
|
||||
export type { DefIndex } from './scope-resolution/def-index.js';
|
||||
export { buildModuleScopeIndex } from './scope-resolution/module-scope-index.js';
|
||||
export type { ModuleScopeIndex, ModuleScopeEntry } from './scope-resolution/module-scope-index.js';
|
||||
export { buildQualifiedNameIndex } from './scope-resolution/qualified-name-index.js';
|
||||
export type { QualifiedNameIndex } from './scope-resolution/qualified-name-index.js';
|
||||
|
||||
// Strict type-reference resolver (RFC §4.6; Ring 2 SHARED #916)
|
||||
export { resolveTypeRef } from './scope-resolution/resolve-type-ref.js';
|
||||
export type { ResolveTypeRefContext, ScopeLookup } from './scope-resolution/resolve-type-ref.js';
|
||||
|
||||
// Method-dispatch materialized view over HeritageMap (RFC §3.1; Ring 2 SHARED #914)
|
||||
export { buildMethodDispatchIndex } from './scope-resolution/method-dispatch-index.js';
|
||||
export type {
|
||||
MethodDispatchIndex,
|
||||
MethodDispatchInput,
|
||||
} from './scope-resolution/method-dispatch-index.js';
|
||||
|
||||
// SCC-aware cross-file finalize (RFC §3.2 Phase 2; Ring 2 SHARED #915)
|
||||
export { finalize } from './scope-resolution/finalize-algorithm.js';
|
||||
export type {
|
||||
FinalizeInput,
|
||||
FinalizeFile,
|
||||
FinalizeHooks,
|
||||
FinalizeOutput,
|
||||
FinalizedScc,
|
||||
FinalizeStats,
|
||||
} from './scope-resolution/finalize-algorithm.js';
|
||||
|
||||
// Scope-aware registries + 7-step lookup (RFC §4; Ring 2 SHARED #917)
|
||||
export { buildClassRegistry } from './scope-resolution/registries/class-registry.js';
|
||||
export type { ClassRegistry } from './scope-resolution/registries/class-registry.js';
|
||||
export { buildMethodRegistry } from './scope-resolution/registries/method-registry.js';
|
||||
export type {
|
||||
MethodRegistry,
|
||||
MethodLookupOptions,
|
||||
} from './scope-resolution/registries/method-registry.js';
|
||||
export { buildFieldRegistry } from './scope-resolution/registries/field-registry.js';
|
||||
export type {
|
||||
FieldRegistry,
|
||||
FieldLookupOptions,
|
||||
} from './scope-resolution/registries/field-registry.js';
|
||||
export { lookupCore } from './scope-resolution/registries/lookup-core.js';
|
||||
export type { CoreLookupParams } from './scope-resolution/registries/lookup-core.js';
|
||||
export { lookupQualified } from './scope-resolution/registries/lookup-qualified.js';
|
||||
export type { LookupQualifiedParams } from './scope-resolution/registries/lookup-qualified.js';
|
||||
export { composeEvidence, confidenceFromEvidence } from './scope-resolution/registries/evidence.js';
|
||||
export type { RawSignals } from './scope-resolution/registries/evidence.js';
|
||||
export {
|
||||
compareByConfidenceWithTiebreaks,
|
||||
CONFIDENCE_EPSILON,
|
||||
} from './scope-resolution/registries/tie-breaks.js';
|
||||
export type { TieBreakKey } from './scope-resolution/registries/tie-breaks.js';
|
||||
export { CLASS_KINDS, METHOD_KINDS, FIELD_KINDS } from './scope-resolution/registries/context.js';
|
||||
export type {
|
||||
RegistryContext,
|
||||
RegistryProviders,
|
||||
OwnerScopedContributor,
|
||||
ArityVerdict,
|
||||
} from './scope-resolution/registries/context.js';
|
||||
|
||||
// Scope tree spine + position lookup (RFC §2.2 + §3.1; Ring 2 SHARED #912)
|
||||
export { makeScopeId, clearScopeIdInternPool } from './scope-resolution/scope-id.js';
|
||||
export type { ScopeIdInput } from './scope-resolution/scope-id.js';
|
||||
export { buildScopeTree, ScopeTreeInvariantError } from './scope-resolution/scope-tree.js';
|
||||
export type { ScopeTree } from './scope-resolution/scope-tree.js';
|
||||
export { buildPositionIndex } from './scope-resolution/position-index.js';
|
||||
export type { PositionIndex } from './scope-resolution/position-index.js';
|
||||
|
||||
// Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918)
|
||||
export { diffResolutions } from './scope-resolution/shadow/diff.js';
|
||||
export type {
|
||||
ShadowAgreement,
|
||||
ShadowCallsite,
|
||||
ShadowDiff,
|
||||
} from './scope-resolution/shadow/diff.js';
|
||||
export { aggregateDiffs } from './scope-resolution/shadow/aggregate.js';
|
||||
export type { LanguageParityRow, ShadowParityReport } from './scope-resolution/shadow/aggregate.js';
|
||||
|
||||
@@ -30,6 +30,7 @@ export const NODE_TABLES = [
|
||||
'TypeAlias',
|
||||
'Const',
|
||||
'Static',
|
||||
'Variable',
|
||||
'Property',
|
||||
'Record',
|
||||
'Delegate',
|
||||
|
||||
@@ -1,23 +1,46 @@
|
||||
/**
|
||||
* MRO (Method Resolution Order) strategy — shared between CLI and any
|
||||
* future consumer that reasons about multiple-inheritance semantics.
|
||||
* MRO (Method Resolution Order) strategy — shared canonical definition.
|
||||
*
|
||||
* Lives in `gitnexus-shared` so the low-level resolution module
|
||||
* (`core/ingestion/model/resolve.ts`) does not need to import from
|
||||
* `languages/` — keeping the `model/` layer free of language-registry
|
||||
* coupling.
|
||||
* Lives in `gitnexus-shared` so `model/resolve.ts` and `mro-processor.ts` share
|
||||
* the type without importing the language registry (avoids circular coupling).
|
||||
*
|
||||
* Strategy semantics:
|
||||
* - `first-wins`: BFS ancestor walk, first match wins (default).
|
||||
* - `leftmost-base`: BFS ancestor walk, leftmost base wins (C++).
|
||||
* - `c3`: C3-linearized ancestor order, first match wins (Python).
|
||||
* - `implements-split`: BFS walk, first match wins (Java/C#/Kotlin) — full
|
||||
* interface-default ambiguity is handled at graph level.
|
||||
* - `qualified-syntax`: No auto-resolution (Rust — requires `<T as Trait>::m`).
|
||||
* `first-wins` (default, Java/C#/Kotlin/Go/Swift/Dart):
|
||||
* BFS ancestor walk in declaration order; first match wins.
|
||||
*
|
||||
* `leftmost-base` (C++):
|
||||
* BFS walk; HeritageMap preserves source insertion order, so BFS naturally
|
||||
* picks the leftmost base in diamond inheritance.
|
||||
*
|
||||
* `c3` (Python):
|
||||
* C3-linearization; falls back to BFS on cyclic/inconsistent hierarchy.
|
||||
* See model/resolve.ts § c3Linearize.
|
||||
*
|
||||
* `implements-split` (Java/C#/Kotlin):
|
||||
* Low-level lookup is BFS; graph-level mro-processor detects and warns on
|
||||
* interface-default method ambiguity.
|
||||
*
|
||||
* `qualified-syntax` (Rust):
|
||||
* No auto-resolution — `lookupMethodByOwnerWithMRO` returns undefined immediately.
|
||||
* Rust requires explicit `<Type as Trait>::method` syntax.
|
||||
*
|
||||
* `ruby-mixin` (Ruby):
|
||||
* Kind-aware walk that does NOT short-circuit on direct owner first (`prepend`
|
||||
* must beat the class's own method). Walk order:
|
||||
* 1. Prepend providers (reverse declaration — last-prepended wins)
|
||||
* 2. Direct owner's own methods
|
||||
* 3. Include providers (reverse declaration)
|
||||
* 4. Transitive ancestors (BFS fallback)
|
||||
* Singleton dispatch: caller passes `ancestryOverride` (extend providers only);
|
||||
* becomes a simple left-to-right scan. Miss NEVER falls through to file-scoped
|
||||
* lookup — null-routes or honors `fallback`.
|
||||
*
|
||||
* @see model/resolve.ts § lookupMethodByOwnerWithMRO
|
||||
* @see languages/ruby.ts § selectDispatch
|
||||
*/
|
||||
export type MroStrategy =
|
||||
| 'first-wins'
|
||||
| 'c3'
|
||||
| 'leftmost-base'
|
||||
| 'implements-split'
|
||||
| 'qualified-syntax';
|
||||
| 'qualified-syntax'
|
||||
| 'ruby-mixin';
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* `DefIndex` — O(1) `DefId → SymbolDefinition` materialization.
|
||||
*
|
||||
* The global "what is this id?" lookup. Every per-kind registry (ClassRegistry,
|
||||
* MethodRegistry, FieldRegistry) returns `DefId[]` and resolves them back to
|
||||
* full `SymbolDefinition` records through this index — one central hash map,
|
||||
* one allocation per def.
|
||||
*
|
||||
* Part of RFC #909 Ring 2 SHARED — #913.
|
||||
*
|
||||
* Consumed by: #917 (`Registry.lookup` implementations), #915 (SCC finalize).
|
||||
*/
|
||||
|
||||
import type { SymbolDefinition } from './symbol-definition.js';
|
||||
import type { DefId } from './types.js';
|
||||
|
||||
export interface DefIndex {
|
||||
readonly byId: ReadonlyMap<DefId, SymbolDefinition>;
|
||||
readonly size: number;
|
||||
get(id: DefId): SymbolDefinition | undefined;
|
||||
has(id: DefId): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `DefIndex` from a flat list of `SymbolDefinition` records.
|
||||
*
|
||||
* **Collision policy: first-write-wins.** `DefId` is meant to be unique
|
||||
* (`nodeId` is the stable graph identifier), so a collision indicates an
|
||||
* upstream bug — most likely the same symbol parsed twice or a duplicate
|
||||
* commit into the pipeline. Rather than silently overwriting with a later
|
||||
* definition that may be partial or wrong, the first record wins and
|
||||
* subsequent records for the same id are dropped. Pipeline bugs surface
|
||||
* later as `has(id) === true` but the def looking older than expected,
|
||||
* which is easier to debug than a silent overwrite.
|
||||
*
|
||||
* Pure function — safe to call repeatedly; no side effects.
|
||||
*/
|
||||
export function buildDefIndex(defs: readonly SymbolDefinition[]): DefIndex {
|
||||
const byId = new Map<DefId, SymbolDefinition>();
|
||||
for (const def of defs) {
|
||||
if (byId.has(def.nodeId)) continue; // first-write-wins
|
||||
byId.set(def.nodeId, def);
|
||||
}
|
||||
return freezeIndex(byId);
|
||||
}
|
||||
|
||||
// ─── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
function freezeIndex(byId: Map<DefId, SymbolDefinition>): DefIndex {
|
||||
return {
|
||||
byId,
|
||||
get size() {
|
||||
return byId.size;
|
||||
},
|
||||
get(id: DefId): SymbolDefinition | undefined {
|
||||
return byId.get(id);
|
||||
},
|
||||
has(id: DefId): boolean {
|
||||
return byId.has(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* `EvidenceWeights` — RFC Appendix A (authoritative values).
|
||||
*
|
||||
* Starting calibration for scope-based resolution. Shadow-first rollout
|
||||
* tunes these against legacy DAG parity. Every `ResolutionEvidence.weight`
|
||||
* value in the codebase MUST reference this map; inline magic numbers are a
|
||||
* lint violation. Extends issue #429 (centralize hardcoded confidence values).
|
||||
*
|
||||
* Evidence composes additively inside `composeEvidence`; the sum is capped
|
||||
* at 1.0 in `Resolution.confidence`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Authoritative weight map. Keys are a mix of `ResolutionEvidence.kind`
|
||||
* values and special modifiers (scope-chain depth, MRO depth decay,
|
||||
* unlinked-import multiplicative cap).
|
||||
*/
|
||||
export const EvidenceWeights = {
|
||||
// ─── Where-found signals (visibility) ─────────────────────────────────────
|
||||
/** `BindingRef.origin === 'local'` */
|
||||
local: 0.55,
|
||||
/** `BindingRef.origin === 'import'` */
|
||||
import: 0.45,
|
||||
/** `BindingRef.origin === 'reexport'` */
|
||||
reexport: 0.4,
|
||||
/** `BindingRef.origin === 'namespace'` */
|
||||
namespace: 0.4,
|
||||
/** `BindingRef.origin === 'wildcard'` */
|
||||
wildcard: 0.3,
|
||||
|
||||
// ─── Scope-chain deduction (per-hop) ──────────────────────────────────────
|
||||
/** Deducted per parent-hop taken (depth-0 = 0, depth-1 = −0.02, …). */
|
||||
scopeChainPerDepth: -0.02,
|
||||
|
||||
// ─── Receiver-type-binding signal (decays by MRO depth) ───────────────────
|
||||
/**
|
||||
* Weight applied when the receiver's type binding resolves to a class that
|
||||
* declares the candidate as a method/field. Decays by MRO depth: direct
|
||||
* class = index 0; 1 parent hop = index 1; etc. Falls back to the last
|
||||
* value for depths beyond the table.
|
||||
*/
|
||||
typeBindingByMroDepth: [0.5, 0.42, 0.36, 0.32, 0.3] as const,
|
||||
|
||||
// ─── Corroborating signals ────────────────────────────────────────────────
|
||||
/** `def.ownerId === resolvedReceiver.def.id` (exact owner match). */
|
||||
ownerMatch: 0.2,
|
||||
/** Explanatory only — retained for debuggability. Never discriminates
|
||||
* because surviving candidates already passed `acceptedKinds`. */
|
||||
kindMatch: 0.0,
|
||||
|
||||
// ─── Arity compatibility (from `provider.arityCompatibility`) ─────────────
|
||||
/** `provider.arityCompatibility(...) === 'compatible'` */
|
||||
arityMatchCompatible: 0.1,
|
||||
/** `provider.arityCompatibility(...) === 'unknown'` */
|
||||
arityMatchUnknown: 0.0,
|
||||
/** `provider.arityCompatibility(...) === 'incompatible'` — penalizes;
|
||||
* candidates filtered only when a compatible candidate exists. */
|
||||
arityMatchIncompatible: -0.15,
|
||||
|
||||
// ─── Global fallback (only when nothing lexically visible) ────────────────
|
||||
/** Hit via `QualifiedNameIndex.byQualifiedName`. */
|
||||
globalQualified: 0.35,
|
||||
/** Fallback hit in a `byName` index (and nothing was lexically visible). */
|
||||
globalName: 0.1,
|
||||
|
||||
// ─── Degraded signals ─────────────────────────────────────────────────────
|
||||
/** Call/reference flowing through a `dynamic-unresolved` edge. */
|
||||
dynamicImportUnresolved: 0.02,
|
||||
|
||||
// ─── Unresolved-import cap (multiplicative, applied per-signal) ───────────
|
||||
/**
|
||||
* Multiplicative cap on the edge-derived evidence signal
|
||||
* (`import`/`wildcard`/`reexport`/`namespace`) when
|
||||
* `ImportEdge.linkStatus === 'unresolved'`. Independent corroborating
|
||||
* signals on the same candidate (`owner-match`, `arity-match`,
|
||||
* `type-binding`) are NOT penalized.
|
||||
*/
|
||||
unlinkedImportMultiplier: 0.5,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Look up the `type-binding` signal weight for a given MRO depth, falling
|
||||
* back to the last tabulated value for depths beyond the table.
|
||||
*/
|
||||
export function typeBindingWeightAtDepth(mroDepth: number): number {
|
||||
const table = EvidenceWeights.typeBindingByMroDepth;
|
||||
if (mroDepth < 0) return table[0];
|
||||
if (mroDepth >= table.length) return table[table.length - 1];
|
||||
return table[mroDepth];
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
/**
|
||||
* `finalize` — cross-file finalize algorithm for the SemanticModel
|
||||
* (RFC §3.2 Phase 2; Ring 2 SHARED #915).
|
||||
*
|
||||
* Pure logic that takes per-file parse output (`ParsedImport[]` +
|
||||
* `SymbolDefinition[]`) and returns:
|
||||
*
|
||||
* - Linked `ImportEdge[]` per module scope, with `targetModuleScope` and
|
||||
* `targetDefId` filled where resolvable; edges that could not be
|
||||
* resolved within the hard fixpoint cap are marked
|
||||
* `linkStatus: 'unresolved'`.
|
||||
* - Materialized `bindings` per module scope — local defs merged with
|
||||
* imported / wildcard-expanded / re-exported names via the provider's
|
||||
* `mergeBindings` precedence.
|
||||
* - The SCC condensation of the import graph, exposed so disjoint SCCs
|
||||
* can be processed in parallel by callers that want that.
|
||||
*
|
||||
* The algorithm is **SCC-aware**: it runs Tarjan SCC over the file-level
|
||||
* import graph, processes SCCs in reverse-topological order (leaves
|
||||
* first), and within each SCC runs a bounded fixpoint link pass capped at
|
||||
* `N = |edges in SCC|`. Cyclic imports finalize without hanging; malformed
|
||||
* inputs are bounded by the cap.
|
||||
*
|
||||
* **No language-specific logic.** Target resolution, wildcard expansion,
|
||||
* and binding precedence all go through caller-supplied hooks
|
||||
* (`resolveImportTarget`, `expandsWildcardTo`, `mergeBindings`) that
|
||||
* match the LanguageProvider surface from #911.
|
||||
*
|
||||
* **Dynamic imports rule.** `kind === 'dynamic-unresolved'` passes through
|
||||
* as an `ImportEdge { kind: 'dynamic-unresolved', targetFile: null }`
|
||||
* with no `BindingRef`. They are parse-time signals, not linkable targets.
|
||||
*/
|
||||
|
||||
import type { SymbolDefinition } from './symbol-definition.js';
|
||||
import type { BindingRef, ImportEdge, ParsedImport, ScopeId, WorkspaceIndex } from './types.js';
|
||||
|
||||
// ─── Public contracts ───────────────────────────────────────────────────────
|
||||
|
||||
/** Per-file input for the finalize pass. */
|
||||
export interface FinalizeFile {
|
||||
readonly filePath: string;
|
||||
/** The module scope id for this file; owns the finalized imports + bindings. */
|
||||
readonly moduleScope: ScopeId;
|
||||
readonly parsedImports: readonly ParsedImport[];
|
||||
/**
|
||||
* Defs exported from this file — the "what other files can import by name"
|
||||
* surface. Typically those with `isExported: true` (the module's own
|
||||
* declarations) plus, for multi-hop re-export chains, the re-exported
|
||||
* names the parser chose to surface here.
|
||||
*
|
||||
* **Multi-hop re-export contract.** `finalize` resolves an edge
|
||||
* `A → B (importedName: 'X')` by looking up `X` in `B.localDefs`. If B
|
||||
* only has `export { X } from './C'` and the parser *does not* include
|
||||
* `X` in `B.localDefs`, A's edge hits the fixpoint cap and is marked
|
||||
* `linkStatus: 'unresolved'`. The fixpoint does NOT mutate `localDefs`
|
||||
* across iterations — it is static input.
|
||||
*
|
||||
* Parsers that want multi-hop re-export chains to settle end-to-end must
|
||||
* include re-exported names in the intermediate file's `localDefs` (with
|
||||
* the original `DefId` of the source symbol). This keeps the algorithm
|
||||
* O(1) per lookup and avoids graph-crawl during finalize.
|
||||
*/
|
||||
readonly localDefs: readonly SymbolDefinition[];
|
||||
}
|
||||
|
||||
/** Input to `finalize`. */
|
||||
export interface FinalizeInput {
|
||||
readonly files: readonly FinalizeFile[];
|
||||
/** Opaque workspace context forwarded to provider hooks. */
|
||||
readonly workspaceIndex: WorkspaceIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-supplied hooks. Mirror the optional LanguageProvider scope-
|
||||
* resolution hooks declared in #911; `finalize` calls them pure-ly and
|
||||
* expects pure answers.
|
||||
*/
|
||||
export interface FinalizeHooks {
|
||||
/**
|
||||
* Resolve a raw import target to the concrete file path that owns it.
|
||||
* Return `null` when no target file is resolvable (e.g., `np.foo` when
|
||||
* `numpy` is external to the workspace).
|
||||
*/
|
||||
resolveImportTarget(
|
||||
targetRaw: string,
|
||||
fromFile: string,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null;
|
||||
|
||||
/**
|
||||
* For a wildcard `import * from M`, return the names visible in the
|
||||
* exporting module scope `M`. The finalize pass looks each name up in
|
||||
* `M`'s local defs to produce a concrete `BindingRef`; names with no
|
||||
* matching export are dropped.
|
||||
*/
|
||||
expandsWildcardTo(targetModuleScope: ScopeId, workspaceIndex: WorkspaceIndex): readonly string[];
|
||||
|
||||
/**
|
||||
* Merge `incoming` bindings into `existing` for a given name. Called
|
||||
* once per name at each scope. Typical rules:
|
||||
* - Python: local > imported > wildcard (last-write-wins within tier).
|
||||
* - Rust: explicit `use` > glob; `pub use` overrides.
|
||||
* Return value replaces the bucket entirely — no implicit append.
|
||||
*/
|
||||
mergeBindings(
|
||||
existing: readonly BindingRef[],
|
||||
incoming: readonly BindingRef[],
|
||||
scope: ScopeId,
|
||||
): readonly BindingRef[];
|
||||
}
|
||||
|
||||
/** One SCC in the file-level import graph. */
|
||||
export interface FinalizedScc {
|
||||
readonly files: readonly string[];
|
||||
/** True iff this SCC has ≥ 2 files OR a single file that self-imports. */
|
||||
readonly isCycle: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counters reported by `finalize`.
|
||||
*
|
||||
* **Counting granularity** — all edge counters are **per-`ParsedImport`**,
|
||||
* not per-materialized-`ImportEdge`. A single `wildcard` ParsedImport that
|
||||
* expands to N exports counts as one linked edge in these stats; the
|
||||
* materialized output (`FinalizeOutput.imports`) will have N edges for
|
||||
* that input. `dynamic-unresolved` ParsedImports count as linked (they
|
||||
* pass through with no `linkStatus`), so `linkedEdges` ≠ "has a
|
||||
* BindingRef" — use the `bindings` map for that.
|
||||
*
|
||||
* In other words: `totalEdges === input.parsedImports.length` summed
|
||||
* across files, and `linkedEdges + unresolvedEdges === totalEdges`.
|
||||
*/
|
||||
export interface FinalizeStats {
|
||||
readonly totalFiles: number;
|
||||
/** Total `ParsedImport` records seen across all files. */
|
||||
readonly totalEdges: number;
|
||||
/**
|
||||
* `ParsedImport`s whose finalized edge does NOT carry
|
||||
* `linkStatus: 'unresolved'`. Includes `dynamic-unresolved` pass-throughs.
|
||||
*/
|
||||
readonly linkedEdges: number;
|
||||
/** `ParsedImport`s whose finalized edge carries `linkStatus: 'unresolved'`. */
|
||||
readonly unresolvedEdges: number;
|
||||
readonly sccCount: number;
|
||||
readonly largestSccSize: number;
|
||||
}
|
||||
|
||||
export interface FinalizeOutput {
|
||||
/** Linked `ImportEdge[]` per module scope, in original input order. */
|
||||
readonly imports: ReadonlyMap<ScopeId, readonly ImportEdge[]>;
|
||||
/** Materialized bindings per module scope. */
|
||||
readonly bindings: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
|
||||
/** SCCs in reverse-topological order (leaves first). */
|
||||
readonly sccs: readonly FinalizedScc[];
|
||||
readonly stats: FinalizeStats;
|
||||
}
|
||||
|
||||
// ─── Entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOutput {
|
||||
const byFilePath = new Map<string, FinalizeFile>();
|
||||
for (const f of input.files) byFilePath.set(f.filePath, f);
|
||||
|
||||
// ── Phase 0: pre-resolve raw import targets (one syscall-equivalent per
|
||||
// (file, parsedImport)). Edges with no resolvable target become
|
||||
// `linkStatus: 'unresolved'` or, for dynamic-unresolved, pass through
|
||||
// with `targetFile: null`.
|
||||
const edgeIndex = new Map<string, ImportEdgeDraft[]>(); // filePath → drafts
|
||||
let totalEdges = 0;
|
||||
|
||||
for (const file of input.files) {
|
||||
const drafts: ImportEdgeDraft[] = [];
|
||||
for (const parsed of file.parsedImports) {
|
||||
const draft = makeEdgeDraft(parsed, file, hooks, input.workspaceIndex);
|
||||
drafts.push(draft);
|
||||
totalEdges++;
|
||||
}
|
||||
edgeIndex.set(file.filePath, drafts);
|
||||
}
|
||||
|
||||
// ── Phase 1: build file-level import graph (only resolvable edges form
|
||||
// graph edges; unresolvable ones are terminal and contribute no
|
||||
// fixpoint obligation).
|
||||
const graph = new Map<string, Set<string>>();
|
||||
for (const file of input.files) {
|
||||
graph.set(file.filePath, new Set());
|
||||
}
|
||||
for (const [fromFile, drafts] of edgeIndex) {
|
||||
const edges = graph.get(fromFile)!;
|
||||
for (const d of drafts) {
|
||||
if (d.targetFile !== null && byFilePath.has(d.targetFile)) {
|
||||
edges.add(d.targetFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: Tarjan SCC → reverse-topological list of SCCs.
|
||||
const sccs = tarjanSccs(graph);
|
||||
|
||||
// ── Phase 3: process SCCs in reverse-topological order (leaves first).
|
||||
// Within each SCC, run a bounded fixpoint that resolves intra-SCC edges.
|
||||
// Edges leaving the SCC are already resolved (their target SCC is
|
||||
// already finalized); edges inside the SCC may need multiple passes.
|
||||
const linkedByScope = new Map<ScopeId, readonly ImportEdge[]>();
|
||||
let linkedEdges = 0;
|
||||
|
||||
for (const scc of sccs) {
|
||||
const sccFiles = new Set(scc.files);
|
||||
const capacity = countEdgesWithin(edgeIndex, sccFiles);
|
||||
|
||||
// Run the fixpoint up to `capacity` iterations. Each iteration tries to
|
||||
// resolve every still-unlinked edge in the SCC; stops early if a pass
|
||||
// makes no progress.
|
||||
let progressed = true;
|
||||
let iterations = 0;
|
||||
while (progressed && iterations < capacity) {
|
||||
progressed = false;
|
||||
iterations++;
|
||||
for (const filePath of scc.files) {
|
||||
const drafts = edgeIndex.get(filePath)!;
|
||||
for (const draft of drafts) {
|
||||
if (draft.finalized !== null) continue;
|
||||
const finalized = tryFinalize(draft, byFilePath);
|
||||
if (finalized !== null) {
|
||||
draft.finalized = finalized;
|
||||
progressed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any drafts still not finalized within this SCC hit the cap → unresolved.
|
||||
for (const filePath of scc.files) {
|
||||
const drafts = edgeIndex.get(filePath)!;
|
||||
for (const draft of drafts) {
|
||||
if (draft.finalized !== null) continue;
|
||||
draft.finalized = {
|
||||
...draft.base,
|
||||
linkStatus: 'unresolved' as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 4: collect finalized `ImportEdge[]` per module scope, preserving
|
||||
// input order within each file, and wildcard-expand where applicable.
|
||||
for (const file of input.files) {
|
||||
const drafts = edgeIndex.get(file.filePath)!;
|
||||
const finalized: ImportEdge[] = [];
|
||||
for (const d of drafts) {
|
||||
const edge = d.finalized!;
|
||||
if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') {
|
||||
// Produce one `wildcard-expanded` ImportEdge per exported name.
|
||||
const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex);
|
||||
for (const e of expanded) finalized.push(e);
|
||||
} else {
|
||||
finalized.push(edge);
|
||||
}
|
||||
if (edge.linkStatus !== 'unresolved') linkedEdges++;
|
||||
}
|
||||
linkedByScope.set(file.moduleScope, Object.freeze(finalized));
|
||||
}
|
||||
|
||||
// ── Phase 5: materialize module-scope bindings (local + imports + wildcards),
|
||||
// delegating precedence to `provider.mergeBindings`.
|
||||
const bindingsByScope = materializeBindings(input.files, linkedByScope, hooks);
|
||||
|
||||
// ── Stats.
|
||||
const sccCount = sccs.length;
|
||||
let largestSccSize = 0;
|
||||
for (const scc of sccs) {
|
||||
if (scc.files.length > largestSccSize) largestSccSize = scc.files.length;
|
||||
}
|
||||
const stats: FinalizeStats = {
|
||||
totalFiles: input.files.length,
|
||||
totalEdges,
|
||||
linkedEdges,
|
||||
unresolvedEdges: totalEdges - linkedEdges,
|
||||
sccCount,
|
||||
largestSccSize,
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
imports: linkedByScope,
|
||||
bindings: bindingsByScope,
|
||||
sccs,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Internal: edge drafting (phase 0) ──────────────────────────────────────
|
||||
|
||||
interface ImportEdgeDraft {
|
||||
readonly source: ParsedImport;
|
||||
readonly fromFile: string;
|
||||
readonly fromScope: ScopeId;
|
||||
readonly targetFile: string | null;
|
||||
readonly base: ImportEdge;
|
||||
finalized: ImportEdge | null;
|
||||
}
|
||||
|
||||
function makeEdgeDraft(
|
||||
parsed: ParsedImport,
|
||||
file: FinalizeFile,
|
||||
hooks: FinalizeHooks,
|
||||
workspace: WorkspaceIndex,
|
||||
): ImportEdgeDraft {
|
||||
// Dynamic-unresolved passes through — no `BindingRef`, no target file.
|
||||
if (parsed.kind === 'dynamic-unresolved') {
|
||||
const base: ImportEdge = {
|
||||
localName: parsed.localName,
|
||||
targetFile: null,
|
||||
targetExportedName: '',
|
||||
kind: 'dynamic-unresolved',
|
||||
};
|
||||
return {
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
targetFile: null,
|
||||
base,
|
||||
finalized: base, // already fully finalized
|
||||
};
|
||||
}
|
||||
|
||||
const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace);
|
||||
|
||||
// Edge is unresolvable at the file level — mark unresolved now.
|
||||
if (targetFile === null) {
|
||||
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
|
||||
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
|
||||
const targetExportedName = extractExportedName(parsed);
|
||||
const base: ImportEdge = {
|
||||
localName,
|
||||
targetFile: null,
|
||||
targetExportedName,
|
||||
kind: edgeKind,
|
||||
linkStatus: 'unresolved',
|
||||
};
|
||||
return {
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
targetFile: null,
|
||||
base,
|
||||
finalized: base,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolvable at the file level; intra-SCC fixpoint may still fail to fill
|
||||
// in `targetDefId` (e.g., symbol not exported from target).
|
||||
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
|
||||
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
|
||||
const targetExportedName = extractExportedName(parsed);
|
||||
const base: ImportEdge = {
|
||||
localName,
|
||||
targetFile,
|
||||
targetExportedName,
|
||||
kind: edgeKind,
|
||||
};
|
||||
return {
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
targetFile,
|
||||
base,
|
||||
finalized: null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractExportedName(parsed: ParsedImport): string {
|
||||
switch (parsed.kind) {
|
||||
case 'named':
|
||||
case 'alias':
|
||||
case 'namespace':
|
||||
case 'reexport':
|
||||
return parsed.importedName;
|
||||
case 'wildcard':
|
||||
case 'dynamic-unresolved':
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal: per-edge finalization (phase 3) ─────────────────────────────
|
||||
|
||||
function tryFinalize(
|
||||
draft: ImportEdgeDraft,
|
||||
byFilePath: Map<string, FinalizeFile>,
|
||||
): ImportEdge | null {
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null) return draft.base; // already terminal
|
||||
|
||||
const targetModule = byFilePath.get(targetFile);
|
||||
if (targetModule === undefined) return draft.base; // external target — leave as-is
|
||||
|
||||
// Wildcards finalize at the file level; their per-name expansion happens
|
||||
// in phase 4. At this stage we just record the target module scope.
|
||||
if (draft.source.kind === 'wildcard') {
|
||||
return {
|
||||
...draft.base,
|
||||
targetModuleScope: targetModule.moduleScope,
|
||||
};
|
||||
}
|
||||
|
||||
// Namespace imports alias the target *module*; they don't name a
|
||||
// specific export. Link the module scope unconditionally. If the target
|
||||
// also exposes a def whose simple name matches `importedName` (some
|
||||
// languages emit a synthetic module-def), pick it up as the `targetDefId`
|
||||
// so consumers can reach the module as a symbol — but its absence is not
|
||||
// a failure.
|
||||
if (draft.source.kind === 'namespace') {
|
||||
const moduleDef = findExportByName(targetModule.localDefs, extractExportedName(draft.source));
|
||||
return {
|
||||
...draft.base,
|
||||
targetModuleScope: targetModule.moduleScope,
|
||||
...(moduleDef !== undefined ? { targetDefId: moduleDef.nodeId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// named / alias / reexport: look up the imported name in the target's
|
||||
// local defs. Multi-hop re-export chains settle iteratively — each hop
|
||||
// resolves once its prior hop is finalized.
|
||||
const importedName = extractExportedName(draft.source);
|
||||
const exported = findExportByName(targetModule.localDefs, importedName);
|
||||
|
||||
if (exported === undefined) {
|
||||
// Target resolvable but the name isn't exported — keep trying in case a
|
||||
// re-export inside the target's SCC surfaces it in a later iteration.
|
||||
return null;
|
||||
}
|
||||
|
||||
const transitiveVia = draft.source.kind === 'reexport' ? Object.freeze([targetFile]) : undefined;
|
||||
|
||||
return {
|
||||
...draft.base,
|
||||
targetModuleScope: targetModule.moduleScope,
|
||||
targetDefId: exported.nodeId,
|
||||
...(transitiveVia !== undefined ? { transitiveVia } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The "simple" (unqualified) name of a def, for import-name matching.
|
||||
*
|
||||
* Canonical source: `def.qualifiedName` — the tail after the last `.` (or
|
||||
* the whole string if no dot). Defs without a qualifiedName can't be
|
||||
* resolved by name here and return `null`; callers treat that as "name
|
||||
* not exported" and either retry in a later fixpoint iteration or mark
|
||||
* the edge unresolved.
|
||||
*/
|
||||
function deriveSimpleName(def: SymbolDefinition): string | null {
|
||||
const q = def.qualifiedName;
|
||||
if (q === undefined || q.length === 0) return null;
|
||||
const dot = q.lastIndexOf('.');
|
||||
return dot === -1 ? q : q.slice(dot + 1);
|
||||
}
|
||||
|
||||
function findExportByName(
|
||||
defs: readonly SymbolDefinition[],
|
||||
name: string,
|
||||
): SymbolDefinition | undefined {
|
||||
for (const d of defs) {
|
||||
if (deriveSimpleName(d) === name) return d;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function countEdgesWithin(edgeIndex: Map<string, ImportEdgeDraft[]>, files: Set<string>): number {
|
||||
let n = 0;
|
||||
for (const filePath of files) {
|
||||
const drafts = edgeIndex.get(filePath);
|
||||
if (drafts === undefined) continue;
|
||||
for (const d of drafts) {
|
||||
if (d.targetFile !== null && files.has(d.targetFile)) n++;
|
||||
}
|
||||
}
|
||||
// Guarantee at least one pass even for a trivial SCC (ensures deterministic
|
||||
// fixpoint termination even when a single-file SCC has zero intra-SCC edges
|
||||
// but still needs one settle pass).
|
||||
return Math.max(n, 1);
|
||||
}
|
||||
|
||||
// ─── Internal: wildcard expansion (phase 4) ────────────────────────────────
|
||||
|
||||
function expandWildcard(
|
||||
edge: ImportEdge,
|
||||
byFilePath: Map<string, FinalizeFile>,
|
||||
hooks: FinalizeHooks,
|
||||
workspace: WorkspaceIndex,
|
||||
): readonly ImportEdge[] {
|
||||
if (edge.targetModuleScope === undefined || edge.targetFile === null) {
|
||||
return [edge]; // unresolvable wildcard survives as a single unlinked edge
|
||||
}
|
||||
const target = byFilePath.get(edge.targetFile);
|
||||
if (target === undefined) return [edge];
|
||||
|
||||
const names = hooks.expandsWildcardTo(edge.targetModuleScope, workspace);
|
||||
if (names.length === 0) return [];
|
||||
|
||||
const expanded: ImportEdge[] = [];
|
||||
for (const name of names) {
|
||||
const def = findExportByName(target.localDefs, name);
|
||||
if (def === undefined) continue;
|
||||
expanded.push({
|
||||
localName: name,
|
||||
targetFile: edge.targetFile,
|
||||
targetExportedName: name,
|
||||
kind: 'wildcard-expanded',
|
||||
targetModuleScope: edge.targetModuleScope,
|
||||
targetDefId: def.nodeId,
|
||||
});
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
// ─── Internal: bindings materialization (phase 5) ───────────────────────────
|
||||
|
||||
function materializeBindings(
|
||||
files: readonly FinalizeFile[],
|
||||
linkedByScope: ReadonlyMap<ScopeId, readonly ImportEdge[]>,
|
||||
hooks: FinalizeHooks,
|
||||
): ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>> {
|
||||
const out = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
|
||||
|
||||
for (const file of files) {
|
||||
const scopeBindings = new Map<string, readonly BindingRef[]>();
|
||||
|
||||
// Start with local defs as `origin: 'local'` bindings.
|
||||
for (const def of file.localDefs) {
|
||||
const name = deriveSimpleName(def);
|
||||
if (name === null) continue;
|
||||
const incoming: BindingRef[] = [{ def, origin: 'local' }];
|
||||
const existing = scopeBindings.get(name) ?? [];
|
||||
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope));
|
||||
}
|
||||
|
||||
// Layer in finalized imports.
|
||||
const imports = linkedByScope.get(file.moduleScope) ?? [];
|
||||
for (const edge of imports) {
|
||||
if (edge.targetDefId === undefined || edge.linkStatus === 'unresolved') continue;
|
||||
// Every def the importing file needs to reach is in some other file's
|
||||
// `localDefs`; walk all files to find it. In practice we could index
|
||||
// this, but at finalize-time N(files) is small per workspace pass.
|
||||
const def = findDefById(files, edge.targetDefId);
|
||||
if (def === undefined) continue;
|
||||
|
||||
const origin: BindingRef['origin'] =
|
||||
edge.kind === 'namespace'
|
||||
? 'namespace'
|
||||
: edge.kind === 'wildcard-expanded'
|
||||
? 'wildcard'
|
||||
: edge.kind === 'reexport'
|
||||
? 'reexport'
|
||||
: 'import';
|
||||
const fallback = deriveSimpleName(def);
|
||||
const name = edge.localName.length > 0 ? edge.localName : fallback;
|
||||
if (name === null) continue;
|
||||
const incoming: BindingRef[] = [{ def, origin, via: edge }];
|
||||
const existing = scopeBindings.get(name) ?? [];
|
||||
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope));
|
||||
}
|
||||
|
||||
// Freeze nested buckets for immutability.
|
||||
const frozen = new Map<string, readonly BindingRef[]>();
|
||||
for (const [name, refs] of scopeBindings) {
|
||||
frozen.set(name, Object.freeze(refs.slice()));
|
||||
}
|
||||
out.set(file.moduleScope, frozen);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function findDefById(files: readonly FinalizeFile[], defId: string): SymbolDefinition | undefined {
|
||||
for (const f of files) {
|
||||
for (const d of f.localDefs) {
|
||||
if (d.nodeId === defId) return d;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Internal: Tarjan SCC ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Iterative Tarjan SCC. Returns SCCs in **reverse-topological** order
|
||||
* (leaves first — a property Tarjan gives for free, and the order
|
||||
* `finalize` wants so leaves are fully resolved before their dependents).
|
||||
*/
|
||||
function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedScc[] {
|
||||
const index = new Map<string, number>();
|
||||
const lowlink = new Map<string, number>();
|
||||
const onStack = new Set<string>();
|
||||
const stack: string[] = [];
|
||||
const sccs: FinalizedScc[] = [];
|
||||
let idx = 0;
|
||||
|
||||
// Iterative DFS to avoid stack overflow on deep import chains.
|
||||
const allNodes = Array.from(graph.keys()).sort(); // deterministic order
|
||||
const iterStack: Array<{ node: string; children: Iterator<string>; entered: boolean }> = [];
|
||||
|
||||
for (const root of allNodes) {
|
||||
if (index.has(root)) continue;
|
||||
iterStack.push({
|
||||
node: root,
|
||||
children: (graph.get(root) ?? new Set<string>()).values(),
|
||||
entered: false,
|
||||
});
|
||||
while (iterStack.length > 0) {
|
||||
const frame = iterStack[iterStack.length - 1]!;
|
||||
|
||||
if (!frame.entered) {
|
||||
frame.entered = true;
|
||||
index.set(frame.node, idx);
|
||||
lowlink.set(frame.node, idx);
|
||||
idx++;
|
||||
stack.push(frame.node);
|
||||
onStack.add(frame.node);
|
||||
}
|
||||
|
||||
const nextChild = frame.children.next();
|
||||
if (nextChild.done) {
|
||||
// Post-visit: compute SCC membership if frame.node is a root.
|
||||
if (lowlink.get(frame.node) === index.get(frame.node)) {
|
||||
const scc: string[] = [];
|
||||
let selfInCycle = false;
|
||||
while (true) {
|
||||
const w = stack.pop()!;
|
||||
onStack.delete(w);
|
||||
scc.push(w);
|
||||
// A single-file self-loop counts as a cycle.
|
||||
if (w === frame.node) {
|
||||
selfInCycle = (graph.get(w) ?? new Set()).has(w);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const isCycle = scc.length > 1 || selfInCycle;
|
||||
sccs.push({ files: Object.freeze(scc), isCycle });
|
||||
}
|
||||
iterStack.pop();
|
||||
// Propagate lowlink to parent.
|
||||
if (iterStack.length > 0) {
|
||||
const parent = iterStack[iterStack.length - 1]!;
|
||||
lowlink.set(parent.node, Math.min(lowlink.get(parent.node)!, lowlink.get(frame.node)!));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const child = nextChild.value;
|
||||
if (!index.has(child)) {
|
||||
iterStack.push({
|
||||
node: child,
|
||||
children: (graph.get(child) ?? new Set<string>()).values(),
|
||||
entered: false,
|
||||
});
|
||||
} else if (onStack.has(child)) {
|
||||
lowlink.set(frame.node, Math.min(lowlink.get(frame.node)!, index.get(child)!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sccs;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* `LanguageClassification` — RFC §6.1 Ring 3 / Ring 4 governance.
|
||||
*
|
||||
* Classifies each `SupportedLanguages` member for the rollout. Ring 4 (DAG
|
||||
* retirement) is gated on *all production languages* being registry-primary
|
||||
* and stable for one release cycle; `experimental` and `quarantined`
|
||||
* languages do not block.
|
||||
*
|
||||
* Initial classification (locked in Ring 1 #910):
|
||||
* - production: javascript, typescript, python, java, c, cpp, csharp, go,
|
||||
* ruby, rust, php, kotlin, swift, dart
|
||||
* - experimental: vue (embedded-language / SFC complexity),
|
||||
* cobol (regex-provider path)
|
||||
* - quarantined: (none)
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from '../languages.js';
|
||||
|
||||
export type LanguageClassification = 'production' | 'experimental' | 'quarantined';
|
||||
|
||||
/**
|
||||
* The canonical classification for each supported language. Governance
|
||||
* changes (promote `experimental` → `production`, quarantine a language, …)
|
||||
* update this map in a dedicated PR.
|
||||
*/
|
||||
export const LanguageClassifications: Readonly<Record<SupportedLanguages, LanguageClassification>> =
|
||||
{
|
||||
[SupportedLanguages.JavaScript]: 'production',
|
||||
[SupportedLanguages.TypeScript]: 'production',
|
||||
[SupportedLanguages.Python]: 'production',
|
||||
[SupportedLanguages.Java]: 'production',
|
||||
[SupportedLanguages.C]: 'production',
|
||||
[SupportedLanguages.CPlusPlus]: 'production',
|
||||
[SupportedLanguages.CSharp]: 'production',
|
||||
[SupportedLanguages.Go]: 'production',
|
||||
[SupportedLanguages.Ruby]: 'production',
|
||||
[SupportedLanguages.Rust]: 'production',
|
||||
[SupportedLanguages.PHP]: 'production',
|
||||
[SupportedLanguages.Kotlin]: 'production',
|
||||
[SupportedLanguages.Swift]: 'production',
|
||||
[SupportedLanguages.Dart]: 'production',
|
||||
[SupportedLanguages.Vue]: 'experimental',
|
||||
[SupportedLanguages.Cobol]: 'experimental',
|
||||
};
|
||||
|
||||
/** Convenience predicate: is this language gating Ring 4 retirement? */
|
||||
export function isProductionLanguage(lang: SupportedLanguages): boolean {
|
||||
return LanguageClassifications[lang] === 'production';
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* `MethodDispatchIndex` — materialized view of class hierarchies keyed by
|
||||
* `DefId` (RFC §3.1; Ring 2 SHARED #914).
|
||||
*
|
||||
* Two O(1)-access maps used by `Registry.lookupMethod` and interface-
|
||||
* dispatch callers:
|
||||
*
|
||||
* - `mroByOwnerDefId` : owner class → full MRO ancestor chain
|
||||
* (excludes the owner itself, in per-language
|
||||
* strategy order).
|
||||
* - `implsByInterfaceDefId` : interface/trait → classes that implement it.
|
||||
*
|
||||
* **Not an MRO implementation.** The build function is a pure aggregator: it
|
||||
* asks the caller (via `computeMro` and `implementsOf` callbacks) for the
|
||||
* per-language answers and materializes the two-way index. MRO strategies
|
||||
* live where they already do today (`model/resolve.ts § c3Linearize`,
|
||||
* `languages/ruby.ts § selectDispatch`, etc.) — this index does not
|
||||
* reimplement them.
|
||||
*
|
||||
* Why callbacks and not a shared strategy registry: the five strategies
|
||||
* (Python C3, Ruby kind-aware, Java/Kotlin linear, Rust qualified-syntax,
|
||||
* COBOL none) already exist in the CLI package and depend on the CLI's
|
||||
* `HeritageMap` + `SemanticModel`. Pulling them into `gitnexus-shared` would
|
||||
* require migrating both — out of scope for #914. Callbacks let the shared
|
||||
* build stay pure while honoring existing strategies verbatim.
|
||||
*
|
||||
* Consumed by: #917 (`Registry.lookupMethod` MRO fast path, interface
|
||||
* dispatch resolver).
|
||||
*/
|
||||
|
||||
import type { DefId } from './types.js';
|
||||
|
||||
// ─── Public contracts ───────────────────────────────────────────────────────
|
||||
|
||||
export interface MethodDispatchIndex {
|
||||
/**
|
||||
* Full MRO ancestor chain per owner class (excludes the owner itself).
|
||||
* Order reflects the per-language strategy used by `computeMro`.
|
||||
*/
|
||||
readonly mroByOwnerDefId: ReadonlyMap<DefId, readonly DefId[]>;
|
||||
/** Interfaces / traits → classes that implement them. */
|
||||
readonly implsByInterfaceDefId: ReadonlyMap<DefId, readonly DefId[]>;
|
||||
|
||||
/** `mroByOwnerDefId.get`, with an empty frozen array on miss. */
|
||||
mroFor(ownerDefId: DefId): readonly DefId[];
|
||||
/** `implsByInterfaceDefId.get`, with an empty frozen array on miss. */
|
||||
implementorsOf(interfaceDefId: DefId): readonly DefId[];
|
||||
}
|
||||
|
||||
export interface MethodDispatchInput {
|
||||
/**
|
||||
* Owner defs to index (classes, structs, traits, interfaces — any kind
|
||||
* that can appear on the owner side of a method-dispatch graph).
|
||||
*/
|
||||
readonly owners: readonly DefId[];
|
||||
/**
|
||||
* Return the full MRO ancestor chain for `ownerDefId`, **excluding the
|
||||
* owner itself**, in the order dictated by the owner's language-specific
|
||||
* MRO strategy.
|
||||
*
|
||||
* Contract:
|
||||
* - Pure (no side effects).
|
||||
* - Deterministic per input.
|
||||
* - `undefined` not allowed — return `[]` when the owner has no parents.
|
||||
*/
|
||||
readonly computeMro: (ownerDefId: DefId) => readonly DefId[];
|
||||
/**
|
||||
* Return the set of interface/trait defs that `ownerDefId` implements.
|
||||
* Transitive inclusion (e.g., `implements` on a parent class) is the
|
||||
* caller's choice — the build function simply inverts whatever is
|
||||
* returned.
|
||||
*
|
||||
* Repeated IDs in the output are deduplicated automatically.
|
||||
*/
|
||||
readonly implementsOf: (ownerDefId: DefId) => readonly DefId[];
|
||||
}
|
||||
|
||||
// ─── Builder ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDispatchIndex {
|
||||
const mroByOwnerDefId = new Map<DefId, readonly DefId[]>();
|
||||
const implsBuilding = new Map<DefId, DefId[]>();
|
||||
const implsSeen = new Map<DefId, Set<DefId>>();
|
||||
|
||||
for (const ownerId of input.owners) {
|
||||
// First-write-wins on duplicate owner ids: a stable policy consistent
|
||||
// with sibling indexes (#913 DefIndex / ModuleScopeIndex).
|
||||
if (!mroByOwnerDefId.has(ownerId)) {
|
||||
const chain = input.computeMro(ownerId);
|
||||
mroByOwnerDefId.set(ownerId, Object.freeze(chain.slice()));
|
||||
}
|
||||
|
||||
for (const ifaceId of input.implementsOf(ownerId)) {
|
||||
let seen = implsSeen.get(ifaceId);
|
||||
if (seen === undefined) {
|
||||
seen = new Set<DefId>();
|
||||
implsSeen.set(ifaceId, seen);
|
||||
}
|
||||
if (seen.has(ownerId)) continue;
|
||||
seen.add(ownerId);
|
||||
|
||||
let bucket = implsBuilding.get(ifaceId);
|
||||
if (bucket === undefined) {
|
||||
bucket = [];
|
||||
implsBuilding.set(ifaceId, bucket);
|
||||
}
|
||||
bucket.push(ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
const implsByInterfaceDefId = new Map<DefId, readonly DefId[]>();
|
||||
for (const [ifaceId, owners] of implsBuilding) {
|
||||
implsByInterfaceDefId.set(ifaceId, Object.freeze(owners.slice()));
|
||||
}
|
||||
|
||||
return freezeIndex(mroByOwnerDefId, implsByInterfaceDefId);
|
||||
}
|
||||
|
||||
// ─── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY: readonly DefId[] = Object.freeze([]);
|
||||
|
||||
function freezeIndex(
|
||||
mroByOwnerDefId: Map<DefId, readonly DefId[]>,
|
||||
implsByInterfaceDefId: Map<DefId, readonly DefId[]>,
|
||||
): MethodDispatchIndex {
|
||||
return {
|
||||
mroByOwnerDefId,
|
||||
implsByInterfaceDefId,
|
||||
mroFor(ownerDefId: DefId): readonly DefId[] {
|
||||
return mroByOwnerDefId.get(ownerDefId) ?? EMPTY;
|
||||
},
|
||||
implementorsOf(interfaceDefId: DefId): readonly DefId[] {
|
||||
return implsByInterfaceDefId.get(interfaceDefId) ?? EMPTY;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* `ModuleScopeIndex` — O(1) `filePath → moduleScopeId` lookup.
|
||||
*
|
||||
* Every file parsed produces exactly one `Module` scope at its root. The
|
||||
* finalize algorithm needs to resolve `ImportEdge.targetFile` to a concrete
|
||||
* module scope id in constant time during the link pass; this index is that
|
||||
* mapping.
|
||||
*
|
||||
* Part of RFC #909 Ring 2 SHARED — #913.
|
||||
*
|
||||
* Consumed by: #915 (SCC finalize link pass), #923 (shadow harness when
|
||||
* resolving callsite file → enclosing module).
|
||||
*/
|
||||
|
||||
import type { ScopeId } from './types.js';
|
||||
|
||||
export interface ModuleScopeIndex {
|
||||
readonly byFilePath: ReadonlyMap<string, ScopeId>;
|
||||
readonly size: number;
|
||||
get(filePath: string): ScopeId | undefined;
|
||||
has(filePath: string): boolean;
|
||||
}
|
||||
|
||||
export interface ModuleScopeEntry {
|
||||
readonly filePath: string;
|
||||
readonly moduleScopeId: ScopeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `ModuleScopeIndex` from a flat list of `{ filePath, moduleScopeId }`
|
||||
* pairs.
|
||||
*
|
||||
* **Collision policy: first-write-wins.** A file should appear exactly once
|
||||
* in a single ingestion run; collisions indicate the same file was parsed
|
||||
* twice or a `filePath` normalization bug upstream. Dropping the later
|
||||
* entry preserves the first-stable id the rest of the pipeline may already
|
||||
* have registered against.
|
||||
*
|
||||
* Pure function — safe to call repeatedly; no side effects.
|
||||
*/
|
||||
export function buildModuleScopeIndex(entries: readonly ModuleScopeEntry[]): ModuleScopeIndex {
|
||||
const byFilePath = new Map<string, ScopeId>();
|
||||
for (const { filePath, moduleScopeId } of entries) {
|
||||
if (byFilePath.has(filePath)) continue; // first-write-wins
|
||||
byFilePath.set(filePath, moduleScopeId);
|
||||
}
|
||||
return freezeIndex(byFilePath);
|
||||
}
|
||||
|
||||
// ─── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
function freezeIndex(byFilePath: Map<string, ScopeId>): ModuleScopeIndex {
|
||||
return {
|
||||
byFilePath,
|
||||
get size() {
|
||||
return byFilePath.size;
|
||||
},
|
||||
get(filePath: string): ScopeId | undefined {
|
||||
return byFilePath.get(filePath);
|
||||
},
|
||||
has(filePath: string): boolean {
|
||||
return byFilePath.has(filePath);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* `ORIGIN_PRIORITY` — RFC Appendix B (authoritative values).
|
||||
*
|
||||
* Tie-break ordering applied inside `Registry.lookup` Step 7 when
|
||||
* `|Δconfidence| < 0.001` between two `Resolution` candidates. Lower number
|
||||
* = stronger (wins the tie).
|
||||
*
|
||||
* Full tie-break order (§4.2 Step 7):
|
||||
* confidence DESC → scope depth ASC → MRO depth ASC → ORIGIN_PRIORITY ASC
|
||||
* → DefId.localeCompare
|
||||
*/
|
||||
|
||||
export type OriginForTieBreak =
|
||||
| 'local'
|
||||
| 'import'
|
||||
| 'reexport'
|
||||
| 'namespace'
|
||||
| 'wildcard'
|
||||
| 'global-qualified'
|
||||
| 'global-name';
|
||||
|
||||
export const ORIGIN_PRIORITY: Readonly<Record<OriginForTieBreak, number>> = {
|
||||
local: 0,
|
||||
import: 1,
|
||||
reexport: 2,
|
||||
namespace: 3,
|
||||
wildcard: 4,
|
||||
'global-qualified': 5,
|
||||
'global-name': 6,
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* `PositionIndex` — O(log N_file) scope-at-position lookup
|
||||
* (RFC §3.1; Ring 2 SHARED #912).
|
||||
*
|
||||
* Per-file sorted array of `(range, scopeId)` entries, sorted by start
|
||||
* position ASC (`startLine`, then `startCol`). `atPosition(filePath, line,
|
||||
* col)` binary-searches for the last entry whose start ≤ (line, col), then
|
||||
* scans backward through the sorted prefix and returns the first entry
|
||||
* whose range contains the query position.
|
||||
*
|
||||
* **Why this works.** `ScopeTree`'s invariants (parent strictly contains
|
||||
* child; siblings don't overlap) guarantee that the scopes containing a
|
||||
* given point form an **ancestor chain**. When scanning backward through
|
||||
* entries sorted by start position ASC, the first scope we find that
|
||||
* contains the query is the innermost one — any deeper-starting scope
|
||||
* that also contained the query would appear *later* in the sorted array,
|
||||
* but we're only scanning entries with start ≤ query, so anything later
|
||||
* necessarily starts after the query and can't contain it.
|
||||
*
|
||||
* Expected complexity: `O(log N_file + D)` where `D` is the lexical depth
|
||||
* at the query position (typically ≤ 10). Worst-case degrades to `O(N_file)`
|
||||
* only under pathological inputs (many scopes starting at the same line).
|
||||
*
|
||||
* **Line/column conventions.** Matches `Range` in `types.ts`: lines are
|
||||
* 1-based, columns are 0-based. Ranges are **inclusive on both ends** —
|
||||
* a scope whose `endLine:endCol` equals the query position still contains
|
||||
* it. That matches how tree-sitter captures bodies (closing brace
|
||||
* included) and how closed PR #902's `enclosingFunctions` behaved.
|
||||
*/
|
||||
|
||||
import type { Range, Scope, ScopeId } from './types.js';
|
||||
|
||||
export interface PositionIndex {
|
||||
/** Total scope entries indexed across all files. */
|
||||
readonly size: number;
|
||||
/**
|
||||
* Innermost scope containing `(line, col)` in `filePath`, or `undefined`
|
||||
* when nothing contains it (position before file start, after file end,
|
||||
* or filePath not indexed).
|
||||
*/
|
||||
atPosition(filePath: string, line: number, col: number): ScopeId | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `PositionIndex` from a flat list of `Scope` records.
|
||||
*
|
||||
* Duplicate `id`s are tolerated and deduplicated — the caller's
|
||||
* `ScopeTree.buildScopeTree` is the authoritative validator of scope
|
||||
* identity, and the position index does not need to re-check that
|
||||
* invariant.
|
||||
*/
|
||||
export function buildPositionIndex(scopes: readonly Scope[]): PositionIndex {
|
||||
const entriesByFile = new Map<string, Entry[]>();
|
||||
const seen = new Set<ScopeId>();
|
||||
|
||||
for (const scope of scopes) {
|
||||
if (seen.has(scope.id)) continue;
|
||||
seen.add(scope.id);
|
||||
|
||||
let bucket = entriesByFile.get(scope.filePath);
|
||||
if (bucket === undefined) {
|
||||
bucket = [];
|
||||
entriesByFile.set(scope.filePath, bucket);
|
||||
}
|
||||
bucket.push({ id: scope.id, range: scope.range });
|
||||
}
|
||||
|
||||
for (const bucket of entriesByFile.values()) {
|
||||
bucket.sort(compareEntry);
|
||||
}
|
||||
|
||||
return freezeIndex(entriesByFile, seen.size);
|
||||
}
|
||||
|
||||
// ─── Internals ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface Entry {
|
||||
readonly id: ScopeId;
|
||||
readonly range: Range;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by start position ASC, breaking ties by end position DESC so that
|
||||
* larger (outer) scopes appear before their smaller (inner) co-starting
|
||||
* siblings in the array. Makes the backward-scan contract crisp: the
|
||||
* first containing hit from the end of the scanned prefix is the
|
||||
* innermost scope.
|
||||
*/
|
||||
function compareEntry(a: Entry, b: Entry): number {
|
||||
if (a.range.startLine !== b.range.startLine) return a.range.startLine - b.range.startLine;
|
||||
if (a.range.startCol !== b.range.startCol) return a.range.startCol - b.range.startCol;
|
||||
if (a.range.endLine !== b.range.endLine) return b.range.endLine - a.range.endLine;
|
||||
return b.range.endCol - a.range.endCol;
|
||||
}
|
||||
|
||||
/** Whether `(line, col)` is at or after `range`'s start. */
|
||||
function startIsAtOrBefore(range: Range, line: number, col: number): boolean {
|
||||
if (range.startLine < line) return true;
|
||||
if (range.startLine > line) return false;
|
||||
return range.startCol <= col;
|
||||
}
|
||||
|
||||
/** Whether `(line, col)` is at or before `range`'s end (inclusive). */
|
||||
function endIsAtOrAfter(range: Range, line: number, col: number): boolean {
|
||||
if (range.endLine > line) return true;
|
||||
if (range.endLine < line) return false;
|
||||
return range.endCol >= col;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the largest index `i` in `arr` where `arr[i].range` starts at or
|
||||
* before `(line, col)`. Returns `-1` if no entry starts ≤ the query.
|
||||
*
|
||||
* Classic "upper bound - 1" binary search: find the first entry that
|
||||
* starts *after* the query, then step back one.
|
||||
*/
|
||||
function findLastStartLteIndex(arr: readonly Entry[], line: number, col: number): number {
|
||||
let lo = 0;
|
||||
let hi = arr.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (startIsAtOrBefore(arr[mid]!.range, line, col)) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
return lo - 1;
|
||||
}
|
||||
|
||||
function freezeIndex(entriesByFile: Map<string, Entry[]>, size: number): PositionIndex {
|
||||
return {
|
||||
get size() {
|
||||
return size;
|
||||
},
|
||||
atPosition(filePath: string, line: number, col: number): ScopeId | undefined {
|
||||
const bucket = entriesByFile.get(filePath);
|
||||
if (bucket === undefined || bucket.length === 0) return undefined;
|
||||
|
||||
const endIdx = findLastStartLteIndex(bucket, line, col);
|
||||
if (endIdx < 0) return undefined;
|
||||
|
||||
// Scan backward; first containing hit is innermost (see file header).
|
||||
for (let i = endIdx; i >= 0; i--) {
|
||||
const entry = bucket[i]!;
|
||||
if (endIsAtOrAfter(entry.range, line, col)) {
|
||||
// `startIsAtOrBefore` is guaranteed true by the binary search.
|
||||
return entry.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* `QualifiedNameIndex` — O(1) `qualifiedName → DefId[]` lookup across all kinds.
|
||||
*
|
||||
* Cross-kind fast path for qualified-name resolution
|
||||
* (`lookupQualified(qname, scope, params)` in RFC §4.5). Class, method,
|
||||
* field, and namespace defs all contribute to a single index here; consumers
|
||||
* filter the returned `DefId[]` by `p.acceptedKinds` at the call site.
|
||||
*
|
||||
* Returns `DefId[]` (not a single `DefId`) because multiple defs can legally
|
||||
* share a qualified name — partial classes in C#, method overloads, or
|
||||
* accidental cross-kind collisions. The lookup caller filters to the expected
|
||||
* kind(s) and ranks the survivors.
|
||||
*
|
||||
* Part of RFC #909 Ring 2 SHARED — #913.
|
||||
*
|
||||
* Consumed by: #917 (`Registry.lookup` qualified fast path, `resolveTypeRef`
|
||||
* dotted fallback via #916).
|
||||
*/
|
||||
|
||||
import type { SymbolDefinition } from './symbol-definition.js';
|
||||
import type { DefId } from './types.js';
|
||||
|
||||
export interface QualifiedNameIndex {
|
||||
readonly byQualifiedName: ReadonlyMap<string, readonly DefId[]>;
|
||||
readonly size: number;
|
||||
/** Returns all `DefId`s registered under this qualified name; empty frozen
|
||||
* array on miss so callers can iterate without null checks. */
|
||||
get(qualifiedName: string): readonly DefId[];
|
||||
has(qualifiedName: string): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `QualifiedNameIndex` from a flat list of `SymbolDefinition` records.
|
||||
*
|
||||
* Only defs with a non-empty `qualifiedName` contribute; defs without one are
|
||||
* silently skipped (not every kind carries a qualified name — anonymous or
|
||||
* top-level symbols, dynamic-unresolved imports, etc.).
|
||||
*
|
||||
* **Duplicate policy: appended in input order.** Each unique `(qname, DefId)`
|
||||
* pair contributes at most once — repeated entries for the same pair are
|
||||
* deduplicated. Distinct `DefId`s sharing a `qname` accumulate in insertion
|
||||
* order (stable output for deterministic lookup ranking at the call site).
|
||||
*
|
||||
* Pure function — safe to call repeatedly; no side effects.
|
||||
*/
|
||||
export function buildQualifiedNameIndex(defs: readonly SymbolDefinition[]): QualifiedNameIndex {
|
||||
const byQualifiedName = new Map<string, DefId[]>();
|
||||
const seenPairs = new Set<string>();
|
||||
|
||||
for (const def of defs) {
|
||||
const qname = def.qualifiedName;
|
||||
if (qname === undefined || qname.length === 0) continue;
|
||||
|
||||
const pairKey = `${qname}\0${def.nodeId}`;
|
||||
if (seenPairs.has(pairKey)) continue;
|
||||
seenPairs.add(pairKey);
|
||||
|
||||
const bucket = byQualifiedName.get(qname);
|
||||
if (bucket === undefined) {
|
||||
byQualifiedName.set(qname, [def.nodeId]);
|
||||
} else {
|
||||
bucket.push(def.nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Freeze bucket arrays so consumers can't mutate the index.
|
||||
const frozen = new Map<string, readonly DefId[]>();
|
||||
for (const [k, v] of byQualifiedName) {
|
||||
frozen.set(k, Object.freeze(v.slice()));
|
||||
}
|
||||
|
||||
return freezeIndex(frozen);
|
||||
}
|
||||
|
||||
// ─── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY: readonly DefId[] = Object.freeze([]);
|
||||
|
||||
function freezeIndex(byQualifiedName: Map<string, readonly DefId[]>): QualifiedNameIndex {
|
||||
return {
|
||||
byQualifiedName,
|
||||
get size() {
|
||||
return byQualifiedName.size;
|
||||
},
|
||||
get(qualifiedName: string): readonly DefId[] {
|
||||
return byQualifiedName.get(qualifiedName) ?? EMPTY;
|
||||
},
|
||||
has(qualifiedName: string): boolean {
|
||||
return byQualifiedName.has(qualifiedName);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* `ClassRegistry` — scope-aware lookup for class-like symbols
|
||||
* (RFC §4.4; Ring 2 SHARED #917).
|
||||
*
|
||||
* Thin wrapper over `lookupCore`, specialized for class kinds:
|
||||
*
|
||||
* - `acceptedKinds` = Class / Interface / Enum / Struct / Union /
|
||||
* Trait / TypeAlias / Typedef / Record / Delegate / Annotation /
|
||||
* Template / Namespace.
|
||||
* - `useReceiverTypeBinding` is **false** — classes are resolved by
|
||||
* name through the lexical chain + global qualified fallback, not
|
||||
* via a receiver type.
|
||||
* - Arity filter is not applicable (classes are not called with
|
||||
* argument counts at lookup time).
|
||||
*/
|
||||
|
||||
import type { Resolution, ScopeId } from '../types.js';
|
||||
import { lookupCore, type CoreLookupParams } from './lookup-core.js';
|
||||
import { CLASS_KINDS, type RegistryContext } from './context.js';
|
||||
|
||||
export interface ClassRegistry {
|
||||
/**
|
||||
* Look up a class-like symbol by simple or dotted name anchored at
|
||||
* `scope`. Returns a confidence-ranked `Resolution[]`; consume `[0]`
|
||||
* for the best answer.
|
||||
*/
|
||||
lookup(name: string, scope: ScopeId): readonly Resolution[];
|
||||
}
|
||||
|
||||
export function buildClassRegistry(ctx: RegistryContext): ClassRegistry {
|
||||
const params: CoreLookupParams = {
|
||||
acceptedKinds: CLASS_KINDS,
|
||||
useReceiverTypeBinding: false,
|
||||
ownerScopedContributor: null,
|
||||
};
|
||||
return {
|
||||
lookup(name: string, scope: ScopeId) {
|
||||
return lookupCore(name, scope, params, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* `RegistryContext` — the injected state required by the scope-aware
|
||||
* registry lookups (RFC §4; Ring 2 SHARED #917).
|
||||
*
|
||||
* Bundles every Ring 2 index + every provider hook the 7-step algorithm
|
||||
* might consult. Threaded through `lookupCore` and the three public
|
||||
* registries unchanged; construction is the caller's responsibility
|
||||
* (typically once per workspace-indexing pass in Ring 2 PKG).
|
||||
*
|
||||
* The design intent is **pure-logic in `gitnexus-shared`, data + hooks
|
||||
* supplied by the caller**. Nothing here loads files, parses AST, or
|
||||
* reaches into the CLI package.
|
||||
*/
|
||||
|
||||
import type { NodeLabel } from '../../graph/types.js';
|
||||
import type { SymbolDefinition } from '../symbol-definition.js';
|
||||
import type { Callsite, DefId } from '../types.js';
|
||||
import type { DefIndex } from '../def-index.js';
|
||||
import type { QualifiedNameIndex } from '../qualified-name-index.js';
|
||||
import type { ModuleScopeIndex } from '../module-scope-index.js';
|
||||
import type { ScopeTree } from '../scope-tree.js';
|
||||
import type { MethodDispatchIndex } from '../method-dispatch-index.js';
|
||||
|
||||
// ─── Provider hooks consumed by the registries ─────────────────────────────
|
||||
|
||||
export interface RegistryProviders {
|
||||
/**
|
||||
* Language-specific arity compatibility between a callsite and a candidate
|
||||
* `def`. Mirrors `LanguageProvider.arityCompatibility` from #911. Optional:
|
||||
* when absent, every candidate receives `'unknown'` (neutral signal).
|
||||
*/
|
||||
arityCompatibility?(callsite: Callsite, def: SymbolDefinition): ArityVerdict;
|
||||
}
|
||||
|
||||
export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible';
|
||||
|
||||
// ─── Owner-scoped contributor (concrete shape for `RegistryContributor`) ────
|
||||
|
||||
/**
|
||||
* Per-owner membership view plugged into `LookupParams.ownerScopedContributor`.
|
||||
*
|
||||
* When the caller knows a receiver is of type `Owner` (e.g., after
|
||||
* resolving an explicit receiver or via `self`), it can supply the
|
||||
* `Owner`'s own member bucket here. `lookupCore` treats hits from this
|
||||
* contributor as `origin: 'local'` inside the owner's body scope —
|
||||
* strongest-visibility evidence, unaffected by the scope-chain hop
|
||||
* deduction that punishes outer-scope hits.
|
||||
*
|
||||
* Ring 1's `RegistryContributor = unknown` opaque placeholder is narrowed
|
||||
* to this concrete shape here in Ring 2 SHARED (#917).
|
||||
*/
|
||||
export interface OwnerScopedContributor {
|
||||
/** The owner (class/struct/trait/interface) that bounds this view. */
|
||||
readonly ownerDefId: DefId;
|
||||
/**
|
||||
* Methods / fields directly declared on the owner, keyed by simple name.
|
||||
* Return empty array on miss; implementations should NOT walk the MRO —
|
||||
* that's `MethodDispatchIndex`'s job, handled in the type-binding step.
|
||||
*/
|
||||
byName(name: string): readonly SymbolDefinition[];
|
||||
}
|
||||
|
||||
// ─── Top-level context threaded through every lookup ───────────────────────
|
||||
|
||||
export interface RegistryContext {
|
||||
readonly scopes: ScopeTree;
|
||||
readonly defs: DefIndex;
|
||||
readonly qualifiedNames: QualifiedNameIndex;
|
||||
readonly moduleScopes: ModuleScopeIndex;
|
||||
/**
|
||||
* Method-dispatch index; required for method/field registries that
|
||||
* honor `useReceiverTypeBinding`. Omit for class-only lookups.
|
||||
*/
|
||||
readonly methodDispatch?: MethodDispatchIndex;
|
||||
readonly providers: RegistryProviders;
|
||||
}
|
||||
|
||||
// ─── Per-kind default `acceptedKinds` sets ─────────────────────────────────
|
||||
//
|
||||
// Exported so the three public registries stay declarative (each one just
|
||||
// points at the right constant + passes it to `lookupCore`).
|
||||
|
||||
export const CLASS_KINDS: readonly NodeLabel[] = Object.freeze([
|
||||
'Class',
|
||||
'Interface',
|
||||
'Enum',
|
||||
'Struct',
|
||||
'Union',
|
||||
'Trait',
|
||||
'TypeAlias',
|
||||
'Typedef',
|
||||
'Record',
|
||||
'Delegate',
|
||||
'Annotation',
|
||||
'Template',
|
||||
'Namespace',
|
||||
]);
|
||||
|
||||
export const METHOD_KINDS: readonly NodeLabel[] = Object.freeze([
|
||||
'Method',
|
||||
'Function',
|
||||
'Constructor',
|
||||
]);
|
||||
|
||||
export const FIELD_KINDS: readonly NodeLabel[] = Object.freeze([
|
||||
'Variable',
|
||||
'Property',
|
||||
'Const',
|
||||
'Static',
|
||||
]);
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* `composeEvidence` — translate accumulated raw signals per candidate
|
||||
* into a `ResolutionEvidence[]` using the authoritative `EvidenceWeights`
|
||||
* map (RFC §4.3 + Appendix A; Ring 2 SHARED #917).
|
||||
*
|
||||
* Each `RawSignals` record describes what was observed about a candidate
|
||||
* during the 7-step walk: where it was found, at what depth, whether
|
||||
* anything corroborates it. This module turns those raw facts into the
|
||||
* typed evidence list attached to the outgoing `Resolution`.
|
||||
*
|
||||
* **Every weight comes from `EvidenceWeights`.** No inline magic numbers.
|
||||
* Extends issue #429 (centralize hardcoded confidence values).
|
||||
*
|
||||
* **Confidence compose rule.** Signals add; the sum is capped at 1.0 at
|
||||
* the call site (inside `lookupCore`). This module only emits the list;
|
||||
* it does NOT compute the capped sum so callers can inspect per-signal
|
||||
* contributions for debugging.
|
||||
*/
|
||||
|
||||
import type { BindingRef, ResolutionEvidence } from '../types.js';
|
||||
import { EvidenceWeights, typeBindingWeightAtDepth } from '../evidence-weights.js';
|
||||
|
||||
/**
|
||||
* Raw signals observed for a single candidate during the 7-step walk.
|
||||
* Optional fields encode "this signal did not fire"; presence encodes
|
||||
* "emit an evidence record".
|
||||
*/
|
||||
export interface RawSignals {
|
||||
// ── Where-found ────────────────────────────────────────────────────────
|
||||
/** Visibility origin of the binding that produced this candidate. */
|
||||
readonly origin?: BindingRef['origin'] | 'global-qualified' | 'global-name';
|
||||
/** Depth at which the binding was found (hops up from start scope). */
|
||||
readonly scopeChainDepth?: number;
|
||||
/** `ImportEdge` that brought the name in; present when origin is a non-local. */
|
||||
readonly viaUnlinkedImport?: boolean;
|
||||
|
||||
// ── Type-binding path ──────────────────────────────────────────────────
|
||||
/** Set when the candidate came via the receiver's type-binding MRO walk. */
|
||||
readonly typeBindingMroDepth?: number;
|
||||
|
||||
// ── Corroborators ──────────────────────────────────────────────────────
|
||||
/** `def.ownerId === resolvedReceiver.def.nodeId`. */
|
||||
readonly ownerMatch?: boolean;
|
||||
/** Always fires for candidates that pass `acceptedKinds`; weight 0. */
|
||||
readonly kindMatch: true;
|
||||
|
||||
// ── Arity ──────────────────────────────────────────────────────────────
|
||||
readonly arityVerdict?: 'compatible' | 'unknown' | 'incompatible';
|
||||
|
||||
// ── Dynamic-unresolved passthrough ─────────────────────────────────────
|
||||
/** Candidate flows through a `kind: 'dynamic-unresolved'` ImportEdge. */
|
||||
readonly dynamicUnresolved?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the raw signals into a stable `ResolutionEvidence[]` list.
|
||||
*
|
||||
* Emission order mirrors the `EvidenceWeights` layout: where-found →
|
||||
* type-binding → corroborators → arity → degraded. Stable order makes
|
||||
* the per-signal contributions easy to reason about in tests and in the
|
||||
* shadow-mode parity dashboard.
|
||||
*/
|
||||
export function composeEvidence(signals: RawSignals): readonly ResolutionEvidence[] {
|
||||
const out: ResolutionEvidence[] = [];
|
||||
|
||||
// ── Where-found visibility ─────────────────────────────────────────────
|
||||
if (signals.origin !== undefined) {
|
||||
const baseWeight = getOriginWeight(signals.origin);
|
||||
const capped = signals.viaUnlinkedImport
|
||||
? baseWeight * EvidenceWeights.unlinkedImportMultiplier
|
||||
: baseWeight;
|
||||
const evidenceKind = whereFoundEvidenceKind(signals.origin);
|
||||
out.push({
|
||||
kind: evidenceKind,
|
||||
weight: capped,
|
||||
...(signals.viaUnlinkedImport
|
||||
? { note: `via unresolved import (${EvidenceWeights.unlinkedImportMultiplier}× cap)` }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Scope-chain depth deduction (per-hop, only meaningful for lexical
|
||||
// hits where scopeChainDepth ≥ 1). Depth 0 = no deduction; depth N ≥ 1
|
||||
// emits a single `scope-chain` evidence with the accumulated penalty.
|
||||
if (signals.scopeChainDepth !== undefined && signals.scopeChainDepth > 0) {
|
||||
out.push({
|
||||
kind: 'scope-chain',
|
||||
weight: EvidenceWeights.scopeChainPerDepth * signals.scopeChainDepth,
|
||||
note: `depth=${signals.scopeChainDepth}`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Type-binding / MRO path ────────────────────────────────────────────
|
||||
if (signals.typeBindingMroDepth !== undefined) {
|
||||
out.push({
|
||||
kind: 'type-binding',
|
||||
weight: typeBindingWeightAtDepth(signals.typeBindingMroDepth),
|
||||
note: `mroDepth=${signals.typeBindingMroDepth}`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Owner match (explanatory for debug) ────────────────────────────────
|
||||
if (signals.ownerMatch === true) {
|
||||
out.push({
|
||||
kind: 'owner-match',
|
||||
weight: EvidenceWeights.ownerMatch,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Kind match (always present; weight 0; retained for debuggability) ──
|
||||
out.push({
|
||||
kind: 'kind-match',
|
||||
weight: EvidenceWeights.kindMatch,
|
||||
});
|
||||
|
||||
// ── Arity ──────────────────────────────────────────────────────────────
|
||||
if (signals.arityVerdict !== undefined) {
|
||||
const weight =
|
||||
signals.arityVerdict === 'compatible'
|
||||
? EvidenceWeights.arityMatchCompatible
|
||||
: signals.arityVerdict === 'incompatible'
|
||||
? EvidenceWeights.arityMatchIncompatible
|
||||
: EvidenceWeights.arityMatchUnknown;
|
||||
out.push({
|
||||
kind: 'arity-match',
|
||||
weight,
|
||||
note: signals.arityVerdict,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Dynamic-unresolved (degraded signal) ───────────────────────────────
|
||||
if (signals.dynamicUnresolved === true) {
|
||||
out.push({
|
||||
kind: 'dynamic-import-unresolved',
|
||||
weight: EvidenceWeights.dynamicImportUnresolved,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum evidence weights and clamp to `[0, 1]`. Separate from `composeEvidence`
|
||||
* so tests and the parity dashboard can inspect the raw evidence list.
|
||||
*/
|
||||
export function confidenceFromEvidence(evidence: readonly ResolutionEvidence[]): number {
|
||||
let sum = 0;
|
||||
for (const e of evidence) sum += e.weight;
|
||||
if (sum < 0) return 0;
|
||||
if (sum > 1) return 1;
|
||||
return sum;
|
||||
}
|
||||
|
||||
// ─── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
function getOriginWeight(origin: NonNullable<RawSignals['origin']>): number {
|
||||
switch (origin) {
|
||||
case 'local':
|
||||
return EvidenceWeights.local;
|
||||
case 'import':
|
||||
return EvidenceWeights.import;
|
||||
case 'reexport':
|
||||
return EvidenceWeights.reexport;
|
||||
case 'namespace':
|
||||
return EvidenceWeights.namespace;
|
||||
case 'wildcard':
|
||||
return EvidenceWeights.wildcard;
|
||||
case 'global-qualified':
|
||||
return EvidenceWeights.globalQualified;
|
||||
case 'global-name':
|
||||
return EvidenceWeights.globalName;
|
||||
}
|
||||
}
|
||||
|
||||
function whereFoundEvidenceKind(
|
||||
origin: NonNullable<RawSignals['origin']>,
|
||||
): ResolutionEvidence['kind'] {
|
||||
switch (origin) {
|
||||
case 'local':
|
||||
return 'local';
|
||||
case 'import':
|
||||
case 'reexport':
|
||||
case 'namespace':
|
||||
case 'wildcard':
|
||||
return 'import';
|
||||
case 'global-qualified':
|
||||
return 'global-qualified';
|
||||
case 'global-name':
|
||||
return 'global-name';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* `FieldRegistry` — scope-aware lookup for field / property / variable
|
||||
* access (RFC §4.4; Ring 2 SHARED #917).
|
||||
*
|
||||
* Thin wrapper over `lookupCore`, specialized for data-member kinds:
|
||||
*
|
||||
* - `acceptedKinds` = Variable / Property / Const / Static.
|
||||
* - `useReceiverTypeBinding` is **true** — fields are resolved against
|
||||
* the receiver type's MRO first, then via the lexical chain for
|
||||
* free variables.
|
||||
* - `callsite` is not meaningful for field access (no arity), but the
|
||||
* `explicitReceiver` and `ownerScopedContributor` knobs are.
|
||||
*/
|
||||
|
||||
import type { Resolution, ScopeId } from '../types.js';
|
||||
import { lookupCore, type CoreLookupParams } from './lookup-core.js';
|
||||
import type { OwnerScopedContributor, RegistryContext } from './context.js';
|
||||
import { FIELD_KINDS } from './context.js';
|
||||
|
||||
export interface FieldLookupOptions {
|
||||
readonly explicitReceiver?: { readonly name: string };
|
||||
readonly ownerScopedContributor?: OwnerScopedContributor;
|
||||
}
|
||||
|
||||
export interface FieldRegistry {
|
||||
lookup(name: string, scope: ScopeId, options?: FieldLookupOptions): readonly Resolution[];
|
||||
}
|
||||
|
||||
export function buildFieldRegistry(ctx: RegistryContext): FieldRegistry {
|
||||
return {
|
||||
lookup(name: string, scope: ScopeId, options: FieldLookupOptions = {}) {
|
||||
const params: CoreLookupParams = {
|
||||
acceptedKinds: FIELD_KINDS,
|
||||
useReceiverTypeBinding: true,
|
||||
ownerScopedContributor: options.ownerScopedContributor ?? null,
|
||||
...(options.explicitReceiver !== undefined
|
||||
? { explicitReceiver: options.explicitReceiver }
|
||||
: {}),
|
||||
};
|
||||
return lookupCore(name, scope, params, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* `lookupCore` — the shared 7-step canonical resolution algorithm
|
||||
* (RFC §4.2; Ring 2 SHARED #917).
|
||||
*
|
||||
* Pure function. Given a name, a starting scope, and per-kind parameters,
|
||||
* walks lexical scopes + optional type-binding MRO + optional owner
|
||||
* contributor + global qualified-name fallback, and returns a ranked
|
||||
* `Resolution[]` with per-candidate evidence.
|
||||
*
|
||||
* All three public registries (`ClassRegistry` / `MethodRegistry` /
|
||||
* `FieldRegistry`) dispatch into this function, differing only in the
|
||||
* parameters they pass. The CHOICE of which steps fire is expressed
|
||||
* through `LookupParams`, not through different algorithms per kind.
|
||||
*
|
||||
* ## Algorithm (RFC §4.2, verbatim names)
|
||||
*
|
||||
* **Step 1 — Lexical scope-chain walk.** From `startScope`, walk
|
||||
* parent-ward. At each scope, consult `scope.bindings.get(name)`:
|
||||
* - Filter candidates whose `def.type ∈ acceptedKinds`.
|
||||
* - For each surviving candidate, record a raw signal with the
|
||||
* binding's origin + the current scope-chain depth.
|
||||
* - **Hard shadow.** If `bindings.get(name)` is non-empty (including
|
||||
* non-kind-matching candidates), stop walking. The name is
|
||||
* lexically bound here; outer scopes are not consulted.
|
||||
*
|
||||
* **Step 2 — Type-binding resolution.** When `useReceiverTypeBinding`
|
||||
* is true, resolve the receiver's type at `startScope` (from
|
||||
* `scope.typeBindings`), then walk the MRO via
|
||||
* `MethodDispatchIndex.mroFor(ownerDefId)`. Membership per owner comes
|
||||
* through `RegistryContext.methodDispatch` + owner lookups into
|
||||
* `scope.ownedDefs`; each hit records a raw signal with the owner's
|
||||
* MRO depth.
|
||||
*
|
||||
* **Step 3 — Owner-scoped contributor.** When
|
||||
* `params.ownerScopedContributor` is present, merge its `byName(name)`
|
||||
* hits with `origin: 'local'` (they are declared directly on the
|
||||
* receiver). Distinct from Step 2 — Step 2 walks the MRO; Step 3 only
|
||||
* looks at the directly-declared owner members.
|
||||
*
|
||||
* **Step 4 — Kind filter (emit `kind-match` evidence).** Already
|
||||
* applied during Steps 1-3; this step just adds a `kind-match` signal
|
||||
* at weight 0 to every candidate for debuggability (so the evidence
|
||||
* array is self-describing).
|
||||
*
|
||||
* **Step 5 — Arity filter.** Call `providers.arityCompatibility(callsite,
|
||||
* def)` per surviving candidate. Verdicts: `compatible` / `unknown` /
|
||||
* `incompatible`. If at least one candidate is `compatible`, drop
|
||||
* `incompatible` ones. Otherwise keep all (the penalty weight alone
|
||||
* will rank them lower but they remain in the result).
|
||||
*
|
||||
* **Step 6 — Global fallback.** When Steps 1-3 produced **no**
|
||||
* candidates and the name contains a `.`, consult the
|
||||
* `QualifiedNameIndex` via `lookupQualified` — see §4.5. The `scope`
|
||||
* argument is NOT passed here because global lookup is scope-agnostic.
|
||||
*
|
||||
* **Step 7 — Rank + tie-break.** Compose evidence, compute confidence
|
||||
* (sum capped at 1.0), sort by the RFC Appendix B cascade.
|
||||
*
|
||||
* ## What this module does NOT do
|
||||
*
|
||||
* - No AST reads (pure data in, pure data out).
|
||||
* - No `gitnexus/` imports.
|
||||
* - No language switches. Language-specific behavior flows exclusively
|
||||
* through `providers.*` and the `params` object.
|
||||
* - No caching. Callers that want memoization can wrap this function.
|
||||
*/
|
||||
|
||||
import type { NodeLabel } from '../../graph/types.js';
|
||||
import type { SymbolDefinition } from '../symbol-definition.js';
|
||||
import type {
|
||||
BindingRef,
|
||||
Callsite,
|
||||
DefId,
|
||||
LookupParams,
|
||||
Resolution,
|
||||
Scope,
|
||||
ScopeId,
|
||||
} from '../types.js';
|
||||
import type { OriginForTieBreak } from '../origin-priority.js';
|
||||
import { composeEvidence, confidenceFromEvidence, type RawSignals } from './evidence.js';
|
||||
import { compareByConfidenceWithTiebreaks, type TieBreakKey } from './tie-breaks.js';
|
||||
import { lookupQualified } from './lookup-qualified.js';
|
||||
import type { ArityVerdict, OwnerScopedContributor, RegistryContext } from './context.js';
|
||||
|
||||
// ─── Public entry point ─────────────────────────────────────────────────────
|
||||
|
||||
/** Extended `LookupParams` narrowing `ownerScopedContributor` to the concrete shape. */
|
||||
export interface CoreLookupParams extends Omit<LookupParams, 'ownerScopedContributor'> {
|
||||
readonly ownerScopedContributor: OwnerScopedContributor | null;
|
||||
/** Call-site description forwarded to `arityCompatibility`. Optional — for non-call lookups. */
|
||||
readonly callsite?: Callsite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the 7-step lookup. Returns a non-empty `Resolution[]` when any
|
||||
* candidate was found; an empty array otherwise. Callers consume `[0]`
|
||||
* for the best answer and optionally inspect the rest for alternates.
|
||||
*/
|
||||
export function lookupCore(
|
||||
name: string,
|
||||
startScope: ScopeId,
|
||||
params: CoreLookupParams,
|
||||
ctx: RegistryContext,
|
||||
): readonly Resolution[] {
|
||||
const acceptedKinds = new Set<NodeLabel>(params.acceptedKinds);
|
||||
const perCandidate = new Map<DefId, CandidateState>();
|
||||
|
||||
// ── Step 1: lexical scope-chain walk ──────────────────────────────────
|
||||
const lexicalShadowed = walkLexicalChain(name, startScope, acceptedKinds, ctx, perCandidate);
|
||||
|
||||
// ── Step 2: type-binding / MRO walk (methods/fields) ──────────────────
|
||||
if (params.useReceiverTypeBinding && ctx.methodDispatch !== undefined) {
|
||||
walkReceiverTypeBinding(name, startScope, acceptedKinds, params, ctx, perCandidate);
|
||||
}
|
||||
|
||||
// ── Step 3: owner-scoped contributor ──────────────────────────────────
|
||||
if (params.ownerScopedContributor !== null) {
|
||||
seedFromOwnerScopedContributor(
|
||||
name,
|
||||
params.ownerScopedContributor,
|
||||
acceptedKinds,
|
||||
perCandidate,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 4: kind-match evidence (emitted by composeEvidence directly) ──
|
||||
// Handled inside `composeEvidence`.
|
||||
|
||||
// ── Step 5: arity filter ──────────────────────────────────────────────
|
||||
if (params.callsite !== undefined) {
|
||||
applyArityFilter(params.callsite, perCandidate, ctx);
|
||||
}
|
||||
|
||||
// ── Step 6: global fallback (only when Steps 1-3 produced nothing) ──
|
||||
if (perCandidate.size === 0 && !lexicalShadowed && name.includes('.')) {
|
||||
const globals = lookupQualified(name, { acceptedKinds: params.acceptedKinds }, ctx);
|
||||
if (globals.length > 0) return globals;
|
||||
}
|
||||
|
||||
if (perCandidate.size === 0) return EMPTY;
|
||||
|
||||
// ── Step 7: compose evidence + rank ──────────────────────────────────
|
||||
return rankCandidates(perCandidate);
|
||||
}
|
||||
|
||||
// ─── Internal state ────────────────────────────────────────────────────────
|
||||
|
||||
interface CandidateState {
|
||||
readonly def: SymbolDefinition;
|
||||
readonly signals: MutableRawSignals;
|
||||
readonly tieBreakKey: MutableTieBreakKey;
|
||||
}
|
||||
|
||||
interface MutableRawSignals {
|
||||
origin?: BindingRef['origin'] | 'global-qualified' | 'global-name';
|
||||
scopeChainDepth?: number;
|
||||
viaUnlinkedImport?: boolean;
|
||||
typeBindingMroDepth?: number;
|
||||
ownerMatch?: boolean;
|
||||
kindMatch: true;
|
||||
arityVerdict?: ArityVerdict;
|
||||
dynamicUnresolved?: boolean;
|
||||
}
|
||||
|
||||
interface MutableTieBreakKey {
|
||||
scopeDepth: number;
|
||||
mroDepth: number;
|
||||
origin: OriginForTieBreak;
|
||||
}
|
||||
|
||||
function ensureCandidate(
|
||||
perCandidate: Map<DefId, CandidateState>,
|
||||
def: SymbolDefinition,
|
||||
): CandidateState {
|
||||
const existing = perCandidate.get(def.nodeId);
|
||||
if (existing !== undefined) return existing;
|
||||
const fresh: CandidateState = {
|
||||
def,
|
||||
signals: { kindMatch: true },
|
||||
tieBreakKey: { scopeDepth: 0, mroDepth: 0, origin: 'local' },
|
||||
};
|
||||
perCandidate.set(def.nodeId, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// ─── Step 1 implementation ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk the lexical scope chain from `startScope` upward. Returns `true`
|
||||
* iff a scope with any `bindings.get(name)` entries was found — the
|
||||
* caller uses this to decide whether to run the global fallback.
|
||||
*/
|
||||
function walkLexicalChain(
|
||||
name: string,
|
||||
startScope: ScopeId,
|
||||
acceptedKinds: ReadonlySet<NodeLabel>,
|
||||
ctx: RegistryContext,
|
||||
perCandidate: Map<DefId, CandidateState>,
|
||||
): boolean {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
let depth = 0;
|
||||
const visited = new Set<ScopeId>();
|
||||
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return false;
|
||||
visited.add(currentId);
|
||||
|
||||
const scope: Scope | undefined = ctx.scopes.getScope(currentId);
|
||||
if (scope === undefined) return false;
|
||||
|
||||
const bindings = scope.bindings.get(name);
|
||||
if (bindings !== undefined && bindings.length > 0) {
|
||||
for (const binding of bindings) {
|
||||
if (!acceptedKinds.has(binding.def.type)) continue;
|
||||
recordLexicalHit(perCandidate, binding, depth);
|
||||
}
|
||||
return true; // hard shadow regardless of kind-filter survivorship
|
||||
}
|
||||
|
||||
currentId = scope.parent;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function recordLexicalHit(
|
||||
perCandidate: Map<DefId, CandidateState>,
|
||||
binding: BindingRef,
|
||||
scopeChainDepth: number,
|
||||
): void {
|
||||
const state = ensureCandidate(perCandidate, binding.def);
|
||||
state.signals.origin = binding.origin;
|
||||
state.signals.scopeChainDepth = scopeChainDepth;
|
||||
if (binding.via?.linkStatus === 'unresolved') {
|
||||
state.signals.viaUnlinkedImport = true;
|
||||
}
|
||||
if (binding.via?.kind === 'dynamic-unresolved') {
|
||||
state.signals.dynamicUnresolved = true;
|
||||
}
|
||||
state.tieBreakKey.scopeDepth = scopeChainDepth;
|
||||
state.tieBreakKey.origin = binding.origin as OriginForTieBreak;
|
||||
}
|
||||
|
||||
// ─── Step 2 implementation ─────────────────────────────────────────────────
|
||||
|
||||
function walkReceiverTypeBinding(
|
||||
name: string,
|
||||
startScope: ScopeId,
|
||||
acceptedKinds: ReadonlySet<NodeLabel>,
|
||||
params: CoreLookupParams,
|
||||
ctx: RegistryContext,
|
||||
perCandidate: Map<DefId, CandidateState>,
|
||||
): void {
|
||||
const ownerDefId = resolveReceiverOwner(startScope, params, ctx);
|
||||
if (ownerDefId === undefined) return;
|
||||
|
||||
if (ctx.methodDispatch === undefined) return;
|
||||
|
||||
const ownerDef = ctx.defs.get(ownerDefId);
|
||||
if (ownerDef === undefined) return;
|
||||
|
||||
// Walk the owner itself at depth 0, then its MRO chain.
|
||||
const walk: DefId[] = [ownerDefId, ...ctx.methodDispatch.mroFor(ownerDefId)];
|
||||
|
||||
for (let mroDepth = 0; mroDepth < walk.length; mroDepth++) {
|
||||
const currentOwnerId = walk[mroDepth]!;
|
||||
const members = collectOwnedMembers(currentOwnerId, name, ctx);
|
||||
for (const def of members) {
|
||||
if (!acceptedKinds.has(def.type)) continue;
|
||||
recordTypeBindingHit(perCandidate, def, mroDepth, ownerDefId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReceiverOwner(
|
||||
startScope: ScopeId,
|
||||
params: CoreLookupParams,
|
||||
ctx: RegistryContext,
|
||||
): DefId | undefined {
|
||||
// Explicit receiver: consult the callsite scope's typeBindings for the
|
||||
// named receiver; the attached TypeRef identifies the owner. Without a
|
||||
// ready resolveTypeRef call (that module is separate), we do a direct
|
||||
// lookup and trust the caller to have populated the binding.
|
||||
if (params.explicitReceiver !== undefined) {
|
||||
return lookupReceiverType(startScope, params.explicitReceiver.name, ctx);
|
||||
}
|
||||
|
||||
// Implicit `self` / `this` — the scope's typeBindings should carry it.
|
||||
for (const implicitName of IMPLICIT_RECEIVERS) {
|
||||
const owner = lookupReceiverType(startScope, implicitName, ctx);
|
||||
if (owner !== undefined) return owner;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const IMPLICIT_RECEIVERS: readonly string[] = Object.freeze(['self', 'this']);
|
||||
|
||||
function lookupReceiverType(
|
||||
startScope: ScopeId,
|
||||
receiverName: string,
|
||||
ctx: RegistryContext,
|
||||
): DefId | undefined {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return undefined;
|
||||
visited.add(currentId);
|
||||
|
||||
const scope = ctx.scopes.getScope(currentId);
|
||||
if (scope === undefined) return undefined;
|
||||
|
||||
const typeRef = scope.typeBindings.get(receiverName);
|
||||
if (typeRef !== undefined) {
|
||||
// rawName must resolve to a def via qualifiedNames; if it doesn't, we
|
||||
// can't claim the receiver type. No fallback — that's what
|
||||
// `resolveTypeRef` would do, but we keep this path lean and let
|
||||
// callers pre-resolve if they want the richer semantics.
|
||||
const candidateIds = ctx.qualifiedNames.get(typeRef.rawName);
|
||||
if (candidateIds.length === 1) return candidateIds[0];
|
||||
// If ambiguous or missing, try a name-match among class-like defs —
|
||||
// but only when the rawName has no dots (simple name).
|
||||
return undefined;
|
||||
}
|
||||
currentId = scope.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectOwnedMembers(
|
||||
ownerDefId: DefId,
|
||||
memberName: string,
|
||||
ctx: RegistryContext,
|
||||
): readonly SymbolDefinition[] {
|
||||
// An owner's members are defs whose `ownerId === ownerDefId` and whose
|
||||
// simple name matches `memberName`. We iterate `defs.byId` — O(D) per
|
||||
// call today. A future by-owner index would make this O(K); tracked as
|
||||
// a follow-up optimization before Ring 3 flips go production.
|
||||
const out: SymbolDefinition[] = [];
|
||||
for (const def of ctx.defs.byId.values()) {
|
||||
if (def.ownerId !== ownerDefId) continue;
|
||||
if (simpleNameOf(def) !== memberName) continue;
|
||||
out.push(def);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function simpleNameOf(def: SymbolDefinition): string | undefined {
|
||||
if (def.qualifiedName === undefined || def.qualifiedName.length === 0) return undefined;
|
||||
const dot = def.qualifiedName.lastIndexOf('.');
|
||||
return dot === -1 ? def.qualifiedName : def.qualifiedName.slice(dot + 1);
|
||||
}
|
||||
|
||||
function recordTypeBindingHit(
|
||||
perCandidate: Map<DefId, CandidateState>,
|
||||
def: SymbolDefinition,
|
||||
mroDepth: number,
|
||||
receiverOwner: DefId,
|
||||
): void {
|
||||
const state = ensureCandidate(perCandidate, def);
|
||||
// Only replace if this hit is shallower (smaller MRO depth).
|
||||
if (
|
||||
state.signals.typeBindingMroDepth === undefined ||
|
||||
mroDepth < state.signals.typeBindingMroDepth
|
||||
) {
|
||||
state.signals.typeBindingMroDepth = mroDepth;
|
||||
state.tieBreakKey.mroDepth = mroDepth;
|
||||
}
|
||||
if (def.ownerId === receiverOwner) {
|
||||
state.signals.ownerMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Step 3 implementation ─────────────────────────────────────────────────
|
||||
|
||||
function seedFromOwnerScopedContributor(
|
||||
name: string,
|
||||
contributor: OwnerScopedContributor,
|
||||
acceptedKinds: ReadonlySet<NodeLabel>,
|
||||
perCandidate: Map<DefId, CandidateState>,
|
||||
): void {
|
||||
for (const def of contributor.byName(name)) {
|
||||
if (!acceptedKinds.has(def.type)) continue;
|
||||
const state = ensureCandidate(perCandidate, def);
|
||||
// Treat the contributor's direct membership as `origin: 'local'` —
|
||||
// strongest visibility, no scope-chain penalty.
|
||||
state.signals.origin = 'local';
|
||||
state.signals.scopeChainDepth = 0;
|
||||
state.signals.ownerMatch = def.ownerId === contributor.ownerDefId;
|
||||
state.tieBreakKey.origin = 'local';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Step 5 implementation ─────────────────────────────────────────────────
|
||||
|
||||
function applyArityFilter(
|
||||
callsite: Callsite,
|
||||
perCandidate: Map<DefId, CandidateState>,
|
||||
ctx: RegistryContext,
|
||||
): void {
|
||||
const arityFn = ctx.providers.arityCompatibility;
|
||||
if (arityFn === undefined) {
|
||||
// No provider → record 'unknown' for every candidate; keeps signal
|
||||
// shape uniform for composeEvidence.
|
||||
for (const state of perCandidate.values()) {
|
||||
state.signals.arityVerdict = 'unknown';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let anyCompatible = false;
|
||||
for (const state of perCandidate.values()) {
|
||||
const verdict = arityFn(callsite, state.def);
|
||||
state.signals.arityVerdict = verdict;
|
||||
if (verdict === 'compatible') anyCompatible = true;
|
||||
}
|
||||
|
||||
if (!anyCompatible) return;
|
||||
|
||||
// Filter: when at least one compatible candidate exists, drop incompatibles.
|
||||
for (const [defId, state] of perCandidate) {
|
||||
if (state.signals.arityVerdict === 'incompatible') {
|
||||
perCandidate.delete(defId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Step 7 implementation ─────────────────────────────────────────────────
|
||||
|
||||
function rankCandidates(perCandidate: Map<DefId, CandidateState>): readonly Resolution[] {
|
||||
const resolutions: Resolution[] = [];
|
||||
const tieKeys = new Map<string, TieBreakKey>();
|
||||
|
||||
for (const state of perCandidate.values()) {
|
||||
const evidence = composeEvidence(state.signals as RawSignals);
|
||||
const confidence = confidenceFromEvidence(evidence);
|
||||
resolutions.push({ def: state.def, confidence, evidence });
|
||||
tieKeys.set(state.def.nodeId, { ...state.tieBreakKey });
|
||||
}
|
||||
|
||||
resolutions.sort((a, b) => compareByConfidenceWithTiebreaks(a, b, tieKeys));
|
||||
return Object.freeze(resolutions);
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY: readonly Resolution[] = Object.freeze([]);
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* `lookupQualified` — qualified-name fast path (RFC §4.5; Ring 2 SHARED #917).
|
||||
*
|
||||
* Consults `QualifiedNameIndex` directly, filters by `acceptedKinds`, and
|
||||
* returns `Resolution[]` with `origin: 'global-qualified'` evidence. Used by:
|
||||
*
|
||||
* - `resolveTypeRef` dotted fallback (#916)
|
||||
* - `Registry.lookup` Step 6 when no lexical candidate survived
|
||||
* - Explicit dotted identifiers in Cypher / MCP tools where the caller
|
||||
* knows the target's canonical qualified name
|
||||
*
|
||||
* **Strict + deterministic.** No receiver-type resolution, no scope walk.
|
||||
* Every surviving candidate gets the same base confidence (from
|
||||
* `EvidenceWeights.globalQualified`), then the tie-break cascade
|
||||
* disambiguates.
|
||||
*/
|
||||
|
||||
import type { NodeLabel } from '../../graph/types.js';
|
||||
import type { Resolution } from '../types.js';
|
||||
import { composeEvidence, confidenceFromEvidence } from './evidence.js';
|
||||
import { compareByConfidenceWithTiebreaks, type TieBreakKey } from './tie-breaks.js';
|
||||
import type { RegistryContext } from './context.js';
|
||||
|
||||
export interface LookupQualifiedParams {
|
||||
readonly acceptedKinds: readonly NodeLabel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a canonical qualified name (e.g., `app.models.User`) across all
|
||||
* defs, filtered by `acceptedKinds`. Returns an empty array when the name
|
||||
* is not indexed or no candidate matches the kind filter.
|
||||
*
|
||||
* Callers consume `[0]` for the strict single-return answer; the remainder
|
||||
* carries alternate candidates (partial classes, overloads, accidental
|
||||
* cross-kind hits) ordered by the tie-break cascade.
|
||||
*/
|
||||
export function lookupQualified(
|
||||
qualifiedName: string,
|
||||
params: LookupQualifiedParams,
|
||||
ctx: RegistryContext,
|
||||
): readonly Resolution[] {
|
||||
const defIds = ctx.qualifiedNames.get(qualifiedName);
|
||||
if (defIds.length === 0) return EMPTY;
|
||||
|
||||
const acceptedKinds = new Set<NodeLabel>(params.acceptedKinds);
|
||||
|
||||
const resolutions: Resolution[] = [];
|
||||
const tieKeys = new Map<string, TieBreakKey>();
|
||||
|
||||
for (const defId of defIds) {
|
||||
const def = ctx.defs.get(defId);
|
||||
if (def === undefined) continue;
|
||||
if (!acceptedKinds.has(def.type)) continue;
|
||||
|
||||
const evidence = composeEvidence({ origin: 'global-qualified', kindMatch: true });
|
||||
const confidence = confidenceFromEvidence(evidence);
|
||||
resolutions.push({ def, confidence, evidence });
|
||||
tieKeys.set(def.nodeId, {
|
||||
scopeDepth: 0,
|
||||
mroDepth: 0,
|
||||
origin: 'global-qualified',
|
||||
});
|
||||
}
|
||||
|
||||
if (resolutions.length === 0) return EMPTY;
|
||||
|
||||
resolutions.sort((a, b) => compareByConfidenceWithTiebreaks(a, b, tieKeys));
|
||||
return Object.freeze(resolutions);
|
||||
}
|
||||
|
||||
const EMPTY: readonly Resolution[] = Object.freeze([]);
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* `MethodRegistry` — scope-aware lookup for method / function / constructor
|
||||
* dispatch (RFC §4.4; Ring 2 SHARED #917).
|
||||
*
|
||||
* Thin wrapper over `lookupCore`, specialized for callable kinds:
|
||||
*
|
||||
* - `acceptedKinds` = Method / Function / Constructor.
|
||||
* - `useReceiverTypeBinding` is **true** — the type-binding + MRO walk
|
||||
* (Step 2) is the primary evidence path for receiver-dispatched calls.
|
||||
* - `callsite.arity` flows through to `provider.arityCompatibility`
|
||||
* when provided. When the provider is absent, arity evidence is
|
||||
* `unknown` (neutral signal).
|
||||
*/
|
||||
|
||||
import type { Callsite, Resolution, ScopeId } from '../types.js';
|
||||
import { lookupCore, type CoreLookupParams } from './lookup-core.js';
|
||||
import type { OwnerScopedContributor, RegistryContext } from './context.js';
|
||||
import { METHOD_KINDS } from './context.js';
|
||||
|
||||
/**
|
||||
* Extra per-call parameters that vary across call sites but NOT across
|
||||
* registries. Kept as a separate shape so `MethodRegistry.lookup` stays
|
||||
* concise while still exposing the explicit-receiver + owner-contributor +
|
||||
* arity knobs the RFC algorithm needs.
|
||||
*/
|
||||
export interface MethodLookupOptions {
|
||||
/** Call-site arity for `provider.arityCompatibility`. */
|
||||
readonly callsite?: Callsite;
|
||||
/** Explicit receiver (e.g., `user` in `user.save()`). See §4.1. */
|
||||
readonly explicitReceiver?: { readonly name: string };
|
||||
/** Optional per-owner contributor (Step 3). */
|
||||
readonly ownerScopedContributor?: OwnerScopedContributor;
|
||||
}
|
||||
|
||||
export interface MethodRegistry {
|
||||
lookup(name: string, scope: ScopeId, options?: MethodLookupOptions): readonly Resolution[];
|
||||
}
|
||||
|
||||
export function buildMethodRegistry(ctx: RegistryContext): MethodRegistry {
|
||||
return {
|
||||
lookup(name: string, scope: ScopeId, options: MethodLookupOptions = {}) {
|
||||
const params: CoreLookupParams = {
|
||||
acceptedKinds: METHOD_KINDS,
|
||||
useReceiverTypeBinding: true,
|
||||
ownerScopedContributor: options.ownerScopedContributor ?? null,
|
||||
...(options.callsite !== undefined ? { callsite: options.callsite } : {}),
|
||||
...(options.explicitReceiver !== undefined
|
||||
? { explicitReceiver: options.explicitReceiver }
|
||||
: {}),
|
||||
};
|
||||
return lookupCore(name, scope, params, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* `compareByConfidenceWithTiebreaks` — the RFC §4.2 Step 7 total order
|
||||
* over `Resolution` candidates (Ring 2 SHARED #917).
|
||||
*
|
||||
* Primary key is confidence (DESC). Remaining ties within `CONFIDENCE_EPSILON`
|
||||
* fall through a deterministic cascade so the same inputs always produce
|
||||
* the same winner, independent of insertion order.
|
||||
*
|
||||
* Tie-break cascade (per RFC Appendix B):
|
||||
*
|
||||
* 1. confidence DESC (primary)
|
||||
* 2. scope depth ASC (nearer lexical scope wins)
|
||||
* 3. MRO depth ASC (nearer class in hierarchy wins)
|
||||
* 4. `ORIGIN_PRIORITY` ASC (local > import > … > global-name)
|
||||
* 5. DefId.localeCompare (final deterministic tiebreaker)
|
||||
*
|
||||
* The per-candidate inputs needed beyond `Resolution.confidence` —
|
||||
* `scopeDepth`, `mroDepth`, `origin` — are supplied via a sidecar
|
||||
* `TieBreakKey` so the comparator stays pure and `Resolution` itself
|
||||
* doesn't need to carry book-keeping fields.
|
||||
*/
|
||||
|
||||
import { ORIGIN_PRIORITY, type OriginForTieBreak } from '../origin-priority.js';
|
||||
import type { Resolution } from '../types.js';
|
||||
|
||||
export const CONFIDENCE_EPSILON = 0.001;
|
||||
|
||||
/** Side-information per candidate used for secondary tie-breaks. */
|
||||
export interface TieBreakKey {
|
||||
readonly scopeDepth: number;
|
||||
readonly mroDepth: number;
|
||||
readonly origin: OriginForTieBreak;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure comparator suitable for `Array.prototype.sort`. Return value follows
|
||||
* the JavaScript convention: negative → `a` wins, positive → `b` wins.
|
||||
*
|
||||
* **Important:** `keys` is keyed by `Resolution.def.nodeId`, not by array
|
||||
* index — stable across reorderings. Missing keys fall back to neutral
|
||||
* values (`scopeDepth: 0`, `mroDepth: 0`, `origin: 'local'`), which means
|
||||
* the tie-break degrades gracefully to defId-lexicographic ordering when
|
||||
* side-info is unavailable. That keeps the total order deterministic
|
||||
* even on malformed inputs.
|
||||
*/
|
||||
export function compareByConfidenceWithTiebreaks(
|
||||
a: Resolution,
|
||||
b: Resolution,
|
||||
keys: ReadonlyMap<string, TieBreakKey>,
|
||||
): number {
|
||||
// Primary: confidence DESC, treating values within epsilon as equal.
|
||||
const delta = b.confidence - a.confidence;
|
||||
if (Math.abs(delta) >= CONFIDENCE_EPSILON) return delta < 0 ? -1 : 1;
|
||||
|
||||
const ka = keys.get(a.def.nodeId) ?? DEFAULT_KEY;
|
||||
const kb = keys.get(b.def.nodeId) ?? DEFAULT_KEY;
|
||||
|
||||
// Secondary: scope depth ASC.
|
||||
if (ka.scopeDepth !== kb.scopeDepth) return ka.scopeDepth - kb.scopeDepth;
|
||||
|
||||
// Tertiary: MRO depth ASC.
|
||||
if (ka.mroDepth !== kb.mroDepth) return ka.mroDepth - kb.mroDepth;
|
||||
|
||||
// Quaternary: ORIGIN_PRIORITY ASC.
|
||||
const po = ORIGIN_PRIORITY[ka.origin] - ORIGIN_PRIORITY[kb.origin];
|
||||
if (po !== 0) return po;
|
||||
|
||||
// Final: DefId lexicographic, locale-aware for deterministic cross-platform output.
|
||||
return a.def.nodeId.localeCompare(b.def.nodeId);
|
||||
}
|
||||
|
||||
const DEFAULT_KEY: TieBreakKey = Object.freeze({
|
||||
scopeDepth: 0,
|
||||
mroDepth: 0,
|
||||
origin: 'local',
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* `resolveTypeRef` — strict single-return resolver for `TypeRef`s
|
||||
* (RFC §4.6; Ring 2 SHARED #916).
|
||||
*
|
||||
* Narrower contract than `Registry.lookup`: no name-only global fallback, no
|
||||
* confidence ranking, no arity check. Used by `Registry.lookup` Step 2 (type-
|
||||
* binding propagation) and by any caller that wants the single best type-
|
||||
* target for an annotation without paying for the full evidence pipeline.
|
||||
*
|
||||
* **Algorithm (strict).** Walk the scope chain from `ref.declaredAtScope`:
|
||||
*
|
||||
* 1. At each scope, inspect `bindings.get(ref.rawName)`:
|
||||
* - If one of the bindings is a **type-kind** def with a **strict origin**
|
||||
* (`'local' | 'import' | 'namespace' | 'reexport'`), return it.
|
||||
* - If any binding for this name exists at this scope but none qualifies
|
||||
* (e.g., a local variable named `User` shadows an outer import of class
|
||||
* `User`), return `null`. The nearer binding shadows; we do NOT fall
|
||||
* through to the global qualified-name index.
|
||||
* - Otherwise continue to the parent scope.
|
||||
* 2. If the raw name is a dotted path (e.g., `'models.User'`) and the scope
|
||||
* walk produced no match, consult `QualifiedNameIndex.byQualifiedName`.
|
||||
* Only accept **exactly one** type-kind hit — anything ambiguous returns
|
||||
* `null` rather than a guess.
|
||||
* 3. Return `null`.
|
||||
*
|
||||
* **What `'strict' origins' means.** `'wildcard'` is intentionally excluded.
|
||||
* A wildcard-expanded name (`from x import *`) is too loose to use as an
|
||||
* anchor for type resolution — it gives no signal about whether the name was
|
||||
* actually imported. `Registry.lookup` may accept wildcard bindings at its
|
||||
* own discretion (with lower evidence weight); `resolveTypeRef` does not.
|
||||
*
|
||||
* **What 'type-kind' means.** The subset of `NodeLabel` that a type annotation
|
||||
* may legitimately reference: class-like, interface-like, enum-like, and
|
||||
* alias-like kinds. See `TYPE_KINDS` below.
|
||||
*
|
||||
* Pure function — safe to call repeatedly; no side effects.
|
||||
*/
|
||||
|
||||
import type { NodeLabel } from '../graph/types.js';
|
||||
import type { SymbolDefinition } from './symbol-definition.js';
|
||||
import type { BindingRef, Scope, ScopeId, TypeRef } from './types.js';
|
||||
import type { DefIndex } from './def-index.js';
|
||||
import type { QualifiedNameIndex } from './qualified-name-index.js';
|
||||
|
||||
// ─── Public contracts ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Minimal scope-lookup contract required by `resolveTypeRef`. Implemented by
|
||||
* the `ScopeTree` from #912; declared here so #916 can ship as a standalone
|
||||
* piece without a hard dependency on the full scope-tree implementation. Any
|
||||
* structure that hands back a `Scope` by `ScopeId` satisfies this contract.
|
||||
*/
|
||||
export interface ScopeLookup {
|
||||
getScope(id: ScopeId): Scope | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* All inputs `resolveTypeRef` needs from the semantic model. Bundled into a
|
||||
* context object so the call site stays short and the interface is stable as
|
||||
* additional indexes get threaded through in later rings.
|
||||
*/
|
||||
export interface ResolveTypeRefContext {
|
||||
readonly scopes: ScopeLookup;
|
||||
readonly defIndex: DefIndex;
|
||||
readonly qualifiedNameIndex: QualifiedNameIndex;
|
||||
}
|
||||
|
||||
// ─── Strict policy constants ────────────────────────────────────────────────
|
||||
|
||||
/** `'wildcard'` is deliberately absent. See file header. */
|
||||
const STRICT_ORIGINS: ReadonlySet<BindingRef['origin']> = new Set<BindingRef['origin']>([
|
||||
'local',
|
||||
'import',
|
||||
'namespace',
|
||||
'reexport',
|
||||
]);
|
||||
|
||||
/**
|
||||
* `NodeLabel` values that may appear on the RHS of a type annotation.
|
||||
*
|
||||
* Includes the usual class-like and interface-like kinds plus the alias-like
|
||||
* ones (`TypeAlias`, `Typedef`). `Namespace` is excluded — it is a scope
|
||||
* container, not a value type. `Function` / `Method` / `Variable` are
|
||||
* excluded by design: a `rawName` bound to them at a strict origin is a
|
||||
* *shadowing* binding, which the algorithm short-circuits to `null`.
|
||||
*/
|
||||
const TYPE_KINDS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
|
||||
'Class',
|
||||
'Interface',
|
||||
'Enum',
|
||||
'Struct',
|
||||
'Union',
|
||||
'Trait',
|
||||
'TypeAlias',
|
||||
'Typedef',
|
||||
'Record',
|
||||
'Delegate',
|
||||
'Annotation',
|
||||
'Template',
|
||||
]);
|
||||
|
||||
// ─── Main entry point ──────────────────────────────────────────────────────
|
||||
|
||||
export function resolveTypeRef(ref: TypeRef, ctx: ResolveTypeRefContext): SymbolDefinition | null {
|
||||
// Phase 1: scope-chain walk anchored at the declaration site.
|
||||
let currentId: ScopeId | null = ref.declaredAtScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
|
||||
while (currentId !== null) {
|
||||
// Cycle guard — a well-formed scope tree never loops, but a bug in the
|
||||
// construction path should fail fast here rather than hanging.
|
||||
if (visited.has(currentId)) return null;
|
||||
visited.add(currentId);
|
||||
|
||||
const scope = ctx.scopes.getScope(currentId);
|
||||
if (scope === undefined) return null; // broken chain = unresolvable
|
||||
|
||||
const bindings = scope.bindings.get(ref.rawName);
|
||||
if (bindings !== undefined && bindings.length > 0) {
|
||||
// At least one binding exists at this scope → it is the shadowing site.
|
||||
// Either one of them qualifies, or the name is shadowed by a non-type.
|
||||
for (const binding of bindings) {
|
||||
if (!STRICT_ORIGINS.has(binding.origin)) continue;
|
||||
if (TYPE_KINDS.has(binding.def.type)) {
|
||||
return binding.def;
|
||||
}
|
||||
}
|
||||
// Shadowed by a non-type / non-strict-origin binding. Fail fast — no
|
||||
// global fallback, no walk to the parent.
|
||||
return null;
|
||||
}
|
||||
|
||||
currentId = scope.parent;
|
||||
}
|
||||
|
||||
// Phase 2: dotted fallback via `QualifiedNameIndex`. Only accept a unique
|
||||
// type-kind hit; anything ambiguous returns null (strict: no guesses).
|
||||
if (ref.rawName.includes('.')) {
|
||||
const candidates = ctx.qualifiedNameIndex.get(ref.rawName);
|
||||
let onlyTypeDef: SymbolDefinition | null = null;
|
||||
for (const defId of candidates) {
|
||||
const def = ctx.defIndex.get(defId);
|
||||
if (def === undefined) continue;
|
||||
if (!TYPE_KINDS.has(def.type)) continue;
|
||||
if (onlyTypeDef !== null) return null; // ambiguous
|
||||
onlyTypeDef = def;
|
||||
}
|
||||
if (onlyTypeDef !== null) return onlyTypeDef;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* `ScopeId` canonical constructor + string intern pool
|
||||
* (RFC §2.2; Ring 2 SHARED #912).
|
||||
*
|
||||
* `ScopeId` is a deterministic string derived from the scope's file path,
|
||||
* byte range, and kind:
|
||||
*
|
||||
* scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind}
|
||||
*
|
||||
* Two scopes produced by reparsing the same file at the same positions are
|
||||
* `===`-equal as strings. Beyond the canonical shape, `makeScopeId` also
|
||||
* **interns** the string through a process-local pool, so repeated calls
|
||||
* with structurally identical inputs return the same string reference —
|
||||
* making `Map<ScopeId, ...>` lookups and cache keys identity-fast.
|
||||
*
|
||||
* The intern pool is unbounded. The number of distinct `ScopeId`s across a
|
||||
* single indexing run is O(total scopes in workspace), which is bounded by
|
||||
* source-text size and already in memory; interning adds no asymptotic
|
||||
* pressure. `clearScopeIdInternPool` is exported for test isolation.
|
||||
*/
|
||||
|
||||
import type { Range } from './types.js';
|
||||
import type { ScopeId, ScopeKind } from './types.js';
|
||||
|
||||
/** Inputs required to construct a canonical `ScopeId`. */
|
||||
export interface ScopeIdInput {
|
||||
readonly filePath: string;
|
||||
readonly range: Range;
|
||||
readonly kind: ScopeKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a canonical `ScopeId` from its structural parts and intern it.
|
||||
*
|
||||
* Pure + referentially transparent: given the same input shape, always
|
||||
* returns the same string reference for the lifetime of the pool.
|
||||
*/
|
||||
export function makeScopeId(input: ScopeIdInput): ScopeId {
|
||||
const raw = `scope:${input.filePath}#${input.range.startLine}:${input.range.startCol}-${input.range.endLine}:${input.range.endCol}:${input.kind}`;
|
||||
const existing = INTERN_POOL.get(raw);
|
||||
if (existing !== undefined) return existing;
|
||||
INTERN_POOL.set(raw, raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the intern pool. Intended for test setup/teardown — production code
|
||||
* should not need this, since the pool's memory usage is bounded by the
|
||||
* number of live scopes and cleaning it mid-run would break identity
|
||||
* equality for existing scope ids.
|
||||
*/
|
||||
export function clearScopeIdInternPool(): void {
|
||||
INTERN_POOL.clear();
|
||||
}
|
||||
|
||||
/** Internal: shared intern pool (process-local). */
|
||||
const INTERN_POOL = new Map<string, string>();
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* `ScopeTree` — the lexical-scope spine of the `SemanticModel`
|
||||
* (RFC §2.2 + §3.1; Ring 2 SHARED #912).
|
||||
*
|
||||
* Generalizes the `enclosingFunctions` pattern from closed PR #902 to
|
||||
* arbitrary `ScopeKind`s. Owns the (parent ↔ children) relationship
|
||||
* derived from each `Scope.parent` pointer, and validates the structural
|
||||
* invariants a well-formed scope tree must satisfy.
|
||||
*
|
||||
* Invariants enforced at build time (throw on violation):
|
||||
*
|
||||
* - Every non-`Module` scope has a non-null parent.
|
||||
* - Every parent pointer references a scope that was also supplied to
|
||||
* `buildScopeTree`.
|
||||
* - Parent range **strictly contains** child range.
|
||||
* - Sibling ranges under the same parent do not overlap.
|
||||
* - Parent and child live in the same `filePath`. (Cross-file parent
|
||||
* pointers would be a category error — a `File` scope is not the
|
||||
* parent of another file's scopes; imports do that job.)
|
||||
*
|
||||
* Satisfies the `ScopeLookup` contract from #916 (`resolve-type-ref`), so
|
||||
* `resolveTypeRef` can take a `ScopeTree` directly without adapters.
|
||||
*
|
||||
* Immutable surface: `byId` is a `ReadonlyMap`; children arrays are
|
||||
* `Object.freeze`d; miss lookups return a shared frozen empty array.
|
||||
*/
|
||||
|
||||
import type { Scope, ScopeId, Range } from './types.js';
|
||||
import type { ScopeLookup } from './resolve-type-ref.js';
|
||||
|
||||
// ─── Public contract ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ScopeTree extends ScopeLookup {
|
||||
readonly size: number;
|
||||
readonly byId: ReadonlyMap<ScopeId, Scope>;
|
||||
|
||||
getScope(id: ScopeId): Scope | undefined;
|
||||
getParent(id: ScopeId): Scope | undefined;
|
||||
/** Child `ScopeId`s of `id`, in input order. Frozen empty array on miss. */
|
||||
getChildren(id: ScopeId): readonly ScopeId[];
|
||||
/**
|
||||
* Ancestor chain from the immediate parent up to (and including) the
|
||||
* root module scope. Excludes the starting scope itself. Frozen empty
|
||||
* array on miss / for a root scope.
|
||||
*/
|
||||
getAncestors(id: ScopeId): readonly ScopeId[];
|
||||
has(id: ScopeId): boolean;
|
||||
}
|
||||
|
||||
// ─── Build errors ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Thrown by `buildScopeTree` when the input violates a structural
|
||||
* invariant. Carries the offending ids + the invariant name so failed
|
||||
* extraction pipelines can report actionable diagnostics.
|
||||
*/
|
||||
export class ScopeTreeInvariantError extends Error {
|
||||
constructor(
|
||||
readonly invariant:
|
||||
| 'non-module-requires-parent'
|
||||
| 'parent-not-found'
|
||||
| 'parent-must-contain-child'
|
||||
| 'sibling-ranges-overlap'
|
||||
| 'parent-must-share-filepath'
|
||||
| 'duplicate-scope-id',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ScopeTreeInvariantError';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Builder ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build an immutable `ScopeTree` from a flat list of `Scope` records.
|
||||
*
|
||||
* Throws `ScopeTreeInvariantError` on the first invariant violation; a
|
||||
* malformed tree is a bug in the extraction pipeline, not a data case for
|
||||
* consumers to handle, so fail-fast is the correct posture.
|
||||
*/
|
||||
export function buildScopeTree(scopes: readonly Scope[]): ScopeTree {
|
||||
const byId = new Map<ScopeId, Scope>();
|
||||
const childrenById = new Map<ScopeId, ScopeId[]>();
|
||||
|
||||
// ── Pass 1: collect by id + duplicate check ───────────────────────────
|
||||
for (const scope of scopes) {
|
||||
if (byId.has(scope.id)) {
|
||||
throw new ScopeTreeInvariantError(
|
||||
'duplicate-scope-id',
|
||||
`Two scopes share id '${scope.id}'. Scope ids must be unique per tree.`,
|
||||
);
|
||||
}
|
||||
byId.set(scope.id, scope);
|
||||
}
|
||||
|
||||
// ── Pass 2: validate parent pointers + build children buckets ─────────
|
||||
for (const scope of scopes) {
|
||||
if (scope.parent === null) {
|
||||
if (scope.kind !== 'Module') {
|
||||
throw new ScopeTreeInvariantError(
|
||||
'non-module-requires-parent',
|
||||
`Scope '${scope.id}' has kind '${scope.kind}' but no parent. Only 'Module' scopes may be root-level.`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const parent = byId.get(scope.parent);
|
||||
if (parent === undefined) {
|
||||
throw new ScopeTreeInvariantError(
|
||||
'parent-not-found',
|
||||
`Scope '${scope.id}' references parent '${scope.parent}' which is not part of this tree.`,
|
||||
);
|
||||
}
|
||||
if (parent.filePath !== scope.filePath) {
|
||||
throw new ScopeTreeInvariantError(
|
||||
'parent-must-share-filepath',
|
||||
`Scope '${scope.id}' (${scope.filePath}) has parent '${parent.id}' in a different file (${parent.filePath}). Parent/child scopes must share filePath.`,
|
||||
);
|
||||
}
|
||||
if (!rangeStrictlyContains(parent.range, scope.range)) {
|
||||
throw new ScopeTreeInvariantError(
|
||||
'parent-must-contain-child',
|
||||
`Parent scope '${parent.id}' at ${formatRange(parent.range)} does not strictly contain child '${scope.id}' at ${formatRange(scope.range)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
let bucket = childrenById.get(parent.id);
|
||||
if (bucket === undefined) {
|
||||
bucket = [];
|
||||
childrenById.set(parent.id, bucket);
|
||||
}
|
||||
bucket.push(scope.id);
|
||||
}
|
||||
|
||||
// ── Pass 3: sibling-overlap check ─────────────────────────────────────
|
||||
for (const [parentId, childIds] of childrenById) {
|
||||
if (childIds.length < 2) continue;
|
||||
// Sort siblings by (startLine, startCol) for an O(n log n) pairwise
|
||||
// scan instead of O(n²) all-pairs.
|
||||
const children = childIds.map((id) => byId.get(id)!).slice();
|
||||
children.sort((a, b) => comparePosition(a.range, b.range));
|
||||
for (let i = 1; i < children.length; i++) {
|
||||
const prev = children[i - 1]!;
|
||||
const curr = children[i]!;
|
||||
if (rangesOverlap(prev.range, curr.range)) {
|
||||
throw new ScopeTreeInvariantError(
|
||||
'sibling-ranges-overlap',
|
||||
`Sibling scopes under parent '${parentId}' overlap: '${prev.id}' ${formatRange(prev.range)} and '${curr.id}' ${formatRange(curr.range)}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Freeze children arrays so the surface is truly read-only.
|
||||
const frozenChildren = new Map<ScopeId, readonly ScopeId[]>();
|
||||
for (const [parentId, childIds] of childrenById) {
|
||||
frozenChildren.set(parentId, Object.freeze(childIds.slice()));
|
||||
}
|
||||
|
||||
return freezeTree(byId, frozenChildren);
|
||||
}
|
||||
|
||||
// ─── Internals ──────────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY_CHILDREN: readonly ScopeId[] = Object.freeze([]);
|
||||
|
||||
function freezeTree(
|
||||
byId: Map<ScopeId, Scope>,
|
||||
childrenById: Map<ScopeId, readonly ScopeId[]>,
|
||||
): ScopeTree {
|
||||
return {
|
||||
byId,
|
||||
get size() {
|
||||
return byId.size;
|
||||
},
|
||||
getScope(id: ScopeId): Scope | undefined {
|
||||
return byId.get(id);
|
||||
},
|
||||
getParent(id: ScopeId): Scope | undefined {
|
||||
const scope = byId.get(id);
|
||||
if (scope === undefined || scope.parent === null) return undefined;
|
||||
return byId.get(scope.parent);
|
||||
},
|
||||
getChildren(id: ScopeId): readonly ScopeId[] {
|
||||
return childrenById.get(id) ?? EMPTY_CHILDREN;
|
||||
},
|
||||
getAncestors(id: ScopeId): readonly ScopeId[] {
|
||||
const start = byId.get(id);
|
||||
if (start === undefined || start.parent === null) return EMPTY_CHILDREN;
|
||||
const out: ScopeId[] = [];
|
||||
const visited = new Set<ScopeId>([id]);
|
||||
let cursor: ScopeId | null = start.parent;
|
||||
while (cursor !== null && !visited.has(cursor)) {
|
||||
visited.add(cursor);
|
||||
out.push(cursor);
|
||||
const next = byId.get(cursor);
|
||||
cursor = next === undefined ? null : next.parent;
|
||||
}
|
||||
return Object.freeze(out);
|
||||
},
|
||||
has(id: ScopeId): boolean {
|
||||
return byId.has(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `outer` strictly contains `inner` when `outer`'s start is at or before
|
||||
* `inner`'s start, `outer`'s end is at or after `inner`'s end, and they are
|
||||
* not the exact same range. Equal ranges are rejected — a child cannot
|
||||
* occupy the exact same span as its parent.
|
||||
*/
|
||||
function rangeStrictlyContains(outer: Range, inner: Range): boolean {
|
||||
if (
|
||||
outer.startLine === inner.startLine &&
|
||||
outer.startCol === inner.startCol &&
|
||||
outer.endLine === inner.endLine &&
|
||||
outer.endCol === inner.endCol
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const outerStartsAtOrBefore =
|
||||
outer.startLine < inner.startLine ||
|
||||
(outer.startLine === inner.startLine && outer.startCol <= inner.startCol);
|
||||
const outerEndsAtOrAfter =
|
||||
outer.endLine > inner.endLine ||
|
||||
(outer.endLine === inner.endLine && outer.endCol >= inner.endCol);
|
||||
return outerStartsAtOrBefore && outerEndsAtOrAfter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two ranges overlap when neither finishes before the other begins. Ranges
|
||||
* that merely touch at a single boundary point (`a.end === b.start`) do
|
||||
* NOT overlap — this matches tree-sitter's half-open-like range semantics
|
||||
* and the typical "sibling blocks meet but don't overlap" pattern.
|
||||
*/
|
||||
function rangesOverlap(a: Range, b: Range): boolean {
|
||||
const aEndsBeforeB =
|
||||
a.endLine < b.startLine || (a.endLine === b.startLine && a.endCol <= b.startCol);
|
||||
const bEndsBeforeA =
|
||||
b.endLine < a.startLine || (b.endLine === a.startLine && b.endCol <= a.startCol);
|
||||
return !(aEndsBeforeB || bEndsBeforeA);
|
||||
}
|
||||
|
||||
function comparePosition(a: Range, b: Range): number {
|
||||
if (a.startLine !== b.startLine) return a.startLine - b.startLine;
|
||||
return a.startCol - b.startCol;
|
||||
}
|
||||
|
||||
function formatRange(r: Range): string {
|
||||
return `${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Shadow-mode aggregation — per-language parity %, per-evidence-kind
|
||||
* breakdown of divergences. Consumed by the parity dashboard (RING2-PKG-5).
|
||||
*
|
||||
* Pure functions; no I/O. The harness persists per-run JSON; the dashboard
|
||||
* reads `.gitnexus/shadow-parity/latest.json` and renders.
|
||||
*
|
||||
* Part of RFC #909 Ring 2 SHARED — #918.
|
||||
*/
|
||||
|
||||
import type { SupportedLanguages } from '../../languages.js';
|
||||
import type { ResolutionEvidence } from '../types.js';
|
||||
import type { ShadowAgreement, ShadowDiff } from './diff.js';
|
||||
|
||||
// ─── Aggregated report shape ────────────────────────────────────────────────
|
||||
|
||||
export interface LanguageParityRow {
|
||||
readonly language: SupportedLanguages;
|
||||
readonly totalCalls: number;
|
||||
readonly bothAgree: number;
|
||||
readonly onlyLegacy: number;
|
||||
readonly onlyNew: number;
|
||||
readonly bothDisagree: number;
|
||||
readonly bothEmpty: number;
|
||||
/**
|
||||
* Fraction in [0, 1]. Numerator = `bothAgree`; denominator = "calls where
|
||||
* at least one side resolved" = `totalCalls - bothEmpty`.
|
||||
*
|
||||
* When the denominator is 0 (all calls for this language were
|
||||
* `both-empty`), returns 0. Callers rendering the dashboard should treat
|
||||
* a 0 parity alongside `totalCalls === bothEmpty` as "no signal" rather
|
||||
* than "total disagreement".
|
||||
*/
|
||||
readonly parity: number;
|
||||
/**
|
||||
* Divergence signals broken down by `ResolutionEvidence.kind`. Sourced
|
||||
* from `ShadowDiff.evidenceDelta` on non-agreeing rows only — `both-agree`
|
||||
* and `both-empty` do not contribute.
|
||||
*/
|
||||
readonly evidenceBreakdown: ReadonlyMap<ResolutionEvidence['kind'], number>;
|
||||
}
|
||||
|
||||
export interface ShadowParityReport {
|
||||
readonly generatedAt: string; // ISO 8601
|
||||
readonly perLanguage: readonly LanguageParityRow[];
|
||||
readonly overall: Omit<LanguageParityRow, 'language' | 'evidenceBreakdown'>;
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Aggregate a stream of `ShadowDiff` records into a `ShadowParityReport`,
|
||||
* bucketed by language. Pure function.
|
||||
*
|
||||
* - `perLanguage` rows are sorted alphabetically by `SupportedLanguages`
|
||||
* value for stable JSON output (the dashboard reads
|
||||
* `.gitnexus/shadow-parity/latest.json` and diffing snapshots is useful).
|
||||
* - `overall` is the column-wise sum across languages.
|
||||
* - `generatedAt` is injected via the `now` parameter so tests stay
|
||||
* deterministic; production callers let it default to `new Date()`.
|
||||
*/
|
||||
export function aggregateDiffs(
|
||||
diffs: readonly { readonly language: SupportedLanguages; readonly diff: ShadowDiff }[],
|
||||
now: Date = new Date(),
|
||||
): ShadowParityReport {
|
||||
const perLanguageMap = new Map<SupportedLanguages, MutableCounts>();
|
||||
|
||||
for (const { language, diff } of diffs) {
|
||||
let counts = perLanguageMap.get(language);
|
||||
if (!counts) {
|
||||
counts = makeEmptyCounts();
|
||||
perLanguageMap.set(language, counts);
|
||||
}
|
||||
tallyDiff(counts, diff);
|
||||
}
|
||||
|
||||
const perLanguage: LanguageParityRow[] = Array.from(perLanguageMap.entries())
|
||||
.map(([language, counts]) => buildRow(language, counts))
|
||||
.sort((a, b) => a.language.localeCompare(b.language));
|
||||
|
||||
const overall = buildOverallRow(perLanguage);
|
||||
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
perLanguage,
|
||||
overall,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
interface MutableCounts {
|
||||
totalCalls: number;
|
||||
bothAgree: number;
|
||||
onlyLegacy: number;
|
||||
onlyNew: number;
|
||||
bothDisagree: number;
|
||||
bothEmpty: number;
|
||||
evidenceBreakdown: Map<ResolutionEvidence['kind'], number>;
|
||||
}
|
||||
|
||||
function makeEmptyCounts(): MutableCounts {
|
||||
return {
|
||||
totalCalls: 0,
|
||||
bothAgree: 0,
|
||||
onlyLegacy: 0,
|
||||
onlyNew: 0,
|
||||
bothDisagree: 0,
|
||||
bothEmpty: 0,
|
||||
evidenceBreakdown: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function tallyDiff(counts: MutableCounts, diff: ShadowDiff): void {
|
||||
counts.totalCalls += 1;
|
||||
incrementAgreement(counts, diff.agreement);
|
||||
if (diff.agreement === 'both-agree' || diff.agreement === 'both-empty') return;
|
||||
for (const ev of diff.evidenceDelta) {
|
||||
counts.evidenceBreakdown.set(ev.kind, (counts.evidenceBreakdown.get(ev.kind) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function incrementAgreement(counts: MutableCounts, agreement: ShadowAgreement): void {
|
||||
switch (agreement) {
|
||||
case 'both-agree':
|
||||
counts.bothAgree += 1;
|
||||
return;
|
||||
case 'only-legacy':
|
||||
counts.onlyLegacy += 1;
|
||||
return;
|
||||
case 'only-new':
|
||||
counts.onlyNew += 1;
|
||||
return;
|
||||
case 'both-disagree':
|
||||
counts.bothDisagree += 1;
|
||||
return;
|
||||
case 'both-empty':
|
||||
counts.bothEmpty += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRow(language: SupportedLanguages, counts: MutableCounts): LanguageParityRow {
|
||||
const resolved = counts.totalCalls - counts.bothEmpty;
|
||||
const parity = resolved > 0 ? counts.bothAgree / resolved : 0;
|
||||
return {
|
||||
language,
|
||||
totalCalls: counts.totalCalls,
|
||||
bothAgree: counts.bothAgree,
|
||||
onlyLegacy: counts.onlyLegacy,
|
||||
onlyNew: counts.onlyNew,
|
||||
bothDisagree: counts.bothDisagree,
|
||||
bothEmpty: counts.bothEmpty,
|
||||
parity,
|
||||
// Freeze via `new Map` on a sorted-kind copy so downstream consumers
|
||||
// can't mutate the aggregator's internal state.
|
||||
evidenceBreakdown: new Map(
|
||||
Array.from(counts.evidenceBreakdown.entries()).sort(([a], [b]) => a.localeCompare(b)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildOverallRow(
|
||||
perLanguage: readonly LanguageParityRow[],
|
||||
): Omit<LanguageParityRow, 'language' | 'evidenceBreakdown'> {
|
||||
let totalCalls = 0;
|
||||
let bothAgree = 0;
|
||||
let onlyLegacy = 0;
|
||||
let onlyNew = 0;
|
||||
let bothDisagree = 0;
|
||||
let bothEmpty = 0;
|
||||
for (const row of perLanguage) {
|
||||
totalCalls += row.totalCalls;
|
||||
bothAgree += row.bothAgree;
|
||||
onlyLegacy += row.onlyLegacy;
|
||||
onlyNew += row.onlyNew;
|
||||
bothDisagree += row.bothDisagree;
|
||||
bothEmpty += row.bothEmpty;
|
||||
}
|
||||
const resolved = totalCalls - bothEmpty;
|
||||
const parity = resolved > 0 ? bothAgree / resolved : 0;
|
||||
return { totalCalls, bothAgree, onlyLegacy, onlyNew, bothDisagree, bothEmpty, parity };
|
||||
}
|
||||
|
||||
export type { ShadowAgreement, ShadowDiff };
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Shadow-mode diff logic — RFC §6.3.
|
||||
*
|
||||
* Pure comparison logic for shadow mode. Takes two `Resolution[]` (legacy
|
||||
* DAG result + new scope-based registry result) and produces a structured
|
||||
* diff record for the parity dashboard.
|
||||
*
|
||||
* Consumed by the Ring 2 PKG shadow harness (#923), which dual-runs each
|
||||
* call through legacy + new paths, diffs results, and persists per-run JSON
|
||||
* for the parity dashboard.
|
||||
*
|
||||
* Part of RFC #909 Ring 2 SHARED — #918.
|
||||
*/
|
||||
|
||||
import type { Resolution, ResolutionEvidence } from '../types.js';
|
||||
|
||||
// ─── Diff record shape ──────────────────────────────────────────────────────
|
||||
|
||||
export type ShadowAgreement =
|
||||
| 'both-agree' // top match identical (same DefId)
|
||||
| 'only-legacy' // legacy resolved; new did not
|
||||
| 'only-new' // new resolved; legacy did not
|
||||
| 'both-disagree' // both resolved, but to different targets
|
||||
| 'both-empty'; // both returned empty
|
||||
|
||||
export interface ShadowDiff {
|
||||
readonly callsite: ShadowCallsite;
|
||||
readonly legacy: Resolution | null;
|
||||
readonly newResult: Resolution | null;
|
||||
readonly agreement: ShadowAgreement;
|
||||
/**
|
||||
* Symmetric difference of the two top resolutions' `evidence` arrays,
|
||||
* keyed on `ResolutionEvidence.kind`.
|
||||
*
|
||||
* - For `'both-agree'` and `'both-empty'` agreements, always empty.
|
||||
* - For `'both-disagree'`, contains evidence kinds present on exactly one
|
||||
* side (not in both).
|
||||
* - For `'only-legacy'`, contains all of legacy's top evidence.
|
||||
* - For `'only-new'`, contains all of new's top evidence.
|
||||
*/
|
||||
readonly evidenceDelta: readonly ResolutionEvidence[];
|
||||
}
|
||||
|
||||
export interface ShadowCallsite {
|
||||
readonly filePath: string;
|
||||
readonly line: number;
|
||||
readonly col: number;
|
||||
readonly calledName: string;
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compare two `Resolution[]` arrays (top matches at `[0]`) and produce a
|
||||
* `ShadowDiff`. Pure function.
|
||||
*
|
||||
* Agreement rules:
|
||||
* - both arrays empty → `'both-empty'`, `evidenceDelta: []`
|
||||
* - legacy empty, new non-empty → `'only-new'`, `evidenceDelta` = new's top evidence
|
||||
* - legacy non-empty, new empty → `'only-legacy'`, `evidenceDelta` = legacy's top evidence
|
||||
* - both non-empty, same top `def.nodeId` → `'both-agree'`, `evidenceDelta: []`
|
||||
* - both non-empty, different top `def.nodeId` → `'both-disagree'`,
|
||||
* `evidenceDelta` = symmetric difference by `ResolutionEvidence.kind`
|
||||
* (first occurrence of a kind-only-on-legacy then kind-only-on-new; order
|
||||
* preserved from input arrays)
|
||||
*
|
||||
* Evidence-delta rationale: callers aggregating divergences want to know
|
||||
* which signal kinds explain a disagreement. Keying on `kind` (not full
|
||||
* equality over `weight`/`note`) avoids spurious deltas when the same
|
||||
* signal fires with slightly different calibration weights on each side.
|
||||
*/
|
||||
export function diffResolutions(
|
||||
callsite: ShadowCallsite,
|
||||
legacy: readonly Resolution[],
|
||||
newResult: readonly Resolution[],
|
||||
): ShadowDiff {
|
||||
const legacyTop: Resolution | null = legacy.length > 0 ? legacy[0] : null;
|
||||
const newTop: Resolution | null = newResult.length > 0 ? newResult[0] : null;
|
||||
|
||||
const agreement: ShadowAgreement = (() => {
|
||||
if (legacyTop === null && newTop === null) return 'both-empty';
|
||||
if (legacyTop === null) return 'only-new';
|
||||
if (newTop === null) return 'only-legacy';
|
||||
return legacyTop.def.nodeId === newTop.def.nodeId ? 'both-agree' : 'both-disagree';
|
||||
})();
|
||||
|
||||
const evidenceDelta = computeEvidenceDelta(legacyTop, newTop, agreement);
|
||||
|
||||
return {
|
||||
callsite,
|
||||
legacy: legacyTop,
|
||||
newResult: newTop,
|
||||
agreement,
|
||||
evidenceDelta,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Symmetric difference of two evidence arrays, keyed on
|
||||
* `ResolutionEvidence.kind`. Preserves input order: legacy-only signals
|
||||
* first (in legacy's original order), then new-only signals (in new's order).
|
||||
*
|
||||
* For `'both-agree'` / `'both-empty'` the delta is empty by contract. For
|
||||
* `'only-legacy'` / `'only-new'` one side's evidence is the delta (nothing to
|
||||
* subtract against).
|
||||
*/
|
||||
function computeEvidenceDelta(
|
||||
legacy: Resolution | null,
|
||||
newResult: Resolution | null,
|
||||
agreement: ShadowAgreement,
|
||||
): readonly ResolutionEvidence[] {
|
||||
if (agreement === 'both-agree' || agreement === 'both-empty') return [];
|
||||
if (agreement === 'only-legacy') return legacy!.evidence;
|
||||
if (agreement === 'only-new') return newResult!.evidence;
|
||||
|
||||
// both-disagree: symmetric difference keyed on `kind`
|
||||
const legacyKinds = new Set(legacy!.evidence.map((e) => e.kind));
|
||||
const newKinds = new Set(newResult!.evidence.map((e) => e.kind));
|
||||
|
||||
const onlyInLegacy = legacy!.evidence.filter((e) => !newKinds.has(e.kind));
|
||||
const onlyInNew = newResult!.evidence.filter((e) => !legacyKinds.has(e.kind));
|
||||
|
||||
return [...onlyInLegacy, ...onlyInNew];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* `SymbolDefinition` — the canonical shape of an indexed symbol record.
|
||||
*
|
||||
* Historically defined in `gitnexus/src/core/ingestion/model/symbol-table.ts`;
|
||||
* moved into `gitnexus-shared` as part of RFC #909 Ring 1 (#910) so the
|
||||
* scope-resolution types that reference it can live in the shared package
|
||||
* alongside their consumers (`gitnexus/` and `gitnexus-web/`).
|
||||
*
|
||||
* Shape is unchanged from the prior local definition.
|
||||
*/
|
||||
|
||||
import type { NodeLabel } from '../graph/types.js';
|
||||
|
||||
export interface SymbolDefinition {
|
||||
nodeId: string;
|
||||
filePath: string;
|
||||
type: NodeLabel;
|
||||
/** Canonical dot-separated qualified type name for class-like symbols
|
||||
* (e.g. `App.Models.User`). Falls back to the simple symbol name when no
|
||||
* package/namespace/module scope exists or no explicit qualified metadata is provided. */
|
||||
qualifiedName?: string;
|
||||
parameterCount?: number;
|
||||
/** Number of required (non-optional, non-default) parameters.
|
||||
* Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */
|
||||
requiredParameterCount?: number;
|
||||
/** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']).
|
||||
* Populated when parameter types are resolvable from AST (any typed language). */
|
||||
parameterTypes?: string[];
|
||||
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
|
||||
returnType?: string;
|
||||
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
|
||||
declaredType?: string;
|
||||
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
|
||||
ownerId?: string;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Scope-resolution type definitions — RFC §2 data model (authoritative source).
|
||||
*
|
||||
* See: https://www.notion.so/346dc50b6ed281cfaacbe480bf231d50
|
||||
*
|
||||
* Anti-drift rule: every type, interface, and enum defined here is the single
|
||||
* source of truth. Later code that references these names must import them
|
||||
* from `gitnexus-shared`; it must not re-define them locally.
|
||||
*
|
||||
* Lifecycle contract (RFC §2.8): scopes are **constructed during extraction,
|
||||
* linked during finalize, immutable after finalize**. All fields are
|
||||
* `readonly` at the type level; `Object.freeze` is applied at runtime in dev
|
||||
* builds. `ReferenceIndex` is the sole structure populated after freeze — by
|
||||
* resolution, before emission.
|
||||
*/
|
||||
|
||||
import type { NodeLabel } from '../graph/types.js';
|
||||
import type { SymbolDefinition } from './symbol-definition.js';
|
||||
|
||||
// ─── §2.1 Type aliases ──────────────────────────────────────────────────────
|
||||
|
||||
/** Stable per-(file, range, kind) scope identifier; interned for identity-fast equality. */
|
||||
export type ScopeId = string;
|
||||
|
||||
/** Stable symbol-definition identifier (graph nodeId). */
|
||||
export type DefId = string;
|
||||
|
||||
/** Kinds of lexical scope a `Scope` node can represent. */
|
||||
export type ScopeKind =
|
||||
| 'Module' // file root
|
||||
| 'Namespace' // C++ namespace, C# namespace, Kotlin package-object, Rust mod
|
||||
| 'Class' // class/struct/trait/interface body
|
||||
| 'Function' // function/method/closure/lambda body
|
||||
| 'Block' // { ... }, if-body, for-body, with-body, match arms
|
||||
| 'Expression'; // comprehensions, for-init, pattern bindings, lambda param lists
|
||||
|
||||
// ─── Range + Capture (parser-agnostic) ──────────────────────────────────────
|
||||
|
||||
/** Source-text range. 1-based `startLine`/`endLine`; 0-based `startCol`/`endCol`. */
|
||||
export interface Range {
|
||||
readonly startLine: number;
|
||||
readonly startCol: number;
|
||||
readonly endLine: number;
|
||||
readonly endCol: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tagged capture emitted by a LanguageProvider's `emitScopeCaptures` hook.
|
||||
*
|
||||
* Parser-agnostic: tree-sitter queries and COBOL's regex tagger both produce
|
||||
* `Capture[]`. The central `ScopeExtractor` consumes captures without
|
||||
* knowing which parser produced them.
|
||||
*/
|
||||
export interface Capture {
|
||||
/** Capture name, including leading `@` (e.g., `'@scope.module'`, `'@declaration.class'`). */
|
||||
readonly name: string;
|
||||
readonly range: Range;
|
||||
/** The captured source text. */
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A grouping of `Capture`s that came from a single query match (e.g., one
|
||||
* `@import.statement` match carries `@import.source`, `@import.name`,
|
||||
* `@import.alias?` as child captures). Keyed by capture name for O(1)
|
||||
* child access.
|
||||
*/
|
||||
export type CaptureMatch = Readonly<Record<string, Capture>>;
|
||||
|
||||
// ─── Hook input/output types (RFC §5.2) ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Provider-interpreted raw import, consumed by finalize (Phase 2) to produce
|
||||
* linked `ImportEdge[]`. The provider's `interpretImport` hook turns a
|
||||
* `CaptureMatch` for an `@import.statement` into one of these; the central
|
||||
* finalize algorithm resolves `targetRaw` to a concrete file via
|
||||
* `resolveImportTarget` and materializes the final `ImportEdge`.
|
||||
*
|
||||
* Discriminated union — each variant carries only the fields that make sense
|
||||
* for its kind. Invalid shapes (e.g., a `namespace` import with an alias-like
|
||||
* `importedName` mismatch) are compile errors, not latent bugs. `'wildcard-
|
||||
* expanded'` is deliberately NOT a variant: that kind is finalize output only,
|
||||
* produced when `expandsWildcardTo` materializes a wildcard against target
|
||||
* exports — a provider must never emit it at parse time.
|
||||
*/
|
||||
export type ParsedImport =
|
||||
/**
|
||||
* Per-name import without rename.
|
||||
*
|
||||
* Examples:
|
||||
* - Python `from foo import X` → `{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'foo' }`
|
||||
* - TS `import { X } from './foo'` → `{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: './foo' }`
|
||||
* - Java `import foo.bar.X` → `{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'foo.bar' }`
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'named';
|
||||
readonly localName: string;
|
||||
readonly importedName: string;
|
||||
readonly targetRaw: string;
|
||||
}
|
||||
/**
|
||||
* Per-name import with rename.
|
||||
*
|
||||
* Examples:
|
||||
* - Python `from foo import X as Y` → `{ kind: 'alias', localName: 'Y', importedName: 'X', alias: 'Y', targetRaw: 'foo' }`
|
||||
* - TS `import { X as Y } from './foo'` → `{ kind: 'alias', localName: 'Y', importedName: 'X', alias: 'Y', targetRaw: './foo' }`
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'alias';
|
||||
readonly localName: string;
|
||||
readonly importedName: string;
|
||||
readonly alias: string;
|
||||
readonly targetRaw: string;
|
||||
}
|
||||
/**
|
||||
* Qualified module handle, with or without rename. `importedName` is the
|
||||
* module being aliased; `localName` is the scope-visible handle (often the
|
||||
* same unless renamed).
|
||||
*
|
||||
* Examples:
|
||||
* - Python `import numpy` → `{ kind: 'namespace', localName: 'numpy', importedName: 'numpy', targetRaw: 'numpy' }`
|
||||
* - Python `import numpy as np` → `{ kind: 'namespace', localName: 'np', importedName: 'numpy', targetRaw: 'numpy' }`
|
||||
* - TS `import * as np from 'numpy'` → `{ kind: 'namespace', localName: 'np', importedName: 'numpy', targetRaw: 'numpy' }`
|
||||
* - Go `import foo "pkg/bar"` → `{ kind: 'namespace', localName: 'foo', importedName: 'bar', targetRaw: 'pkg/bar' }`
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'namespace';
|
||||
/** Scope-visible handle (e.g. `np` in `import numpy as np`; `numpy` when unaliased). */
|
||||
readonly localName: string;
|
||||
/** Module being aliased (e.g. `numpy` in `import numpy as np`). */
|
||||
readonly importedName: string;
|
||||
readonly targetRaw: string;
|
||||
}
|
||||
/**
|
||||
* Syntactically-detectable parse-time re-export. Finalize may still produce
|
||||
* `ImportEdge { kind: 'reexport', transitiveVia }` when flattening chains;
|
||||
* this variant preserves the *parse-time* signal so finalize doesn't have
|
||||
* to re-derive it from scratch.
|
||||
*
|
||||
* Examples:
|
||||
* - TS `export { X } from './y'` → `{ kind: 'reexport', localName: 'X', importedName: 'X', targetRaw: './y' }`
|
||||
* - TS `export { X as Y } from './y'` → `{ kind: 'reexport', localName: 'Y', importedName: 'X', alias: 'Y', targetRaw: './y' }`
|
||||
* - Rust `pub use foo::bar` → `{ kind: 'reexport', localName: 'bar', importedName: 'bar', targetRaw: 'foo' }`
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'reexport';
|
||||
/** Name as re-exported in the current module. */
|
||||
readonly localName: string;
|
||||
/** Name in the source module. */
|
||||
readonly importedName: string;
|
||||
readonly targetRaw: string;
|
||||
/** Set when the re-export renames the symbol (e.g. `export { X as Y } from './y'`). */
|
||||
readonly alias?: string;
|
||||
}
|
||||
/**
|
||||
* Wildcard import — brings every exported name from the target module into
|
||||
* the importing scope. The finalize algorithm expands this into one
|
||||
* `BindingRef` per exported name via the provider's `expandsWildcardTo`
|
||||
* hook, producing the finalize-only `ImportEdge` kind `'wildcard-expanded'`.
|
||||
*
|
||||
* Examples:
|
||||
* - Python `from foo import *` → `{ kind: 'wildcard', targetRaw: 'foo' }`
|
||||
* - JS `export * from './foo'` → `{ kind: 'wildcard', targetRaw: './foo' }`
|
||||
* - Rust `pub use foo::*` → `{ kind: 'wildcard', targetRaw: 'foo' }`
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'wildcard';
|
||||
readonly targetRaw: string;
|
||||
}
|
||||
/**
|
||||
* Runtime-computed target — the import path is not a static literal at
|
||||
* parse time. Providers SHOULD emit the unresolvable expression's source
|
||||
* text as `targetRaw` to aid diagnostics; `null` only when no string form
|
||||
* exists.
|
||||
*
|
||||
* Examples:
|
||||
* - JS `await import(expr)` → `{ kind: 'dynamic-unresolved', localName: '', targetRaw: 'expr' }`
|
||||
* - Python `importlib.import_module(f'pkg.{name}')` → `{ kind: 'dynamic-unresolved', localName: '', targetRaw: "f'pkg.{name}'" }`
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'dynamic-unresolved';
|
||||
readonly localName: string;
|
||||
/** Source text of the unresolved expression when available; `null` otherwise. */
|
||||
readonly targetRaw: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider-interpreted type binding. The provider's `interpretTypeBinding`
|
||||
* hook turns a `CaptureMatch` (e.g., `@type-binding.parameter`) into one of
|
||||
* these; the central extractor attaches the resulting `TypeRef` to the
|
||||
* appropriate scope's `typeBindings` map.
|
||||
*/
|
||||
export interface ParsedTypeBinding {
|
||||
/** The name being bound (parameter name, `self`, assignment LHS, …). */
|
||||
readonly boundName: string;
|
||||
/** The raw type name as written in source (`'User'`, `'models.User'`, …). */
|
||||
readonly rawTypeName: string;
|
||||
readonly source: TypeRef['source'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-file workspace index consumed by finalize-phase hooks
|
||||
* (`resolveImportTarget`, `expandsWildcardTo`). Opaque placeholder in Ring 1;
|
||||
* concretely typed in Ring 2 SHARED (#915).
|
||||
*/
|
||||
export type WorkspaceIndex = unknown;
|
||||
|
||||
// `ScopeTree` is exported from `./scope-tree.js` as of Ring 2 SHARED (#912).
|
||||
// The former opaque placeholder lived here during Ring 1; removed now that
|
||||
// the concrete type exists. Consumers import from `gitnexus-shared` directly.
|
||||
|
||||
/** Call-site description passed to `arityCompatibility`. */
|
||||
export interface Callsite {
|
||||
/** Number of arguments at the call site. */
|
||||
readonly arity: number;
|
||||
}
|
||||
|
||||
// ─── §2.4 ImportEdge ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A cross-file import edge attached to a module/namespace scope.
|
||||
*
|
||||
* Raw (unlinked) edges are emitted during parse (Phase 1); `targetModuleScope`
|
||||
* and `targetDefId` are filled in during finalize (Phase 2) via SCC-aware
|
||||
* bounded-fixpoint linking (RFC §3.2).
|
||||
*/
|
||||
export interface ImportEdge {
|
||||
/** How this scope sees the imported name (after alias). */
|
||||
readonly localName: string;
|
||||
/** Exporting file; `null` only when `kind === 'dynamic-unresolved'`. */
|
||||
readonly targetFile: string | null;
|
||||
/** The name under which the target exports this symbol. */
|
||||
readonly targetExportedName: string;
|
||||
/** Pre-resolved at finalize: the module scope of the exporting file. */
|
||||
readonly targetModuleScope?: ScopeId;
|
||||
/** Pre-resolved at finalize: the exported symbol's `DefId`. */
|
||||
readonly targetDefId?: DefId;
|
||||
readonly kind:
|
||||
| 'named'
|
||||
| 'alias'
|
||||
| 'namespace'
|
||||
| 'wildcard-expanded'
|
||||
| 'reexport'
|
||||
| 'dynamic-unresolved';
|
||||
/** Re-export chain, for provenance (e.g., `['./y']` when re-exported via `./y`). */
|
||||
readonly transitiveVia?: readonly string[];
|
||||
/** Set to `'unresolved'` when the SCC fixpoint could not link this edge. */
|
||||
readonly linkStatus?: 'unresolved';
|
||||
}
|
||||
|
||||
// ─── §2.3 BindingRef ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A name binding visible at a scope, with provenance.
|
||||
*
|
||||
* Provenance stays at the visibility layer — a name being visible because it
|
||||
* is local vs imported vs wildcard-expanded vs re-exported is a property of
|
||||
* the binding itself. This keeps evidence emission and `import-use` reference
|
||||
* stamping first-class instead of reconstructing provenance from a side table.
|
||||
*/
|
||||
export interface BindingRef {
|
||||
readonly def: SymbolDefinition;
|
||||
readonly origin: 'local' | 'import' | 'namespace' | 'wildcard' | 'reexport';
|
||||
/** Non-null for non-local origins; carries the `ImportEdge` that brought the name into this scope. */
|
||||
readonly via?: ImportEdge;
|
||||
}
|
||||
|
||||
// ─── §2.5 TypeRef ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A reference to a named type, anchored at its declaration site.
|
||||
*
|
||||
* Design choice: raw name + declaration-site scope, resolved at lookup time.
|
||||
* Pre-resolution would invert the extraction/resolution wall. Deferred thunks
|
||||
* add no capability. Structured type systems are months of work per language.
|
||||
* This shape keeps V1 tractable while preserving correctness for aliases,
|
||||
* re-exports, and nested modules. Generics deferred to V2 via `typeArgs`.
|
||||
*/
|
||||
export interface TypeRef {
|
||||
/** The name as written in source (e.g., `'User'`, `'models.User'`, `'List'`). */
|
||||
readonly rawName: string;
|
||||
/** Anchor for resolving `rawName` — the scope where the annotation/inference was written. */
|
||||
readonly declaredAtScope: ScopeId;
|
||||
readonly source:
|
||||
| 'annotation'
|
||||
| 'parameter-annotation'
|
||||
| 'return-annotation'
|
||||
| 'self'
|
||||
| 'assignment-inferred'
|
||||
| 'constructor-inferred'
|
||||
| 'receiver-propagated';
|
||||
/** Reserved for V2+: generic type arguments (`List<User>` → `[TypeRef('User')]`). V1 ignores. */
|
||||
readonly typeArgs?: readonly TypeRef[];
|
||||
}
|
||||
|
||||
// ─── §2.2 Scope ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The canonical lexical-scope node. Forms the spine of the SemanticModel.
|
||||
*
|
||||
* ScopeId shape (RFC §2.2): `scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind}`
|
||||
* — deterministic, stable across reparses of the same source, interned.
|
||||
*/
|
||||
export interface Scope {
|
||||
readonly id: ScopeId;
|
||||
readonly parent: ScopeId | null;
|
||||
readonly kind: ScopeKind;
|
||||
readonly range: Range;
|
||||
readonly filePath: string;
|
||||
|
||||
/** Names visible from this scope. Provenance preserved via `BindingRef.origin`. */
|
||||
readonly bindings: ReadonlyMap<string, readonly BindingRef[]>;
|
||||
|
||||
/** Defs structurally owned by this scope (e.g., methods owned by a class body scope). */
|
||||
readonly ownedDefs: readonly SymbolDefinition[];
|
||||
|
||||
/** Import edges attached to this scope. Mostly module/namespace scopes, but some
|
||||
* languages allow local imports (Python `def f(): from x import Y`, Rust
|
||||
* fn-local `use`, TS dynamic `import()`). */
|
||||
readonly imports: readonly ImportEdge[];
|
||||
|
||||
/** Local type facts visible from this scope (parameter annotations, `self` binding, etc.). */
|
||||
readonly typeBindings: ReadonlyMap<string, TypeRef>;
|
||||
}
|
||||
|
||||
// ─── §2.6 Resolution + ResolutionEvidence ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* One piece of evidence for a `Resolution`. Multiple signals corroborate a
|
||||
* single match; their weights compose additively to produce `confidence`.
|
||||
*
|
||||
* Weights come from `EvidenceWeights` (see `./evidence-weights.ts`).
|
||||
*/
|
||||
export interface ResolutionEvidence {
|
||||
readonly kind:
|
||||
| 'local'
|
||||
| 'scope-chain'
|
||||
| 'import'
|
||||
| 'type-binding'
|
||||
| 'owner-match'
|
||||
| 'kind-match'
|
||||
| 'arity-match'
|
||||
| 'global-name'
|
||||
| 'global-qualified'
|
||||
| 'dynamic-import-unresolved';
|
||||
/** Signal weight, sourced from `EvidenceWeights`. Additive; sum capped at 1.0. */
|
||||
readonly weight: number;
|
||||
/** Optional debug annotation (e.g., `'matched via self: User'`). */
|
||||
readonly note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ranked resolution candidate returned by `ClassRegistry.lookup` /
|
||||
* `MethodRegistry.lookup` / `FieldRegistry.lookup`. Evidence composes
|
||||
* additively; callers read `[0]` for the one-shot answer or inspect the
|
||||
* evidence trace for debugging.
|
||||
*/
|
||||
export interface Resolution {
|
||||
readonly def: SymbolDefinition;
|
||||
/** Σ of `evidence[].weight`, capped at 1.0. */
|
||||
readonly confidence: number;
|
||||
readonly evidence: readonly ResolutionEvidence[];
|
||||
/** Optional debug trace: scopes walked to reach `def`. */
|
||||
readonly path?: readonly ScopeId[];
|
||||
}
|
||||
|
||||
// ─── §2.7 Reference + ReferenceIndex ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A post-resolution usage fact: some code at `atRange` inside `fromScope`
|
||||
* references `toDef` with the given confidence/evidence. Materialized by the
|
||||
* resolution phase; emitted as graph edges (`CALLS`/`READS`/`WRITES`/etc.)
|
||||
* during the emit phase.
|
||||
*/
|
||||
export interface Reference {
|
||||
/** Innermost lexical scope containing `atRange`. */
|
||||
readonly fromScope: ScopeId;
|
||||
readonly toDef: DefId;
|
||||
/** Location of the reference in source. */
|
||||
readonly atRange: Range;
|
||||
readonly kind: 'call' | 'read' | 'write' | 'type-reference' | 'inherits' | 'import-use';
|
||||
readonly confidence: number;
|
||||
readonly evidence: readonly ResolutionEvidence[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-way index over `Reference` records, populated during the resolution
|
||||
* phase. Scopes stay immutable after finalize; references accumulate here.
|
||||
*/
|
||||
export interface ReferenceIndex {
|
||||
readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]>;
|
||||
readonly byTargetDef: ReadonlyMap<DefId, readonly Reference[]>;
|
||||
}
|
||||
|
||||
// ─── §4.1 LookupParams ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Opaque placeholder for the per-kind registry passed as the owner-scoped
|
||||
* contributor. Typed concretely in Ring 2 SHARED (#917); kept as `unknown`
|
||||
* here so Ring 1 can ship without pulling in the registry implementation.
|
||||
*/
|
||||
export type RegistryContributor = unknown;
|
||||
|
||||
/**
|
||||
* Parameters accepted by `Registry.lookup`. Three registries (Class/Method/
|
||||
* Field) run the same 7-step algorithm with different parameter tuples; see
|
||||
* RFC §4.4 for per-registry specializations.
|
||||
*/
|
||||
export interface LookupParams {
|
||||
readonly acceptedKinds: readonly NodeLabel[];
|
||||
/** Class lookups: false. Method/Field lookups: true. */
|
||||
readonly useReceiverTypeBinding: boolean;
|
||||
readonly ownerScopedContributor: RegistryContributor | null;
|
||||
/** Optional arity hint fed to `provider.arityCompatibility`. */
|
||||
readonly arityHint?: number;
|
||||
/** Explicit receiver name (e.g., `'user'` in `user.save()`). When present,
|
||||
* the receiver's type binding at the callsite scope is used; otherwise
|
||||
* the enclosing method's implicit `self`/`this` is consulted. See §4.1. */
|
||||
readonly explicitReceiver?: { readonly name: string };
|
||||
}
|
||||
@@ -61,13 +61,22 @@ test.beforeAll(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-connect downloads the full graph from the backend; under parallel
|
||||
// workers in CI the same backend serves multiple downloads concurrently, so
|
||||
// reaching the "Ready" state can take noticeably longer than a single-worker
|
||||
// run. Match the 45s budget used by waitForGraphLoaded() in
|
||||
// server-connect.spec.ts which has been stable on the same backend.
|
||||
const READY_TIMEOUT_MS = 45_000;
|
||||
|
||||
test.describe('Multi-Repo Scoping', () => {
|
||||
test('auto-connect via ?server= sets ?project= in URL', async ({ page }) => {
|
||||
// Navigate with ?server= param (the bookmarkable shortcut)
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
|
||||
// Wait for graph to load
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
|
||||
timeout: READY_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// URL should now contain ?project= with the repo name
|
||||
const url = new URL(page.url());
|
||||
@@ -77,8 +86,14 @@ test.describe('Multi-Repo Scoping', () => {
|
||||
});
|
||||
|
||||
test('?server= is preserved in URL for F5 recovery', async ({ page }) => {
|
||||
// Two sequential auto-connects (initial + reload), each up to READY_TIMEOUT_MS,
|
||||
// can exceed the default 60s test timeout under parallel workers.
|
||||
test.slow();
|
||||
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
|
||||
timeout: READY_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// URL should still have ?server=
|
||||
const url = new URL(page.url());
|
||||
@@ -86,12 +101,16 @@ test.describe('Multi-Repo Scoping', () => {
|
||||
|
||||
// F5 should reconnect (not show onboarding)
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
|
||||
timeout: READY_TIMEOUT_MS,
|
||||
});
|
||||
});
|
||||
|
||||
test('node count in status bar matches backend data', async ({ page }) => {
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
|
||||
timeout: READY_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Fetch expected node count from backend
|
||||
const res = await fetch(`${BACKEND_URL}/api/repo?repo=${encodeURIComponent(firstRepoName)}`);
|
||||
|
||||
@@ -26,7 +26,10 @@ async function enterExploringView(page: import('@playwright/test').Page) {
|
||||
// Landing screen may not appear (e.g. ?server auto-connect)
|
||||
}
|
||||
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
// Match the 45s budget used by waitForGraphLoaded() in
|
||||
// server-connect.spec.ts; under parallel CI workers, downloading the full
|
||||
// graph can occasionally exceed 30s.
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 45_000 });
|
||||
}
|
||||
|
||||
// ── Flow 1: Onboarding (no server running) ─────────────────────────────────
|
||||
@@ -244,6 +247,10 @@ test.describe('Flow 3: Analyze form', () => {
|
||||
test.describe('Flow 4: Repo dropdown in exploring view', () => {
|
||||
const SKIP_MSG = 'Requires running gitnexus server with indexed repos';
|
||||
|
||||
// enterExploringView() can take up to ~45s under parallel CI workers; combined
|
||||
// with the dropdown interactions this can exceed the default 60s test budget.
|
||||
test.slow();
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (process.env.E2E) return;
|
||||
try {
|
||||
|
||||
@@ -84,11 +84,20 @@ test.describe('Hold-queue timeout error', () => {
|
||||
|
||||
// ── 2. ?project= URL persistence ─────────────────────────────────────────────
|
||||
|
||||
// Auto-connect downloads the full graph from the backend; under parallel
|
||||
// workers in CI the same backend serves multiple downloads concurrently, so
|
||||
// reaching the "Ready" state can take noticeably longer than a single-worker
|
||||
// run. Match the 45s budget used by waitForGraphLoaded() in
|
||||
// server-connect.spec.ts which has been stable on the same backend.
|
||||
const READY_TIMEOUT_MS = 45_000;
|
||||
|
||||
test.describe('?project= URL persistence', () => {
|
||||
test('?project= is set in URL after connecting via ?server=', async ({ page }) => {
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
|
||||
timeout: READY_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const url = new URL(page.url());
|
||||
const project = url.searchParams.get('project');
|
||||
@@ -98,12 +107,20 @@ test.describe('?project= URL persistence', () => {
|
||||
});
|
||||
|
||||
test('?project= is still present after F5 reload', async ({ page }) => {
|
||||
// Two sequential auto-connects (initial + reload), each up to READY_TIMEOUT_MS,
|
||||
// can exceed the default 60s test timeout under parallel workers.
|
||||
test.slow();
|
||||
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
|
||||
timeout: READY_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// After connect, URL has ?server=&project= — F5 re-uses both params
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
|
||||
timeout: READY_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get('project')).toBeTruthy();
|
||||
@@ -122,7 +139,9 @@ test.describe('?project= auto-connect', () => {
|
||||
`/?server=${encodeURIComponent(BACKEND_URL)}&project=${encodeURIComponent(firstRepoName)}`,
|
||||
);
|
||||
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
|
||||
timeout: READY_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// ?project= in URL should match what we passed in
|
||||
const url = new URL(page.url());
|
||||
@@ -155,7 +174,9 @@ test.describe('Windows path normalization', () => {
|
||||
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
|
||||
timeout: READY_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// URL ?project= must be the short basename, NOT the full Windows path
|
||||
const url = new URL(page.url());
|
||||
|
||||
Generated
+162
-393
@@ -29,7 +29,7 @@
|
||||
"langchain": "^1.2.10",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"mermaid": "^11.14.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -39,7 +39,7 @@
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
@@ -57,12 +57,12 @@
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"jsdom": "^29.0.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.0",
|
||||
"vitest": "^3.2.4",
|
||||
"wait-on": "^8.0.5"
|
||||
"wait-on": "^9.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -129,39 +129,49 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
|
||||
"integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^3.1.1",
|
||||
"@csstools/css-color-parser": "^4.0.2",
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0",
|
||||
"lru-cache": "^11.2.6"
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz",
|
||||
"integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==",
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.10.tgz",
|
||||
"integrity": "sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.2.7"
|
||||
"is-potential-custom-element-name": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
@@ -533,54 +543,40 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@chevrotain/cst-dts-gen": {
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz",
|
||||
"integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
|
||||
"integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/gast": "11.0.3",
|
||||
"@chevrotain/types": "11.0.3",
|
||||
"lodash-es": "4.17.21"
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/cst-dts-gen/node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@chevrotain/gast": {
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz",
|
||||
"integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz",
|
||||
"integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/types": "11.0.3",
|
||||
"lodash-es": "4.17.21"
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/gast/node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@chevrotain/regexp-to-ast": {
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz",
|
||||
"integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz",
|
||||
"integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/types": {
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz",
|
||||
"integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz",
|
||||
"integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/utils": {
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz",
|
||||
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz",
|
||||
"integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
@@ -628,9 +624,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
|
||||
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
|
||||
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -652,9 +648,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
|
||||
"integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
|
||||
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -669,7 +665,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.0.2",
|
||||
"@csstools/css-calc": "^3.1.1"
|
||||
"@csstools/css-calc": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
@@ -1764,12 +1760,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mermaid-js/parser": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz",
|
||||
"integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
|
||||
"integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"langium": "3.3.1"
|
||||
"langium": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
@@ -2224,257 +2220,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/core": {
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.8.tgz",
|
||||
"integrity": "sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3",
|
||||
"@swc/types": "^0.1.25"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/swc"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@swc/core-darwin-arm64": "1.15.8",
|
||||
"@swc/core-darwin-x64": "1.15.8",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.15.8",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.8",
|
||||
"@swc/core-linux-arm64-musl": "1.15.8",
|
||||
"@swc/core-linux-x64-gnu": "1.15.8",
|
||||
"@swc/core-linux-x64-musl": "1.15.8",
|
||||
"@swc/core-win32-arm64-msvc": "1.15.8",
|
||||
"@swc/core-win32-ia32-msvc": "1.15.8",
|
||||
"@swc/core-win32-x64-msvc": "1.15.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/helpers": ">=0.5.17"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/helpers": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-arm64": {
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.8.tgz",
|
||||
"integrity": "sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-x64": {
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.8.tgz",
|
||||
"integrity": "sha512-j47DasuOvXl80sKJHSi2X25l44CMc3VDhlJwA7oewC1nV1VsSzwX+KOwE5tLnfORvVJJyeiXgJORNYg4jeIjYQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.8.tgz",
|
||||
"integrity": "sha512-siAzDENu2rUbwr9+fayWa26r5A9fol1iORG53HWxQL1J8ym4k7xt9eME0dMPXlYZDytK5r9sW8zEA10F2U3Xwg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.8.tgz",
|
||||
"integrity": "sha512-o+1y5u6k2FfPYbTRUPvurwzNt5qd0NTumCTFscCNuBksycloXY16J8L+SMW5QRX59n4Hp9EmFa3vpvNHRVv1+Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-musl": {
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.8.tgz",
|
||||
"integrity": "sha512-koiCqL09EwOP1S2RShCI7NbsQuG6r2brTqUYE7pV7kZm9O17wZ0LSz22m6gVibpwEnw8jI3IE1yYsQTVpluALw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-gnu": {
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.8.tgz",
|
||||
"integrity": "sha512-4p6lOMU3bC+Vd5ARtKJ/FxpIC5G8v3XLoPEZ5s7mLR8h7411HWC/LmTXDHcrSXRC55zvAVia1eldy6zDLz8iFQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-musl": {
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.8.tgz",
|
||||
"integrity": "sha512-z3XBnbrZAL+6xDGAhJoN4lOueIxC/8rGrJ9tg+fEaeqLEuAtHSW2QHDHxDwkxZMjuF/pZ6MUTjHjbp8wLbuRLA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.8.tgz",
|
||||
"integrity": "sha512-djQPJ9Rh9vP8GTS/Df3hcc6XP6xnG5c8qsngWId/BLA9oX6C7UzCPAn74BG/wGb9a6j4w3RINuoaieJB3t+7iQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.8.tgz",
|
||||
"integrity": "sha512-/wfAgxORg2VBaUoFdytcVBVCgf1isWZIEXB9MZEUty4wwK93M/PxAkjifOho9RN3WrM3inPLabICRCEgdHpKKQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-x64-msvc": {
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.8.tgz",
|
||||
"integrity": "sha512-GpMePrh9Sl4d61o4KAHOOv5is5+zt6BEXCOCgs/H0FLGeii7j9bWDE8ExvKFy2GRRZVNR1ugsnzaGWHKM6kuzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/counter": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@swc/types": {
|
||||
"version": "0.1.25",
|
||||
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz",
|
||||
"integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/wasm": {
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.8.tgz",
|
||||
"integrity": "sha512-RG2BxGbbsjtddFCo1ghKH6A/BMXbY1eMBfpysV0lJMCpI4DZOjW1BNBnxvBt7YsYmlJtmy5UXIg9/4ekBTFFaQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
|
||||
@@ -2490,6 +2235,12 @@
|
||||
"tailwindcss": "4.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/tailwindcss": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
|
||||
@@ -2732,6 +2483,12 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite/node_modules/tailwindcss": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
@@ -3358,6 +3115,16 @@
|
||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@upsetjs/venn.js": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
|
||||
"integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-transition": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/build-utils": {
|
||||
"version": "13.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.2.11.tgz",
|
||||
@@ -3829,14 +3596,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bail": {
|
||||
@@ -4129,37 +3896,33 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain": {
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
|
||||
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
|
||||
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/cst-dts-gen": "11.0.3",
|
||||
"@chevrotain/gast": "11.0.3",
|
||||
"@chevrotain/regexp-to-ast": "11.0.3",
|
||||
"@chevrotain/types": "11.0.3",
|
||||
"@chevrotain/utils": "11.0.3",
|
||||
"lodash-es": "4.17.21"
|
||||
"@chevrotain/cst-dts-gen": "12.0.0",
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/regexp-to-ast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0",
|
||||
"@chevrotain/utils": "12.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain-allstar": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz",
|
||||
"integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz",
|
||||
"integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"chevrotain": "^11.0.0"
|
||||
"chevrotain": "^12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain/node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
@@ -4830,9 +4593,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dagre-d3-es": {
|
||||
"version": "7.0.13",
|
||||
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz",
|
||||
"integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==",
|
||||
"version": "7.0.14",
|
||||
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
|
||||
"integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3": "^7.9.0",
|
||||
@@ -6064,9 +5827,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "18.0.2",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz",
|
||||
"integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==",
|
||||
"version": "18.1.2",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz",
|
||||
"integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -6076,7 +5839,7 @@
|
||||
"@hapi/pinpoint": "^2.0.1",
|
||||
"@hapi/tlds": "^1.1.1",
|
||||
"@hapi/topo": "^6.0.2",
|
||||
"@standard-schema/spec": "^1.0.0"
|
||||
"@standard-schema/spec": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
@@ -6098,14 +5861,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.0.tgz",
|
||||
"integrity": "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==",
|
||||
"version": "29.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
|
||||
"integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.0.1",
|
||||
"@asamuzakjp/dom-selector": "^7.0.2",
|
||||
"@asamuzakjp/css-color": "^5.1.5",
|
||||
"@asamuzakjp/dom-selector": "^7.0.6",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
@@ -6119,7 +5882,7 @@
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.1",
|
||||
"undici": "^7.24.3",
|
||||
"undici": "^7.24.5",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
@@ -6152,9 +5915,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/undici": {
|
||||
"version": "7.24.3",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz",
|
||||
"integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==",
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -6295,19 +6058,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/langium": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz",
|
||||
"integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz",
|
||||
"integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chevrotain": "~11.0.3",
|
||||
"chevrotain-allstar": "~0.3.0",
|
||||
"@chevrotain/regexp-to-ast": "~12.0.0",
|
||||
"chevrotain": "~12.0.0",
|
||||
"chevrotain-allstar": "~0.4.1",
|
||||
"vscode-languageserver": "~9.0.1",
|
||||
"vscode-languageserver-textdocument": "~1.0.11",
|
||||
"vscode-uri": "~3.0.8"
|
||||
"vscode-uri": "~3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": ">=20.10.0",
|
||||
"npm": ">=10.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/langsmith": {
|
||||
@@ -6613,16 +6378,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.22",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
|
||||
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/longest-streak": {
|
||||
@@ -7072,27 +6837,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid": {
|
||||
"version": "11.12.2",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz",
|
||||
"integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==",
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
|
||||
"integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.1",
|
||||
"@iconify/utils": "^3.0.1",
|
||||
"@mermaid-js/parser": "^0.6.3",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@mermaid-js/parser": "^1.1.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"cytoscape": "^3.29.3",
|
||||
"@upsetjs/venn.js": "^2.0.0",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape-cose-bilkent": "^4.1.0",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"d3": "^7.9.0",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"dagre-d3-es": "7.0.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"dompurify": "^3.2.5",
|
||||
"katex": "^0.16.22",
|
||||
"dagre-d3-es": "7.0.14",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.3.1",
|
||||
"katex": "^0.16.25",
|
||||
"khroma": "^2.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^16.2.1",
|
||||
"lodash-es": "^4.17.23",
|
||||
"marked": "^16.3.0",
|
||||
"roughjs": "^4.6.6",
|
||||
"stylis": "^4.3.6",
|
||||
"ts-dedent": "^2.2.0",
|
||||
@@ -8317,10 +8083,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
@@ -9006,9 +8775,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
@@ -10270,9 +10039,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz",
|
||||
"integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
@@ -10289,15 +10058,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wait-on": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz",
|
||||
"integrity": "sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag==",
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.5.tgz",
|
||||
"integrity": "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.12.1",
|
||||
"joi": "^18.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"axios": "^1.15.0",
|
||||
"joi": "^18.1.2",
|
||||
"lodash": "^4.18.1",
|
||||
"minimist": "^1.2.8",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
@@ -10305,7 +10074,7 @@
|
||||
"wait-on": "bin/wait-on"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"langchain": "^1.2.10",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"mermaid": "^11.14.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -49,7 +49,7 @@
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
@@ -67,11 +67,11 @@
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"jsdom": "^29.0.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.0",
|
||||
"vitest": "^3.2.4",
|
||||
"wait-on": "^8.0.5"
|
||||
"wait-on": "^9.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@shared': path.resolve(__dirname, '../shared'),
|
||||
'gitnexus-shared': path.resolve(__dirname, '../gitnexus-shared/src/index.ts'),
|
||||
// Fix for Rollup failing to resolve this deep import from @langchain/anthropic
|
||||
'@anthropic-ai/sdk/lib/transform-json-schema': path.resolve(
|
||||
__dirname,
|
||||
|
||||
@@ -9,6 +9,10 @@ tsconfig.json
|
||||
.gitignore
|
||||
node_modules/
|
||||
|
||||
# Vendor build artifacts (created during install, not shipped)
|
||||
vendor/**/node_modules
|
||||
vendor/**/build
|
||||
|
||||
# Package lock (consumers use their own)
|
||||
package-lock.json
|
||||
|
||||
|
||||
@@ -2,6 +2,46 @@
|
||||
|
||||
All notable changes to GitNexus will be documented in this file.
|
||||
|
||||
## [1.6.2] - 2026-04-18
|
||||
|
||||
### Added
|
||||
|
||||
- **Docker support** — containerized ingestion and MCP serving for reproducible runs on CI and container platforms (#848)
|
||||
- **Language-agnostic heritage extractor** — config+factory pattern for class-heritage extraction (EXTENDS / IMPLEMENTS), completing the extractor refactor alongside method/field/call/variable (#890)
|
||||
- **Language-agnostic call extractor** — config+factory pattern that collapses ~225 lines of inline parse-worker logic into declarative per-language configs (#877)
|
||||
- **Language-agnostic variable extractor** — structured metadata for `Const` / `Static` / `Variable` nodes via config+factory pattern (#878)
|
||||
- **AST-aware embedding chunking** — offset-based splitting preserves symbol boundaries, improving semantic search precision on large files (#889)
|
||||
- **HTTP consumer detection for jQuery and axios object-form** — `$.ajax` / `$.get` / `$.post` and `axios({ url, method })` now recognized as HTTP call sites (#887)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Python external dotted imports** — avoid spurious same-file matches when an import path like `foo.bar.baz` refers to a third-party module (#899)
|
||||
- **Worker warnings no longer terminate ingestion** — non-fatal parser warnings keep the pipeline running instead of aborting the run (#900, #261)
|
||||
- **Global-install upgrade `ENOTEMPTY`** — devendored `tree-sitter-proto` install lifecycle + preinstall cleanup so `npm i -g gitnexus@latest` succeeds on top of an older install (#843, #846)
|
||||
- **`env.cacheDir`** now defaults to a user-writable location, unblocking ingestion on systems where the install directory is read-only (#845)
|
||||
- **Content-hash staleness detection for embeddings** — zero-node rebuilds no longer skip vector-index creation, fixing semantic search after selective re-analysis (#831)
|
||||
- **`tree-sitter-c-sharp` version pin** — locked to 0.23.1 to avoid a breaking change in a transitive prerelease (#834)
|
||||
- **`release-drafter` v7 CI** — replaced the removed `disable-releaser` flag with `dry-run` so release-note drafts still work
|
||||
- **`npm arborist` crash from `tree-sitter-dart`** — switched the dependency URL format so `npm install` no longer crashes on clean installs
|
||||
- **Service-group `ManifestExtractor`** — `config.links` now wires the manifest extractor properly, restoring cross-link discovery that had silently dropped to zero
|
||||
|
||||
### Changed
|
||||
|
||||
- **SemanticModel wired as a first-class resolution input (SM-20)** — `call-processor`, `resolution-context`, `type-env`, and `heritage-map` now consult `table.model.*` directly; 37 internal call sites migrated off the SymbolTable wrapper (#885)
|
||||
- **Per-strategy `ImportSemantics` hooks** — `named` / `wildcard-transitive` / `wildcard-leaf` / `namespace` strategies split into composable hooks, replacing the monolithic conditional (Strategies 1–4 of #886)
|
||||
- **Class extraction configs moved to `configs/` subdirectory** — per-language class configs now co-locate with the other extractor configs, completing the extractor layer's directory convention (#879)
|
||||
- **CLI AI-context trimmed** — duplicated CLAUDE.md block removed from the shipped context, reducing token usage in LLM-consuming workflows (#904)
|
||||
- **LLM context files optimized** — AI-consumed documentation tuned for accuracy and token efficiency (#857)
|
||||
- **Workflow concurrency standardized** — all CI workflows adopt the consistent concurrency key pattern documented in CONTRIBUTING.md; release-note labeling automated (#837)
|
||||
- **E2E status-ready timeout raised** — 45s accommodates parallel-worker startup variance on CI (#908)
|
||||
|
||||
### Chore / Dependencies
|
||||
|
||||
- **tree-sitter 0.25 upgrade readiness** — daily Dependabot monitor for the upcoming major-version bump (#847)
|
||||
- Dependency bumps: `glob` 11.1.0 → 13.0.6 (#867), `commander` 12.1.0 → 14.0.3 (#868), `@huggingface/transformers` (#869), `@modelcontextprotocol/sdk` (#866), `lru-cache` 11.2.7 → 11.3.5 (#870), `mnemonist` 0.39.8 → 0.40.3 (#871), `@ladybugdb/core` (#873)
|
||||
- gitnexus-web dependency bumps: `mermaid` 11.12.2 → 11.14.0 (#860), `tailwindcss` (#861), `jsdom` 29.0.0 → 29.0.2 (#863), `wait-on` 8.0.5 → 9.0.5 (#859), `@vitest/coverage-v8` (#864)
|
||||
- GitHub Actions bumps: `actions/checkout` 4.3.1 → 6.0.2 (#842), `actions/upload-artifact` 4.6.2 → 7.0.1 (#838), `actions/setup-node` 4.4.0 → 6.3.0 (#841), `actions/cache` 5.0.4 → 5.0.5 (#840), `actions/github-script` 7.0.1 → 9.0.0 (#850), `dorny/paths-filter` 3.0.2 → 4.0.1 (#839), `amannn/action-semantic-pull-request` 6.1.1 (#853), `release-drafter/release-drafter` 6.0.0 → 7.2.0 (#852), `marocchino/sticky-pull-request-comment` 3.0.4 (#851), `softprops/action-gh-release` 2.5.0 → 3.0.0 (#849)
|
||||
|
||||
## [1.6.1] - 2026-04-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
FROM node:20-bookworm
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
COPY . .
|
||||
RUN npm ci --ignore-scripts \
|
||||
&& node scripts/patch-tree-sitter-swift.cjs \
|
||||
|
||||
@@ -234,6 +234,29 @@ Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setu
|
||||
- Node.js >= 18
|
||||
- Git repository (uses git for commit tracking)
|
||||
|
||||
## Release candidates
|
||||
|
||||
Stable releases publish to the default `latest` dist-tag. When a pull request
|
||||
with non-documentation changes merges into `main`, an automated workflow also
|
||||
publishes a prerelease build under the `rc` dist-tag, so early adopters can
|
||||
try in-flight fixes without waiting for the next stable cut. (Docs-only
|
||||
merges are skipped.)
|
||||
|
||||
```bash
|
||||
# Try the latest release candidate (pre-stable — may change at any time)
|
||||
npm install -g gitnexus@rc
|
||||
# — or —
|
||||
npx gitnexus@rc analyze
|
||||
```
|
||||
|
||||
Release-candidate versions follow the standard semver prerelease format
|
||||
`X.Y.Z-rc.N`, where `X.Y.Z` is the next stable target (bumped from the
|
||||
current `latest` by patch by default; `minor` or `major` when kicking off a
|
||||
bigger cycle) and `N` increments per published rc. Example sequence:
|
||||
`1.6.2-rc.1`, `1.6.2-rc.2`, …, then once `1.6.2` ships stable,
|
||||
`1.6.3-rc.1`. See the [Releases page](https://github.com/abhigyanpatwari/GitNexus/releases)
|
||||
for the full list; stable `latest` is unaffected.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `Cannot destructure property 'package' of 'node.target' as it is null`
|
||||
|
||||
Generated
+272
-411
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.1",
|
||||
"version": "1.6.3-rc.9",
|
||||
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
|
||||
"author": "Abhigyan Patwari",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
@@ -46,32 +46,32 @@
|
||||
"test:integration": "vitest run test/integration",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"postinstall": "node scripts/patch-tree-sitter-swift.cjs",
|
||||
"postinstall": "node scripts/patch-tree-sitter-swift.cjs && node scripts/build-tree-sitter-proto.cjs",
|
||||
"prepare": "node scripts/build.js",
|
||||
"prepack": "node scripts/build.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.0.0",
|
||||
"@huggingface/transformers": "^4.1.0",
|
||||
"@ladybugdb/core": "^0.15.2",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"commander": "^12.0.0",
|
||||
"commander": "^14.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"glob": "^11.0.0",
|
||||
"glob": "^13.0.6",
|
||||
"graphology": "^0.25.4",
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"ignore": "^7.0.5",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lru-cache": "^11.0.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"mnemonist": "^0.40.3",
|
||||
"onnxruntime-node": "^1.24.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"tree-sitter": "^0.21.1",
|
||||
"tree-sitter-c": "0.23.2",
|
||||
"tree-sitter-c-sharp": "^0.23.1",
|
||||
"tree-sitter-c-sharp": "0.23.1",
|
||||
"tree-sitter-cpp": "^0.23.4",
|
||||
"tree-sitter-go": "^0.23.0",
|
||||
"tree-sitter-java": "^0.23.5",
|
||||
@@ -84,6 +84,8 @@
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.0",
|
||||
"tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4",
|
||||
"tree-sitter-kotlin": "^0.3.8",
|
||||
"tree-sitter-proto": "file:./vendor/tree-sitter-proto",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build tree-sitter-proto native binding.
|
||||
*
|
||||
* Why this script exists:
|
||||
* tree-sitter-proto is vendored under gitnexus/vendor/tree-sitter-proto/
|
||||
* and declared as a `file:` optionalDependency. Previously, the vendored
|
||||
* package had its own `dependencies` and `install` script, which caused
|
||||
* npm to create `vendor/tree-sitter-proto/node_modules/` and
|
||||
* `vendor/tree-sitter-proto/build/` during install. Those directories
|
||||
* blocked `rmdir` on global-install upgrade, producing:
|
||||
*
|
||||
* ENOTEMPTY: directory not empty, rmdir
|
||||
* '.../gitnexus/vendor/tree-sitter-proto/node_modules/node-addon-api'
|
||||
*
|
||||
* (See https://github.com/abhigyanpatwari/GitNexus/issues/836.)
|
||||
*
|
||||
* We stripped `dependencies` and the `install` script from the vendored
|
||||
* package.json, hoisted `node-addon-api` and `node-gyp-build` into
|
||||
* gitnexus's own optionalDependencies, and moved native compilation here.
|
||||
*
|
||||
* What this does:
|
||||
* Runs `npx node-gyp rebuild` inside `node_modules/tree-sitter-proto/`
|
||||
* (which npm creates as a copy of vendor/tree-sitter-proto/ when
|
||||
* resolving the file: dep). Build output lands in
|
||||
* `node_modules/tree-sitter-proto/build/Release/tree_sitter_proto_binding.node`
|
||||
* — under npm-managed territory, safe on upgrade.
|
||||
*
|
||||
* Mirrors scripts/patch-tree-sitter-swift.cjs. Best-effort: if any
|
||||
* precondition fails (optional dep absent, no toolchain, --ignore-scripts),
|
||||
* warn and exit 0 so gitnexus install still succeeds.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const protoDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-proto');
|
||||
const bindingGyp = path.join(protoDir, 'binding.gyp');
|
||||
const bindingNode = path.join(protoDir, 'build', 'Release', 'tree_sitter_proto_binding.node');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(bindingGyp)) {
|
||||
// tree-sitter-proto is an optionalDependency; absent when install
|
||||
// skipped optional deps or the file: dep was not resolved.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Skip if the native binding already exists (idempotent re-run).
|
||||
if (fs.existsSync(bindingNode)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Pre-flight: the hoisted build deps must be resolvable.
|
||||
try {
|
||||
require.resolve('node-addon-api');
|
||||
require.resolve('node-gyp-build');
|
||||
} catch (resolveErr) {
|
||||
console.warn(
|
||||
'[tree-sitter-proto] Skipping build: hoisted build deps not resolvable (%s).',
|
||||
resolveErr.message,
|
||||
);
|
||||
console.warn(
|
||||
'[tree-sitter-proto] Proto parsing will be unavailable. Install without --no-optional and with scripts enabled to build.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('[tree-sitter-proto] Building native binding...');
|
||||
execSync('npx node-gyp rebuild', {
|
||||
cwd: protoDir,
|
||||
stdio: 'pipe',
|
||||
timeout: 180000,
|
||||
});
|
||||
console.log('[tree-sitter-proto] Native binding built successfully');
|
||||
} catch (err) {
|
||||
console.warn('[tree-sitter-proto] Could not build native binding:', err.message);
|
||||
console.warn(
|
||||
'[tree-sitter-proto] Proto (.proto) parsing will be unavailable. Non-proto gitnexus functionality is unaffected.',
|
||||
);
|
||||
// Exit 0: optionalDependency failures must not fail the gitnexus install.
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -101,19 +101,6 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
|
||||
- When exploring unfamiliar code, use \`gitnexus_query({query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`gitnexus_context({name: "symbolName"})\`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. \`gitnexus_query({query: "<error or symptom>"})\` — find execution flows related to the issue
|
||||
2. \`gitnexus_context({name: "<suspect function>"})\` — see all callers, callees, and process participation
|
||||
3. \`READ gitnexus://repo/${projectName}/process/{processName}\` — trace the full execution flow step by step
|
||||
4. For regressions: \`gitnexus_detect_changes({scope: "compare", base_ref: "main"})\` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use \`gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})\` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with \`dry_run: false\`.
|
||||
- **Extracting/Splitting**: MUST run \`gitnexus_context({name: "target"})\` to see all incoming/outgoing refs, then \`gitnexus_impact({target: "target", direction: "upstream"})\` to find all external callers before moving code.
|
||||
- After any refactor: run \`gitnexus_detect_changes({scope: "all"})\` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running \`gitnexus_impact\` on it.
|
||||
@@ -121,25 +108,6 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
|
||||
- NEVER rename symbols with find-and-replace — use \`gitnexus_rename\` which understands the call graph.
|
||||
- NEVER commit changes without running \`gitnexus_detect_changes()\` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| \`query\` | Find code by concept | \`gitnexus_query({query: "auth validation"})\` |
|
||||
| \`context\` | 360-degree view of one symbol | \`gitnexus_context({name: "validateUser"})\` |
|
||||
| \`impact\` | Blast radius before editing | \`gitnexus_impact({target: "X", direction: "upstream"})\` |
|
||||
| \`detect_changes\` | Pre-commit scope check | \`gitnexus_detect_changes({scope: "staged"})\` |
|
||||
| \`rename\` | Safe multi-file rename | \`gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})\` |
|
||||
| \`cypher\` | Custom graph queries | \`gitnexus_cypher({query: "MATCH ..."})\` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
@@ -149,32 +117,6 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
|
||||
| \`gitnexus://repo/${projectName}/processes\` | All execution flows |
|
||||
| \`gitnexus://repo/${projectName}/process/{name}\` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. \`gitnexus_impact\` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. \`gitnexus_detect_changes()\` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
\`\`\`bash
|
||||
npx gitnexus analyze
|
||||
\`\`\`
|
||||
|
||||
If the index previously included embeddings, preserve them by adding \`--embeddings\`:
|
||||
|
||||
\`\`\`bash
|
||||
npx gitnexus analyze --embeddings
|
||||
\`\`\`
|
||||
|
||||
To check whether embeddings exist, inspect \`.gitnexus/meta.json\` — the \`stats.embeddings\` field shows the count (0 means no embeddings). **Running analyze without \`--embeddings\` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after \`git commit\` and \`git merge\`.
|
||||
|
||||
${
|
||||
groupNames && groupNames.length > 0
|
||||
? `## Cross-Repo Groups
|
||||
|
||||
@@ -147,9 +147,11 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
const origLog = console.log.bind(console);
|
||||
const origWarn = console.warn.bind(console);
|
||||
const origError = console.error.bind(console);
|
||||
let barCurrentValue = 0;
|
||||
const barLog = (...args: any[]) => {
|
||||
process.stdout.write('\x1b[2K\r');
|
||||
origLog(args.map((a) => (typeof a === 'string' ? a : String(a))).join(' '));
|
||||
bar.update(barCurrentValue);
|
||||
};
|
||||
console.log = barLog;
|
||||
console.warn = barLog;
|
||||
@@ -160,6 +162,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
let phaseStart = Date.now();
|
||||
|
||||
const updateBar = (value: number, phaseLabel: string) => {
|
||||
barCurrentValue = value;
|
||||
if (phaseLabel !== lastPhaseLabel) {
|
||||
lastPhaseLabel = phaseLabel;
|
||||
phaseStart = Date.now();
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Shared AST utilities for the embedding pipeline.
|
||||
* Centralizes parser caching and tree-sitter node lookups
|
||||
* used by both chunker.ts and structural-extractor.ts.
|
||||
*/
|
||||
|
||||
import { getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import {
|
||||
createParserForLanguage,
|
||||
isLanguageAvailable,
|
||||
resolveLanguageKey,
|
||||
} from '../tree-sitter/parser-loader.js';
|
||||
|
||||
const parserCache = new Map<string, any>();
|
||||
|
||||
/**
|
||||
* Ensure parser is initialized and language is loaded, then parse content.
|
||||
* Returns null if language is unavailable or parsing fails.
|
||||
*/
|
||||
export const ensureAndParse = async (content: string, filePath: string): Promise<any | null> => {
|
||||
const language = getLanguageFromFilename(filePath);
|
||||
if (!language) return null;
|
||||
if (!isLanguageAvailable(language)) return null;
|
||||
|
||||
const parserKey = resolveLanguageKey(language, filePath);
|
||||
let parserInstance = parserCache.get(parserKey);
|
||||
if (!parserInstance) {
|
||||
parserInstance = await createParserForLanguage(language, filePath);
|
||||
parserCache.set(parserKey, parserInstance);
|
||||
}
|
||||
|
||||
return parserInstance.parse(content);
|
||||
};
|
||||
|
||||
const FUNCTION_LIKE_TYPES = new Set([
|
||||
'function_declaration',
|
||||
'function_definition',
|
||||
'method_declaration',
|
||||
'method_definition',
|
||||
'function_item',
|
||||
'function_signature_item',
|
||||
'arrow_function',
|
||||
'function_expression',
|
||||
'generator_function_declaration',
|
||||
'generator_function',
|
||||
'async_function_declaration',
|
||||
'async_arrow_function',
|
||||
'constructor_declaration',
|
||||
'constructor_definition',
|
||||
'compact_constructor_declaration',
|
||||
'short_function_declaration',
|
||||
'proc_declaration',
|
||||
'func_literal',
|
||||
'local_function_statement',
|
||||
'anonymous_function',
|
||||
'lambda_literal',
|
||||
'init_declaration',
|
||||
'deinit_declaration',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Find the first function/method-like declaration in a snippet AST.
|
||||
* Used by the chunker when parsing node.content where absolute line
|
||||
* numbers don't apply.
|
||||
*/
|
||||
export const findFunctionNode = (root: any): any | null => {
|
||||
if (FUNCTION_LIKE_TYPES.has(root.type)) return root;
|
||||
|
||||
for (let i = 0; i < root.namedChildCount; i++) {
|
||||
const child = root.namedChild(i);
|
||||
if (!child) continue;
|
||||
if (FUNCTION_LIKE_TYPES.has(child.type)) return child;
|
||||
const found = findFunctionNode(child);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the first class/struct/interface/enum-like declaration in an AST.
|
||||
* Used when parsing node.content (a snippet, not a full file) where
|
||||
* absolute line numbers don't apply.
|
||||
*/
|
||||
export const findDeclarationNode = (root: any): any | null => {
|
||||
const CLASS_LIKE_TYPES = new Set([
|
||||
'class_declaration',
|
||||
'class_definition',
|
||||
'struct_declaration',
|
||||
'struct_item',
|
||||
'interface_declaration',
|
||||
'interface_definition',
|
||||
'enum_declaration',
|
||||
'enum_item',
|
||||
'type_declaration', // Go: type X struct
|
||||
'declaration', // Go: type X struct
|
||||
'object_declaration', // Kotlin: object
|
||||
'impl_item', // Rust: impl
|
||||
]);
|
||||
|
||||
if (CLASS_LIKE_TYPES.has(root.type)) return root;
|
||||
|
||||
for (let i = 0; i < root.namedChildCount; i++) {
|
||||
const child = root.namedChild(i);
|
||||
if (!child) continue;
|
||||
if (CLASS_LIKE_TYPES.has(child.type)) return child;
|
||||
const found = findDeclarationNode(child);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Character-based sliding window chunking (pure, no tree-sitter dependency)
|
||||
*/
|
||||
|
||||
import { buildLineIndex, resolveChunkLines } from './line-index.js';
|
||||
|
||||
export interface Chunk {
|
||||
text: string;
|
||||
chunkIndex: number;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
}
|
||||
|
||||
export const characterChunk = (
|
||||
content: string,
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
chunkSize: number = 1200,
|
||||
overlap: number = 120,
|
||||
): Chunk[] => {
|
||||
if (content.length <= chunkSize) {
|
||||
return [
|
||||
{
|
||||
text: content,
|
||||
chunkIndex: 0,
|
||||
startOffset: 0,
|
||||
endOffset: content.length,
|
||||
startLine,
|
||||
endLine,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const chunks: Chunk[] = [];
|
||||
let offset = 0;
|
||||
const lineOffsets = buildLineIndex(content);
|
||||
|
||||
while (offset < content.length) {
|
||||
const end = Math.min(offset + chunkSize, content.length);
|
||||
const chunkText = content.slice(offset, end);
|
||||
const lineRange = resolveChunkLines(lineOffsets, offset, end, startLine);
|
||||
|
||||
chunks.push({
|
||||
text: chunkText,
|
||||
chunkIndex: chunks.length,
|
||||
startOffset: offset,
|
||||
endOffset: end,
|
||||
startLine: lineRange.startLine,
|
||||
endLine: lineRange.endLine,
|
||||
});
|
||||
|
||||
offset = end - overlap;
|
||||
if (offset >= content.length) break;
|
||||
if (end >= content.length) break;
|
||||
if (offset <= (chunks.length > 1 ? end - chunkSize : 0)) {
|
||||
offset = end;
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
};
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Chunker Module
|
||||
*
|
||||
* Splits code nodes into chunks for embedding.
|
||||
* - Function/Method: AST-aware chunking by statement boundaries
|
||||
* - Other types: character-based sliding window fallback
|
||||
* - Short content (≤ chunkSize): no chunking
|
||||
*/
|
||||
|
||||
export { type Chunk, characterChunk } from './character-chunk.js';
|
||||
|
||||
import { characterChunk } from './character-chunk.js';
|
||||
import type { Chunk } from './character-chunk.js';
|
||||
import { ensureAndParse, findDeclarationNode, findFunctionNode } from './ast-utils.js';
|
||||
import { buildLineIndex, resolveChunkLines } from './line-index.js';
|
||||
|
||||
/**
|
||||
* Main chunkNode function: dispatches by label
|
||||
*/
|
||||
export const chunkNode = async (
|
||||
label: string,
|
||||
content: string,
|
||||
filePath: string,
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
chunkSize: number = 1200,
|
||||
overlap: number = 120,
|
||||
): Promise<Chunk[]> => {
|
||||
// Content fits in one chunk — no splitting needed
|
||||
if (content.length <= chunkSize) {
|
||||
return [
|
||||
{
|
||||
text: content,
|
||||
chunkIndex: 0,
|
||||
startOffset: 0,
|
||||
endOffset: content.length,
|
||||
startLine,
|
||||
endLine,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Only function-like labels get AST chunking
|
||||
if (label === 'Function' || label === 'Method' || label === 'Constructor') {
|
||||
try {
|
||||
const astChunks = await astChunk(content, filePath, startLine, endLine, chunkSize, overlap);
|
||||
if (astChunks.length > 0) return astChunks;
|
||||
} catch {
|
||||
// AST parsing failed — fall through to character fallback
|
||||
}
|
||||
}
|
||||
|
||||
if (label === 'Class' || label === 'Interface') {
|
||||
try {
|
||||
const declarationChunks = await declarationChunk(
|
||||
label,
|
||||
content,
|
||||
filePath,
|
||||
startLine,
|
||||
endLine,
|
||||
chunkSize,
|
||||
overlap,
|
||||
);
|
||||
if (declarationChunks.length > 0) return declarationChunks;
|
||||
} catch {
|
||||
// AST parsing failed — fall through to character fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Character-based fallback for everything else
|
||||
return characterChunk(content, startLine, endLine, chunkSize, overlap);
|
||||
};
|
||||
|
||||
/**
|
||||
* AST-based chunking for Function/Method
|
||||
* Parse snippet content, locate the function declaration node,
|
||||
* split body by statement boundaries.
|
||||
*/
|
||||
const astChunk = async (
|
||||
content: string,
|
||||
filePath: string,
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
chunkSize: number,
|
||||
overlap: number,
|
||||
): Promise<Chunk[]> => {
|
||||
const tree = await ensureAndParse(content, filePath);
|
||||
if (!tree) return [];
|
||||
|
||||
const root = tree.rootNode;
|
||||
const lineOffsets = buildLineIndex(content);
|
||||
|
||||
// Find the function/method declaration in the snippet AST.
|
||||
// tree-sitter parses node.content (a snippet), so rows are relative (0-based).
|
||||
const targetNode = findFunctionNode(root);
|
||||
if (!targetNode) return [];
|
||||
|
||||
// Get the body (statements) via childForFieldName('body')
|
||||
const bodyNode = targetNode.childForFieldName('body');
|
||||
if (!bodyNode) return [];
|
||||
|
||||
// Extract individual statements
|
||||
const statements: Array<{ startIndex: number; endIndex: number }> = [];
|
||||
for (let i = 0; i < bodyNode.namedChildCount; i++) {
|
||||
const child = bodyNode.namedChild(i);
|
||||
if (!child) continue;
|
||||
statements.push({
|
||||
startIndex: child.startIndex,
|
||||
endIndex: child.endIndex,
|
||||
});
|
||||
}
|
||||
|
||||
if (statements.length === 0) return [];
|
||||
|
||||
return chunkByUnits(
|
||||
content,
|
||||
lineOffsets,
|
||||
startLine,
|
||||
chunkSize,
|
||||
overlap,
|
||||
statements,
|
||||
targetNode.startIndex,
|
||||
targetNode.endIndex,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
const DECLARATION_BODY_NODE_TYPES = new Set([
|
||||
'class_body',
|
||||
'object_type',
|
||||
'declaration_list',
|
||||
'interface_body',
|
||||
]);
|
||||
|
||||
const FIELD_LIKE_MEMBER_TYPES = new Set([
|
||||
'field_definition',
|
||||
'public_field_definition',
|
||||
'property_definition',
|
||||
'property_signature',
|
||||
'variable_declarator',
|
||||
'lexical_declaration',
|
||||
'pair',
|
||||
'enum_assignment',
|
||||
]);
|
||||
|
||||
const declarationChunk = async (
|
||||
label: 'Class' | 'Interface',
|
||||
content: string,
|
||||
filePath: string,
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
chunkSize: number,
|
||||
overlap: number,
|
||||
): Promise<Chunk[]> => {
|
||||
const tree = await ensureAndParse(content, filePath);
|
||||
if (!tree) return [];
|
||||
|
||||
const targetNode = findDeclarationNode(tree.rootNode);
|
||||
if (!targetNode) return [];
|
||||
|
||||
const bodyNode = getDeclarationBodyNode(targetNode);
|
||||
if (!bodyNode) return [];
|
||||
|
||||
const members = collectDeclarationUnits(bodyNode, label);
|
||||
if (members.length === 0) return [];
|
||||
|
||||
return chunkByUnits(
|
||||
content,
|
||||
buildLineIndex(content),
|
||||
startLine,
|
||||
chunkSize,
|
||||
overlap,
|
||||
members,
|
||||
targetNode.startIndex,
|
||||
targetNode.endIndex,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
const buildChunk = (
|
||||
content: string,
|
||||
lineOffsets: Int32Array,
|
||||
chunkIndex: number,
|
||||
startOffset: number,
|
||||
endOffset: number,
|
||||
baseStartLine: number,
|
||||
): Chunk => {
|
||||
const lineRange = resolveChunkLines(lineOffsets, startOffset, endOffset, baseStartLine);
|
||||
return {
|
||||
text: content.slice(startOffset, endOffset),
|
||||
chunkIndex,
|
||||
startOffset,
|
||||
endOffset,
|
||||
startLine: lineRange.startLine,
|
||||
endLine: lineRange.endLine,
|
||||
};
|
||||
};
|
||||
|
||||
const chunkByUnits = (
|
||||
content: string,
|
||||
lineOffsets: Int32Array,
|
||||
baseStartLine: number,
|
||||
chunkSize: number,
|
||||
overlap: number,
|
||||
units: Array<{ startIndex: number; endIndex: number }>,
|
||||
containerStartOffset: number,
|
||||
containerEndOffset: number,
|
||||
includeContainerPrefixOnFirstChunk: boolean,
|
||||
includeContainerSuffixOnLastChunk: boolean,
|
||||
): Chunk[] => {
|
||||
const chunks: Chunk[] = [];
|
||||
let chunkStartUnitIdx = 0;
|
||||
|
||||
while (chunkStartUnitIdx < units.length) {
|
||||
const chunkStartOffset =
|
||||
chunkStartUnitIdx === 0 && includeContainerPrefixOnFirstChunk
|
||||
? containerStartOffset
|
||||
: units[chunkStartUnitIdx].startIndex;
|
||||
|
||||
let chunkEndUnitIdx = chunkStartUnitIdx;
|
||||
let candidateEndOffset =
|
||||
chunkEndUnitIdx === units.length - 1 && includeContainerSuffixOnLastChunk
|
||||
? containerEndOffset
|
||||
: units[chunkEndUnitIdx].endIndex;
|
||||
|
||||
while (chunkEndUnitIdx + 1 < units.length) {
|
||||
const nextEndOffset =
|
||||
chunkEndUnitIdx + 1 === units.length - 1 && includeContainerSuffixOnLastChunk
|
||||
? containerEndOffset
|
||||
: units[chunkEndUnitIdx + 1].endIndex;
|
||||
if (nextEndOffset - chunkStartOffset > chunkSize) break;
|
||||
chunkEndUnitIdx += 1;
|
||||
candidateEndOffset = nextEndOffset;
|
||||
}
|
||||
|
||||
if (candidateEndOffset - chunkStartOffset > chunkSize) {
|
||||
const oversizedUnit = units[chunkStartUnitIdx];
|
||||
const oversizedLineRange = resolveChunkLines(
|
||||
lineOffsets,
|
||||
oversizedUnit.startIndex,
|
||||
oversizedUnit.endIndex,
|
||||
baseStartLine,
|
||||
);
|
||||
const oversizedChunks = characterChunk(
|
||||
content.slice(oversizedUnit.startIndex, oversizedUnit.endIndex),
|
||||
oversizedLineRange.startLine,
|
||||
oversizedLineRange.endLine,
|
||||
chunkSize,
|
||||
overlap,
|
||||
).map((chunk, offsetIdx) => ({
|
||||
...chunk,
|
||||
chunkIndex: chunks.length + offsetIdx,
|
||||
startOffset: chunk.startOffset + oversizedUnit.startIndex,
|
||||
endOffset: chunk.endOffset + oversizedUnit.startIndex,
|
||||
}));
|
||||
chunks.push(...oversizedChunks);
|
||||
chunkStartUnitIdx += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks.push(
|
||||
buildChunk(
|
||||
content,
|
||||
lineOffsets,
|
||||
chunks.length,
|
||||
chunkStartOffset,
|
||||
candidateEndOffset,
|
||||
baseStartLine,
|
||||
),
|
||||
);
|
||||
|
||||
if (chunkEndUnitIdx === units.length - 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const nextChunkStartUnitIdx = findOverlapStartIndex(
|
||||
units,
|
||||
chunkStartUnitIdx,
|
||||
chunkEndUnitIdx,
|
||||
overlap,
|
||||
);
|
||||
if (nextChunkStartUnitIdx <= chunkStartUnitIdx) {
|
||||
chunkStartUnitIdx = chunkEndUnitIdx + 1;
|
||||
} else {
|
||||
chunkStartUnitIdx = nextChunkStartUnitIdx;
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
};
|
||||
|
||||
const findOverlapStartIndex = (
|
||||
statements: Array<{ startIndex: number; endIndex: number }>,
|
||||
chunkStartStmtIdx: number,
|
||||
chunkEndStmtIdx: number,
|
||||
overlapSize: number,
|
||||
): number => {
|
||||
if (overlapSize <= 0) return chunkEndStmtIdx + 1;
|
||||
|
||||
let overlapStartIdx = chunkEndStmtIdx;
|
||||
while (overlapStartIdx > chunkStartStmtIdx) {
|
||||
const overlapLength =
|
||||
statements[chunkEndStmtIdx].endIndex - statements[overlapStartIdx - 1].startIndex;
|
||||
if (overlapLength > overlapSize) break;
|
||||
overlapStartIdx -= 1;
|
||||
}
|
||||
|
||||
return overlapStartIdx;
|
||||
};
|
||||
|
||||
const getDeclarationBodyNode = (node: any): any | null => {
|
||||
const bodyNode = node.childForFieldName?.('body');
|
||||
if (bodyNode) return bodyNode;
|
||||
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child) continue;
|
||||
if (DECLARATION_BODY_NODE_TYPES.has(child.type)) return child;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const collectDeclarationUnits = (
|
||||
bodyNode: any,
|
||||
label: 'Class' | 'Interface',
|
||||
): Array<{ startIndex: number; endIndex: number }> => {
|
||||
const members: Array<{ startIndex: number; endIndex: number; groupable: boolean }> = [];
|
||||
|
||||
for (let i = 0; i < bodyNode.namedChildCount; i++) {
|
||||
const child = bodyNode.namedChild(i);
|
||||
if (!child) continue;
|
||||
members.push({
|
||||
startIndex: child.startIndex,
|
||||
endIndex: child.endIndex,
|
||||
groupable: label === 'Class' && FIELD_LIKE_MEMBER_TYPES.has(child.type),
|
||||
});
|
||||
}
|
||||
|
||||
if (members.length === 0) return [];
|
||||
|
||||
const grouped: Array<{ startIndex: number; endIndex: number }> = [];
|
||||
let current = members[0];
|
||||
|
||||
for (let i = 1; i < members.length; i++) {
|
||||
const next = members[i];
|
||||
if (current.groupable && next.groupable) {
|
||||
current = {
|
||||
startIndex: current.startIndex,
|
||||
endIndex: next.endIndex,
|
||||
groupable: true,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
grouped.push({ startIndex: current.startIndex, endIndex: current.endIndex });
|
||||
current = next;
|
||||
}
|
||||
|
||||
grouped.push({ startIndex: current.startIndex, endIndex: current.endIndex });
|
||||
return grouped;
|
||||
};
|
||||
@@ -157,6 +157,11 @@ export const initEmbedder = async (
|
||||
try {
|
||||
// Configure transformers.js environment
|
||||
env.allowLocalModels = false;
|
||||
// Default cache to user-writable location. transformers.js defaults to
|
||||
// ./node_modules/.cache inside its own install dir, which is unwritable
|
||||
// when gitnexus is installed globally (e.g. /usr/lib/node_modules/).
|
||||
// Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface.
|
||||
env.cacheDir = process.env.HF_HOME ?? `${process.env.HOME}/.cache/huggingface`;
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
if (isDev) {
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
*
|
||||
* Orchestrates the background embedding process:
|
||||
* 1. Query embeddable nodes from LadybugDB
|
||||
* 2. Generate text representations
|
||||
* 3. Batch embed using transformers.js
|
||||
* 4. Update LadybugDB with embeddings
|
||||
* 2. Generate text representations with enriched metadata
|
||||
* 3. Chunk long nodes, batch embed
|
||||
* 4. Update LadybugDB with chunk-aware embeddings
|
||||
* 5. Create vector index for semantic search
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import {
|
||||
initEmbedder,
|
||||
embedBatch,
|
||||
@@ -16,19 +17,54 @@ import {
|
||||
embeddingToArray,
|
||||
isEmbedderReady,
|
||||
} from './embedder.js';
|
||||
import { generateBatchEmbeddingTexts } from './text-generator.js';
|
||||
import { generateEmbeddingText } from './text-generator.js';
|
||||
import { chunkNode, characterChunk } from './chunker.js';
|
||||
import { extractStructuralNames } from './structural-extractor.js';
|
||||
import {
|
||||
type EmbeddingProgress,
|
||||
type EmbeddingConfig,
|
||||
type EmbeddableNode,
|
||||
type SemanticSearchResult,
|
||||
type ModelProgress,
|
||||
type EmbeddingContext,
|
||||
DEFAULT_EMBEDDING_CONFIG,
|
||||
EMBEDDABLE_LABELS,
|
||||
isShortLabel,
|
||||
LABELS_WITH_EXPORTED,
|
||||
STRUCTURAL_LABELS,
|
||||
collectBestChunks,
|
||||
} from './types.js';
|
||||
import {
|
||||
EMBEDDING_TABLE_NAME,
|
||||
EMBEDDING_INDEX_NAME,
|
||||
CREATE_VECTOR_INDEX_QUERY,
|
||||
STALE_HASH_SENTINEL,
|
||||
} from '../lbug/schema.js';
|
||||
import { loadVectorExtension } from '../lbug/lbug-adapter.js';
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
* Compute a stable content fingerprint for an embeddable node.
|
||||
* Used to detect when the underlying text has changed so stale vectors
|
||||
* can be replaced (DELETE-then-INSERT, the Kuzu-sanctioned pattern for
|
||||
* vector-indexed rows).
|
||||
*/
|
||||
export const contentHashForNode = (
|
||||
node: EmbeddableNode,
|
||||
config: Partial<EmbeddingConfig> = {},
|
||||
): string => {
|
||||
// Hash must be deterministic across runs, so exclude methodNames/fieldNames
|
||||
// which are populated during the batch loop via AST extraction.
|
||||
// Using only node.content ensures the hash stays stable.
|
||||
const text = generateEmbeddingText(
|
||||
{ ...node, methodNames: undefined, fieldNames: undefined },
|
||||
node.content,
|
||||
config,
|
||||
);
|
||||
return createHash('sha1').update(text).digest('hex');
|
||||
};
|
||||
|
||||
/**
|
||||
* Progress callback type
|
||||
*/
|
||||
@@ -36,37 +72,50 @@ export type EmbeddingProgressCallback = (progress: EmbeddingProgress) => void;
|
||||
|
||||
/**
|
||||
* Query all embeddable nodes from LadybugDB
|
||||
* Uses table-specific queries (File has different schema than code elements)
|
||||
* Uses table-specific queries for different label types
|
||||
*/
|
||||
const queryEmbeddableNodes = async (
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
): Promise<EmbeddableNode[]> => {
|
||||
const allNodes: EmbeddableNode[] = [];
|
||||
|
||||
// Query each embeddable table with table-specific columns
|
||||
for (const label of EMBEDDABLE_LABELS) {
|
||||
try {
|
||||
let query: string;
|
||||
|
||||
if (label === 'File') {
|
||||
// File nodes don't have startLine/endLine
|
||||
if (label === 'Method') {
|
||||
// Method has parameterCount and returnType
|
||||
query = `
|
||||
MATCH (n:File)
|
||||
RETURN n.id AS id, n.name AS name, 'File' AS label,
|
||||
n.filePath AS filePath, n.content AS content
|
||||
MATCH (n:Method)
|
||||
RETURN n.id AS id, n.name AS name, 'Method' AS label,
|
||||
n.filePath AS filePath, n.content AS content,
|
||||
n.startLine AS startLine, n.endLine AS endLine,
|
||||
n.isExported AS isExported, n.description AS description,
|
||||
n.parameterCount AS parameterCount, n.returnType AS returnType
|
||||
`;
|
||||
} else if (LABELS_WITH_EXPORTED.has(label)) {
|
||||
// Function, Class, Interface have isExported and description
|
||||
query = `
|
||||
MATCH (n:\`${label}\`)
|
||||
RETURN n.id AS id, n.name AS name, '${label}' AS label,
|
||||
n.filePath AS filePath, n.content AS content,
|
||||
n.startLine AS startLine, n.endLine AS endLine,
|
||||
n.isExported AS isExported, n.description AS description
|
||||
`;
|
||||
} else {
|
||||
// Code elements have startLine/endLine
|
||||
// Multi-language tables (Struct, Enum, etc.) — have description but no isExported
|
||||
query = `
|
||||
MATCH (n:${label})
|
||||
RETURN n.id AS id, n.name AS name, '${label}' AS label,
|
||||
MATCH (n:\`${label}\`)
|
||||
RETURN n.id AS id, n.name AS name, '${label}' AS label,
|
||||
n.filePath AS filePath, n.content AS content,
|
||||
n.startLine AS startLine, n.endLine AS endLine
|
||||
n.startLine AS startLine, n.endLine AS endLine,
|
||||
n.description AS description
|
||||
`;
|
||||
}
|
||||
|
||||
const rows = await executeQuery(query);
|
||||
for (const row of rows) {
|
||||
const hasExportedColumn = label === 'Method' || LABELS_WITH_EXPORTED.has(label);
|
||||
allNodes.push({
|
||||
id: row.id ?? row[0],
|
||||
name: row.name ?? row[1],
|
||||
@@ -75,10 +124,17 @@ const queryEmbeddableNodes = async (
|
||||
content: row.content ?? row[4] ?? '',
|
||||
startLine: row.startLine ?? row[5],
|
||||
endLine: row.endLine ?? row[6],
|
||||
isExported: hasExportedColumn ? (row.isExported ?? row[7]) : undefined,
|
||||
description: row.description ?? (hasExportedColumn ? row[8] : row[7]),
|
||||
...(label === 'Method'
|
||||
? {
|
||||
parameterCount: row.parameterCount ?? row[9],
|
||||
returnType: row.returnType ?? row[10],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Table might not exist or be empty, continue
|
||||
if (isDev) {
|
||||
console.warn(`Query for ${label} nodes failed:`, error);
|
||||
}
|
||||
@@ -89,52 +145,52 @@ const queryEmbeddableNodes = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Batch INSERT embeddings into separate CodeEmbedding table
|
||||
* Using a separate lightweight table avoids copy-on-write overhead
|
||||
* that occurs when UPDATEing nodes with large content fields
|
||||
* Batch INSERT chunk-aware embeddings into CodeEmbedding table
|
||||
*/
|
||||
const batchInsertEmbeddings = async (
|
||||
export const batchInsertEmbeddings = async (
|
||||
executeWithReusedStatement: (
|
||||
cypher: string,
|
||||
paramsList: Array<Record<string, any>>,
|
||||
) => Promise<void>,
|
||||
updates: Array<{ id: string; embedding: number[] }>,
|
||||
updates: Array<{
|
||||
nodeId: string;
|
||||
chunkIndex: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
embedding: number[];
|
||||
contentHash?: string;
|
||||
}>,
|
||||
): Promise<void> => {
|
||||
// MERGE instead of CREATE — idempotent, handles concurrent analyzes and partial prior runs
|
||||
const cypher = `MERGE (e:CodeEmbedding {nodeId: $nodeId}) SET e.embedding = $embedding`;
|
||||
const paramsList = updates.map((u) => ({ nodeId: u.id, embedding: u.embedding }));
|
||||
const cypher = `CREATE (e:${EMBEDDING_TABLE_NAME} {id: $id, nodeId: $nodeId, chunkIndex: $chunkIndex, startLine: $startLine, endLine: $endLine, embedding: $embedding, contentHash: $contentHash})`;
|
||||
const paramsList = updates.map((u) => ({
|
||||
id: `${u.nodeId}:${u.chunkIndex}`,
|
||||
nodeId: u.nodeId,
|
||||
chunkIndex: u.chunkIndex,
|
||||
startLine: u.startLine,
|
||||
endLine: u.endLine,
|
||||
embedding: u.embedding,
|
||||
contentHash: u.contentHash ?? STALE_HASH_SENTINEL,
|
||||
}));
|
||||
await executeWithReusedStatement(cypher, paramsList);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the vector index for semantic search
|
||||
* Now indexes the separate CodeEmbedding table
|
||||
*/
|
||||
let vectorExtensionLoaded = false;
|
||||
|
||||
* Now indexes the separate CodeEmbedding table.
|
||||
* Delegates extension loading to lbug-adapter's loadVectorExtension(),
|
||||
* which owns the VECTOR extension lifecycle and state tracking.
|
||||
|
||||
*/
|
||||
const createVectorIndex = async (
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
): Promise<void> => {
|
||||
// LadybugDB v0.15+ requires explicit VECTOR extension loading (once per session)
|
||||
if (!vectorExtensionLoaded) {
|
||||
try {
|
||||
await executeQuery('INSTALL VECTOR');
|
||||
await executeQuery('LOAD EXTENSION VECTOR');
|
||||
vectorExtensionLoaded = true;
|
||||
} catch {
|
||||
// Extension may already be loaded — CREATE_VECTOR_INDEX will fail clearly if not
|
||||
vectorExtensionLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
const cypher = `
|
||||
CALL CREATE_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', 'embedding', metric := 'cosine')
|
||||
`;
|
||||
// Delegate to the adapter which tracks loaded state and handles DB reconnect resets
|
||||
await loadVectorExtension();
|
||||
|
||||
try {
|
||||
await executeQuery(cypher);
|
||||
await executeQuery(CREATE_VECTOR_INDEX_QUERY);
|
||||
} catch (error) {
|
||||
// Index might already exist
|
||||
if (isDev) {
|
||||
console.warn('Vector index creation warning:', error);
|
||||
}
|
||||
@@ -149,6 +205,11 @@ const createVectorIndex = async (
|
||||
* @param onProgress - Callback for progress updates
|
||||
* @param config - Optional configuration override
|
||||
* @param skipNodeIds - Optional set of node IDs that already have embeddings (incremental mode)
|
||||
* @param context - Optional repo/server context for metadata enrichment
|
||||
* @param existingEmbeddings - Optional map of nodeId → contentHash for incremental mode.
|
||||
* Nodes whose hash matches are skipped; nodes with a changed hash are DELETE'd
|
||||
* and re-embedded; nodes not in the map are embedded fresh.
|
||||
|
||||
*/
|
||||
export const runEmbeddingPipeline = async (
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
@@ -159,6 +220,8 @@ export const runEmbeddingPipeline = async (
|
||||
onProgress: EmbeddingProgressCallback,
|
||||
config: Partial<EmbeddingConfig> = {},
|
||||
skipNodeIds?: Set<string>,
|
||||
context?: EmbeddingContext,
|
||||
existingEmbeddings?: Map<string, string>,
|
||||
): Promise<void> => {
|
||||
const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config };
|
||||
|
||||
@@ -194,13 +257,65 @@ export const runEmbeddingPipeline = async (
|
||||
// Phase 2: Query embeddable nodes
|
||||
let nodes = await queryEmbeddableNodes(executeQuery);
|
||||
|
||||
// Incremental mode: filter out nodes that already have embeddings
|
||||
if (skipNodeIds && skipNodeIds.size > 0) {
|
||||
// Apply context metadata
|
||||
if (context?.repoName) {
|
||||
for (const node of nodes) {
|
||||
node.repoName = context.repoName;
|
||||
node.serverName = context.serverName;
|
||||
}
|
||||
}
|
||||
|
||||
// Incremental mode: compare content hashes, delete stale rows, skip fresh ones.
|
||||
// Computed hashes for stale nodes are cached so batchInsertEmbeddings can reuse them
|
||||
// (avoids double computation).
|
||||
const computedStaleHashes = new Map<string, string>();
|
||||
if (existingEmbeddings && existingEmbeddings.size > 0) {
|
||||
const beforeCount = nodes.length;
|
||||
nodes = nodes.filter((n) => !skipNodeIds.has(n.id));
|
||||
const staleNodeIds: string[] = [];
|
||||
nodes = nodes.filter((n) => {
|
||||
const existingHash = existingEmbeddings.get(n.id);
|
||||
if (existingHash === undefined) {
|
||||
// New node — needs embedding
|
||||
return true;
|
||||
}
|
||||
const currentHash = contentHashForNode(n, finalConfig);
|
||||
if (currentHash !== existingHash) {
|
||||
// Content changed — cache hash for reuse during insert, mark for DELETE + re-embed
|
||||
computedStaleHashes.set(n.id, currentHash);
|
||||
staleNodeIds.push(n.id);
|
||||
return true;
|
||||
}
|
||||
// Hash matches — skip (fresh); no need to cache hash for skipped nodes
|
||||
return false;
|
||||
});
|
||||
|
||||
// DELETE stale embedding rows so they can be re-inserted
|
||||
// (Kuzu forbids SET on vector-indexed properties; DELETE-then-INSERT is the sanctioned pattern)
|
||||
if (staleNodeIds.length > 0) {
|
||||
if (isDev) {
|
||||
console.log(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`);
|
||||
}
|
||||
try {
|
||||
await executeWithReusedStatement(
|
||||
`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) DELETE e`,
|
||||
staleNodeIds.map((nodeId) => ({ nodeId })),
|
||||
);
|
||||
} catch (err) {
|
||||
// "does not exist" = rows already gone — safe to proceed.
|
||||
// All other errors risk vector-index corruption (Kuzu requires DELETE-before-INSERT
|
||||
// for vector-indexed properties) — propagate so the pipeline aborts cleanly.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes('does not exist')) {
|
||||
throw new Error(
|
||||
`[embed] Failed to delete stale embedding rows — aborting to prevent vector-index corruption: ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
console.log(
|
||||
`📦 Incremental embeddings: ${beforeCount} total, ${skipNodeIds.size} cached, ${nodes.length} to embed`,
|
||||
`📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.length} stale, ${nodes.length} to embed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -212,6 +327,11 @@ export const runEmbeddingPipeline = async (
|
||||
}
|
||||
|
||||
if (totalNodes === 0) {
|
||||
// Ensure the vector index exists even when no new nodes need embedding.
|
||||
// A prior crash or first-time incremental run may have left CodeEmbedding
|
||||
// rows without ever reaching index creation.
|
||||
await createVectorIndex(executeQuery);
|
||||
|
||||
onProgress({
|
||||
phase: 'ready',
|
||||
percent: 100,
|
||||
@@ -221,10 +341,12 @@ export const runEmbeddingPipeline = async (
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 3: Batch embed nodes
|
||||
// Phase 3: Chunk + embed nodes
|
||||
const batchSize = finalConfig.batchSize;
|
||||
const totalBatches = Math.ceil(totalNodes / batchSize);
|
||||
const chunkSize = finalConfig.chunkSize;
|
||||
const overlap = finalConfig.overlap;
|
||||
let processedNodes = 0;
|
||||
let totalChunks = 0;
|
||||
|
||||
onProgress({
|
||||
phase: 'embedding',
|
||||
@@ -232,39 +354,116 @@ export const runEmbeddingPipeline = async (
|
||||
nodesProcessed: 0,
|
||||
totalNodes,
|
||||
currentBatch: 0,
|
||||
totalBatches,
|
||||
totalBatches: Math.ceil(totalNodes / batchSize),
|
||||
});
|
||||
|
||||
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
|
||||
const start = batchIndex * batchSize;
|
||||
const end = Math.min(start + batchSize, totalNodes);
|
||||
const batch = nodes.slice(start, end);
|
||||
// Process in batches of nodes
|
||||
for (let batchIndex = 0; batchIndex < totalNodes; batchIndex += batchSize) {
|
||||
const batch = nodes.slice(batchIndex, batchIndex + batchSize);
|
||||
|
||||
// Generate texts for this batch
|
||||
const texts = generateBatchEmbeddingTexts(batch, finalConfig);
|
||||
// Chunk each node and generate text
|
||||
const allTexts: string[] = [];
|
||||
const allUpdates: Array<{
|
||||
nodeId: string;
|
||||
chunkIndex: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
contentHash: string;
|
||||
}> = [];
|
||||
|
||||
// Embed the batch
|
||||
const embeddings = await embedBatch(texts);
|
||||
for (const node of batch) {
|
||||
const isShort = isShortLabel(node.label);
|
||||
const startLine = node.startLine ?? 0;
|
||||
const endLine = node.endLine ?? 0;
|
||||
|
||||
// Update LadybugDB with embeddings
|
||||
const updates = batch.map((node, i) => ({
|
||||
id: node.id,
|
||||
embedding: embeddingToArray(embeddings[i]),
|
||||
}));
|
||||
// Extract structural names for class-like nodes via AST extractors
|
||||
if (!isShort && STRUCTURAL_LABELS.has(node.label)) {
|
||||
try {
|
||||
const names = await extractStructuralNames(node.content, node.filePath);
|
||||
node.methodNames = names.methodNames;
|
||||
node.fieldNames = names.fieldNames;
|
||||
} catch {
|
||||
// AST extraction failed — names stay undefined, text-generator handles gracefully
|
||||
}
|
||||
}
|
||||
|
||||
await batchInsertEmbeddings(executeWithReusedStatement, updates);
|
||||
// Compute content hash once per node (re-use cached value for stale nodes)
|
||||
const hash = computedStaleHashes.get(node.id) ?? contentHashForNode(node, finalConfig);
|
||||
|
||||
let chunks: Array<{ text: string; chunkIndex: number; startLine: number; endLine: number }>;
|
||||
if (isShort) {
|
||||
chunks = [{ text: node.content, chunkIndex: 0, startLine, endLine }];
|
||||
} else {
|
||||
try {
|
||||
chunks = await chunkNode(
|
||||
node.label,
|
||||
node.content,
|
||||
node.filePath,
|
||||
startLine,
|
||||
endLine,
|
||||
chunkSize,
|
||||
overlap,
|
||||
);
|
||||
} catch (chunkErr) {
|
||||
if (isDev) {
|
||||
console.warn(
|
||||
`⚠️ AST chunking failed for ${node.label} "${node.name}" (${node.filePath}), falling back to character-based chunking:`,
|
||||
chunkErr,
|
||||
);
|
||||
}
|
||||
chunks = characterChunk(node.content, startLine, endLine, chunkSize, overlap);
|
||||
}
|
||||
}
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const text = generateEmbeddingText(node, chunk.text, finalConfig);
|
||||
allTexts.push(text);
|
||||
allUpdates.push({
|
||||
nodeId: node.id,
|
||||
chunkIndex: chunk.chunkIndex,
|
||||
startLine: chunk.startLine,
|
||||
endLine: chunk.endLine,
|
||||
contentHash: hash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Embed chunk texts in sub-batches to control memory
|
||||
const EMBED_SUB_BATCH = 8;
|
||||
for (let si = 0; si < allTexts.length; si += EMBED_SUB_BATCH) {
|
||||
const subTexts = allTexts.slice(si, si + EMBED_SUB_BATCH);
|
||||
const subUpdates = allUpdates.slice(si, si + EMBED_SUB_BATCH);
|
||||
|
||||
let embeddings: Float32Array[];
|
||||
try {
|
||||
embeddings = await embedBatch(subTexts);
|
||||
} catch (embedErr) {
|
||||
console.error(
|
||||
`❌ embedBatch failed for ${subTexts.length} texts (first: "${subTexts[0]?.substring(0, 80)}..."):`,
|
||||
embedErr,
|
||||
);
|
||||
throw embedErr;
|
||||
}
|
||||
|
||||
const dbUpdates = subUpdates.map((u, i) => ({
|
||||
...u,
|
||||
embedding: embeddingToArray(embeddings[i]),
|
||||
}));
|
||||
|
||||
await batchInsertEmbeddings(executeWithReusedStatement, dbUpdates);
|
||||
}
|
||||
|
||||
processedNodes += batch.length;
|
||||
totalChunks += allUpdates.length;
|
||||
|
||||
// Report progress (20-90% for embedding phase)
|
||||
const embeddingProgress = 20 + (processedNodes / totalNodes) * 70;
|
||||
onProgress({
|
||||
phase: 'embedding',
|
||||
percent: Math.round(embeddingProgress),
|
||||
nodesProcessed: processedNodes,
|
||||
totalNodes,
|
||||
currentBatch: batchIndex + 1,
|
||||
totalBatches,
|
||||
currentBatch: Math.floor(batchIndex / batchSize) + 1,
|
||||
totalBatches: Math.ceil(totalNodes / batchSize),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -282,7 +481,6 @@ export const runEmbeddingPipeline = async (
|
||||
|
||||
await createVectorIndex(executeQuery);
|
||||
|
||||
// Complete
|
||||
onProgress({
|
||||
phase: 'ready',
|
||||
percent: 100,
|
||||
@@ -291,7 +489,9 @@ export const runEmbeddingPipeline = async (
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
console.log('✅ Embedding pipeline complete!');
|
||||
console.log(
|
||||
`✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@@ -311,15 +511,7 @@ export const runEmbeddingPipeline = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform semantic search using the vector index
|
||||
*
|
||||
* Uses CodeEmbedding table and queries each node table to get metadata
|
||||
*
|
||||
* @param executeQuery - Function to execute Cypher queries
|
||||
* @param query - Search query text
|
||||
* @param k - Number of results to return (default: 10)
|
||||
* @param maxDistance - Maximum distance threshold (default: 0.5)
|
||||
* @returns Array of search results ordered by relevance
|
||||
* Perform semantic search using the vector index with chunk deduplication
|
||||
*/
|
||||
export const semanticSearch = async (
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
@@ -331,37 +523,46 @@ export const semanticSearch = async (
|
||||
throw new Error('Embedding model not initialized. Run embedding pipeline first.');
|
||||
}
|
||||
|
||||
// Embed the query
|
||||
const queryEmbedding = await embedText(query);
|
||||
const queryVec = embeddingToArray(queryEmbedding);
|
||||
const queryVecStr = `[${queryVec.join(',')}]`;
|
||||
|
||||
// Query the vector index on CodeEmbedding to get nodeIds and distances
|
||||
const vectorQuery = `
|
||||
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx',
|
||||
CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${k})
|
||||
YIELD node AS emb, distance
|
||||
WITH emb, distance
|
||||
WHERE distance < ${maxDistance}
|
||||
RETURN emb.nodeId AS nodeId, distance
|
||||
ORDER BY distance
|
||||
`;
|
||||
const bestChunks = await collectBestChunks(k, async (fetchLimit) => {
|
||||
const vectorQuery = `
|
||||
CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}',
|
||||
CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${fetchLimit})
|
||||
YIELD node AS emb, distance
|
||||
WITH emb, distance
|
||||
WHERE distance < ${maxDistance}
|
||||
RETURN emb.nodeId AS nodeId, emb.chunkIndex AS chunkIndex,
|
||||
emb.startLine AS startLine, emb.endLine AS endLine, distance
|
||||
ORDER BY distance
|
||||
`;
|
||||
|
||||
const embResults = await executeQuery(vectorQuery);
|
||||
const embResults = await executeQuery(vectorQuery);
|
||||
return embResults.map((row) => ({
|
||||
nodeId: row.nodeId ?? row[0],
|
||||
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
|
||||
startLine: row.startLine ?? row[2] ?? 0,
|
||||
endLine: row.endLine ?? row[3] ?? 0,
|
||||
distance: row.distance ?? row[4],
|
||||
}));
|
||||
});
|
||||
|
||||
if (embResults.length === 0) {
|
||||
if (bestChunks.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group results by label for batched metadata queries
|
||||
const byLabel = new Map<string, Array<{ nodeId: string; distance: number }>>();
|
||||
for (const embRow of embResults) {
|
||||
const nodeId = embRow.nodeId ?? embRow[0];
|
||||
const distance = embRow.distance ?? embRow[1];
|
||||
const byLabel = new Map<
|
||||
string,
|
||||
Array<{ nodeId: string; distance: number } & Record<string, any>>
|
||||
>();
|
||||
for (const [nodeId, chunk] of Array.from(bestChunks.entries()).slice(0, k)) {
|
||||
const labelEndIdx = nodeId.indexOf(':');
|
||||
const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown';
|
||||
if (!byLabel.has(label)) byLabel.set(label, []);
|
||||
byLabel.get(label)!.push({ nodeId, distance });
|
||||
byLabel.get(label)!.push({ nodeId, ...chunk });
|
||||
}
|
||||
|
||||
// Batch-fetch metadata per label
|
||||
@@ -370,19 +571,11 @@ export const semanticSearch = async (
|
||||
for (const [label, items] of byLabel) {
|
||||
const idList = items.map((i) => `'${i.nodeId.replace(/'/g, "''")}'`).join(', ');
|
||||
try {
|
||||
let nodeQuery: string;
|
||||
if (label === 'File') {
|
||||
nodeQuery = `
|
||||
MATCH (n:File) WHERE n.id IN [${idList}]
|
||||
RETURN n.id AS id, n.name AS name, n.filePath AS filePath
|
||||
`;
|
||||
} else {
|
||||
nodeQuery = `
|
||||
MATCH (n:${label}) WHERE n.id IN [${idList}]
|
||||
RETURN n.id AS id, n.name AS name, n.filePath AS filePath,
|
||||
n.startLine AS startLine, n.endLine AS endLine
|
||||
`;
|
||||
}
|
||||
const nodeQuery = `
|
||||
MATCH (n:\`${label}\`) WHERE n.id IN [${idList}]
|
||||
RETURN n.id AS id, n.name AS name, n.filePath AS filePath,
|
||||
n.startLine AS startLine, n.endLine AS endLine
|
||||
`;
|
||||
const nodeRows = await executeQuery(nodeQuery);
|
||||
const rowMap = new Map<string, any>();
|
||||
for (const row of nodeRows) {
|
||||
@@ -398,8 +591,8 @@ export const semanticSearch = async (
|
||||
label,
|
||||
filePath: nodeRow.filePath ?? nodeRow[2] ?? '',
|
||||
distance: item.distance,
|
||||
startLine: label !== 'File' ? (nodeRow.startLine ?? nodeRow[3]) : undefined,
|
||||
endLine: label !== 'File' ? (nodeRow.endLine ?? nodeRow[4]) : undefined,
|
||||
startLine: item.startLine,
|
||||
endLine: item.endLine,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -408,7 +601,6 @@ export const semanticSearch = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Re-sort by distance since batch queries may have mixed order
|
||||
results.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
return results;
|
||||
@@ -416,16 +608,6 @@ export const semanticSearch = async (
|
||||
|
||||
/**
|
||||
* Semantic search with graph expansion (flattened results)
|
||||
*
|
||||
* Note: With multi-table schema, graph traversal is simplified.
|
||||
* Returns semantic matches with their metadata.
|
||||
* For full graph traversal, use execute_vector_cypher tool directly.
|
||||
*
|
||||
* @param executeQuery - Function to execute Cypher queries
|
||||
* @param query - Search query text
|
||||
* @param k - Number of initial semantic matches (default: 5)
|
||||
* @param _hops - Unused (kept for API compatibility).
|
||||
* @returns Semantic matches with metadata
|
||||
*/
|
||||
export const semanticSearchWithContext = async (
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
@@ -433,8 +615,6 @@ export const semanticSearchWithContext = async (
|
||||
k: number = 5,
|
||||
_hops: number = 1,
|
||||
): Promise<any[]> => {
|
||||
// For multi-table schema, just return semantic search results
|
||||
// Graph traversal is complex with separate tables - use execute_vector_cypher instead
|
||||
const results = await semanticSearch(executeQuery, query, k, 0.5);
|
||||
|
||||
return results.map((r) => ({
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface ResolvedLineRange {
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
}
|
||||
|
||||
export const buildLineIndex = (content: string): Int32Array => {
|
||||
const offsets: number[] = [0];
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (content.charCodeAt(i) === 10) offsets.push(i + 1);
|
||||
}
|
||||
return new Int32Array(offsets);
|
||||
};
|
||||
|
||||
const clampOffset = (lineOffsets: Int32Array, charOffset: number): number => {
|
||||
if (lineOffsets.length === 0) return 0;
|
||||
const maxOffset = lineOffsets[lineOffsets.length - 1];
|
||||
if (charOffset < 0) return 0;
|
||||
if (charOffset > maxOffset) return maxOffset;
|
||||
return charOffset;
|
||||
};
|
||||
|
||||
export const lineFromOffset = (lineOffsets: Int32Array, charOffset: number): number => {
|
||||
if (lineOffsets.length === 0) return 0;
|
||||
|
||||
const clamped = clampOffset(lineOffsets, charOffset);
|
||||
let lo = 0;
|
||||
let hi = lineOffsets.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (lineOffsets[mid] <= clamped) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
};
|
||||
|
||||
export const resolveChunkLines = (
|
||||
lineOffsets: Int32Array,
|
||||
startOffset: number,
|
||||
endOffset: number,
|
||||
baseStartLine: number,
|
||||
): ResolvedLineRange => {
|
||||
const relativeStartLine = lineFromOffset(lineOffsets, startOffset);
|
||||
const effectiveEndOffset = endOffset > startOffset ? endOffset - 1 : startOffset;
|
||||
const relativeEndLine = lineFromOffset(lineOffsets, effectiveEndOffset);
|
||||
|
||||
return {
|
||||
startLine: baseStartLine + relativeStartLine,
|
||||
endLine: baseStartLine + relativeEndLine,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Server Mapping Configuration
|
||||
*
|
||||
* Reads ~/.gitnexus/server-mapping.json to map repo names to service names.
|
||||
* Used in embedding text to enrich metadata with microservice context.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const MAPPING_FILE = path.join(os.homedir(), '.gitnexus', 'server-mapping.json');
|
||||
|
||||
let cachedMapping: Record<string, string> | null = null;
|
||||
|
||||
/**
|
||||
* Read the server mapping file and return the serverName for a given repoName.
|
||||
* Returns undefined if no mapping exists.
|
||||
*/
|
||||
export const readServerMapping = async (repoName: string): Promise<string | undefined> => {
|
||||
try {
|
||||
if (!cachedMapping) {
|
||||
const raw = await fs.readFile(MAPPING_FILE, 'utf-8');
|
||||
cachedMapping = JSON.parse(raw);
|
||||
}
|
||||
return cachedMapping[repoName];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the cached mapping (useful for testing or after file changes)
|
||||
*/
|
||||
export const clearServerMappingCache = (): void => {
|
||||
cachedMapping = null;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Structural Extractor Module
|
||||
*
|
||||
* Reuses ingestion pipeline's AST-based MethodExtractor / FieldExtractor
|
||||
* to extract method and field names for embedding text generation.
|
||||
*/
|
||||
|
||||
import { getProviderForFile } from '../ingestion/languages/index.js';
|
||||
import type { MethodExtractorContext, ExtractedMethods } from '../ingestion/method-types.js';
|
||||
import type { FieldExtractorContext, ExtractedFields } from '../ingestion/field-types.js';
|
||||
import type { LanguageProvider } from '../ingestion/language-provider.js';
|
||||
import { buildTypeEnv } from '../ingestion/type-env.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { ensureAndParse, findDeclarationNode } from './ast-utils.js';
|
||||
|
||||
export interface StructuralNames {
|
||||
methodNames: string[];
|
||||
fieldNames: string[];
|
||||
}
|
||||
|
||||
const NOOP_SYMBOL_TABLE = {
|
||||
lookupExactAll: () => [],
|
||||
lookupExact: () => undefined,
|
||||
lookupExactFull: () => undefined,
|
||||
} as any;
|
||||
|
||||
/**
|
||||
* Extract method and field names from a class/struct/interface node
|
||||
* using the ingestion pipeline's AST extractors.
|
||||
*/
|
||||
export const extractStructuralNames = async (
|
||||
content: string,
|
||||
filePath: string,
|
||||
): Promise<StructuralNames> => {
|
||||
const provider = getProviderForFile(filePath);
|
||||
if (!provider) return { methodNames: [], fieldNames: [] };
|
||||
|
||||
const tree = await ensureAndParse(content, filePath);
|
||||
if (!tree) return { methodNames: [], fieldNames: [] };
|
||||
|
||||
// Parse node.content (a snippet) — find declaration directly, not by range
|
||||
const classNode = findDeclarationNode(tree.rootNode);
|
||||
if (!classNode) return { methodNames: [], fieldNames: [] };
|
||||
|
||||
const language = provider.id;
|
||||
|
||||
const methodNames = extractMethodNames(classNode, provider, filePath, language);
|
||||
const fieldNames = extractFieldNames(classNode, provider, tree, filePath, language);
|
||||
|
||||
return { methodNames, fieldNames };
|
||||
};
|
||||
|
||||
function extractMethodNames(
|
||||
classNode: any,
|
||||
provider: LanguageProvider,
|
||||
filePath: string,
|
||||
language: SupportedLanguages,
|
||||
): string[] {
|
||||
if (!provider.methodExtractor) return [];
|
||||
|
||||
const context: MethodExtractorContext = { filePath, language };
|
||||
const result: ExtractedMethods | null = provider.methodExtractor.extract(classNode, context);
|
||||
if (!result?.methods?.length) return [];
|
||||
|
||||
return result.methods.map((m) => m.name);
|
||||
}
|
||||
|
||||
function extractFieldNames(
|
||||
classNode: any,
|
||||
provider: LanguageProvider,
|
||||
tree: any,
|
||||
filePath: string,
|
||||
language: SupportedLanguages,
|
||||
): string[] {
|
||||
if (!provider.fieldExtractor) return [];
|
||||
|
||||
const typeEnv = buildTypeEnv(tree, language);
|
||||
|
||||
const context: FieldExtractorContext = {
|
||||
typeEnv,
|
||||
symbolTable: NOOP_SYMBOL_TABLE,
|
||||
filePath,
|
||||
language,
|
||||
};
|
||||
const result: ExtractedFields | null = provider.fieldExtractor.extract(classNode, context);
|
||||
if (!result?.fields?.length) return [];
|
||||
|
||||
return result.fields.map((f) => f.name);
|
||||
}
|
||||
@@ -1,206 +1,253 @@
|
||||
/**
|
||||
* Text Generator Module
|
||||
*
|
||||
* Pure functions to generate embedding text from code nodes.
|
||||
* Combines node metadata with code snippets for semantic matching.
|
||||
* Generates enriched embedding text from code nodes with metadata.
|
||||
* Supports chunkable labels (Function/Method with AST chunking),
|
||||
* Class-specific structural text, and short-node direct embed.
|
||||
*
|
||||
* Method/field names for Class nodes are extracted by the ingestion
|
||||
* pipeline's AST extractors and passed via node.methodNames/node.fieldNames.
|
||||
*/
|
||||
|
||||
import type { EmbeddableNode, EmbeddingConfig } from './types.js';
|
||||
import { DEFAULT_EMBEDDING_CONFIG } from './types.js';
|
||||
import { DEFAULT_EMBEDDING_CONFIG, isShortLabel } from './types.js';
|
||||
|
||||
/**
|
||||
* Extract the filename from a file path
|
||||
* Truncate description to max length at sentence/word boundary
|
||||
*/
|
||||
const getFileName = (filePath: string): string => {
|
||||
const parts = filePath.split('/');
|
||||
return parts[parts.length - 1] || filePath;
|
||||
};
|
||||
const truncateDescription = (text: string, maxLength: number): string => {
|
||||
if (text.length <= maxLength) return text;
|
||||
|
||||
/**
|
||||
* Extract the directory path from a file path
|
||||
*/
|
||||
const getDirectory = (filePath: string): string => {
|
||||
const parts = filePath.split('/');
|
||||
parts.pop();
|
||||
return parts.join('/') || '';
|
||||
};
|
||||
const truncated = text.slice(0, maxLength);
|
||||
|
||||
/**
|
||||
* Truncate content to max length, preserving word boundaries
|
||||
*/
|
||||
const truncateContent = (content: string, maxLength: number): string => {
|
||||
if (content.length <= maxLength) {
|
||||
return content;
|
||||
// Try sentence boundary (. ! ?)
|
||||
const sentenceEnd = Math.max(
|
||||
truncated.lastIndexOf('. '),
|
||||
truncated.lastIndexOf('! '),
|
||||
truncated.lastIndexOf('? '),
|
||||
);
|
||||
if (sentenceEnd > maxLength * 0.5) {
|
||||
return truncated.slice(0, sentenceEnd + 1);
|
||||
}
|
||||
|
||||
// Find last space before maxLength to avoid cutting words
|
||||
const truncated = content.slice(0, maxLength);
|
||||
// Try word boundary
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
|
||||
if (lastSpace > maxLength * 0.8) {
|
||||
return truncated.slice(0, lastSpace) + '...';
|
||||
if (lastSpace > maxLength * 0.5) {
|
||||
return truncated.slice(0, lastSpace);
|
||||
}
|
||||
|
||||
return truncated + '...';
|
||||
return truncated;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean code content for embedding
|
||||
* Removes excessive whitespace while preserving structure
|
||||
*/
|
||||
const cleanContent = (content: string): string => {
|
||||
return (
|
||||
content
|
||||
// Normalize line endings
|
||||
.replace(/\r\n/g, '\n')
|
||||
// Remove excessive blank lines (more than 2)
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
// Trim each line
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.join('\n')
|
||||
.trim()
|
||||
);
|
||||
return content
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.join('\n')
|
||||
.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate embedding text for a Function node
|
||||
* Build metadata header for a node
|
||||
*/
|
||||
const generateFunctionText = (node: EmbeddableNode, maxSnippetLength: number): string => {
|
||||
const parts: string[] = [`Function: ${node.name}`, `File: ${getFileName(node.filePath)}`];
|
||||
const buildMetadataHeader = (node: EmbeddableNode, config: Partial<EmbeddingConfig>): string => {
|
||||
const parts: string[] = [];
|
||||
|
||||
const dir = getDirectory(node.filePath);
|
||||
if (dir) {
|
||||
parts.push(`Directory: ${dir}`);
|
||||
// Label + name
|
||||
parts.push(`${node.label}: ${node.name}`);
|
||||
|
||||
// Repo name
|
||||
if (node.repoName) {
|
||||
parts.push(`Repo: ${node.repoName}`);
|
||||
}
|
||||
|
||||
if (node.content) {
|
||||
const cleanedContent = cleanContent(node.content);
|
||||
const snippet = truncateContent(cleanedContent, maxSnippetLength);
|
||||
parts.push('', snippet);
|
||||
// Server name (optional)
|
||||
if (node.serverName) {
|
||||
parts.push(`Server: ${node.serverName}`);
|
||||
}
|
||||
|
||||
// Full file path
|
||||
parts.push(`Path: ${node.filePath}`);
|
||||
|
||||
// Export status
|
||||
if (node.isExported !== undefined) {
|
||||
parts.push(`Export: ${node.isExported}`);
|
||||
}
|
||||
|
||||
// Description (truncated)
|
||||
if (node.description) {
|
||||
const maxLen = config.maxDescriptionLength ?? DEFAULT_EMBEDDING_CONFIG.maxDescriptionLength;
|
||||
const truncated = truncateDescription(node.description, maxLen);
|
||||
if (truncated) {
|
||||
parts.push(truncated);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate embedding text for a Class node
|
||||
*/
|
||||
const generateClassText = (node: EmbeddableNode, maxSnippetLength: number): string => {
|
||||
const parts: string[] = [`Class: ${node.name}`, `File: ${getFileName(node.filePath)}`];
|
||||
const generateCodeBodyText = (
|
||||
node: EmbeddableNode,
|
||||
codeBody: string,
|
||||
config: Partial<EmbeddingConfig>,
|
||||
): string => {
|
||||
const header = buildMetadataHeader(node, config);
|
||||
const cleaned = cleanContent(codeBody);
|
||||
return `${header}\n\n${cleaned}`;
|
||||
};
|
||||
|
||||
const dir = getDirectory(node.filePath);
|
||||
if (dir) {
|
||||
parts.push(`Directory: ${dir}`);
|
||||
/**
|
||||
* Generate embedding text for Class nodes
|
||||
* Signature + properties + method name list only (no method bodies)
|
||||
* Method/field names come from AST extractors via node.methodNames/node.fieldNames.
|
||||
*/
|
||||
const generateClassText = (
|
||||
node: EmbeddableNode,
|
||||
codeBody: string,
|
||||
config: Partial<EmbeddingConfig>,
|
||||
): string => {
|
||||
return generateStructuralTypeText(node, codeBody, config);
|
||||
};
|
||||
|
||||
const generateStructuralTypeText = (
|
||||
node: EmbeddableNode,
|
||||
codeBody: string,
|
||||
config: Partial<EmbeddingConfig>,
|
||||
): string => {
|
||||
const header = buildMetadataHeader(node, config);
|
||||
const parts: string[] = [header];
|
||||
|
||||
if (node.methodNames?.length) {
|
||||
parts.push(`Methods: ${node.methodNames.join(', ')}`);
|
||||
}
|
||||
if (node.fieldNames?.length) {
|
||||
parts.push(`Properties: ${node.fieldNames.join(', ')}`);
|
||||
}
|
||||
|
||||
if (node.content) {
|
||||
const cleanedContent = cleanContent(node.content);
|
||||
const snippet = truncateContent(cleanedContent, maxSnippetLength);
|
||||
parts.push('', snippet);
|
||||
const declarationOnly = extractDeclarationOnly(cleanContent(node.content));
|
||||
if (declarationOnly) {
|
||||
parts.push('', declarationOnly);
|
||||
}
|
||||
|
||||
const cleanedChunk = cleanContent(codeBody);
|
||||
if (cleanedChunk && cleanedChunk !== cleanContent(node.content)) {
|
||||
parts.push('', cleanedChunk);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate embedding text for a Method node
|
||||
*/
|
||||
const generateMethodText = (node: EmbeddableNode, maxSnippetLength: number): string => {
|
||||
const parts: string[] = [`Method: ${node.name}`, `File: ${getFileName(node.filePath)}`];
|
||||
|
||||
const dir = getDirectory(node.filePath);
|
||||
if (dir) {
|
||||
parts.push(`Directory: ${dir}`);
|
||||
}
|
||||
|
||||
if (node.content) {
|
||||
const cleanedContent = cleanContent(node.content);
|
||||
const snippet = truncateContent(cleanedContent, maxSnippetLength);
|
||||
parts.push('', snippet);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
};
|
||||
const DECL_START_RE =
|
||||
/^(?:(?:export|pub|data|abstract)\s+)*(?:type\s+\w+\s+struct|(?:class|struct|enum|interface)\s)/;
|
||||
|
||||
/**
|
||||
* Generate embedding text for an Interface node
|
||||
* Extract class/interface/struct declaration lines, skipping method bodies.
|
||||
* - Brace-based languages: detects method signatures (lines with `(` and `{`)
|
||||
* and skips until depth returns to class body level.
|
||||
* - Non-brace languages (Python/Ruby): returns empty string (patterns handle extraction).
|
||||
*/
|
||||
const generateInterfaceText = (node: EmbeddableNode, maxSnippetLength: number): string => {
|
||||
const parts: string[] = [`Interface: ${node.name}`, `File: ${getFileName(node.filePath)}`];
|
||||
export const extractDeclarationOnly = (content: string): string => {
|
||||
const lines = content.split('\n');
|
||||
const declLines: string[] = [];
|
||||
let depth = 0;
|
||||
let started = false;
|
||||
let classDepth = 0;
|
||||
let skipDepth = 0;
|
||||
|
||||
const dir = getDirectory(node.filePath);
|
||||
if (dir) {
|
||||
parts.push(`Directory: ${dir}`);
|
||||
for (const [idx, line] of lines.entries()) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!started) {
|
||||
if (DECL_START_RE.test(trimmed)) {
|
||||
// Non-brace language check: current line or next 3 lines must have `{`
|
||||
const nextLines = lines.slice(idx + 1, idx + 4);
|
||||
if (!trimmed.includes('{') && !nextLines.some((l) => l.includes('{'))) {
|
||||
return '';
|
||||
}
|
||||
started = true;
|
||||
declLines.push(trimmed);
|
||||
for (const ch of trimmed) {
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
}
|
||||
if (depth > 0) classDepth = depth;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Always update depth (even when skipping)
|
||||
const opens = (trimmed.match(/{/g) || []).length;
|
||||
const closes = (trimmed.match(/}/g) || []).length;
|
||||
const prevDepth = depth;
|
||||
depth += opens - closes;
|
||||
|
||||
if (skipDepth > 0) {
|
||||
if (depth <= classDepth) {
|
||||
skipDepth = 0;
|
||||
// Closing brace of class
|
||||
if (depth <= 0) {
|
||||
declLines.push(trimmed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect method signature: line has both `(` and `{` and goes deeper than class body
|
||||
const hasParens = trimmed.includes('(');
|
||||
const hasOpenBrace = opens > 0;
|
||||
if (hasParens && hasOpenBrace && prevDepth + opens > classDepth) {
|
||||
if (opens === closes && trimmed.endsWith(';')) {
|
||||
// Property with function/object initializer like `config = { timeout: 5000 };` — keep
|
||||
declLines.push(trimmed);
|
||||
}
|
||||
// else: single-line or multi-line method — skip entirely
|
||||
if (opens !== closes) {
|
||||
skipDepth = classDepth;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
declLines.push(trimmed);
|
||||
|
||||
if (depth <= 0 && declLines.length > 1) break;
|
||||
}
|
||||
|
||||
if (node.content) {
|
||||
const cleanedContent = cleanContent(node.content);
|
||||
const snippet = truncateContent(cleanedContent, maxSnippetLength);
|
||||
parts.push('', snippet);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate embedding text for a File node
|
||||
* Uses file name and first N characters of content
|
||||
*/
|
||||
const generateFileText = (node: EmbeddableNode, maxSnippetLength: number): string => {
|
||||
const parts: string[] = [`File: ${node.name}`, `Path: ${node.filePath}`];
|
||||
|
||||
if (node.content) {
|
||||
const cleanedContent = cleanContent(node.content);
|
||||
// For files, use a shorter snippet since they can be very long
|
||||
const snippet = truncateContent(cleanedContent, Math.min(maxSnippetLength, 300));
|
||||
parts.push('', snippet);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
return declLines.join('\n').trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate embedding text for any embeddable node
|
||||
* Dispatches to the appropriate generator based on node label
|
||||
*
|
||||
* @param node - The node to generate text for
|
||||
* @param config - Optional configuration for max snippet length
|
||||
* @returns Text suitable for embedding
|
||||
*/
|
||||
export const generateEmbeddingText = (
|
||||
node: EmbeddableNode,
|
||||
codeBody: string,
|
||||
config: Partial<EmbeddingConfig> = {},
|
||||
): string => {
|
||||
const maxSnippetLength = config.maxSnippetLength ?? DEFAULT_EMBEDDING_CONFIG.maxSnippetLength;
|
||||
|
||||
switch (node.label) {
|
||||
case 'Function':
|
||||
return generateFunctionText(node, maxSnippetLength);
|
||||
case 'Class':
|
||||
return generateClassText(node, maxSnippetLength);
|
||||
case 'Method':
|
||||
return generateMethodText(node, maxSnippetLength);
|
||||
case 'Interface':
|
||||
return generateInterfaceText(node, maxSnippetLength);
|
||||
case 'File':
|
||||
return generateFileText(node, maxSnippetLength);
|
||||
default:
|
||||
// Fallback for any other embeddable type
|
||||
return `${node.label}: ${node.name}\nPath: ${node.filePath}`;
|
||||
if (isShortLabel(node.label)) {
|
||||
const header = buildMetadataHeader(node, config);
|
||||
const cleaned = cleanContent(node.content);
|
||||
return `${header}\n\n${cleaned}`;
|
||||
}
|
||||
|
||||
if (node.label === 'Class') {
|
||||
return generateClassText(node, codeBody, config);
|
||||
}
|
||||
|
||||
if (node.label === 'Interface') {
|
||||
return generateStructuralTypeText(node, codeBody, config);
|
||||
}
|
||||
|
||||
return generateCodeBodyText(node, codeBody, config);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate embedding texts for a batch of nodes
|
||||
*
|
||||
* @param nodes - Array of nodes to generate text for
|
||||
* @param config - Optional configuration
|
||||
* @returns Array of texts in the same order as input nodes
|
||||
* Export truncation helper for testing
|
||||
*/
|
||||
export const generateBatchEmbeddingTexts = (
|
||||
nodes: EmbeddableNode[],
|
||||
config: Partial<EmbeddingConfig> = {},
|
||||
): string[] => {
|
||||
return nodes.map((node) => generateEmbeddingText(node, config));
|
||||
};
|
||||
export { truncateDescription };
|
||||
|
||||
@@ -5,10 +5,40 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Node labels that should be embedded for semantic search
|
||||
* These are code elements that benefit from semantic matching
|
||||
* Node labels that need chunking (have code body, potentially long)
|
||||
*/
|
||||
export const EMBEDDABLE_LABELS = ['Function', 'Class', 'Method', 'Interface', 'File'] as const;
|
||||
export const CHUNKABLE_LABELS = [
|
||||
'Function',
|
||||
'Method',
|
||||
'Constructor',
|
||||
'Class',
|
||||
'Interface',
|
||||
'Struct',
|
||||
'Enum',
|
||||
'Trait',
|
||||
'Impl',
|
||||
'Macro',
|
||||
'Namespace',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Node labels that are short (no chunking needed, embed directly)
|
||||
*/
|
||||
export const SHORT_LABELS = [
|
||||
'TypeAlias',
|
||||
'Typedef',
|
||||
'Const',
|
||||
'Property',
|
||||
'Record',
|
||||
'Union',
|
||||
'Static',
|
||||
'Variable',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* All embeddable labels (union of CHUNKABLE + SHORT)
|
||||
*/
|
||||
export const EMBEDDABLE_LABELS = [...CHUNKABLE_LABELS, ...SHORT_LABELS] as const;
|
||||
|
||||
export type EmbeddableLabel = (typeof EMBEDDABLE_LABELS)[number];
|
||||
|
||||
@@ -18,6 +48,39 @@ export type EmbeddableLabel = (typeof EMBEDDABLE_LABELS)[number];
|
||||
export const isEmbeddableLabel = (label: string): label is EmbeddableLabel =>
|
||||
EMBEDDABLE_LABELS.includes(label as EmbeddableLabel);
|
||||
|
||||
/**
|
||||
* Check if a label needs chunking
|
||||
*/
|
||||
export const isChunkableLabel = (label: string): boolean =>
|
||||
(CHUNKABLE_LABELS as readonly string[]).includes(label);
|
||||
|
||||
/**
|
||||
* Check if a label is a short type (no chunking)
|
||||
*/
|
||||
export const isShortLabel = (label: string): boolean =>
|
||||
(SHORT_LABELS as readonly string[]).includes(label);
|
||||
|
||||
/**
|
||||
* Node labels that have structural names (methods/fields) extractable via AST
|
||||
*/
|
||||
export const STRUCTURAL_LABELS: ReadonlySet<string> = new Set([
|
||||
'Class',
|
||||
'Struct',
|
||||
'Interface',
|
||||
'Enum',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Node labels that have isExported column in their schema
|
||||
*/
|
||||
export const LABELS_WITH_EXPORTED = new Set([
|
||||
'Function',
|
||||
'Class',
|
||||
'Interface',
|
||||
'Method',
|
||||
'CodeElement',
|
||||
]) as ReadonlySet<string>;
|
||||
|
||||
/**
|
||||
* Embedding pipeline phases
|
||||
*/
|
||||
@@ -57,6 +120,12 @@ export interface EmbeddingConfig {
|
||||
device: 'auto' | 'dml' | 'cuda' | 'cpu' | 'wasm';
|
||||
/** Maximum characters of code snippet to include */
|
||||
maxSnippetLength: number;
|
||||
/** Maximum code chunk size in characters (for chunking long code) */
|
||||
chunkSize: number;
|
||||
/** Overlap between chunks in characters */
|
||||
overlap: number;
|
||||
/** Maximum description length in characters */
|
||||
maxDescriptionLength: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +139,9 @@ export const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = {
|
||||
dimensions: 384,
|
||||
device: 'auto',
|
||||
maxSnippetLength: 500,
|
||||
chunkSize: 1200,
|
||||
overlap: 120,
|
||||
maxDescriptionLength: 150,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -96,6 +168,34 @@ export interface EmbeddableNode {
|
||||
content: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
isExported?: boolean;
|
||||
description?: string;
|
||||
parameterCount?: number;
|
||||
returnType?: string;
|
||||
repoName?: string;
|
||||
serverName?: string;
|
||||
methodNames?: string[];
|
||||
fieldNames?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached embedding entry restored from LadybugDB before a graph rebuild
|
||||
*/
|
||||
export interface CachedEmbedding {
|
||||
nodeId: string;
|
||||
chunkIndex: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
embedding: number[];
|
||||
contentHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context info for embedding pipeline (repo/server metadata enrichment)
|
||||
*/
|
||||
export interface EmbeddingContext {
|
||||
repoName?: string;
|
||||
serverName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,3 +208,75 @@ export interface ModelProgress {
|
||||
loaded?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface ChunkSearchRow {
|
||||
nodeId: string;
|
||||
chunkIndex: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export interface BestChunkMatch {
|
||||
chunkIndex: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate vector search chunk results by nodeId,
|
||||
* keeping the chunk with smallest distance for each node.
|
||||
*/
|
||||
export const dedupBestChunks = (
|
||||
rows: ChunkSearchRow[],
|
||||
limit?: number,
|
||||
): Map<string, BestChunkMatch> => {
|
||||
const best = new Map<string, BestChunkMatch>();
|
||||
for (const row of rows) {
|
||||
const existing = best.get(row.nodeId);
|
||||
if (!existing || row.distance < existing.distance) {
|
||||
best.set(row.nodeId, {
|
||||
chunkIndex: row.chunkIndex,
|
||||
startLine: row.startLine,
|
||||
endLine: row.endLine,
|
||||
distance: row.distance,
|
||||
});
|
||||
}
|
||||
if (limit !== undefined && best.size >= limit) break;
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
const DEFAULT_FETCH_MULTIPLIER = 4;
|
||||
const DEFAULT_FETCH_BUFFER = 8;
|
||||
const DEFAULT_MAX_FETCH = 200;
|
||||
|
||||
/**
|
||||
* Fetch vector-search chunks until we have enough unique nodeIds
|
||||
* or can tell the result set is exhausted.
|
||||
*/
|
||||
export const collectBestChunks = async (
|
||||
limit: number,
|
||||
fetchRows: (fetchLimit: number) => Promise<ChunkSearchRow[]>,
|
||||
maxFetch: number = DEFAULT_MAX_FETCH,
|
||||
): Promise<Map<string, BestChunkMatch>> => {
|
||||
if (limit <= 0) return new Map();
|
||||
|
||||
let fetchLimit = Math.max(limit * DEFAULT_FETCH_MULTIPLIER, limit + DEFAULT_FETCH_BUFFER);
|
||||
let previousFetchLimit = 0;
|
||||
|
||||
while (fetchLimit > previousFetchLimit) {
|
||||
const rows = await fetchRows(fetchLimit);
|
||||
const bestChunks = dedupBestChunks(rows, limit);
|
||||
|
||||
if (bestChunks.size >= limit || rows.length < fetchLimit) {
|
||||
return bestChunks;
|
||||
}
|
||||
|
||||
previousFetchLimit = fetchLimit;
|
||||
fetchLimit = fetchLimit >= maxFetch ? fetchLimit * 2 : Math.min(maxFetch, fetchLimit * 2);
|
||||
}
|
||||
|
||||
return new Map();
|
||||
};
|
||||
|
||||
@@ -17,6 +17,9 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
|
||||
* - Express `router.get(...)` / `app.post(...)` providers
|
||||
* - `fetch(url)` / `fetch(url, { method: 'POST' })` consumers
|
||||
* - `axios.get(url)` / `axios.delete(url)` consumers
|
||||
* - `axios({ method, url })` object-form consumers
|
||||
* - jQuery `$.get(url)` / `$.post(url, ...)` shorthand consumers
|
||||
* - jQuery `$.ajax({ url, method | type })` consumers
|
||||
*
|
||||
* Because the JavaScript and TypeScript tree-sitter grammars share
|
||||
* node type names for every construct we query, pattern sources are
|
||||
@@ -103,6 +106,48 @@ const AXIOS_SPEC: PatternSpec<Record<string, never>> = {
|
||||
`,
|
||||
};
|
||||
|
||||
// ─── Consumer: jQuery shorthand $.get(url) / $.post(url, ...) ────────
|
||||
// `$` is a valid JS identifier, so tree-sitter parses `$.get(...)` as a
|
||||
// call_expression whose function is a member_expression on identifier `$`.
|
||||
const JQUERY_SHORTHAND_SPEC: PatternSpec<Record<string, never>> = {
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
function: (member_expression
|
||||
object: (identifier) @obj (#eq? @obj "$")
|
||||
property: (property_identifier) @http_method (#match? @http_method "^(get|post)$"))
|
||||
arguments: (arguments . [(string) (template_string)] @path))
|
||||
`,
|
||||
};
|
||||
|
||||
// ─── Consumer: jQuery $.ajax({ url, method|type }) ───────────────────
|
||||
// The query captures the options object only; key/value pairs are read
|
||||
// programmatically via `readStringProp` below, which tolerates any key
|
||||
// order and accepts either `method:` or `type:` (jQuery supports both).
|
||||
const JQUERY_AJAX_SPEC: PatternSpec<Record<string, never>> = {
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
function: (member_expression
|
||||
object: (identifier) @obj (#eq? @obj "$")
|
||||
property: (property_identifier) @fn (#eq? @fn "ajax"))
|
||||
arguments: (arguments (object) @options))
|
||||
`,
|
||||
};
|
||||
|
||||
// ─── Consumer: axios({ method, url }) object form ────────────────────
|
||||
// Distinct from AXIOS_SPEC above because the call target is an identifier
|
||||
// (`axios`) rather than a member expression (`axios.get`). As with the
|
||||
// jQuery ajax form, option keys are resolved programmatically.
|
||||
const AXIOS_OBJECT_SPEC: PatternSpec<Record<string, never>> = {
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
function: (identifier) @fn (#eq? @fn "axios")
|
||||
arguments: (arguments (object) @options))
|
||||
`,
|
||||
};
|
||||
|
||||
interface NodePatternBundle {
|
||||
controller: CompiledPatterns<Record<string, never>>;
|
||||
methodDecorator: CompiledPatterns<Record<string, never>>;
|
||||
@@ -110,6 +155,9 @@ interface NodePatternBundle {
|
||||
fetchNoOptions: CompiledPatterns<Record<string, never>>;
|
||||
fetchWithOptions: CompiledPatterns<Record<string, never>>;
|
||||
axios: CompiledPatterns<Record<string, never>>;
|
||||
jqueryShorthand: CompiledPatterns<Record<string, never>>;
|
||||
jqueryAjax: CompiledPatterns<Record<string, never>>;
|
||||
axiosObject: CompiledPatterns<Record<string, never>>;
|
||||
}
|
||||
|
||||
function compileBundle(language: unknown, name: string): NodePatternBundle {
|
||||
@@ -126,6 +174,9 @@ function compileBundle(language: unknown, name: string): NodePatternBundle {
|
||||
fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'),
|
||||
fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'),
|
||||
axios: mk(AXIOS_SPEC, 'axios'),
|
||||
jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'),
|
||||
jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'),
|
||||
axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,6 +211,28 @@ function joinPath(prefix: string, sub: string): string {
|
||||
return `/${cleanPrefix}/${cleanSub}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk `pair` children of an `object` literal and return the unquoted
|
||||
* string/template_string value for the first pair whose key matches one
|
||||
* of `keyNames`. Returns null when no matching pair is present or the
|
||||
* value is not a string literal. Used by the jQuery ajax / axios object
|
||||
* consumers to resolve `url` / `method` / `type` keys in any order.
|
||||
*/
|
||||
function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string[]): string | null {
|
||||
for (let i = 0; i < objectNode.namedChildCount; i++) {
|
||||
const pair = objectNode.namedChild(i);
|
||||
if (!pair || pair.type !== 'pair') continue;
|
||||
const keyNode = pair.childForFieldName('key');
|
||||
const valueNode = pair.childForFieldName('value');
|
||||
if (!keyNode || !valueNode) continue;
|
||||
if (!keyNames.includes(keyNode.text)) continue;
|
||||
if (valueNode.type !== 'string' && valueNode.type !== 'template_string') continue;
|
||||
const lit = unquoteLiteral(valueNode.text);
|
||||
if (lit !== null) return lit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a standalone `decorator` node (child of class_body / program),
|
||||
* find the related `class_declaration` node that it decorates. In
|
||||
@@ -351,6 +424,62 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
|
||||
});
|
||||
}
|
||||
|
||||
// Consumer: jQuery shorthand $.get(url) / $.post(url, ...)
|
||||
for (const match of runCompiledPatterns(bundle.jqueryShorthand, tree)) {
|
||||
const methodNode = match.captures.http_method;
|
||||
const pathNode = match.captures.path;
|
||||
if (!methodNode || !pathNode) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'jquery',
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
// Consumer: jQuery $.ajax({ url, method|type }). jQuery accepts either
|
||||
// `method:` or `type:`; both default to GET when absent.
|
||||
for (const match of runCompiledPatterns(bundle.jqueryAjax, tree)) {
|
||||
const optionsNode = match.captures.options;
|
||||
if (!optionsNode) continue;
|
||||
const path = readStringProp(optionsNode, ['url']);
|
||||
if (path === null) continue;
|
||||
const rawMethod = readStringProp(optionsNode, ['method', 'type']);
|
||||
const method = (rawMethod ?? 'GET').toUpperCase();
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'jquery',
|
||||
method,
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
// Consumer: axios({ method, url }) object form. Structurally distinct
|
||||
// from axios.<verb>(url) (identifier vs member_expression call), so no
|
||||
// dedup against the member-form loop above is required.
|
||||
for (const match of runCompiledPatterns(bundle.axiosObject, tree)) {
|
||||
const optionsNode = match.captures.options;
|
||||
if (!optionsNode) continue;
|
||||
const path = readStringProp(optionsNode, ['url']);
|
||||
if (path === null) continue;
|
||||
const rawMethod = readStringProp(optionsNode, ['method']);
|
||||
const method = (rawMethod ?? 'GET').toUpperCase();
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'axios',
|
||||
method,
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,17 +79,50 @@ export class ManifestExtractor {
|
||||
links: GroupManifestLink[],
|
||||
dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<ManifestExtractResult> {
|
||||
// Resolve all (repo, link) pairs in parallel. The previous sequential
|
||||
// await-per-link produced 2N round-trips; parallel resolution uses the
|
||||
// per-repo executor pool directly and scales linearly with manifest size.
|
||||
//
|
||||
// Memoization: a manifest can list the same contract multiple times
|
||||
// (e.g. a consumer and provider declaration, or cross-referenced groups).
|
||||
// Key on (repo, type, contract) — the canonical input to the Cypher
|
||||
// query — so duplicate links resolve to one DB hit.
|
||||
type ResolvedSymbol = { filePath: string; name: string; uid: string } | null;
|
||||
const resolveCache = new Map<string, Promise<ResolvedSymbol>>();
|
||||
const resolveOnce = (repo: string, link: GroupManifestLink): Promise<ResolvedSymbol> => {
|
||||
const key = `${repo}\u0000${link.type}\u0000${link.contract}`;
|
||||
let pending = resolveCache.get(key);
|
||||
if (!pending) {
|
||||
pending = this.resolveSymbol(repo, link, dbExecutors);
|
||||
resolveCache.set(key, pending);
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
|
||||
const perLink = await Promise.all(
|
||||
links.map(async (link) => {
|
||||
const contractId = this.buildContractId(link.type, link.contract);
|
||||
const providerRepo = link.role === 'provider' ? link.from : link.to;
|
||||
const consumerRepo = link.role === 'provider' ? link.to : link.from;
|
||||
const [providerSymbol, consumerSymbol] = await Promise.all([
|
||||
resolveOnce(providerRepo, link),
|
||||
resolveOnce(consumerRepo, link),
|
||||
]);
|
||||
return { link, contractId, providerRepo, consumerRepo, providerSymbol, consumerSymbol };
|
||||
}),
|
||||
);
|
||||
|
||||
const contracts: StoredContract[] = [];
|
||||
const crossLinks: CrossLink[] = [];
|
||||
|
||||
for (const link of links) {
|
||||
const contractId = this.buildContractId(link.type, link.contract);
|
||||
|
||||
const providerRepo = link.role === 'provider' ? link.from : link.to;
|
||||
const consumerRepo = link.role === 'provider' ? link.to : link.from;
|
||||
|
||||
const providerSymbol = await this.resolveSymbol(providerRepo, link, dbExecutors);
|
||||
const consumerSymbol = await this.resolveSymbol(consumerRepo, link, dbExecutors);
|
||||
for (const {
|
||||
link,
|
||||
contractId,
|
||||
providerRepo,
|
||||
consumerRepo,
|
||||
providerSymbol,
|
||||
consumerSymbol,
|
||||
} of perLink) {
|
||||
const providerRef = providerSymbol || { filePath: '', name: link.contract };
|
||||
const consumerRef = consumerSymbol || { filePath: '', name: link.contract };
|
||||
// When the resolver finds a real graph symbol we keep its uid, otherwise
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { GroupConfig, RepoHandle, RepoSnapshot, StoredContract, CrossLink }
|
||||
import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
|
||||
import { GrpcExtractor } from './extractors/grpc-extractor.js';
|
||||
import { TopicExtractor } from './extractors/topic-extractor.js';
|
||||
import { ManifestExtractor } from './extractors/manifest-extractor.js';
|
||||
import { runExactMatch } from './matching.js';
|
||||
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
|
||||
import type { CypherExecutor } from './contract-extractor.js';
|
||||
@@ -60,10 +61,28 @@ function defaultResolveHandle(allEntries: RegistryEntry[]) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedupe cross-links that point from the same consumer endpoint to the same
|
||||
* provider endpoint for the same contract. Preserves first-seen order so the
|
||||
* caller controls precedence (e.g., pass manifest links first).
|
||||
*/
|
||||
function dedupeCrossLinks(links: CrossLink[]): CrossLink[] {
|
||||
const seen = new Set<string>();
|
||||
const out: CrossLink[] = [];
|
||||
for (const link of links) {
|
||||
const key = `${link.from.repo}::${link.from.symbolUid}|${link.to.repo}::${link.to.symbolUid}|${link.type}|${link.contractId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(link);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promise<SyncResult> {
|
||||
const missingRepos: string[] = [];
|
||||
const repoSnapshots: Record<string, RepoSnapshot> = {};
|
||||
let autoContracts: StoredContract[] = [];
|
||||
let manifestCrossLinks: CrossLink[] = [];
|
||||
let dbExecutors: Map<string, CypherExecutor> | undefined;
|
||||
|
||||
const eo = opts?.extractorOverride;
|
||||
@@ -158,8 +177,44 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
||||
}
|
||||
}
|
||||
|
||||
// Process manifest links declared in group.yaml.
|
||||
// ManifestExtractor is fully implemented but was never wired into this
|
||||
// pipeline — config.links were parsed and validated but silently dropped.
|
||||
// Placed after the DB try/finally: resolveSymbol falls back to synthetic
|
||||
// UIDs when dbExecutors is undefined or a pool is closed, so cross-links
|
||||
// are always generated regardless of whether real DB executors are available.
|
||||
if (config.links.length > 0) {
|
||||
// Warn about dangling links that reference repos not declared in config.repos.
|
||||
// They still generate cross-links via synthetic UIDs (determinism is preserved),
|
||||
// but the operator probably meant something that now silently does nothing useful.
|
||||
const knownRepos = new Set(Object.keys(config.repos));
|
||||
for (const link of config.links) {
|
||||
const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r));
|
||||
if (dangling.length > 0) {
|
||||
console.warn(
|
||||
`[group/sync] manifest link ${link.type}:${link.contract} references repos not in config.repos: ${dangling.join(', ')} — cross-links will use synthetic UIDs`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const manifestEx = new ManifestExtractor();
|
||||
const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors);
|
||||
autoContracts.push(...manifestResult.contracts);
|
||||
manifestCrossLinks = manifestResult.crossLinks;
|
||||
if (opts?.verbose) {
|
||||
console.log(
|
||||
` manifest: ${manifestCrossLinks.length} cross-links from ${config.links.length} declared links`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { matched, unmatched } = runExactMatch(autoContracts);
|
||||
const crossLinks: CrossLink[] = matched;
|
||||
|
||||
// Dedupe cross-links. Manifest contracts participate in runExactMatch, so a
|
||||
// manifest-declared link can also emit a matchType:'exact' CrossLink with the
|
||||
// same endpoints. Prefer the manifest version — it reflects operator intent
|
||||
// and carries matchType:'manifest' which downstream consumers may rely on.
|
||||
const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched]);
|
||||
const allContracts: StoredContract[] = autoContracts;
|
||||
|
||||
const registry: ContractRegistry = {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { CallExtractionConfig } from '../../call-types.js';
|
||||
|
||||
export const cCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.C,
|
||||
};
|
||||
|
||||
export const cppCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.CPlusPlus,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
// gitnexus/src/core/ingestion/call-extractors/configs/csharp.ts
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { CallExtractionConfig } from '../../call-types.js';
|
||||
|
||||
export const csharpCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.CSharp,
|
||||
typeAsReceiverHeuristic: true,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// gitnexus/src/core/ingestion/call-extractors/configs/dart.ts
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { CallExtractionConfig } from '../../call-types.js';
|
||||
|
||||
export const dartCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.Dart,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// gitnexus/src/core/ingestion/call-extractors/configs/go.ts
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { CallExtractionConfig } from '../../call-types.js';
|
||||
|
||||
export const goCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.Go,
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
// gitnexus/src/core/ingestion/call-extractors/configs/jvm.ts
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { CallExtractionConfig, ExtractedCallSite } from '../../call-types.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Java method_reference (::) parsing — absorbs call-sites/java.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse Java `method_reference` nodes (`expr::method`, `Type::new`,
|
||||
* `this::m`, `super::m`).
|
||||
*/
|
||||
function parseJavaMethodReference(callNode: SyntaxNode): ExtractedCallSite | null {
|
||||
if (callNode.type !== 'method_reference') return null;
|
||||
|
||||
const recv = callNode.namedChild(0);
|
||||
if (!recv) return null;
|
||||
|
||||
// Type::new → constructor call
|
||||
for (const c of callNode.children) {
|
||||
if (c.type === 'new') {
|
||||
if (recv.type !== 'identifier') return null;
|
||||
return { calledName: recv.text, callForm: 'constructor' };
|
||||
}
|
||||
}
|
||||
|
||||
// expr::method → member call with receiver
|
||||
const rhs = callNode.child(callNode.childCount - 1);
|
||||
if (!rhs || rhs.type !== 'identifier') return null;
|
||||
const methodName = rhs.text;
|
||||
|
||||
if (recv.type === 'identifier') {
|
||||
return { calledName: methodName, callForm: 'member', receiverName: recv.text };
|
||||
}
|
||||
if (recv.type === 'this') {
|
||||
return { calledName: methodName, callForm: 'member', receiverName: 'this' };
|
||||
}
|
||||
if (recv.type === 'super') {
|
||||
return { calledName: methodName, callForm: 'member', receiverName: 'super' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const javaCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.Java,
|
||||
extractLanguageCallSite: parseJavaMethodReference,
|
||||
typeAsReceiverHeuristic: true,
|
||||
};
|
||||
|
||||
export const kotlinCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.Kotlin,
|
||||
typeAsReceiverHeuristic: true,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// gitnexus/src/core/ingestion/call-extractors/configs/php.ts
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { CallExtractionConfig } from '../../call-types.js';
|
||||
|
||||
export const phpCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.PHP,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// gitnexus/src/core/ingestion/call-extractors/configs/python.ts
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { CallExtractionConfig } from '../../call-types.js';
|
||||
|
||||
export const pythonCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.Python,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// gitnexus/src/core/ingestion/call-extractors/configs/ruby.ts
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { CallExtractionConfig } from '../../call-types.js';
|
||||
|
||||
export const rubyCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.Ruby,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// gitnexus/src/core/ingestion/call-extractors/configs/rust.ts
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { CallExtractionConfig } from '../../call-types.js';
|
||||
|
||||
export const rustCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.Rust,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// gitnexus/src/core/ingestion/call-extractors/configs/swift.ts
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { CallExtractionConfig } from '../../call-types.js';
|
||||
|
||||
export const swiftCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.Swift,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
// gitnexus/src/core/ingestion/call-extractors/configs/typescript-javascript.ts
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { CallExtractionConfig } from '../../call-types.js';
|
||||
|
||||
export const typescriptCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.TypeScript,
|
||||
};
|
||||
|
||||
export const javascriptCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.JavaScript,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user