Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a528c7a12 | ||
|
|
36ff15151f | ||
|
|
7f8b01d506 | ||
|
|
114d5304d9 | ||
|
|
1fe3bf9399 | ||
|
|
db22a89021 | ||
|
|
b9a17f553d | ||
|
|
bc722b9d8f | ||
|
|
368049576b | ||
|
|
55d504284f | ||
|
|
0418cbb347 | ||
|
|
4be4abe8e4 | ||
|
|
59acfb2261 | ||
|
|
26b39560ce | ||
|
|
2a1546a8b6 | ||
|
|
ec07467601 | ||
|
|
e07a95dffb | ||
|
|
6372b0bfeb | ||
|
|
c90ffdd2e1 | ||
|
|
71e1e8a3f0 | ||
|
|
b278926bb6 | ||
|
|
275f09d6c0 | ||
|
|
b4f9d56f33 | ||
|
|
6f42253dfd | ||
|
|
b5316c2df1 | ||
|
|
aa7c273093 | ||
|
|
78e965bf62 | ||
|
|
b79278705a | ||
|
|
3f0c74fea0 | ||
|
|
883091f3e0 | ||
|
|
66ad5c4980 | ||
|
|
ca3d520293 | ||
|
|
9cd8c3663f | ||
|
|
d57f15f18d | ||
|
|
d31156288f | ||
|
|
02ed335ff6 | ||
|
|
52febea560 | ||
|
|
ef96603fed | ||
|
|
bb6aa0c855 | ||
|
|
851d2ab749 | ||
|
|
07629a39d1 | ||
|
|
93a0be2310 | ||
|
|
7be595d317 | ||
|
|
dafda284bc | ||
|
|
2ff3e64f71 | ||
|
|
2a0c97c178 | ||
|
|
1f6df5fdbb | ||
|
|
86abc01445 | ||
|
|
46586a8319 | ||
|
|
ffa0510f9a | ||
|
|
038f2b33ec | ||
|
|
780ee83d52 | ||
|
|
38ccf7ceb1 | ||
|
|
aa7bacd48b | ||
|
|
2a799ae369 | ||
|
|
2727a8ca2a | ||
|
|
77844acb6a | ||
|
|
8d7beaf1cf | ||
|
|
94a4365e6d | ||
|
|
c4999b02b0 | ||
|
|
1e80285c47 | ||
|
|
8fbbb35718 | ||
|
|
5c434ff313 | ||
|
|
7c3fa5853f | ||
|
|
09d78cadec |
@@ -36,7 +36,6 @@ 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
|
||||
@@ -73,18 +72,37 @@ GRAMMARS: dict[str, tuple[str, str, str]] = {
|
||||
"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"),
|
||||
# Vendored parsers — kept here so the upstream coords for drift
|
||||
# detection are co-located with every other grammar's coords.
|
||||
"tree-sitter-proto": ("coder3101/tree-sitter-proto", "main", "src/parser.c"),
|
||||
}
|
||||
|
||||
UPSTREAM_PROTO_OWNER = "coder3101"
|
||||
UPSTREAM_PROTO_REPO = "tree-sitter-proto"
|
||||
UPSTREAM_PROTO_BRANCH = "main"
|
||||
# Grammars deliberately held below npm latest. The readiness report surfaces
|
||||
# these so reviewers can tell intentional pins apart from drift, and so the
|
||||
# context for each pin (which issue motivated it) is visible at a glance.
|
||||
# Add an entry whenever you pin a grammar below npm latest.
|
||||
INTENTIONAL_PINS: dict[str, str] = {
|
||||
"tree-sitter-c": (
|
||||
"#1242 — last release built against the tree-sitter@0.21 ABI; "
|
||||
"tree-sitter-c@0.23.x prebuilds segfault on Windows under tree-sitter@0.21.1"
|
||||
),
|
||||
"tree-sitter-cpp": (
|
||||
"#1242 — last 0.23.x release before tree-sitter-cpp added a runtime "
|
||||
"dep on the broken-ABI tree-sitter-c@^0.23.1; pinning here removes "
|
||||
"the need for a transitive override"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_package_json() -> dict:
|
||||
return json.loads((GITNEXUS_DIR / "package.json").read_text())
|
||||
|
||||
|
||||
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())
|
||||
pkg = _load_package_json()
|
||||
raw = pkg["dependencies"]["tree-sitter"]
|
||||
match = re.search(r"(\d+)\.(\d+)", raw)
|
||||
if not match:
|
||||
@@ -92,6 +110,22 @@ def read_current_runtime() -> str:
|
||||
return f"{match.group(1)}.{match.group(2)}"
|
||||
|
||||
|
||||
def read_pinned_grammar_versions() -> dict[str, str]:
|
||||
"""Return the grammar version range pinned in gitnexus/package.json.
|
||||
|
||||
Looks at both runtime and optional dependencies. Returns the raw range
|
||||
string (e.g. '0.21.4', '^0.23.0', 'file:./vendor/...') so the report can
|
||||
expose how flexible each pin is.
|
||||
"""
|
||||
pkg = _load_package_json()
|
||||
pinned: dict[str, str] = {}
|
||||
for section in ("dependencies", "optionalDependencies"):
|
||||
for name, spec in (pkg.get(section) or {}).items():
|
||||
if name.startswith("tree-sitter-"):
|
||||
pinned[name] = spec
|
||||
return pinned
|
||||
|
||||
|
||||
def npm_view_json(pkg: str) -> dict | None:
|
||||
"""Fetch package metadata from the npm registry via HTTPS.
|
||||
|
||||
@@ -185,8 +219,178 @@ def md_h(text: str, level: int = 2) -> str:
|
||||
return f"{'#' * level} {text}\n"
|
||||
|
||||
|
||||
def _first_sentence(text: str) -> str:
|
||||
"""Return the leading sentence of a free-form rationale string.
|
||||
|
||||
Vendor package.json `_vendoredBy` fields often look like
|
||||
"<reason>. <install-script breadcrumb>. Do NOT <warning>." — the
|
||||
first sentence is what reviewers actually want to read; the rest is
|
||||
noise in this context. Match a sentence-ending '.' followed by
|
||||
whitespace; fall back to the whole string if nothing matches.
|
||||
"""
|
||||
text = text.strip()
|
||||
match = re.search(r"\.\s+[A-Z]", text)
|
||||
return text[: match.start() + 1] if match else text
|
||||
|
||||
|
||||
def range_includes(spec: str | None, version: str) -> bool:
|
||||
"""Return True if pinned-range `spec` accepts the concrete `version`.
|
||||
|
||||
Handles the spec shapes we actually use in package.json:
|
||||
- exact pins ('0.21.4')
|
||||
- caret / tilde ranges ('^0.23.0', '~0.23.5')
|
||||
- non-registry pins ('file:./vendor/...', 'git+...') — always False,
|
||||
because there's no meaningful "behind npm latest" comparison.
|
||||
"""
|
||||
if not spec or spec == "—":
|
||||
return False
|
||||
if spec.startswith(("file:", "git", "http")):
|
||||
return False
|
||||
if spec.startswith(("^", "~")):
|
||||
return satisfies_target(spec, version)
|
||||
return spec.strip() == version.strip()
|
||||
|
||||
|
||||
def is_vendored_pin(spec: str | None) -> bool:
|
||||
return bool(spec) and spec.startswith(("file:", "git", "http"))
|
||||
|
||||
|
||||
def vendored_drift_summary(
|
||||
name: str, upstream_repo: str, upstream_branch: str, parser_path: str
|
||||
) -> dict:
|
||||
"""Inspect a vendored grammar under gitnexus/vendor/<name>.
|
||||
|
||||
Returns the vendored package.json's ``version`` and ``_vendoredBy``
|
||||
fields (which carry the human rationale for vendoring), the vendored
|
||||
parser's ABI, and a comparison against upstream main. We deliberately
|
||||
rely on ``_vendoredBy`` rather than a parallel registry in this
|
||||
script: the rationale belongs next to the vendored sources, not in
|
||||
a daily-running CI script.
|
||||
"""
|
||||
vendor_dir = GITNEXUS_DIR / "vendor" / name
|
||||
pkg: dict = {}
|
||||
pkg_path = vendor_dir / "package.json"
|
||||
if pkg_path.is_file():
|
||||
try:
|
||||
pkg = json.loads(pkg_path.read_text(encoding="utf-8", errors="ignore"))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
vendored_parser = vendor_dir / parser_path
|
||||
if not vendored_parser.is_file():
|
||||
vendored_parser = vendor_dir / "src" / "parser.c"
|
||||
vendored_abi = extract_language_version(vendored_parser)
|
||||
|
||||
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
|
||||
|
||||
sha_text = fetch_text(
|
||||
f"https://api.github.com/repos/{upstream_repo}/commits/{upstream_branch}"
|
||||
)
|
||||
upstream_sha = "?"
|
||||
if sha_text:
|
||||
try:
|
||||
upstream_sha = json.loads(sha_text).get("sha", "?")[:12]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
local_text = (
|
||||
vendored_parser.read_text(encoding="utf-8", errors="ignore")
|
||||
if vendored_parser.is_file()
|
||||
else ""
|
||||
)
|
||||
in_sync = bool(
|
||||
upstream_text
|
||||
and local_text.replace("\r\n", "\n") == upstream_text.replace("\r\n", "\n")
|
||||
)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"vendored_version": pkg.get("version", "?"),
|
||||
"vendored_by": pkg.get("_vendoredBy"),
|
||||
"vendored_abi": vendored_abi,
|
||||
"upstream_repo": upstream_repo,
|
||||
"upstream_branch": upstream_branch,
|
||||
"upstream_sha": upstream_sha,
|
||||
"upstream_abi": upstream_abi,
|
||||
"in_sync": in_sync,
|
||||
}
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _classify_grammar(
|
||||
*,
|
||||
name: str,
|
||||
pinned_spec: str | None,
|
||||
npm_version: str,
|
||||
peer_range: str | None,
|
||||
fetch_failed: bool,
|
||||
target_compat: bool,
|
||||
current_compat: bool,
|
||||
upstream_progress: str | None,
|
||||
) -> dict:
|
||||
"""Decide a single primary disposition + a separate bump-now hint.
|
||||
|
||||
Buckets are mutually exclusive and ordered by what a reviewer should
|
||||
look at first:
|
||||
- fetch_failed : npm registry fetch failed (treat as blocker, but
|
||||
surface separately so reviewers don't confuse it
|
||||
with an upstream block)
|
||||
- intentional : pinned in INTENTIONAL_PINS — explicit choice
|
||||
- ready : npm-latest peer dep already accepts the target
|
||||
runtime; nothing to do
|
||||
- waiting : main has a fix (ABI 15 or relaxed peer) but no
|
||||
published npm release yet
|
||||
- blocked : peer dep too tight on both npm and main
|
||||
|
||||
Independently of bucket, `bump_now` reports whether reviewers can
|
||||
move the pin forward today without touching the runtime — we only
|
||||
suggest it when npm-latest's peer dep also accepts our *current*
|
||||
runtime, otherwise the bump would break `npm install`.
|
||||
"""
|
||||
is_vendored = is_vendored_pin(pinned_spec)
|
||||
behind_latest = (
|
||||
not is_vendored
|
||||
and npm_version != "?"
|
||||
and not range_includes(pinned_spec, npm_version)
|
||||
)
|
||||
# Intentional pins must never appear as actionable bumps — by definition
|
||||
# we're holding them back on purpose. The pin can only be lifted by
|
||||
# editing INTENTIONAL_PINS and package.json together.
|
||||
bump_now = behind_latest and current_compat and name not in INTENTIONAL_PINS
|
||||
|
||||
if fetch_failed:
|
||||
bucket = "fetch_failed"
|
||||
elif name in INTENTIONAL_PINS:
|
||||
bucket = "intentional"
|
||||
elif target_compat:
|
||||
bucket = "ready"
|
||||
elif upstream_progress:
|
||||
bucket = "waiting"
|
||||
else:
|
||||
bucket = "blocked"
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"pinned_spec": pinned_spec or "—",
|
||||
"npm_version": npm_version,
|
||||
"peer_range": peer_range,
|
||||
"target_compat": target_compat,
|
||||
"current_compat": current_compat,
|
||||
"upstream_progress": upstream_progress,
|
||||
"behind_latest": behind_latest,
|
||||
"bump_now": bump_now,
|
||||
"bucket": bucket,
|
||||
"is_vendored": is_vendored,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
blockers: dict[str, str] = {}
|
||||
lines: list[str] = []
|
||||
@@ -196,20 +400,68 @@ def main() -> int:
|
||||
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))
|
||||
pinned_versions = read_pinned_grammar_versions()
|
||||
|
||||
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(
|
||||
f"`tree-sitter@{current_runtime}.x` (ABI {current_abi_range[0]}–{current_abi_range[1]}) "
|
||||
f"→ target `tree-sitter@{TARGET_RUNTIME}` "
|
||||
f"(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("|---|---|---|---|---|---|---|")
|
||||
# First pass: gather raw data + classification per grammar. We render
|
||||
# the human-friendly buckets first, then the raw matrix in a <details>
|
||||
# block at the end. Status text in the matrix is preserved verbatim
|
||||
# so the workflow's row-diff change-detection keeps working.
|
||||
grammar_rows: list[dict] = []
|
||||
raw_matrix: list[str] = [
|
||||
"| Grammar | Pinned | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |",
|
||||
"|---|---|---|---|---|---|---|---|",
|
||||
]
|
||||
|
||||
ready_count = 0
|
||||
total_count = len(GRAMMARS)
|
||||
vendored_grammars: list[dict] = []
|
||||
|
||||
for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()):
|
||||
pinned_spec = pinned_versions.get(name, "—")
|
||||
|
||||
# Vendored grammars don't have an "npm latest" we install from —
|
||||
# we ship our own copy under gitnexus/vendor/<name>. Treat them
|
||||
# as a separate kind of artefact: their readiness for the runtime
|
||||
# upgrade depends on the vendored ABI being in the target range,
|
||||
# not on a peer-dep negotiation.
|
||||
if is_vendored_pin(pinned_spec):
|
||||
v = vendored_drift_summary(name, upstream_repo, upstream_branch, parser_path)
|
||||
v["pinned_spec"] = pinned_spec
|
||||
# Three-state classification: in-range, out-of-range, or
|
||||
# not-introspectable (e.g. tree-sitter-swift ships only
|
||||
# prebuilt .node binaries, no parser.c — assume compatible).
|
||||
if v["vendored_abi"] is None:
|
||||
v["target_compat"] = True
|
||||
v["abi_state"] = "prebuilt"
|
||||
status = "Vendored (prebuilt — ABI not introspectable)"
|
||||
elif target_abi_range[0] <= v["vendored_abi"] <= target_abi_range[1]:
|
||||
v["target_compat"] = True
|
||||
v["abi_state"] = "in_range"
|
||||
status = "Vendored (ABI in target range)"
|
||||
else:
|
||||
v["target_compat"] = False
|
||||
v["abi_state"] = "out_of_range"
|
||||
status = "Vendored (ABI out of range)"
|
||||
blockers[name] = (
|
||||
f"vendored `{name}`: ABI {v['vendored_abi']} outside target range "
|
||||
f"{target_abi_range[0]}..{target_abi_range[1]}"
|
||||
)
|
||||
# Keep vendored grammars in the raw matrix so the workflow's
|
||||
# row-diff change-detection picks up status transitions on
|
||||
# them too. npm-only columns get sentinels.
|
||||
raw_matrix.append(
|
||||
f"| `{name}` | {pinned_spec} | (vendored) | (vendored) | "
|
||||
f"{'Yes' if v['target_compat'] else '**No**'} | "
|
||||
f"{v['vendored_abi'] or '?'} | {v['upstream_abi'] or '?'} | {status} |"
|
||||
)
|
||||
vendored_grammars.append(v)
|
||||
continue
|
||||
|
||||
# Fetch latest npm metadata.
|
||||
info = npm_view_json(name)
|
||||
fetch_failed = info is None
|
||||
@@ -226,12 +478,14 @@ def main() -> int:
|
||||
|
||||
if fetch_failed:
|
||||
peer_display = "? (fetch failed)"
|
||||
compatible = False
|
||||
target_compat = False
|
||||
current_compat = 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)
|
||||
target_compat = satisfies_target(peer_range, TARGET_RUNTIME)
|
||||
current_compat = satisfies_target(peer_range, f"{current_runtime}.0")
|
||||
|
||||
# Check installed ABI using the same parser_path from GRAMMARS.
|
||||
installed_parser = GITNEXUS_DIR / "node_modules" / name / parser_path
|
||||
@@ -250,22 +504,40 @@ def main() -> int:
|
||||
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.
|
||||
# Status text + upstream-progress detection. The Status column
|
||||
# values are preserved as-is to keep the workflow's row-diff
|
||||
# change-detection working on the raw matrix below.
|
||||
upstream_progress: str | None = None
|
||||
if fetch_failed:
|
||||
status = "Unknown (fetch failed)"
|
||||
blockers[name] = f"`{name}`: npm registry fetch failed — could not verify peer dep"
|
||||
elif compatible:
|
||||
elif name in INTENTIONAL_PINS:
|
||||
# An intentional pin is, by definition, a held-back grammar:
|
||||
# whatever npm-latest's peer dep says, our shipped version is
|
||||
# the one whose ABI/peer must accept the target runtime, and
|
||||
# the pin entry exists precisely because it does not. Treat
|
||||
# it as a blocker until the pin is lifted (entry removed from
|
||||
# INTENTIONAL_PINS), at which point this grammar falls back
|
||||
# to standard classification on the next run.
|
||||
status = "Intentionally pinned"
|
||||
blockers[name] = (
|
||||
f"`{name}` intentionally pinned at `{pinned_spec}` "
|
||||
f"({INTENTIONAL_PINS[name]}) — pin must be lifted "
|
||||
f"before the {TARGET_RUNTIME} runtime upgrade"
|
||||
)
|
||||
elif target_compat:
|
||||
status = "Ready"
|
||||
ready_count += 1
|
||||
elif upstream_abi and upstream_abi >= 15:
|
||||
status = "Unreleased (ABI 15 on main)"
|
||||
upstream_progress = f"ABI 15 on `{upstream_repo}@{upstream_branch}` not yet published"
|
||||
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:
|
||||
# Also check upstream package.json for relaxed peer dep — beats
|
||||
# the ABI-15 hint when both are true.
|
||||
if not target_compat and not fetch_failed:
|
||||
upstream_pkg_url = (
|
||||
f"https://raw.githubusercontent.com/{upstream_repo}/"
|
||||
f"{upstream_branch}/package.json"
|
||||
@@ -277,82 +549,250 @@ def main() -> int:
|
||||
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)"
|
||||
upstream_progress = (
|
||||
f"peer relaxed to `{upstream_peer}` on "
|
||||
f"`{upstream_repo}@{upstream_branch}` not yet published"
|
||||
)
|
||||
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} |"
|
||||
pinned_spec = pinned_versions.get(name, "—")
|
||||
compat_icon = "Yes" if target_compat else "**No**"
|
||||
raw_matrix.append(
|
||||
f"| `{name}` | {pinned_spec} | {npm_version} | {peer_display} | "
|
||||
f"{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("")
|
||||
grammar_rows.append(_classify_grammar(
|
||||
name=name,
|
||||
pinned_spec=pinned_spec,
|
||||
npm_version=npm_version,
|
||||
peer_range=peer_range,
|
||||
fetch_failed=fetch_failed,
|
||||
target_compat=target_compat,
|
||||
current_compat=current_compat,
|
||||
upstream_progress=upstream_progress,
|
||||
))
|
||||
|
||||
# ── Vendored proto drift ─────────────────────────────────────────
|
||||
lines.append(md_h("Vendored tree-sitter-proto", 2))
|
||||
vendored_abi = extract_language_version(VENDOR_PROTO_DIR / "src" / "parser.c")
|
||||
# ── Bucketize ────────────────────────────────────────────────────
|
||||
by_bucket: dict[str, list[dict]] = {
|
||||
k: [] for k in ("ready", "intentional", "waiting", "blocked", "fetch_failed")
|
||||
}
|
||||
for row in grammar_rows:
|
||||
by_bucket[row["bucket"]].append(row)
|
||||
bump_now = [r for r in grammar_rows if r["bump_now"]]
|
||||
ready_count = len(by_bucket["ready"])
|
||||
|
||||
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
|
||||
# ── TL;DR ────────────────────────────────────────────────────────
|
||||
npm_count = len(grammar_rows)
|
||||
vendored_count = len(vendored_grammars)
|
||||
vendored_ready = sum(1 for v in vendored_grammars if v["target_compat"])
|
||||
|
||||
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**.")
|
||||
if not blockers:
|
||||
verdict = "**Ready** — all grammars are 0.25-compatible. The runtime upgrade can proceed."
|
||||
else:
|
||||
lines.append("All grammars are compatible. Upgrade to `tree-sitter@0.25` is **ready**.")
|
||||
moved = "no" if not by_bucket["waiting"] else f"yes — {len(by_bucket['waiting'])} grammars have unreleased fixes on main"
|
||||
verdict = (
|
||||
f"**Blocked** — {len(blockers)} grammars are not yet 0.25-compatible. "
|
||||
f"Upstream movement: {moved}."
|
||||
)
|
||||
|
||||
lines.append(md_h("TL;DR", 2))
|
||||
lines.append(verdict)
|
||||
lines.append("")
|
||||
lines.append(f"- {ready_count}/{npm_count} npm-installed grammars already accept tree-sitter@{TARGET_RUNTIME}")
|
||||
if vendored_count:
|
||||
lines.append(
|
||||
f"- {vendored_ready}/{vendored_count} vendored grammars at an ABI within the target runtime range"
|
||||
)
|
||||
lines.append(f"- {len(by_bucket['intentional'])} intentionally pinned (see below)")
|
||||
lines.append(f"- {len(by_bucket['waiting'])} waiting on an upstream npm release")
|
||||
lines.append(f"- {len(by_bucket['blocked'])} blocked on upstream (no fix even on main)")
|
||||
if by_bucket['fetch_failed']:
|
||||
lines.append(f"- {len(by_bucket['fetch_failed'])} could not be checked (npm registry unreachable)")
|
||||
if bump_now:
|
||||
lines.append(
|
||||
f"- **{len(bump_now)} bump candidate(s) you can take TODAY** (npm-latest "
|
||||
f"is newer than the pin AND its peer dep accepts our current runtime)"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# ── What you can do today ───────────────────────────────────────
|
||||
if bump_now:
|
||||
lines.append(md_h("What you can do today", 2))
|
||||
lines.append(
|
||||
"These pins lag npm latest and the latest version's peer dep already "
|
||||
"accepts our current `tree-sitter@" + current_runtime + ".x` runtime. "
|
||||
"Bumping is independent of the 0.25 upgrade and should be a quick PR."
|
||||
)
|
||||
lines.append("")
|
||||
for r in sorted(bump_now, key=lambda r: r["name"]):
|
||||
lines.append(
|
||||
f"- `{r['name']}`: `{r['pinned_spec']}` → `{r['npm_version']}` "
|
||||
f"(peer `{r['peer_range'] or 'none'}`)"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# ── Per-disposition sections ────────────────────────────────────
|
||||
def _emit_bucket(title: str, body_intro: str, rows: list[dict], render) -> None:
|
||||
if not rows:
|
||||
return
|
||||
lines.append(md_h(f"{title} ({len(rows)})", 3))
|
||||
lines.append(body_intro)
|
||||
lines.append("")
|
||||
for r in sorted(rows, key=lambda r: r["name"]):
|
||||
lines.append(render(r))
|
||||
lines.append("")
|
||||
|
||||
lines.append(md_h("Disposition", 2))
|
||||
|
||||
_emit_bucket(
|
||||
"Ready for 0.25",
|
||||
"These grammars' npm-latest peer dep already accepts the target runtime. No action needed for the upgrade.",
|
||||
by_bucket["ready"],
|
||||
lambda r: (
|
||||
f"- `{r['name']}` — pinned `{r['pinned_spec']}`, npm latest `{r['npm_version']}`"
|
||||
+ (" _(also a bump candidate — see above)_" if r["bump_now"] else "")
|
||||
),
|
||||
)
|
||||
|
||||
if by_bucket["intentional"]:
|
||||
lines.append(md_h(f"Intentionally pinned ({len(by_bucket['intentional'])})", 3))
|
||||
lines.append(
|
||||
"Deliberately held below npm latest. These are **not** drift — each entry "
|
||||
"lists the issue motivating the pin and the condition for unpinning."
|
||||
)
|
||||
lines.append("")
|
||||
for r in sorted(by_bucket["intentional"], key=lambda r: r["name"]):
|
||||
reason = INTENTIONAL_PINS.get(r["name"], "(no rationale recorded)")
|
||||
lines.append(
|
||||
f"- `{r['name']}` pinned at `{r['pinned_spec']}` "
|
||||
f"(npm latest `{r['npm_version']}`)\n {reason}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
_emit_bucket(
|
||||
"Waiting on upstream npm release",
|
||||
"Fixes are merged on the upstream main branch but not yet published to npm. "
|
||||
"We can move forward as soon as upstream cuts a release.",
|
||||
by_bucket["waiting"],
|
||||
lambda r: (
|
||||
f"- `{r['name']}@{r['npm_version']}` — peer `{r['peer_range'] or 'none'}`. "
|
||||
f"_{r['upstream_progress']}_"
|
||||
),
|
||||
)
|
||||
|
||||
_emit_bucket(
|
||||
"Blocked on upstream",
|
||||
"Peer dep is too tight on both the latest npm release and on upstream main. "
|
||||
"These need an upstream issue/PR before we can proceed.",
|
||||
by_bucket["blocked"],
|
||||
lambda r: (
|
||||
f"- `{r['name']}@{r['npm_version']}` — peer `{r['peer_range'] or 'none'}`"
|
||||
+ (" _(vendored)_" if r["is_vendored"] else "")
|
||||
),
|
||||
)
|
||||
|
||||
_emit_bucket(
|
||||
"Could not check",
|
||||
"npm registry fetch failed for these grammars. Re-run the workflow to retry.",
|
||||
by_bucket["fetch_failed"],
|
||||
lambda r: f"- `{r['name']}` (pinned `{r['pinned_spec']}`)",
|
||||
)
|
||||
|
||||
# ── Vendored parsers ────────────────────────────────────────────
|
||||
if vendored_grammars:
|
||||
lines.append(md_h(f"Vendored parsers ({len(vendored_grammars)})", 2))
|
||||
lines.append(
|
||||
"These grammars ship from `gitnexus/vendor/` rather than the npm "
|
||||
"registry. Their compatibility is governed by the **vendored "
|
||||
"ABI** (must lie in the target runtime's range), not by a peer-"
|
||||
"dep negotiation. The rationale for each vendored copy lives in "
|
||||
"its own `package.json` `_vendoredBy` field."
|
||||
)
|
||||
lines.append("")
|
||||
for v in sorted(vendored_grammars, key=lambda v: v["name"]):
|
||||
sync_label = (
|
||||
"in sync with upstream" if v["in_sync"] else "diverged from upstream"
|
||||
)
|
||||
if v["abi_state"] == "in_range":
|
||||
abi_label = f"ABI `{v['vendored_abi']}` (in target range)"
|
||||
elif v["abi_state"] == "prebuilt":
|
||||
abi_label = "ABI `prebuilt` (binary-only vendor, source not introspectable)"
|
||||
else:
|
||||
abi_label = (
|
||||
f"ABI `{v['vendored_abi']}` (**outside** target range "
|
||||
f"{target_abi_range[0]}..{target_abi_range[1]})"
|
||||
)
|
||||
upstream_abi_str = (
|
||||
f"ABI `{v['upstream_abi']}`" if v["upstream_abi"] else "ABI `?`"
|
||||
)
|
||||
lines.append(
|
||||
f"- **`{v['name']}`** `{v['vendored_version']}` — {abi_label}, "
|
||||
f"upstream `{v['upstream_repo']}@{v['upstream_sha']}` "
|
||||
f"{upstream_abi_str} · {sync_label}"
|
||||
)
|
||||
if v["vendored_by"]:
|
||||
# Show the first sentence — vendor package.json fields tend
|
||||
# to start with the rationale and tail off into install-
|
||||
# script breadcrumbs that aren't useful in this report.
|
||||
rationale = _first_sentence(v["vendored_by"])
|
||||
lines.append(f" - **Why vendored:** {rationale}")
|
||||
# Action computation: needs regen iff upstream ABI exceeds
|
||||
# vendored AND is still within target range. If upstream ABI
|
||||
# exceeds the target, that's a runtime-side blocker. For
|
||||
# prebuilt-only vendors we can't drive this from source ABI;
|
||||
# the action is a manual upstream-binary refresh, surfaced
|
||||
# via the in-sync flag instead.
|
||||
if v["abi_state"] == "prebuilt":
|
||||
if not v["in_sync"]:
|
||||
lines.append(
|
||||
" - **Action:** check whether upstream has shipped a new "
|
||||
"prebuilt release; this vendor ships binary-only artefacts."
|
||||
)
|
||||
elif v["upstream_abi"] and v["vendored_abi"] and v["upstream_abi"] > v["vendored_abi"]:
|
||||
if v["upstream_abi"] <= target_abi_range[1]:
|
||||
lines.append(
|
||||
f" - **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, "
|
||||
f"regenerate `parser.c` from upstream `{v['upstream_sha']}`."
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f" - **Action:** wait for a runtime supporting ABI "
|
||||
f"{v['upstream_abi']}; current target ({TARGET_RUNTIME}) only "
|
||||
f"goes up to ABI {target_abi_range[1]}."
|
||||
)
|
||||
blockers[f"vendored-{v['name']}-abi"] = (
|
||||
f"vendored {v['name']}: upstream ABI {v['upstream_abi']} outside target range"
|
||||
)
|
||||
elif not v["in_sync"]:
|
||||
lines.append(
|
||||
" - **Action:** review upstream changes; vendored copy may "
|
||||
"need a refresh (no ABI bump required)."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# ── Raw matrix (for completeness + workflow row-diff) ────────────
|
||||
lines.append(md_h("Full grammar matrix", 2))
|
||||
lines.append(
|
||||
"<details><summary>Click to expand the raw per-grammar table "
|
||||
"(used by the workflow's change-detection bot).</summary>\n"
|
||||
)
|
||||
lines.extend(raw_matrix)
|
||||
lines.append("\n</details>")
|
||||
lines.append("")
|
||||
|
||||
print("\n".join(lines))
|
||||
return 1 if blockers else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Force UTF-8 output: the report contains em-dashes and arrows that
|
||||
# Windows' default cp1252 codepage can't encode, while Linux runners
|
||||
# default to UTF-8 anyway.
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(main())
|
||||
|
||||
@@ -28,6 +28,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Configure e2e GitNexus home
|
||||
run: echo "GITNEXUS_HOME=${RUNNER_TEMP}/gitnexus-home" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: ./.github/actions/setup-gitnexus-web
|
||||
|
||||
- name: Install Playwright browsers
|
||||
@@ -44,9 +47,14 @@ jobs:
|
||||
|
||||
- name: Analyze repository (index for backend)
|
||||
run: |
|
||||
node gitnexus/dist/cli/index.js analyze || true
|
||||
if [ ! -d ".gitnexus" ]; then
|
||||
echo "::error::No .gitnexus index created"
|
||||
E2E_REPO="${RUNNER_TEMP}/gitnexus-e2e-repo"
|
||||
rm -rf "${E2E_REPO}"
|
||||
mkdir -p "${E2E_REPO}"
|
||||
cp -R gitnexus/test/fixtures/mini-repo/src "${E2E_REPO}/src"
|
||||
printf '%s\n' '{"name":"e2e-mini-repo","version":"0.0.0","private":true}' > "${E2E_REPO}/package.json"
|
||||
node gitnexus/dist/cli/index.js analyze "${E2E_REPO}" --skip-git --skip-agents-md --name e2e-mini-repo
|
||||
if [ ! -d "${E2E_REPO}/.gitnexus" ]; then
|
||||
echo "::error::No fixture .gitnexus index created"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
@@ -15,13 +12,12 @@ on:
|
||||
# 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.).
|
||||
# immune. Direct `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. `push` to main is handled by release-candidate.yml, which
|
||||
# calls this workflow once before publishing.
|
||||
concurrency:
|
||||
group: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && format('CI-{0}', github.ref) || format('CI-nested-{0}', github.run_id) }}
|
||||
group: ${{ github.event_name == 'pull_request' && format('CI-{0}', github.ref) || format('CI-nested-{0}', github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# ── Reusable workflow orchestration ─────────────────────────────────
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
name: Claude Code Review
|
||||
|
||||
# Uses pull_request_target so the workflow runs as defined on the default branch,
|
||||
# which allows access to secrets for posting review comments on fork PRs.
|
||||
# SECURITY: The checkout pins the fork's HEAD SHA (not the branch name) to
|
||||
# prevent TOCTOU races (force-push between trigger and checkout). The
|
||||
# claude-code-action sandboxes execution — it does NOT run arbitrary code
|
||||
# from the checked-out source.
|
||||
|
||||
on:
|
||||
# Trigger only when explicitly requested:
|
||||
# - Add the "claude-review" label to a PR, OR
|
||||
# - Comment "@claude" or "/review" on a PR
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Serialize per-PR to avoid racing review comments.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Run only when:
|
||||
# 1. The "claude-review" label is added to a non-draft PR by a trusted contributor, OR
|
||||
# 2. A trusted contributor comments "@claude" or "/review" on a PR
|
||||
if: |
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.label.name == 'claude-review' &&
|
||||
github.event.pull_request.draft == false &&
|
||||
(github.event.pull_request.author_association == 'OWNER' ||
|
||||
github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
(contains(github.event.comment.body, '@claude') ||
|
||||
contains(github.event.comment.body, '/review')) &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
# For issue_comment triggers, resolve the PR number, head SHA, and fork repo
|
||||
- name: Resolve PR context
|
||||
id: pr
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
|
||||
with:
|
||||
script: |
|
||||
let pr;
|
||||
if (context.eventName === 'issue_comment') {
|
||||
const resp = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.issue.number,
|
||||
});
|
||||
pr = resp.data;
|
||||
} else {
|
||||
pr = context.payload.pull_request;
|
||||
}
|
||||
core.setOutput('number', pr.number);
|
||||
core.setOutput('sha', pr.head.sha);
|
||||
core.setOutput('repo', pr.head.repo.full_name);
|
||||
core.setOutput('branch', pr.head.ref);
|
||||
|
||||
- name: Checkout PR head
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.repo }}
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: '*'
|
||||
show_full_output: true
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
|
||||
@@ -1,8 +1,17 @@
|
||||
name: Claude Code
|
||||
|
||||
# Label-triggered code-review requests use pull_request_target so the workflow
|
||||
# runs as defined on the default branch, which allows access to secrets for
|
||||
# posting review comments on fork PRs. SECURITY: PR checkouts pin the fork's
|
||||
# HEAD SHA (not the branch name) to prevent TOCTOU races.
|
||||
# The claude-code-action sandboxes execution; it does not run arbitrary code
|
||||
# from the checked-out source.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
@@ -21,7 +30,10 @@ jobs:
|
||||
if: |
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
(
|
||||
contains(github.event.comment.body, '@claude') ||
|
||||
(github.event.issue.pull_request && contains(github.event.comment.body, '/review'))
|
||||
) &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')
|
||||
@@ -46,6 +58,14 @@ jobs:
|
||||
(github.event.issue.author_association == 'OWNER' ||
|
||||
github.event.issue.author_association == 'MEMBER' ||
|
||||
github.event.issue.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.label.name == 'claude-review' &&
|
||||
github.event.pull_request.draft == false &&
|
||||
(github.event.pull_request.author_association == 'OWNER' ||
|
||||
github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'COLLABORATOR')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
@@ -63,33 +83,48 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
// Determine if this event is PR-related
|
||||
let prNumber = null;
|
||||
let pr = null;
|
||||
if (context.eventName === 'issue_comment' && context.payload.issue.pull_request) {
|
||||
prNumber = context.payload.issue.number;
|
||||
const resp = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.issue.number,
|
||||
});
|
||||
pr = resp.data;
|
||||
} else if (context.eventName === 'pull_request_review_comment') {
|
||||
prNumber = context.payload.pull_request.number;
|
||||
pr = context.payload.pull_request;
|
||||
} else if (context.eventName === 'pull_request_review') {
|
||||
prNumber = context.payload.pull_request.number;
|
||||
pr = context.payload.pull_request;
|
||||
} else if (context.eventName === 'pull_request_target') {
|
||||
pr = context.payload.pull_request;
|
||||
}
|
||||
|
||||
if (!prNumber) {
|
||||
if (!pr) {
|
||||
core.setOutput('is_pr', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
const pr = resp.data;
|
||||
|
||||
core.setOutput('is_pr', 'true');
|
||||
core.setOutput('number', String(prNumber));
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('sha', pr.head.sha);
|
||||
core.setOutput('repo', pr.head.repo.full_name);
|
||||
core.setOutput('branch', pr.head.ref);
|
||||
|
||||
- name: Resolve Claude mode
|
||||
id: mode
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
|
||||
with:
|
||||
script: |
|
||||
const body = (context.payload.comment?.body ?? '').toLowerCase();
|
||||
const isCodeReview =
|
||||
(context.eventName === 'pull_request_target' &&
|
||||
context.payload.label?.name === 'claude-review') ||
|
||||
(context.eventName === 'issue_comment' &&
|
||||
Boolean(context.payload.issue?.pull_request) &&
|
||||
body.includes('/review'));
|
||||
|
||||
core.setOutput('code_review', isCodeReview ? 'true' : 'false');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -98,6 +133,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
if: steps.mode.outputs.code_review != 'true'
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
|
||||
with:
|
||||
@@ -109,3 +145,16 @@ jobs:
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
- name: Run Claude Code Review
|
||||
if: steps.mode.outputs.code_review == 'true'
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: '*'
|
||||
show_full_output: true
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
|
||||
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
# 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
|
||||
- uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
dry-run: true
|
||||
|
||||
@@ -107,3 +107,4 @@ local_docs/
|
||||
.tmp/
|
||||
.agents/
|
||||
.context/
|
||||
gitnexus/web/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# GitNexus
|
||||
⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
|
||||
**⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
||||
|
||||
> *Like DeepWiki, but deeper.* DeepWiki helps you *understand* code. GitNexus lets you *analyze* it — because a knowledge graph tracks every relationship, not just descriptions.
|
||||
|
||||
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with goliath models.
|
||||
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with Goliath models.
|
||||
|
||||
---
|
||||
|
||||
@@ -197,6 +197,7 @@ gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexu
|
||||
gitnexus analyze --skip-git # Index folders that are not Git repositories
|
||||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
|
||||
gitnexus list # List all indexed repositories
|
||||
@@ -218,6 +219,8 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
|
||||
gitnexus group status <name> # Check staleness of repos in a group
|
||||
```
|
||||
|
||||
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
|
||||
|
||||
### What Your AI Agent Gets
|
||||
|
||||
**16 tools** exposed via MCP (11 per-repo + 5 group):
|
||||
@@ -321,19 +324,21 @@ flowchart TD
|
||||
|
||||
## Web UI (browser-based)
|
||||
|
||||
A fully client-side graph explorer and AI chat. No server, no install — your code never leaves the browser.
|
||||
A client-side graph explorer and AI chat — your code never leaves your machine.
|
||||
|
||||
**Try it now:** [gitnexus.vercel.app](https://gitnexus.vercel.app) — drag & drop a ZIP and start exploring.
|
||||
**Try it now:** [gitnexus.vercel.app](https://gitnexus.vercel.app) — run `npx gitnexus@latest serve` locally and the page auto-connects to your local backend.
|
||||
|
||||
<img width="2550" height="1343" alt="gitnexus_img" src="https://github.com/user-attachments/assets/cc5d637d-e0e5-48e6-93ff-5bcfdb929285" />
|
||||
|
||||
Or run locally:
|
||||
Or run the frontend locally:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/abhigyanpatwari/gitnexus.git
|
||||
cd gitnexus/gitnexus-shared && npm install && npm run build
|
||||
cd ../gitnexus-web && npm install
|
||||
npm run dev
|
||||
# Then in another terminal, start the backend the frontend connects to:
|
||||
npx gitnexus@latest serve
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ services:
|
||||
- ${WORKSPACE_DIR:-./workspace}:/workspace:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-fsS', 'http://localhost:4747/api/heartbeat']
|
||||
test: ['CMD', 'curl', '-fsSI', 'http://localhost:4747/api/heartbeat']
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -82,6 +82,9 @@ matching:
|
||||
bm25_threshold: 0.7
|
||||
embedding_threshold: 0.65
|
||||
max_candidates_per_step: 3
|
||||
# Exclude noisy paths from cross-link matching (contracts are still extracted)
|
||||
exclude_links_paths: [/ping, /health, /healthcheck]
|
||||
exclude_links_param_only_paths: true
|
||||
```
|
||||
|
||||
Field notes (schema in [`types.ts`](../../gitnexus/src/core/group/types.ts)):
|
||||
@@ -91,7 +94,9 @@ Field notes (schema in [`types.ts`](../../gitnexus/src/core/group/types.ts)):
|
||||
- `repos` — a mapping from **group path** (a logical name you choose; can be a hierarchy like `backend/orders`) to **registry name** (the name shown by `npx gitnexus list`). Both sides appear throughout the tooling: contract rows use the group path; `@<group>/<groupPath>` routes tools to a single member.
|
||||
- `links` — optional manifest escape hatch, one entry per explicit cross-repo contract. Validated by the parser: `from` and `to` must be known repo paths, `type` must be one of `http | grpc | topic | lib | custom`, and `role` must be `provider | consumer`.
|
||||
- `detect` — toggles per extractor family. Defaults (set in `config-parser.ts`) turn `http`, `grpc`, `topics`, and `shared_libs` on; disable the ones you don't use to speed up sync.
|
||||
- `matching` — thresholds for the matching cascade. The exact match is always run; other strategies depend on indexer state.
|
||||
- `matching` — thresholds for the matching cascade. The exact match is always run; other strategies depend on indexer state. Two optional fields reduce false-positive cross-links in large groups:
|
||||
- `exclude_links_paths` — list of HTTP paths to exclude from cross-link matching (default `[]`). Contracts at these paths are still extracted and visible in the registry, but they don't produce cross-repo links. Useful for health-check endpoints (`/ping`, `/health`) that every service exposes. Trailing slashes are normalized.
|
||||
- `exclude_links_param_only_paths` — when `true`, exclude routes where every segment is `{param}` (e.g. `/{param}`, `/{param}/{param}`) from cross-link matching (default `false`). Mixed routes like `/users/{param}` are not affected.
|
||||
|
||||
### 3. Sync the group
|
||||
|
||||
|
||||
+20
-19
@@ -53,13 +53,13 @@ class MCPBridge:
|
||||
|
||||
try:
|
||||
# Find gitnexus binary
|
||||
gitnexus_bin = self._find_gitnexus()
|
||||
if not gitnexus_bin:
|
||||
gitnexus_cmd = self._find_gitnexus_command()
|
||||
if not gitnexus_cmd:
|
||||
logger.error("GitNexus not found. Install with: npm install -g gitnexus")
|
||||
return False
|
||||
|
||||
self.process = subprocess.Popen(
|
||||
[gitnexus_bin, "mcp"],
|
||||
[*gitnexus_cmd, "mcp"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
@@ -152,33 +152,34 @@ class MCPBridge:
|
||||
return contents[0].get("text", "")
|
||||
return None
|
||||
|
||||
def _find_gitnexus(self) -> str | None:
|
||||
"""Find the gitnexus CLI binary."""
|
||||
def _find_gitnexus_command(self) -> list[str] | None:
|
||||
"""Find the gitnexus CLI command prefix."""
|
||||
# Check if npx is available (preferred - uses local install)
|
||||
for cmd in ["npx"]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[cmd, "gitnexus", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=MCP_FIND_GITNEXUS_TIMEOUT_SECONDS,
|
||||
cwd=self.repo_path,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return cmd # Will use "npx gitnexus mcp"
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["npx", "gitnexus", "--version"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=MCP_FIND_GITNEXUS_TIMEOUT_SECONDS,
|
||||
cwd=self.repo_path,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return ["npx", "gitnexus"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check for global install
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["gitnexus", "--version"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=MCP_FIND_GITNEXUS_FALLBACK_TIMEOUT_SECONDS,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return "gitnexus"
|
||||
return ["gitnexus"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Tests for MCPBridge._find_gitnexus_command() and subprocess spawn."""
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
|
||||
class TestFindGitnexusCommand(unittest.TestCase):
|
||||
"""Verify _find_gitnexus_command() returns the correct command prefix."""
|
||||
|
||||
def _make_bridge(self):
|
||||
from bridge.mcp_bridge import MCPBridge
|
||||
return MCPBridge(repo_path="/fake/repo")
|
||||
|
||||
def _success(self):
|
||||
r = MagicMock()
|
||||
r.returncode = 0
|
||||
return r
|
||||
|
||||
def _failure(self):
|
||||
r = MagicMock()
|
||||
r.returncode = 1
|
||||
return r
|
||||
|
||||
def test_npx_path_returns_npx_gitnexus(self):
|
||||
"""When npx probe succeeds, command prefix is ['npx', 'gitnexus']."""
|
||||
with patch("subprocess.run", return_value=self._success()) as mock_run:
|
||||
bridge = self._make_bridge()
|
||||
result = bridge._find_gitnexus_command()
|
||||
|
||||
self.assertEqual(result, ["npx", "gitnexus"])
|
||||
mock_run.assert_called_once()
|
||||
args = mock_run.call_args[0][0]
|
||||
self.assertEqual(args, ["npx", "gitnexus", "--version"])
|
||||
|
||||
def test_global_path_returns_gitnexus(self):
|
||||
"""When npx probe fails but global install exists, prefix is ['gitnexus']."""
|
||||
with patch("subprocess.run", side_effect=[self._failure(), self._success()]) as mock_run:
|
||||
bridge = self._make_bridge()
|
||||
result = bridge._find_gitnexus_command()
|
||||
|
||||
self.assertEqual(result, ["gitnexus"])
|
||||
self.assertEqual(mock_run.call_count, 2)
|
||||
|
||||
def test_both_fail_returns_none(self):
|
||||
"""When both probes fail, returns None."""
|
||||
with patch("subprocess.run", return_value=self._failure()):
|
||||
bridge = self._make_bridge()
|
||||
result = bridge._find_gitnexus_command()
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_npx_exception_falls_back_to_global(self):
|
||||
"""When npx raises (not installed), falls back to global probe."""
|
||||
with patch("subprocess.run", side_effect=[FileNotFoundError, self._success()]):
|
||||
bridge = self._make_bridge()
|
||||
result = bridge._find_gitnexus_command()
|
||||
|
||||
self.assertEqual(result, ["gitnexus"])
|
||||
|
||||
def test_both_raise_returns_none(self):
|
||||
"""When both probes raise exceptions, returns None."""
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError):
|
||||
bridge = self._make_bridge()
|
||||
result = bridge._find_gitnexus_command()
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_stdin_devnull_on_npx_probe(self):
|
||||
"""npx probe must pass stdin=DEVNULL to prevent interactive blocking."""
|
||||
with patch("subprocess.run", return_value=self._success()) as mock_run:
|
||||
bridge = self._make_bridge()
|
||||
bridge._find_gitnexus_command()
|
||||
|
||||
kwargs = mock_run.call_args[1]
|
||||
self.assertEqual(kwargs.get("stdin"), subprocess.DEVNULL)
|
||||
|
||||
def test_stdin_devnull_on_global_probe(self):
|
||||
"""global probe must pass stdin=DEVNULL to prevent interactive blocking."""
|
||||
with patch("subprocess.run", side_effect=[self._failure(), self._success()]) as mock_run:
|
||||
bridge = self._make_bridge()
|
||||
bridge._find_gitnexus_command()
|
||||
|
||||
global_call_kwargs = mock_run.call_args_list[1][1]
|
||||
self.assertEqual(global_call_kwargs.get("stdin"), subprocess.DEVNULL)
|
||||
|
||||
|
||||
class TestStartSpawnCommand(unittest.TestCase):
|
||||
"""Verify start() spawns Popen with the correct argv."""
|
||||
|
||||
def _make_bridge(self):
|
||||
from bridge.mcp_bridge import MCPBridge
|
||||
return MCPBridge(repo_path="/fake/repo")
|
||||
|
||||
def test_npx_path_spawns_npx_gitnexus_mcp(self):
|
||||
"""When npx path found, Popen must receive ['npx', 'gitnexus', 'mcp']."""
|
||||
bridge = self._make_bridge()
|
||||
|
||||
with patch.object(bridge, "_find_gitnexus_command", return_value=["npx", "gitnexus"]), \
|
||||
patch("subprocess.Popen") as mock_popen, \
|
||||
patch.object(bridge, "_send_request", return_value={"protocolVersion": "2024-11-05"}), \
|
||||
patch.object(bridge, "_send_notification"):
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.stdin = MagicMock()
|
||||
mock_proc.stdout = MagicMock()
|
||||
mock_proc.stderr = MagicMock()
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
bridge.start()
|
||||
|
||||
mock_popen.assert_called_once()
|
||||
argv = mock_popen.call_args[0][0]
|
||||
self.assertEqual(argv, ["npx", "gitnexus", "mcp"])
|
||||
|
||||
def test_global_path_spawns_gitnexus_mcp(self):
|
||||
"""When global path found, Popen must receive ['gitnexus', 'mcp']."""
|
||||
bridge = self._make_bridge()
|
||||
|
||||
with patch.object(bridge, "_find_gitnexus_command", return_value=["gitnexus"]), \
|
||||
patch("subprocess.Popen") as mock_popen, \
|
||||
patch.object(bridge, "_send_request", return_value={"protocolVersion": "2024-11-05"}), \
|
||||
patch.object(bridge, "_send_notification"):
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.stdin = MagicMock()
|
||||
mock_proc.stdout = MagicMock()
|
||||
mock_proc.stderr = MagicMock()
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
bridge.start()
|
||||
|
||||
mock_popen.assert_called_once()
|
||||
argv = mock_popen.call_args[0][0]
|
||||
self.assertEqual(argv, ["gitnexus", "mcp"])
|
||||
|
||||
def test_no_shell_true(self):
|
||||
"""Popen must never be called with shell=True."""
|
||||
bridge = self._make_bridge()
|
||||
|
||||
with patch.object(bridge, "_find_gitnexus_command", return_value=["npx", "gitnexus"]), \
|
||||
patch("subprocess.Popen") as mock_popen, \
|
||||
patch.object(bridge, "_send_request", return_value={"protocolVersion": "2024-11-05"}), \
|
||||
patch.object(bridge, "_send_notification"):
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.stdin = MagicMock()
|
||||
mock_proc.stdout = MagicMock()
|
||||
mock_proc.stderr = MagicMock()
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
bridge.start()
|
||||
|
||||
kwargs = mock_popen.call_args[1]
|
||||
self.assertNotEqual(kwargs.get("shell"), True)
|
||||
|
||||
def test_gitnexus_not_found_returns_false(self):
|
||||
"""start() returns False and does not call Popen when gitnexus not found."""
|
||||
bridge = self._make_bridge()
|
||||
|
||||
with patch.object(bridge, "_find_gitnexus_command", return_value=None), \
|
||||
patch("subprocess.Popen") as mock_popen:
|
||||
|
||||
result = bridge.start()
|
||||
|
||||
self.assertFalse(result)
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -31,11 +31,25 @@ function readInput() {
|
||||
* Find the .gitnexus directory by walking up from startDir.
|
||||
* Returns the path to .gitnexus/ or null if not found.
|
||||
*/
|
||||
function findGitNexusDir(startDir) {
|
||||
let dir = startDir || process.cwd();
|
||||
function isGlobalRegistryDir(candidate) {
|
||||
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
|
||||
return (
|
||||
fs.existsSync(path.join(candidate, 'registry.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'repos'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from `startDir` looking for a non-registry `.gitnexus/` folder.
|
||||
* Returns the path to `.gitnexus/` or null if not found within 5 levels.
|
||||
*/
|
||||
function walkForGitNexusDir(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
if (fs.existsSync(candidate)) {
|
||||
if (!isGlobalRegistryDir(candidate)) return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
@@ -43,6 +57,51 @@ function findGitNexusDir(startDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside
|
||||
* any git working tree — including a *linked* worktree created via
|
||||
* `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the
|
||||
* upward walk from cwd alone misses the index. Returns null when `cwd` is
|
||||
* not inside a git repo or `git` is not available.
|
||||
*
|
||||
* Implementation: `git rev-parse --git-common-dir` resolves to the canonical
|
||||
* `.git/` directory (or `.git/worktrees/...` parent) that is shared across
|
||||
* all linked worktrees. The canonical repo root is its parent directory.
|
||||
*/
|
||||
function findCanonicalRepoRoot(cwd) {
|
||||
try {
|
||||
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const commonDir = (result.stdout || '').trim();
|
||||
if (!commonDir || !path.isAbsolute(commonDir)) return null;
|
||||
return path.dirname(commonDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findGitNexusDir(startDir) {
|
||||
const cwd = startDir || process.cwd();
|
||||
|
||||
// Fast path: the cwd is inside the canonical repo (most common case).
|
||||
const fromCwd = walkForGitNexusDir(cwd);
|
||||
if (fromCwd) return fromCwd;
|
||||
|
||||
// Fallback: cwd may be inside a linked git worktree whose `.gitnexus/`
|
||||
// only lives in the canonical repo root. Resolve the shared git dir
|
||||
// and retry from there.
|
||||
const canonicalRoot = findCanonicalRepoRoot(cwd);
|
||||
if (canonicalRoot && canonicalRoot !== cwd) {
|
||||
return walkForGitNexusDir(canonicalRoot);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract search pattern from tool input.
|
||||
*/
|
||||
|
||||
@@ -134,7 +134,11 @@ export type {
|
||||
// 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 {
|
||||
buildScopeTree,
|
||||
canParentScope,
|
||||
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';
|
||||
|
||||
@@ -740,10 +740,58 @@ function findExportByName(
|
||||
defs: readonly SymbolDefinition[],
|
||||
name: string,
|
||||
): SymbolDefinition | undefined {
|
||||
// GENERIC RULE (applies to every language using this finalize
|
||||
// algorithm): when MULTIPLE `SymbolDefinition`s share the same simple
|
||||
// name in `localDefs`, prefer callable / type-like defs over plain
|
||||
// value defs (`Variable`, `Property`, …). The CALLER side of an
|
||||
// import almost always wants the callable, not a value shadow that
|
||||
// happens to share the name — and without a deterministic
|
||||
// preference, capture order silently decides which def the import
|
||||
// binds to.
|
||||
//
|
||||
// The single-def case is unchanged: when only one def has the name,
|
||||
// it's returned regardless of its type (the `fallback` path below).
|
||||
//
|
||||
// TypeScript is the first known language where this matters in
|
||||
// practice: `const fn = () => {}` emits BOTH a `Function` def (from
|
||||
// `@declaration.function` on the inner arrow) AND a `Variable` def
|
||||
// (from the generic `@declaration.variable` pattern matching the
|
||||
// wrapping `lexical_declaration`), and consumers of `import { fn }`
|
||||
// need to bind to the callable. Other migrated languages don't
|
||||
// currently produce dual emits of this shape, so the rule is a no-op
|
||||
// for them today; future languages get the same correctness
|
||||
// guarantee for free if they ever do.
|
||||
//
|
||||
// See `gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts`
|
||||
// for the cross-file regression this rule prevents.
|
||||
let fallback: SymbolDefinition | undefined;
|
||||
for (const d of defs) {
|
||||
if (deriveSimpleName(d) === name) return d;
|
||||
if (deriveSimpleName(d) !== name) continue;
|
||||
if (isCallableOrTypeLike(d.type)) return d;
|
||||
if (fallback === undefined) fallback = d;
|
||||
}
|
||||
return undefined;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const CALLABLE_OR_TYPE_LIKE: ReadonlySet<string> = new Set([
|
||||
'Function',
|
||||
'Method',
|
||||
'Constructor',
|
||||
'Class',
|
||||
'Interface',
|
||||
'Enum',
|
||||
'Struct',
|
||||
'Record',
|
||||
'Trait',
|
||||
'Namespace',
|
||||
'Module',
|
||||
'TypeAlias',
|
||||
'Type',
|
||||
'Typedef',
|
||||
]);
|
||||
|
||||
function isCallableOrTypeLike(type: string): boolean {
|
||||
return CALLABLE_OR_TYPE_LIKE.has(type);
|
||||
}
|
||||
|
||||
function countEdgesWithin(edgeIndex: Map<string, ImportEdgeDraft[]>, files: Set<string>): number {
|
||||
|
||||
@@ -119,10 +119,10 @@ export function buildScopeTree(scopes: readonly Scope[]): ScopeTree {
|
||||
`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)) {
|
||||
if (!canParentScope(parent.range, scope.range, parent.kind, scope.kind)) {
|
||||
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)}.`,
|
||||
`Parent scope '${parent.id}' at ${formatRange(parent.range)} does not contain child '${scope.id}' at ${formatRange(scope.range)} (allowed: strict containment, or equal-range Module-as-parent).`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,6 +230,47 @@ function rangeStrictlyContains(outer: Range, inner: Range): boolean {
|
||||
return outerStartsAtOrBefore && outerEndsAtOrAfter;
|
||||
}
|
||||
|
||||
function rangesEqual(a: Range, b: Range): boolean {
|
||||
return (
|
||||
a.startLine === b.startLine &&
|
||||
a.startCol === b.startCol &&
|
||||
a.endLine === b.endLine &&
|
||||
a.endCol === b.endCol
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `outer` (kind `outerKind`) is a valid parent for `inner` (kind
|
||||
* `innerKind`).
|
||||
*
|
||||
* Strict containment is the general rule. The single carve-out is the
|
||||
* `Module`/non-`Module` pair whose ranges are exactly equal — this happens
|
||||
* naturally when tree-sitter reports identical byte spans for the
|
||||
* `compilation_unit` (or equivalent file-root construct) and the file's
|
||||
* single top-level scope. Common shape: a C# file consisting of nothing
|
||||
* but `namespace X { ... }` with no leading or trailing trivia outside the
|
||||
* namespace's `{}` body — `compilation_unit` and `namespace_declaration`
|
||||
* both span exactly the same byte range. The `Module` is the universal
|
||||
* outer of any file-level scope by language semantics, so coincident
|
||||
* ranges should not break the parent chain.
|
||||
*
|
||||
* The carve-out is direction-asymmetric: only `Module`-as-outer parents a
|
||||
* same-range non-`Module`, never the reverse. This preserves the
|
||||
* acyclicity buildScopeTree relies on, and matches the corresponding
|
||||
* helper in `scope-extractor.ts` so `pass1BuildScopes` and the validator
|
||||
* agree on what a well-formed parent edge looks like.
|
||||
*/
|
||||
export function canParentScope(
|
||||
outer: Range,
|
||||
inner: Range,
|
||||
outerKind: Scope['kind'],
|
||||
innerKind: Scope['kind'],
|
||||
): boolean {
|
||||
if (rangeStrictlyContains(outer, inner)) return true;
|
||||
if (outerKind === 'Module' && innerKind !== 'Module' && rangesEqual(outer, inner)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two ranges overlap when neither finishes before the other begins. Ranges
|
||||
* that merely touch at a single boundary point (`a.end === b.start`) do
|
||||
|
||||
Generated
+69
-95
@@ -13,7 +13,7 @@
|
||||
"@langchain/google-genai": "^2.1.28",
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/ollama": "^1.2.6",
|
||||
"@langchain/openai": "^1.4.4",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"axios": "^1.13.2",
|
||||
@@ -32,15 +32,15 @@
|
||||
"mermaid": "^11.14.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.2",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -50,9 +50,9 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
@@ -1435,9 +1435,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "1.1.41",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.41.tgz",
|
||||
"integrity": "sha512-KdoNEf1YVJ9jnOP+smq4O6teu63tE7GDUryOnZ2lVfooHLrHK/ECUadjOcDSCK/yk/xBw/8nexJ3ZNBMtKnstw==",
|
||||
"version": "1.1.42",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.42.tgz",
|
||||
"integrity": "sha512-d0tN96BrwPMryYyWR9VfyAntSivn7EQrZCe5Kpxum93tcjTXbKKmKvItFec8AluQt88iTcmAJrahUZUNfzGwTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.0.2",
|
||||
@@ -1449,26 +1449,12 @@
|
||||
"langsmith": ">=0.5.0 <1.0.0",
|
||||
"mustache": "^4.2.0",
|
||||
"p-queue": "^6.6.2",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/core/node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/google-genai": {
|
||||
"version": "2.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.1.28.tgz",
|
||||
@@ -1552,9 +1538,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk": {
|
||||
"version": "1.8.9",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.9.tgz",
|
||||
"integrity": "sha512-vpz90auS4iFTNy2X/CFexOEoeFSvaK+MyI7iSmzYs9gGcfzwRjWUJ4MWsuc5ZNRecLStwho0PExVXRgGOXtcRw==",
|
||||
"version": "1.8.10",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.10.tgz",
|
||||
"integrity": "sha512-wrB3rkRw5KAmsqezwvKP3midT4qJrV6Hj9XJMYo+cbvXC4HYpSAmyY/VriSyeTFRbLG/OP/pY2Yz+9Z54nSaXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15",
|
||||
@@ -1594,12 +1580,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk/node_modules/p-queue": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.2.tgz",
|
||||
"integrity": "sha512-ktsDOALzTYTWWF1PbkNVg2rOt+HaOaMWJMUnt7T3qf5tvZ1L8dBW3tObzprBcXNMKkwj+yFSLqHso0x+UFcJXw==",
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.2.0.tgz",
|
||||
"integrity": "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"p-timeout": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1621,6 +1607,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk/node_modules/uuid": {
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz",
|
||||
"integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph/node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
@@ -1664,20 +1663,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/openai": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.4.4.tgz",
|
||||
"integrity": "sha512-mRr/X5rvlwPj6cSXPxbL+CtOqYANO1/+CQ3Z+5t48kWnrlgPYOazmA+UAWvqQOuwJ6LaYn3SFrt43rR4lte/Ow==",
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.4.5.tgz",
|
||||
"integrity": "sha512-bQ2WMIZfSh02trJLYSAtiIcD3j6EBCiAm9nw0dZWQsVaUxmWc3JJqs8uUte6AkMazmLHzcUIw+14UkXO5fRJvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"openai": "^6.32.0",
|
||||
"openai": "^6.34.0",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.39"
|
||||
"@langchain/core": "^1.1.42"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
@@ -2885,13 +2884,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
|
||||
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
@@ -2900,30 +2899,23 @@
|
||||
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-syntax-highlighter": {
|
||||
@@ -5505,6 +5497,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
@@ -6047,18 +6040,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lowlight": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
|
||||
@@ -7746,28 +7727,24 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"react": "^19.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
@@ -7836,9 +7813,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-zoom-pan-pinch": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz",
|
||||
"integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-4.0.3.tgz",
|
||||
"integrity": "sha512-N2Hi6L78fFmhRra+ORpFSW7WST5x6kxpOPplIvtB0b7b+U2anpo1z1wLgaWRPS2kUSqcraRG+JgBCIlDJnqqAg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
@@ -8093,13 +8070,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
@@ -8558,9 +8532,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -8693,9 +8667,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
|
||||
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@langchain/google-genai": "^2.1.28",
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/ollama": "^1.2.6",
|
||||
"@langchain/openai": "^1.4.4",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"axios": "^1.13.2",
|
||||
@@ -42,15 +42,15 @@
|
||||
"mermaid": "^11.14.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.2",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -60,9 +60,9 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
|
||||
@@ -4,8 +4,8 @@ const DEFAULT_BOTTOM_THRESHOLD = 100;
|
||||
const USER_SCROLL_EPSILON = 5;
|
||||
|
||||
export interface UseAutoScrollResult {
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement>;
|
||||
messagesContainerRef: React.RefObject<HTMLDivElement>;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
isAtBottom: boolean;
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ interface UseSigmaOptions {
|
||||
}
|
||||
|
||||
interface UseSigmaReturn {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
sigmaRef: React.RefObject<Sigma | null>;
|
||||
setGraph: (graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>) => void;
|
||||
zoomIn: () => void;
|
||||
|
||||
@@ -3,7 +3,8 @@ WORKDIR /app
|
||||
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 \
|
||||
&& npm rebuild tree-sitter-swift 2>&1 \
|
||||
&& node -e "require('tree-sitter-swift')" \
|
||||
&& (npm rebuild 2>&1 || true) \
|
||||
&& cd node_modules/tree-sitter-kotlin && npx --yes node-gyp rebuild 2>&1
|
||||
CMD ["npx", "vitest", "run", "test/integration", "--reporter=verbose"]
|
||||
|
||||
@@ -156,6 +156,7 @@ gitnexus analyze --embeddings # Enable embedding generation (slower, better s
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768)
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI
|
||||
gitnexus index # Register an existing .gitnexus/ folder into the global registry
|
||||
@@ -295,6 +296,25 @@ If `npm install -g gitnexus` fails on native modules:
|
||||
npm install -g gitnexus
|
||||
```
|
||||
|
||||
### Analyze warns about unavailable FTS or VECTOR extensions
|
||||
|
||||
GitNexus uses optional DuckDB extensions for BM25 and vector search. The `gitnexus serve` and MCP read paths only ever try to `LOAD` the extensions — they never block on a network install. The `analyze` command, by default, attempts one bounded out-of-process `INSTALL` if `LOAD` fails and proceeds even when that install times out, so the index is always written to disk; BM25/vector search degrade gracefully until the extensions become available.
|
||||
|
||||
Configure the behavior with two environment variables:
|
||||
|
||||
| Variable | Values | Default | Effect |
|
||||
|----------|--------|---------|--------|
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded INSTALL if LOAD fails. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. |
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process `INSTALL` child before it is killed. |
|
||||
|
||||
```bash
|
||||
# Offline/airgapped: never reach the network for extensions
|
||||
GITNEXUS_LBUG_EXTENSION_INSTALL=load-only npx gitnexus analyze
|
||||
|
||||
# Slow network: give extension downloads more time
|
||||
GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS=30000 npx gitnexus analyze
|
||||
```
|
||||
|
||||
### Analysis runs out of memory
|
||||
|
||||
For very large repositories:
|
||||
@@ -323,6 +343,21 @@ npx gitnexus analyze
|
||||
|
||||
Values above **32768 KB (32 MB)** are clamped to the tree-sitter parser ceiling; invalid values fall back to the 512 KB default with a one-time warning. When an override is active, `analyze` prints the effective threshold in its startup banner (e.g. `GITNEXUS_MAX_FILE_SIZE: effective threshold 2048KB (default 512KB)`).
|
||||
|
||||
### Analyze reports a worker timeout
|
||||
|
||||
Worker parse timeouts are recoverable. GitNexus retries stalled worker jobs with backoff, splits large jobs to isolate slow files, and falls back to the sequential parser when needed. If a large repository needs more time per worker job, use either:
|
||||
|
||||
```bash
|
||||
# CLI flag, in seconds
|
||||
npx gitnexus analyze --worker-timeout 60
|
||||
|
||||
# Environment variable, in milliseconds
|
||||
export GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
For repositories with very large source files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget. The default is **8388608 bytes (8 MB)**.
|
||||
|
||||
## Privacy
|
||||
|
||||
- All processing happens locally on your machine
|
||||
|
||||
@@ -31,11 +31,25 @@ function readInput() {
|
||||
* Find the .gitnexus directory by walking up from startDir.
|
||||
* Returns the path to .gitnexus/ or null if not found.
|
||||
*/
|
||||
function findGitNexusDir(startDir) {
|
||||
let dir = startDir || process.cwd();
|
||||
function isGlobalRegistryDir(candidate) {
|
||||
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
|
||||
return (
|
||||
fs.existsSync(path.join(candidate, 'registry.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'repos'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from `startDir` looking for a non-registry `.gitnexus/` folder.
|
||||
* Returns the path to `.gitnexus/` or null if not found within 5 levels.
|
||||
*/
|
||||
function walkForGitNexusDir(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
if (fs.existsSync(candidate)) {
|
||||
if (!isGlobalRegistryDir(candidate)) return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
@@ -43,6 +57,51 @@ function findGitNexusDir(startDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside
|
||||
* any git working tree — including a *linked* worktree created via
|
||||
* `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the
|
||||
* upward walk from cwd alone misses the index. Returns null when `cwd` is
|
||||
* not inside a git repo or `git` is not available.
|
||||
*
|
||||
* Implementation: `git rev-parse --git-common-dir` resolves to the canonical
|
||||
* `.git/` directory (or `.git/worktrees/...` parent) that is shared across
|
||||
* all linked worktrees. The canonical repo root is its parent directory.
|
||||
*/
|
||||
function findCanonicalRepoRoot(cwd) {
|
||||
try {
|
||||
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const commonDir = (result.stdout || '').trim();
|
||||
if (!commonDir || !path.isAbsolute(commonDir)) return null;
|
||||
return path.dirname(commonDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findGitNexusDir(startDir) {
|
||||
const cwd = startDir || process.cwd();
|
||||
|
||||
// Fast path: the cwd is inside the canonical repo (most common case).
|
||||
const fromCwd = walkForGitNexusDir(cwd);
|
||||
if (fromCwd) return fromCwd;
|
||||
|
||||
// Fallback: cwd may be inside a linked git worktree whose `.gitnexus/`
|
||||
// only lives in the canonical repo root. Resolve the shared git dir
|
||||
// and retry from there.
|
||||
const canonicalRoot = findCanonicalRepoRoot(cwd);
|
||||
if (canonicalRoot && canonicalRoot !== cwd) {
|
||||
return walkForGitNexusDir(canonicalRoot);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract search pattern from tool input.
|
||||
*/
|
||||
|
||||
Generated
+108
-205
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.3",
|
||||
"version": "1.6.4-rc.48",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.3",
|
||||
"version": "1.6.4-rc.48",
|
||||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^4.1.0",
|
||||
"@ladybugdb/core": "^0.15.2",
|
||||
"@ladybugdb/core": "^0.16.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
@@ -30,9 +30,9 @@
|
||||
"onnxruntime-node": "^1.24.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"tree-sitter": "^0.21.1",
|
||||
"tree-sitter-c": "0.23.2",
|
||||
"tree-sitter-c": "0.21.4",
|
||||
"tree-sitter-c-sharp": "0.23.1",
|
||||
"tree-sitter-cpp": "^0.23.4",
|
||||
"tree-sitter-cpp": "0.23.2",
|
||||
"tree-sitter-go": "^0.23.0",
|
||||
"tree-sitter-java": "^0.23.5",
|
||||
"tree-sitter-javascript": "^0.23.0",
|
||||
@@ -65,17 +65,17 @@
|
||||
"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-dart": "file:./vendor/tree-sitter-dart",
|
||||
"tree-sitter-kotlin": "^0.3.8",
|
||||
"tree-sitter-proto": "file:./vendor/tree-sitter-proto",
|
||||
"tree-sitter-swift": "^0.6.0"
|
||||
"tree-sitter-swift": "file:./vendor/tree-sitter-swift"
|
||||
}
|
||||
},
|
||||
"../gitnexus-shared": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
@@ -1159,9 +1159,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ladybugdb/core": {
|
||||
"version": "0.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.15.3.tgz",
|
||||
"integrity": "sha512-Xa8VmWhMTvTCWmApnqm9FJtyxxV+CiMCokl1p9vEfXNuBz3SWXWGDmHlzKikswtQbUe9tTV3J9MxPdVFVE6/yg==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.16.0.tgz",
|
||||
"integrity": "sha512-t/t4MPZmBMocFBzG5G3E3iHPwuIiXYEuLeW0CTOloGofkKQ7gHt3JlLzyDn2a+AHNQjr1YqlsodKKYQFhsFZXw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1169,16 +1169,17 @@
|
||||
"node-addon-api": "^6.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ladybugdb/core-darwin-arm64": "0.15.3",
|
||||
"@ladybugdb/core-linux-arm64": "0.15.3",
|
||||
"@ladybugdb/core-linux-x64": "0.15.3",
|
||||
"@ladybugdb/core-win32-x64": "0.15.3"
|
||||
"@ladybugdb/core-darwin-arm64": "0.16.0",
|
||||
"@ladybugdb/core-darwin-x64": "0.16.0",
|
||||
"@ladybugdb/core-linux-arm64": "0.16.0",
|
||||
"@ladybugdb/core-linux-x64": "0.16.0",
|
||||
"@ladybugdb/core-win32-x64": "0.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ladybugdb/core-darwin-arm64": {
|
||||
"version": "0.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.15.3.tgz",
|
||||
"integrity": "sha512-+bqAb3wbbmxPSeNQjbVd6Ek5K8GbHr1KlDr09YkNqZ7XWhKqWxbs097xAG9bynLcZh9oxok2PGCoK4w5YHs11w==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.16.0.tgz",
|
||||
"integrity": "sha512-2IpiUbd6Lb50KRUkURk+PIgDRKume63uI4KYZNpjxNDwdHRXdadZTBZn74+DgK7IhpTyiPbtKddiXHKtSV2CWg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1189,9 +1190,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-linux-arm64": {
|
||||
"version": "0.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.15.3.tgz",
|
||||
"integrity": "sha512-Z8Ur6YbC5y6pgtKh/7b1/xdeRHy69sGhsoVJm1tc9xp9Zrar6G2A71bEdjOdDJ/mDRt6RtY0zdhUgIgQXYQtbQ==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.16.0.tgz",
|
||||
"integrity": "sha512-l+lV7BXfnA0w1voApKblBaGE+bKQqSlOG+30HkSYOAW7POYv+OoydgY/BGwabBUTvcnhVyrNApvBsPF8G3Nm3g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1202,9 +1203,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-linux-x64": {
|
||||
"version": "0.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.15.3.tgz",
|
||||
"integrity": "sha512-DT9xBc91tuxzjRu1dJ3xGt/K/uR1Q8bX5+8tCtj66UbVIVvp1RWAAE9phq7eahcF/3zBuFRonkxW/tTyQdQIlQ==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.16.0.tgz",
|
||||
"integrity": "sha512-XOL2H0y51e57dIFIHO8LHtN8Ner2qEyti6zAkxKr+w8LkvczHeVX910doz2de8+xvxDYJyzrcj2xWqDTxcK/Jg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1215,9 +1216,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-win32-x64": {
|
||||
"version": "0.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.15.3.tgz",
|
||||
"integrity": "sha512-ymHC8nHGIT7M9aditBQFIystxW+WoqvI3xklz22BHaFpU9CrTNtdU20K6cuRZvqEA2//Edu7kMoP9OwLkIleCg==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.16.0.tgz",
|
||||
"integrity": "sha512-MyKiELqPgzx9gVHmwxzptnToAcDtCN7dTP5Y4IPMYhc2QpNbZKCihmvdXjbOmdIOfIHW4fBp4vrzT8fVbdAMZw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1227,6 +1228,9 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core/node_modules/@ladybugdb/core-darwin-x64": {
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@ladybugdb/core/node_modules/node-addon-api": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
|
||||
@@ -2428,13 +2432,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/boolean": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
@@ -2763,12 +2760,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
|
||||
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -2841,12 +2832,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es6-error": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
|
||||
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
@@ -3270,17 +3255,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/global-agent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
|
||||
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz",
|
||||
"integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"boolean": "^3.0.1",
|
||||
"es6-error": "^4.1.1",
|
||||
"matcher": "^3.0.0",
|
||||
"roarr": "^2.15.3",
|
||||
"semver": "^7.3.2",
|
||||
"serialize-error": "^7.0.1"
|
||||
"globalthis": "^1.0.2",
|
||||
"matcher": "^4.0.0",
|
||||
"semver": "^7.3.5",
|
||||
"serialize-error": "^8.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0"
|
||||
@@ -3612,12 +3595,6 @@
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/json-stringify-safe": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||
@@ -3951,15 +3928,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/matcher": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
||||
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz",
|
||||
"integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
@@ -4077,9 +4057,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mnemonist": {
|
||||
"version": "0.40.3",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
|
||||
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
|
||||
"version": "0.40.4",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz",
|
||||
"integrity": "sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"obliterator": "^2.0.4"
|
||||
@@ -4214,15 +4194,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-common": {
|
||||
"version": "1.24.3",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
|
||||
"integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.25.1.tgz",
|
||||
"integrity": "sha512-kKvYQFdos4LWJqhZ+nmKu3NT8NXzw8I5x9fNUKe1rNKcPfNKnYXUtW7JBpcKFsvLtrJashRgVYSbFap4cHxvNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/onnxruntime-node": {
|
||||
"version": "1.24.3",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz",
|
||||
"integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.25.1.tgz",
|
||||
"integrity": "sha512-N0M58CGTiTsLkPpx9bxmRFi24GT6r67Qei/GrBEIiDyntcYdXU5vQZp112ypydG9vEKRFgbgUYQJnEi+jll8dg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
@@ -4232,8 +4212,8 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"global-agent": "^3.0.0",
|
||||
"onnxruntime-common": "1.24.3"
|
||||
"global-agent": "^4.1.3",
|
||||
"onnxruntime-common": "1.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-web": {
|
||||
@@ -4520,23 +4500,6 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/roarr": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
|
||||
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"boolean": "^3.0.1",
|
||||
"detect-node": "^2.0.4",
|
||||
"globalthis": "^1.0.1",
|
||||
"json-stringify-safe": "^5.0.1",
|
||||
"semver-compare": "^1.0.0",
|
||||
"sprintf-js": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
|
||||
@@ -4635,12 +4598,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/semver-compare": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
|
||||
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
@@ -4681,12 +4638,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serialize-error": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
|
||||
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
|
||||
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"type-fest": "^0.13.1"
|
||||
"type-fest": "^0.20.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -4870,12 +4827,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -5028,20 +4979,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-c": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-c/-/tree-sitter-c-0.23.2.tgz",
|
||||
"integrity": "sha512-9kADOx31AF94DHcrsMGW0zM/2LS6v7wFkPHPVm7RQU+vYVVZMKZ2FJ9e99pm5feqsAcjUzB9CarqDLgRT1Fe/w==",
|
||||
"version": "0.21.4",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-c/-/tree-sitter-c-0.21.4.tgz",
|
||||
"integrity": "sha512-IahxFIhXiY15SUlrt2upBiKSBGdOaE1fjKLK1Ik5zxqGHf6T1rvr3IJrovbsE5sXhypx7Hnmf50gshsppaIihA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.2.2",
|
||||
"node-gyp-build": "^4.8.2"
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.1"
|
||||
"tree-sitter": "^0.21.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tree-sitter": {
|
||||
"tree_sitter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
@@ -5065,30 +5016,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-cli": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-cli/-/tree-sitter-cli-0.23.2.tgz",
|
||||
"integrity": "sha512-kPPXprOqREX+C/FgUp2Qpt9jd0vSwn+hOgjzVv/7hapdoWpa+VeWId53rf4oNNd29ikheF12BYtGD/W90feMbA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"tree-sitter": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-cpp": {
|
||||
"version": "0.23.4",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-cpp/-/tree-sitter-cpp-0.23.4.tgz",
|
||||
"integrity": "sha512-qR5qUDyhZ5jJ6V8/umiBxokRbe89bCGmcq/dk94wI4kN86qfdV8k0GHIUEKaqWgcu42wKal5E97LKpLeVW8sKw==",
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-cpp/-/tree-sitter-cpp-0.23.2.tgz",
|
||||
"integrity": "sha512-GTa5Dx1O9ihzW70LvaUviTclh+wlBDRz6opR9Ij4NQIFmq/joeZ/k65UbLV4nLidR7xZ9eNNGT/SonCqAmjGVg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.2.1",
|
||||
"node-gyp-build": "^4.8.2",
|
||||
"tree-sitter-c": "^0.23.1"
|
||||
"node-gyp-build": "^4.8.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.1"
|
||||
@@ -5100,31 +5036,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-dart": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "git+ssh://git@github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4",
|
||||
"integrity": "sha512-Bs/1wAOIJ2akPEXlE/XVpuES19Oo3NqoSJRJ/0N2r38qAd9nTXdqmaGHQ44/JXnA6QHcbgD2YzCCc4wUc98cyQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0",
|
||||
"node-gyp-build": "^4.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tree_sitter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-dart/node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"resolved": "vendor/tree-sitter-dart",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/tree-sitter-go": {
|
||||
"version": "0.23.4",
|
||||
@@ -5291,49 +5204,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-swift": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-swift/-/tree-sitter-swift-0.6.0.tgz",
|
||||
"integrity": "sha512-9vOJZes4/UFjBr4COHtp6ZHVuZYwfChSQbpneXQog04dAstfx5px3ybVX2cN+ylvLqsvVpmXLpidxxgF2rDQ7A==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.0",
|
||||
"tree-sitter-cli": "^0.23",
|
||||
"which": "2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tree_sitter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-swift/node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tree-sitter-swift/node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
"resolved": "vendor/tree-sitter-swift",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/tree-sitter-typescript": {
|
||||
"version": "0.23.2",
|
||||
@@ -5383,9 +5255,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
||||
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
|
||||
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -5768,6 +5640,19 @@
|
||||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
},
|
||||
"vendor/tree-sitter-dart": {
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tree_sitter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"vendor/tree-sitter-proto": {
|
||||
"version": "0.4.1",
|
||||
"license": "MIT",
|
||||
@@ -5775,6 +5660,24 @@
|
||||
"peerDependencies": {
|
||||
"tree-sitter": ">=0.21.0"
|
||||
}
|
||||
},
|
||||
"vendor/tree-sitter-swift": {
|
||||
"version": "0.7.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.1 || ^0.22.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tree-sitter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.3",
|
||||
"version": "1.6.4-rc.48",
|
||||
"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",
|
||||
@@ -35,7 +35,8 @@
|
||||
"hooks",
|
||||
"scripts",
|
||||
"skills",
|
||||
"vendor"
|
||||
"vendor",
|
||||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node scripts/build.js",
|
||||
@@ -46,13 +47,13 @@
|
||||
"test:integration": "vitest run test/integration",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"postinstall": "node scripts/patch-tree-sitter-swift.cjs && node scripts/build-tree-sitter-proto.cjs",
|
||||
"postinstall": "node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs",
|
||||
"prepare": "node scripts/build.js",
|
||||
"prepack": "node scripts/build.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^4.1.0",
|
||||
"@ladybugdb/core": "^0.15.2",
|
||||
"@ladybugdb/core": "^0.16.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
@@ -71,9 +72,9 @@
|
||||
"onnxruntime-node": "^1.24.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"tree-sitter": "^0.21.1",
|
||||
"tree-sitter-c": "0.23.2",
|
||||
"tree-sitter-c": "0.21.4",
|
||||
"tree-sitter-c-sharp": "0.23.1",
|
||||
"tree-sitter-cpp": "^0.23.4",
|
||||
"tree-sitter-cpp": "0.23.2",
|
||||
"tree-sitter-go": "^0.23.0",
|
||||
"tree-sitter-java": "^0.23.5",
|
||||
"tree-sitter-javascript": "^0.23.0",
|
||||
@@ -87,10 +88,10 @@
|
||||
"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-dart": "file:./vendor/tree-sitter-dart",
|
||||
"tree-sitter-kotlin": "^0.3.8",
|
||||
"tree-sitter-proto": "file:./vendor/tree-sitter-proto",
|
||||
"tree-sitter-swift": "^0.6.0"
|
||||
"tree-sitter-swift": "file:./vendor/tree-sitter-swift"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
@@ -108,8 +109,7 @@
|
||||
"overrides": {
|
||||
"@huggingface/transformers": {
|
||||
"onnxruntime-node": "$onnxruntime-node"
|
||||
},
|
||||
"tree-sitter-c": "0.23.2"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const dartDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-dart');
|
||||
const bindingGyp = path.join(dartDir, 'binding.gyp');
|
||||
const bindingNode = path.join(dartDir, 'build', 'Release', 'tree_sitter_dart_binding.node');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(bindingGyp) || fs.existsSync(bindingNode)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
require.resolve('node-addon-api');
|
||||
require.resolve('node-gyp-build');
|
||||
} catch (resolveErr) {
|
||||
console.warn(
|
||||
'[tree-sitter-dart] Skipping build: hoisted build deps not resolvable (%s).',
|
||||
resolveErr.message,
|
||||
);
|
||||
console.warn(
|
||||
'[tree-sitter-dart] Dart parsing will be unavailable. Install without --no-optional and with scripts enabled to build.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('[tree-sitter-dart] Building native binding...');
|
||||
execSync('npx node-gyp rebuild', {
|
||||
cwd: dartDir,
|
||||
stdio: 'pipe',
|
||||
timeout: 180000,
|
||||
});
|
||||
console.log('[tree-sitter-dart] Native binding built successfully');
|
||||
} catch (err) {
|
||||
console.warn('[tree-sitter-dart] Could not build native binding:', err.message);
|
||||
console.warn(
|
||||
'[tree-sitter-dart] Dart parsing will be unavailable. Non-Dart functionality is unaffected.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
* `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
|
||||
* Mirrors the tree-sitter-dart build helper. Best-effort: if any
|
||||
* precondition fails (optional dep absent, no toolchain, --ignore-scripts),
|
||||
* warn and exit 0 so gitnexus install still succeeds.
|
||||
*/
|
||||
|
||||
@@ -21,11 +21,11 @@ const SHARED_DEST = path.join(DIST, '_shared');
|
||||
|
||||
// ── 1. Build gitnexus-shared ───────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus-shared…');
|
||||
execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit' });
|
||||
execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// ── 2. Build gitnexus ──────────────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus…');
|
||||
execSync('npx tsc', { cwd: ROOT, stdio: 'inherit' });
|
||||
execSync('npx tsc', { cwd: ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// ── 3. Copy shared dist ────────────────────────────────────────────
|
||||
console.log('[build] copying shared module into dist/_shared…');
|
||||
@@ -70,4 +70,24 @@ walk(DIST, ['.js', '.d.ts'], rewriteFile);
|
||||
const cliEntry = path.join(DIST, 'cli', 'index.js');
|
||||
if (fs.existsSync(cliEntry)) fs.chmodSync(cliEntry, 0o755);
|
||||
|
||||
// ── 6. Build & copy web UI ──────────────────────────────────────────
|
||||
const WEB_ROOT = path.resolve(ROOT, '..', 'gitnexus-web');
|
||||
const WEB_DEST = path.join(DIST, '..', 'web');
|
||||
|
||||
if (fs.existsSync(path.join(WEB_ROOT, 'package.json'))) {
|
||||
console.log('[build] building gitnexus-web…');
|
||||
if (!fs.existsSync(path.join(WEB_ROOT, 'node_modules'))) {
|
||||
console.log('[build] installing gitnexus-web dependencies…');
|
||||
execSync('npm ci', { cwd: WEB_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
}
|
||||
execSync('npm run build', { cwd: WEB_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// Copy dist → gitnexus/web/ (shipped in the npm package)
|
||||
fs.rmSync(WEB_DEST, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(WEB_ROOT, 'dist'), WEB_DEST, { recursive: true });
|
||||
console.log('[build] copied web UI → gitnexus/web/');
|
||||
} else {
|
||||
console.log('[build] skipping web UI (gitnexus-web not found)');
|
||||
}
|
||||
|
||||
console.log(`[build] done — rewrote ${rewritten} files.`);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
|
||||
|
||||
function parseLbugMaxDbSize(raw) {
|
||||
const parsed = raw ? Number(raw) : NaN;
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`Invalid LadybugDB max DB size for extension installer: ${raw ?? '<missing>'}`);
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
async function installDuckDbExtension(extensionName) {
|
||||
if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) {
|
||||
throw new Error(`Invalid DuckDB extension name: ${extensionName ?? '<missing>'}`);
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const lbugModule = require('@ladybugdb/core');
|
||||
const lbug = lbugModule.default ?? lbugModule;
|
||||
const lbugMaxDbSize = parseLbugMaxDbSize(
|
||||
process.argv[3] ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE,
|
||||
);
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ext-install-'));
|
||||
const dbPath = path.join(tmpDir, 'install.lbug');
|
||||
let db;
|
||||
let conn;
|
||||
|
||||
try {
|
||||
db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize);
|
||||
conn = new lbug.Connection(db);
|
||||
await conn.query(`INSTALL ${extensionName}`);
|
||||
} finally {
|
||||
if (conn) await conn.close().catch(() => {});
|
||||
if (db) await db.close().catch(() => {});
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
installDuckDbExtension(process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME).catch((err) => {
|
||||
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* WORKAROUND: tree-sitter-swift@0.6.0 binding.gyp build failure
|
||||
*
|
||||
* Background:
|
||||
* tree-sitter-swift@0.6.0's binding.gyp contains an "actions" array that
|
||||
* invokes `tree-sitter generate` to regenerate parser.c from grammar.js.
|
||||
* This is intended for grammar developers, but the published npm package
|
||||
* already ships pre-generated parser files (parser.c, scanner.c), so the
|
||||
* actions are unnecessary for consumers. Since consumers don't have
|
||||
* tree-sitter-cli installed, the actions always fail during `npm install`.
|
||||
*
|
||||
* Why we can't just upgrade:
|
||||
* tree-sitter-swift@0.7.1 fixes this (removes postinstall, ships prebuilds),
|
||||
* but it requires tree-sitter@^0.22.1. The upstream project pins tree-sitter
|
||||
* to ^0.21.0 and all other grammar packages depend on that version.
|
||||
* Upgrading tree-sitter would be a separate breaking change.
|
||||
*
|
||||
* How this workaround works:
|
||||
* 1. tree-sitter-swift's own postinstall fails (npm warns but continues)
|
||||
* 2. This script runs as gitnexus's postinstall
|
||||
* 3. It removes the "actions" array from binding.gyp
|
||||
* 4. It rebuilds the native binding with the cleaned binding.gyp
|
||||
*
|
||||
* TODO: Remove this script when tree-sitter is upgraded to ^0.22.x,
|
||||
* which allows using tree-sitter-swift@0.7.1+ directly.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift');
|
||||
const bindingPath = path.join(swiftDir, 'binding.gyp');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(bindingPath)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(bindingPath, 'utf8');
|
||||
let needsRebuild = false;
|
||||
|
||||
if (content.includes('"actions"')) {
|
||||
// Strip Python-style comments (#) and trailing commas before JSON parsing
|
||||
const cleaned = content
|
||||
.replace(/#[^\n]*/g, '') // Remove # comments
|
||||
.replace(/,(\s*[\]}])/g, '$1'); // Remove trailing commas before ] or }
|
||||
const gyp = JSON.parse(cleaned);
|
||||
|
||||
if (gyp.targets && gyp.targets[0] && gyp.targets[0].actions) {
|
||||
delete gyp.targets[0].actions;
|
||||
fs.writeFileSync(bindingPath, JSON.stringify(gyp, null, 2) + '\n');
|
||||
console.log('[tree-sitter-swift] Patched binding.gyp (removed actions array)');
|
||||
needsRebuild = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if native binding exists
|
||||
const bindingNode = path.join(swiftDir, 'build', 'Release', 'tree_sitter_swift_binding.node');
|
||||
if (!fs.existsSync(bindingNode)) {
|
||||
needsRebuild = true;
|
||||
}
|
||||
|
||||
if (needsRebuild) {
|
||||
console.log('[tree-sitter-swift] Rebuilding native binding...');
|
||||
execSync('npx node-gyp rebuild', {
|
||||
cwd: swiftDir,
|
||||
stdio: 'pipe',
|
||||
timeout: 120000,
|
||||
});
|
||||
console.log('[tree-sitter-swift] Native binding built successfully');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[tree-sitter-swift] Could not build native binding:', err.message);
|
||||
console.warn(
|
||||
'[tree-sitter-swift] You may need to manually run: cd node_modules/tree-sitter-swift && npx node-gyp rebuild',
|
||||
);
|
||||
}
|
||||
+155
-12
@@ -17,12 +17,52 @@ import {
|
||||
getStoragePaths,
|
||||
getGlobalRegistryPath,
|
||||
RegistryNameCollisionError,
|
||||
AnalysisNotFinalizedError,
|
||||
assertAnalysisFinalized,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { getGitRoot, hasGitDir } from '../storage/git.js';
|
||||
import { runFullAnalysis } from '../core/run-analyze.js';
|
||||
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
// Capture stderr.write at module load BEFORE anything (LadybugDB native
|
||||
// init, progress bar, console redirection) can monkey-patch it. The
|
||||
// fatal handlers below MUST reach the user even when the analyze path
|
||||
// has redirected console.* through the progress bar's bar.log() — the
|
||||
// previous behaviour silently swallowed stack traces and made #1169
|
||||
// indistinguishable from a no-op success on Windows.
|
||||
const realStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
|
||||
const writeFatalToStderr = (label: string, err: unknown): void => {
|
||||
const isErr = err instanceof Error;
|
||||
const message = isErr ? err.message : String(err);
|
||||
realStderrWrite(`\n ${label}: ${message}\n`);
|
||||
if (isErr && err.stack) realStderrWrite(`${err.stack}\n`);
|
||||
};
|
||||
|
||||
let fatalHandlersInstalled = false;
|
||||
|
||||
/**
|
||||
* Install one-shot `unhandledRejection` / `uncaughtException` handlers
|
||||
* that surface the failure to the real stderr (bypassing any console
|
||||
* redirection installed by the progress bar) and force a non-zero exit
|
||||
* code. Without these, an async error escaping {@link analyzeCommand}'s
|
||||
* try/catch was reported as exit 0 with no diagnostic — the silent
|
||||
* failure mode tracked in #1169.
|
||||
*/
|
||||
const installFatalHandlers = (): void => {
|
||||
if (fatalHandlersInstalled) return;
|
||||
fatalHandlersInstalled = true;
|
||||
process.on('unhandledRejection', (err) => {
|
||||
writeFatalToStderr('Analysis failed (unhandled rejection)', err);
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('uncaughtException', (err) => {
|
||||
writeFatalToStderr('Analysis failed (uncaught exception)', err);
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
|
||||
const HEAP_MB = 8192;
|
||||
const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`;
|
||||
/** Increase default stack size (KB) to prevent stack overflow on deep class hierarchies. */
|
||||
@@ -91,11 +131,22 @@ export interface AnalyzeOptions {
|
||||
* `GITNEXUS_MAX_FILE_SIZE` for the rest of the pipeline.
|
||||
*/
|
||||
maxFileSize?: string;
|
||||
/** Override worker sub-batch idle timeout in seconds. */
|
||||
workerTimeout?: string;
|
||||
embeddingThreads?: string;
|
||||
embeddingBatchSize?: string;
|
||||
embeddingSubBatchSize?: string;
|
||||
embeddingDevice?: string;
|
||||
}
|
||||
|
||||
export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
|
||||
if (ensureHeap()) return;
|
||||
|
||||
// Install fatal handlers immediately after re-exec resolution so any
|
||||
// async error that escapes the try/catch below (#1169) surfaces with
|
||||
// a stack trace and a non-zero exit code instead of a silent exit 0.
|
||||
installFatalHandlers();
|
||||
|
||||
if (options?.verbose) {
|
||||
process.env.GITNEXUS_VERBOSE = '1';
|
||||
}
|
||||
@@ -104,26 +155,82 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
process.env.GITNEXUS_MAX_FILE_SIZE = options.maxFileSize;
|
||||
}
|
||||
|
||||
if (options?.workerTimeout) {
|
||||
const workerTimeoutSeconds = Number(options.workerTimeout);
|
||||
if (!Number.isFinite(workerTimeoutSeconds) || workerTimeoutSeconds < 1) {
|
||||
console.error(' --worker-timeout must be at least 1 second.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(
|
||||
Math.round(workerTimeoutSeconds * 1000),
|
||||
);
|
||||
}
|
||||
|
||||
const setPositiveEnv = (
|
||||
optionName: string,
|
||||
envName: string,
|
||||
value: string | undefined,
|
||||
): boolean => {
|
||||
if (value === undefined) return true;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
console.error(` ${optionName} must be a positive integer.\n`);
|
||||
process.exitCode = 1;
|
||||
return false;
|
||||
}
|
||||
process.env[envName] = String(parsed);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (
|
||||
!setPositiveEnv(
|
||||
'--embedding-threads',
|
||||
'GITNEXUS_EMBEDDING_THREADS',
|
||||
options?.embeddingThreads,
|
||||
) ||
|
||||
!setPositiveEnv(
|
||||
'--embedding-batch-size',
|
||||
'GITNEXUS_EMBEDDING_BATCH_SIZE',
|
||||
options?.embeddingBatchSize,
|
||||
) ||
|
||||
!setPositiveEnv(
|
||||
'--embedding-sub-batch-size',
|
||||
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
|
||||
options?.embeddingSubBatchSize,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.embeddingDevice) {
|
||||
const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']);
|
||||
if (!allowed.has(options.embeddingDevice)) {
|
||||
console.error(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.env.GITNEXUS_EMBEDDING_DEVICE = options.embeddingDevice;
|
||||
}
|
||||
|
||||
console.log('\n GitNexus Analyzer\n');
|
||||
|
||||
let repoPath: string;
|
||||
if (inputPath) {
|
||||
repoPath = path.resolve(inputPath);
|
||||
} else if (options?.skipGit) {
|
||||
// --skip-git: treat cwd as the index root, do not walk up to a parent git repo.
|
||||
repoPath = path.resolve(process.cwd());
|
||||
} else {
|
||||
const gitRoot = getGitRoot(process.cwd());
|
||||
if (!gitRoot) {
|
||||
if (!options?.skipGit) {
|
||||
console.log(
|
||||
' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// --skip-git: fall back to cwd as the root
|
||||
repoPath = path.resolve(process.cwd());
|
||||
} else {
|
||||
repoPath = gitRoot;
|
||||
console.log(
|
||||
' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
repoPath = gitRoot;
|
||||
}
|
||||
|
||||
const repoHasGit = hasGitDir(repoPath);
|
||||
@@ -252,6 +359,11 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
);
|
||||
|
||||
if (result.alreadyUpToDate) {
|
||||
// Even the fast path must prove the repo is discoverable. A prior
|
||||
// run can write meta.json and then fail before registerRepo(); in
|
||||
// that half-finalized state, runFullAnalysis returns alreadyUpToDate
|
||||
// on the next invocation unless we check the registry here too.
|
||||
await assertAnalysisFinalized(repoPath);
|
||||
clearInterval(elapsedTimer);
|
||||
process.removeListener('SIGINT', sigintHandler);
|
||||
console.log = origLog;
|
||||
@@ -264,6 +376,15 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
return;
|
||||
}
|
||||
|
||||
// Post-finalize invariant (#1169): runFullAnalysis nominally writes
|
||||
// meta.json and registers the repo, but on Windows it has been
|
||||
// observed to return successfully with neither artifact present
|
||||
// (banner-only output, exit 0). Verify both before declaring
|
||||
// success so the silent-finalize state surfaces with a non-zero
|
||||
// exit code and an actionable error instead of being mistaken for
|
||||
// a healthy index.
|
||||
await assertAnalysisFinalized(repoPath);
|
||||
|
||||
// Skill generation (CLI-only, uses pipeline result from analysis)
|
||||
if (options?.skills && result.pipelineResult) {
|
||||
updateBar(99, 'Generating skill files...');
|
||||
@@ -365,7 +486,29 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`\n Analysis failed: ${msg}\n`);
|
||||
// Finalize invariant failure (#1169) — keep the rich actionable
|
||||
// message intact and write through realStderrWrite so it can't be
|
||||
// erased by a leftover bar refresh on slow terminals.
|
||||
if (err instanceof AnalysisNotFinalizedError) {
|
||||
writeFatalToStderr('Analysis did not finalize', err);
|
||||
realStderrWrite(
|
||||
`\n Diagnostic checklist:\n` +
|
||||
` 1. Re-run "gitnexus analyze" - transient native errors often clear on retry.\n` +
|
||||
` 2. Inspect ${err.storagePath} - a leftover lbug.wal indicates an aborted write.\n` +
|
||||
` 3. If the failure persists, run with NODE_OPTIONS="--max-old-space-size=8192 --trace-exit"\n` +
|
||||
` and attach the trace to the GitNexus issue tracker.\n\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Bypass the redirected console.error and write the full stack to
|
||||
// the real stderr captured at module load. The redirected
|
||||
// console.error wraps every line with `\\x1b[2K\\r` (ANSI clear-line)
|
||||
// and forces a bar.update() afterwards, which on some Windows
|
||||
// terminals visually erases the failure message — the canonical
|
||||
// shape of the silent-exit symptom in #1169.
|
||||
writeFatalToStderr('Analysis failed', err);
|
||||
|
||||
// Provide helpful guidance for known failure modes
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js';
|
||||
import { resolveEmbeddingConfig } from '../core/embeddings/config.js';
|
||||
import { isHttpMode } from '../core/embeddings/http-client.js';
|
||||
|
||||
export const doctorCommand = async () => {
|
||||
const fingerprint = getRuntimeFingerprint();
|
||||
const capabilities = getRuntimeCapabilities();
|
||||
const embeddingConfig = resolveEmbeddingConfig();
|
||||
|
||||
console.log('GitNexus Doctor\n');
|
||||
console.log('Runtime');
|
||||
console.log(` OS: ${fingerprint.platform}/${fingerprint.arch}`);
|
||||
console.log(` Node: ${fingerprint.node}`);
|
||||
console.log(` GitNexus: ${fingerprint.gitnexus}`);
|
||||
console.log(` LadybugDB: ${fingerprint.ladybugdb ?? 'unknown'}`);
|
||||
console.log(` ONNX: ${fingerprint.onnxruntime ?? 'unknown'}`);
|
||||
console.log('');
|
||||
console.log('Capabilities');
|
||||
console.log(` Graph store: ${capabilities.graph}`);
|
||||
console.log(` Full-text search:${capabilities.fts.padStart(10)}`);
|
||||
console.log(` VECTOR index: ${capabilities.vector}`);
|
||||
console.log(` Semantic mode: ${capabilities.semanticMode}`);
|
||||
console.log(` Exact scan limit:${String(capabilities.exactScanLimit).padStart(9)} chunks`);
|
||||
if (capabilities.reason) console.log(` Note: ${capabilities.reason}`);
|
||||
console.log('');
|
||||
console.log('Embeddings');
|
||||
console.log(` Backend: ${isHttpMode() ? 'http' : 'local'}`);
|
||||
console.log(` Device: ${embeddingConfig.device}`);
|
||||
console.log(` Threads: ${embeddingConfig.threads}`);
|
||||
console.log(` Batch: ${embeddingConfig.batchSize} nodes`);
|
||||
console.log(` Sub-batch: ${embeddingConfig.subBatchSize} chunks`);
|
||||
};
|
||||
@@ -14,7 +14,7 @@ import fs from 'fs/promises';
|
||||
import {
|
||||
getStoragePaths,
|
||||
loadMeta,
|
||||
addToGitignore,
|
||||
ensureGitNexusIgnored,
|
||||
registerRepo,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { getGitRoot, getRemoteUrl, isGitRepo } from '../storage/git.js';
|
||||
@@ -115,7 +115,7 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt
|
||||
meta.remoteUrl = getRemoteUrl(repoPath);
|
||||
}
|
||||
await registerRepo(repoPath, meta);
|
||||
await addToGitignore(repoPath);
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
|
||||
const projectName = path.basename(repoPath);
|
||||
const { stats } = meta;
|
||||
|
||||
@@ -32,7 +32,10 @@ program
|
||||
.option('--skills', 'Generate repo-specific skill files from detected communities')
|
||||
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
|
||||
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
|
||||
.option('--skip-git', 'Index a folder without requiring a .git directory')
|
||||
.option(
|
||||
'--skip-git',
|
||||
'Treat the provided path/cwd as the index root and skip parent git-root discovery',
|
||||
)
|
||||
.option(
|
||||
'--name <alias>',
|
||||
'Register this repo under a custom name in ~/.gitnexus/registry.json ' +
|
||||
@@ -48,11 +51,23 @@ program
|
||||
'--max-file-size <kb>',
|
||||
'Skip files larger than this (KB). Default: 512. Hard cap: 32768 (tree-sitter limit).',
|
||||
)
|
||||
.option(
|
||||
'--worker-timeout <seconds>',
|
||||
'Worker sub-batch idle timeout before retry/fallback. Default: 30.',
|
||||
)
|
||||
.option('--embedding-threads <n>', 'Limit local ONNX embedding CPU threads')
|
||||
.option('--embedding-batch-size <n>', 'Number of nodes per embedding batch')
|
||||
.option('--embedding-sub-batch-size <n>', 'Number of chunks per embedding model call')
|
||||
.option('--embedding-device <device>', 'Embedding device: auto, cpu, dml, cuda, or wasm')
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nEnvironment variables:\n' +
|
||||
' GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n' +
|
||||
' GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n' +
|
||||
' GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n' +
|
||||
' GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n' +
|
||||
' GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n' +
|
||||
' GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n' +
|
||||
'\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n' +
|
||||
' `!__tests__/` to index a directory that is auto-filtered by default (#771).',
|
||||
)
|
||||
@@ -89,6 +104,11 @@ program
|
||||
.description('Show index status for current repo')
|
||||
.action(createLazyAction(() => import('./status.js'), 'statusCommand'));
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.description('Show runtime platform capabilities and embedding configuration')
|
||||
.action(createLazyAction(() => import('./doctor.js'), 'doctorCommand'));
|
||||
|
||||
program
|
||||
.command('clean')
|
||||
.description('Delete GitNexus index for current repo')
|
||||
|
||||
@@ -581,13 +581,13 @@ async function installCursorSkills(result: SetupResult): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Install global OpenCode skills to ~/.config/opencode/skill/gitnexus/
|
||||
* Install global OpenCode skills to ~/.config/opencode/skills/gitnexus/
|
||||
*/
|
||||
async function installOpenCodeSkills(result: SetupResult): Promise<void> {
|
||||
const opencodeDir = path.join(os.homedir(), '.config', 'opencode');
|
||||
if (!(await dirExists(opencodeDir))) return;
|
||||
|
||||
const skillsDir = path.join(opencodeDir, 'skill');
|
||||
const skillsDir = path.join(opencodeDir, 'skills');
|
||||
try {
|
||||
const installed = await installSkillsTo(skillsDir);
|
||||
if (installed.length > 0) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { defaultEmbeddingThreads } from '../platform/capabilities.js';
|
||||
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig } from './types.js';
|
||||
|
||||
const parsePositiveInt = (name: string, value: string | undefined, fallback: number): number => {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, got "${value}"`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const parseDevice = (value: string | undefined): EmbeddingConfig['device'] | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
if (
|
||||
value === 'auto' ||
|
||||
value === 'dml' ||
|
||||
value === 'cuda' ||
|
||||
value === 'cpu' ||
|
||||
value === 'wasm'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`embedding device must be one of auto, dml, cuda, cpu, wasm; got "${value}"`);
|
||||
};
|
||||
|
||||
export const resolveEmbeddingConfig = (
|
||||
overrides: Partial<EmbeddingConfig> = {},
|
||||
): EmbeddingConfig => {
|
||||
const env = process.env;
|
||||
return {
|
||||
...DEFAULT_EMBEDDING_CONFIG,
|
||||
...overrides,
|
||||
batchSize: parsePositiveInt(
|
||||
'GITNEXUS_EMBEDDING_BATCH_SIZE',
|
||||
env.GITNEXUS_EMBEDDING_BATCH_SIZE,
|
||||
overrides.batchSize ?? DEFAULT_EMBEDDING_CONFIG.batchSize,
|
||||
),
|
||||
subBatchSize: parsePositiveInt(
|
||||
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
|
||||
env.GITNEXUS_EMBEDDING_SUB_BATCH_SIZE,
|
||||
overrides.subBatchSize ?? DEFAULT_EMBEDDING_CONFIG.subBatchSize,
|
||||
),
|
||||
threads: parsePositiveInt(
|
||||
'GITNEXUS_EMBEDDING_THREADS',
|
||||
env.GITNEXUS_EMBEDDING_THREADS,
|
||||
overrides.threads ?? defaultEmbeddingThreads(),
|
||||
),
|
||||
device:
|
||||
parseDevice(env.GITNEXUS_EMBEDDING_DEVICE) ??
|
||||
overrides.device ??
|
||||
DEFAULT_EMBEDDING_CONFIG.device,
|
||||
};
|
||||
};
|
||||
@@ -15,13 +15,14 @@ if (!process.env.ORT_LOG_LEVEL) {
|
||||
}
|
||||
|
||||
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
|
||||
import os from 'os';
|
||||
import { existsSync } from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { createRequire } from 'module';
|
||||
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js';
|
||||
import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js';
|
||||
import { resolveEmbeddingConfig } from './config.js';
|
||||
import { applyHfEnvOverrides } from './hf-env.js';
|
||||
|
||||
/**
|
||||
* Check whether the onnxruntime-node package that @huggingface/transformers
|
||||
@@ -144,13 +145,12 @@ export const initEmbedder = async (
|
||||
|
||||
isInitializing = true;
|
||||
|
||||
const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config };
|
||||
// On Windows, use DirectML for GPU acceleration (via DirectX12)
|
||||
// CUDA is only available on Linux x64 with onnxruntime-node
|
||||
const finalConfig = resolveEmbeddingConfig(config);
|
||||
// CUDA is probe-gated because ONNX Runtime can crash in native code when
|
||||
// provider libraries are missing. DirectML stays opt-in for the same reason.
|
||||
// Probe for CUDA first — ONNX Runtime crashes (uncatchable native error)
|
||||
// if we attempt CUDA without the required shared libraries
|
||||
const isWindows = process.platform === 'win32';
|
||||
const gpuDevice = isWindows ? 'dml' : isCudaAvailable() ? 'cuda' : 'cpu';
|
||||
const gpuDevice = isCudaAvailable() ? 'cuda' : 'cpu';
|
||||
const requestedDevice =
|
||||
forceDevice || (finalConfig.device === 'auto' ? gpuDevice : finalConfig.device);
|
||||
|
||||
@@ -158,11 +158,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 ?? join(os.homedir(), '.cache', 'huggingface');
|
||||
// Bridge user-controlled env vars to transformers.js: HF_HOME →
|
||||
// env.cacheDir, HF_ENDPOINT → env.remoteHost (#1205). Centralised in
|
||||
// applyHfEnvOverrides so the MCP embedder entry point behaves
|
||||
// identically.
|
||||
applyHfEnvOverrides(env);
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
if (isDev) {
|
||||
@@ -205,7 +205,12 @@ export const initEmbedder = async (
|
||||
device: device,
|
||||
dtype: 'fp32',
|
||||
progress_callback: progressCallback,
|
||||
session_options: { logSeverityLevel: 3 },
|
||||
session_options: {
|
||||
logSeverityLevel: 3,
|
||||
intraOpNumThreads: finalConfig.threads,
|
||||
interOpNumThreads: 1,
|
||||
executionMode: 'sequential',
|
||||
},
|
||||
});
|
||||
currentDevice = device;
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
type SemanticSearchResult,
|
||||
type ModelProgress,
|
||||
type EmbeddingContext,
|
||||
DEFAULT_EMBEDDING_CONFIG,
|
||||
EMBEDDABLE_LABELS,
|
||||
isShortLabel,
|
||||
LABEL_METHOD,
|
||||
@@ -35,6 +34,8 @@ import {
|
||||
STRUCTURAL_LABELS,
|
||||
collectBestChunks,
|
||||
} from './types.js';
|
||||
import { resolveEmbeddingConfig } from './config.js';
|
||||
import { rankExactEmbeddingRows, type ExactEmbeddingRow } from './exact-search.js';
|
||||
import {
|
||||
EMBEDDING_TABLE_NAME,
|
||||
EMBEDDING_INDEX_NAME,
|
||||
@@ -42,8 +43,20 @@ import {
|
||||
STALE_HASH_SENTINEL,
|
||||
} from '../lbug/schema.js';
|
||||
import { loadVectorExtension } from '../lbug/lbug-adapter.js';
|
||||
import { getExactScanLimit } from '../platform/capabilities.js';
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
const vectorUnavailableMessage =
|
||||
'VECTOR extension is unavailable for this LadybugDB runtime; semantic search will use exact scan when embeddings exist.';
|
||||
|
||||
const ensureVectorExtensionAvailable = async (): Promise<boolean> => {
|
||||
const vectorReady = await loadVectorExtension();
|
||||
if (!vectorReady) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
/**
|
||||
* Bump this when the embedding text template changes in a way that should
|
||||
* invalidate existing vectors, such as metadata/header shape changes,
|
||||
@@ -192,19 +205,26 @@ export const batchInsertEmbeddings = async (
|
||||
*/
|
||||
const createVectorIndex = async (
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
): Promise<void> => {
|
||||
// Delegate to the adapter which tracks loaded state and handles DB reconnect resets
|
||||
await loadVectorExtension();
|
||||
|
||||
): Promise<boolean> => {
|
||||
if (!(await ensureVectorExtensionAvailable())) return false;
|
||||
try {
|
||||
await executeQuery(CREATE_VECTOR_INDEX_QUERY);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isDev) {
|
||||
console.warn('Vector index creation warning:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export interface EmbeddingPipelineResult {
|
||||
nodesProcessed: number;
|
||||
chunksProcessed: number;
|
||||
vectorIndexReady: boolean;
|
||||
semanticMode: 'vector-index' | 'exact-scan';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the embedding pipeline
|
||||
*
|
||||
@@ -230,10 +250,14 @@ export const runEmbeddingPipeline = async (
|
||||
skipNodeIds?: Set<string>,
|
||||
context?: EmbeddingContext,
|
||||
existingEmbeddings?: Map<string, string>,
|
||||
): Promise<void> => {
|
||||
const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config };
|
||||
): Promise<EmbeddingPipelineResult> => {
|
||||
const finalConfig = resolveEmbeddingConfig(config);
|
||||
let totalChunks = 0;
|
||||
|
||||
try {
|
||||
const vectorAvailable = await ensureVectorExtensionAvailable();
|
||||
if (!vectorAvailable && isDev) console.warn(vectorUnavailableMessage);
|
||||
|
||||
// Phase 1: Load embedding model
|
||||
onProgress({
|
||||
phase: 'loading-model',
|
||||
@@ -338,7 +362,7 @@ export const runEmbeddingPipeline = async (
|
||||
// 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);
|
||||
const vectorIndexReady = await createVectorIndex(executeQuery);
|
||||
|
||||
onProgress({
|
||||
phase: 'ready',
|
||||
@@ -346,7 +370,12 @@ export const runEmbeddingPipeline = async (
|
||||
nodesProcessed: 0,
|
||||
totalNodes: 0,
|
||||
});
|
||||
return;
|
||||
return {
|
||||
nodesProcessed: 0,
|
||||
chunksProcessed: 0,
|
||||
vectorIndexReady,
|
||||
semanticMode: vectorIndexReady ? 'vector-index' : 'exact-scan',
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 3: Chunk + embed nodes
|
||||
@@ -354,7 +383,6 @@ export const runEmbeddingPipeline = async (
|
||||
const chunkSize = finalConfig.chunkSize;
|
||||
const overlap = finalConfig.overlap;
|
||||
let processedNodes = 0;
|
||||
let totalChunks = 0;
|
||||
|
||||
onProgress({
|
||||
phase: 'embedding',
|
||||
@@ -445,7 +473,7 @@ export const runEmbeddingPipeline = async (
|
||||
}
|
||||
|
||||
// Embed chunk texts in sub-batches to control memory
|
||||
const EMBED_SUB_BATCH = 8;
|
||||
const EMBED_SUB_BATCH = finalConfig.subBatchSize;
|
||||
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);
|
||||
@@ -495,7 +523,7 @@ export const runEmbeddingPipeline = async (
|
||||
console.log('📇 Creating vector index...');
|
||||
}
|
||||
|
||||
await createVectorIndex(executeQuery);
|
||||
const vectorIndexReady = await createVectorIndex(executeQuery);
|
||||
|
||||
onProgress({
|
||||
phase: 'ready',
|
||||
@@ -509,6 +537,12 @@ export const runEmbeddingPipeline = async (
|
||||
`✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
nodesProcessed: totalNodes,
|
||||
chunksProcessed: totalChunks,
|
||||
vectorIndexReady,
|
||||
semanticMode: vectorIndexReady ? 'vector-index' : 'exact-scan',
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
@@ -543,27 +577,71 @@ export const semanticSearch = async (
|
||||
const queryVec = embeddingToArray(queryEmbedding);
|
||||
const queryVecStr = `[${queryVec.join(',')}]`;
|
||||
|
||||
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
|
||||
`;
|
||||
let bestChunks = new Map<
|
||||
string,
|
||||
{ distance: number; chunkIndex: number; startLine: number; endLine: number }
|
||||
>();
|
||||
if (await loadVectorExtension()) {
|
||||
try {
|
||||
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);
|
||||
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],
|
||||
}));
|
||||
});
|
||||
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],
|
||||
}));
|
||||
});
|
||||
} catch {
|
||||
bestChunks = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
if (bestChunks.size === 0) {
|
||||
const countRows = await executeQuery(
|
||||
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN count(e) AS cnt`,
|
||||
);
|
||||
const countRow = countRows[0];
|
||||
const embeddingCount = Number(countRow?.cnt ?? countRow?.[0] ?? 0);
|
||||
const exactLimit = getExactScanLimit();
|
||||
if (embeddingCount > 0 && embeddingCount <= exactLimit) {
|
||||
const rows = await executeQuery(`
|
||||
MATCH (e:${EMBEDDING_TABLE_NAME})
|
||||
RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex,
|
||||
e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding
|
||||
`);
|
||||
const exactRows: ExactEmbeddingRow[] = rows.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,
|
||||
embedding: row.embedding ?? row[4] ?? [],
|
||||
}));
|
||||
bestChunks = new Map(
|
||||
rankExactEmbeddingRows(exactRows, queryVec, k, maxDistance).map((row) => [
|
||||
row.nodeId,
|
||||
{
|
||||
distance: row.distance,
|
||||
chunkIndex: row.chunkIndex,
|
||||
startLine: row.startLine,
|
||||
endLine: row.endLine,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (bestChunks.size === 0) {
|
||||
return [];
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface ExactEmbeddingRow {
|
||||
nodeId: string;
|
||||
chunkIndex: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
embedding: readonly number[];
|
||||
}
|
||||
|
||||
export interface ExactSearchChunk {
|
||||
nodeId: string;
|
||||
chunkIndex: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
const cosineDistance = (a: readonly number[], b: readonly number[]): number => {
|
||||
let dot = 0;
|
||||
let aNorm = 0;
|
||||
let bNorm = 0;
|
||||
const len = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const av = a[i] ?? 0;
|
||||
const bv = b[i] ?? 0;
|
||||
dot += av * bv;
|
||||
aNorm += av * av;
|
||||
bNorm += bv * bv;
|
||||
}
|
||||
if (aNorm === 0 || bNorm === 0) return 1;
|
||||
return 1 - dot / (Math.sqrt(aNorm) * Math.sqrt(bNorm));
|
||||
};
|
||||
|
||||
export const rankExactEmbeddingRows = (
|
||||
rows: readonly ExactEmbeddingRow[],
|
||||
queryEmbedding: readonly number[],
|
||||
limit: number,
|
||||
maxDistance: number,
|
||||
): ExactSearchChunk[] =>
|
||||
rows
|
||||
.map((row) => ({
|
||||
nodeId: row.nodeId,
|
||||
chunkIndex: row.chunkIndex,
|
||||
startLine: row.startLine,
|
||||
endLine: row.endLine,
|
||||
distance: cosineDistance(row.embedding, queryEmbedding),
|
||||
}))
|
||||
.filter((row) => row.distance < maxDistance)
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.slice(0, limit);
|
||||
@@ -0,0 +1,62 @@
|
||||
import os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* @internal Exported only for unit tests and the two embedder entry points
|
||||
* (`core/embeddings/embedder.ts` + `mcp/core/embedder.ts`). Not part of the
|
||||
* public package API.
|
||||
*
|
||||
* Minimal subset of `@huggingface/transformers`' `env` object that gitnexus
|
||||
* mutates. Defining a local structural type keeps this helper free of a
|
||||
* transitive dependency on transformers' generated `.d.ts` while still
|
||||
* giving full type-checking on the two fields we actually touch.
|
||||
*/
|
||||
export interface HfEnvSubset {
|
||||
cacheDir: string;
|
||||
remoteHost: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Exported only for unit tests and the two embedder entry points
|
||||
* (`core/embeddings/embedder.ts` + `mcp/core/embedder.ts`). Not part of the
|
||||
* public package API.
|
||||
*
|
||||
* Apply user-controlled HuggingFace environment overrides to the
|
||||
* `@huggingface/transformers` `env` object. Centralises the two env-var
|
||||
* bridges so every gitnexus embedder entry point (the analyze pipeline
|
||||
* and the MCP server) behaves identically.
|
||||
*
|
||||
* - **`HF_HOME`** → `env.cacheDir` (default: `~/.cache/huggingface`).
|
||||
* transformers.js otherwise defaults to `./node_modules/.cache` inside
|
||||
* its own install dir, which is unwritable when gitnexus is installed
|
||||
* globally (e.g. `/usr/lib/node_modules/`).
|
||||
*
|
||||
* - **`HF_ENDPOINT`** → `env.remoteHost` (#1205). transformers.js does
|
||||
* not read `HF_ENDPOINT` on its own — it reads `env.remoteHost` —
|
||||
* even though `HF_ENDPOINT` is the standard env var the upstream
|
||||
* `huggingface_hub` Python client and the official HF mirror docs
|
||||
* tell users to set. Bridging the two unblocks `--embeddings` for
|
||||
* users behind networks where `huggingface.co` is unreachable
|
||||
* (corporate proxies, the GFW, air-gapped mirrors). The trailing
|
||||
* slash is normalised because transformers.js builds URLs by string
|
||||
* concatenation and a missing slash silently falls through to its
|
||||
* default `huggingface.co/...` host.
|
||||
*
|
||||
* Mutation rather than return-and-apply because callers already hold a
|
||||
* reference to the live `env` object imported from
|
||||
* `@huggingface/transformers` — passing the same reference in keeps the
|
||||
* call site a single line at each entry point.
|
||||
*/
|
||||
export function applyHfEnvOverrides(env: HfEnvSubset): void {
|
||||
env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface');
|
||||
// `.trim()` guards against the common copy-paste failure mode of
|
||||
// `HF_ENDPOINT=" https://hf-mirror.com "` (leading/trailing whitespace
|
||||
// from shell scripts or docs) — without it, a whitespace-only value
|
||||
// would be truthy and produce an invalid `env.remoteHost = ' /'` that
|
||||
// silently misroutes downloads. Empty string remains falsy in JS so the
|
||||
// truthy guard already handles the unset/empty cases.
|
||||
const endpoint = process.env.HF_ENDPOINT?.trim();
|
||||
if (endpoint) {
|
||||
env.remoteHost = endpoint.endsWith('/') ? endpoint : endpoint + '/';
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,10 @@ export interface EmbeddingConfig {
|
||||
modelId: string;
|
||||
/** Number of nodes to embed in each batch */
|
||||
batchSize: number;
|
||||
/** Number of chunks passed to one local/HTTP embedding call */
|
||||
subBatchSize: number;
|
||||
/** Maximum ONNX Runtime CPU threads for local inference */
|
||||
threads: number;
|
||||
/** Embedding vector dimensions */
|
||||
dimensions: number;
|
||||
/** Device to use for inference: 'auto' tries GPU first (DirectML on Windows, CUDA on Linux), falls back to CPU */
|
||||
@@ -229,6 +233,8 @@ export interface EmbeddingConfig {
|
||||
export const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = {
|
||||
modelId: 'Snowflake/snowflake-arctic-embed-xs',
|
||||
batchSize: 16,
|
||||
subBatchSize: 8,
|
||||
threads: 2,
|
||||
dimensions: 384,
|
||||
device: 'auto',
|
||||
maxSnippetLength: 500,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'path';
|
||||
import { readRegistry, type RegistryEntry, type CwdMatch } from '../storage/repo-manager.js';
|
||||
import { getGitRoot, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
|
||||
import { findGitRootByDotGit, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
|
||||
|
||||
export interface StalenessInfo {
|
||||
isStale: boolean;
|
||||
@@ -101,9 +101,10 @@ export async function checkCwdMatch(cwd: string): Promise<CwdMatch> {
|
||||
}
|
||||
if (bestPath) return { match: 'path', entry: bestPath };
|
||||
|
||||
// 2) Sibling-by-remote: locate the cwd's git root, get its remote
|
||||
// URL, and look for any registered entry with the same fingerprint.
|
||||
const cwdGitRoot = getGitRoot(cwdResolved);
|
||||
// 2) Sibling-by-remote: locate the cwd's git root using only ancestor
|
||||
// `.git` checks before shelling out. This keeps MCP startup from
|
||||
// running git in an unrelated launch cwd such as $HOME (#1138).
|
||||
const cwdGitRoot = findGitRootByDotGit(cwdResolved);
|
||||
if (!cwdGitRoot) return { match: 'none' };
|
||||
|
||||
const cwdRemote = getRemoteUrl(cwdGitRoot);
|
||||
|
||||
@@ -5,8 +5,42 @@ import lbug from '@ladybugdb/core';
|
||||
import type { LbugValue } from '@ladybugdb/core';
|
||||
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
|
||||
import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
|
||||
import {
|
||||
closeLbugConnection,
|
||||
openLbugConnection,
|
||||
type LbugConnectionHandle,
|
||||
} from '../lbug/lbug-config.js';
|
||||
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
|
||||
|
||||
/**
|
||||
* Sidecar files that LadybugDB creates next to a `bridge.lbug` file.
|
||||
*
|
||||
* - `.wal` — write-ahead log; persists across opens but must be associated
|
||||
* with the same database instance (LadybugDB 0.16.0 enforces this via a
|
||||
* database-id check and rejects opens with the diagnostic
|
||||
* `"Database ID for temporary file 'X.wal' does not match the current
|
||||
* database. This file may have been left behind from a previous database
|
||||
* with the same name"`).
|
||||
* - `.shadow` — non-blocking concurrent checkpoint sidecar (added in
|
||||
* LadybugDB 0.15.4); same pairing constraint as `.wal`.
|
||||
*
|
||||
* `bridge-db` writes to a `bridge.lbug.tmp` file and then atomically renames
|
||||
* it into place. The rename only moves the main file; sidecars must be
|
||||
* cleaned up explicitly or the next writer trips the database-id check.
|
||||
*/
|
||||
const LBUG_SIDECAR_SUFFIXES = ['.wal', '.shadow'] as const;
|
||||
|
||||
async function removeLbugFile(basePath: string): Promise<void> {
|
||||
const candidates = [basePath, ...LBUG_SIDECAR_SUFFIXES.map((s) => `${basePath}${s}`)];
|
||||
for (const f of candidates) {
|
||||
try {
|
||||
await fsp.rm(f, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best-effort: caller will surface real errors via the open path */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function contractNodeId(
|
||||
repo: string,
|
||||
contractId: string,
|
||||
@@ -127,8 +161,7 @@ export function findContractNode(
|
||||
export async function openBridgeDb(dbPath: string): Promise<BridgeHandle> {
|
||||
const parentDir = path.dirname(dbPath);
|
||||
await fsp.mkdir(parentDir, { recursive: true });
|
||||
const db = new lbug.Database(dbPath, 0, false, false); // writable
|
||||
const conn = new lbug.Connection(db);
|
||||
const { db, conn } = await openLbugConnection(lbug, dbPath);
|
||||
return { _db: db, _conn: conn, groupDir: parentDir } as BridgeHandle;
|
||||
}
|
||||
|
||||
@@ -195,6 +228,17 @@ function unwrapQueryResult(queryResult: lbug.QueryResult | lbug.QueryResult[]):
|
||||
}
|
||||
|
||||
export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
|
||||
// CHECKPOINT before close so the WAL/.shadow contents are flushed into
|
||||
// the main database file. Without this, LadybugDB 0.16.0's non-blocking
|
||||
// checkpoint thread can outlive the close call and leave sidecar pages
|
||||
// pending on disk, which makes a subsequent read-side open either race
|
||||
// with the WAL replay or trip the database-id check on the sidecars.
|
||||
// CHECKPOINT is a no-op when there's nothing pending, so it's cheap.
|
||||
try {
|
||||
await (handle._conn as lbug.Connection).query('CHECKPOINT');
|
||||
} catch {
|
||||
/* ignore — older LadybugDB or schemaless DB may not accept it */
|
||||
}
|
||||
try {
|
||||
await (handle._conn as lbug.Connection).close();
|
||||
} catch {
|
||||
@@ -322,12 +366,11 @@ export async function writeBridge(
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up any leftover tmp
|
||||
try {
|
||||
await fsp.rm(tmpPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Clean up any leftover tmp main file AND its `.wal` / `.shadow` sidecars.
|
||||
// LadybugDB 0.16.0 rejects opening a database whose sidecars belong to a
|
||||
// different database instance (database-id check), so any stale sidecar
|
||||
// from a crashed previous run will fail the next writeBridge.
|
||||
await removeLbugFile(tmpPath);
|
||||
|
||||
// 1. Create temp DB, insert all data.
|
||||
//
|
||||
@@ -497,18 +540,43 @@ export async function writeBridge(
|
||||
}
|
||||
|
||||
// 3. Atomic swap: old→.bak, tmp→final, rm .bak
|
||||
//
|
||||
// The current database file (with its `.wal` / `.shadow` sidecars) is
|
||||
// moved aside, then the freshly built tmp database takes its place.
|
||||
// We move the sidecars together with the main file so the open below
|
||||
// and any external readers see a consistent set; orphan sidecars from
|
||||
// the tmp namespace are then removed because LadybugDB looks for them
|
||||
// under the renamed-to base name and would reject mismatching IDs.
|
||||
try {
|
||||
await fsp.access(finalPath);
|
||||
await retryRename(finalPath, bakPath);
|
||||
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
|
||||
try {
|
||||
await fsp.access(`${finalPath}${suffix}`);
|
||||
await retryRename(`${finalPath}${suffix}`, `${bakPath}${suffix}`);
|
||||
} catch {
|
||||
/* sidecar absent — nothing to move */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* no existing db */
|
||||
}
|
||||
await retryRename(tmpPath, finalPath);
|
||||
try {
|
||||
await fsp.rm(bakPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
|
||||
// Rename — not delete — so the WAL (which may carry uncommitted-at-
|
||||
// close-time pages on a graceful close, depending on
|
||||
// `autoCheckpoint` / `checkpointThreshold`) and the `.shadow`
|
||||
// checkpoint snapshot stay paired with the database file under its
|
||||
// final name. LadybugDB 0.16.0's database-id check rejects an open
|
||||
// when the sidecars belong to a different base name.
|
||||
try {
|
||||
await fsp.access(`${tmpPath}${suffix}`);
|
||||
await retryRename(`${tmpPath}${suffix}`, `${finalPath}${suffix}`);
|
||||
} catch {
|
||||
/* sidecar absent — nothing to move */
|
||||
}
|
||||
}
|
||||
await removeLbugFile(bakPath);
|
||||
|
||||
// 4. Write meta.json
|
||||
await writeBridgeMeta(groupDir, {
|
||||
@@ -524,10 +592,38 @@ export async function writeBridge(
|
||||
/* openBridgeDbReadOnly */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHandle | null> {
|
||||
/**
|
||||
* Substrings observed in the message of an `Error` raised by the LadybugDB
|
||||
* native open path when Windows still holds an exclusive lock on the file
|
||||
* after a writer's `Database.close()` returned. LadybugDB 0.16.0's
|
||||
* non-blocking checkpoint thread can briefly outlive the close call, so a
|
||||
* read-side opener that races in immediately afterwards sees Win32 error
|
||||
* 33 ("The process cannot access the file because another process has
|
||||
* locked a portion of the file"). Retrying with a small back-off lets the
|
||||
* background thread settle and the OS release the handle.
|
||||
*/
|
||||
const LBUG_OPEN_RETRY_PATTERNS = [
|
||||
'process cannot access the file',
|
||||
'another process has locked',
|
||||
'could not set lock',
|
||||
'lock held by another process',
|
||||
];
|
||||
|
||||
const LBUG_OPEN_RETRY_ATTEMPTS = 10;
|
||||
const LBUG_OPEN_RETRY_BASE_MS = 100;
|
||||
/** Cap individual back-off delays so the total wait is bounded (~3s). */
|
||||
const LBUG_OPEN_RETRY_MAX_MS = 500;
|
||||
|
||||
function isTransientLockError(err: unknown): boolean {
|
||||
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||
return LBUG_OPEN_RETRY_PATTERNS.some((p) => msg.includes(p));
|
||||
}
|
||||
|
||||
async function ensureBridgeDbFileAvailable(groupDir: string): Promise<boolean> {
|
||||
const dbPath = path.join(groupDir, 'bridge.lbug');
|
||||
try {
|
||||
await fsp.access(dbPath);
|
||||
return true;
|
||||
} catch {
|
||||
// Check for .bak recovery. Use `retryRename` (not `fsp.rename`) for the
|
||||
// exact same reason the rest of this file does: the scenario that
|
||||
@@ -538,42 +634,62 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
|
||||
try {
|
||||
await fsp.access(bakPath);
|
||||
await retryRename(bakPath, dbPath);
|
||||
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
|
||||
try {
|
||||
await fsp.access(`${bakPath}${suffix}`);
|
||||
await retryRename(`${bakPath}${suffix}`, `${dbPath}${suffix}`);
|
||||
} catch {
|
||||
/* sidecar absent */
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHandle | null> {
|
||||
const dbPath = path.join(groupDir, 'bridge.lbug');
|
||||
if (!(await ensureBridgeDbFileAvailable(groupDir))) return null;
|
||||
|
||||
// Version gate: check meta.json version compatibility
|
||||
const meta = await readBridgeMeta(groupDir);
|
||||
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
|
||||
return null; // incompatible schema version — fallback to JSON or re-sync
|
||||
}
|
||||
|
||||
// Open the native handle. If Connection construction throws AFTER
|
||||
// Database was successfully allocated, we'd leak the native Database
|
||||
// object. Wrap each step separately and tear down the partial handle.
|
||||
let db: lbug.Database | undefined;
|
||||
let conn: lbug.Connection | undefined;
|
||||
try {
|
||||
db = new lbug.Database(dbPath, 0, false, true); // readOnly
|
||||
conn = new lbug.Connection(db);
|
||||
return { _db: db, _conn: conn, groupDir } as BridgeHandle;
|
||||
} catch {
|
||||
if (conn) {
|
||||
try {
|
||||
await conn.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Open the native handle with a bounded retry on transient OS-level file
|
||||
// locks (see LBUG_OPEN_RETRY_PATTERNS). If Connection construction throws
|
||||
// AFTER Database was successfully allocated, we'd leak the native Database
|
||||
// object — wrap each step separately and tear down the partial handle.
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= LBUG_OPEN_RETRY_ATTEMPTS; attempt++) {
|
||||
let handle: LbugConnectionHandle | undefined;
|
||||
try {
|
||||
handle = await openLbugConnection(lbug, dbPath, { readOnly: true });
|
||||
// Force the lazy native init now so a transient lock surfaces here
|
||||
// (where we can retry) instead of on the first user query.
|
||||
await handle.db.init();
|
||||
await handle.conn.init();
|
||||
return { _db: handle.db, _conn: handle.conn, groupDir } as BridgeHandle;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (handle) await closeLbugConnection(handle);
|
||||
if (!isTransientLockError(err) || attempt === LBUG_OPEN_RETRY_ATTEMPTS) break;
|
||||
const delay = Math.min(LBUG_OPEN_RETRY_BASE_MS * attempt, LBUG_OPEN_RETRY_MAX_MS);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
if (db) {
|
||||
try {
|
||||
await db.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (process.env.GITNEXUS_DEBUG_BRIDGE) {
|
||||
console.warn(
|
||||
`[bridge-db] openBridgeDbReadOnly(${groupDir}) gave up after ` +
|
||||
`${LBUG_OPEN_RETRY_ATTEMPTS} attempts: ${
|
||||
lastErr instanceof Error ? lastErr.message : String(lastErr)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -581,8 +697,7 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function bridgeExists(groupDir: string): Promise<boolean> {
|
||||
const handle = await openBridgeDbReadOnly(groupDir);
|
||||
if (!handle) return false;
|
||||
await closeBridgeDb(handle);
|
||||
return true;
|
||||
if (!(await ensureBridgeDbFileAvailable(groupDir))) return false;
|
||||
const meta = await readBridgeMeta(groupDir);
|
||||
return meta.version === 0 || meta.version === BRIDGE_SCHEMA_VERSION;
|
||||
}
|
||||
|
||||
@@ -13,12 +13,15 @@ const DEFAULT_DETECT = {
|
||||
topics: true,
|
||||
shared_libs: true,
|
||||
embedding_fallback: true,
|
||||
workspace_deps: true,
|
||||
};
|
||||
|
||||
const DEFAULT_MATCHING = {
|
||||
bm25_threshold: 0.7,
|
||||
embedding_threshold: 0.65,
|
||||
max_candidates_per_step: 3,
|
||||
exclude_links_paths: [] as string[],
|
||||
exclude_links_param_only_paths: false,
|
||||
};
|
||||
|
||||
export function parseGroupConfig(yamlContent: string): GroupConfig {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as path from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
import Parser from 'tree-sitter';
|
||||
import { createIgnoreFilter } from '../../../config/ignore-service.js';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
@@ -227,11 +228,16 @@ async function buildProtoContext(repoPath: string): Promise<{
|
||||
servicesByName: Map<string, ProtoServiceInfo[]>;
|
||||
}> {
|
||||
const servicesByName = new Map<string, ProtoServiceInfo[]>();
|
||||
// `.gitnexusignore` / `.gitignore` honoured via the shared IgnoreService —
|
||||
// see `filesystem-walker.ts` for the canonical pattern. Replaces a
|
||||
// hardcoded `[node_modules, .git, vendor]` array; those names plus the
|
||||
// rest of `DEFAULT_IGNORE_LIST` are still excluded by default (#1185).
|
||||
const protoIgnoreFilter = await createIgnoreFilter(repoPath);
|
||||
const protoFiles = await glob('**/*.proto', {
|
||||
cwd: repoPath,
|
||||
absolute: false,
|
||||
nodir: true,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'],
|
||||
ignore: protoIgnoreFilter,
|
||||
});
|
||||
const contents = new Map<string, string>();
|
||||
|
||||
@@ -401,9 +407,14 @@ export class GrpcExtractor implements ContractExtractor {
|
||||
}
|
||||
|
||||
// ─── Source files (+ .proto when plugin available) ────────────
|
||||
// Honour `.gitnexusignore` / `.gitignore` via the shared IgnoreService —
|
||||
// mirrors `filesystem-walker.ts`. Replaces a hardcoded
|
||||
// `[node_modules, .git, vendor, dist, build]` array; those names are all
|
||||
// in `DEFAULT_IGNORE_LIST`, so default behaviour is preserved (#1185).
|
||||
const sourceIgnoreFilter = await createIgnoreFilter(repoPath);
|
||||
const sourceFiles = await glob(GRPC_SCAN_GLOB, {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
|
||||
ignore: sourceIgnoreFilter,
|
||||
nodir: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as path from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
import Parser from 'tree-sitter';
|
||||
import { createIgnoreFilter } from '../../../config/ignore-service.js';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
@@ -208,9 +209,16 @@ export class HttpRouteExtractor implements ContractExtractor {
|
||||
}
|
||||
|
||||
private async scanFiles(repoPath: string): Promise<string[]> {
|
||||
// Honour `.gitnexusignore` and `.gitignore` via the shared IgnoreService
|
||||
// so contract extraction respects the same exclusion rules as the rest of
|
||||
// the ingestion pipeline. Mirrors `filesystem-walker.ts` which uses the
|
||||
// same shape. Replaces a hardcoded `[node_modules, .git, dist, build,
|
||||
// vendor]` array — those names are still in `DEFAULT_IGNORE_LIST`, so
|
||||
// default behaviour is preserved (#1185).
|
||||
const ignoreFilter = await createIgnoreFilter(repoPath);
|
||||
return glob(HTTP_SCAN_GLOB, {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/vendor/**'],
|
||||
ignore: ignoreFilter,
|
||||
nodir: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -268,6 +268,19 @@ export class ManifestExtractor {
|
||||
LIMIT 1`,
|
||||
{ contract: link.contract },
|
||||
);
|
||||
} else if (link.type === 'custom') {
|
||||
// V1: exact name-only match on code-definition nodes.
|
||||
// Positive allowlist mirrors other contract types. If multiple code
|
||||
// symbols share the same name, ORDER BY filePath ASC LIMIT 1 picks
|
||||
// the alphabetically-first occurrence deterministically.
|
||||
rows = await executor(
|
||||
`MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement)
|
||||
WHERE n.name = $contract
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
ORDER BY n.filePath ASC
|
||||
LIMIT 1`,
|
||||
{ contract: link.contract },
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { CypherExecutor } from '../contract-extractor.js';
|
||||
import type { GroupManifestLink, ContractRole } from '../types.js';
|
||||
import { shouldIgnorePath } from '../../../config/ignore-service.js';
|
||||
import { loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
||||
/**
|
||||
* Discover cross-crate contracts in a Rust workspace by reading each
|
||||
* member's `Cargo.toml` dependencies and scanning source files for
|
||||
* `use <workspace_dep>::<Type>` imports.
|
||||
*
|
||||
* Emits `GroupManifestLink[]` with `type: 'custom'` that feed into the
|
||||
* existing ManifestExtractor pipeline — no new matching logic needed.
|
||||
*
|
||||
* Designed for the group-level sync pipeline: it receives all repos in
|
||||
* a group and produces cross-repo links between them.
|
||||
*/
|
||||
|
||||
interface CrateMeta {
|
||||
name: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
workspaceDeps: string[];
|
||||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
crateName: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Cargo.toml to extract the crate name and workspace dependency
|
||||
* names. Uses simple line-based parsing — no TOML library needed for
|
||||
* the subset we care about.
|
||||
*/
|
||||
async function parseCrateManifest(
|
||||
repoPath: string,
|
||||
): Promise<{ name: string; workspaceDeps: string[] } | null> {
|
||||
const cargoPath = path.join(repoPath, 'Cargo.toml');
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(cargoPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let name = '';
|
||||
const workspaceDeps: string[] = [];
|
||||
|
||||
const nameMatch = content.match(/^\[package\]\s*\n(?:[^\[]*?\n)*?name\s*=\s*"([^"]+)"/m);
|
||||
if (nameMatch) name = nameMatch[1];
|
||||
|
||||
// Match dependencies that use workspace = true, which indicates they
|
||||
// are workspace-internal deps:
|
||||
// dep_name = { workspace = true }
|
||||
// dep_name.workspace = true
|
||||
//
|
||||
// Also match plain path dependencies:
|
||||
// dep_name = { path = "../other" }
|
||||
const depSections = content.matchAll(
|
||||
/\[(dependencies|dev-dependencies|build-dependencies)\]\s*\n([\s\S]*?)(?=\n\[|$)/g,
|
||||
);
|
||||
|
||||
for (const section of depSections) {
|
||||
const sectionBody = section[2];
|
||||
// workspace = true style
|
||||
const wsMatches = sectionBody.matchAll(
|
||||
/^(\w[\w-]*)\s*=\s*\{[^}]*workspace\s*=\s*true[^}]*\}/gm,
|
||||
);
|
||||
for (const m of wsMatches) workspaceDeps.push(m[1]);
|
||||
|
||||
// dotted workspace style: dep_name.workspace = true
|
||||
const dottedMatches = sectionBody.matchAll(/^(\w[\w-]*)\.workspace\s*=\s*true/gm);
|
||||
for (const m of dottedMatches) workspaceDeps.push(m[1]);
|
||||
|
||||
// path = "../other" style (local path deps within workspace)
|
||||
const pathMatches = sectionBody.matchAll(
|
||||
/^(\w[\w-]*)\s*=\s*\{[^}]*path\s*=\s*"[^"]*"[^}]*\}/gm,
|
||||
);
|
||||
for (const m of pathMatches) workspaceDeps.push(m[1]);
|
||||
}
|
||||
|
||||
if (!name) return null;
|
||||
return { name, workspaceDeps: [...new Set(workspaceDeps)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan Rust source files for `use <crate>::<path>::<Symbol>` patterns
|
||||
* where <crate> is a known workspace dependency.
|
||||
*/
|
||||
async function scanImports(repoPath: string, knownCrates: Set<string>): Promise<ImportedSymbol[]> {
|
||||
const results: ImportedSymbol[] = [];
|
||||
|
||||
const normalizedCrates = new Map<string, string>();
|
||||
for (const c of knownCrates) {
|
||||
normalizedCrates.set(c.replace(/-/g, '_'), c);
|
||||
}
|
||||
|
||||
const sourceFiles = await findRustFiles(repoPath);
|
||||
for (const relFile of sourceFiles) {
|
||||
const absPath = path.join(repoPath, relFile);
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match patterns:
|
||||
// use crate_name::Type;
|
||||
// use crate_name::module::Type;
|
||||
// use crate_name::{Type1, Type2};
|
||||
// use crate_name::module::{Type1, Type2};
|
||||
const useRegex = /^use\s+(\w+)::(.+);/gm;
|
||||
let match;
|
||||
while ((match = useRegex.exec(content)) !== null) {
|
||||
const crateName = match[1];
|
||||
const originalCrateName = normalizedCrates.get(crateName);
|
||||
if (!originalCrateName) continue;
|
||||
|
||||
const importPath = match[2].trim();
|
||||
|
||||
// Handle grouped imports: {Type1, Type2, module::Type3}
|
||||
const braceMatch = importPath.match(/\{([^}]+)\}/);
|
||||
if (braceMatch) {
|
||||
const items = braceMatch[1].split(',').map((s) => s.trim());
|
||||
for (const item of items) {
|
||||
const symbolName = extractSymbolName(item);
|
||||
if (symbolName && isTypeName(symbolName)) {
|
||||
results.push({ crateName: originalCrateName, symbolName, filePath: relFile });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const symbolName = extractSymbolName(importPath);
|
||||
if (symbolName && isTypeName(symbolName)) {
|
||||
results.push({ crateName: originalCrateName, symbolName, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Extract the final symbol name from a path like `module::submod::TypeName`. */
|
||||
function extractSymbolName(importPath: string): string | null {
|
||||
const trimmed = importPath.trim();
|
||||
if (!trimmed || trimmed === '*' || trimmed === 'self') return null;
|
||||
const parts = trimmed.split('::');
|
||||
return parts[parts.length - 1].trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic: in Rust, types (structs, enums, traits) are PascalCase.
|
||||
* Functions and modules are snake_case. We only want types as cross-crate
|
||||
* contracts — functions are too granular and modules too broad.
|
||||
*/
|
||||
function isTypeName(name: string): boolean {
|
||||
return /^[A-Z][A-Za-z0-9]*$/.test(name);
|
||||
}
|
||||
|
||||
async function findRustFiles(repoPath: string): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
const ig = await loadIgnoreRules(repoPath);
|
||||
|
||||
async function walk(dir: string, rel: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel + '/')) continue;
|
||||
await walk(path.join(dir, entry.name), childRel);
|
||||
} else if (entry.name.endsWith('.rs')) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel)) continue;
|
||||
results.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(repoPath, '');
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface RustWorkspaceResult {
|
||||
links: GroupManifestLink[];
|
||||
discoveredCrates: Map<string, CrateMeta>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover cross-crate contracts across all Rust repos in a group.
|
||||
*
|
||||
* Returns `GroupManifestLink[]` ready to feed into `ManifestExtractor`.
|
||||
*/
|
||||
export async function extractRustWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<RustWorkspaceResult> {
|
||||
// Phase 1: Parse all Cargo.toml files to build crate registry
|
||||
const cratesByName = new Map<string, CrateMeta>();
|
||||
const cratesByGroupPath = new Map<string, CrateMeta>();
|
||||
|
||||
for (const [groupPath] of Object.entries(repos)) {
|
||||
const repoPath = repoPaths.get(groupPath);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = await parseCrateManifest(repoPath);
|
||||
if (!manifest) continue;
|
||||
|
||||
const meta: CrateMeta = {
|
||||
name: manifest.name,
|
||||
groupPath,
|
||||
repoPath,
|
||||
workspaceDeps: manifest.workspaceDeps,
|
||||
};
|
||||
const existing = cratesByName.get(manifest.name);
|
||||
if (existing) {
|
||||
console.warn(
|
||||
`[rust-workspace-extractor] duplicate crate name "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
cratesByName.set(manifest.name, meta);
|
||||
cratesByGroupPath.set(groupPath, meta);
|
||||
}
|
||||
|
||||
// Phase 2: For each crate, identify which of its workspace deps are
|
||||
// also in this group (i.e., repos we can link to)
|
||||
const links: GroupManifestLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [, crate] of cratesByGroupPath) {
|
||||
const groupCrateDeps = crate.workspaceDeps.filter((d) => cratesByName.has(d));
|
||||
if (groupCrateDeps.length === 0) continue;
|
||||
|
||||
// Phase 3: Scan source files for imports from workspace deps
|
||||
const knownCrates = new Set(groupCrateDeps);
|
||||
const imports = await scanImports(crate.repoPath, knownCrates);
|
||||
|
||||
for (const imp of imports) {
|
||||
const providerCrate = cratesByName.get(imp.crateName);
|
||||
if (!providerCrate) continue;
|
||||
|
||||
const qualifiedContract = `${imp.crateName}::${imp.symbolName}`;
|
||||
const key = `${crate.groupPath}→${providerCrate.groupPath}::${qualifiedContract}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const link: GroupManifestLink = {
|
||||
from: providerCrate.groupPath,
|
||||
to: crate.groupPath,
|
||||
type: 'custom',
|
||||
contract: qualifiedContract,
|
||||
role: 'provider' as ContractRole,
|
||||
};
|
||||
links.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
return { links, discoveredCrates: cratesByGroupPath };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { glob } from 'glob';
|
||||
import Parser from 'tree-sitter';
|
||||
import { createIgnoreFilter } from '../../../config/ignore-service.js';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
@@ -56,22 +57,21 @@ export class TopicExtractor implements ContractExtractor {
|
||||
repoPath: string,
|
||||
_repo: RepoHandle,
|
||||
): Promise<ExtractedContract[]> {
|
||||
// Honour `.gitnexusignore` / `.gitignore` via the shared IgnoreService —
|
||||
// mirrors `filesystem-walker.ts`. The 5-name hardcoded list
|
||||
// (`node_modules, .git, vendor, dist, build`) is preserved because every
|
||||
// entry is in `DEFAULT_IGNORE_LIST`, so default behaviour is unchanged
|
||||
// (#1185). The Go-specific `**/*_test.go` filter is layered on top via a
|
||||
// small wrapper so glob-level pruning is preserved (we never read those
|
||||
// files); the wrapper short-circuits before calling the base filter.
|
||||
const baseFilter = await createIgnoreFilter(repoPath);
|
||||
const ignoreFilter: typeof baseFilter = {
|
||||
ignored: (p) => p.relative().endsWith('_test.go') || baseFilter.ignored(p),
|
||||
childrenIgnored: (p) => baseFilter.childrenIgnored(p),
|
||||
};
|
||||
const files = await glob(TOPIC_SCAN_GLOB, {
|
||||
cwd: repoPath,
|
||||
ignore: [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/vendor/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
// Language-level test file conventions. Go test files
|
||||
// `*_test.go` live next to source; other languages either use
|
||||
// separate test directories (Python's `tests/`, Java's
|
||||
// `src/test/`) or are already covered by the dist/build ignores.
|
||||
// Pushed to the glob level so the orchestrator stays
|
||||
// language-agnostic.
|
||||
'**/*_test.go',
|
||||
],
|
||||
ignore: ignoreFilter,
|
||||
nodir: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { StoredContract, CrossLink } from './types.js';
|
||||
import type { StoredContract, CrossLink, MatchingConfig } from './types.js';
|
||||
|
||||
export interface MatchResult {
|
||||
matched: CrossLink[];
|
||||
@@ -14,6 +14,43 @@ function isGrpcWildcard(cid: string): boolean {
|
||||
return cid.startsWith('grpc::') && cid.endsWith('/*');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect HTTP contracts that are too generic or infrastructure-level to
|
||||
* produce meaningful cross-repo links. These are still extracted (useful
|
||||
* for documentation / route maps) but excluded from cross-link matching.
|
||||
*
|
||||
* Two categories:
|
||||
* 1. Health-check / readiness endpoints — every service has one, matching
|
||||
* them produces N×M false links.
|
||||
* 2. Param-only paths — routes like `/{param}` or `/{param}/{param}` that
|
||||
* collapse to a single catch-all after normalization. These match any
|
||||
* service with a similar shape, producing false positives.
|
||||
*
|
||||
* Both are configurable via matching.exclude_links_paths and
|
||||
* matching.exclude_links_param_only_paths in group.yaml.
|
||||
*/
|
||||
function buildNoisyContractFilter(
|
||||
matchingConfig?: MatchingConfig,
|
||||
): (contractId: string) => boolean {
|
||||
const excludePaths = matchingConfig?.exclude_links_paths?.length
|
||||
? new Set(matchingConfig.exclude_links_paths.map((p) => p.replace(/\/+$/, '')))
|
||||
: new Set<string>();
|
||||
const excludeParamOnly = matchingConfig?.exclude_links_param_only_paths === true;
|
||||
|
||||
return function isNoisyHttpContract(contractId: string): boolean {
|
||||
if (!contractId.startsWith('http::')) return false;
|
||||
const parts = contractId.split('::');
|
||||
if (parts.length < 3) return false;
|
||||
const pathPart = parts.slice(2).join('::').replace(/\/+$/, '');
|
||||
if (excludePaths.has(pathPart)) return true;
|
||||
if (excludeParamOnly) {
|
||||
const segments = pathPart.split('/').filter(Boolean);
|
||||
if (segments.length > 0 && segments.every((s) => s === '{param}')) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeContractId(id: string): string {
|
||||
const colonIdx = id.indexOf('::');
|
||||
if (colonIdx === -1) return id;
|
||||
@@ -91,8 +128,12 @@ function findMatchingKeys(contractId: string, index: Map<string, StoredContract[
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildProviderIndex(contracts: StoredContract[]): Map<string, StoredContract[]> {
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
export function buildProviderIndex(
|
||||
contracts: StoredContract[],
|
||||
matchingConfig?: MatchingConfig,
|
||||
): Map<string, StoredContract[]> {
|
||||
const isNoisy = buildNoisyContractFilter(matchingConfig);
|
||||
const providers = contracts.filter((c) => c.role === 'provider' && !isNoisy(c.contractId));
|
||||
const index = new Map<string, StoredContract[]>();
|
||||
for (const p of providers) {
|
||||
const key = normalizeContractId(p.contractId);
|
||||
@@ -106,11 +147,14 @@ export function buildProviderIndex(contracts: StoredContract[]): Map<string, Sto
|
||||
export function runExactMatch(
|
||||
contracts: StoredContract[],
|
||||
providerIndex?: Map<string, StoredContract[]>,
|
||||
matchingConfig?: MatchingConfig,
|
||||
): MatchResult {
|
||||
const index = providerIndex ?? buildProviderIndex(contracts);
|
||||
const isNoisy = buildNoisyContractFilter(matchingConfig);
|
||||
const index = providerIndex ?? buildProviderIndex(contracts, matchingConfig);
|
||||
|
||||
// Skip gRPC wildcard consumers — they go to wildcard pass only
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId));
|
||||
const consumers = contracts.filter(
|
||||
(c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId) && !isNoisy(c.contractId),
|
||||
);
|
||||
|
||||
const matched: CrossLink[] = [];
|
||||
const matchedConsumerIds = new Set<string>();
|
||||
@@ -155,6 +199,7 @@ export function runExactMatch(
|
||||
// normalUnmatched: contracts that weren't matched in exact pass
|
||||
const normalUnmatched = contracts.filter((c) => {
|
||||
if (isGrpcWildcard(c.contractId)) return false; // excluded from exact, handled separately
|
||||
if (isNoisy(c.contractId)) return false; // excluded from matching — don't surface as unmatched
|
||||
const id = `${c.repo}::${c.contractId}`;
|
||||
return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id);
|
||||
});
|
||||
|
||||
@@ -103,6 +103,8 @@ matching:
|
||||
bm25_threshold: 0.7
|
||||
embedding_threshold: 0.65
|
||||
max_candidates_per_step: 3
|
||||
# exclude_links_paths: [/ping, /health, /healthcheck]
|
||||
# exclude_links_param_only_paths: false
|
||||
`;
|
||||
await fsp.writeFile(path.join(groupDir, 'group.yaml'), template, 'utf-8');
|
||||
return groupDir;
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { extractRustWorkspaceLinks } from './extractors/rust-workspace-extractor.js';
|
||||
import { runExactMatch } from './matching.js';
|
||||
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
|
||||
import type { CypherExecutor } from './contract-extractor.js';
|
||||
@@ -84,12 +85,14 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
||||
let autoContracts: StoredContract[] = [];
|
||||
let manifestCrossLinks: CrossLink[] = [];
|
||||
let dbExecutors: Map<string, CypherExecutor> | undefined;
|
||||
let registryEntries: RegistryEntry[] | undefined;
|
||||
|
||||
const eo = opts?.extractorOverride;
|
||||
if (eo && eo.length === 0) {
|
||||
autoContracts = await (eo as () => Promise<StoredContract[]>)();
|
||||
} else {
|
||||
const entries = await readRegistry();
|
||||
registryEntries = await readRegistry();
|
||||
const entries = registryEntries;
|
||||
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
|
||||
const httpEx = new HttpRouteExtractor();
|
||||
const grpcEx = new GrpcExtractor();
|
||||
@@ -177,18 +180,39 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
||||
}
|
||||
}
|
||||
|
||||
// Process manifest links declared in group.yaml.
|
||||
// Auto-discover workspace dependency contracts (Rust Cargo workspaces, etc.)
|
||||
// and merge them with explicit manifest links. Discovered links use the same
|
||||
// ManifestExtractor pipeline as hand-written links in group.yaml.
|
||||
let allLinks = [...config.links];
|
||||
|
||||
if (config.detect.workspace_deps) {
|
||||
const repoPaths = new Map<string, string>();
|
||||
if (!registryEntries) registryEntries = await readRegistry();
|
||||
for (const [groupPath, regName] of Object.entries(config.repos)) {
|
||||
const e = registryEntries.find((en) => en.name === regName);
|
||||
if (e) repoPaths.set(groupPath, e.path);
|
||||
}
|
||||
|
||||
const wsResult = await extractRustWorkspaceLinks(config.repos, repoPaths, dbExecutors);
|
||||
if (wsResult.links.length > 0) {
|
||||
allLinks = [...allLinks, ...wsResult.links];
|
||||
if (opts?.verbose) {
|
||||
console.log(
|
||||
` workspace-deps: discovered ${wsResult.links.length} cross-crate links from ${wsResult.discoveredCrates.size} Rust crates`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process manifest links declared in group.yaml (plus any auto-discovered).
|
||||
// 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.
|
||||
if (allLinks.length > 0) {
|
||||
const knownRepos = new Set(Object.keys(config.repos));
|
||||
for (const link of config.links) {
|
||||
for (const link of allLinks) {
|
||||
const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r));
|
||||
if (dangling.length > 0) {
|
||||
console.warn(
|
||||
@@ -198,17 +222,17 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
||||
}
|
||||
|
||||
const manifestEx = new ManifestExtractor();
|
||||
const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors);
|
||||
const manifestResult = await manifestEx.extractFromManifest(allLinks, dbExecutors);
|
||||
autoContracts.push(...manifestResult.contracts);
|
||||
manifestCrossLinks = manifestResult.crossLinks;
|
||||
if (opts?.verbose) {
|
||||
console.log(
|
||||
` manifest: ${manifestCrossLinks.length} cross-links from ${config.links.length} declared links`,
|
||||
` manifest: ${manifestCrossLinks.length} cross-links from ${allLinks.length} links (${config.links.length} declared + ${allLinks.length - config.links.length} discovered)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { matched, unmatched } = runExactMatch(autoContracts);
|
||||
const { matched, unmatched } = runExactMatch(autoContracts, undefined, config.matching);
|
||||
|
||||
// Dedupe cross-links. Manifest contracts participate in runExactMatch, so a
|
||||
// manifest-declared link can also emit a matchType:'exact' CrossLink with the
|
||||
|
||||
@@ -27,12 +27,31 @@ export interface DetectConfig {
|
||||
topics: boolean;
|
||||
shared_libs: boolean;
|
||||
embedding_fallback: boolean;
|
||||
workspace_deps: boolean;
|
||||
}
|
||||
|
||||
export interface MatchingConfig {
|
||||
bm25_threshold: number;
|
||||
embedding_threshold: number;
|
||||
max_candidates_per_step: number;
|
||||
/**
|
||||
* HTTP paths to exclude from cross-link matching. Contracts at these paths
|
||||
* are still extracted and visible in the registry, but they don't produce
|
||||
* cross-repo links. Useful for health-check endpoints (`/ping`, `/health`)
|
||||
* that every service exposes and would otherwise create N×M false links.
|
||||
* Trailing slashes are normalized before comparison.
|
||||
* @default []
|
||||
*/
|
||||
exclude_links_paths?: string[];
|
||||
/**
|
||||
* When `true`, exclude HTTP routes where every path segment is `{param}`
|
||||
* (e.g. `/{param}`, `/{param}/{param}`) from cross-link matching. Mixed
|
||||
* routes like `/users/{param}` are not affected. These param-only routes
|
||||
* collapse to a single catch-all after normalization and produce false
|
||||
* positives across unrelated services.
|
||||
* @default false
|
||||
*/
|
||||
exclude_links_param_only_paths?: boolean;
|
||||
}
|
||||
|
||||
export interface SymbolRef {
|
||||
|
||||
@@ -12,7 +12,7 @@ type ReceiverSource = ReceiverEnriched['receiverSource'];
|
||||
* DAG stage 4 fallback: used when `selectDispatch` is absent or returns null.
|
||||
* Preserves pre-DAG dispatch semantics:
|
||||
* - 'constructor' → constructor branch
|
||||
* - 'free' → free branch (admits Swift/Kotlin class-target fast path)
|
||||
* - 'free' → free branch (admits class-target fast path)
|
||||
* - 'member' or undefined → owner-scoped branch
|
||||
*
|
||||
* `undefined` callForm MUST route through owner-scoped (not free) so bare
|
||||
@@ -1595,41 +1595,30 @@ const disambiguateByOverloadOrArgTypes = (
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapse Swift-extension duplicate Class/Struct candidates to the primary
|
||||
* definition, preferring the shortest file path.
|
||||
*
|
||||
* Swift extensions (`extension User { ... }` in a separate file) create
|
||||
* multiple `Class` nodes sharing the same symbol name — one for the primary
|
||||
* declaration and one per extension file. When overload disambiguation and
|
||||
* receiver narrowing both fail to converge on a single candidate, this
|
||||
* heuristic picks the primary definition based on the assumption that it
|
||||
* lives at the shortest file path (e.g. `User.swift` over `UserExtensions.swift`).
|
||||
*
|
||||
* Intentionally narrower than {@link INSTANTIABLE_CLASS_TYPES}: only `Class`
|
||||
* and `Struct` are considered, not `Record`. Swift extensions only produce
|
||||
* `Class` duplicates in practice, and C#/Kotlin records do not exhibit the
|
||||
* same multi-file-definition pattern, so widening this set risks accidental
|
||||
* dedup of legitimately distinct record types.
|
||||
*
|
||||
* Returns a `ResolveResult` when the heuristic fires, `null` when the
|
||||
* candidate pool does not match the shape (mixed types, non-Class/Struct
|
||||
* kinds, or `length <= 1`). Callers should fall through to their own null
|
||||
* return when this helper returns `null`.
|
||||
*
|
||||
* Used by `resolveFreeCall`. Having a single source of truth prevents
|
||||
* duplication if the heuristic is ever tuned.
|
||||
*/
|
||||
const dedupSwiftExtensionCandidates = (
|
||||
const orderProviderSameNameTypeCandidates = (
|
||||
candidates: readonly SymbolDefinition[],
|
||||
typeName: string,
|
||||
filePath: string,
|
||||
): readonly SymbolDefinition[] | null => {
|
||||
const language = getLanguageFromFilename(filePath);
|
||||
if (language == null) return null;
|
||||
return (
|
||||
getProvider(language).orderSameNameTypeCandidates?.({
|
||||
typeName,
|
||||
callSiteFilePath: filePath,
|
||||
candidates,
|
||||
}) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const resolveProviderPrimaryTypeCandidate = (
|
||||
candidates: readonly SymbolDefinition[],
|
||||
tier: ResolutionTier,
|
||||
typeName: string,
|
||||
filePath: string,
|
||||
): ResolveResult | null => {
|
||||
if (candidates.length <= 1) return null;
|
||||
const allSameType = candidates.every((c) => c.type === candidates[0].type);
|
||||
if (!allSameType) return null;
|
||||
if (candidates[0].type !== 'Class' && candidates[0].type !== 'Struct') return null;
|
||||
const sorted = [...candidates].sort((a, b) => a.filePath.length - b.filePath.length);
|
||||
return toResolveResult(sorted[0], tier);
|
||||
const ordered = orderProviderSameNameTypeCandidates(candidates, typeName, filePath);
|
||||
return ordered && ordered.length > 0 ? toResolveResult(ordered[0], tier) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -2223,6 +2212,35 @@ const resolveMethodByOwner = (
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstDef && !ambiguous) {
|
||||
const orderedTypeCandidates = orderProviderSameNameTypeCandidates(
|
||||
ctx.model.types.lookupClassByName(receiverTypeName),
|
||||
receiverTypeName,
|
||||
filePath,
|
||||
);
|
||||
if (orderedTypeCandidates) {
|
||||
for (const candidate of orderedTypeCandidates) {
|
||||
const def = canWalkMRO
|
||||
? lookupMethodByOwnerWithMRO(
|
||||
candidate.nodeId,
|
||||
methodName,
|
||||
heritageMap,
|
||||
ctx.model,
|
||||
mroStrategy,
|
||||
argCount,
|
||||
)
|
||||
: ctx.model.methods.lookupMethodByOwner(candidate.nodeId, methodName, argCount);
|
||||
if (!def) continue;
|
||||
if (!firstDef) {
|
||||
firstDef = def;
|
||||
} else if (def.nodeId !== firstDef.nodeId) {
|
||||
ambiguous = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstDef || ambiguous) return undefined;
|
||||
return { def: firstDef, tier: typeResolved.tier };
|
||||
};
|
||||
@@ -2290,9 +2308,9 @@ export const resolveMemberCall = (
|
||||
* resolution via `ctx.resolve()`.
|
||||
*
|
||||
* Used for `foo()`, `doStuff()` — unqualified calls with no receiver.
|
||||
* Also handles Swift/Kotlin implicit constructors (`User()` without `new`)
|
||||
* by delegating to {@link resolveStaticCall} when the tiered pool contains
|
||||
* class-like targets.
|
||||
* Also handles implicit constructors (`User()` without `new`) by delegating
|
||||
* to {@link resolveStaticCall} when the tiered pool contains class-like
|
||||
* targets.
|
||||
*
|
||||
* {@link resolveCallTarget} delegates here for `callForm === 'free'`.
|
||||
*
|
||||
@@ -2324,33 +2342,30 @@ export const resolveFreeCall = (
|
||||
|
||||
let filteredCandidates = filterCallableCandidates(tiered.candidates, argCount, 'free');
|
||||
|
||||
// Class-target fast path: Swift/Kotlin `User()` — free-form call targeting a
|
||||
// class. Delegates to resolveStaticCall for O(1) class + constructor lookup.
|
||||
// Class-target fast path: free-form call targeting a class. Delegates to
|
||||
// resolveStaticCall for O(1) class + constructor lookup.
|
||||
// The `.some()` trigger must stay aligned with `INSTANTIABLE_CLASS_TYPES` —
|
||||
// any type admitted here that is not in that set will cause resolveStaticCall
|
||||
// to return null, wasting two lookup passes per call. `Enum` is deliberately
|
||||
// excluded; `Record` is included so C# records and Kotlin data classes reach
|
||||
// the fast path.
|
||||
// excluded; `Record` is included so record-like class targets reach the fast
|
||||
// path.
|
||||
// Align with INSTANTIABLE_CLASS_TYPES by reusing the set directly rather
|
||||
// than enumerating literal strings. This converts an invariant that was
|
||||
// previously enforced by a comment ("keep this list aligned with
|
||||
// INSTANTIABLE_CLASS_TYPES") into one enforced structurally — any future
|
||||
// extension of the set (e.g. Kotlin `object`) propagates here automatically.
|
||||
// The `dedupSwiftExtensionCandidates` helper used in the tail of this
|
||||
// function deliberately uses a narrower literal `'Class' | 'Struct'` check
|
||||
// — Swift extensions only produce Class duplicates in practice, so Record
|
||||
// is excluded there by design. Do not collapse that helper into
|
||||
// INSTANTIABLE_CLASS_TYPES.
|
||||
// extension of the set propagates here automatically.
|
||||
// Language providers can still choose a primary same-name type candidate in
|
||||
// the tail of this function when their grammars index one logical type
|
||||
// multiple times.
|
||||
const hasClassTarget =
|
||||
filteredCandidates.length === 0 &&
|
||||
tiered.candidates.some((c) => INSTANTIABLE_CLASS_TYPES.has(c.type));
|
||||
if (hasClassTarget) {
|
||||
const staticResult = resolveStaticCall(calledName, filePath, ctx, argCount, tiered);
|
||||
if (staticResult) return staticResult;
|
||||
// Retry with constructor form: Swift/Kotlin constructor calls look like
|
||||
// free function calls (no `new` keyword). If resolveStaticCall didn't
|
||||
// match, re-filter with constructor form so CONSTRUCTOR_TARGET_TYPES
|
||||
// applies.
|
||||
// Retry with constructor form for languages whose constructor calls look
|
||||
// like free function calls. If resolveStaticCall didn't match, re-filter
|
||||
// with constructor form so CONSTRUCTOR_TARGET_TYPES applies.
|
||||
//
|
||||
// The retry fires for every null return from `resolveStaticCall`, which
|
||||
// can happen for three distinct reasons — all three are handled below:
|
||||
@@ -2364,9 +2379,8 @@ export const resolveFreeCall = (
|
||||
// (b) Homonym ambiguity — two or more instantiable class candidates
|
||||
// share the name (e.g. `User` in two files, same tier). The
|
||||
// retry repopulates `filteredCandidates` with both Classes and
|
||||
// they flow into `dedupSwiftExtensionCandidates` below, which
|
||||
// either picks the shortest-path primary or null-routes.
|
||||
// Covered by the R7 Swift-extension dedup test.
|
||||
// they flow into the provider same-name candidate hook below, which
|
||||
// can pick a primary definition or null-route.
|
||||
//
|
||||
// (c) `resolveStaticCall` step 4 bailed because the tiered pool
|
||||
// contains ownerless `Constructor` nodes (some extractors emit
|
||||
@@ -2391,10 +2405,13 @@ export const resolveFreeCall = (
|
||||
}
|
||||
|
||||
if (filteredCandidates.length !== 1) {
|
||||
// See `dedupSwiftExtensionCandidates` — shared helper, single source of
|
||||
// truth for the Swift-extension same-name collision heuristic.
|
||||
const deduped = dedupSwiftExtensionCandidates(filteredCandidates, tiered.tier);
|
||||
if (deduped) return deduped;
|
||||
const primary = resolveProviderPrimaryTypeCandidate(
|
||||
filteredCandidates,
|
||||
tiered.tier,
|
||||
calledName,
|
||||
filePath,
|
||||
);
|
||||
if (primary) return primary;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2559,9 +2576,16 @@ export const resolveStaticCall = (
|
||||
// Interface / Trait / Impl). Null-route via the fall-through `return
|
||||
// null` — this is the dominant Codex-fix case.
|
||||
// length === 1 → a single instantiable candidate remains, return it.
|
||||
// length > 1 → two or more instantiable classes share the name (e.g.
|
||||
// homonym classes across files with no import narrowing). Fall through
|
||||
// to `return null` so the caller null-routes rather than guess.
|
||||
// length > 1 → let the call-site provider choose a primary when it can
|
||||
// prove the candidates are one logical type; otherwise null-route.
|
||||
const primary = resolveProviderPrimaryTypeCandidate(
|
||||
instantiableCandidates,
|
||||
typeResolved.tier,
|
||||
className,
|
||||
currentFile,
|
||||
);
|
||||
if (primary) return primary;
|
||||
|
||||
if (instantiableCandidates.length === 1) {
|
||||
return toResolveResult(instantiableCandidates[0], typeResolved.tier);
|
||||
}
|
||||
|
||||
@@ -12,18 +12,15 @@
|
||||
|
||||
import { detectFrameworkFromPath } from './framework-detection.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { providers } from './languages/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// NAME PATTERNS - All 13 supported languages
|
||||
// NAME PATTERNS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Common entry point naming patterns by language.
|
||||
* These patterns indicate functions that are likely feature entry points.
|
||||
*
|
||||
* Universal patterns are separated from per-language patterns so the per-language
|
||||
* table can use `satisfies Record<SupportedLanguages, RegExp[]>` for compile-time
|
||||
* exhaustiveness — the compiler catches any missing language entry.
|
||||
* Universal entry point naming patterns shared across all languages.
|
||||
* Per-language patterns live on each LanguageProvider.entryPointPatterns.
|
||||
*/
|
||||
const UNIVERSAL_ENTRY_POINT_PATTERNS: RegExp[] = [
|
||||
/^(main|init|bootstrap|start|run|setup|configure)$/i,
|
||||
@@ -40,201 +37,11 @@ const UNIVERSAL_ENTRY_POINT_PATTERNS: RegExp[] = [
|
||||
/^emit[A-Z]/, // emitEvent
|
||||
];
|
||||
|
||||
export const ENTRY_POINT_PATTERNS = {
|
||||
// JavaScript/TypeScript
|
||||
[SupportedLanguages.JavaScript]: [
|
||||
/^use[A-Z]/, // React hooks (useEffect, etc.)
|
||||
],
|
||||
[SupportedLanguages.TypeScript]: [
|
||||
/^use[A-Z]/, // React hooks
|
||||
],
|
||||
|
||||
// Python
|
||||
[SupportedLanguages.Python]: [
|
||||
/^app$/, // Flask/FastAPI app
|
||||
/^(get|post|put|delete|patch)_/i, // REST conventions
|
||||
/^api_/, // API functions
|
||||
/^view_/, // Django views
|
||||
],
|
||||
|
||||
// Java
|
||||
[SupportedLanguages.Java]: [
|
||||
/^do[A-Z]/, // doGet, doPost (Servlets)
|
||||
/^create[A-Z]/, // Factory patterns
|
||||
/^build[A-Z]/, // Builder patterns
|
||||
/Service$/, // UserService
|
||||
],
|
||||
|
||||
// Kotlin
|
||||
[SupportedLanguages.Kotlin]: [
|
||||
/^on(Create|Start|Resume|Pause|Stop|Destroy)$/, // Android lifecycle
|
||||
/^do[A-Z]/, // doGet, doPost (shared JVM Servlet pattern)
|
||||
/^create[A-Z]/, // Factory patterns
|
||||
/^build[A-Z]/, // Builder patterns
|
||||
/ViewModel$/, // MVVM pattern (Android)
|
||||
/^module$/, // Ktor module entry point
|
||||
/Service$/, // Service classes
|
||||
],
|
||||
|
||||
// C#
|
||||
[SupportedLanguages.CSharp]: [
|
||||
/^(Get|Post|Put|Delete|Patch)/, // ASP.NET action methods
|
||||
/Action$/, // MVC actions
|
||||
/^On[A-Z]/, // Event handlers / Blazor lifecycle
|
||||
/Async$/, // Async entry points
|
||||
/^Configure$/, // Startup.Configure
|
||||
/^ConfigureServices$/, // Startup.ConfigureServices
|
||||
/^Handle$/, // MediatR / generic handler
|
||||
/^Execute$/, // Command pattern
|
||||
/^Invoke$/, // Middleware Invoke
|
||||
/^Map[A-Z]/, // Minimal API MapGet, MapPost
|
||||
/Service$/, // Service classes
|
||||
/^Seed/, // Database seeding
|
||||
],
|
||||
|
||||
// Go
|
||||
[SupportedLanguages.Go]: [
|
||||
/Handler$/, // http.Handler pattern
|
||||
/^Serve/, // ServeHTTP
|
||||
/^New[A-Z]/, // Constructor pattern (returns new instance)
|
||||
/^Make[A-Z]/, // Make functions
|
||||
],
|
||||
|
||||
// Rust
|
||||
[SupportedLanguages.Rust]: [
|
||||
/^(get|post|put|delete)_handler$/i,
|
||||
/^handle_/, // handle_request
|
||||
/^new$/, // Constructor pattern
|
||||
/^run$/, // run entry point
|
||||
/^spawn/, // Async spawn
|
||||
],
|
||||
|
||||
// C - explicit main() boost plus common C entry point conventions
|
||||
[SupportedLanguages.C]: [
|
||||
/^main$/, // THE entry point
|
||||
/^init_/, // init_server, init_client
|
||||
/_init$/, // module_init, server_init
|
||||
/^start_/, // start_server
|
||||
/_start$/, // thread_start
|
||||
/^run_/, // run_loop
|
||||
/_run$/, // event_run
|
||||
/^stop_/, // stop_server
|
||||
/_stop$/, // service_stop
|
||||
/^open_/, // open_connection
|
||||
/_open$/, // file_open
|
||||
/^close_/, // close_connection
|
||||
/_close$/, // socket_close
|
||||
/^create_/, // create_session
|
||||
/_create$/, // object_create
|
||||
/^destroy_/, // destroy_session
|
||||
/_destroy$/, // object_destroy
|
||||
/^handle_/, // handle_request
|
||||
/_handler$/, // signal_handler
|
||||
/_callback$/, // event_callback
|
||||
/^cmd_/, // tmux: cmd_new_window, cmd_attach_session
|
||||
/^server_/, // server_start, server_loop
|
||||
/^client_/, // client_connect
|
||||
/^session_/, // session_create
|
||||
/^window_/, // window_resize (tmux)
|
||||
/^key_/, // key_press
|
||||
/^input_/, // input_parse
|
||||
/^output_/, // output_write
|
||||
/^notify_/, // notify_client
|
||||
/^control_/, // control_start
|
||||
],
|
||||
|
||||
// C++ - same as C plus OOP/template patterns
|
||||
[SupportedLanguages.CPlusPlus]: [
|
||||
/^main$/, // THE entry point
|
||||
/^init_/,
|
||||
/_init$/,
|
||||
/^Create[A-Z]/, // Factory patterns
|
||||
/^create_/,
|
||||
/^Run$/, // Run methods
|
||||
/^run$/,
|
||||
/^Start$/, // Start methods
|
||||
/^start$/,
|
||||
/^handle_/,
|
||||
/_handler$/,
|
||||
/_callback$/,
|
||||
/^OnEvent/, // Event callbacks
|
||||
/^on_/,
|
||||
/::Run$/, // Class::Run
|
||||
/::Start$/, // Class::Start
|
||||
/::Init$/, // Class::Init
|
||||
/::Execute$/, // Class::Execute
|
||||
],
|
||||
|
||||
// Swift / iOS
|
||||
[SupportedLanguages.Swift]: [
|
||||
/^viewDidLoad$/, // UIKit lifecycle
|
||||
/^viewWillAppear$/, // UIKit lifecycle
|
||||
/^viewDidAppear$/, // UIKit lifecycle
|
||||
/^viewWillDisappear$/, // UIKit lifecycle
|
||||
/^viewDidDisappear$/, // UIKit lifecycle
|
||||
/^application\(/, // AppDelegate methods
|
||||
/^scene\(/, // SceneDelegate methods
|
||||
/^body$/, // SwiftUI View.body
|
||||
/Coordinator$/, // Coordinator pattern
|
||||
/^sceneDidBecomeActive$/, // SceneDelegate lifecycle
|
||||
/^sceneWillResignActive$/, // SceneDelegate lifecycle
|
||||
/^didFinishLaunchingWithOptions$/, // AppDelegate
|
||||
/ViewController$/, // ViewController classes
|
||||
/^configure[A-Z]/, // Configuration methods
|
||||
/^setup[A-Z]/, // Setup methods
|
||||
/^makeBody$/, // SwiftUI ViewModifier
|
||||
],
|
||||
|
||||
// PHP / Laravel
|
||||
[SupportedLanguages.PHP]: [
|
||||
/Controller$/, // UserController (class name convention)
|
||||
/^handle$/, // Job::handle(), Listener::handle()
|
||||
/^execute$/, // Command::execute()
|
||||
/^boot$/, // ServiceProvider::boot()
|
||||
/^register$/, // ServiceProvider::register()
|
||||
/^__invoke$/, // Invokable controllers/actions
|
||||
/^(index|show|store|update|destroy|create|edit)$/, // RESTful resource methods
|
||||
/^(get|post|put|delete|patch)[A-Z]/, // Explicit HTTP method actions
|
||||
/^run$/, // Command/Job run()
|
||||
/^fire$/, // Event fire()
|
||||
/^dispatch$/, // Dispatchable jobs
|
||||
/Service$/, // UserService (Service layer)
|
||||
/Repository$/, // UserRepository (Repository pattern)
|
||||
/^find$/, // Repository::find()
|
||||
/^findAll$/, // Repository::findAll()
|
||||
/^save$/, // Repository::save()
|
||||
/^delete$/, // Repository::delete()
|
||||
],
|
||||
|
||||
// Ruby
|
||||
[SupportedLanguages.Ruby]: [
|
||||
/^call$/, // Service objects (MyService.call)
|
||||
/^perform$/, // Background jobs (Sidekiq, ActiveJob)
|
||||
/^execute$/, // Command pattern
|
||||
],
|
||||
|
||||
// Dart / Flutter
|
||||
[SupportedLanguages.Dart]: [
|
||||
/^main$/, // App entry
|
||||
/^build$/, // Widget.build — fundamental Flutter render entry point
|
||||
/^createState$/, // StatefulWidget.createState
|
||||
/^initState$/, // State lifecycle initialization
|
||||
/^dispose$/, // State lifecycle teardown
|
||||
/^didChangeDependencies$/, // State lifecycle — InheritedWidget changes
|
||||
/^didUpdateWidget$/, // State lifecycle — widget rebuild with new config
|
||||
/^runApp$/, // App entry point
|
||||
/^onEvent$/, // BLoC event handler
|
||||
/^mapEventToState$/, // Legacy BLoC pattern
|
||||
],
|
||||
[SupportedLanguages.Vue]: [], // Vue uses TypeScript queries — entry points handled via TS patterns
|
||||
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no tree-sitter entry points
|
||||
} satisfies Record<SupportedLanguages, RegExp[]>;
|
||||
|
||||
/** Pre-computed merged patterns (universal + language-specific) to avoid per-call array allocation. */
|
||||
/** Pre-computed merged patterns (universal + language-specific) from providers. */
|
||||
const MERGED_ENTRY_POINT_PATTERNS = Object.fromEntries(
|
||||
Object.values(SupportedLanguages).map((lang) => [
|
||||
Object.entries(providers).map(([lang, provider]) => [
|
||||
lang,
|
||||
[...UNIVERSAL_ENTRY_POINT_PATTERNS, ...(ENTRY_POINT_PATTERNS[lang] ?? [])],
|
||||
[...UNIVERSAL_ENTRY_POINT_PATTERNS, ...(provider.entryPointPatterns ?? [])],
|
||||
]),
|
||||
) as Record<SupportedLanguages, RegExp[]>;
|
||||
|
||||
|
||||
@@ -106,15 +106,13 @@ export function finalizeScopeModel(
|
||||
const allScopes: Scope[] = [];
|
||||
const allDefs: SymbolDefinition[] = [];
|
||||
const moduleEntries: { filePath: string; moduleScopeId: ScopeId }[] = [];
|
||||
const allReferenceSites = [] as ReturnType<typeof collectReferenceSites>;
|
||||
const allReferenceSites = collectReferenceSites(parsedFiles);
|
||||
|
||||
for (const file of parsedFiles) {
|
||||
for (const s of file.scopes) allScopes.push(s);
|
||||
for (const d of file.localDefs) allDefs.push(d);
|
||||
moduleEntries.push({ filePath: file.filePath, moduleScopeId: file.moduleScope });
|
||||
}
|
||||
// References kept out of the loop above to centralize list-init.
|
||||
allReferenceSites.push(...collectReferenceSites(parsedFiles));
|
||||
|
||||
const scopeTree = buildScopeTree(allScopes);
|
||||
const defs = buildDefIndex(allDefs);
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { AstFrameworkPatternConfig } from './language-provider.js';
|
||||
import { providers } from './languages/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
@@ -518,395 +520,14 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
|
||||
// AST-BASED FRAMEWORK DETECTION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Patterns that indicate framework entry points within code definitions.
|
||||
* These are matched against AST node text (class/method/function declaration text).
|
||||
*/
|
||||
export const FRAMEWORK_AST_PATTERNS = {
|
||||
// JavaScript/TypeScript decorators
|
||||
nestjs: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'],
|
||||
'expo-router': [
|
||||
'router.push',
|
||||
'router.replace',
|
||||
'router.navigate',
|
||||
'useRouter',
|
||||
'useLocalSearchParams',
|
||||
'useSegments',
|
||||
'expo-router',
|
||||
],
|
||||
express: ['app.get', 'app.post', 'app.put', 'app.delete', 'router.get', 'router.post'],
|
||||
|
||||
// Python decorators
|
||||
fastapi: ['@app.get', '@app.post', '@app.put', '@app.delete', '@router.get'],
|
||||
flask: ['@app.route', '@blueprint.route'],
|
||||
|
||||
// Java annotations
|
||||
spring: ['@RestController', '@Controller', '@GetMapping', '@PostMapping', '@RequestMapping'],
|
||||
jaxrs: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'],
|
||||
|
||||
// C# attributes
|
||||
aspnet: [
|
||||
'[ApiController]',
|
||||
'[HttpGet]',
|
||||
'[HttpPost]',
|
||||
'[HttpPut]',
|
||||
'[HttpDelete]',
|
||||
'[Route]',
|
||||
'[Authorize]',
|
||||
'[AllowAnonymous]',
|
||||
],
|
||||
signalr: ['[HubMethodName]', ': Hub', ': Hub<'],
|
||||
blazor: ['@page', '[Parameter]', '@inject'],
|
||||
efcore: ['DbContext', 'DbSet<', 'OnModelCreating'],
|
||||
|
||||
// Go patterns (function signatures include framework types)
|
||||
'go-http': [
|
||||
'http.Handler',
|
||||
'http.HandlerFunc',
|
||||
'ServeHTTP',
|
||||
'http.ResponseWriter',
|
||||
'http.Request',
|
||||
],
|
||||
gin: ['gin.Context', 'gin.Default', 'gin.New'],
|
||||
echo: ['echo.Context', 'echo.New'],
|
||||
fiber: ['fiber.Ctx', 'fiber.New', 'fiber.App'],
|
||||
'go-grpc': ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'],
|
||||
|
||||
// ORM patterns
|
||||
prisma: ['prisma.', 'PrismaClient', '@prisma/client'],
|
||||
supabase: ['supabase.from', 'createClient', '@supabase/supabase-js'],
|
||||
|
||||
// PHP/Laravel
|
||||
laravel: [
|
||||
'Route::get',
|
||||
'Route::post',
|
||||
'Route::put',
|
||||
'Route::delete',
|
||||
'Route::resource',
|
||||
'Route::apiResource',
|
||||
'#[Route(',
|
||||
],
|
||||
|
||||
// Rust macros (proc-macro attributes in definition text)
|
||||
actix: ['#[get', '#[post', '#[put', '#[delete', '#[actix_web', 'HttpRequest', 'HttpResponse'],
|
||||
axum: ['Router::new', 'axum::extract', 'axum::routing'],
|
||||
rocket: ['#[get', '#[post', '#[launch', 'rocket::'],
|
||||
tokio: ['#[tokio::main]', '#[tokio::test]'],
|
||||
|
||||
// C++ patterns (Qt, Boost)
|
||||
qt: [
|
||||
'Q_OBJECT',
|
||||
'Q_INVOKABLE',
|
||||
'Q_PROPERTY',
|
||||
'Q_SIGNALS',
|
||||
'Q_SLOTS',
|
||||
'Q_SIGNAL',
|
||||
'Q_SLOT',
|
||||
'QWidget',
|
||||
'QApplication',
|
||||
],
|
||||
|
||||
// Swift/iOS
|
||||
uikit: [
|
||||
'viewDidLoad',
|
||||
'viewWillAppear',
|
||||
'viewDidAppear',
|
||||
'UIViewController',
|
||||
'@IBOutlet',
|
||||
'@IBAction',
|
||||
'@objc',
|
||||
],
|
||||
swiftui: [
|
||||
'@main',
|
||||
'WindowGroup',
|
||||
'ContentView',
|
||||
'@StateObject',
|
||||
'@ObservedObject',
|
||||
'@EnvironmentObject',
|
||||
'@Published',
|
||||
],
|
||||
vapor: ['app.get', 'app.post', 'req.content.decode', 'Vapor'],
|
||||
|
||||
// Ruby patterns (class-level macros in definition text)
|
||||
rails: [
|
||||
'ApplicationController',
|
||||
'ApplicationRecord',
|
||||
'ActiveRecord::Base',
|
||||
'before_action',
|
||||
'after_action',
|
||||
'has_many',
|
||||
'belongs_to',
|
||||
'has_one',
|
||||
'validates',
|
||||
],
|
||||
sinatra: ['Sinatra::Base', 'Sinatra::Application'],
|
||||
|
||||
// Dart/Flutter
|
||||
flutter: [
|
||||
'StatelessWidget',
|
||||
'StatefulWidget',
|
||||
'BuildContext',
|
||||
'Widget build',
|
||||
'ChangeNotifier',
|
||||
'GetxController',
|
||||
'Cubit<',
|
||||
'Bloc<',
|
||||
'ConsumerWidget',
|
||||
],
|
||||
riverpod: ['@riverpod', 'ref.watch', 'ref.read', 'AsyncNotifier', 'Notifier'],
|
||||
};
|
||||
|
||||
interface AstFrameworkPatternConfig {
|
||||
framework: string;
|
||||
entryPointMultiplier: number;
|
||||
reason: string;
|
||||
patterns: string[];
|
||||
}
|
||||
|
||||
export const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = {
|
||||
[SupportedLanguages.JavaScript]: [
|
||||
{
|
||||
framework: 'nestjs',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'nestjs-decorator',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.nestjs,
|
||||
},
|
||||
{
|
||||
framework: 'expo-router',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'expo-router-navigation',
|
||||
patterns: FRAMEWORK_AST_PATTERNS['expo-router'],
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.TypeScript]: [
|
||||
{
|
||||
framework: 'nestjs',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'nestjs-decorator',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.nestjs,
|
||||
},
|
||||
{
|
||||
framework: 'expo-router',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'expo-router-navigation',
|
||||
patterns: FRAMEWORK_AST_PATTERNS['expo-router'],
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Python]: [
|
||||
{
|
||||
framework: 'fastapi',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'fastapi-decorator',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.fastapi,
|
||||
},
|
||||
{
|
||||
framework: 'flask',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'flask-decorator',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.flask,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Java]: [
|
||||
{
|
||||
framework: 'spring',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'spring-annotation',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.spring,
|
||||
},
|
||||
{
|
||||
framework: 'jaxrs',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'jaxrs-annotation',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.jaxrs,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Kotlin]: [
|
||||
{
|
||||
framework: 'spring-kotlin',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'spring-kotlin-annotation',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.spring,
|
||||
},
|
||||
{
|
||||
framework: 'jaxrs',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'jaxrs-annotation',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.jaxrs,
|
||||
},
|
||||
{
|
||||
framework: 'ktor',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'ktor-routing',
|
||||
patterns: ['routing', 'embeddedServer', 'Application.module'],
|
||||
},
|
||||
{
|
||||
framework: 'android-kotlin',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'android-annotation',
|
||||
patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('],
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.CSharp]: [
|
||||
{
|
||||
framework: 'aspnet',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'aspnet-attribute',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.aspnet,
|
||||
},
|
||||
{
|
||||
framework: 'signalr',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'signalr-attribute',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.signalr,
|
||||
},
|
||||
{
|
||||
framework: 'blazor',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'blazor-attribute',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.blazor,
|
||||
},
|
||||
{
|
||||
framework: 'efcore',
|
||||
entryPointMultiplier: 2.0,
|
||||
reason: 'efcore-pattern',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.efcore,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.PHP]: [
|
||||
{
|
||||
framework: 'laravel',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'php-route-attribute',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.laravel,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Go]: [
|
||||
{
|
||||
framework: 'go-http',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'go-http-handler',
|
||||
patterns: FRAMEWORK_AST_PATTERNS['go-http'],
|
||||
},
|
||||
{
|
||||
framework: 'gin',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'gin-handler',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.gin,
|
||||
},
|
||||
{
|
||||
framework: 'echo',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'echo-handler',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.echo,
|
||||
},
|
||||
{
|
||||
framework: 'fiber',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'fiber-handler',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.fiber,
|
||||
},
|
||||
{
|
||||
framework: 'go-grpc',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'grpc-service',
|
||||
patterns: FRAMEWORK_AST_PATTERNS['go-grpc'],
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Rust]: [
|
||||
{
|
||||
framework: 'actix-web',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'actix-attribute',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.actix,
|
||||
},
|
||||
{
|
||||
framework: 'axum',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'axum-routing',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.axum,
|
||||
},
|
||||
{
|
||||
framework: 'rocket',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'rocket-attribute',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.rocket,
|
||||
},
|
||||
{
|
||||
framework: 'tokio',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'tokio-runtime',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.tokio,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.C]: [], // C has no framework-specific AST patterns (POSIX/socket patterns are in entry-point-scoring)
|
||||
[SupportedLanguages.CPlusPlus]: [
|
||||
{
|
||||
framework: 'qt',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'qt-macro',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.qt,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Swift]: [
|
||||
{
|
||||
framework: 'uikit',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'uikit-lifecycle',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.uikit,
|
||||
},
|
||||
{
|
||||
framework: 'swiftui',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'swiftui-pattern',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.swiftui,
|
||||
},
|
||||
{
|
||||
framework: 'vapor',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'vapor-routing',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.vapor,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Ruby]: [
|
||||
{
|
||||
framework: 'rails',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'rails-pattern',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.rails,
|
||||
},
|
||||
{
|
||||
framework: 'sinatra',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'sinatra-pattern',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.sinatra,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Dart]: [
|
||||
{
|
||||
framework: 'flutter',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'flutter-widget',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.flutter,
|
||||
},
|
||||
{
|
||||
framework: 'riverpod',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'riverpod-pattern',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.riverpod,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Vue]: [], // Vue uses TypeScript AST framework detection
|
||||
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no AST framework patterns
|
||||
} satisfies Record<SupportedLanguages, AstFrameworkPatternConfig[]>;
|
||||
|
||||
/** Pre-lowercased patterns for O(1) pattern matching at runtime */
|
||||
const AST_PATTERNS_LOWERED: Record<
|
||||
string,
|
||||
Array<{ framework: string; entryPointMultiplier: number; reason: string; patterns: string[] }>
|
||||
> = Object.fromEntries(
|
||||
Object.entries(AST_FRAMEWORK_PATTERNS_BY_LANGUAGE).map(([lang, cfgs]) => [
|
||||
/** Pre-lowercased patterns for O(1) pattern matching at runtime — built from providers. */
|
||||
const AST_PATTERNS_LOWERED: Record<string, AstFrameworkPatternConfig[]> = Object.fromEntries(
|
||||
Object.entries(providers).map(([lang, provider]) => [
|
||||
lang,
|
||||
cfgs.map((cfg) => ({ ...cfg, patterns: cfg.patterns.map((p) => p.toLowerCase()) })),
|
||||
(provider.astFrameworkPatterns ?? []).map((cfg) => ({
|
||||
...cfg,
|
||||
patterns: cfg.patterns.map((p) => p.toLowerCase()),
|
||||
})),
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -78,6 +78,14 @@ export type ImportSemantics =
|
||||
| 'namespace'
|
||||
| 'explicit-reexport';
|
||||
|
||||
/** Configuration for AST-based framework detection patterns. */
|
||||
export interface AstFrameworkPatternConfig {
|
||||
framework: string;
|
||||
entryPointMultiplier: number;
|
||||
reason: string;
|
||||
patterns: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a language needs to provide.
|
||||
* Required fields must be explicitly set; optional fields have defaults
|
||||
@@ -89,6 +97,16 @@ interface LanguageProviderConfig {
|
||||
/** File extensions that map to this language (e.g., ['.ts', '.tsx']) */
|
||||
readonly extensions: readonly string[];
|
||||
|
||||
/** Entry-point function name patterns specific to this language.
|
||||
* Merged with universal patterns at runtime for process detection scoring.
|
||||
* Default: [] (only universal patterns apply). */
|
||||
readonly entryPointPatterns?: readonly RegExp[];
|
||||
|
||||
/** AST-based framework detection patterns for this language.
|
||||
* Used by detectFrameworkFromAST to identify framework entry points.
|
||||
* Default: [] (no AST framework detection for this language). */
|
||||
readonly astFrameworkPatterns?: readonly AstFrameworkPatternConfig[];
|
||||
|
||||
// ── Parser ────────────────────────────────────────────────────────
|
||||
/** Parse strategy: 'tree-sitter' (default) uses AST parsing via tree-sitter.
|
||||
* 'standalone' means the language has its own regex-based processor and
|
||||
@@ -498,6 +516,15 @@ interface LanguageProviderConfig {
|
||||
|
||||
// ── Resolution phase (RFC §4v2) ────────────────────────────────────
|
||||
|
||||
/** Order same-name type candidates when a language can index multiple
|
||||
* definitions for one logical type. Return null to keep shared ambiguity
|
||||
* handling. */
|
||||
readonly orderSameNameTypeCandidates?: (params: {
|
||||
readonly typeName: string;
|
||||
readonly callSiteFilePath: string;
|
||||
readonly candidates: readonly SymbolDefinition[];
|
||||
}) => readonly SymbolDefinition[] | null;
|
||||
|
||||
/**
|
||||
* Is this callable definition compatible with the given call-site arity?
|
||||
* Language-specific rules: Python `*args`/`**kwargs`/defaults, JS default
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { cClassConfig, cppClassConfig } from '../class-extractors/configs/c-cpp.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { typeConfig as cCppConfig } from '../type-extractors/c-cpp.js';
|
||||
import { cCppExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
@@ -317,6 +318,38 @@ const cppLabelOverride: NonNullable<LanguageProvider['labelOverride']> = (
|
||||
export const cProvider = defineLanguage({
|
||||
id: SupportedLanguages.C,
|
||||
extensions: ['.c'],
|
||||
entryPointPatterns: [
|
||||
/^main$/,
|
||||
/^init_/,
|
||||
/_init$/,
|
||||
/^start_/,
|
||||
/_start$/,
|
||||
/^run_/,
|
||||
/_run$/,
|
||||
/^stop_/,
|
||||
/_stop$/,
|
||||
/^open_/,
|
||||
/_open$/,
|
||||
/^close_/,
|
||||
/_close$/,
|
||||
/^create_/,
|
||||
/_create$/,
|
||||
/^destroy_/,
|
||||
/_destroy$/,
|
||||
/^handle_/,
|
||||
/_handler$/,
|
||||
/_callback$/,
|
||||
/^cmd_/,
|
||||
/^server_/,
|
||||
/^client_/,
|
||||
/^session_/,
|
||||
/^window_/,
|
||||
/^key_/,
|
||||
/^input_/,
|
||||
/^output_/,
|
||||
/^notify_/,
|
||||
/^control_/,
|
||||
],
|
||||
treeSitterQueries: C_QUERIES,
|
||||
typeConfig: cCppConfig,
|
||||
exportChecker: cCppExportChecker,
|
||||
@@ -338,6 +371,44 @@ export const cProvider = defineLanguage({
|
||||
export const cppProvider = defineLanguage({
|
||||
id: SupportedLanguages.CPlusPlus,
|
||||
extensions: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'],
|
||||
entryPointPatterns: [
|
||||
/^main$/,
|
||||
/^init_/,
|
||||
/_init$/,
|
||||
/^Create[A-Z]/,
|
||||
/^create_/,
|
||||
/^Run$/,
|
||||
/^run$/,
|
||||
/^Start$/,
|
||||
/^start$/,
|
||||
/^handle_/,
|
||||
/_handler$/,
|
||||
/_callback$/,
|
||||
/^OnEvent/,
|
||||
/^on_/,
|
||||
/::Run$/,
|
||||
/::Start$/,
|
||||
/::Init$/,
|
||||
/::Execute$/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'qt',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'qt-macro',
|
||||
patterns: [
|
||||
'Q_OBJECT',
|
||||
'Q_INVOKABLE',
|
||||
'Q_PROPERTY',
|
||||
'Q_SIGNALS',
|
||||
'Q_SLOTS',
|
||||
'Q_SIGNAL',
|
||||
'Q_SLOT',
|
||||
'QWidget',
|
||||
'QApplication',
|
||||
],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: CPP_QUERIES,
|
||||
typeConfig: cCppConfig,
|
||||
exportChecker: cCppExportChecker,
|
||||
|
||||
@@ -16,6 +16,8 @@ export const cobolProvider = defineLanguage({
|
||||
id: SupportedLanguages.Cobol,
|
||||
parseStrategy: 'standalone',
|
||||
extensions: [], // COBOL files detected by cobol-processor's isCobolFile/isJclFile
|
||||
entryPointPatterns: [],
|
||||
astFrameworkPatterns: [],
|
||||
treeSitterQueries: '',
|
||||
typeConfig: {
|
||||
declarationNodeTypes: new Set(),
|
||||
|
||||
@@ -16,6 +16,7 @@ import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
import { csharpImportConfig } from '../import-resolvers/configs/csharp.js';
|
||||
import { extractCSharpNamedBindings } from '../named-bindings/csharp.js';
|
||||
import { CSHARP_QUERIES } from '../tree-sitter-queries.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { csharpCallConfig } from '../call-extractors/configs/csharp.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
@@ -135,6 +136,55 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
export const csharpProvider = defineLanguage({
|
||||
id: SupportedLanguages.CSharp,
|
||||
extensions: ['.cs'],
|
||||
entryPointPatterns: [
|
||||
/^(Get|Post|Put|Delete|Patch)/,
|
||||
/Action$/,
|
||||
/^On[A-Z]/,
|
||||
/Async$/,
|
||||
/^Configure$/,
|
||||
/^ConfigureServices$/,
|
||||
/^Handle$/,
|
||||
/^Execute$/,
|
||||
/^Invoke$/,
|
||||
/^Map[A-Z]/,
|
||||
/Service$/,
|
||||
/^Seed/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'aspnet',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'aspnet-attribute',
|
||||
patterns: [
|
||||
'[ApiController]',
|
||||
'[HttpGet]',
|
||||
'[HttpPost]',
|
||||
'[HttpPut]',
|
||||
'[HttpDelete]',
|
||||
'[Route]',
|
||||
'[Authorize]',
|
||||
'[AllowAnonymous]',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'signalr',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'signalr-attribute',
|
||||
patterns: ['[HubMethodName]', ': Hub', ': Hub<'],
|
||||
},
|
||||
{
|
||||
framework: 'blazor',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'blazor-attribute',
|
||||
patterns: ['@page', '[Parameter]', '@inject'],
|
||||
},
|
||||
{
|
||||
framework: 'efcore',
|
||||
entryPointMultiplier: 2.0,
|
||||
reason: 'efcore-pattern',
|
||||
patterns: ['DbContext', 'DbSet<', 'OnModelCreating'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: CSHARP_QUERIES,
|
||||
typeConfig: csharpConfig,
|
||||
exportChecker: csharpExportChecker,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { dartClassConfig } from '../class-extractors/configs/dart.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { typeConfig as dartConfig } from '../type-extractors/dart.js';
|
||||
import { dartExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
@@ -93,6 +94,42 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
export const dartProvider = defineLanguage({
|
||||
id: SupportedLanguages.Dart,
|
||||
extensions: ['.dart'],
|
||||
entryPointPatterns: [
|
||||
/^main$/,
|
||||
/^build$/,
|
||||
/^createState$/,
|
||||
/^initState$/,
|
||||
/^dispose$/,
|
||||
/^didChangeDependencies$/,
|
||||
/^didUpdateWidget$/,
|
||||
/^runApp$/,
|
||||
/^onEvent$/,
|
||||
/^mapEventToState$/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'flutter',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'flutter-widget',
|
||||
patterns: [
|
||||
'StatelessWidget',
|
||||
'StatefulWidget',
|
||||
'BuildContext',
|
||||
'Widget build',
|
||||
'ChangeNotifier',
|
||||
'GetxController',
|
||||
'Cubit<',
|
||||
'Bloc<',
|
||||
'ConsumerWidget',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'riverpod',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'riverpod-pattern',
|
||||
patterns: ['@riverpod', 'ref.watch', 'ref.read', 'AsyncNotifier', 'Notifier'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: DART_QUERIES,
|
||||
typeConfig: dartConfig,
|
||||
exportChecker: dartExportChecker,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { goExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
import { goImportConfig } from '../import-resolvers/configs/go.js';
|
||||
import { GO_QUERIES } from '../tree-sitter-queries.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { goConfig as goFieldConfig } from '../field-extractors/configs/go.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
@@ -32,6 +33,45 @@ import { goHeritageConfig } from '../heritage-extractors/configs/go.js';
|
||||
export const goProvider = defineLanguage({
|
||||
id: SupportedLanguages.Go,
|
||||
extensions: ['.go'],
|
||||
entryPointPatterns: [/Handler$/, /^Serve/, /^New[A-Z]/, /^Make[A-Z]/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'go-http',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'go-http-handler',
|
||||
patterns: [
|
||||
'http.Handler',
|
||||
'http.HandlerFunc',
|
||||
'ServeHTTP',
|
||||
'http.ResponseWriter',
|
||||
'http.Request',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'gin',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'gin-handler',
|
||||
patterns: ['gin.Context', 'gin.Default', 'gin.New'],
|
||||
},
|
||||
{
|
||||
framework: 'echo',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'echo-handler',
|
||||
patterns: ['echo.Context', 'echo.New'],
|
||||
},
|
||||
{
|
||||
framework: 'fiber',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'fiber-handler',
|
||||
patterns: ['fiber.Ctx', 'fiber.New', 'fiber.App'],
|
||||
},
|
||||
{
|
||||
framework: 'go-grpc',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'grpc-service',
|
||||
patterns: ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: GO_QUERIES,
|
||||
typeConfig: goConfig,
|
||||
exportChecker: goExportChecker,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { javaClassConfig } from '../class-extractors/configs/jvm.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { javaTypeConfig } from '../type-extractors/jvm.js';
|
||||
import { javaExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
@@ -30,6 +31,27 @@ import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
export const javaProvider = defineLanguage({
|
||||
id: SupportedLanguages.Java,
|
||||
extensions: ['.java'],
|
||||
entryPointPatterns: [/^do[A-Z]/, /^create[A-Z]/, /^build[A-Z]/, /Service$/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'spring',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'spring-annotation',
|
||||
patterns: [
|
||||
'@RestController',
|
||||
'@Controller',
|
||||
'@GetMapping',
|
||||
'@PostMapping',
|
||||
'@RequestMapping',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'jaxrs',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'jaxrs-annotation',
|
||||
patterns: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: JAVA_QUERIES,
|
||||
typeConfig: javaTypeConfig,
|
||||
exportChecker: javaExportChecker,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { kotlinImportConfig } from '../import-resolvers/configs/jvm.js';
|
||||
import { extractKotlinNamedBindings } from '../named-bindings/kotlin.js';
|
||||
import { appendKotlinWildcard } from '../import-resolvers/jvm.js';
|
||||
import { KOTLIN_QUERIES } from '../tree-sitter-queries.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { kotlinCallConfig } from '../call-extractors/configs/jvm.js';
|
||||
@@ -105,6 +106,47 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
export const kotlinProvider = defineLanguage({
|
||||
id: SupportedLanguages.Kotlin,
|
||||
extensions: ['.kt', '.kts'],
|
||||
entryPointPatterns: [
|
||||
/^on(Create|Start|Resume|Pause|Stop|Destroy)$/,
|
||||
/^do[A-Z]/,
|
||||
/^create[A-Z]/,
|
||||
/^build[A-Z]/,
|
||||
/ViewModel$/,
|
||||
/^module$/,
|
||||
/Service$/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'spring-kotlin',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'spring-kotlin-annotation',
|
||||
patterns: [
|
||||
'@RestController',
|
||||
'@Controller',
|
||||
'@GetMapping',
|
||||
'@PostMapping',
|
||||
'@RequestMapping',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'jaxrs',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'jaxrs-annotation',
|
||||
patterns: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'],
|
||||
},
|
||||
{
|
||||
framework: 'ktor',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'ktor-routing',
|
||||
patterns: ['routing', 'embeddedServer', 'Application.module'],
|
||||
},
|
||||
{
|
||||
framework: 'android-kotlin',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'android-annotation',
|
||||
patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: KOTLIN_QUERIES,
|
||||
typeConfig: kotlinTypeConfig,
|
||||
exportChecker: kotlinExportChecker,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { phpClassConfig } from '../class-extractors/configs/php.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { typeConfig as phpConfig } from '../type-extractors/php.js';
|
||||
import { phpExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
@@ -239,6 +240,41 @@ function isPhpRouteFile(filePath: string): boolean {
|
||||
export const phpProvider = defineLanguage({
|
||||
id: SupportedLanguages.PHP,
|
||||
extensions: ['.php', '.phtml', '.php3', '.php4', '.php5', '.php8'],
|
||||
entryPointPatterns: [
|
||||
/Controller$/,
|
||||
/^handle$/,
|
||||
/^execute$/,
|
||||
/^boot$/,
|
||||
/^register$/,
|
||||
/^__invoke$/,
|
||||
/^(index|show|store|update|destroy|create|edit)$/,
|
||||
/^(get|post|put|delete|patch)[A-Z]/,
|
||||
/^run$/,
|
||||
/^fire$/,
|
||||
/^dispatch$/,
|
||||
/Service$/,
|
||||
/Repository$/,
|
||||
/^find$/,
|
||||
/^findAll$/,
|
||||
/^save$/,
|
||||
/^delete$/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'laravel',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'php-route-attribute',
|
||||
patterns: [
|
||||
'Route::get',
|
||||
'Route::post',
|
||||
'Route::put',
|
||||
'Route::delete',
|
||||
'Route::resource',
|
||||
'Route::apiResource',
|
||||
'#[Route(',
|
||||
],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: PHP_QUERIES,
|
||||
typeConfig: phpConfig,
|
||||
exportChecker: phpExportChecker,
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
* - namedBindingExtractor: present (from X import Y)
|
||||
*/
|
||||
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { pythonClassConfig } from '../class-extractors/configs/python.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { typeConfig as pythonConfig } from '../type-extractors/python.js';
|
||||
import { pythonExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
@@ -29,8 +31,11 @@ import { pythonVariableConfig } from '../variable-extractors/configs/python.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { pythonCallConfig } from '../call-extractors/configs/python.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import type { CaptureMap } from '../language-provider.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import {
|
||||
emitPythonScopeCaptures,
|
||||
pythonFunctionDefinitionLabel,
|
||||
interpretPythonImport,
|
||||
interpretPythonTypeBinding,
|
||||
pythonArityCompatibility,
|
||||
@@ -71,9 +76,52 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'abs',
|
||||
]);
|
||||
|
||||
function pythonDescriptionExtractor(
|
||||
nodeLabel: NodeLabel,
|
||||
_nodeName: string,
|
||||
captureMap: CaptureMap,
|
||||
): string | undefined {
|
||||
if (nodeLabel !== 'Function' && nodeLabel !== 'Method') return undefined;
|
||||
const functionNode = captureMap['definition.function'] ?? captureMap['definition.method'];
|
||||
if (functionNode === undefined) return undefined;
|
||||
return extractPythonDocstring(functionNode);
|
||||
}
|
||||
|
||||
function extractPythonDocstring(functionNode: SyntaxNode): string | undefined {
|
||||
const body = functionNode.childForFieldName('body');
|
||||
const firstStatement = body?.namedChild(0);
|
||||
if (firstStatement?.type !== 'expression_statement') return undefined;
|
||||
|
||||
const literal = firstStatement.namedChild(0);
|
||||
if (literal?.type !== 'string') return undefined;
|
||||
return normalizePythonStringLiteral(literal.text);
|
||||
}
|
||||
|
||||
function normalizePythonStringLiteral(text: string): string | undefined {
|
||||
const match = text.match(/^[rRuUbBfF]*("""|'''|"|')([\s\S]*)\1$/);
|
||||
const raw = match?.[2]?.trim();
|
||||
if (!raw) return undefined;
|
||||
return raw.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export const pythonProvider = defineLanguage({
|
||||
id: SupportedLanguages.Python,
|
||||
extensions: ['.py'],
|
||||
entryPointPatterns: [/^app$/, /^(get|post|put|delete|patch)_/i, /^api_/, /^view_/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'fastapi',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'fastapi-decorator',
|
||||
patterns: ['@app.get', '@app.post', '@app.put', '@app.delete', '@router.get'],
|
||||
},
|
||||
{
|
||||
framework: 'flask',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'flask-decorator',
|
||||
patterns: ['@app.route', '@blueprint.route'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: PYTHON_QUERIES,
|
||||
typeConfig: pythonConfig,
|
||||
exportChecker: pythonExportChecker,
|
||||
@@ -87,7 +135,9 @@ export const pythonProvider = defineLanguage({
|
||||
variableExtractor: createVariableExtractor(pythonVariableConfig),
|
||||
classExtractor: createClassExtractor(pythonClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.Python),
|
||||
descriptionExtractor: pythonDescriptionExtractor,
|
||||
builtInNames: BUILT_INS,
|
||||
labelOverride: pythonFunctionDefinitionLabel,
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
|
||||
// Python is the first migration. See ./python/index.ts for the
|
||||
|
||||
@@ -24,6 +24,7 @@ import { synthesizeReceiverTypeBinding } from './receiver-binding.js';
|
||||
import { computePythonArityMetadata } from './arity-metadata.js';
|
||||
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { pythonFunctionDefinitionLabel } from './simple-hooks.js';
|
||||
|
||||
export function emitPythonScopeCaptures(
|
||||
sourceText: string,
|
||||
@@ -37,14 +38,24 @@ export function emitPythonScopeCaptures(
|
||||
// here at the use site.
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getPythonParser>['parse']> | undefined;
|
||||
if (tree === undefined) {
|
||||
tree = getPythonParser().parse(sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
try {
|
||||
tree = getPythonParser().parse(sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
} catch (err) {
|
||||
throw scopeExtractionError('parse', _filePath, err);
|
||||
}
|
||||
recordCacheMiss();
|
||||
} else {
|
||||
recordCacheHit();
|
||||
}
|
||||
const rawMatches = getPythonScopeQuery().matches(tree.rootNode);
|
||||
|
||||
let rawMatches: ReturnType<ReturnType<typeof getPythonScopeQuery>['matches']>;
|
||||
try {
|
||||
rawMatches = getPythonScopeQuery().matches(tree.rootNode);
|
||||
} catch (err) {
|
||||
throw scopeExtractionError('scope query', _filePath, err);
|
||||
}
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
@@ -98,6 +109,10 @@ export function emitPythonScopeCaptures(
|
||||
const anchorCap = grouped['@declaration.function']!;
|
||||
const fnNode = findNodeAtRange(tree.rootNode, anchorCap.range, 'function_definition');
|
||||
if (fnNode !== null) {
|
||||
if (pythonFunctionDefinitionLabel(fnNode, 'Function') === 'Method') {
|
||||
delete grouped['@declaration.function'];
|
||||
grouped['@declaration.method'] = { ...anchorCap, name: '@declaration.method' };
|
||||
}
|
||||
const arity = computePythonArityMetadata(fnNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
grouped['@declaration.parameter-count'] = syntheticCapture(
|
||||
@@ -133,3 +148,10 @@ export function emitPythonScopeCaptures(
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function scopeExtractionError(stage: string, filePath: string, err: unknown): Error {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
return new Error(
|
||||
`[python] tree-sitter ${stage} failed for ${filePath}: ${reason}; skipping scope extraction for this file`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,60 +61,159 @@ export function resolvePythonImportTarget(
|
||||
const pathLike = parsedImport.targetRaw.replace(/\./g, '/');
|
||||
if (pathLike.includes('/')) {
|
||||
const [leadingSegment] = pathLike.split('/').filter(Boolean);
|
||||
if (!leadingSegment || !hasRepoCandidate(leadingSegment, ctx.allFilePaths)) {
|
||||
if (!leadingSegment || !hasRepoCandidate(leadingSegment, ctx.allFilePaths, ctx.fromFile)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-segment absolute resolve: try exact paths first, then suffix
|
||||
// match in nested repos. Using direct `Set.has` + `endsWith` instead of
|
||||
// `suffixResolve`'s shared helper because that helper requires a
|
||||
// pre-built `SuffixIndex` to disambiguate ties — without one it falls
|
||||
// back to an O(files) scan that silently picks the wrong file when
|
||||
// the last segment collides across directories (e.g. `accounts.models`
|
||||
// matching `billing/models.py` when both files exist).
|
||||
return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths);
|
||||
// Multi-segment absolute resolve: try exact paths first, then ancestor
|
||||
// walk (mirrors the single-segment ancestor walk in
|
||||
// `resolvePythonImportInternal`), then a suffix match in nested repos.
|
||||
// Using direct `Set.has` + `endsWith` instead of `suffixResolve`'s shared
|
||||
// helper because that helper requires a pre-built `SuffixIndex` to
|
||||
// disambiguate ties — without one it falls back to an O(files) scan that
|
||||
// silently picks the wrong file when the last segment collides across
|
||||
// directories (e.g. `accounts.models` matching `billing/models.py` when
|
||||
// both files exist).
|
||||
return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths, ctx.fromFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `package/sub/module` style paths (already dot-flattened) to a
|
||||
* concrete file in `allFilePaths`. Tries the exact path first, then the
|
||||
* `__init__.py` variant, then a suffix match for nested layouts.
|
||||
* concrete file in `allFilePaths`. Tries the exact path first, then walks
|
||||
* ancestors of `fromFile` looking for `<ancestor>/<pathLike>.py` (or
|
||||
* `__init__.py`), then falls back to a suffix match for nested layouts.
|
||||
* Returns the original (un-normalized) path from the set.
|
||||
*
|
||||
* Precedence order:
|
||||
* 1. Workspace-root direct hit (`<pathLike>.py`, `<pathLike>/__init__.py`).
|
||||
* 2. Closest-ancestor match walking up from the importer's directory.
|
||||
* 3. Suffix fallback (deterministic: fewest path segments, then
|
||||
* lexicographic on the normalized path).
|
||||
*
|
||||
* Root wins over ancestor by construction — if both `services/sync.py` and
|
||||
* `backend/services/sync.py` exist, `backend/routers/cron.py`'s
|
||||
* `from services.sync import X` resolves to the root file. This mirrors
|
||||
* Python's `sys.path` semantics where the project root is searched first.
|
||||
*
|
||||
* The ancestor walk mirrors the single-segment behavior in
|
||||
* `resolvePythonImportInternal`. For `from services.sync import X` in
|
||||
* `backend/routers/cron.py`, walk up: `backend/routers/services/sync.py` →
|
||||
* `backend/services/sync.py` ✓.
|
||||
*/
|
||||
function resolveAbsoluteFromFiles(pathLike: string, allFilePaths: Set<string>): string | null {
|
||||
function resolveAbsoluteFromFiles(
|
||||
pathLike: string,
|
||||
allFilePaths: Set<string>,
|
||||
fromFile: string,
|
||||
): string | null {
|
||||
const directFile = `${pathLike}.py`;
|
||||
const directPkg = `${pathLike}/__init__.py`;
|
||||
const suffixFile = `/${directFile}`;
|
||||
const suffixPkg = `/${directPkg}`;
|
||||
|
||||
let suffixMatch: string | null = null;
|
||||
for (const raw of allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (f === directFile || f === directPkg) return raw;
|
||||
if (suffixMatch === null && (f.endsWith(suffixFile) || f.endsWith(suffixPkg))) {
|
||||
suffixMatch = raw;
|
||||
// Direct hit at workspace root.
|
||||
if (allFilePaths.has(directFile)) return directFile;
|
||||
if (allFilePaths.has(directPkg)) return directPkg;
|
||||
|
||||
// Ancestor walk — match the single-segment resolver's behavior at
|
||||
// multi-segment granularity. Closest match wins. Stop at `i > 0` because
|
||||
// `i === 0` would re-check the workspace-root candidates already covered
|
||||
// by the direct check above.
|
||||
const importerDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
|
||||
if (importerDir) {
|
||||
const dirParts = importerDir.split('/').filter(Boolean);
|
||||
for (let i = dirParts.length; i > 0; i--) {
|
||||
const ancestor = dirParts.slice(0, i).join('/');
|
||||
const prefix = `${ancestor}/`;
|
||||
const candidateFile = `${prefix}${directFile}`;
|
||||
const candidatePkg = `${prefix}${directPkg}`;
|
||||
if (allFilePaths.has(candidateFile)) return candidateFile;
|
||||
if (allFilePaths.has(candidatePkg)) return candidatePkg;
|
||||
}
|
||||
}
|
||||
return suffixMatch;
|
||||
|
||||
// Suffix-match fallback (preserved for monorepo/nested-repo layouts
|
||||
// that don't share a directory ancestor with the importer).
|
||||
//
|
||||
// Tie-break order when multiple files match the same suffix:
|
||||
// 1. Fewest path segments (shorter, more canonical paths win — `lib/x.py`
|
||||
// beats `tooling/extras/x.py`).
|
||||
// 2. Lexicographic order over the normalized path (final stable
|
||||
// tiebreak independent of file-set insertion order).
|
||||
//
|
||||
// Without an explicit tie-break the previous implementation returned
|
||||
// the first match in `Set` iteration order, which depended on file
|
||||
// ingestion order and produced non-deterministic edges across runs in
|
||||
// multi-directory collision repos.
|
||||
const suffixFile = `/${directFile}`;
|
||||
const suffixPkg = `/${directPkg}`;
|
||||
const matches: { raw: string; norm: string }[] = [];
|
||||
for (const raw of allFilePaths) {
|
||||
const norm = raw.replace(/\\/g, '/');
|
||||
if (norm.endsWith(suffixFile) || norm.endsWith(suffixPkg)) {
|
||||
matches.push({ raw, norm });
|
||||
}
|
||||
}
|
||||
if (matches.length === 0) return null;
|
||||
if (matches.length === 1) return matches[0].raw;
|
||||
matches.sort((a, b) => {
|
||||
const aDepth = a.norm.split('/').length;
|
||||
const bDepth = b.norm.split('/').length;
|
||||
if (aDepth !== bDepth) return aDepth - bDepth;
|
||||
if (a.norm < b.norm) return -1;
|
||||
if (a.norm > b.norm) return 1;
|
||||
return 0;
|
||||
});
|
||||
return matches[0].raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the repo contain a module/package named `leadingSegment` at the top
|
||||
* level? Used to guard against false-positive suffix matches on external
|
||||
* dotted imports (e.g. `django.apps` matching a local `accounts/apps.py`).
|
||||
* Does the repo contain a module/package named `leadingSegment` somewhere
|
||||
* the importer can plausibly reach?
|
||||
*
|
||||
* Checks, in order: `<segment>.py` root file, `<segment>/__init__.py`
|
||||
* regular package, or any `<segment>/**.py` file (namespace package).
|
||||
* Used to guard against false-positive suffix matches on external dotted
|
||||
* imports (e.g. `django.apps` matching a local `accounts/apps.py`).
|
||||
*
|
||||
* Checks, in order:
|
||||
* 1. `SEGMENT.py` root file or `SEGMENT/__init__.py` regular package.
|
||||
* 2. Any `SEGMENT/...py` file at the workspace root (namespace package).
|
||||
* 3. Any `<importer-ancestor>/SEGMENT/...py` file (nested namespace
|
||||
* package the importer could reach via an ancestor walk, e.g.
|
||||
* `backend/services/sync.py` from `backend/routers/cron.py`).
|
||||
*
|
||||
* The nested case is bounded to the importer's own ancestors so a
|
||||
* vendored copy of an external package (e.g. `vendor/django/urls.py`)
|
||||
* does not gate-pass external imports like `from django.urls import path`
|
||||
* issued from `app/main.py`. Files inside the vendored tree itself
|
||||
* (importer under `vendor/django/...`) still resolve correctly because
|
||||
* the ancestor walk includes their own parents.
|
||||
*/
|
||||
function hasRepoCandidate(leadingSegment: string, allFilePaths: Set<string>): boolean {
|
||||
function hasRepoCandidate(
|
||||
leadingSegment: string,
|
||||
allFilePaths: Set<string>,
|
||||
fromFile: string,
|
||||
): boolean {
|
||||
const prefix = `${leadingSegment}/`;
|
||||
const rootFile = `${leadingSegment}.py`;
|
||||
const initFile = `${leadingSegment}/__init__.py`;
|
||||
|
||||
// Build importer-ancestor prefixes: for `backend/routers/cron.py`,
|
||||
// produces `["backend/routers/services/", "backend/services/"]` for
|
||||
// segment `services` (closest first, root excluded — covered above).
|
||||
const importerDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
|
||||
const dirParts = importerDir ? importerDir.split('/').filter(Boolean) : [];
|
||||
const ancestorPrefixes: string[] = [];
|
||||
for (let i = dirParts.length; i > 0; i--) {
|
||||
ancestorPrefixes.push(`${dirParts.slice(0, i).join('/')}/${leadingSegment}/`);
|
||||
}
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (f === rootFile || f === initFile) return true;
|
||||
if (f.startsWith(prefix) && f.endsWith('.py')) return true;
|
||||
if (f.endsWith('.py')) {
|
||||
for (const ap of ancestorPrefixes) {
|
||||
if (f.startsWith(ap)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ export { pythonArityCompatibility } from './arity.js';
|
||||
export { resolvePythonImportTarget, type PythonResolveContext } from './import-target.js';
|
||||
export {
|
||||
pythonBindingScopeFor,
|
||||
pythonFunctionDefinitionLabel,
|
||||
pythonImportOwningScope,
|
||||
pythonReceiverBinding,
|
||||
} from './simple-hooks.js';
|
||||
|
||||
@@ -8,12 +8,30 @@
|
||||
|
||||
import type {
|
||||
CaptureMatch,
|
||||
NodeLabel,
|
||||
ParsedImport,
|
||||
Scope,
|
||||
ScopeId,
|
||||
ScopeTree,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
import type { SyntaxNode } from 'tree-sitter';
|
||||
import { findAncestorBeforeBoundary, FUNCTION_NODE_TYPES } from '../../utils/ast-helpers.js';
|
||||
|
||||
const PYTHON_METHOD_CONTAINER_TYPES: ReadonlySet<string> = new Set(['class_definition']);
|
||||
|
||||
export function pythonFunctionDefinitionLabel(
|
||||
functionNode: SyntaxNode,
|
||||
defaultLabel: NodeLabel,
|
||||
): NodeLabel {
|
||||
if (defaultLabel !== 'Function') return defaultLabel;
|
||||
const ancestor = findAncestorBeforeBoundary(
|
||||
functionNode,
|
||||
PYTHON_METHOD_CONTAINER_TYPES,
|
||||
FUNCTION_NODE_TYPES,
|
||||
);
|
||||
return ancestor === null ? 'Function' : 'Method';
|
||||
}
|
||||
|
||||
// ─── bindingScopeFor ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { rubyClassConfig } from '../class-extractors/configs/ruby.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { typeConfig as rubyConfig } from '../type-extractors/ruby.js';
|
||||
import { routeRubyCall } from '../call-routing.js';
|
||||
@@ -151,6 +152,31 @@ const rubyResolveEnclosingOwner = (node: SyntaxNode): SyntaxNode | null => {
|
||||
export const rubyProvider = defineLanguage({
|
||||
id: SupportedLanguages.Ruby,
|
||||
extensions: ['.rb', '.rake', '.gemspec'],
|
||||
entryPointPatterns: [/^call$/, /^perform$/, /^execute$/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'rails',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'rails-pattern',
|
||||
patterns: [
|
||||
'ApplicationController',
|
||||
'ApplicationRecord',
|
||||
'ActiveRecord::Base',
|
||||
'before_action',
|
||||
'after_action',
|
||||
'has_many',
|
||||
'belongs_to',
|
||||
'has_one',
|
||||
'validates',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'sinatra',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'sinatra-pattern',
|
||||
patterns: ['Sinatra::Base', 'Sinatra::Application'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: RUBY_QUERIES,
|
||||
typeConfig: rubyConfig,
|
||||
exportChecker: rubyExportChecker,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
import { rustImportConfig } from '../import-resolvers/configs/rust.js';
|
||||
import { extractRustNamedBindings } from '../named-bindings/rust.js';
|
||||
import { RUST_QUERIES } from '../tree-sitter-queries.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { rustConfig as rustFieldConfig } from '../field-extractors/configs/rust.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
@@ -121,6 +122,41 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
export const rustProvider = defineLanguage({
|
||||
id: SupportedLanguages.Rust,
|
||||
extensions: ['.rs'],
|
||||
entryPointPatterns: [/^(get|post|put|delete)_handler$/i, /^handle_/, /^new$/, /^run$/, /^spawn/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'actix-web',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'actix-attribute',
|
||||
patterns: [
|
||||
'#[get',
|
||||
'#[post',
|
||||
'#[put',
|
||||
'#[delete',
|
||||
'#[actix_web',
|
||||
'HttpRequest',
|
||||
'HttpResponse',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'axum',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'axum-routing',
|
||||
patterns: ['Router::new', 'axum::extract', 'axum::routing'],
|
||||
},
|
||||
{
|
||||
framework: 'rocket',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'rocket-attribute',
|
||||
patterns: ['#[get', '#[post', '#[launch', 'rocket::'],
|
||||
},
|
||||
{
|
||||
framework: 'tokio',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'tokio-runtime',
|
||||
patterns: ['#[tokio::main]', '#[tokio::test]'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: RUST_QUERIES,
|
||||
typeConfig: rustConfig,
|
||||
exportChecker: rustExportChecker,
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { swiftClassConfig } from '../class-extractors/configs/swift.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { typeConfig as swiftConfig } from '../type-extractors/swift.js';
|
||||
import { swiftExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
@@ -128,6 +129,24 @@ const swiftExtractFunctionName = (
|
||||
return null; // fall through to generic
|
||||
};
|
||||
|
||||
const orderSwiftSameNameTypeCandidates = ({
|
||||
callSiteFilePath,
|
||||
candidates,
|
||||
}: {
|
||||
readonly typeName: string;
|
||||
readonly callSiteFilePath: string;
|
||||
readonly candidates: readonly SymbolDefinition[];
|
||||
}): readonly SymbolDefinition[] | null => {
|
||||
if (!callSiteFilePath.endsWith('.swift')) return null;
|
||||
if (candidates.length <= 1) return null;
|
||||
if (!candidates.every((c) => c.type === candidates[0].type)) return null;
|
||||
if (candidates[0].type !== 'Class' && candidates[0].type !== 'Struct') return null;
|
||||
if (!candidates.every((c) => c.filePath.endsWith('.swift'))) return null;
|
||||
return [...candidates].sort(
|
||||
(a, b) => a.filePath.length - b.filePath.length || a.filePath.localeCompare(b.filePath),
|
||||
);
|
||||
};
|
||||
|
||||
const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'print',
|
||||
'debugPrint',
|
||||
@@ -241,6 +260,60 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
export const swiftProvider = defineLanguage({
|
||||
id: SupportedLanguages.Swift,
|
||||
extensions: ['.swift'],
|
||||
entryPointPatterns: [
|
||||
/^viewDidLoad$/,
|
||||
/^viewWillAppear$/,
|
||||
/^viewDidAppear$/,
|
||||
/^viewWillDisappear$/,
|
||||
/^viewDidDisappear$/,
|
||||
/^application\(/,
|
||||
/^scene\(/,
|
||||
/^body$/,
|
||||
/Coordinator$/,
|
||||
/^sceneDidBecomeActive$/,
|
||||
/^sceneWillResignActive$/,
|
||||
/^didFinishLaunchingWithOptions$/,
|
||||
/ViewController$/,
|
||||
/^configure[A-Z]/,
|
||||
/^setup[A-Z]/,
|
||||
/^makeBody$/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'uikit',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'uikit-lifecycle',
|
||||
patterns: [
|
||||
'viewDidLoad',
|
||||
'viewWillAppear',
|
||||
'viewDidAppear',
|
||||
'UIViewController',
|
||||
'@IBOutlet',
|
||||
'@IBAction',
|
||||
'@objc',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'swiftui',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'swiftui-pattern',
|
||||
patterns: [
|
||||
'@main',
|
||||
'WindowGroup',
|
||||
'ContentView',
|
||||
'@StateObject',
|
||||
'@ObservedObject',
|
||||
'@EnvironmentObject',
|
||||
'@Published',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'vapor',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'vapor-routing',
|
||||
patterns: ['app.get', 'app.post', 'req.content.decode', 'Vapor'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: SWIFT_QUERIES,
|
||||
typeConfig: swiftConfig,
|
||||
exportChecker: swiftExportChecker,
|
||||
@@ -257,5 +330,6 @@ export const swiftProvider = defineLanguage({
|
||||
classExtractor: createClassExtractor(swiftClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.Swift),
|
||||
implicitImportWirer: wireSwiftImplicitImports,
|
||||
orderSameNameTypeCandidates: orderSwiftSameNameTypeCandidates,
|
||||
builtInNames: BUILT_INS,
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import {
|
||||
typescriptClassConfig,
|
||||
@@ -57,8 +58,30 @@ import {
|
||||
} from './typescript/index.js';
|
||||
|
||||
/**
|
||||
* TypeScript/JavaScript: arrow_function and function_expression get their name
|
||||
* from the parent variable_declarator (e.g. `const foo = () => {}`).
|
||||
* TypeScript/JavaScript: arrow_function and function_expression are
|
||||
* anonymous AST nodes — they take their name from the surrounding
|
||||
* declarative context.
|
||||
*
|
||||
* Recognised contexts:
|
||||
* - `const foo = () => {}` (variable_declarator) → "foo"
|
||||
* - `{ addItem: (item) => ... }` (pair / property_assignment) → "addItem"
|
||||
* Covers Zustand stores, TanStack Query factories, React Context
|
||||
* providers, and most other HOF-heavy idioms (issue #1166).
|
||||
* - `const X = HOC((args) => { ... })` (arguments → call_expression →
|
||||
* variable_declarator) → "X". Covers `React.forwardRef`, `memo`,
|
||||
* `useCallback`, `useMemo`, `observer`, `debounce`, and other HOC
|
||||
* factories that wrap their behaviour-defining arrow. Without this
|
||||
* branch, every shadcn/Radix UI component (`const Button =
|
||||
* React.forwardRef(...)`) registered as an anonymous arrow with
|
||||
* calls inside falling back to File-level attribution. The same
|
||||
* applied to all `useCallback` / `useMemo` callbacks bound to a
|
||||
* const — the sole way to give them a named caller anchor.
|
||||
*
|
||||
* Returns `null` for funcName when the arrow lives in a context that has
|
||||
* no static name — bare call arguments (not bound to a const), computed
|
||||
* keys, return-from-arrow positions. The parent walk in
|
||||
* findEnclosingFunctionId then continues up to the next named ancestor
|
||||
* (or to the file).
|
||||
*/
|
||||
const tsExtractFunctionName = (
|
||||
node: SyntaxNode,
|
||||
@@ -66,19 +89,74 @@ const tsExtractFunctionName = (
|
||||
if (node.type !== 'arrow_function' && node.type !== 'function_expression') return null;
|
||||
|
||||
const parent = node.parent;
|
||||
if (parent?.type !== 'variable_declarator') return null;
|
||||
if (!parent) return null;
|
||||
|
||||
let nameNode = parent.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < parent.childCount; i++) {
|
||||
const c = parent.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
if (parent.type === 'variable_declarator') {
|
||||
let nameNode = parent.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < parent.childCount; i++) {
|
||||
const c = parent.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { funcName: nameNode?.text ?? null, label: 'Function' };
|
||||
}
|
||||
return { funcName: nameNode?.text ?? null, label: 'Function' };
|
||||
|
||||
// Object property pair: `{ addItem: (item) => ... }`.
|
||||
// tree-sitter-typescript uses `pair`; tree-sitter-javascript also exposes
|
||||
// `pair`. (Older grammars used `property_assignment`; we accept both.)
|
||||
if (parent.type === 'pair' || parent.type === 'property_assignment') {
|
||||
const keyNode = parent.childForFieldName?.('key');
|
||||
if (!keyNode) return { funcName: null, label: 'Function' };
|
||||
if (keyNode.type === 'property_identifier' || keyNode.type === 'identifier') {
|
||||
return { funcName: keyNode.text, label: 'Function' };
|
||||
}
|
||||
if (keyNode.type === 'string') {
|
||||
// `"add-item": () => ...` — the literal text inside the quotes.
|
||||
const fragment = keyNode.children?.find((c: SyntaxNode) => c.type === 'string_fragment');
|
||||
const text = fragment?.text ?? null;
|
||||
return { funcName: text, label: 'Function' };
|
||||
}
|
||||
// computed_property_name (`[ACTION_KEY]`) and other dynamic keys have
|
||||
// no static name — fall through anonymous.
|
||||
return { funcName: null, label: 'Function' };
|
||||
}
|
||||
|
||||
// HOC-wrapped variable declarations: `const Button = forwardRef((p, r) => { ... })`,
|
||||
// `const handleClick = useCallback(() => doStuff(), [deps])`,
|
||||
// `const Card = React.memo((props) => { ... })`. The arrow's `parent` is
|
||||
// `arguments`, grandparent is `call_expression`, great-grandparent is
|
||||
// `variable_declarator`. Walk the chain up and take the variable's name
|
||||
// — the meaningful identifier the developer wrote on the LHS. Mirrors
|
||||
// the four registry-primary patterns in `typescript/query.ts`. The
|
||||
// wrapping callee (`forwardRef`, `memo`, `React.memo`, `useCallback`,
|
||||
// user-defined HOCs) is intentionally NOT constrained: any function
|
||||
// call whose result is bound to a const and whose first/positional
|
||||
// argument is an arrow takes the const's name. Chained array-method
|
||||
// calls (`const x = arr.find((y) => p(y))`) match too and produce a
|
||||
// mostly-harmless `Function:x` (consumed as a value, never invoked),
|
||||
// accepted as a small false-positive cost vs. the much larger gain of
|
||||
// capturing the React UI-component idiom.
|
||||
if (parent.type === 'arguments') {
|
||||
const callExpr = parent.parent;
|
||||
if (!callExpr || callExpr.type !== 'call_expression') {
|
||||
return { funcName: null, label: 'Function' };
|
||||
}
|
||||
const declarator = callExpr.parent;
|
||||
if (!declarator || declarator.type !== 'variable_declarator') {
|
||||
return { funcName: null, label: 'Function' };
|
||||
}
|
||||
const nameNode = declarator.childForFieldName?.('name');
|
||||
if (nameNode?.type === 'identifier') {
|
||||
return { funcName: nameNode.text, label: 'Function' };
|
||||
}
|
||||
return { funcName: null, label: 'Function' };
|
||||
}
|
||||
|
||||
return { funcName: null, label: 'Function' };
|
||||
};
|
||||
|
||||
export const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
@@ -181,6 +259,29 @@ export const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
export const typescriptProvider = defineLanguage({
|
||||
id: SupportedLanguages.TypeScript,
|
||||
extensions: ['.ts', '.tsx'],
|
||||
entryPointPatterns: [/^use[A-Z]/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'nestjs',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'nestjs-decorator',
|
||||
patterns: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'],
|
||||
},
|
||||
{
|
||||
framework: 'expo-router',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'expo-router-navigation',
|
||||
patterns: [
|
||||
'router.push',
|
||||
'router.replace',
|
||||
'router.navigate',
|
||||
'useRouter',
|
||||
'useLocalSearchParams',
|
||||
'useSegments',
|
||||
'expo-router',
|
||||
],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: TYPESCRIPT_QUERIES,
|
||||
typeConfig: typescriptConfig,
|
||||
exportChecker: tsExportChecker,
|
||||
@@ -220,6 +321,29 @@ export const typescriptProvider = defineLanguage({
|
||||
export const javascriptProvider = defineLanguage({
|
||||
id: SupportedLanguages.JavaScript,
|
||||
extensions: ['.js', '.jsx'],
|
||||
entryPointPatterns: [/^use[A-Z]/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'nestjs',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'nestjs-decorator',
|
||||
patterns: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'],
|
||||
},
|
||||
{
|
||||
framework: 'expo-router',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'expo-router-navigation',
|
||||
patterns: [
|
||||
'router.push',
|
||||
'router.replace',
|
||||
'router.navigate',
|
||||
'useRouter',
|
||||
'useLocalSearchParams',
|
||||
'useSegments',
|
||||
'expo-router',
|
||||
],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: JAVASCRIPT_QUERIES,
|
||||
typeConfig: typescriptConfig,
|
||||
exportChecker: tsExportChecker,
|
||||
|
||||
@@ -84,6 +84,11 @@ function pickFirstDefined(grouped: CaptureMatch, tags: readonly string[]): Captu
|
||||
* as `@reference.write.member`).
|
||||
* 4. The member_expression is the `function:` of an `await_expression`
|
||||
* being called (handled by the member-call capture).
|
||||
* 5. The member_expression is the `name:` of a `jsx_self_closing_element`
|
||||
* or `jsx_opening_element` (it's a JSX component invocation, already
|
||||
* captured as `@reference.call.member` by the TSX-only query suffix).
|
||||
* Without this filter, `<Foo.Bar />` would emit a phantom ACCESSES
|
||||
* edge to `Foo.Bar` IN ADDITION to the CALLS edge.
|
||||
*
|
||||
* Returns `true` when the capture should be kept as a read reference,
|
||||
* `false` when it should be dropped.
|
||||
@@ -99,6 +104,9 @@ function shouldEmitReadMember(memberNode: SyntaxNode): boolean {
|
||||
case 'assignment_expression':
|
||||
case 'augmented_assignment_expression':
|
||||
return parent.childForFieldName('left')?.id !== memberNode.id;
|
||||
case 'jsx_self_closing_element':
|
||||
case 'jsx_opening_element':
|
||||
return parent.childForFieldName('name')?.id !== memberNode.id;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
@@ -232,6 +240,20 @@ export function emitTsScopeCaptures(
|
||||
// arity filter can narrow overloads. Count the `argument` named
|
||||
// children of the backing `arguments` node. TypeScript constructor
|
||||
// calls use `new_expression`; regular calls use `call_expression`.
|
||||
//
|
||||
// JSX call anchors (`jsx_self_closing_element` / `jsx_opening_element`
|
||||
// captured by the TSX-only suffix in `query.ts`) intentionally do
|
||||
// NOT carry arity metadata. The lookup below would resolve `callNode`
|
||||
// to `null` for a JSX anchor (the anchor is neither a call_expression
|
||||
// nor a new_expression), so the synthesis branch silently no-ops and
|
||||
// the JSX call enters the registry with name-only resolution. This
|
||||
// is acceptable for React: components are virtually never
|
||||
// overloaded in the current GitNexus graph model, so name-only
|
||||
// dispatch matches the single component definition. If a future
|
||||
// codebase introduces overloaded React components AND needs JSX
|
||||
// calls to disambiguate by props-arity, a JSX-aware arity
|
||||
// synthesizer would need to count `jsx_attribute` children of the
|
||||
// opening tag instead of `arguments`.
|
||||
const callAnchor = pickFirstDefined(grouped, CALL_TAGS);
|
||||
if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
const callNode =
|
||||
|
||||
@@ -136,25 +136,172 @@ const TYPESCRIPT_SCOPE_QUERY = `
|
||||
;; Arrow/function-expression assigned to a const/let/var — named by the
|
||||
;; variable_declarator. Covers \`const fn = () => {}\` and its export
|
||||
;; variant. Matches the legacy TYPESCRIPT_QUERIES pattern.
|
||||
;;
|
||||
;; The \`@declaration.function\` anchor sits on the INNER arrow_function /
|
||||
;; function_expression node (NOT the wrapping lexical_declaration), so
|
||||
;; \`anchor.range\` aligns with the corresponding \`@scope.function\` scope
|
||||
;; range. \`pass2AttachDeclarations\` then resolves \`innermost\` to the
|
||||
;; arrow's own scope (instead of the module scope) and the def is owned
|
||||
;; by the arrow itself. Without this alignment, calls inside the arrow
|
||||
;; body lose caller attribution: \`resolveCallerGraphId\` walks up past
|
||||
;; the empty arrow scope into the module scope and grabs whichever
|
||||
;; Function-like def appears first there — silently mis-attributing
|
||||
;; every nested call (Zustand stores, TanStack hooks, Promise-all/map,
|
||||
;; etc.). See \`typescript-hof-callbacks.test.ts\`.
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (arrow_function))) @declaration.function
|
||||
value: (arrow_function) @declaration.function))
|
||||
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (function_expression))) @declaration.function
|
||||
value: (function_expression) @declaration.function))
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (arrow_function))) @declaration.function
|
||||
value: (arrow_function) @declaration.function))
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (function_expression))) @declaration.function
|
||||
value: (function_expression) @declaration.function))
|
||||
|
||||
;; Object-property arrows / function expressions named by their pair key:
|
||||
;; \`{ addItem: (item) => ..., removeItem: (item) => ... }\`. The legacy
|
||||
;; TYPESCRIPT_QUERIES emits the same shape; mirroring it here keeps
|
||||
;; scope-resolution declarations in sync (issue #1166). Computed keys
|
||||
;; (\`[K]: () => ...\`) intentionally fall through anonymous.
|
||||
;;
|
||||
;; Same anchor discipline as the \`lexical_declaration\` block above: the
|
||||
;; \`@declaration.function\` capture must sit on the INNER \`arrow_function\`
|
||||
;; / \`function_expression\` node — NOT the outer \`pair\`. The pair node
|
||||
;; starts at the property-key token, BEFORE the arrow's
|
||||
;; \`@scope.function\` range. \`pass2AttachDeclarations.atPosition(pair.startLine,
|
||||
;; pair.startCol)\` therefore resolves to the PARENT scope (the enclosing
|
||||
;; function-like, e.g. the \`(set) => ({...})\` callback in
|
||||
;; \`persist((set) => ({...}))\`), not the inner arrow's own scope.
|
||||
;;
|
||||
;; With the anchor on \`pair\`, ALL pair-function defs from the same object
|
||||
;; literal land in the same parent scope's \`ownedDefs\`. \`resolveCallerGraphId\`
|
||||
;; walking up from a call inside any of those arrows then matches the
|
||||
;; FIRST Function-like def via \`ownedDefs.find()\` — silently mis-attributing
|
||||
;; every call to the first sibling. Multi-action Zustand stores
|
||||
;; (\`{ addItem, removeItem, fetchData, … }\`) — the dominant 0%-capture
|
||||
;; pattern in the bug report — would land all calls on \`addItem\`.
|
||||
;;
|
||||
;; With the anchor on the inner \`arrow_function\` / \`function_expression\`,
|
||||
;; \`anchor.range\` matches the arrow's own \`@scope.function\` range; the
|
||||
;; def lands in the arrow scope's own \`ownedDefs\` and \`pass2AttachDeclarations\`'s
|
||||
;; auto-hoist (\`rangesEqual(anchor.range, innermost.range)\`) promotes
|
||||
;; the BINDING to the parent scope (so importers and lookups still find
|
||||
;; the name in the object's surrounding scope). Each pair-arrow becomes
|
||||
;; an independent caller anchor in the walk.
|
||||
(pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (arrow_function) @declaration.function)
|
||||
|
||||
(pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (function_expression) @declaration.function)
|
||||
|
||||
(pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (arrow_function) @declaration.function)
|
||||
|
||||
(pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (function_expression) @declaration.function)
|
||||
|
||||
;; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`.
|
||||
;;
|
||||
;; Covers the dominant React UI idiom (\`React.forwardRef\`, \`React.memo\`,
|
||||
;; bare \`forwardRef\` / \`memo\` / \`observer\`), Hook callbacks
|
||||
;; (\`useCallback\`, \`useMemo\`), and library-wrapper factories (\`debounce\`,
|
||||
;; \`throttle\`, user-defined \`withErrorBoundary\` / \`createHook\`, etc.).
|
||||
;; All produce the same AST shape:
|
||||
;;
|
||||
;; lexical_declaration
|
||||
;; variable_declarator
|
||||
;; name: identifier "X" ← we want this name
|
||||
;; value: call_expression
|
||||
;; function: identifier | member_expression ← any callee
|
||||
;; arguments: arguments
|
||||
;; arrow_function | function_expression ← the actual code
|
||||
;;
|
||||
;; The pre-fix \`tsExtractFunctionName\` only handled \`variable_declarator\`
|
||||
;; and \`pair\` parents, so HOC-wrapped arrows fell through anonymous. The
|
||||
;; registry-primary \`query.ts\` had no pattern for this shape either —
|
||||
;; \`const Button = forwardRef((p, r) => { ... })\` registered as a
|
||||
;; \`Variable\` with no \`Function\` def, and every call inside the arrow
|
||||
;; body lost caller attribution: \`resolveCallerGraphId\` walked up past
|
||||
;; the empty arrow scope to the module's File fallback. Sourcerer-fe alone
|
||||
;; has ~296 such declarations (57 forwardRef + 21 memo + 161 useCallback
|
||||
;; + 57 useMemo) — all invisible to \`gitnexus_context\` /
|
||||
;; \`gitnexus_impact\` for outgoing edges before this fix.
|
||||
;;
|
||||
;; Anchor discipline: same as the \`lexical_declaration\` / \`pair\` blocks
|
||||
;; above — on the INNER \`arrow_function\` / \`function_expression\`, NOT
|
||||
;; the outer \`call_expression\`. The arrow's range matches its own
|
||||
;; \`@scope.function\` range, so \`pass2AttachDeclarations.atPosition\`
|
||||
;; resolves \`innermost\` to the arrow's own scope and
|
||||
;; \`rangesEqual(anchor.range, innermost.range)\` triggers the auto-hoist
|
||||
;; that promotes the binding to the parent scope (where \`const X\`
|
||||
;; lives).
|
||||
;;
|
||||
;; Trade-off — chained array-method form: \`const x = arr.find((y) => p(y))\`
|
||||
;; has the same syntactic shape and would also match, naming the
|
||||
;; \`.find\` callback as \`x\`. The resulting \`Function:x\` is mostly
|
||||
;; harmless: \`x\` is consumed as a value (\`if (x) { ... }\`), never
|
||||
;; invoked as a function, so it gets zero incoming \`CALLS\` edges. The
|
||||
;; one outgoing edge \`Function:x → p\` is a minor mis-attribution that
|
||||
;; could in principle be fixed by adding a \`function: [(identifier)
|
||||
;; (member_expression)]\` predicate that excludes property-identifiers
|
||||
;; matching a known array-method blocklist (\`map\` / \`filter\` / \`find\`
|
||||
;; / \`reduce\` / \`forEach\` / \`some\` / \`every\`). We don't do that here
|
||||
;; because (a) the false-positive cost is negligible, (b) the blocklist
|
||||
;; would need maintenance, and (c) any user-defined fluent-API method
|
||||
;; with a callback argument would still false-positive — there's no
|
||||
;; clean syntactic line.
|
||||
;;
|
||||
;; Trade-off — multi-arrow arguments: \`const x = call(arrow1, arrow2)\`
|
||||
;; would emit TWO matches with the same name \`x\`. tree-sitter-query
|
||||
;; iterates all arrow_function direct children of \`arguments\`, so each
|
||||
;; emits its own \`(name=x, function=...)\` pair. \`pass2AttachDeclarations\`
|
||||
;; pushes both \`Function:x\` defs into the same arrow scopes (each in
|
||||
;; its own arrow's \`ownedDefs\`) and hoists both bindings to the parent.
|
||||
;; The downstream registry's qualified-name dedup then collapses them
|
||||
;; via \`(filePath, type, qualifiedName)\` — second wins. Acceptable;
|
||||
;; multi-arrow-callback APIs are rare (\`new Promise(executor)\` is the
|
||||
;; main one and takes a single executor).
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function))))
|
||||
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function))))
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function))))
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function))))
|
||||
|
||||
;; Method definitions — regular + private (#field) methods.
|
||||
(method_definition
|
||||
@@ -723,6 +870,53 @@ const TYPESCRIPT_SCOPE_QUERY = `
|
||||
property: (property_identifier) @reference.name) @reference.read.member
|
||||
`;
|
||||
|
||||
/**
|
||||
* JSX-only query suffix. Appended to the base query when compiling
|
||||
* against the TSX grammar; NOT compiled against the plain TS grammar
|
||||
* (which has no \`jsx_*\` node types and would reject these patterns).
|
||||
*
|
||||
* Why JSX as a CALLS edge: \`<Foo />\` is syntactic sugar for \`Foo(props)\`
|
||||
* and the React component is invoked by the renderer, so for blast-radius
|
||||
* (\`gitnexus_impact("Badge", direction: "upstream")\`) and call-graph
|
||||
* (\`gitnexus_context("Foo")\`) purposes JSX usage IS a call. Routing
|
||||
* through \`@reference.call.free\` / \`@reference.call.member\` makes the
|
||||
* downstream caller-walk + edge-emission paths handle JSX uniformly with
|
||||
* ordinary call expressions — no new edge type, no schema changes.
|
||||
*
|
||||
* Identifier-only JSX is filtered to PascalCase via \`(#match? ... "^[A-Z]")\`
|
||||
* so \`<div>\`, \`<span>\`, \`<button>\` and other native HTML elements (which
|
||||
* by JSX convention start lowercase) don't emit edges to nonexistent
|
||||
* "div" / "span" symbols. Member-form JSX (\`<Foo.Bar />\`) is always a
|
||||
* component (HTML element names can't contain dots), so no predicate
|
||||
* filter is applied there.
|
||||
*
|
||||
* Both \`jsx_self_closing_element\` (\`<Foo />\`) and \`jsx_opening_element\`
|
||||
* (\`<Foo>...</Foo>\`) emit; the closing tag is intentionally NOT captured —
|
||||
* each JSX element should emit exactly one CALLS edge per use site.
|
||||
*/
|
||||
const TSX_JSX_QUERY_SUFFIX = `
|
||||
;; <Foo />
|
||||
((jsx_self_closing_element
|
||||
name: (identifier) @reference.name) @reference.call.free
|
||||
(#match? @reference.name "^[A-Z]"))
|
||||
|
||||
;; <Foo> ... </Foo> (paired form — match the opening tag only)
|
||||
((jsx_opening_element
|
||||
name: (identifier) @reference.name) @reference.call.free
|
||||
(#match? @reference.name "^[A-Z]"))
|
||||
|
||||
;; <Foo.Bar /> / <Container.Section.Title /> — namespaced JSX
|
||||
(jsx_self_closing_element
|
||||
name: (member_expression
|
||||
object: (_) @reference.receiver
|
||||
property: (property_identifier) @reference.name)) @reference.call.member
|
||||
|
||||
(jsx_opening_element
|
||||
name: (member_expression
|
||||
object: (_) @reference.receiver
|
||||
property: (property_identifier) @reference.name)) @reference.call.member
|
||||
`;
|
||||
|
||||
let _tsParser: Parser | null = null;
|
||||
let _tsxParser: Parser | null = null;
|
||||
let _tsQuery: Parser.Query | null = null;
|
||||
@@ -753,11 +947,18 @@ export function getTsParser(filePath?: string): Parser {
|
||||
* executed against a Tree produced by the `tsx` grammar — tree-sitter
|
||||
* matches by node-type id, and the two grammars have separate id
|
||||
* spaces.
|
||||
*
|
||||
* The TSX query is compiled with the JSX-as-call patterns appended.
|
||||
* Those patterns reference `jsx_self_closing_element` /
|
||||
* `jsx_opening_element` which exist only in the TSX grammar — embedding
|
||||
* them in the plain TS query would throw `Query.InvalidNodeType` at
|
||||
* compile time (and even if it didn't, the patterns would never fire on
|
||||
* `.ts` source).
|
||||
*/
|
||||
export function getTsScopeQuery(filePath?: string): Parser.Query {
|
||||
if (filePath !== undefined && isTsxFile(filePath)) {
|
||||
if (_tsxQuery === null) {
|
||||
_tsxQuery = new Parser.Query(TSX_GRAMMAR, TYPESCRIPT_SCOPE_QUERY);
|
||||
_tsxQuery = new Parser.Query(TSX_GRAMMAR, TYPESCRIPT_SCOPE_QUERY + TSX_JSX_QUERY_SUFFIX);
|
||||
}
|
||||
return _tsxQuery;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ const vueClassExtractor = createClassExtractor(vueClassConfig);
|
||||
export const vueProvider = defineLanguage({
|
||||
id: SupportedLanguages.Vue,
|
||||
extensions: ['.vue'],
|
||||
entryPointPatterns: [],
|
||||
astFrameworkPatterns: [],
|
||||
treeSitterQueries: TYPESCRIPT_QUERIES,
|
||||
typeConfig: typescriptConfig,
|
||||
exportChecker: tsExportChecker,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// gitnexus/src/core/ingestion/method-extractors/configs/swift.ts
|
||||
// Verified against tree-sitter-swift 0.6.0
|
||||
// Verified against tree-sitter-swift 0.7.x
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type {
|
||||
@@ -110,7 +110,7 @@ function extractSwiftReturnType(node: SyntaxNode): string | undefined {
|
||||
function extractSwiftParameters(node: SyntaxNode): ParameterInfo[] {
|
||||
const params: ParameterInfo[] = [];
|
||||
|
||||
// In tree-sitter-swift 0.6.0, parameters are direct children of function_declaration.
|
||||
// In tree-sitter-swift, parameters are direct children of function_declaration.
|
||||
// Default value tokens ('=', literal) are siblings of the parameter node at the
|
||||
// function_declaration level, not children of the parameter node.
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
@@ -264,8 +264,7 @@ function extractSwiftAnnotations(node: SyntaxNode): string[] {
|
||||
export const swiftMethodConfig: MethodExtractionConfig = {
|
||||
language: SupportedLanguages.Swift,
|
||||
|
||||
// tree-sitter-swift 0.6.0 may use class_declaration for classes, structs, enums, extensions,
|
||||
// and actors — but this cannot be verified until the grammar installs on Node 22+.
|
||||
// Keep this conservative until Swift type-shape coverage is expanded.
|
||||
// TODO: Verify struct_declaration, enum_declaration, extension_declaration, actor_declaration
|
||||
// node types once tree-sitter-swift loads on Node 22, and add them here if they are distinct.
|
||||
// protocol_declaration is a separate, confirmed node type.
|
||||
|
||||
@@ -542,10 +542,13 @@ const processParsingSequential = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Append #<paramCount> to Method/Constructor IDs to disambiguate overloads.
|
||||
// Functions are not suffixed — they don't overload by name in the same scope.
|
||||
// Append #<paramCount> to owned callable IDs to disambiguate overloads.
|
||||
// Top-level Function IDs stay stable; functions inside an owner may overload.
|
||||
// When same-arity collisions exist, append ~type1,type2 for further disambiguation.
|
||||
const needsAritySuffix = nodeLabel === 'Method' || nodeLabel === 'Constructor';
|
||||
const needsAritySuffix =
|
||||
nodeLabel === 'Method' ||
|
||||
nodeLabel === 'Constructor' ||
|
||||
(nodeLabel === 'Function' && enclosingClassId !== null);
|
||||
let arityTag = needsAritySuffix && arityForId !== undefined ? `#${arityForId}` : '';
|
||||
if (arityTag && seqDefMethods && seqDefMethodInfo && seqClassNodeId !== undefined) {
|
||||
// Use cached method map + collision groups (built once per class, not per method)
|
||||
@@ -725,6 +728,14 @@ export const processParsing = async (
|
||||
onFileProgress?: FileProgressCallback,
|
||||
workerPool?: WorkerPool,
|
||||
): Promise<WorkerExtractedData | null> => {
|
||||
let lastProgress = 0;
|
||||
const reportProgress: FileProgressCallback | undefined = onFileProgress
|
||||
? (current, total, detail) => {
|
||||
lastProgress = Math.max(lastProgress, current);
|
||||
onFileProgress(lastProgress, total, detail);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (workerPool) {
|
||||
if (scopeTreeCache !== undefined && process.env.PROF_SCOPE_RESOLUTION === '1') {
|
||||
// Trees can't cross MessageChannels, so worker-parsed files land
|
||||
@@ -742,12 +753,15 @@ export const processParsing = async (
|
||||
symbolTable,
|
||||
astCache,
|
||||
workerPool,
|
||||
onFileProgress,
|
||||
reportProgress,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'Worker pool parsing failed, falling back to sequential:',
|
||||
err instanceof Error ? err.message : err,
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn('Worker pool parsing stopped; continuing with sequential parser:', message);
|
||||
reportProgress?.(
|
||||
lastProgress,
|
||||
files.length,
|
||||
`Sequential fallback after worker issue: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -759,7 +773,7 @@ export const processParsing = async (
|
||||
symbolTable,
|
||||
astCache,
|
||||
scopeTreeCache,
|
||||
onFileProgress,
|
||||
reportProgress,
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -112,12 +112,15 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
|
||||
}
|
||||
list.push(url);
|
||||
}
|
||||
const toolsByFile = new Map<string, string[]>();
|
||||
const toolsByHandlerId = new Map<string, string[]>();
|
||||
const toolsWithoutHandlerByFile = new Map<string, string[]>();
|
||||
for (const td of toolDefs) {
|
||||
let list = toolsByFile.get(td.filePath);
|
||||
const key = td.handlerNodeId ?? td.filePath;
|
||||
const targetMap = td.handlerNodeId ? toolsByHandlerId : toolsWithoutHandlerByFile;
|
||||
let list = targetMap.get(key);
|
||||
if (!list) {
|
||||
list = [];
|
||||
toolsByFile.set(td.filePath, list);
|
||||
targetMap.set(key, list);
|
||||
}
|
||||
list.push(td.name);
|
||||
}
|
||||
@@ -145,7 +148,9 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
|
||||
linked++;
|
||||
}
|
||||
}
|
||||
const toolNames = toolsByFile.get(entryFile);
|
||||
const exactToolNames = toolsByHandlerId.get(proc.entryPointId);
|
||||
const fallbackToolNames = toolsWithoutHandlerByFile.get(entryFile);
|
||||
const toolNames = exactToolNames ?? fallbackToolNames;
|
||||
if (toolNames) {
|
||||
for (const toolName of toolNames) {
|
||||
const toolNodeId = generateId('Tool', toolName);
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface ToolDef {
|
||||
name: string;
|
||||
filePath: string;
|
||||
description: string;
|
||||
handlerNodeId?: string;
|
||||
}
|
||||
|
||||
export interface ToolsOutput {
|
||||
@@ -42,7 +43,14 @@ export const toolsPhase: PipelinePhase<ToolsOutput> = {
|
||||
for (const td of allToolDefs) {
|
||||
if (seenToolNames.has(td.toolName)) continue;
|
||||
seenToolNames.add(td.toolName);
|
||||
toolDefs.push({ name: td.toolName, filePath: td.filePath, description: td.description });
|
||||
const handlerNodeId =
|
||||
td.handlerNodeId && ctx.graph.getNode(td.handlerNodeId) ? td.handlerNodeId : undefined;
|
||||
toolDefs.push({
|
||||
name: td.toolName,
|
||||
filePath: td.filePath,
|
||||
description: td.description,
|
||||
...(handlerNodeId !== undefined ? { handlerNodeId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// TS tool definition arrays — require inputSchema nearby
|
||||
@@ -84,10 +92,10 @@ export const toolsPhase: PipelinePhase<ToolsOutput> = {
|
||||
properties: { name: td.name, filePath: td.filePath, description: td.description },
|
||||
});
|
||||
|
||||
const handlerFileId = generateId('File', td.filePath);
|
||||
const handlerId = td.handlerNodeId ?? generateId('File', td.filePath);
|
||||
ctx.graph.addRelationship({
|
||||
id: generateId('HANDLES_TOOL', `${handlerFileId}->${toolNodeId}`),
|
||||
sourceId: handlerFileId,
|
||||
id: generateId('HANDLES_TOOL', `${handlerId}->${toolNodeId}`),
|
||||
sourceId: handlerId,
|
||||
targetId: toolNodeId,
|
||||
type: 'HANDLES_TOOL',
|
||||
confidence: 1.0,
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
* `emitScopeCaptures`. Returns `undefined`; zero work done. This is
|
||||
* the state of every language today — `ParsedFile` production stays
|
||||
* dormant until a language migrates.
|
||||
* 2. Invokes the hook + feeds its output to `ScopeExtractor.extract`.
|
||||
* 3. **Swallows exceptions from either side.** A failure here returns
|
||||
* 2. Short-circuits empty / whitespace-only files. There is no scope
|
||||
* content to extract, and some tree-sitter queries do not match an
|
||||
* otherwise valid empty root node.
|
||||
* 3. Invokes the hook + feeds its output to `ScopeExtractor.extract`.
|
||||
* 4. **Swallows exceptions from either side.** A failure here returns
|
||||
* `undefined` and emits a warning via `onWarn`; legacy parsing on
|
||||
* the same file continues unaffected by the scope-extraction miss.
|
||||
* Scope-based resolution is the new path under construction — it
|
||||
@@ -41,6 +44,7 @@ export function extractParsedFile(
|
||||
cachedTree?: unknown,
|
||||
): ParsedFile | undefined {
|
||||
if (provider.emitScopeCaptures === undefined) return undefined;
|
||||
if (sourceText.trim().length === 0) return undefined;
|
||||
try {
|
||||
const captures = provider.emitScopeCaptures(sourceText, filePath, cachedTree);
|
||||
return extractScope(captures, filePath, provider);
|
||||
|
||||
@@ -74,7 +74,7 @@ import type {
|
||||
SymbolDefinition,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
import { buildPositionIndex, buildScopeTree, makeScopeId } from 'gitnexus-shared';
|
||||
import { buildPositionIndex, buildScopeTree, canParentScope, makeScopeId } from 'gitnexus-shared';
|
||||
import type { LanguageProvider } from './language-provider.js';
|
||||
|
||||
// ─── Narrow hook surface the extractor actually uses ───────────────────────
|
||||
@@ -122,6 +122,7 @@ export function extract(
|
||||
|
||||
// ── Pass 1: build the scope tree ─────────────────────────────────────
|
||||
const scopeDrafts = pass1BuildScopes(partitioned.scope, filePath, provider);
|
||||
const moduleScope = ensureModuleScope(scopeDrafts, matches.length, filePath);
|
||||
const scopes = scopeDrafts.map(draftToScope);
|
||||
// buildScopeTree validates invariants (throws on violation) and exposes
|
||||
// the lookup contract consumed by Passes 2-5.
|
||||
@@ -136,14 +137,6 @@ export function extract(
|
||||
const scopeTree = buildScopeTree(scopes);
|
||||
const positionIndex = buildPositionIndex(scopes);
|
||||
|
||||
const moduleScope = scopeDrafts.find((s) => s.kind === 'Module');
|
||||
if (moduleScope === undefined) {
|
||||
throw new Error(
|
||||
`ScopeExtractor: no Module scope found for '${filePath}'. ` +
|
||||
`Provider must emit at least one @scope.module capture per file.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pass 2: attach declarations + local bindings ────────────────────
|
||||
const localDefs: SymbolDefinition[] = [];
|
||||
pass2AttachDeclarations(
|
||||
@@ -283,6 +276,33 @@ interface ScopeDraft {
|
||||
readonly typeBindings: Map<string, TypeRef>;
|
||||
}
|
||||
|
||||
function ensureModuleScope(
|
||||
scopeDrafts: ScopeDraft[],
|
||||
matchCount: number,
|
||||
filePath: string,
|
||||
): ScopeDraft {
|
||||
const moduleScope = scopeDrafts.find((s) => s.kind === 'Module');
|
||||
if (moduleScope !== undefined) return moduleScope;
|
||||
|
||||
if (scopeDrafts.length === 0 && matchCount === 0) {
|
||||
const range: Range = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 };
|
||||
const synthetic = makeDraft(
|
||||
makeScopeId({ filePath, range, kind: 'Module' }),
|
||||
null,
|
||||
'Module',
|
||||
range,
|
||||
filePath,
|
||||
);
|
||||
scopeDrafts.push(synthetic);
|
||||
return synthetic;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`ScopeExtractor: no Module scope found for '${filePath}'. ` +
|
||||
`Provider must emit at least one @scope.module capture per file.`,
|
||||
);
|
||||
}
|
||||
|
||||
function draftToScope(draft: ScopeDraft): Scope {
|
||||
const frozenBindings = new Map<string, readonly BindingRef[]>();
|
||||
for (const [name, refs] of draft.bindings) {
|
||||
@@ -331,20 +351,37 @@ function pass1BuildScopes(
|
||||
}
|
||||
|
||||
// Sort by (startLine, startCol) ASC, (endLine, endCol) DESC so outer
|
||||
// scopes appear before their children for parent-resolution.
|
||||
// scopes appear before their children for parent-resolution. When two
|
||||
// candidates have exactly equal ranges (e.g. a `compilation_unit` and
|
||||
// the only top-level scope in the file — see `canParentScope`), Module
|
||||
// sorts first so it lands on the stack ahead of the candidate that will
|
||||
// claim it as parent.
|
||||
candidates.sort((a, b) => {
|
||||
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;
|
||||
if (a.range.endCol !== b.range.endCol) return b.range.endCol - a.range.endCol;
|
||||
if (a.kind === b.kind) return 0;
|
||||
if (a.kind === 'Module') return -1;
|
||||
if (b.kind === 'Module') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const drafts: ScopeDraft[] = [];
|
||||
const stack: Candidate[] = []; // enclosing real scopes, outermost at [0]
|
||||
|
||||
for (const cand of candidates) {
|
||||
// Pop the stack until the top strictly contains this candidate.
|
||||
while (stack.length > 0 && !rangeStrictlyContains(stack[stack.length - 1]!.range, cand.range)) {
|
||||
// Pop the stack until the top can parent this candidate (strict
|
||||
// containment, plus the equal-range Module carve-out).
|
||||
while (
|
||||
stack.length > 0 &&
|
||||
!canParentScope(
|
||||
stack[stack.length - 1]!.range,
|
||||
cand.range,
|
||||
stack[stack.length - 1]!.kind,
|
||||
cand.kind,
|
||||
)
|
||||
) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
@@ -907,24 +944,6 @@ function rangesEqual(a: Range, b: Range): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
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 startsBefore =
|
||||
outer.startLine < inner.startLine ||
|
||||
(outer.startLine === inner.startLine && outer.startCol <= inner.startCol);
|
||||
const endsAfter =
|
||||
outer.endLine > inner.endLine ||
|
||||
(outer.endLine === inner.endLine && outer.endCol >= inner.endCol);
|
||||
return startsBefore && endsAfter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture names that are never anchors — they are sub-tags nested inside a
|
||||
* larger anchor (e.g., the receiver expression inside a `@reference.call`
|
||||
|
||||
@@ -20,12 +20,38 @@
|
||||
import type { NodeLabel, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import {
|
||||
isLinkableLabel,
|
||||
qualifiedKey,
|
||||
simpleKey,
|
||||
type GraphNodeLookup,
|
||||
} from '../graph-bridge/node-lookup.js';
|
||||
import { qualifiedKey, simpleKey, type GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
|
||||
/**
|
||||
* Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the
|
||||
* source ("caller"). A Variable / Property can be the TARGET of an
|
||||
* edge (e.g., a write-access to `user.name`), but it cannot be a
|
||||
* caller — variables don't execute code, so attributing a call to a
|
||||
* sibling Variable in the same scope produces nonsense edges like
|
||||
* `Variable:create → Function:create` (which the simpleKey fallback
|
||||
* in `resolveDefGraphId` then silently rewrites to
|
||||
* `Function:create → Function:create`, a self-loop that doesn't exist
|
||||
* in the source).
|
||||
*
|
||||
* Module-level call expressions inside a `const X = expr(args)`
|
||||
* declaration are the canonical case where this used to fail: the
|
||||
* walk-up over module scope's ownedDefs (only Variables) would land
|
||||
* on the FIRST Variable, get name-aliased to its sibling Function
|
||||
* with the same simple name, and emit a self-CALLS. With this label
|
||||
* restricted to function/class-likes, those calls correctly fall
|
||||
* through to the File-node fallback at the bottom of the walk.
|
||||
*/
|
||||
function isCallerAnchorLabel(label: NodeLabel): boolean {
|
||||
return (
|
||||
label === 'Function' ||
|
||||
label === 'Method' ||
|
||||
label === 'Constructor' ||
|
||||
label === 'Class' ||
|
||||
label === 'Interface' ||
|
||||
label === 'Struct' ||
|
||||
label === 'Enum'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a `SymbolDefinition` in the graph node lookup.
|
||||
@@ -105,7 +131,9 @@ export function resolveCallerGraphId(
|
||||
if (scope === undefined) break;
|
||||
lastFilePath = scope.filePath;
|
||||
|
||||
// Prefer Function/Method anchors; fall back to Class.
|
||||
// Prefer Function/Method/Constructor anchors; fall back to
|
||||
// Class/Interface/Struct/Enum. Variable/Property are NOT valid
|
||||
// caller anchors — see `isCallerAnchorLabel` for why.
|
||||
const fnDef = scope.ownedDefs.find(
|
||||
(d) => d.type === 'Function' || d.type === 'Method' || d.type === 'Constructor',
|
||||
);
|
||||
@@ -113,7 +141,7 @@ export function resolveCallerGraphId(
|
||||
const id = resolveDefGraphId(scope.filePath, fnDef, nodeLookup);
|
||||
if (id !== undefined) return id;
|
||||
}
|
||||
const classDef = scope.ownedDefs.find((d) => isLinkableLabel(d.type));
|
||||
const classDef = scope.ownedDefs.find((d) => isCallerAnchorLabel(d.type));
|
||||
if (classDef !== undefined) {
|
||||
const id = resolveDefGraphId(scope.filePath, classDef, nodeLookup);
|
||||
if (id !== undefined) return id;
|
||||
|
||||
@@ -61,6 +61,86 @@ export const TYPESCRIPT_QUERIES = `
|
||||
name: (identifier) @name
|
||||
value: (function_expression)))) @definition.function
|
||||
|
||||
; Object-property arrows / function expressions: \`{ addItem: () => ... }\`.
|
||||
; The pair's key field carries the meaningful name. Without these patterns,
|
||||
; calls inside the arrow are attributed to the file (issue #1166), and the
|
||||
; arrow itself is invisible to context() / impact() despite carrying real
|
||||
; behaviour (Zustand actions, TanStack queryFn, React Context providers).
|
||||
; String-key variant covers \`"add-item": () => ...\`; computed keys
|
||||
; (\`[K]: () => ...\`) intentionally fall through anonymous.
|
||||
(pair
|
||||
key: (property_identifier) @name
|
||||
value: (arrow_function)) @definition.function
|
||||
|
||||
(pair
|
||||
key: (property_identifier) @name
|
||||
value: (function_expression)) @definition.function
|
||||
|
||||
(pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (arrow_function)) @definition.function
|
||||
|
||||
(pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (function_expression)) @definition.function
|
||||
|
||||
; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`.
|
||||
; Mirrors the registry-primary patterns in \`languages/typescript/query.ts\`
|
||||
; so the legacy Call-Resolution DAG and the registry-primary pipeline
|
||||
; produce the same set of \`Function\` nodes — required for the CI parity
|
||||
; gate. Covers React.forwardRef / memo / useCallback / useMemo / observer
|
||||
; / debounce / user-defined HOC factories. The \`var X = HOC(...)\` form is
|
||||
; mirrored too (registry-primary has it) so that codebases mixing \`var\` and
|
||||
; \`const\` see identical attribution on both pipelines. See
|
||||
; \`tsExtractFunctionName\` for the resolution logic and the \`query.ts\`
|
||||
; comment for the full anchor-discipline rationale and the chained-
|
||||
; array-method trade-off.
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function))))) @definition.function
|
||||
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression))))) @definition.function
|
||||
|
||||
(export_statement
|
||||
declaration: (lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function)))))) @definition.function
|
||||
|
||||
(export_statement
|
||||
declaration: (lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression)))))) @definition.function
|
||||
|
||||
; \`var X = HOC(...)\` parity with registry-primary. Legacy code (and any
|
||||
; transpiler output that downlevels \`const\` to \`var\`) hits this shape.
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function))))) @definition.function
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression))))) @definition.function
|
||||
|
||||
; Variable/constant declarations (non-function values).
|
||||
; Overlap with @definition.function patterns is handled by parse-worker dedup.
|
||||
(lexical_declaration
|
||||
@@ -219,6 +299,75 @@ export const JAVASCRIPT_QUERIES = `
|
||||
name: (identifier) @name
|
||||
value: (function_expression)))) @definition.function
|
||||
|
||||
; Object-property arrows / function expressions: \`{ addItem: () => ... }\`.
|
||||
; See TYPESCRIPT_QUERIES for rationale (issue #1166).
|
||||
(pair
|
||||
key: (property_identifier) @name
|
||||
value: (arrow_function)) @definition.function
|
||||
|
||||
(pair
|
||||
key: (property_identifier) @name
|
||||
value: (function_expression)) @definition.function
|
||||
|
||||
(pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (arrow_function)) @definition.function
|
||||
|
||||
(pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (function_expression)) @definition.function
|
||||
|
||||
; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`.
|
||||
; See TYPESCRIPT_QUERIES section above for the full rationale (issue #1166
|
||||
; follow-up — covers forwardRef / memo / useCallback / useMemo / observer
|
||||
; / debounce / user-defined HOC factories). Both \`const\` and \`var\` forms
|
||||
; are mirrored so JS code that uses \`var\` (or transpiler output) gets the
|
||||
; same attribution as the registry-primary path.
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function))))) @definition.function
|
||||
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression))))) @definition.function
|
||||
|
||||
(export_statement
|
||||
declaration: (lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function)))))) @definition.function
|
||||
|
||||
(export_statement
|
||||
declaration: (lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression)))))) @definition.function
|
||||
|
||||
; \`var X = HOC(...)\` parity with registry-primary.
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function))))) @definition.function
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression))))) @definition.function
|
||||
|
||||
; Variable/constant declarations (non-function values).
|
||||
; Overlap with @definition.function patterns is handled by parse-worker dedup.
|
||||
(lexical_declaration
|
||||
|
||||
@@ -41,6 +41,10 @@ function unwrapSwiftExpression(node: SyntaxNode): SyntaxNode {
|
||||
return node;
|
||||
}
|
||||
|
||||
function swiftNavigationSuffixName(node: SyntaxNode | null): string | undefined {
|
||||
return node?.type === 'navigation_suffix' ? node.lastNamedChild?.text : node?.text;
|
||||
}
|
||||
|
||||
/** Swift: let x: Foo = ... */
|
||||
const extractDeclaration: TypeBindingExtractor = (
|
||||
node: SyntaxNode,
|
||||
@@ -119,8 +123,10 @@ const extractInitializer: InitializerExtractor = (
|
||||
// Explicit init: User.init(name: "alice") — navigation_expression with .init suffix
|
||||
if (callee.type === 'navigation_expression') {
|
||||
const receiver = callee.firstNamedChild;
|
||||
const suffix = callee.lastNamedChild;
|
||||
if (receiver?.type === 'simple_identifier' && suffix?.text === 'init') {
|
||||
if (
|
||||
receiver?.type === 'simple_identifier' &&
|
||||
swiftNavigationSuffixName(callee.lastNamedChild) === 'init'
|
||||
) {
|
||||
const calleeName = receiver.text;
|
||||
if (calleeName && classNames.has(calleeName)) {
|
||||
env.set(varName, calleeName);
|
||||
@@ -133,7 +139,7 @@ const extractInitializer: InitializerExtractor = (
|
||||
const scanConstructorBinding: ConstructorBindingScanner = (node) => {
|
||||
if (node.type !== 'property_declaration') return undefined;
|
||||
if (hasTypeAnnotation(node)) return undefined;
|
||||
const pattern = node.childForFieldName('pattern');
|
||||
const pattern = node.childForFieldName('pattern') ?? findChild(node, 'pattern');
|
||||
if (!pattern) return undefined;
|
||||
const varName = pattern.text;
|
||||
if (!varName) return undefined;
|
||||
@@ -162,7 +168,7 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
|
||||
if (callee.type === 'navigation_expression') {
|
||||
const receiver = callee.firstNamedChild;
|
||||
const suffix = callee.lastNamedChild;
|
||||
if (receiver?.type === 'simple_identifier' && suffix?.text === 'init') {
|
||||
if (receiver?.type === 'simple_identifier' && swiftNavigationSuffixName(suffix) === 'init') {
|
||||
return { varName, calleeName: receiver.text };
|
||||
}
|
||||
// General qualified call: service.getUser() → extract method name.
|
||||
|
||||
@@ -166,6 +166,21 @@ export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
|
||||
companion_object: 'Class',
|
||||
};
|
||||
|
||||
/** Return the first matching ancestor unless a boundary ancestor is reached first. */
|
||||
export function findAncestorBeforeBoundary(
|
||||
node: SyntaxNode,
|
||||
targetTypes: ReadonlySet<string>,
|
||||
boundaryTypes: ReadonlySet<string>,
|
||||
): SyntaxNode | null {
|
||||
let current = node.parent;
|
||||
while (current !== null) {
|
||||
if (boundaryTypes.has(current.type)) return null;
|
||||
if (targetTypes.has(current.type)) return current;
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the graph node label from a tree-sitter capture map.
|
||||
* Handles language-specific reclassification via the provider's labelOverride hook
|
||||
@@ -414,10 +429,24 @@ export const findSiblingChild = (
|
||||
|
||||
/** Generic name extraction from a function-like AST node.
|
||||
* Tries `node.childForFieldName('name')?.text`, then scans children for
|
||||
* `identifier` / `property_identifier` / `simple_identifier`. */
|
||||
* `identifier` / `property_identifier` / `simple_identifier`.
|
||||
*
|
||||
* `arrow_function` and `function_expression` (TS/JS) are inherently
|
||||
* anonymous — they have no `name` field, and their first identifier
|
||||
* child is a *parameter*, not a function name. Returning a parameter
|
||||
* identifier here would synthesize phantom Function IDs (e.g. callers
|
||||
* walking up from a call inside `arr.map(x => fn(x))` would get
|
||||
* attributed to a non-existent "Function x"). The language's
|
||||
* `methodExtractor.extractFunctionName` hook is responsible for naming
|
||||
* these via parent context (variable_declarator, pair, etc.); when it
|
||||
* declines, the parent walk should continue rather than fall through
|
||||
* here. See issue #1166. */
|
||||
export const genericFuncName = (node: SyntaxNode): string | null => {
|
||||
const nameField = node.childForFieldName?.('name');
|
||||
if (nameField) return nameField.text;
|
||||
if (node.type === 'arrow_function' || node.type === 'function_expression') {
|
||||
return null;
|
||||
}
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (
|
||||
|
||||
@@ -214,6 +214,7 @@ export interface ExtractedToolDef {
|
||||
toolName: string;
|
||||
description: string;
|
||||
lineNumber: number;
|
||||
handlerNodeId?: string;
|
||||
}
|
||||
|
||||
export interface ExtractedORMQuery {
|
||||
@@ -762,7 +763,7 @@ const processBatch = (
|
||||
|
||||
let totalProcessed = 0;
|
||||
let lastReported = 0;
|
||||
const PROGRESS_INTERVAL = 100; // report every 100 files
|
||||
const PROGRESS_INTERVAL = Math.max(1, Math.min(100, Math.ceil(files.length / 10)));
|
||||
|
||||
const onFileProcessed = onProgress
|
||||
? () => {
|
||||
@@ -828,6 +829,10 @@ const processBatch = (
|
||||
}
|
||||
}
|
||||
|
||||
if (onProgress && totalProcessed !== lastReported) {
|
||||
onProgress(totalProcessed);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -1436,10 +1441,16 @@ const processFileGroup = (
|
||||
// Runs BEFORE legacy extraction and its result is independent: a
|
||||
// failure here is caught inside `extractParsedFile` and does NOT
|
||||
// affect the legacy DAG path that follows.
|
||||
const parsedFile = extractParsedFile(provider, parseContent, file.path, (message) => {
|
||||
if (parentPort) parentPort.postMessage({ type: 'warning', message });
|
||||
else console.warn(message);
|
||||
});
|
||||
const parsedFile = extractParsedFile(
|
||||
provider,
|
||||
parseContent,
|
||||
file.path,
|
||||
(message) => {
|
||||
if (parentPort) parentPort.postMessage({ type: 'warning', message });
|
||||
else console.warn(message);
|
||||
},
|
||||
tree,
|
||||
);
|
||||
if (parsedFile !== undefined) result.parsedFiles.push(parsedFile);
|
||||
|
||||
// Pre-pass: extract heritage from query matches to build parentMap for buildTypeEnv.
|
||||
@@ -2073,10 +2084,13 @@ const processFileGroup = (
|
||||
}
|
||||
}
|
||||
|
||||
// Append #<paramCount> to Method/Constructor IDs to disambiguate overloads.
|
||||
// Functions are not suffixed — they don't overload by name in the same scope.
|
||||
// Append #<paramCount> to owned callable IDs to disambiguate overloads.
|
||||
// Top-level Function IDs stay stable; functions inside an owner may overload.
|
||||
// When same-arity collisions exist, append ~type1,type2 for further disambiguation.
|
||||
const needsAritySuffix = nodeLabel === 'Method' || nodeLabel === 'Constructor';
|
||||
const needsAritySuffix =
|
||||
nodeLabel === 'Method' ||
|
||||
nodeLabel === 'Constructor' ||
|
||||
(nodeLabel === 'Function' && enclosingClassId !== null);
|
||||
let arityTag = needsAritySuffix && arityForId !== undefined ? `#${arityForId}` : '';
|
||||
if (arityTag && defMethodMap && defMethodInfo) {
|
||||
const groups = buildCollisionGroups(defMethodMap);
|
||||
@@ -2145,8 +2159,9 @@ const processFileGroup = (
|
||||
result.toolDefs.push({
|
||||
filePath: file.path,
|
||||
toolName: nodeName,
|
||||
description: dec.arg || '',
|
||||
description: (dec.arg || description || '').slice(0, 200),
|
||||
lineNumber: definitionNode.startPosition.row + lineOffset,
|
||||
handlerNodeId: nodeId,
|
||||
});
|
||||
}
|
||||
fileDecorators.delete(checkLine);
|
||||
|
||||
@@ -5,9 +5,8 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
export interface WorkerPool {
|
||||
/**
|
||||
* Dispatch items across workers. Items are split into chunks (one per worker),
|
||||
* each worker processes its chunk via sub-batches to limit peak memory,
|
||||
* and results are concatenated back in order.
|
||||
* Dispatch items across workers. Items are split into bounded jobs, each job
|
||||
* is committed independently, and stalled jobs are split/retried locally.
|
||||
*/
|
||||
dispatch<TInput, TResult>(
|
||||
items: TInput[],
|
||||
@@ -21,6 +20,14 @@ export interface WorkerPool {
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
export interface WorkerPoolOptions {
|
||||
subBatchSize?: number;
|
||||
subBatchMaxBytes?: number;
|
||||
subBatchIdleTimeoutMs?: number;
|
||||
maxTimeoutRetries?: number;
|
||||
timeoutBackoffFactor?: number;
|
||||
}
|
||||
|
||||
/** Message shapes sent back by worker threads. */
|
||||
type WorkerOutgoingMessage =
|
||||
| { type: 'progress'; filesProcessed: number }
|
||||
@@ -29,20 +36,122 @@ type WorkerOutgoingMessage =
|
||||
| { type: 'error'; error: string }
|
||||
| { type: 'result'; data: unknown };
|
||||
|
||||
interface WorkerJob<TInput> {
|
||||
startIndex: number;
|
||||
items: TInput[];
|
||||
estimatedBytes: number;
|
||||
attempt: number;
|
||||
splitDepth: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
interface WorkerJobResult<TResult> {
|
||||
startIndex: number;
|
||||
data: TResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Max files to send to a worker in a single postMessage.
|
||||
* Keeps structured-clone memory bounded per sub-batch.
|
||||
*/
|
||||
const SUB_BATCH_SIZE = 1500;
|
||||
const SUB_BATCH_MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** Per sub-batch timeout. If a single sub-batch takes longer than this,
|
||||
* likely a pathological file (e.g. minified 50MB JS). Fail fast. */
|
||||
const SUB_BATCH_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_SUB_BATCH_IDLE_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_TIMEOUT_RETRIES = 1;
|
||||
const DEFAULT_TIMEOUT_BACKOFF_FACTOR = 2;
|
||||
|
||||
function positiveInteger(value: unknown): number | undefined {
|
||||
const parsed = typeof value === 'string' ? Number(value) : value;
|
||||
return typeof parsed === 'number' && Number.isFinite(parsed) && parsed > 0
|
||||
? Math.floor(parsed)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown): number | undefined {
|
||||
const parsed = typeof value === 'string' ? Number(value) : value;
|
||||
return typeof parsed === 'number' && Number.isFinite(parsed) && parsed >= 0
|
||||
? Math.floor(parsed)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveWorkerPoolOptions(
|
||||
options: WorkerPoolOptions = {},
|
||||
): Required<WorkerPoolOptions> {
|
||||
return {
|
||||
subBatchSize: positiveInteger(options.subBatchSize) ?? SUB_BATCH_SIZE,
|
||||
subBatchMaxBytes:
|
||||
positiveInteger(options.subBatchMaxBytes) ??
|
||||
positiveInteger(process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES) ??
|
||||
SUB_BATCH_MAX_BYTES,
|
||||
subBatchIdleTimeoutMs:
|
||||
positiveInteger(options.subBatchIdleTimeoutMs) ??
|
||||
positiveInteger(process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS) ??
|
||||
DEFAULT_SUB_BATCH_IDLE_TIMEOUT_MS,
|
||||
maxTimeoutRetries: nonNegativeInteger(options.maxTimeoutRetries) ?? DEFAULT_TIMEOUT_RETRIES,
|
||||
timeoutBackoffFactor:
|
||||
positiveInteger(options.timeoutBackoffFactor) ?? DEFAULT_TIMEOUT_BACKOFF_FACTOR,
|
||||
};
|
||||
}
|
||||
|
||||
function estimateItemBytes(item: unknown): number {
|
||||
if (typeof item !== 'object' || item === null) return 0;
|
||||
const content = (item as { content?: unknown }).content;
|
||||
return typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : 0;
|
||||
}
|
||||
|
||||
function itemPath(item: unknown): string | undefined {
|
||||
if (typeof item !== 'object' || item === null) return undefined;
|
||||
const path = (item as { path?: unknown }).path;
|
||||
return typeof path === 'string' ? path : undefined;
|
||||
}
|
||||
|
||||
function createJobs<TInput>(
|
||||
items: TInput[],
|
||||
maxItems: number,
|
||||
maxBytes: number,
|
||||
timeoutMs: number,
|
||||
): WorkerJob<TInput>[] {
|
||||
const jobs: WorkerJob<TInput>[] = [];
|
||||
let startIndex = 0;
|
||||
let batch: TInput[] = [];
|
||||
let batchBytes = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (batch.length === 0) return;
|
||||
jobs.push({
|
||||
startIndex,
|
||||
items: batch,
|
||||
estimatedBytes: batchBytes,
|
||||
attempt: 0,
|
||||
splitDepth: 0,
|
||||
timeoutMs,
|
||||
});
|
||||
startIndex += batch.length;
|
||||
batch = [];
|
||||
batchBytes = 0;
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const itemBytes = estimateItemBytes(item);
|
||||
const wouldExceedItems = batch.length >= maxItems;
|
||||
const wouldExceedBytes = batch.length > 0 && batchBytes + itemBytes > maxBytes;
|
||||
if (wouldExceedItems || wouldExceedBytes) flush();
|
||||
batch.push(item);
|
||||
batchBytes += itemBytes;
|
||||
}
|
||||
flush();
|
||||
return jobs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pool of worker threads.
|
||||
*/
|
||||
export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool => {
|
||||
export const createWorkerPool = (
|
||||
workerUrl: URL,
|
||||
poolSize?: number,
|
||||
options?: WorkerPoolOptions,
|
||||
): WorkerPool => {
|
||||
// Validate worker script exists before spawning to prevent uncaught
|
||||
// MODULE_NOT_FOUND crashes in worker threads (e.g. when running from src/ via vitest)
|
||||
const workerPath = fileURLToPath(workerUrl);
|
||||
@@ -51,7 +160,10 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
|
||||
}
|
||||
|
||||
const size = poolSize ?? Math.min(8, Math.max(1, os.cpus().length - 1));
|
||||
const poolOptions = resolveWorkerPoolOptions(options);
|
||||
const workers: Worker[] = [];
|
||||
let poolBroken = false;
|
||||
let poolFailure: Error | undefined;
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
workers.push(new Worker(workerUrl));
|
||||
@@ -61,78 +173,209 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
|
||||
items: TInput[],
|
||||
onProgress?: (filesProcessed: number) => void,
|
||||
): Promise<TResult[]> => {
|
||||
if (items.length === 0) return Promise.resolve([]);
|
||||
|
||||
const chunkSize = Math.ceil(items.length / size);
|
||||
const chunks: TInput[][] = [];
|
||||
for (let i = 0; i < items.length; i += chunkSize) {
|
||||
chunks.push(items.slice(i, i + chunkSize));
|
||||
if (poolBroken) {
|
||||
const reason = poolFailure ? `: ${poolFailure.message}` : '';
|
||||
return Promise.reject(
|
||||
new Error(`Worker pool is unavailable after a previous failure${reason}`),
|
||||
);
|
||||
}
|
||||
if (items.length === 0) return Promise.resolve([]);
|
||||
if (workers.length === 0) return Promise.reject(new Error('Worker pool has no active workers'));
|
||||
|
||||
const workerProgress = new Array(chunks.length).fill(0);
|
||||
const jobs = createJobs(
|
||||
items,
|
||||
poolOptions.subBatchSize,
|
||||
poolOptions.subBatchMaxBytes,
|
||||
poolOptions.subBatchIdleTimeoutMs,
|
||||
);
|
||||
|
||||
const promises = chunks.map((chunk, i) => {
|
||||
const worker = workers[i];
|
||||
return new Promise<TResult>((resolve, reject) => {
|
||||
return new Promise<TResult[]>((resolve, reject) => {
|
||||
const results: WorkerJobResult<TResult>[] = [];
|
||||
const inFlightProgress = new Array(workers.length).fill(0);
|
||||
let completedFiles = 0;
|
||||
let activeWorkers = 0;
|
||||
let stopped = false;
|
||||
let maxReported = 0;
|
||||
|
||||
const reportProgress = () => {
|
||||
if (!onProgress) return;
|
||||
const inFlight = inFlightProgress.reduce((sum, value) => sum + value, 0);
|
||||
const next = Math.min(items.length, Math.max(maxReported, completedFiles + inFlight));
|
||||
if (next === maxReported) return;
|
||||
maxReported = next;
|
||||
onProgress(next);
|
||||
};
|
||||
|
||||
const replaceWorker = async (workerIndex: number) => {
|
||||
const worker = workers[workerIndex];
|
||||
await worker?.terminate().catch(() => undefined);
|
||||
if (!stopped) workers[workerIndex] = new Worker(workerUrl);
|
||||
};
|
||||
|
||||
const fail = async (err: Error) => {
|
||||
poolBroken = true;
|
||||
poolFailure = err;
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
await Promise.all(workers.map((worker) => worker.terminate().catch(() => undefined)));
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const maybeDone = () => {
|
||||
if (stopped) return;
|
||||
if (jobs.length === 0 && activeWorkers === 0) {
|
||||
stopped = true;
|
||||
results.sort((a, b) => a.startIndex - b.startIndex);
|
||||
if (onProgress && maxReported < items.length) onProgress(items.length);
|
||||
resolve(results.map((result) => result.data));
|
||||
}
|
||||
};
|
||||
|
||||
const requeueAfterTimeout = (
|
||||
workerIndex: number,
|
||||
job: WorkerJob<TInput>,
|
||||
lastProgress: number,
|
||||
): boolean => {
|
||||
const nextTimeout = Math.ceil(job.timeoutMs * poolOptions.timeoutBackoffFactor);
|
||||
|
||||
if (job.items.length > 1) {
|
||||
const midpoint = Math.ceil(job.items.length / 2);
|
||||
const firstItems = job.items.slice(0, midpoint);
|
||||
const secondItems = job.items.slice(midpoint);
|
||||
const first: WorkerJob<TInput> = {
|
||||
startIndex: job.startIndex,
|
||||
items: firstItems,
|
||||
estimatedBytes: firstItems.reduce((sum, item) => sum + estimateItemBytes(item), 0),
|
||||
attempt: job.attempt,
|
||||
splitDepth: job.splitDepth + 1,
|
||||
timeoutMs: nextTimeout,
|
||||
};
|
||||
const second: WorkerJob<TInput> = {
|
||||
startIndex: job.startIndex + midpoint,
|
||||
items: secondItems,
|
||||
estimatedBytes: secondItems.reduce((sum, item) => sum + estimateItemBytes(item), 0),
|
||||
attempt: job.attempt,
|
||||
splitDepth: job.splitDepth + 1,
|
||||
timeoutMs: nextTimeout,
|
||||
};
|
||||
console.warn(
|
||||
`Worker ${workerIndex} parse job idle timeout after ${job.timeoutMs / 1000}s ` +
|
||||
`(${job.items.length} items, ${job.estimatedBytes} bytes, last progress: ${lastProgress}). ` +
|
||||
`Splitting into ${first.items.length}/${second.items.length} item jobs with ` +
|
||||
`${nextTimeout / 1000}s timeout.`,
|
||||
);
|
||||
// Preserve intuitive retry order; final result order is still enforced by startIndex sort.
|
||||
jobs.unshift(first, second);
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextAttempt = job.attempt + 1;
|
||||
if (nextAttempt <= poolOptions.maxTimeoutRetries) {
|
||||
console.warn(
|
||||
`Worker ${workerIndex} parse job idle timeout after ${job.timeoutMs / 1000}s ` +
|
||||
`(single item, attempt ${nextAttempt}/${poolOptions.maxTimeoutRetries + 1}). ` +
|
||||
`Retrying with ${nextTimeout / 1000}s timeout.`,
|
||||
);
|
||||
jobs.unshift({
|
||||
...job,
|
||||
attempt: nextAttempt,
|
||||
timeoutMs: nextTimeout,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void fail(
|
||||
new Error(
|
||||
`Worker ${workerIndex} parse job idle timeout after ${job.timeoutMs / 1000}s ` +
|
||||
`(single item${itemPath(job.items[0]) ? `: ${itemPath(job.items[0])}` : ''}, ` +
|
||||
`${job.estimatedBytes} bytes, last progress: ${lastProgress}). ` +
|
||||
`Analyze will retry through sequential fallback. Increase with ` +
|
||||
`--worker-timeout or GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS.`,
|
||||
),
|
||||
);
|
||||
return false;
|
||||
};
|
||||
|
||||
const runWorker = (workerIndex: number) => {
|
||||
if (stopped) return;
|
||||
const job = jobs.shift();
|
||||
if (!job) {
|
||||
maybeDone();
|
||||
return;
|
||||
}
|
||||
|
||||
activeWorkers++;
|
||||
inFlightProgress[workerIndex] = 0;
|
||||
const worker = workers[workerIndex];
|
||||
let settled = false;
|
||||
let subBatchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let waitingForFlush = false;
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastProgress = 0;
|
||||
|
||||
const cleanup = () => {
|
||||
if (subBatchTimer) clearTimeout(subBatchTimer);
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
worker.removeListener('message', handler);
|
||||
worker.removeListener('error', errorHandler);
|
||||
worker.removeListener('exit', exitHandler);
|
||||
};
|
||||
|
||||
const resetSubBatchTimer = () => {
|
||||
if (subBatchTimer) clearTimeout(subBatchTimer);
|
||||
subBatchTimer = setTimeout(() => {
|
||||
const finishJob = () => {
|
||||
activeWorkers--;
|
||||
inFlightProgress[workerIndex] = 0;
|
||||
runWorker(workerIndex);
|
||||
maybeDone();
|
||||
};
|
||||
|
||||
const resetIdleTimer = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(async () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
`Worker ${i} sub-batch timed out after ${SUB_BATCH_TIMEOUT_MS / 1000}s (chunk: ${chunk.length} items).`,
|
||||
),
|
||||
);
|
||||
activeWorkers--;
|
||||
inFlightProgress[workerIndex] = 0;
|
||||
const shouldContinue = requeueAfterTimeout(workerIndex, job, lastProgress);
|
||||
if (!shouldContinue) return;
|
||||
await replaceWorker(workerIndex);
|
||||
reportProgress();
|
||||
runWorker(workerIndex);
|
||||
maybeDone();
|
||||
}
|
||||
}, SUB_BATCH_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
let subBatchIdx = 0;
|
||||
|
||||
const sendNextSubBatch = () => {
|
||||
const start = subBatchIdx * SUB_BATCH_SIZE;
|
||||
if (start >= chunk.length) {
|
||||
worker.postMessage({ type: 'flush' });
|
||||
return;
|
||||
}
|
||||
const subBatch = chunk.slice(start, start + SUB_BATCH_SIZE);
|
||||
subBatchIdx++;
|
||||
resetSubBatchTimer();
|
||||
worker.postMessage({ type: 'sub-batch', files: subBatch });
|
||||
}, job.timeoutMs);
|
||||
};
|
||||
|
||||
const handler = (msg: WorkerOutgoingMessage) => {
|
||||
if (settled) return;
|
||||
if (settled || stopped) return;
|
||||
if (msg.type === 'progress') {
|
||||
workerProgress[i] = msg.filesProcessed;
|
||||
if (onProgress) {
|
||||
const total = workerProgress.reduce((a, b) => a + b, 0);
|
||||
onProgress(total);
|
||||
}
|
||||
const bounded = Math.min(job.items.length, Math.max(0, msg.filesProcessed));
|
||||
inFlightProgress[workerIndex] = bounded;
|
||||
lastProgress = bounded;
|
||||
resetIdleTimer();
|
||||
reportProgress();
|
||||
} else if (msg.type === 'warning') {
|
||||
resetIdleTimer();
|
||||
console.warn(msg.message);
|
||||
} else if (msg.type === 'sub-batch-done') {
|
||||
sendNextSubBatch();
|
||||
waitingForFlush = true;
|
||||
resetIdleTimer();
|
||||
worker.postMessage({ type: 'flush' });
|
||||
} else if (msg.type === 'error') {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Worker ${i} error: ${msg.error}`));
|
||||
void fail(new Error(`Worker ${workerIndex} error: ${msg.error}`));
|
||||
} else if (msg.type === 'result') {
|
||||
if (!waitingForFlush) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
void fail(new Error(`Worker ${workerIndex} protocol error: result before flush`));
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(msg.data as TResult);
|
||||
results.push({ startIndex: job.startIndex, data: msg.data as TResult });
|
||||
completedFiles += job.items.length;
|
||||
reportProgress();
|
||||
finishJob();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -140,7 +383,7 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
void fail(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -148,9 +391,9 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(
|
||||
void fail(
|
||||
new Error(
|
||||
`Worker ${i} exited with code ${code}. Likely OOM or native addon failure.`,
|
||||
`Worker ${workerIndex} exited with code ${code}. Likely OOM or native addon failure.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -159,11 +402,16 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
|
||||
worker.on('message', handler);
|
||||
worker.once('error', errorHandler);
|
||||
worker.once('exit', exitHandler);
|
||||
sendNextSubBatch();
|
||||
});
|
||||
});
|
||||
resetIdleTimer();
|
||||
if (stopped) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
worker.postMessage({ type: 'sub-batch', files: job.items });
|
||||
};
|
||||
|
||||
return Promise.all(promises);
|
||||
for (let i = 0; i < workers.length; i++) runWorker(i);
|
||||
});
|
||||
};
|
||||
|
||||
const terminate = async (): Promise<void> => {
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { LBUG_MAX_DB_SIZE } from './lbug-config.js';
|
||||
|
||||
const DEFAULT_EXTENSION_INSTALL_TIMEOUT_MS = 15_000;
|
||||
const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
|
||||
|
||||
/**
|
||||
* Lifecycle policy for an optional DuckDB extension.
|
||||
*
|
||||
* - `auto` — try `LOAD`, fall back to one bounded out-of-process `INSTALL`
|
||||
* attempt per process if `LOAD` fails. Default for analyze.
|
||||
* - `load-only`— try `LOAD` only; never spawn an installer. Used by serve/MCP
|
||||
* read paths so user queries never block on a network install.
|
||||
* - `never` — skip the extension entirely. Operators can use this to
|
||||
* forcibly disable optional search features.
|
||||
*/
|
||||
export type ExtensionInstallPolicy = 'auto' | 'load-only' | 'never';
|
||||
|
||||
export interface ExtensionInstallResult {
|
||||
success: boolean;
|
||||
timedOut: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Snapshot of one optional extension's resolved capability state. */
|
||||
export interface ExtensionCapability {
|
||||
name: string;
|
||||
loaded: boolean;
|
||||
/** Human-readable reason when `loaded` is false. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Per-call overrides applied on top of `ExtensionManager` defaults. */
|
||||
export interface ExtensionEnsureOptions {
|
||||
policy?: ExtensionInstallPolicy;
|
||||
installTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ExtensionManagerOptions {
|
||||
policy?: ExtensionInstallPolicy;
|
||||
installTimeoutMs?: number;
|
||||
installExtension?: (extensionName: string, timeoutMs: number) => Promise<ExtensionInstallResult>;
|
||||
warn?: (message: string) => void;
|
||||
}
|
||||
|
||||
const alreadyAvailable = (message: string): boolean =>
|
||||
message.includes('already loaded') ||
|
||||
message.includes('already installed') ||
|
||||
message.includes('already exists');
|
||||
|
||||
const resolvePolicyFromEnv = (): ExtensionInstallPolicy => {
|
||||
const raw = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
|
||||
if (raw === 'load-only' || raw === 'never' || raw === 'auto') return raw;
|
||||
return 'auto';
|
||||
};
|
||||
|
||||
export const getExtensionInstallTimeoutMs = (): number => {
|
||||
const raw = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS;
|
||||
const parsed = raw ? Number(raw) : NaN;
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_EXTENSION_INSTALL_TIMEOUT_MS;
|
||||
};
|
||||
|
||||
export const getExtensionInstallChildProcessArgs = (
|
||||
extensionName: string,
|
||||
maxDbSize: number = LBUG_MAX_DB_SIZE,
|
||||
): string[] => {
|
||||
const childScript = new URL('../../../scripts/install-duckdb-extension.mjs', import.meta.url);
|
||||
return [fileURLToPath(childScript), extensionName, String(maxDbSize)];
|
||||
};
|
||||
|
||||
/**
|
||||
* Run `INSTALL <extension>` in a short-lived child Node process so the parent
|
||||
* event loop is never blocked by DuckDB's synchronous network call.
|
||||
*
|
||||
* The child opens its own scratch LadybugDB, executes the install, and exits.
|
||||
* If the child exceeds `timeoutMs` the parent kills it with SIGKILL and
|
||||
* resolves with `timedOut: true`.
|
||||
*/
|
||||
export const installDuckDbExtensionOutOfProcess = async (
|
||||
extensionName: string,
|
||||
timeoutMs: number = getExtensionInstallTimeoutMs(),
|
||||
): Promise<ExtensionInstallResult> => {
|
||||
if (!EXTENSION_NAME_PATTERN.test(extensionName)) {
|
||||
throw new Error(`Invalid DuckDB extension name: ${extensionName}`);
|
||||
}
|
||||
|
||||
return await new Promise<ExtensionInstallResult>((resolve) => {
|
||||
const child = spawn(process.execPath, getExtensionInstallChildProcessArgs(extensionName), {
|
||||
env: {
|
||||
...process.env,
|
||||
GITNEXUS_LBUG_EXTENSION_NAME: extensionName,
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
child.stderr?.setEncoding('utf8');
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr = (stderr + chunk).slice(-4000);
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill('SIGKILL');
|
||||
resolve({
|
||||
success: false,
|
||||
timedOut: true,
|
||||
message: `INSTALL ${extensionName} timed out after ${timeoutMs}ms`,
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ success: false, timedOut: false, message: err.message });
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
success: code === 0,
|
||||
timedOut: false,
|
||||
message:
|
||||
code === 0
|
||||
? `INSTALL ${extensionName} completed`
|
||||
: `INSTALL ${extensionName} failed with ${signal ?? `exit code ${code}`}${stderr ? `: ${stderr.trim()}` : ''}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Centralized lifecycle manager for optional LadybugDB extensions.
|
||||
*
|
||||
* Always tries `LOAD EXTENSION <name>` first — it is per-connection,
|
||||
* idempotent, and never touches the network. If `LOAD` fails and the active
|
||||
* policy permits, the manager runs a single bounded out-of-process `INSTALL`
|
||||
* attempt per process and retries `LOAD`. Capability outcomes are cached so
|
||||
* unavailable extensions degrade search features without ever blocking
|
||||
* subsequent analyze or query calls.
|
||||
*
|
||||
* Policy precedence (most specific wins):
|
||||
* per-call `opts.policy` → constructor `options.policy` → env → `auto`
|
||||
*/
|
||||
export class ExtensionManager {
|
||||
private readonly capabilities = new Map<string, ExtensionCapability>();
|
||||
private readonly installAttempted = new Map<string, ExtensionInstallResult>();
|
||||
private readonly warnedKeys = new Set<string>();
|
||||
|
||||
constructor(private readonly options: ExtensionManagerOptions = {}) {}
|
||||
|
||||
/** Reset cached capability and install state. Test-only. */
|
||||
reset(): void {
|
||||
this.capabilities.clear();
|
||||
this.installAttempted.clear();
|
||||
this.warnedKeys.clear();
|
||||
}
|
||||
|
||||
/** Snapshot of currently-known optional extension capabilities. */
|
||||
getCapabilities(): ExtensionCapability[] {
|
||||
return Array.from(this.capabilities.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an optional extension is loaded on the supplied connection.
|
||||
*
|
||||
* Returns `true` when the extension is usable on `query`, `false` when it
|
||||
* is unavailable. Never throws on install failure — analyze and query
|
||||
* paths are expected to degrade gracefully.
|
||||
*/
|
||||
async ensure(
|
||||
query: (sql: string) => Promise<unknown>,
|
||||
name: string,
|
||||
label: string,
|
||||
opts: ExtensionEnsureOptions = {},
|
||||
): Promise<boolean> {
|
||||
if (!EXTENSION_NAME_PATTERN.test(name)) {
|
||||
throw new Error(`Invalid DuckDB extension name: ${name}`);
|
||||
}
|
||||
|
||||
const policy = opts.policy ?? this.options.policy ?? resolvePolicyFromEnv();
|
||||
const timeoutMs =
|
||||
opts.installTimeoutMs ?? this.options.installTimeoutMs ?? getExtensionInstallTimeoutMs();
|
||||
const warn = this.options.warn ?? console.warn;
|
||||
|
||||
if (policy === 'never') {
|
||||
this.markUnavailable(name, label, 'extension install policy is "never"', warn);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await this.tryLoad(query, name)) {
|
||||
this.markLoaded(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (policy === 'load-only') {
|
||||
this.markUnavailable(name, label, 'load-only policy: extension not pre-installed', warn);
|
||||
return false;
|
||||
}
|
||||
|
||||
let install = this.installAttempted.get(name);
|
||||
if (!install) {
|
||||
const installFn = this.options.installExtension ?? installDuckDbExtensionOutOfProcess;
|
||||
install = await installFn(name, timeoutMs);
|
||||
this.installAttempted.set(name, install);
|
||||
}
|
||||
|
||||
if (!install.success) {
|
||||
this.markUnavailable(name, label, install.message, warn);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await this.tryLoad(query, name)) {
|
||||
this.markLoaded(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
this.markUnavailable(name, label, `LOAD ${name} failed after successful INSTALL`, warn);
|
||||
return false;
|
||||
}
|
||||
|
||||
private async tryLoad(query: (sql: string) => Promise<unknown>, name: string): Promise<boolean> {
|
||||
try {
|
||||
await query(`LOAD EXTENSION ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return alreadyAvailable(msg);
|
||||
}
|
||||
}
|
||||
|
||||
private markLoaded(name: string): void {
|
||||
this.capabilities.set(name, { name, loaded: true });
|
||||
}
|
||||
|
||||
private markUnavailable(
|
||||
name: string,
|
||||
label: string,
|
||||
reason: string,
|
||||
warn: (message: string) => void,
|
||||
): void {
|
||||
this.capabilities.set(name, { name, loaded: false, reason });
|
||||
const key = `${name}:${reason}`;
|
||||
if (this.warnedKeys.has(key)) return;
|
||||
this.warnedKeys.add(key);
|
||||
warn(
|
||||
`GitNexus: ${label} extension unavailable; continuing without ${label} features. ${reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-wide singleton shared by core and pool adapters. */
|
||||
export const extensionManager = new ExtensionManager();
|
||||
|
||||
/** Snapshot of which optional DuckDB extensions are loaded in this process. */
|
||||
export const getExtensionCapabilities = (): ExtensionCapability[] =>
|
||||
extensionManager.getCapabilities();
|
||||
|
||||
/** Test-only: clear the singleton's cached capability and install state. */
|
||||
export const resetExtensionState = (): void => extensionManager.reset();
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
} from './schema.js';
|
||||
import { streamAllCSVsToDisk } from './csv-generator.js';
|
||||
import type { CachedEmbedding } from '../embeddings/types.js';
|
||||
import { extensionManager, type ExtensionEnsureOptions } from './extension-loader.js';
|
||||
import {
|
||||
closeLbugConnection,
|
||||
openLbugConnection,
|
||||
type LbugConnectionHandle,
|
||||
} from './lbug-config.js';
|
||||
import { isVectorExtensionSupportedByPlatform } from '../platform/capabilities.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Relationship CSV splitting — extracted for testability (PR #818)
|
||||
@@ -145,17 +152,16 @@ let ftsLoaded = false;
|
||||
let vectorExtensionLoaded = false;
|
||||
|
||||
/**
|
||||
* In-process cache of FTS indexes that have been ensured against the current
|
||||
* connection. Prevents repeated `CALL CREATE_FTS_INDEX` round-trips inside a
|
||||
* single CLI/MCP session — the first call to `ensureFTSIndex` for a given
|
||||
* `(tableName, indexName)` pays the LadybugDB cost (~440 ms even when the
|
||||
* index already exists on disk), subsequent calls are a Set lookup. Cleared
|
||||
* by `closeLbug` so a re-init starts fresh.
|
||||
* In-process cache of FTS indexes observed against the current singleton
|
||||
* connection. Avoids repeated `CALL CREATE_FTS_INDEX` calls, which can trip
|
||||
* native duplicate-index/WAL edge cases. Cleared on re-init and close.
|
||||
*
|
||||
* Key format: `${tableName}:${indexName}`.
|
||||
*/
|
||||
const ensuredFTSIndexes = new Set<string>();
|
||||
|
||||
const ftsIndexKey = (tableName: string, indexName: string): string => `${tableName}:${indexName}`;
|
||||
|
||||
/**
|
||||
* Check if an error indicates a missing column or table (schema-level problem)
|
||||
* rather than a transient/connection error. Used for legacy DB fallback logic.
|
||||
@@ -193,6 +199,19 @@ export const isDbBusyError = (err: unknown): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return true when the error message indicates a write was attempted against
|
||||
* a read-only LadybugDB connection. The MCP query pool opens DBs read-only,
|
||||
* so any path that calls a `CREATE_*` procedure there will surface this
|
||||
* (e.g. defensive `ensureFTSIndex` calls). Owners of the writable analyze
|
||||
* path should ignore this error — index creation is owned by `gitnexus
|
||||
* analyze` and either already happened or will happen on the next run.
|
||||
*/
|
||||
export const isReadOnlyDbError = (err: unknown): boolean => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return /read-only database/i.test(msg);
|
||||
};
|
||||
|
||||
const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const previous = sessionLock;
|
||||
let release: (() => void) | null = null;
|
||||
@@ -253,6 +272,7 @@ export const withLbugDb = async <T>(dbPath: string, operation: () => Promise<T>)
|
||||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
vectorExtensionLoaded = false;
|
||||
ensuredFTSIndexes.clear();
|
||||
});
|
||||
// Sleep outside the lock — no need to block others while waiting
|
||||
await new Promise((resolve) => setTimeout(resolve, DB_LOCK_RETRY_DELAY_MS * attempt));
|
||||
@@ -285,6 +305,7 @@ const doInitLbug = async (dbPath: string) => {
|
||||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
vectorExtensionLoaded = false;
|
||||
ensuredFTSIndexes.clear();
|
||||
}
|
||||
|
||||
// LadybugDB stores the database as a single file (not a directory).
|
||||
@@ -317,14 +338,14 @@ const doInitLbug = async (dbPath: string) => {
|
||||
const parentDir = path.dirname(dbPath);
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
|
||||
db = new lbug.Database(dbPath);
|
||||
conn = new lbug.Connection(db);
|
||||
const opened = await openLbugConnection(lbug, dbPath);
|
||||
db = opened.db;
|
||||
conn = opened.conn;
|
||||
|
||||
for (const schemaQuery of SCHEMA_QUERIES) {
|
||||
try {
|
||||
await conn.query(schemaQuery);
|
||||
} catch (err) {
|
||||
// Only ignore "already exists" errors - log everything else
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes('already exists')) {
|
||||
console.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
|
||||
@@ -332,8 +353,9 @@ const doInitLbug = async (dbPath: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Load VECTOR extension for semantic search support
|
||||
await loadVectorExtension();
|
||||
// FTS powers baseline search, so initialize it with the core DB. VECTOR is
|
||||
// only required for semantic embeddings and is probed lazily there.
|
||||
await loadFTSExtension();
|
||||
|
||||
currentDbPath = dbPath;
|
||||
return { db, conn };
|
||||
@@ -663,18 +685,12 @@ export const insertNodeToLbug = async (
|
||||
|
||||
// Use per-query connection if dbPath provided (avoids lock conflicts)
|
||||
if (targetDbPath) {
|
||||
const tempDb = new lbug.Database(targetDbPath);
|
||||
const tempConn = new lbug.Connection(tempDb);
|
||||
const tempHandle = await openLbugConnection(lbug, targetDbPath);
|
||||
try {
|
||||
await tempConn.query(query);
|
||||
await tempHandle.conn.query(query);
|
||||
return true;
|
||||
} finally {
|
||||
try {
|
||||
await tempConn.close();
|
||||
} catch {}
|
||||
try {
|
||||
await tempDb.close();
|
||||
} catch {}
|
||||
await closeLbugConnection(tempHandle);
|
||||
}
|
||||
} else if (conn) {
|
||||
// Use existing persistent connection (when called from analyze)
|
||||
@@ -710,8 +726,8 @@ export const batchInsertNodesToLbug = async (
|
||||
};
|
||||
|
||||
// Open a single connection for all inserts
|
||||
const tempDb = new lbug.Database(dbPath);
|
||||
const tempConn = new lbug.Connection(tempDb);
|
||||
const tempHandle = await openLbugConnection(lbug, dbPath);
|
||||
const tempConn = tempHandle.conn;
|
||||
|
||||
let inserted = 0;
|
||||
let failed = 0;
|
||||
@@ -752,12 +768,7 @@ export const batchInsertNodesToLbug = async (
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await tempConn.close();
|
||||
} catch {}
|
||||
try {
|
||||
await tempDb.close();
|
||||
} catch {}
|
||||
await closeLbugConnection(tempHandle);
|
||||
}
|
||||
|
||||
return { inserted, failed };
|
||||
@@ -847,8 +858,11 @@ export const executeWithReusedStatement = async (
|
||||
await conn.execute(stmt, params);
|
||||
}
|
||||
} catch (e) {
|
||||
// Log the error and continue with next batch
|
||||
console.warn('Batch execution error:', e);
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
const queryPreview = cypher.replace(/\s+/g, ' ').slice(0, 120);
|
||||
throw new Error(
|
||||
`Batch execution failed for rows ${i + 1}-${i + subBatch.length}: ${msg} (${queryPreview})`,
|
||||
);
|
||||
}
|
||||
// Note: LadybugDB PreparedStatement doesn't require explicit close()
|
||||
}
|
||||
@@ -1067,13 +1081,13 @@ export const deleteNodesForFile = async (
|
||||
const usePerQuery = !!dbPath;
|
||||
|
||||
// Set up connection (either use existing or create per-query)
|
||||
let tempDb: lbug.Database | null = null;
|
||||
let tempHandle: LbugConnectionHandle | null = null;
|
||||
let tempConn: lbug.Connection | null = null;
|
||||
let targetConn: lbug.Connection | null = conn;
|
||||
|
||||
if (usePerQuery) {
|
||||
tempDb = new lbug.Database(dbPath);
|
||||
tempConn = new lbug.Connection(tempDb);
|
||||
tempHandle = await openLbugConnection(lbug, dbPath);
|
||||
tempConn = tempHandle.conn;
|
||||
targetConn = tempConn;
|
||||
} else if (!conn) {
|
||||
throw new Error('LadybugDB not initialized. Provide dbPath or call initLbug first.');
|
||||
@@ -1123,16 +1137,7 @@ export const deleteNodesForFile = async (
|
||||
return { deletedNodes };
|
||||
} finally {
|
||||
// Close per-query connection if used
|
||||
if (tempConn) {
|
||||
try {
|
||||
await tempConn.close();
|
||||
} catch {}
|
||||
}
|
||||
if (tempDb) {
|
||||
try {
|
||||
await tempDb.close();
|
||||
} catch {}
|
||||
}
|
||||
if (tempHandle) await closeLbugConnection(tempHandle);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1143,18 +1148,21 @@ export const getEmbeddingTableName = (): string => EMBEDDING_TABLE_NAME;
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Load the FTS extension (required before using FTS functions).
|
||||
* Load the FTS extension on the supplied connection (or the singleton
|
||||
* writable connection when none is given).
|
||||
*
|
||||
* Safe to call multiple times — when invoked without arguments, tracks loaded
|
||||
* state via module-level `ftsLoaded`. When invoked with an explicit
|
||||
* connection, loads on that connection and returns whether the load
|
||||
* succeeded — letting callers (e.g. the pool adapter) track their own state.
|
||||
*
|
||||
* Tries `LOAD EXTENSION fts` first so previously-cached installs skip the
|
||||
* network entirely; falls back to `INSTALL` + `LOAD` only when the extension
|
||||
* hasn't been cached yet.
|
||||
* Delegates to the shared `ExtensionManager` so install policy (auto /
|
||||
* load-only / never), out-of-process bounded INSTALL, and capability
|
||||
* caching are owned in one place. The module-level `ftsLoaded` flag is
|
||||
* kept purely as a per-call short-circuit on the singleton writable
|
||||
* connection so repeated callers (e.g. createFTSIndex) avoid an extra
|
||||
* `LOAD` round-trip per invocation. Pool adapter callers pass
|
||||
* `{ policy: 'load-only' }` so query paths never block on a network install.
|
||||
*/
|
||||
export const loadFTSExtension = async (targetConn?: lbug.Connection): Promise<boolean> => {
|
||||
export const loadFTSExtension = async (
|
||||
targetConn?: lbug.Connection,
|
||||
opts: ExtensionEnsureOptions = {},
|
||||
): Promise<boolean> => {
|
||||
const useModuleState = targetConn === undefined;
|
||||
if (useModuleState && ftsLoaded) return true;
|
||||
|
||||
@@ -1163,60 +1171,32 @@ export const loadFTSExtension = async (targetConn?: lbug.Connection): Promise<bo
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
}
|
||||
|
||||
const markLoaded = (): true => {
|
||||
if (useModuleState) ftsLoaded = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
// Try loading locally first (no network required)
|
||||
await c.query('LOAD EXTENSION fts');
|
||||
return markLoaded();
|
||||
} catch {
|
||||
// Fall back to install + load (requires network)
|
||||
try {
|
||||
await c.query('INSTALL fts');
|
||||
await c.query('LOAD EXTENSION fts');
|
||||
return markLoaded();
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || '';
|
||||
if (
|
||||
msg.includes('already loaded') ||
|
||||
msg.includes('already installed') ||
|
||||
msg.includes('already exists')
|
||||
) {
|
||||
return markLoaded();
|
||||
}
|
||||
console.error('GitNexus: FTS extension load failed:', msg);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const loaded = await extensionManager.ensure((sql) => c.query(sql), 'fts', 'FTS', opts);
|
||||
if (loaded && useModuleState) ftsLoaded = true;
|
||||
return loaded;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load the VECTOR extension (required before using QUERY_VECTOR_INDEX).
|
||||
* Safe to call multiple times -- tracks loaded state via module-level vectorExtensionLoaded.
|
||||
* Load the VECTOR extension on the supplied connection (or the singleton
|
||||
* writable connection when none is given). Returns false when VECTOR is
|
||||
* unavailable so semantic search can fall back to exact scan.
|
||||
*/
|
||||
export const loadVectorExtension = async (): Promise<void> => {
|
||||
if (vectorExtensionLoaded) return;
|
||||
if (!conn) {
|
||||
export const loadVectorExtension = async (
|
||||
targetConn?: lbug.Connection,
|
||||
opts: ExtensionEnsureOptions = {},
|
||||
): Promise<boolean> => {
|
||||
const useModuleState = targetConn === undefined;
|
||||
if (useModuleState && vectorExtensionLoaded) return true;
|
||||
if (!isVectorExtensionSupportedByPlatform()) return false;
|
||||
|
||||
const c: lbug.Connection | null = targetConn ?? conn;
|
||||
if (!c) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
}
|
||||
try {
|
||||
await conn.query('INSTALL VECTOR');
|
||||
await conn.query('LOAD EXTENSION VECTOR');
|
||||
vectorExtensionLoaded = true;
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || '';
|
||||
if (
|
||||
msg.includes('already loaded') ||
|
||||
msg.includes('already installed') ||
|
||||
msg.includes('already exists')
|
||||
) {
|
||||
vectorExtensionLoaded = true;
|
||||
} else {
|
||||
console.error('GitNexus: VECTOR extension load failed:', msg);
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = await extensionManager.ensure((sql) => c.query(sql), 'VECTOR', 'VECTOR', opts);
|
||||
if (loaded && useModuleState) vectorExtensionLoaded = true;
|
||||
return loaded;
|
||||
};
|
||||
/**
|
||||
* Create a full-text search index on a table
|
||||
@@ -1235,30 +1215,44 @@ export const createFTSIndex = async (
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
}
|
||||
|
||||
await loadFTSExtension();
|
||||
const key = ftsIndexKey(tableName, indexName);
|
||||
if (ensuredFTSIndexes.has(key)) return;
|
||||
|
||||
if (!(await loadFTSExtension())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const propList = properties.map((p) => `'${p}'`).join(', ');
|
||||
const query = `CALL CREATE_FTS_INDEX('${tableName}', '${indexName}', [${propList}], stemmer := '${stemmer}')`;
|
||||
|
||||
try {
|
||||
await conn.query(query);
|
||||
ensuredFTSIndexes.add(key);
|
||||
} catch (e: any) {
|
||||
if (!e.message?.includes('already exists')) {
|
||||
throw e;
|
||||
if (e.message?.includes('already exists')) {
|
||||
ensuredFTSIndexes.add(key);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lazy-create an FTS index, caching the fact in-process.
|
||||
*
|
||||
* Used by `queryFTS` so that `analyze` doesn't pay the ~440 ms × 5 fixed
|
||||
* LadybugDB cost up-front (it dominates analyze on small repos). Instead,
|
||||
* the cost is moved to the first `query`/`context` call in a session,
|
||||
* where it's amortised across many lookups.
|
||||
* Kept for writable maintenance paths that need to lazily materialize an
|
||||
* index. Read-only query paths must not call this; production analysis owns
|
||||
* creating the configured search indexes before the database is served.
|
||||
*
|
||||
* Safe to call repeatedly — the in-process Set guarantees only the first
|
||||
* call hits LadybugDB. `closeLbug` clears the cache so re-init starts fresh.
|
||||
*
|
||||
* Defense in depth: if the active connection is read-only (e.g. the MCP
|
||||
* pool adapter), `CREATE_FTS_INDEX` will fail with "Cannot execute write
|
||||
* operations in a read-only database". Treat that as a no-op and cache
|
||||
* the key so callers don't loop on a path that can never succeed here —
|
||||
* the index is owned by `gitnexus analyze` (writable) and either already
|
||||
* exists or will be created on the next analyze.
|
||||
*/
|
||||
export const ensureFTSIndex = async (
|
||||
tableName: string,
|
||||
@@ -1266,10 +1260,20 @@ export const ensureFTSIndex = async (
|
||||
properties: string[],
|
||||
stemmer: string = 'porter',
|
||||
): Promise<void> => {
|
||||
const key = `${tableName}:${indexName}`;
|
||||
const key = ftsIndexKey(tableName, indexName);
|
||||
if (ensuredFTSIndexes.has(key)) return;
|
||||
await createFTSIndex(tableName, indexName, properties, stemmer);
|
||||
ensuredFTSIndexes.add(key);
|
||||
try {
|
||||
await createFTSIndex(tableName, indexName, properties, stemmer);
|
||||
} catch (e) {
|
||||
// Read-only DB: writable analyze owns index creation; silently skip
|
||||
// and cache so callers don't loop on a path that can never succeed
|
||||
// here (the MCP query pool opens DBs read-only by design).
|
||||
if (isReadOnlyDbError(e)) {
|
||||
ensuredFTSIndexes.add(key);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1341,5 +1345,7 @@ export const dropFTSIndex = async (tableName: string, indexName: string): Promis
|
||||
await conn.query(`CALL DROP_FTS_INDEX('${tableName}', '${indexName}')`);
|
||||
} catch {
|
||||
// Index may not exist
|
||||
} finally {
|
||||
ensuredFTSIndexes.delete(ftsIndexKey(tableName, indexName));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type lbug from '@ladybugdb/core';
|
||||
|
||||
/**
|
||||
* Shared configuration for `@ladybugdb/core` `Database` construction.
|
||||
*
|
||||
* Two values changed meaningfully in `@ladybugdb/core` 0.16.0 and need to be
|
||||
* pinned explicitly by every caller, otherwise GitNexus regresses:
|
||||
*
|
||||
* 1. `maxDBSize` defaults to `0`, which the native runtime interprets as
|
||||
* "use the platform's full mmap address space" — typically 8 TB on
|
||||
* 64-bit Linux. Constrained environments (CI runners, containers, WSL)
|
||||
* cannot reserve that much address space and crash with
|
||||
* `Buffer manager exception: Mmap for size 8796093022208 failed.`
|
||||
* See LadybugDB upstream JSDoc:
|
||||
* > "introduced temporarily for now to get around with the default 8TB
|
||||
* > mmap address space limit some environment".
|
||||
*
|
||||
* 2. `enableCompression` flipped its default from `false` (0.15.x) to
|
||||
* `true` (0.16.0). Existing call sites that relied on the positional
|
||||
* default must now pass `false` explicitly to preserve behaviour.
|
||||
*
|
||||
* Putting both in one shared module guarantees every `new lbug.Database(...)`
|
||||
* call site agrees on the same ceiling and behaviour.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Upper bound for any single GitNexus LadybugDB file (graph index, group
|
||||
* bridge, install scratch, test fixture). 16 GiB is intentionally generous
|
||||
* for real-world code graphs (the GitNexus self-index uses < 50 MiB) while
|
||||
* remaining far below any 64-bit OS mmap ceiling.
|
||||
*
|
||||
* Override with the `GITNEXUS_LBUG_MAX_DB_SIZE` environment variable when
|
||||
* indexing genuinely huge monorepos. Values are coerced to a positive
|
||||
* integer; anything invalid falls back to the default.
|
||||
*/
|
||||
export const LBUG_MAX_DB_SIZE: number = (() => {
|
||||
const raw = process.env.GITNEXUS_LBUG_MAX_DB_SIZE;
|
||||
if (raw) {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
|
||||
}
|
||||
return 16 * 1024 * 1024 * 1024;
|
||||
})();
|
||||
|
||||
type LbugModule = typeof lbug;
|
||||
|
||||
export interface LbugDatabaseOptions {
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface LbugConnectionHandle {
|
||||
db: lbug.Database;
|
||||
conn: lbug.Connection;
|
||||
}
|
||||
|
||||
export function createLbugDatabase(
|
||||
lbugModule: LbugModule,
|
||||
databasePath: string,
|
||||
options: LbugDatabaseOptions = {},
|
||||
): lbug.Database {
|
||||
return new lbugModule.Database(
|
||||
databasePath,
|
||||
0,
|
||||
false,
|
||||
options.readOnly ?? false,
|
||||
LBUG_MAX_DB_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
export async function openLbugConnection(
|
||||
lbugModule: LbugModule,
|
||||
databasePath: string,
|
||||
options: LbugDatabaseOptions = {},
|
||||
): Promise<LbugConnectionHandle> {
|
||||
let db: lbug.Database | undefined;
|
||||
try {
|
||||
db = createLbugDatabase(lbugModule, databasePath, options);
|
||||
return { db, conn: new lbugModule.Connection(db) };
|
||||
} catch (err) {
|
||||
if (db) await db.close().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeLbugConnection(handle: LbugConnectionHandle): Promise<void> {
|
||||
await handle.conn.close().catch(() => {});
|
||||
await handle.db.close().catch(() => {});
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
import fs from 'fs/promises';
|
||||
import lbug from '@ladybugdb/core';
|
||||
import { loadFTSExtension } from './lbug-adapter.js';
|
||||
import { createLbugDatabase } from './lbug-config.js';
|
||||
|
||||
/** Per-repo pool: one Database, many Connections */
|
||||
interface PoolEntry {
|
||||
@@ -69,7 +70,6 @@ interface SharedDB {
|
||||
db: lbug.Database;
|
||||
refCount: number;
|
||||
ftsLoaded: boolean;
|
||||
vectorLoaded: boolean;
|
||||
/** When true, closeOne skips db.close() — the Database is owned externally. */
|
||||
external?: boolean;
|
||||
}
|
||||
@@ -175,7 +175,6 @@ function closeOne(repoId: string): void {
|
||||
// for the same dbPath reuse it instead of hitting a file lock.
|
||||
shared.refCount = 0;
|
||||
shared.ftsLoaded = false;
|
||||
shared.vectorLoaded = false;
|
||||
} else {
|
||||
shared.db.close().catch(() => {});
|
||||
dbCache.delete(entry.dbPath);
|
||||
@@ -307,14 +306,9 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
|
||||
for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
|
||||
silenceStdout();
|
||||
try {
|
||||
const db = new lbug.Database(
|
||||
dbPath,
|
||||
0, // bufferManagerSize (default)
|
||||
false, // enableCompression (default)
|
||||
true, // readOnly
|
||||
);
|
||||
const db = createLbugDatabase(lbug, dbPath, { readOnly: true });
|
||||
restoreStdout();
|
||||
shared = { db, refCount: 0, ftsLoaded: false, vectorLoaded: false };
|
||||
shared = { db, refCount: 0, ftsLoaded: false };
|
||||
dbCache.set(dbPath, shared);
|
||||
break;
|
||||
} catch (err: any) {
|
||||
@@ -354,19 +348,11 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
|
||||
// Load FTS extension once per shared Database.
|
||||
// Done BEFORE pool registration so no concurrent checkout can grab
|
||||
// the connection while the async FTS load is in progress.
|
||||
// policy: 'load-only' — the read pool must never trigger a network
|
||||
// install; analyze owns extension installation. If LOAD fails, search
|
||||
// features degrade gracefully and the user-facing query path proceeds.
|
||||
if (!shared.ftsLoaded) {
|
||||
shared.ftsLoaded = await loadFTSExtension(available[0]);
|
||||
}
|
||||
|
||||
// Load VECTOR extension once per shared Database for semantic search support.
|
||||
if (!shared.vectorLoaded) {
|
||||
try {
|
||||
await available[0].query('INSTALL VECTOR');
|
||||
await available[0].query('LOAD EXTENSION VECTOR');
|
||||
shared.vectorLoaded = true;
|
||||
} catch {
|
||||
// VECTOR extension may not be available
|
||||
}
|
||||
shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
|
||||
}
|
||||
|
||||
// Register pool entry only after all connections are pre-warmed and FTS is
|
||||
@@ -412,7 +398,7 @@ export async function initLbugWithDb(
|
||||
// closeOne() respects the external flag and skips db.close().
|
||||
let shared = dbCache.get(dbPath);
|
||||
if (!shared) {
|
||||
shared = { db: existingDb, refCount: 0, ftsLoaded: false, vectorLoaded: false, external: true };
|
||||
shared = { db: existingDb, refCount: 0, ftsLoaded: false, external: true };
|
||||
dbCache.set(dbPath, shared);
|
||||
}
|
||||
shared.refCount++;
|
||||
@@ -427,20 +413,11 @@ export async function initLbugWithDb(
|
||||
preWarmActive = false;
|
||||
}
|
||||
|
||||
// Load FTS extension if not already loaded on this Database
|
||||
// Load FTS extension if not already loaded on this Database.
|
||||
// policy: 'load-only' — same contract as initLbug above; the read pool
|
||||
// must not block on a network install during query execution.
|
||||
if (!shared.ftsLoaded) {
|
||||
shared.ftsLoaded = await loadFTSExtension(available[0]);
|
||||
}
|
||||
|
||||
// Load VECTOR extension for semantic search support
|
||||
if (!shared.vectorLoaded) {
|
||||
try {
|
||||
await available[0].query('INSTALL VECTOR');
|
||||
await available[0].query('LOAD EXTENSION VECTOR');
|
||||
shared.vectorLoaded = true;
|
||||
} catch {
|
||||
// VECTOR extension may not be available
|
||||
}
|
||||
shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
|
||||
}
|
||||
|
||||
pool.set(repoId, {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import os from 'os';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export type CapabilityStatus = 'available' | 'degraded' | 'unavailable';
|
||||
export type SemanticSearchMode = 'vector-index' | 'exact-scan' | 'unavailable';
|
||||
|
||||
export interface RuntimeFingerprint {
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
node: string;
|
||||
gitnexus: string;
|
||||
ladybugdb?: string;
|
||||
onnxruntime?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeCapabilities {
|
||||
graph: CapabilityStatus;
|
||||
fts: CapabilityStatus;
|
||||
vector: CapabilityStatus;
|
||||
semanticMode: SemanticSearchMode;
|
||||
exactScanLimit: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const packageVersion = (name: string): string | undefined => {
|
||||
try {
|
||||
return require(`${name}/package.json`).version;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const gitnexusVersion = (): string => {
|
||||
try {
|
||||
return require('../../../package.json').version;
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const parsePositiveInt = (value: string | undefined, fallback: number): number => {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
export const DEFAULT_EXACT_SCAN_LIMIT = 10_000;
|
||||
|
||||
export const getExactScanLimit = (): number =>
|
||||
parsePositiveInt(process.env.GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT, DEFAULT_EXACT_SCAN_LIMIT);
|
||||
|
||||
export const getRuntimeFingerprint = (): RuntimeFingerprint => ({
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
node: process.version,
|
||||
gitnexus: gitnexusVersion(),
|
||||
ladybugdb: packageVersion('@ladybugdb/core'),
|
||||
onnxruntime: packageVersion('onnxruntime-node'),
|
||||
});
|
||||
|
||||
export const isVectorExtensionSupportedByPlatform = (
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean => platform !== 'win32';
|
||||
|
||||
export const getRuntimeCapabilities = (): RuntimeCapabilities => {
|
||||
const vector = isVectorExtensionSupportedByPlatform() ? 'available' : 'unavailable';
|
||||
const exactScanLimit = getExactScanLimit();
|
||||
return {
|
||||
graph: 'available',
|
||||
fts: 'available',
|
||||
vector,
|
||||
semanticMode: vector === 'available' ? 'vector-index' : 'exact-scan',
|
||||
exactScanLimit,
|
||||
reason:
|
||||
vector === 'unavailable'
|
||||
? 'LadybugDB VECTOR is disabled on this platform; semantic search uses exact scan when embeddings exist.'
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const defaultEmbeddingThreads = (): number => {
|
||||
const available =
|
||||
typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length;
|
||||
return Math.max(1, Math.min(4, Math.floor(available / 2) || 1));
|
||||
};
|
||||
@@ -21,11 +21,12 @@ import {
|
||||
closeLbug,
|
||||
loadCachedEmbeddings,
|
||||
} from './lbug/lbug-adapter.js';
|
||||
import { createSearchFTSIndexes } from './search/fts-indexes.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
saveMeta,
|
||||
loadMeta,
|
||||
addToGitignore,
|
||||
ensureGitNexusIgnored,
|
||||
registerRepo,
|
||||
cleanupOldKuzuFiles,
|
||||
} from '../storage/repo-manager.js';
|
||||
@@ -165,6 +166,7 @@ export async function runFullAnalysis(
|
||||
if (existingMeta && !options.force && existingMeta.lastCommit === currentCommit) {
|
||||
// Non-git folders have currentCommit = '' — always rebuild since we can't detect changes
|
||||
if (currentCommit !== '') {
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
return {
|
||||
repoName: options.registryName ?? getInferredRepoName(repoPath) ?? path.basename(repoPath),
|
||||
repoPath,
|
||||
@@ -250,7 +252,8 @@ export async function runFullAnalysis(
|
||||
const pipelineResult = await runPipelineFromRepo(repoPath, (p) => {
|
||||
const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
|
||||
const scaled = Math.round(p.percent * 0.6);
|
||||
progress(p.phase, scaled, phaseLabel);
|
||||
const message = p.detail ? `${p.message || phaseLabel} (${p.detail})` : p.message || phaseLabel;
|
||||
progress(p.phase, scaled, message);
|
||||
});
|
||||
|
||||
// ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
|
||||
@@ -280,12 +283,9 @@ export async function runFullAnalysis(
|
||||
});
|
||||
|
||||
// ── Phase 3: FTS (85–90%) ─────────────────────────────────────────
|
||||
// FTS indexes are created lazily on first `query`/`context` call instead
|
||||
// of eagerly here. On small repos / CI runners the LadybugDB
|
||||
// CREATE_FTS_INDEX cost is ~440 ms × 5 (≈2 s) regardless of table size,
|
||||
// which dominated `analyze` runtime and pushed Windows CI past its
|
||||
// 30 s test budget. Lazy creation is implemented in
|
||||
// `core/search/bm25-index.ts` via `ensureFTSIndex`.
|
||||
progress('fts', 85, 'Creating search indexes...');
|
||||
await createSearchFTSIndexes();
|
||||
progress('fts', 90, 'Search indexes ready');
|
||||
|
||||
// ── Phase 3.5: Re-insert cached embeddings ────────────────────────
|
||||
if (cachedEmbeddings.length > 0) {
|
||||
@@ -318,6 +318,7 @@ export async function runFullAnalysis(
|
||||
// ── Phase 4: Embeddings (90–98%) ──────────────────────────────────
|
||||
const stats = await getLbugStats();
|
||||
let embeddingSkipped = true;
|
||||
let semanticMode: 'vector-index' | 'exact-scan' | undefined;
|
||||
|
||||
if (shouldGenerateEmbeddings) {
|
||||
if (stats.nodes <= EMBEDDING_NODE_LIMIT) {
|
||||
@@ -346,7 +347,7 @@ export async function runFullAnalysis(
|
||||
const { readServerMapping } = await import('./embeddings/server-mapping.js');
|
||||
const projectName = path.basename(repoPath);
|
||||
const serverName = await readServerMapping(projectName);
|
||||
await runEmbeddingPipeline(
|
||||
const embeddingResult = await runEmbeddingPipeline(
|
||||
executeQuery,
|
||||
executeWithReusedStatement,
|
||||
(p) => {
|
||||
@@ -364,6 +365,15 @@ export async function runFullAnalysis(
|
||||
{ repoName: projectName, serverName },
|
||||
existingEmbeddings,
|
||||
);
|
||||
if (embeddingResult.semanticMode === 'exact-scan') {
|
||||
semanticMode = 'exact-scan';
|
||||
log(
|
||||
'Semantic embeddings were generated without a VECTOR index; ' +
|
||||
'queries will use exact-scan fallback within the configured limit.',
|
||||
);
|
||||
} else {
|
||||
semanticMode = 'vector-index';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 5: Finalize (98–100%) ───────────────────────────────────
|
||||
@@ -375,11 +385,24 @@ export async function runFullAnalysis(
|
||||
const embResult = await executeQuery(
|
||||
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN count(e) AS cnt`,
|
||||
);
|
||||
embeddingCount = embResult?.[0]?.cnt ?? 0;
|
||||
const row = embResult?.[0];
|
||||
embeddingCount = Number(row?.cnt ?? row?.[0] ?? 0);
|
||||
} catch {
|
||||
/* table may not exist if embeddings never ran */
|
||||
}
|
||||
|
||||
if (!embeddingSkipped && stats.nodes > 0 && embeddingCount === 0) {
|
||||
throw new Error(
|
||||
'Embedding generation completed without persisted embeddings. ' +
|
||||
'The index was not registered to avoid silently reporting embeddings: 0.',
|
||||
);
|
||||
}
|
||||
|
||||
const { getRuntimeCapabilities } = await import('./platform/capabilities.js');
|
||||
const runtimeCapabilities = getRuntimeCapabilities();
|
||||
const effectiveSemanticMode =
|
||||
semanticMode ??
|
||||
(runtimeCapabilities.semanticMode === 'vector-index' ? 'vector-index' : 'exact-scan');
|
||||
const meta = {
|
||||
repoPath,
|
||||
lastCommit: currentCommit,
|
||||
@@ -399,6 +422,16 @@ export async function runFullAnalysis(
|
||||
processes: pipelineResult.processResult?.stats.totalProcesses,
|
||||
embeddings: embeddingCount,
|
||||
},
|
||||
capabilities: {
|
||||
graph: { provider: 'ladybugdb', status: runtimeCapabilities.graph },
|
||||
fts: { provider: 'ladybugdb-fts', status: runtimeCapabilities.fts },
|
||||
vectorSearch: {
|
||||
provider: effectiveSemanticMode === 'vector-index' ? 'ladybugdb-vector' : 'exact-scan',
|
||||
status: embeddingCount > 0 ? effectiveSemanticMode : 'unavailable',
|
||||
exactScanLimit: runtimeCapabilities.exactScanLimit,
|
||||
reason: runtimeCapabilities.reason,
|
||||
},
|
||||
},
|
||||
};
|
||||
await saveMeta(storagePath, meta);
|
||||
// Forward the --name alias and the registry-collision bypass bit.
|
||||
@@ -415,10 +448,8 @@ export async function runFullAnalysis(
|
||||
allowDuplicateName: options.allowDuplicateName,
|
||||
});
|
||||
|
||||
// Only attempt to update .gitignore when a .git directory is present.
|
||||
if (hasGitDir(repoPath)) {
|
||||
await addToGitignore(repoPath);
|
||||
}
|
||||
// Keep generated .gitnexus contents ignored without editing the user's root .gitignore.
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
|
||||
// ── Generate AI context files (best-effort) ───────────────────────
|
||||
let aggregatedClusterCount = 0;
|
||||
|
||||
@@ -3,15 +3,10 @@
|
||||
*
|
||||
* Uses LadybugDB's built-in full-text search indexes for keyword-based search.
|
||||
* Always reads from the database (no cached state to drift).
|
||||
*
|
||||
* FTS indexes are created lazily on first query (via `ensureFTSIndex`) — see
|
||||
* `lbug-adapter.ts` for the rationale. This keeps `analyze` fast (the
|
||||
* ~440 ms × 5 LadybugDB CREATE_FTS_INDEX cost dominates pipeline time on
|
||||
* small repos / CI runners) at the cost of paying that overhead on the
|
||||
* first `query`/`context` call in a session.
|
||||
*/
|
||||
|
||||
import { queryFTS, ensureFTSIndex } from '../lbug/lbug-adapter.js';
|
||||
import { queryFTS } from '../lbug/lbug-adapter.js';
|
||||
import { FTS_INDEXES } from './fts-schema.js';
|
||||
|
||||
export interface BM25SearchResult {
|
||||
filePath: string;
|
||||
@@ -20,104 +15,6 @@ export interface BM25SearchResult {
|
||||
nodeIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* FTS schema served by `searchFTSFromLbug`. Centralised so that both the
|
||||
* CLI/pipeline path and the MCP pool path use identical (table, index,
|
||||
* properties) tuples and the lazy-create logic stays in one place.
|
||||
*/
|
||||
const FTS_INDEXES: ReadonlyArray<{
|
||||
table: string;
|
||||
indexName: string;
|
||||
properties: readonly string[];
|
||||
}> = [
|
||||
{ table: 'File', indexName: 'file_fts', properties: ['name', 'content'] },
|
||||
{ table: 'Function', indexName: 'function_fts', properties: ['name', 'content'] },
|
||||
{ table: 'Class', indexName: 'class_fts', properties: ['name', 'content'] },
|
||||
{ table: 'Method', indexName: 'method_fts', properties: ['name', 'content'] },
|
||||
{ table: 'Interface', indexName: 'interface_fts', properties: ['name', 'content'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Per-process cache for the MCP pool path: tracks which `(repoId, table)`
|
||||
* pairs have been ensured. The CLI/pipeline path gets its own cache inside
|
||||
* `lbug-adapter.ts` keyed by table/index, scoped to the singleton connection.
|
||||
*
|
||||
* IMPORTANT: an entry is added ONLY when the index was confirmed to exist
|
||||
* (CREATE_FTS_INDEX succeeded, or failed with `'already exists'`). Other
|
||||
* failures (transient lock errors, missing extension, etc.) leave the key
|
||||
* unset so the next query retries instead of silently caching the failure.
|
||||
*
|
||||
* Entries for a given repoId are invalidated when its pool is closed —
|
||||
* see the `addPoolCloseListener` registration in `searchFTSFromLbug`.
|
||||
*/
|
||||
const ensuredPoolFTS = new Set<string>();
|
||||
|
||||
/**
|
||||
* Drop all ensured-FTS cache entries for a given repoId.
|
||||
*
|
||||
* Called from the pool-close listener so that a pool teardown / recreation
|
||||
* forces the next `searchFTSFromLbug` call to re-issue `CREATE_FTS_INDEX`
|
||||
* against the fresh connection rather than trust stale ensure-state from a
|
||||
* previous pool lifetime.
|
||||
*
|
||||
* Exported for tests; the listener wiring is internal.
|
||||
*/
|
||||
export function invalidateEnsuredFTSForRepo(repoId: string): void {
|
||||
const prefix = `${repoId}:`;
|
||||
for (const key of ensuredPoolFTS) {
|
||||
if (key.startsWith(prefix)) ensuredPoolFTS.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether we've already wired the pool-close listener for this
|
||||
* process. The pool adapter is dynamically imported, so registration
|
||||
* happens lazily on the first MCP-pool-backed FTS query.
|
||||
*/
|
||||
let poolCloseListenerRegistered = false;
|
||||
function registerPoolCloseListenerOnce(
|
||||
addPoolCloseListener: (listener: (repoId: string) => void) => void,
|
||||
): void {
|
||||
if (poolCloseListenerRegistered) return;
|
||||
poolCloseListenerRegistered = true;
|
||||
addPoolCloseListener((repoId) => invalidateEnsuredFTSForRepo(repoId));
|
||||
}
|
||||
|
||||
async function ensureFTSIndexViaExecutor(
|
||||
executor: (cypher: string) => Promise<any[]>,
|
||||
repoId: string,
|
||||
table: string,
|
||||
indexName: string,
|
||||
properties: readonly string[],
|
||||
): Promise<void> {
|
||||
const key = `${repoId}:${table}:${indexName}`;
|
||||
if (ensuredPoolFTS.has(key)) return;
|
||||
const propList = properties.map((p) => `'${p}'`).join(', ');
|
||||
try {
|
||||
await executor(
|
||||
`CALL CREATE_FTS_INDEX('${table}', '${indexName}', [${propList}], stemmer := 'porter')`,
|
||||
);
|
||||
// Index was created successfully — safe to cache.
|
||||
ensuredPoolFTS.add(key);
|
||||
} catch (e: any) {
|
||||
// 'already exists' is the happy path (index persists on disk between
|
||||
// process invocations) — cache it. Anything else is treated as a
|
||||
// transient failure: surface a one-time warning and leave the key
|
||||
// unset so the NEXT query retries rather than silently using a
|
||||
// cached failure (which previously disabled BM25 for the whole
|
||||
// process for that repo).
|
||||
const msg = String(e?.message ?? '');
|
||||
if (msg.includes('already exists')) {
|
||||
ensuredPoolFTS.add(key);
|
||||
} else {
|
||||
console.warn(
|
||||
`[gitnexus] FTS index ensure failed for repo "${repoId}" table "${table}" ` +
|
||||
`(index "${indexName}"): ${msg || e}. Will retry on next query.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single FTS query via a custom executor (for MCP connection pool).
|
||||
* Returns the same shape as core queryFTS (from LadybugDB adapter).
|
||||
@@ -169,58 +66,24 @@ export const searchFTSFromLbug = async (
|
||||
limit: number = 20,
|
||||
repoId?: string,
|
||||
): Promise<BM25SearchResult[]> => {
|
||||
let fileResults: any[],
|
||||
functionResults: any[],
|
||||
classResults: any[],
|
||||
methodResults: any[],
|
||||
interfaceResults: any[];
|
||||
const resultsByIndex: any[][] = [];
|
||||
|
||||
if (repoId) {
|
||||
// Use MCP connection pool via dynamic import
|
||||
// IMPORTANT: FTS queries run sequentially to avoid connection contention.
|
||||
// The MCP pool supports multiple connections, but FTS is best run serially.
|
||||
const poolMod = await import('../lbug/pool-adapter.js');
|
||||
const { executeQuery, addPoolCloseListener } = poolMod;
|
||||
// Register the pool-close listener lazily on first use so a teardown of
|
||||
// the pool entry (LRU eviction, idle timeout, explicit close) drops the
|
||||
// matching `ensuredPoolFTS` entries. Without this, stale ensure-state
|
||||
// can outlive the pool that produced it.
|
||||
registerPoolCloseListenerOnce(addPoolCloseListener);
|
||||
const { executeQuery } = poolMod;
|
||||
const executor = (cypher: string) => executeQuery(repoId, cypher);
|
||||
|
||||
// Lazy-create FTS indexes on first query for this repo (analyze no longer
|
||||
// creates them up-front, so we ensure them here). Cached per-process.
|
||||
for (const { table, indexName, properties } of FTS_INDEXES) {
|
||||
await ensureFTSIndexViaExecutor(executor, repoId, table, indexName, properties);
|
||||
for (const { table, indexName } of FTS_INDEXES) {
|
||||
resultsByIndex.push(await queryFTSViaExecutor(executor, table, indexName, query, limit));
|
||||
}
|
||||
|
||||
fileResults = await queryFTSViaExecutor(executor, 'File', 'file_fts', query, limit);
|
||||
functionResults = await queryFTSViaExecutor(executor, 'Function', 'function_fts', query, limit);
|
||||
classResults = await queryFTSViaExecutor(executor, 'Class', 'class_fts', query, limit);
|
||||
methodResults = await queryFTSViaExecutor(executor, 'Method', 'method_fts', query, limit);
|
||||
interfaceResults = await queryFTSViaExecutor(
|
||||
executor,
|
||||
'Interface',
|
||||
'interface_fts',
|
||||
query,
|
||||
limit,
|
||||
);
|
||||
} else {
|
||||
// Use core lbug adapter (CLI / pipeline context) — also sequential for safety.
|
||||
// Lazy-create FTS indexes on first query (analyze no longer does it).
|
||||
for (const { table, indexName, properties } of FTS_INDEXES) {
|
||||
await ensureFTSIndex(table, indexName, [...properties]).catch(() => {});
|
||||
for (const { table, indexName } of FTS_INDEXES) {
|
||||
resultsByIndex.push(await queryFTS(table, indexName, query, limit, false).catch(() => []));
|
||||
}
|
||||
|
||||
fileResults = await queryFTS('File', 'file_fts', query, limit, false).catch(() => []);
|
||||
functionResults = await queryFTS('Function', 'function_fts', query, limit, false).catch(
|
||||
() => [],
|
||||
);
|
||||
classResults = await queryFTS('Class', 'class_fts', query, limit, false).catch(() => []);
|
||||
methodResults = await queryFTS('Method', 'method_fts', query, limit, false).catch(() => []);
|
||||
interfaceResults = await queryFTS('Interface', 'interface_fts', query, limit, false).catch(
|
||||
() => [],
|
||||
);
|
||||
}
|
||||
|
||||
// Collect all node scores per filePath to track which nodes actually matched
|
||||
@@ -233,11 +96,7 @@ export const searchFTSFromLbug = async (
|
||||
}
|
||||
};
|
||||
|
||||
addResults(fileResults);
|
||||
addResults(functionResults);
|
||||
addResults(classResults);
|
||||
addResults(methodResults);
|
||||
addResults(interfaceResults);
|
||||
for (const results of resultsByIndex) addResults(results);
|
||||
|
||||
// Sum the top-3 highest-scoring nodes per file and collect their nodeIds.
|
||||
// Summing all nodes naively inflates scores for files with many mediocre
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createFTSIndex } from '../lbug/lbug-adapter.js';
|
||||
import { FTS_INDEXES } from './fts-schema.js';
|
||||
|
||||
export async function createSearchFTSIndexes(): Promise<void> {
|
||||
for (const { table, indexName, properties } of FTS_INDEXES) {
|
||||
await createFTSIndex(table, indexName, [...properties]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface FTSIndexDefinition {
|
||||
readonly table: string;
|
||||
readonly indexName: string;
|
||||
readonly properties: readonly string[];
|
||||
}
|
||||
|
||||
export const FTS_INDEXES: readonly FTSIndexDefinition[] = [
|
||||
{ table: 'File', indexName: 'file_fts', properties: ['name', 'content'] },
|
||||
{ table: 'Function', indexName: 'function_fts', properties: ['name', 'content'] },
|
||||
{ table: 'Class', indexName: 'class_fts', properties: ['name', 'content'] },
|
||||
{ table: 'Method', indexName: 'method_fts', properties: ['name', 'content'] },
|
||||
{ table: 'Interface', indexName: 'interface_fts', properties: ['name', 'content'] },
|
||||
];
|
||||
@@ -1,97 +1,255 @@
|
||||
import Parser from 'tree-sitter';
|
||||
import JavaScript from 'tree-sitter-javascript';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
import Python from 'tree-sitter-python';
|
||||
import Java from 'tree-sitter-java';
|
||||
import C from 'tree-sitter-c';
|
||||
import CPP from 'tree-sitter-cpp';
|
||||
// Explicit subpath import: tree-sitter-c-sharp declares `type: "module"` with
|
||||
// `main: "bindings/node"` (no extension) and no `exports` field, which triggers
|
||||
// Node 22's DEP0151 deprecation warning on the bare-package import. Importing
|
||||
// the built entrypoint directly bypasses the deprecated ESM main-field
|
||||
// resolution. (#1013)
|
||||
import CSharp from 'tree-sitter-c-sharp/bindings/node/index.js';
|
||||
import Go from 'tree-sitter-go';
|
||||
import Rust from 'tree-sitter-rust';
|
||||
import PHP from 'tree-sitter-php';
|
||||
import Ruby from 'tree-sitter-ruby';
|
||||
import { createRequire } from 'node:module';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
|
||||
// tree-sitter-swift and tree-sitter-dart are optionalDependencies — may not be installed
|
||||
const _require = createRequire(import.meta.url);
|
||||
let Swift: any = null;
|
||||
try {
|
||||
Swift = _require('tree-sitter-swift');
|
||||
} catch {}
|
||||
let Dart: any = null;
|
||||
try {
|
||||
Dart = _require('tree-sitter-dart');
|
||||
} catch {}
|
||||
|
||||
// tree-sitter-kotlin is an optionalDependency — may not be installed
|
||||
let Kotlin: any = null;
|
||||
try {
|
||||
Kotlin = _require('tree-sitter-kotlin');
|
||||
} catch {}
|
||||
/**
|
||||
* One row per (language, optional variant) describes how to obtain a
|
||||
* grammar object suitable for `Parser.setLanguage`.
|
||||
*
|
||||
* - `load` — returns the grammar object (lazy, called on
|
||||
* first use, then cached).
|
||||
* - `unavailableNote` — actionable message surfaced *whenever* the
|
||||
* grammar can't be loaded. Mandatory for every
|
||||
* row so failures are never silent and never
|
||||
* generic.
|
||||
* - `optional` — when true, a load failure does not throw:
|
||||
* we report the language as unavailable and
|
||||
* let callers skip files of this language.
|
||||
* When false (the default), a load failure
|
||||
* re-throws the original error so the
|
||||
* pipeline halts loudly.
|
||||
* - `severity` — log level for failure diagnostics. Defaults
|
||||
* to `error` for required grammars and `warn`
|
||||
* for optional ones. Set explicitly to `error`
|
||||
* on optional rows whose package is listed in
|
||||
* `dependencies` (not `optionalDependencies`):
|
||||
* those failures indicate a real install
|
||||
* problem and should never be hidden behind
|
||||
* a low-severity warning.
|
||||
*
|
||||
* Adding or removing a grammar is one entry in this table — there is
|
||||
* no second list, no conditional spread, and no per-grammar branch in
|
||||
* the resolver.
|
||||
*/
|
||||
interface GrammarSource {
|
||||
load: () => unknown;
|
||||
unavailableNote: string;
|
||||
optional?: boolean;
|
||||
severity?: 'warn' | 'error';
|
||||
}
|
||||
|
||||
let parser: Parser | null = null;
|
||||
const ISSUES_URL = 'https://github.com/abhigyanpatwari/GitNexus/issues';
|
||||
|
||||
const languageMap: Record<string, any> = {
|
||||
[SupportedLanguages.JavaScript]: JavaScript,
|
||||
[SupportedLanguages.TypeScript]: TypeScript.typescript,
|
||||
[`${SupportedLanguages.TypeScript}:tsx`]: TypeScript.tsx,
|
||||
[SupportedLanguages.Python]: Python,
|
||||
[SupportedLanguages.Java]: Java,
|
||||
[SupportedLanguages.C]: C,
|
||||
[SupportedLanguages.CPlusPlus]: CPP,
|
||||
[SupportedLanguages.CSharp]: CSharp,
|
||||
[SupportedLanguages.Go]: Go,
|
||||
[SupportedLanguages.Rust]: Rust,
|
||||
...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}),
|
||||
[SupportedLanguages.PHP]: PHP.php_only,
|
||||
[SupportedLanguages.Ruby]: Ruby,
|
||||
[SupportedLanguages.Vue]: TypeScript.typescript,
|
||||
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
|
||||
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
|
||||
const SOURCES: Record<string, GrammarSource> = {
|
||||
[SupportedLanguages.JavaScript]: {
|
||||
load: () => _require('tree-sitter-javascript'),
|
||||
unavailableNote:
|
||||
'JavaScript parsing requires `tree-sitter-javascript`. ' +
|
||||
'Check that the package and its native binding installed cleanly (`npm ci`).',
|
||||
},
|
||||
[SupportedLanguages.TypeScript]: {
|
||||
load: () => _require('tree-sitter-typescript').typescript,
|
||||
unavailableNote:
|
||||
'TypeScript parsing requires `tree-sitter-typescript`. ' +
|
||||
'Check that the package and its native binding installed cleanly (`npm ci`).',
|
||||
},
|
||||
[`${SupportedLanguages.TypeScript}:tsx`]: {
|
||||
load: () => _require('tree-sitter-typescript').tsx,
|
||||
unavailableNote:
|
||||
'TSX parsing requires `tree-sitter-typescript` (re-uses the same native binding as TS).',
|
||||
},
|
||||
[SupportedLanguages.Python]: {
|
||||
load: () => _require('tree-sitter-python'),
|
||||
unavailableNote:
|
||||
'Python parsing requires `tree-sitter-python`. Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.Java]: {
|
||||
load: () => _require('tree-sitter-java'),
|
||||
unavailableNote:
|
||||
'Java parsing requires `tree-sitter-java`. Check the install and native binding.',
|
||||
},
|
||||
// tree-sitter-c-sharp declares `type: "module"` with `main: "bindings/node"`
|
||||
// (no extension) and no `exports` field, which triggers Node 22's DEP0151
|
||||
// deprecation warning on the bare-package import. The explicit subpath
|
||||
// bypasses the deprecated ESM main-field resolution. (#1013)
|
||||
[SupportedLanguages.CSharp]: {
|
||||
load: () => _require('tree-sitter-c-sharp/bindings/node/index.js'),
|
||||
unavailableNote:
|
||||
'C# parsing requires `tree-sitter-c-sharp/bindings/node/index.js`. ' +
|
||||
`If the subpath is missing, see ${ISSUES_URL}/1013.`,
|
||||
},
|
||||
[SupportedLanguages.CPlusPlus]: {
|
||||
load: () => _require('tree-sitter-cpp'),
|
||||
unavailableNote:
|
||||
'C++ parsing requires `tree-sitter-cpp`. Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.Go]: {
|
||||
load: () => _require('tree-sitter-go'),
|
||||
unavailableNote: 'Go parsing requires `tree-sitter-go`. Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.Rust]: {
|
||||
load: () => _require('tree-sitter-rust'),
|
||||
unavailableNote:
|
||||
'Rust parsing requires `tree-sitter-rust`. Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.PHP]: {
|
||||
load: () => _require('tree-sitter-php').php_only,
|
||||
unavailableNote:
|
||||
'PHP parsing requires `tree-sitter-php` (the `php_only` export). ' +
|
||||
'Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.Ruby]: {
|
||||
load: () => _require('tree-sitter-ruby'),
|
||||
unavailableNote:
|
||||
'Ruby parsing requires `tree-sitter-ruby`. Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.Vue]: {
|
||||
load: () => _require('tree-sitter-typescript').typescript,
|
||||
unavailableNote:
|
||||
'Vue parsing piggybacks on `tree-sitter-typescript`. Check the install and native binding.',
|
||||
},
|
||||
|
||||
// tree-sitter-c is a required dependency, but its native binding has
|
||||
// historically been ABI-incompatible with the bundled tree-sitter@0.21.1
|
||||
// runtime on some platforms (#1242, #858). Loading it through the
|
||||
// optional machinery turns a would-be segfault into a clean degradation
|
||||
// while preserving every other language's analysis. Severity is pinned
|
||||
// to `error` because the package is in `dependencies`: a failure here
|
||||
// is always an install/platform problem the user needs to see, never an
|
||||
// expected "user opted out" condition like Swift/Dart/Kotlin.
|
||||
[SupportedLanguages.C]: {
|
||||
load: () => _require('tree-sitter-c'),
|
||||
optional: true,
|
||||
severity: 'error',
|
||||
unavailableNote:
|
||||
'C parsing disabled: `tree-sitter-c` could not be loaded. ' +
|
||||
'This package is in `dependencies` and prebuilds ship for all supported ' +
|
||||
'platforms (win32/darwin/linux x64+arm64, Node 18/20/22), so this ' +
|
||||
'usually indicates a corrupted install, an unsupported Node version, ' +
|
||||
'or a native ABI mismatch with the bundled tree-sitter runtime. ' +
|
||||
'Try `npm rebuild tree-sitter-c` or reinstalling, then re-run analyze. ' +
|
||||
`If the failure persists, file details at ${ISSUES_URL}/1242.`,
|
||||
},
|
||||
|
||||
// optionalDependencies — may be absent on platforms without prebuilds
|
||||
// or when users skip optional installs.
|
||||
[SupportedLanguages.Swift]: {
|
||||
load: () => _require('tree-sitter-swift'),
|
||||
optional: true,
|
||||
unavailableNote:
|
||||
'Swift parsing disabled: vendored `tree-sitter-swift` (under ' +
|
||||
'`gitnexus/vendor/tree-sitter-swift`) failed to load. ' +
|
||||
'Likely cause: no prebuilt `.node` for this platform/architecture. ' +
|
||||
`See ${ISSUES_URL}/1130.`,
|
||||
},
|
||||
[SupportedLanguages.Dart]: {
|
||||
load: () => _require('tree-sitter-dart'),
|
||||
optional: true,
|
||||
unavailableNote:
|
||||
'Dart parsing disabled: vendored `tree-sitter-dart` (under ' +
|
||||
'`gitnexus/vendor/tree-sitter-dart`) failed to load. ' +
|
||||
'Likely cause: native compile failed at install (missing python3/make/g++). ' +
|
||||
`See ${ISSUES_URL}/1125.`,
|
||||
},
|
||||
[SupportedLanguages.Kotlin]: {
|
||||
load: () => _require('tree-sitter-kotlin'),
|
||||
optional: true,
|
||||
unavailableNote:
|
||||
'Kotlin parsing disabled: `tree-sitter-kotlin` is an optionalDependency ' +
|
||||
'and is not installed (or its native binding failed to build).',
|
||||
},
|
||||
};
|
||||
|
||||
export const isLanguageAvailable = (language: SupportedLanguages): boolean =>
|
||||
language in languageMap;
|
||||
type LoadResult =
|
||||
| { ok: true; grammar: unknown }
|
||||
| { ok: false; error: Error; note: string; fatal: boolean; severity: 'warn' | 'error' };
|
||||
|
||||
const loadCache = new Map<string, LoadResult>();
|
||||
const logged = new Set<string>();
|
||||
|
||||
const logFailure = (key: string, result: LoadResult): void => {
|
||||
if (result.ok === true) return;
|
||||
if (logged.has(key)) return;
|
||||
logged.add(key);
|
||||
const message = `[gitnexus] ${result.note} (${result.error.message})`;
|
||||
|
||||
if (result.severity === 'error') console.error(message);
|
||||
else console.warn(message);
|
||||
};
|
||||
|
||||
export const resolveLanguageKey = (language: SupportedLanguages, filePath?: string): string =>
|
||||
language === SupportedLanguages.TypeScript && filePath?.endsWith('.tsx')
|
||||
? `${language}:tsx`
|
||||
: language;
|
||||
|
||||
export const getLanguageGrammar = (language: SupportedLanguages, filePath?: string): any => {
|
||||
const key = resolveLanguageKey(language, filePath);
|
||||
const lang = languageMap[key];
|
||||
if (!lang) {
|
||||
throw new Error(`Unsupported language: ${language}`);
|
||||
const loadGrammar = (key: string): LoadResult => {
|
||||
const cached = loadCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const source = SOURCES[key];
|
||||
if (!source) {
|
||||
const result: LoadResult = {
|
||||
ok: false,
|
||||
error: new Error(`Unsupported language: ${key}`),
|
||||
note: `No grammar registered for language key \`${key}\`. Add a row to SOURCES.`,
|
||||
fatal: true,
|
||||
severity: 'error',
|
||||
};
|
||||
loadCache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
return lang;
|
||||
|
||||
let result: LoadResult;
|
||||
try {
|
||||
result = { ok: true, grammar: source.load() };
|
||||
} catch (err) {
|
||||
const fatal = !source.optional;
|
||||
result = {
|
||||
ok: false,
|
||||
error: err as Error,
|
||||
note: source.unavailableNote,
|
||||
fatal,
|
||||
severity: source.severity ?? (fatal ? 'error' : 'warn'),
|
||||
};
|
||||
}
|
||||
loadCache.set(key, result);
|
||||
if (result.ok === false) logFailure(key, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const loadParser = async (): Promise<Parser> => {
|
||||
if (parser) return parser;
|
||||
parser = new Parser();
|
||||
return parser;
|
||||
export const isLanguageAvailable = (language: SupportedLanguages, filePath?: string): boolean =>
|
||||
loadGrammar(resolveLanguageKey(language, filePath)).ok;
|
||||
|
||||
export const getLanguageGrammar = (language: SupportedLanguages, filePath?: string): unknown => {
|
||||
const key = resolveLanguageKey(language, filePath);
|
||||
const result = loadGrammar(key);
|
||||
if (result.ok === true) return result.grammar;
|
||||
// Fatal failures throw the original underlying error (preserving stack)
|
||||
// after the note has been logged. Optional failures fall through to the
|
||||
// standard "Unsupported language" message that callers already handle.
|
||||
if (result.fatal) throw result.error;
|
||||
throw new Error(`Unsupported language: ${language}`);
|
||||
};
|
||||
|
||||
let sharedParser: Parser | null = null;
|
||||
|
||||
export const loadParser = async (): Promise<Parser> => (sharedParser ??= new Parser());
|
||||
|
||||
export const loadLanguage = async (
|
||||
language: SupportedLanguages,
|
||||
filePath?: string,
|
||||
): Promise<void> => {
|
||||
if (!parser) await loadParser();
|
||||
parser!.setLanguage(getLanguageGrammar(language, filePath));
|
||||
const parser = await loadParser();
|
||||
parser.setLanguage(getLanguageGrammar(language, filePath));
|
||||
};
|
||||
|
||||
export const createParserForLanguage = async (
|
||||
language: SupportedLanguages,
|
||||
filePath?: string,
|
||||
): Promise<Parser> => {
|
||||
const freshParser = new Parser();
|
||||
freshParser.setLanguage(getLanguageGrammar(language, filePath));
|
||||
return freshParser;
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(getLanguageGrammar(language, filePath));
|
||||
return parser;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user