Merge remote-tracking branch 'origin/main' into codex/feat-thrift-contracts-impl

# Conflicts:
#	gitnexus/src/core/group/sync.ts
#	gitnexus/test/unit/group/manifest-extractor.test.ts
#	gitnexus/test/unit/group/sync.test.ts
This commit is contained in:
liyipeng06 2026-05-03 21:23:24 +08:00
commit 91a0fd1cc8
141 changed files with 7827 additions and 1494 deletions

View file

@ -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())

View file

@ -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

View file

@ -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 ─────────────────────────────────

View file

@ -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 }}'

View file

@ -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 }}'

1
.gitignore vendored
View file

@ -107,3 +107,4 @@ local_docs/
.tmp/
.agents/
.context/
gitnexus/web/

View file

@ -39,8 +39,12 @@ function isGlobalRegistryDir(candidate) {
);
}
function findGitNexusDir(startDir) {
let dir = startDir || process.cwd();
/**
* 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)) {
@ -53,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.
*/

View file

@ -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 {

View file

@ -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",
@ -1538,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",
@ -1580,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": {
@ -1607,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",
@ -2871,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": {
@ -2886,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": {
@ -5491,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": {
@ -6033,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",
@ -7732,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": {
@ -7822,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",
@ -8079,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",
@ -8544,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"
},
@ -8679,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"

View file

@ -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",

View file

@ -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.Ref<HTMLDivElement>;
messagesContainerRef: React.Ref<HTMLDivElement>;
isAtBottom: boolean;
scrollToBottom: (behavior?: ScrollBehavior) => void;
}

View file

@ -65,7 +65,7 @@ interface UseSigmaOptions {
}
interface UseSigmaReturn {
containerRef: React.RefObject<HTMLDivElement>;
containerRef: React.Ref<HTMLDivElement>;
sigmaRef: React.RefObject<Sigma | null>;
setGraph: (graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>) => void;
zoomIn: () => void;

View file

@ -39,8 +39,12 @@ function isGlobalRegistryDir(candidate) {
);
}
function findGitNexusDir(startDir) {
let dir = startDir || process.cwd();
/**
* 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)) {
@ -53,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.
*/

View file

@ -11,7 +11,7 @@
"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",
@ -75,7 +75,7 @@
"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
}
}
@ -5066,15 +5017,14 @@
}
},
"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"
@ -5305,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"

View file

@ -53,7 +53,7 @@
},
"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",
@ -72,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",
@ -109,8 +109,7 @@
"overrides": {
"@huggingface/transformers": {
"onnxruntime-node": "$onnxruntime-node"
},
"tree-sitter-c": "0.23.2"
}
},
"engines": {
"node": ">=20.0.0"

View file

@ -6,6 +6,14 @@ 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>'}`);
@ -14,6 +22,9 @@ async function installDuckDbExtension(extensionName) {
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');
@ -21,7 +32,7 @@ async function installDuckDbExtension(extensionName) {
let conn;
try {
db = new lbug.Database(dbPath);
db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize);
conn = new lbug.Connection(db);
await conn.query(`INSTALL ${extensionName}`);
} finally {

View file

@ -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. */
@ -102,6 +142,11 @@ export interface AnalyzeOptions {
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';
}
@ -173,21 +218,19 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
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);
@ -316,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;
@ -328,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...');
@ -429,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 (

View file

@ -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;

View file

@ -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 ' +

View file

@ -15,7 +15,6 @@ 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';
@ -23,6 +22,7 @@ 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
@ -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) {

View file

@ -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 + '/';
}
}

View file

@ -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);

View file

@ -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;
}

View file

@ -14,6 +14,7 @@ const DEFAULT_DETECT = {
topics: true,
shared_libs: true,
embedding_fallback: true,
workspace_deps: true,
};
const DEFAULT_MATCHING = {

View file

@ -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,
});

View file

@ -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,
});
}

View file

@ -273,6 +273,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;
}

View file

@ -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 };
}

View file

@ -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,
});

View file

@ -10,6 +10,7 @@ import { ThriftExtractor } from './extractors/thrift-extractor.js';
import { TopicExtractor } from './extractors/topic-extractor.js';
import { ManifestExtractor } from './extractors/manifest-extractor.js';
import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js';
import { extractRustWorkspaceLinks } from './extractors/rust-workspace-extractor.js';
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
import type { CypherExecutor } from './contract-extractor.js';
import { writeContractRegistry } from './storage.js';
@ -85,12 +86,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();
@ -190,18 +193,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(
@ -211,12 +235,12 @@ 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)`,
);
}
}

View file

@ -28,6 +28,7 @@ export interface DetectConfig {
topics: boolean;
shared_libs: boolean;
embedding_fallback: boolean;
workspace_deps: boolean;
}
export interface MatchingConfig {

View file

@ -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[]>;

View file

@ -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()),
})),
]),
);

View file

@ -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

View file

@ -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,

View file

@ -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(),

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -15,6 +15,7 @@ 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';
@ -106,6 +107,21 @@ function normalizePythonStringLiteral(text: string): string | undefined {
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,

View file

@ -38,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[] = [];
@ -138,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`,
);
}

View 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;
}

View file

@ -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,

View file

@ -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,

View file

@ -15,6 +15,7 @@ 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';
@ -259,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,

View file

@ -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,

View file

@ -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 =

View file

@ -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;
}

View file

@ -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,

View file

@ -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) {

View file

@ -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;

View file

@ -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

View file

@ -429,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 (

View file

@ -1,5 +1,6 @@
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_]*$/;
@ -60,9 +61,12 @@ export const getExtensionInstallTimeoutMs = (): number => {
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_EXTENSION_INSTALL_TIMEOUT_MS;
};
export const getExtensionInstallChildProcessArgs = (extensionName: string): string[] => {
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];
return [fileURLToPath(childScript), extensionName, String(maxDbSize)];
};
/**

View file

@ -17,6 +17,11 @@ import {
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';
// ---------------------------------------------------------------------------
@ -147,15 +152,16 @@ let ftsLoaded = false;
let vectorExtensionLoaded = false;
/**
* In-process cache of FTS indexes that have been ensured against the current
* writable connection. Prevents repeated `CALL CREATE_FTS_INDEX` round-trips
* for callers that explicitly opt into `ensureFTSIndex`. 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)}`);
@ -664,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)
@ -711,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;
@ -753,12 +768,7 @@ export const batchInsertNodesToLbug = async (
}
}
} finally {
try {
await tempConn.close();
} catch {}
try {
await tempDb.close();
} catch {}
await closeLbugConnection(tempHandle);
}
return { inserted, failed };
@ -1071,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.');
@ -1127,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);
}
};
@ -1214,6 +1215,9 @@ export const createFTSIndex = async (
throw new Error('LadybugDB not initialized. Call initLbug first.');
}
const key = ftsIndexKey(tableName, indexName);
if (ensuredFTSIndexes.has(key)) return;
if (!(await loadFTSExtension())) {
return;
}
@ -1223,10 +1227,13 @@ export const createFTSIndex = async (
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;
}
};
@ -1239,6 +1246,13 @@ export const createFTSIndex = async (
*
* 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,
@ -1246,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;
}
};
/**
@ -1321,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));
}
};

View file

@ -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(() => {});
}

View file

@ -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 {
@ -305,12 +306,7 @@ 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 };
dbCache.set(dbPath, shared);

View file

@ -26,7 +26,7 @@ import {
getStoragePaths,
saveMeta,
loadMeta,
addToGitignore,
ensureGitNexusIgnored,
registerRepo,
cleanupOldKuzuFiles,
} from '../storage/repo-manager.js';
@ -166,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,
@ -447,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;

View file

@ -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;
};

View file

@ -6,14 +6,13 @@
*/
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
import os from 'os';
import { join } from 'path';
import {
isHttpMode,
getHttpDimensions,
httpEmbedQuery,
} from '../../core/embeddings/http-client.js';
import { resolveEmbeddingConfig } from '../../core/embeddings/config.js';
import { applyHfEnvOverrides } from '../../core/embeddings/hf-env.js';
import { silenceStdout, restoreStdout, realStderrWrite } from '../../core/lbug/pool-adapter.js';
// Model config
@ -45,11 +44,11 @@ export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
initPromise = (async () => {
try {
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 this MCP entry point behaves identically to
// the analyze pipeline embedder.
applyHfEnvOverrides(env);
const embeddingConfig = resolveEmbeddingConfig();
console.error('GitNexus: Loading embedding model (first search may take a moment)...');

View file

@ -1110,13 +1110,12 @@ export class LocalBackend {
}
} else if (!this.warnedVectorUnsupported) {
// Rare diagnostic: surface why we fell back to the exact scan path so
// operators can see at a glance that the VECTOR extension is missing on
// this runtime (e.g. Windows builds without the optional native
// dependency). Emitted once per `LocalBackend` instance lifetime to
// avoid noisy stderr on hot semantic-search paths (DoD §2.8).
// operators can see at a glance that VECTOR is disabled by platform
// policy. Emitted once per `LocalBackend` instance lifetime to avoid
// noisy stderr on hot semantic-search paths (DoD §2.8).
this.warnedVectorUnsupported = true;
console.error(
'GitNexus [query:vector]: VECTOR index unavailable for this runtime; using exact scan fallback',
'GitNexus [query:vector]: VECTOR extension not supported on this platform; using exact scan fallback',
);
}

View file

@ -158,6 +158,7 @@ export function createMCPServer(backend: LocalBackend): Server {
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
annotations: tool.annotations,
})),
}));

View file

@ -5,9 +5,12 @@
* All tools support an optional `repo` parameter for multi-repo setups.
*/
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
export interface ToolDefinition {
name: string;
description: string;
annotations: ToolAnnotations;
inputSchema: {
type: 'object';
properties: Record<
@ -27,6 +30,27 @@ export interface ToolDefinition {
};
}
const READ_ONLY_TOOL_ANNOTATIONS: ToolAnnotations = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
};
const QUERY_TOOL_ANNOTATIONS: ToolAnnotations = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
};
const DESTRUCTIVE_TOOL_ANNOTATIONS: ToolAnnotations = {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
};
export const GITNEXUS_TOOLS: ToolDefinition[] = [
{
name: 'list_repos',
@ -39,6 +63,7 @@ AFTER THIS: READ gitnexus://repo/{name}/context for the repo you want to work wi
When multiple repos are indexed, you MUST specify the "repo" parameter
on other tools (query, context, impact, etc.) to target the correct one.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {},
@ -63,6 +88,7 @@ Hybrid ranking: BM25 keyword + semantic vector search, ranked by Reciprocal Rank
GROUP MODE: set "repo" to "@<groupName>" to search all member repos in that group (merged via RRF), or "@<groupName>/<groupRepoPath>" to run against a single member (same path keys as in group.yaml). If you use "@<groupName>" only, the member repo defaults to the lexicographically first key in group.yaml "repos". Prefer resources for contracts/status (see migration from legacy group_* tools).
SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). When "repo" starts with "@", only processes whose symbols fall under that prefix are included. For a normal indexed repo name (no leading @), this field is currently ignored by the server.`,
annotations: QUERY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@ -156,6 +182,7 @@ TIPS:
- Community = auto-detected functional area (Leiden algorithm). Properties: heuristicLabel, cohesion, symbolCount, keywords, description, enrichedBy
- Process = execution flow trace from entry point to terminal. Properties: heuristicLabel, processType, stepCount, communities, entryPointId, terminalId
- Use heuristicLabel (not label) for human-readable community/process names`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@ -183,6 +210,7 @@ NOTE: ACCESSES edges (field read/write tracking) are included in context results
GROUP MODE: set "repo" to "@<groupName>" to run context in each member repo (aggregated list), or "@<groupName>/<groupRepoPath>" for one member. If you use "@<groupName>" only, the member defaults to the lexicographically first key in group.yaml "repos".
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", prefix-matches resolved symbol file paths; when a hit is outside the prefix, that member returns an empty payload for the symbol. Ignored for a normal indexed repo name.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@ -226,6 +254,7 @@ WHEN TO USE: Before committing — to understand what your changes affect. Pre-c
AFTER THIS: Review affected processes. Use context() on high-risk symbols. READ gitnexus://repo/{name}/process/{name} for full traces.
Returns: changed symbols, affected processes, and a risk summary.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@ -258,6 +287,7 @@ AFTER THIS: Run detect_changes() to verify no unexpected side effects.
Each edit is tagged with confidence:
- "graph": found via knowledge graph relationships (high confidence, safe to accept)
- "text_search": found via regex text search (lower confidence, review carefully)`,
annotations: DESTRUCTIVE_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@ -311,6 +341,7 @@ Confidence: 1.0 = certain, <0.8 = fuzzy match
GROUP MODE: set "repo" to "@<groupName>" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@<groupName>/<groupRepoPath>" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge.
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", scopes the local impact walk and cross-repo symbol paths to files under that prefix; ignored for a normal indexed repo name.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@ -404,6 +435,7 @@ WHEN TO USE: Understanding API consumption patterns, finding orphaned routes. Fo
AFTER THIS: Use impact() on specific route handlers to see full blast radius.
Returns: route nodes with their handlers, middleware wrapper chains (e.g., withAuth, withRateLimit), and consumers.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@ -426,6 +458,7 @@ Returns: route nodes with their handlers, middleware wrapper chains (e.g., withA
WHEN TO USE: Understanding tool APIs, finding tool implementations, impact analysis for tool changes.
Returns: tool nodes with their handler files and descriptions.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@ -443,6 +476,7 @@ WHEN TO USE: Detecting mismatches between what an API route returns and what con
REQUIRES: Route nodes with responseKeys (extracted from .json({...}) calls during indexing).
Returns routes that have both detected response keys AND consumers. Shows top-level keys each endpoint returns (e.g., data, pagination, error) and what keys each consumer accesses. Reports MISMATCH status when a consumer accesses keys not present in the route's response shape.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@ -467,6 +501,7 @@ WHEN TO USE: BEFORE modifying any API route handler. Shows what consumers depend
Risk levels: LOW (0-3 consumers), MEDIUM (4-9 or any mismatches), HIGH (10+ consumers or mismatches with 4+ consumers). Mismatches with confidence "low" indicate the consumer file fetches multiple routes — property attribution is approximate.
Returns: single route object when one match, or { routes: [...], total: N } for multiple matches. Combines route_map, shape_check, and impact data.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@ -482,6 +517,7 @@ Returns: single route object when one match, or { routes: [...], total: N } for
description: `List all configured repository groups, or return details for one group (repos, manifest links).
WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single group's config.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@ -495,6 +531,9 @@ WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single
description: `Rebuild the Contract Registry (contracts.json) for a group: extract HTTP contracts, apply manifest links, exact-match cross-links.
WHEN TO USE: After changing group.yaml or re-indexing member repos.`,
// Writes contracts.json on every call; conservatively non-idempotent
// even though output is deterministic for identical input.
annotations: DESTRUCTIVE_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {

View file

@ -93,6 +93,34 @@ export const getGitRoot = (fromPath: string): string | null => {
return null;
}
};
/**
* Find a git root by checking only `.git` entries on the ancestor chain.
*
* Unlike `getGitRoot`, this does not spawn `git`, so MCP can cheaply decide
* whether a launch cwd is a worktree before running any subprocess there.
*/
export const findGitRootByDotGit = (fromPath: string): string | null => {
let current = path.resolve(fromPath);
try {
if (!statSync(current).isDirectory()) {
current = path.dirname(current);
}
} catch {
return null;
}
while (true) {
try {
statSync(path.join(current, '.git'));
return current;
} catch {
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
};
/**
* Check whether a directory contains a .git entry (file or folder).
*

View file

@ -96,6 +96,7 @@ export interface RegistryEntry {
}
const GITNEXUS_DIR = '.gitnexus';
const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`;
// ─── Local Storage Helpers ─────────────────────────────────────────────
@ -238,23 +239,45 @@ export const findRepo = async (startPath: string): Promise<IndexedRepo | null> =
};
/**
* Add .gitnexus to .gitignore if not already present
* Keep generated index files ignored without modifying the user's root .gitignore.
*/
export const addToGitignore = async (repoPath: string): Promise<void> => {
const gitignorePath = path.join(repoPath, '.gitignore');
export const ensureGitNexusIgnored = async (repoPath: string): Promise<void> => {
const gitignorePath = path.join(getStoragePath(repoPath), '.gitignore');
await fs.mkdir(path.dirname(gitignorePath), { recursive: true });
await fs.writeFile(gitignorePath, '*\n', 'utf-8');
await ensureGitInfoExclude(repoPath);
};
const ensureGitInfoExclude = async (repoPath: string): Promise<void> => {
const gitDirPath = path.join(path.resolve(repoPath), '.git');
const excludePath = path.join(gitDirPath, 'info', 'exclude');
try {
const content = await fs.readFile(gitignorePath, 'utf-8');
if (content.includes(GITNEXUS_DIR)) return;
const newContent = content.endsWith('\n')
? `${content}${GITNEXUS_DIR}\n`
: `${content}\n${GITNEXUS_DIR}\n`;
await fs.writeFile(gitignorePath, newContent, 'utf-8');
const gitDir = await fs.stat(gitDirPath);
if (!gitDir.isDirectory()) return;
} catch {
// .gitignore doesn't exist, create it
await fs.writeFile(gitignorePath, `${GITNEXUS_DIR}\n`, 'utf-8');
return;
}
await fs.mkdir(path.dirname(excludePath), { recursive: true });
let content = '';
try {
content = await fs.readFile(excludePath, 'utf-8');
} catch (err: any) {
if (err?.code !== 'ENOENT') throw err;
}
const excludes = content
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
if (excludes.includes(GITNEXUS_DIR) || excludes.includes(GITNEXUS_EXCLUDE_ENTRY)) return;
const separator = content.length === 0 || content.endsWith('\n') ? '' : '\n';
await fs.writeFile(excludePath, `${content}${separator}${GITNEXUS_EXCLUDE_ENTRY}\n`, 'utf-8');
};
// ─── Global Registry (~/.gitnexus/registry.json) ───────────────────────
@ -561,6 +584,84 @@ export class RegistryAmbiguousTargetError extends Error {
}
}
/**
* Thrown by {@link assertAnalysisFinalized} when a successful `analyze`
* run did not actually persist `meta.json` or did not register the repo
* in `~/.gitnexus/registry.json` (#1169).
*
* Why this exists: on Windows, `gitnexus analyze` has been observed to
* exit cleanly (code 0) with `lbug.wal` written but no `meta.json`,
* leaving the repo invisible to `gitnexus list`/`status` and downstream
* MCP discovery. The only signal to the user was an empty banner —
* which is indistinguishable from a no-op early return. This invariant
* fails loudly with an actionable diagnostic so the silent-finalize bug
* surfaces with a non-zero exit code and a recoverable error message
* regardless of the upstream root cause (re-exec churn, native module
* side effects, antivirus, or future regressions).
*/
export class AnalysisNotFinalizedError extends Error {
readonly kind = 'AnalysisNotFinalizedError' as const;
constructor(
public readonly repoPath: string,
public readonly storagePath: string,
public readonly missing: 'meta' | 'registry-entry',
public readonly registryPath: string,
) {
const detail =
missing === 'meta'
? `meta.json was not written to ${path.join(storagePath, 'meta.json')}`
: `registry entry for ${repoPath} was not added to ${registryPath}`;
super(
`Analysis did not finalize for ${repoPath}: ${detail}. ` +
`The on-disk index is incomplete and was not registered. ` +
`Re-run "gitnexus analyze" — if the problem persists, inspect ` +
`${storagePath} for a stale lbug.wal that signals an aborted write.`,
);
this.name = 'AnalysisNotFinalizedError';
}
}
/**
* Verify that a successful `analyze` call actually produced an indexed,
* registered repo on disk. Two checks, both strictly required:
*
* 1. `meta.json` must exist at `<repoPath>/.gitnexus/meta.json`.
* 2. The global registry (`getGlobalRegistryPath()`) must contain an
* entry whose canonical path matches `repoPath`.
*
* Throws {@link AnalysisNotFinalizedError} on the first failure with the
* specific missing artifact. Pure read — does not mutate disk state.
*
* Callers must skip this assertion on the `alreadyUpToDate` early-return
* path, where the rebuild was deliberately not run.
*/
export const assertAnalysisFinalized = async (repoPath: string): Promise<void> => {
const resolved = path.resolve(repoPath);
const { storagePath, metaPath } = getStoragePaths(resolved);
try {
await fs.access(metaPath);
} catch {
throw new AnalysisNotFinalizedError(resolved, storagePath, 'meta', getGlobalRegistryPath());
}
const entries = await readRegistry();
const canonicalInput = canonicalizePath(resolved);
const isWin = process.platform === 'win32';
const found = entries.some((e) => {
const a = canonicalizePath(e.path);
return isWin ? a.toLowerCase() === canonicalInput.toLowerCase() : a === canonicalInput;
});
if (!found) {
throw new AnalysisNotFinalizedError(
resolved,
storagePath,
'registry-entry',
getGlobalRegistryPath(),
);
}
};
/**
* Thrown by {@link assertSafeStoragePath} when a registry entry's
* `storagePath` does NOT point at the expected `<entry.path>/.gitnexus`

View file

@ -0,0 +1,6 @@
def verify_cron_secret(token):
return token == "expected"
def get_org_id_from_header(headers):
return headers.get("x-org-id") if headers else None

View file

@ -0,0 +1,2 @@
def send_daily_alerts():
return "sent"

View file

@ -0,0 +1,24 @@
from auth_utils import verify_cron_secret, get_org_id_from_header
from services.sync import _start_cron_run, _complete_cron_run
from services.alerts import _create_ops_alert
from routers.alerts import send_daily_alerts
def handler_a():
if verify_cron_secret("x"):
org = get_org_id_from_header(None)
_start_cron_run("a")
_create_ops_alert("a")
send_daily_alerts()
_complete_cron_run("a")
return org
def handler_b():
_start_cron_run("b")
_create_ops_alert("b")
send_daily_alerts()
def handler_c():
_start_cron_run("c")

View file

@ -0,0 +1,2 @@
def _create_ops_alert(name):
return f"alert:{name}"

View file

@ -0,0 +1,6 @@
def _start_cron_run(name):
return f"started:{name}"
def _complete_cron_run(name):
return f"completed:{name}"

View file

@ -0,0 +1,11 @@
// Library-wrapper / utility-HOC form: `debounce`, `throttle`, `once`,
// `memoize` — all share the same shape `const X = wrap(arrow)` and should
// produce a `Function:X` def named after the const.
import { doStuff } from './helpers';
const debounce = <F extends (...args: unknown[]) => unknown>(fn: F, _ms: number): F => fn;
export const debouncedSearch = debounce((query: string) => {
doStuff(query.length);
}, 250);

View file

@ -0,0 +1,29 @@
// shadcn/Radix UI canonical pattern: every primitive component is wrapped
// in `React.forwardRef` so callers can attach a ref. The arrow inside is
// where the actual rendering logic lives — every call inside its body
// (cn(), helper(), JSX renders) should attribute to `Button`, not File.
//
// Pre-fix: `Button` was a Variable; calls inside attributed to File.
// Post-fix: `Button` is a Function; calls attribute to `Button`.
import { helper, cn } from './helpers';
// Stand-in for React.forwardRef — defined locally so the outer call_expression
// is in-fixture and we don't need to mock the React types. Same shape as
// the real React.forwardRef<T, P>.
const React = {
forwardRef: <T, P>(render: (props: P, ref: T | null) => unknown) => render,
};
interface ButtonProps {
className?: string;
variant?: 'default' | 'ghost';
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant }, _ref) => {
const cls = cn('btn', variant ?? 'default', className ?? '');
helper(cls);
return null;
},
);

View file

@ -0,0 +1,11 @@
// Shared helpers used as call targets in HOC-wrapped fixture files. Each
// helper is a plain named arrow so we can assert exact `Caller → helper`
// edges without confounding cross-file resolution.
export const helper = (label: string): string => label.toUpperCase();
export const doStuff = (n: number): number => n + 1;
export const cn = (...classes: string[]): string => classes.filter(Boolean).join(' ');
export const fmt = (value: number): string => `[${value}]`;

View file

@ -0,0 +1,19 @@
// Bare-identifier HOC form: `const Card = memo((props) => { ... })`.
// Common when the HOC is named-imported (`import { memo } from 'react'`)
// rather than accessed via a namespace (`React.memo`). Both should work.
import { helper, cn } from './helpers';
const memo = <P,>(render: (props: P) => unknown) => render;
interface CardProps {
title: string;
className?: string;
}
export const Card = memo<CardProps>(({ title, className }) => {
const cls = cn('card', className ?? '');
helper(title);
helper(cls);
return null;
});

View file

@ -0,0 +1,30 @@
// Negative-control: bare statement-level HOC calls (NOT bound to a
// `const`/`let`/`var`) must NOT produce phantom Function nodes.
//
// This exercises the `parent.type === 'arguments'` branch in
// `tsExtractFunctionName`: the walk-up `arguments → call_expression →
// (program | expression_statement)` short-circuits because the parent
// of `call_expression` is NOT `variable_declarator`. The arrow stays
// anonymous and calls inside fall back to the enclosing module scope.
import { doStuff } from './helpers';
const useCallback = <F extends (...args: unknown[]) => unknown>(fn: F, _deps: unknown[]): F => fn;
const memo = <P,>(render: (props: P) => unknown) => render;
// Statement-level: result is discarded.
useCallback(() => {
doStuff(1);
}, []);
memo<{ x: number }>(({ x }) => {
doStuff(x);
});
// Function-arg position (passed to another call): also unbound.
const wrap = <T>(value: T): T => value;
wrap(
memo<{ y: number }>(({ y }) => {
doStuff(y);
}),
);

View file

@ -0,0 +1,60 @@
// Nested HOCs: `const X = memo(forwardRef((p, r) => { ... }))`.
//
// Tree-sitter shape:
// variable_declarator
// value: call_expression (memo(...))
// arguments: arguments
// call_expression (forwardRef(arrow)) ← FIRST positional arg
// arguments: arguments
// arrow_function ← deepest behaviour-arrow
//
// Our outermost lexical_declaration → call_expression → arguments →
// arrow_function pattern requires the arrow to be a direct child of the
// outermost call's `arguments` — which it is NOT here (it's two levels
// deeper). So the OUTER pattern misses. But the same pattern, anchored
// on the INNER call (`forwardRef(arrow)`), wouldn't match either: the
// inner call_expression is a child of `arguments`, not of a
// `variable_declarator`.
//
// So the question is: which Function name does `arrow_function`'s
// `tsExtractFunctionName` resolve to in the legacy DAG?
//
// arrow.parent = arguments (forwardRef's args)
// arguments.parent = call_expression (forwardRef(...))
// call_expr.parent = arguments (memo's args) ← NOT variable_declarator!
//
// The legacy walker requires `call_expression.parent === variable_declarator`,
// so it returns null → arrow stays anonymous. Same for the registry-primary
// query (the lexical_declaration shape doesn't match because the arrow
// isn't a direct grandchild of the outer call).
//
// CURRENT STATE (post-fix): the deepest arrow is anonymous, calls inside
// fall back to the next named ancestor. There IS no named ancestor here
// (the outer `forwardRef` and `memo` calls don't have variable_declarator
// `value:` containing them — they ARE that value). So calls walk up to
// File. The OUTER `Wrapped` const is named `Variable:Wrapped`, not a
// Function — because no Function pattern matches the OUTER shape either
// (the immediate arg of `memo(...)` is a `call_expression`, not an arrow).
//
// This file documents a known limitation: deep-nested HOCs (memo of
// forwardRef of arrow) lose attribution at depth ≥ 2. The test below
// asserts the ABSENCE of phantom edges (we don't make this worse) rather
// than positive resolution. A future fix would require recursing into
// arguments-containing-call_expression on the value side, which has its
// own trade-offs (which level wins the name?).
import { helper } from './helpers';
const memo = <P,>(render: (props: P) => unknown) => render;
const forwardRef = <T, P>(render: (props: P, ref: T | null) => unknown) => render;
interface DeepProps {
label: string;
}
export const Wrapped = memo(
forwardRef<HTMLDivElement, DeepProps>(({ label }, _ref) => {
helper(label);
return null;
}),
);

View file

@ -0,0 +1,16 @@
// MobX `observer` HOC: `const Card = observer((props) => { ... })`. Same
// shape as `memo` but the wrapper is named `observer`. Used heavily in
// MobX-based React codebases.
import { helper } from './helpers';
const observer = <P,>(render: (props: P) => unknown) => render;
interface ItemProps {
label: string;
}
export const Item = observer<ItemProps>(({ label }) => {
helper(label);
return null;
});

View file

@ -0,0 +1,16 @@
// React Hook callback bound to a const — `useCallback((...) => ..., [deps])`.
// Calls inside the callback body should attribute to `handleClick` /
// `handleSubmit`, the names the developer wrote on the LHS.
import { doStuff, fmt } from './helpers';
const useCallback = <F extends (...args: unknown[]) => unknown>(fn: F, _deps: unknown[]): F => fn;
export const handleClick = useCallback(() => {
const n = doStuff(1);
fmt(n);
}, []);
export const handleSubmit = useCallback((value: number) => {
doStuff(value);
}, []);

View file

@ -0,0 +1,12 @@
// `useMemo((...) => value, [deps])` — same shape as useCallback but the
// arrow returns a value instead of a callable. The shape we care about is
// `const X = useMemo(() => { ... }, [...])` and the test is symmetric:
// calls inside should attribute to `computed`.
import { doStuff } from './helpers';
const useMemo = <T>(factory: () => T, _deps: unknown[]): T => factory();
export const computed = useMemo(() => {
return doStuff(42);
}, []);

View file

@ -0,0 +1,6 @@
import { transform } from './helpers';
// Control: a "plain helper" pattern that the bug report says hits ~100%
// capture. If THIS edge is missing, the bug is more fundamental than
// HOF-callback attribution.
export const direct = (x: string): string => transform(x);

View file

@ -0,0 +1,6 @@
// Plain helpers — the targets of HOF-callback calls.
// `transform` is intentionally not a Node.js / browser global so naming
// collisions with built-ins don't pollute the resolution test.
export const fetchData = (): string => 'data';
export const transform = (x: string): string => x.toUpperCase();

View file

@ -0,0 +1,53 @@
// Multi-action Zustand-shape store: the regression case the single-pair
// `bump` fixture in `store.ts` masked.
//
// Mirrors the dominant Zustand idiom from the bug report:
//
// create<State>()(persist((set) => ({
// addItem: (item) => doA(item),
// removeItem: (item) => doB(item),
// fetchData: async () => doC(),
// })))
//
// With the pre-fix `pair`-with-arrow query patterns, all three
// pair-function defs landed in the same `(set) => ({...})` callback's
// `ownedDefs`. `resolveCallerGraphId.ownedDefs.find(d => d.type === 'Function')`
// then returned `addItem` for every walk-up — every call inside
// `removeItem` and `fetchData` mis-attributed to `addItem`. `gitnexus_context("removeItem")`
// returned zero outgoing edges; `gitnexus_impact("doB", direction:"upstream")`
// missed `removeItem` entirely.
//
// After moving `@declaration.function` to the inner `arrow_function`,
// each pair-arrow gets its own scope's `ownedDefs` populated with its own
// def. Calls inside `removeItem` now stop at `removeItem`'s arrow scope,
// resolve to `removeItem`, and the per-action attribution is correct.
import { create, persist } from './store';
interface MultiAction {
readonly count: number;
readonly addItem: (item: number) => void;
readonly removeItem: (item: number) => void;
readonly fetchData: () => void;
}
export const doA = (_item: number): void => {};
export const doB = (_item: number): void => {};
export const doC = (): void => {};
export const useMultiActionStore = create<MultiAction>()(
persist((set) => ({
count: 0,
addItem: (item) => {
doA(item);
set({ count: 1 });
},
removeItem: (item) => {
doB(item);
set({ count: 0 });
},
fetchData: () => {
doC();
},
})),
);

View file

@ -0,0 +1,11 @@
import { transform } from './helpers';
// Pattern: call inside `new Promise((resolve, reject) => ...)` callback.
// Mirrors `fileToDataUrl` from the bug report. The synchronous body of
// the executor invokes `transform` directly — the edge `wrap → transform`
// should exist regardless of the surrounding HOF.
export const wrap = (x: string): Promise<string> =>
new Promise<string>((resolve) => {
const v = transform(x);
resolve(v);
});

View file

@ -0,0 +1,9 @@
import { transform } from './helpers';
// Pattern: call inside `.map` callback inside `Promise.all(...)`.
// Mirrors `clients/web/src/utils/file-upload.ts` `processSelectedFiles`
// from the bug report — `transform` should be reachable as
// `fanOut → transform`.
export const fanOut = async (xs: string[]): Promise<string[]> => {
return Promise.all(xs.map((x) => transform(x)));
};

View file

@ -0,0 +1,18 @@
import { fetchData } from './helpers';
// Stand-in for TanStack Query's `useQuery` — accepts a config object.
// Defined in-fixture (rather than imported from an external lib) so the
// `useQuery` reference resolves to a real graph node and we can also
// assert the `useFeature → useQuery` edge.
export const useQuery = <T>(opts: { queryFn: () => T; queryKey: readonly string[] }): T => {
return opts.queryFn();
};
// Pattern: call inside `queryFn` callback passed to a HOF.
// Mirrors `clients/*/src/hooks/use-gateway-queries.ts` from the bug
// report (4% capture rate). `useFeature → fetchData` should be reachable.
export const useFeature = (): string =>
useQuery({
queryFn: () => fetchData(),
queryKey: ['feature'],
});

View file

@ -0,0 +1,46 @@
// Stand-ins for Zustand's `create / persist / devtools`. Defined locally
// so the references resolve to real graph nodes and the test doesn't
// depend on an external library.
//
// The shape mirrors the actual Zustand API closely enough that the
// nested-arrow-in-arguments pattern (which was the worst-case repro in
// the bug report — Zustand store files hit 0% CALLS-edge capture) is
// faithfully reproduced.
type StateCreator<T> = (set: (next: Partial<T>) => void) => T;
export const create = <T>() => {
return (initializer: (...wrappers: never[]) => StateCreator<T>): T => {
return initializer()(() => {
/* runtime no-op for this fixture */
});
};
};
export const persist = <T>(creator: StateCreator<T>): StateCreator<T> => creator;
export const devtools = <T>(creator: StateCreator<T>): StateCreator<T> => creator;
interface Counter {
readonly count: number;
readonly bump: () => void;
}
// The classic Zustand shape:
//
// const useStore = create<State>()(devtools(persist((set) => ({ ... }))))
//
// Pre-fix: `useStore` lived as a Variable on the module scope; the
// nested arrow callbacks had empty `ownedDefs`; calls inside them
// walked up to the module's first Function-like def and silently
// mis-attributed (or dropped). After the fix, each named arrow gets
// its def attached to its own scope and HOF-wrapped declarations
// participate in the call graph normally.
export const useStore = create<Counter>()(
devtools(
persist((set) => ({
count: 0,
bump: () => set({ count: 1 }),
})),
),
);

View file

@ -0,0 +1,12 @@
// Component targets used by the JSX-as-call tests. The bodies are
// trivial — what matters is that they're declared as named arrow
// functions returning JSX (the canonical React component shape) so the
// scope-resolution pipeline emits Function defs for them.
export const Foo = (): string => 'foo';
export const Bar = (): string => 'bar';
export const Inner = (): string => 'inner';
export const Outer = (): string => 'outer';

View file

@ -0,0 +1,9 @@
import { Foo } from './components';
// Combined fix verification: an arrow-typed component (the HOF-callback
// fix's central case — `const fn = () => ...`) whose body returns JSX
// (the JSX-as-call fix). The HOF fix anchors the Function def on the
// inner arrow so caller-attribution lands on `Wrapped`; the JSX fix
// emits the `<Foo />` reference site as `@reference.call.free`. The
// combined effect: `Wrapped → Foo` is captured.
export const Wrapped = () => <Foo />;

View file

@ -0,0 +1,11 @@
// Pure-HTML JSX — `<div>`, `<span>`, `<button>`. By JSX convention,
// lowercase-first-character identifiers are native DOM elements (NOT
// React components). The query's `(#match? @reference.name "^[A-Z]")`
// predicate filters these out, so this caller must NOT emit any CALLS
// edges to identifiers `div` / `span` / `button`.
export const useHtml = () => (
<div>
<span>text</span>
<button>click</button>
</div>
);

View file

@ -0,0 +1,12 @@
import { Container } from './namespaced';
// Namespaced JSX — `<Container.Title />`. The query's
// `@reference.call.member` capture splits this into:
//
// receiver: `Container` (an identifier)
// property: `Title` (the leaf identifier)
//
// Note: Member-form JSX is NOT filtered by the PascalCase predicate —
// HTML element names can't contain dots, so any `.`-form is unambiguously
// a component reference.
export const useNamespaced = () => <Container.Title />;

View file

@ -0,0 +1,14 @@
// Namespaced component — the canonical `<Container.Title />` idiom
// (used by libraries like Radix UI, shadcn/ui, Headless UI). Exposes a
// `Container` object whose members are themselves React components, so
// JSX consumers write `<Container.Title>` instead of importing each
// piece individually.
//
// The TSX grammar represents `<Foo.Bar />` as `jsx_self_closing_element
// name: (member_expression ...)`. Our query's `@reference.call.member`
// capture decomposes the member chain so the downstream member-call
// resolver can route the edge to the right `Title` definition.
const Title = () => 'title';
export const Container = { Title };

View file

@ -0,0 +1,13 @@
import { Inner, Outer } from './components';
// Nested JSX — `<Outer><Inner /></Outer>`. Both `<Outer>` (paired) and
// `<Inner />` (self-closing) are reference sites for the same enclosing
// caller (`useNested`). Should emit TWO CALLS edges from `useNested`:
//
// useNested → Outer
// useNested → Inner
export const useNested = () => (
<Outer>
<Inner />
</Outer>
);

View file

@ -0,0 +1,6 @@
import { Bar } from './components';
// Paired JSX element (`<Bar>...</Bar>`). The query captures
// `jsx_opening_element` (NOT `jsx_closing_element`) so each JSX use
// emits exactly one CALLS edge — the closing tag would double-count.
export const useBar = () => <Bar>child text</Bar>;

View file

@ -0,0 +1,5 @@
import { Foo } from './components';
// Self-closing JSX element — the most common React-component invocation
// shape. Should emit `useFoo → Foo` as a CALLS edge.
export const useFoo = () => <Foo />;

View file

@ -1,54 +0,0 @@
/**
* Vitest globalSetup — runs once in the MAIN process before any forks.
*
* Creates a single shared LadybugDB with full schema so that forked test
* files only need to clear + reseed data instead of recreating the
* entire schema each time (~29 DDL queries per file eliminated).
*
* The dbPath is shared with test files via vitest's provide/inject API.
*/
import path from 'path';
import lbug from '@ladybugdb/core';
import type { GlobalSetupContext } from 'vitest/node';
import { createTempDir } from './helpers/test-db.js';
import {
NODE_SCHEMA_QUERIES,
REL_SCHEMA_QUERIES,
EMBEDDING_SCHEMA,
} from '../src/core/lbug/schema.js';
export default async function setup({ provide }: GlobalSetupContext) {
const tmpHandle = await createTempDir('gitnexus-shared-');
const dbPath = path.join(tmpHandle.dbPath, 'lbug');
// Create DB with full schema
const db = new lbug.Database(dbPath);
const conn = new lbug.Connection(db);
for (const q of NODE_SCHEMA_QUERIES) {
await conn.query(q);
}
for (const q of REL_SCHEMA_QUERIES) {
await conn.query(q);
}
await conn.query(EMBEDDING_SCHEMA);
// Pre-install FTS extension so forks don't need to download it
try {
await conn.query('INSTALL fts');
await conn.query('LOAD EXTENSION fts');
} catch {
// FTS may already be installed system-wide — not fatal
}
await conn.close();
await db.close();
// Share the dbPath with all test files via inject('lbugDbPath')
provide('lbugDbPath', dbPath);
// Teardown: remove temp directory after all tests complete
return async () => {
await tmpHandle.cleanup();
};
}

Some files were not shown because too many files have changed in this diff Show more