diff --git a/.github/scripts/check-tree-sitter-upgrade-readiness.py b/.github/scripts/check-tree-sitter-upgrade-readiness.py index df21533a8..f54afd7f0 100644 --- a/.github/scripts/check-tree-sitter-upgrade-readiness.py +++ b/.github/scripts/check-tree-sitter-upgrade-readiness.py @@ -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 + ". . Do NOT ." — 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/. + + 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
+ # 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/. 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( + "
Click to expand the raw per-grammar table " + "(used by the workflow's change-detection bot).\n" + ) + lines.extend(raw_matrix) + lines.append("\n
") + 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()) diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index a017af628..af0e2758d 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a588d66b..3059a34dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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-`; 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-`; 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 ───────────────────────────────── diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index e5642cb3e..000000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -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 }}' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 553d3ab0d..cfba3ecbc 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -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 }}' diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 3c0c52725..51664bba8 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -105,7 +105,7 @@ jobs: # Pinned to v7.2.0. Verify SHA via: # gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0 # v7 removed `disable-releaser`; use `dry-run: true` to only autolabel. - - uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0 + - uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1 with: config-name: release-drafter.yml dry-run: true diff --git a/.gitignore b/.gitignore index bbebd405d..2c370d2d0 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,4 @@ local_docs/ .tmp/ .agents/ .context/ +gitnexus/web/ diff --git a/README.md b/README.md index 7f4b36a9b..ade7a4879 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72 > *Like DeepWiki, but deeper.* DeepWiki helps you *understand* code. GitNexus lets you *analyze* it — because a knowledge graph tracks every relationship, not just descriptions. -**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with goliath models. +**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with Goliath models. --- @@ -324,19 +324,21 @@ flowchart TD ## Web UI (browser-based) -A fully client-side graph explorer and AI chat. No server, no install — your code never leaves the browser. +A client-side graph explorer and AI chat — your code never leaves your machine. -**Try it now:** [gitnexus.vercel.app](https://gitnexus.vercel.app) — drag & drop a ZIP and start exploring. +**Try it now:** [gitnexus.vercel.app](https://gitnexus.vercel.app) — run `npx gitnexus@latest serve` locally and the page auto-connects to your local backend. gitnexus_img -Or run locally: +Or run the frontend locally: ```bash git clone https://github.com/abhigyanpatwari/gitnexus.git cd gitnexus/gitnexus-shared && npm install && npm run build cd ../gitnexus-web && npm install npm run dev +# Then in another terminal, start the backend the frontend connects to: +npx gitnexus@latest serve ``` ## Docker diff --git a/docker-compose.yaml b/docker-compose.yaml index d17f012db..297eab85e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -19,7 +19,7 @@ services: - ${WORKSPACE_DIR:-./workspace}:/workspace:ro restart: unless-stopped healthcheck: - test: ['CMD', 'curl', '-fsS', 'http://localhost:4747/api/heartbeat'] + test: ['CMD', 'curl', '-fsSI', 'http://localhost:4747/api/heartbeat'] interval: 30s timeout: 5s retries: 3 diff --git a/docs/guides/microservices-grpc.md b/docs/guides/microservices-grpc.md index afe6b2499..b09fd55e3 100644 --- a/docs/guides/microservices-grpc.md +++ b/docs/guides/microservices-grpc.md @@ -82,6 +82,9 @@ matching: bm25_threshold: 0.7 embedding_threshold: 0.65 max_candidates_per_step: 3 + # Exclude noisy paths from cross-link matching (contracts are still extracted) + exclude_links_paths: [/ping, /health, /healthcheck] + exclude_links_param_only_paths: true ``` Field notes (schema in [`types.ts`](../../gitnexus/src/core/group/types.ts)): @@ -91,7 +94,9 @@ Field notes (schema in [`types.ts`](../../gitnexus/src/core/group/types.ts)): - `repos` — a mapping from **group path** (a logical name you choose; can be a hierarchy like `backend/orders`) to **registry name** (the name shown by `npx gitnexus list`). Both sides appear throughout the tooling: contract rows use the group path; `@/` routes tools to a single member. - `links` — optional manifest escape hatch, one entry per explicit cross-repo contract. Validated by the parser: `from` and `to` must be known repo paths, `type` must be one of `http | grpc | topic | lib | custom`, and `role` must be `provider | consumer`. - `detect` — toggles per extractor family. Defaults (set in `config-parser.ts`) turn `http`, `grpc`, `topics`, and `shared_libs` on; disable the ones you don't use to speed up sync. -- `matching` — thresholds for the matching cascade. The exact match is always run; other strategies depend on indexer state. +- `matching` — thresholds for the matching cascade. The exact match is always run; other strategies depend on indexer state. Two optional fields reduce false-positive cross-links in large groups: + - `exclude_links_paths` — list of HTTP paths to exclude from cross-link matching (default `[]`). Contracts at these paths are still extracted and visible in the registry, but they don't produce cross-repo links. Useful for health-check endpoints (`/ping`, `/health`) that every service exposes. Trailing slashes are normalized. + - `exclude_links_param_only_paths` — when `true`, exclude routes where every segment is `{param}` (e.g. `/{param}`, `/{param}/{param}`) from cross-link matching (default `false`). Mixed routes like `/users/{param}` are not affected. ### 3. Sync the group diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index e29726d85..7d8fbfda4 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -31,11 +31,25 @@ function readInput() { * Find the .gitnexus directory by walking up from startDir. * Returns the path to .gitnexus/ or null if not found. */ -function findGitNexusDir(startDir) { - let dir = startDir || process.cwd(); +function isGlobalRegistryDir(candidate) { + if (fs.existsSync(path.join(candidate, 'meta.json'))) return false; + return ( + fs.existsSync(path.join(candidate, 'registry.json')) || + fs.existsSync(path.join(candidate, 'repos')) + ); +} + +/** + * Walk up from `startDir` looking for a non-registry `.gitnexus/` folder. + * Returns the path to `.gitnexus/` or null if not found within 5 levels. + */ +function walkForGitNexusDir(startDir) { + let dir = startDir; for (let i = 0; i < 5; i++) { const candidate = path.join(dir, '.gitnexus'); - if (fs.existsSync(candidate)) return candidate; + if (fs.existsSync(candidate)) { + if (!isGlobalRegistryDir(candidate)) return candidate; + } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -43,6 +57,51 @@ function findGitNexusDir(startDir) { return null; } +/** + * Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside + * any git working tree — including a *linked* worktree created via + * `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the + * upward walk from cwd alone misses the index. Returns null when `cwd` is + * not inside a git repo or `git` is not available. + * + * Implementation: `git rev-parse --git-common-dir` resolves to the canonical + * `.git/` directory (or `.git/worktrees/...` parent) that is shared across + * all linked worktrees. The canonical repo root is its parent directory. + */ +function findCanonicalRepoRoot(cwd) { + try { + const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + if (result.error || result.status !== 0) return null; + const commonDir = (result.stdout || '').trim(); + if (!commonDir || !path.isAbsolute(commonDir)) return null; + return path.dirname(commonDir); + } catch { + return null; + } +} + +function findGitNexusDir(startDir) { + const cwd = startDir || process.cwd(); + + // Fast path: the cwd is inside the canonical repo (most common case). + const fromCwd = walkForGitNexusDir(cwd); + if (fromCwd) return fromCwd; + + // Fallback: cwd may be inside a linked git worktree whose `.gitnexus/` + // only lives in the canonical repo root. Resolve the shared git dir + // and retry from there. + const canonicalRoot = findCanonicalRepoRoot(cwd); + if (canonicalRoot && canonicalRoot !== cwd) { + return walkForGitNexusDir(canonicalRoot); + } + return null; +} + /** * Extract search pattern from tool input. */ diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index 5ebb098f1..d363c3c97 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -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 = 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, files: Set): number { diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 1214e61a4..61506f60c 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -13,7 +13,7 @@ "@langchain/google-genai": "^2.1.28", "@langchain/langgraph": "^1.2.9", "@langchain/ollama": "^1.2.6", - "@langchain/openai": "^1.4.4", + "@langchain/openai": "^1.4.5", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.2.4", "axios": "^1.13.2", @@ -32,15 +32,15 @@ "mermaid": "^11.14.0", "mnemonist": "^0.39.0", "pandemonium": "^2.4.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", - "react-zoom-pan-pinch": "^3.7.0", + "react-zoom-pan-pinch": "^4.0.3", "remark-gfm": "^4.0.1", "sigma": "^3.0.2", "tailwindcss": "^4.2.4", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "zod": "^3.25.76" }, "devDependencies": { @@ -50,9 +50,9 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/dompurify": "^3.0.5", - "@types/node": "^24.10.1", - "@types/react": "^18.3.5", - "@types/react-dom": "^18.3.0", + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.5.16", "@vitejs/plugin-react": "^5.1.4", @@ -1435,9 +1435,9 @@ } }, "node_modules/@langchain/core": { - "version": "1.1.41", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.41.tgz", - "integrity": "sha512-KdoNEf1YVJ9jnOP+smq4O6teu63tE7GDUryOnZ2lVfooHLrHK/ECUadjOcDSCK/yk/xBw/8nexJ3ZNBMtKnstw==", + "version": "1.1.42", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.42.tgz", + "integrity": "sha512-d0tN96BrwPMryYyWR9VfyAntSivn7EQrZCe5Kpxum93tcjTXbKKmKvItFec8AluQt88iTcmAJrahUZUNfzGwTA==", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.0.2", @@ -1449,26 +1449,12 @@ "langsmith": ">=0.5.0 <1.0.0", "mustache": "^4.2.0", "p-queue": "^6.6.2", - "uuid": "^11.1.0", "zod": "^3.25.76 || ^4" }, "engines": { "node": ">=20" } }, - "node_modules/@langchain/core/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/@langchain/google-genai": { "version": "2.1.28", "resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.1.28.tgz", @@ -1552,9 +1538,9 @@ } }, "node_modules/@langchain/langgraph-sdk": { - "version": "1.8.9", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.9.tgz", - "integrity": "sha512-vpz90auS4iFTNy2X/CFexOEoeFSvaK+MyI7iSmzYs9gGcfzwRjWUJ4MWsuc5ZNRecLStwho0PExVXRgGOXtcRw==", + "version": "1.8.10", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.10.tgz", + "integrity": "sha512-wrB3rkRw5KAmsqezwvKP3midT4qJrV6Hj9XJMYo+cbvXC4HYpSAmyY/VriSyeTFRbLG/OP/pY2Yz+9Z54nSaXQ==", "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.15", @@ -1594,12 +1580,12 @@ "license": "MIT" }, "node_modules/@langchain/langgraph-sdk/node_modules/p-queue": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.2.tgz", - "integrity": "sha512-ktsDOALzTYTWWF1PbkNVg2rOt+HaOaMWJMUnt7T3qf5tvZ1L8dBW3tObzprBcXNMKkwj+yFSLqHso0x+UFcJXw==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.2.0.tgz", + "integrity": "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==", "license": "MIT", "dependencies": { - "eventemitter3": "^5.0.1", + "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" }, "engines": { @@ -1621,6 +1607,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@langchain/langgraph-sdk/node_modules/uuid": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz", + "integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/@langchain/langgraph/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -1664,20 +1663,20 @@ } }, "node_modules/@langchain/openai": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.4.4.tgz", - "integrity": "sha512-mRr/X5rvlwPj6cSXPxbL+CtOqYANO1/+CQ3Z+5t48kWnrlgPYOazmA+UAWvqQOuwJ6LaYn3SFrt43rR4lte/Ow==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.4.5.tgz", + "integrity": "sha512-bQ2WMIZfSh02trJLYSAtiIcD3j6EBCiAm9nw0dZWQsVaUxmWc3JJqs8uUte6AkMazmLHzcUIw+14UkXO5fRJvQ==", "license": "MIT", "dependencies": { "js-tiktoken": "^1.0.12", - "openai": "^6.32.0", + "openai": "^6.34.0", "zod": "^3.25.76 || ^4" }, "engines": { "node": ">=20" }, "peerDependencies": { - "@langchain/core": "^1.1.39" + "@langchain/core": "^1.1.42" } }, "node_modules/@mapbox/node-pre-gyp": { @@ -2885,13 +2884,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", - "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/prismjs": { @@ -2900,30 +2899,23 @@ "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", "license": "MIT" }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", "dependencies": { - "@types/prop-types": "*", "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^18.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/react-syntax-highlighter": { @@ -5505,6 +5497,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/jsdom": { @@ -6047,18 +6040,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lowlight": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", @@ -7746,28 +7727,24 @@ "license": "MIT" }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.2.5" } }, "node_modules/react-is": { @@ -7836,9 +7813,9 @@ } }, "node_modules/react-zoom-pan-pinch": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz", - "integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-4.0.3.tgz", + "integrity": "sha512-N2Hi6L78fFmhRra+ORpFSW7WST5x6kxpOPplIvtB0b7b+U2anpo1z1wLgaWRPS2kUSqcraRG+JgBCIlDJnqqAg==", "license": "MIT", "engines": { "node": ">=8", @@ -8093,13 +8070,10 @@ } }, "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" }, "node_modules/semver": { "version": "7.7.3", @@ -8558,9 +8532,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "devOptional": true, "license": "MIT" }, @@ -8693,9 +8667,9 @@ } }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 836bad7b0..6d08273f5 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -24,7 +24,7 @@ "@langchain/google-genai": "^2.1.28", "@langchain/langgraph": "^1.2.9", "@langchain/ollama": "^1.2.6", - "@langchain/openai": "^1.4.4", + "@langchain/openai": "^1.4.5", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.2.4", "axios": "^1.13.2", @@ -42,15 +42,15 @@ "mermaid": "^11.14.0", "mnemonist": "^0.39.0", "pandemonium": "^2.4.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", - "react-zoom-pan-pinch": "^3.7.0", + "react-zoom-pan-pinch": "^4.0.3", "remark-gfm": "^4.0.1", "sigma": "^3.0.2", "tailwindcss": "^4.2.4", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "zod": "^3.25.76" }, "devDependencies": { @@ -60,9 +60,9 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/dompurify": "^3.0.5", - "@types/node": "^24.10.1", - "@types/react": "^18.3.5", - "@types/react-dom": "^18.3.0", + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.5.16", "@vitejs/plugin-react": "^5.1.4", diff --git a/gitnexus-web/src/hooks/useAutoScroll.ts b/gitnexus-web/src/hooks/useAutoScroll.ts index 55c2946f7..fb2a9ec97 100644 --- a/gitnexus-web/src/hooks/useAutoScroll.ts +++ b/gitnexus-web/src/hooks/useAutoScroll.ts @@ -4,8 +4,8 @@ const DEFAULT_BOTTOM_THRESHOLD = 100; const USER_SCROLL_EPSILON = 5; export interface UseAutoScrollResult { - scrollContainerRef: React.RefObject; - messagesContainerRef: React.RefObject; + scrollContainerRef: React.RefObject; + messagesContainerRef: React.RefObject; isAtBottom: boolean; scrollToBottom: (behavior?: ScrollBehavior) => void; } diff --git a/gitnexus-web/src/hooks/useSigma.ts b/gitnexus-web/src/hooks/useSigma.ts index 8f350f0d3..461de0f5a 100644 --- a/gitnexus-web/src/hooks/useSigma.ts +++ b/gitnexus-web/src/hooks/useSigma.ts @@ -65,7 +65,7 @@ interface UseSigmaOptions { } interface UseSigmaReturn { - containerRef: React.RefObject; + containerRef: React.RefObject; sigmaRef: React.RefObject; setGraph: (graph: Graph) => void; zoomIn: () => void; diff --git a/gitnexus/Dockerfile.test b/gitnexus/Dockerfile.test index 7cafbe2c1..0282129ff 100644 --- a/gitnexus/Dockerfile.test +++ b/gitnexus/Dockerfile.test @@ -3,7 +3,8 @@ WORKDIR /app RUN apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* COPY . . RUN npm ci --ignore-scripts \ - && node scripts/patch-tree-sitter-swift.cjs \ + && npm rebuild tree-sitter-swift 2>&1 \ + && node -e "require('tree-sitter-swift')" \ && (npm rebuild 2>&1 || true) \ && cd node_modules/tree-sitter-kotlin && npx --yes node-gyp rebuild 2>&1 CMD ["npx", "vitest", "run", "test/integration", "--reporter=verbose"] diff --git a/gitnexus/README.md b/gitnexus/README.md index a764761ed..8a5144598 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -296,6 +296,25 @@ If `npm install -g gitnexus` fails on native modules: npm install -g gitnexus ``` +### Analyze warns about unavailable FTS or VECTOR extensions + +GitNexus uses optional DuckDB extensions for BM25 and vector search. The `gitnexus serve` and MCP read paths only ever try to `LOAD` the extensions — they never block on a network install. The `analyze` command, by default, attempts one bounded out-of-process `INSTALL` if `LOAD` fails and proceeds even when that install times out, so the index is always written to disk; BM25/vector search degrade gracefully until the extensions become available. + +Configure the behavior with two environment variables: + +| Variable | Values | Default | Effect | +|----------|--------|---------|--------| +| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded INSTALL if LOAD fails. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. | +| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process `INSTALL` child before it is killed. | + +```bash +# Offline/airgapped: never reach the network for extensions +GITNEXUS_LBUG_EXTENSION_INSTALL=load-only npx gitnexus analyze + +# Slow network: give extension downloads more time +GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS=30000 npx gitnexus analyze +``` + ### Analysis runs out of memory For very large repositories: diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 8b146acfc..7bfa150cd 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -31,11 +31,25 @@ function readInput() { * Find the .gitnexus directory by walking up from startDir. * Returns the path to .gitnexus/ or null if not found. */ -function findGitNexusDir(startDir) { - let dir = startDir || process.cwd(); +function isGlobalRegistryDir(candidate) { + if (fs.existsSync(path.join(candidate, 'meta.json'))) return false; + return ( + fs.existsSync(path.join(candidate, 'registry.json')) || + fs.existsSync(path.join(candidate, 'repos')) + ); +} + +/** + * Walk up from `startDir` looking for a non-registry `.gitnexus/` folder. + * Returns the path to `.gitnexus/` or null if not found within 5 levels. + */ +function walkForGitNexusDir(startDir) { + let dir = startDir; for (let i = 0; i < 5; i++) { const candidate = path.join(dir, '.gitnexus'); - if (fs.existsSync(candidate)) return candidate; + if (fs.existsSync(candidate)) { + if (!isGlobalRegistryDir(candidate)) return candidate; + } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -43,6 +57,51 @@ function findGitNexusDir(startDir) { return null; } +/** + * Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside + * any git working tree — including a *linked* worktree created via + * `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the + * upward walk from cwd alone misses the index. Returns null when `cwd` is + * not inside a git repo or `git` is not available. + * + * Implementation: `git rev-parse --git-common-dir` resolves to the canonical + * `.git/` directory (or `.git/worktrees/...` parent) that is shared across + * all linked worktrees. The canonical repo root is its parent directory. + */ +function findCanonicalRepoRoot(cwd) { + try { + const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + if (result.error || result.status !== 0) return null; + const commonDir = (result.stdout || '').trim(); + if (!commonDir || !path.isAbsolute(commonDir)) return null; + return path.dirname(commonDir); + } catch { + return null; + } +} + +function findGitNexusDir(startDir) { + const cwd = startDir || process.cwd(); + + // Fast path: the cwd is inside the canonical repo (most common case). + const fromCwd = walkForGitNexusDir(cwd); + if (fromCwd) return fromCwd; + + // Fallback: cwd may be inside a linked git worktree whose `.gitnexus/` + // only lives in the canonical repo root. Resolve the shared git dir + // and retry from there. + const canonicalRoot = findCanonicalRepoRoot(cwd); + if (canonicalRoot && canonicalRoot !== cwd) { + return walkForGitNexusDir(canonicalRoot); + } + return null; +} + /** * Extract search pattern from tool input. */ diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 1c9211d09..61ddca305 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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", @@ -65,17 +65,17 @@ "optionalDependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0", - "tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", + "tree-sitter-dart": "file:./vendor/tree-sitter-dart", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-proto": "file:./vendor/tree-sitter-proto", - "tree-sitter-swift": "^0.6.0" + "tree-sitter-swift": "file:./vendor/tree-sitter-swift" } }, "../gitnexus-shared": { "version": "1.0.0", "dev": true, "devDependencies": { - "typescript": "^6.0.2" + "typescript": "^6.0.3" } }, "node_modules/@babel/helper-string-parser": { @@ -1159,9 +1159,9 @@ } }, "node_modules/@ladybugdb/core": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.15.3.tgz", - "integrity": "sha512-Xa8VmWhMTvTCWmApnqm9FJtyxxV+CiMCokl1p9vEfXNuBz3SWXWGDmHlzKikswtQbUe9tTV3J9MxPdVFVE6/yg==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.16.0.tgz", + "integrity": "sha512-t/t4MPZmBMocFBzG5G3E3iHPwuIiXYEuLeW0CTOloGofkKQ7gHt3JlLzyDn2a+AHNQjr1YqlsodKKYQFhsFZXw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1169,16 +1169,17 @@ "node-addon-api": "^6.0.0" }, "optionalDependencies": { - "@ladybugdb/core-darwin-arm64": "0.15.3", - "@ladybugdb/core-linux-arm64": "0.15.3", - "@ladybugdb/core-linux-x64": "0.15.3", - "@ladybugdb/core-win32-x64": "0.15.3" + "@ladybugdb/core-darwin-arm64": "0.16.0", + "@ladybugdb/core-darwin-x64": "0.16.0", + "@ladybugdb/core-linux-arm64": "0.16.0", + "@ladybugdb/core-linux-x64": "0.16.0", + "@ladybugdb/core-win32-x64": "0.16.0" } }, "node_modules/@ladybugdb/core-darwin-arm64": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.15.3.tgz", - "integrity": "sha512-+bqAb3wbbmxPSeNQjbVd6Ek5K8GbHr1KlDr09YkNqZ7XWhKqWxbs097xAG9bynLcZh9oxok2PGCoK4w5YHs11w==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.16.0.tgz", + "integrity": "sha512-2IpiUbd6Lb50KRUkURk+PIgDRKume63uI4KYZNpjxNDwdHRXdadZTBZn74+DgK7IhpTyiPbtKddiXHKtSV2CWg==", "cpu": [ "arm64" ], @@ -1189,9 +1190,9 @@ ] }, "node_modules/@ladybugdb/core-linux-arm64": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.15.3.tgz", - "integrity": "sha512-Z8Ur6YbC5y6pgtKh/7b1/xdeRHy69sGhsoVJm1tc9xp9Zrar6G2A71bEdjOdDJ/mDRt6RtY0zdhUgIgQXYQtbQ==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.16.0.tgz", + "integrity": "sha512-l+lV7BXfnA0w1voApKblBaGE+bKQqSlOG+30HkSYOAW7POYv+OoydgY/BGwabBUTvcnhVyrNApvBsPF8G3Nm3g==", "cpu": [ "arm64" ], @@ -1202,9 +1203,9 @@ ] }, "node_modules/@ladybugdb/core-linux-x64": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.15.3.tgz", - "integrity": "sha512-DT9xBc91tuxzjRu1dJ3xGt/K/uR1Q8bX5+8tCtj66UbVIVvp1RWAAE9phq7eahcF/3zBuFRonkxW/tTyQdQIlQ==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.16.0.tgz", + "integrity": "sha512-XOL2H0y51e57dIFIHO8LHtN8Ner2qEyti6zAkxKr+w8LkvczHeVX910doz2de8+xvxDYJyzrcj2xWqDTxcK/Jg==", "cpu": [ "x64" ], @@ -1215,9 +1216,9 @@ ] }, "node_modules/@ladybugdb/core-win32-x64": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.15.3.tgz", - "integrity": "sha512-ymHC8nHGIT7M9aditBQFIystxW+WoqvI3xklz22BHaFpU9CrTNtdU20K6cuRZvqEA2//Edu7kMoP9OwLkIleCg==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.16.0.tgz", + "integrity": "sha512-MyKiELqPgzx9gVHmwxzptnToAcDtCN7dTP5Y4IPMYhc2QpNbZKCihmvdXjbOmdIOfIHW4fBp4vrzT8fVbdAMZw==", "cpu": [ "x64" ], @@ -1227,6 +1228,9 @@ "win32" ] }, + "node_modules/@ladybugdb/core/node_modules/@ladybugdb/core-darwin-x64": { + "optional": true + }, "node_modules/@ladybugdb/core/node_modules/node-addon-api": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", @@ -2428,13 +2432,6 @@ "node": ">= 0.8" } }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -2763,12 +2760,6 @@ "node": ">=8" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "license": "MIT" - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2841,12 +2832,6 @@ "node": ">= 0.4" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "license": "MIT" - }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -3270,17 +3255,15 @@ } }, "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz", + "integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==", "license": "BSD-3-Clause", "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" + "globalthis": "^1.0.2", + "matcher": "^4.0.0", + "semver": "^7.3.5", + "serialize-error": "^8.1.0" }, "engines": { "node": ">=10.0" @@ -3612,12 +3595,6 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC" - }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -3951,15 +3928,18 @@ } }, "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz", + "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==", "license": "MIT", "dependencies": { "escape-string-regexp": "^4.0.0" }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/math-intrinsics": { @@ -4077,9 +4057,9 @@ } }, "node_modules/mnemonist": { - "version": "0.40.3", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", - "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", + "version": "0.40.4", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz", + "integrity": "sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==", "license": "MIT", "dependencies": { "obliterator": "^2.0.4" @@ -4214,15 +4194,15 @@ } }, "node_modules/onnxruntime-common": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", - "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.25.1.tgz", + "integrity": "sha512-kKvYQFdos4LWJqhZ+nmKu3NT8NXzw8I5x9fNUKe1rNKcPfNKnYXUtW7JBpcKFsvLtrJashRgVYSbFap4cHxvNg==", "license": "MIT" }, "node_modules/onnxruntime-node": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", - "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.25.1.tgz", + "integrity": "sha512-N0M58CGTiTsLkPpx9bxmRFi24GT6r67Qei/GrBEIiDyntcYdXU5vQZp112ypydG9vEKRFgbgUYQJnEi+jll8dg==", "hasInstallScript": true, "license": "MIT", "os": [ @@ -4232,8 +4212,8 @@ ], "dependencies": { "adm-zip": "^0.5.16", - "global-agent": "^3.0.0", - "onnxruntime-common": "1.24.3" + "global-agent": "^4.1.3", + "onnxruntime-common": "1.25.1" } }, "node_modules/onnxruntime-web": { @@ -4520,23 +4500,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "license": "BSD-3-Clause", - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/rolldown": { "version": "1.0.0-rc.16", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", @@ -4635,12 +4598,6 @@ "node": ">=10" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "license": "MIT" - }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -4681,12 +4638,12 @@ "license": "MIT" }, "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", "license": "MIT", "dependencies": { - "type-fest": "^0.13.1" + "type-fest": "^0.20.2" }, "engines": { "node": ">=10" @@ -4870,12 +4827,6 @@ "node": ">=0.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause" - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -5028,20 +4979,20 @@ } }, "node_modules/tree-sitter-c": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/tree-sitter-c/-/tree-sitter-c-0.23.2.tgz", - "integrity": "sha512-9kADOx31AF94DHcrsMGW0zM/2LS6v7wFkPHPVm7RQU+vYVVZMKZ2FJ9e99pm5feqsAcjUzB9CarqDLgRT1Fe/w==", + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/tree-sitter-c/-/tree-sitter-c-0.21.4.tgz", + "integrity": "sha512-IahxFIhXiY15SUlrt2upBiKSBGdOaE1fjKLK1Ik5zxqGHf6T1rvr3IJrovbsE5sXhypx7Hnmf50gshsppaIihA==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "node-addon-api": "^8.2.2", - "node-gyp-build": "^4.8.2" + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.1" }, "peerDependencies": { - "tree-sitter": "^0.21.1" + "tree-sitter": "^0.21.0" }, "peerDependenciesMeta": { - "tree-sitter": { + "tree_sitter": { "optional": true } } @@ -5065,30 +5016,15 @@ } } }, - "node_modules/tree-sitter-cli": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/tree-sitter-cli/-/tree-sitter-cli-0.23.2.tgz", - "integrity": "sha512-kPPXprOqREX+C/FgUp2Qpt9jd0vSwn+hOgjzVv/7hapdoWpa+VeWId53rf4oNNd29ikheF12BYtGD/W90feMbA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "bin": { - "tree-sitter": "cli.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/tree-sitter-cpp": { - "version": "0.23.4", - "resolved": "https://registry.npmjs.org/tree-sitter-cpp/-/tree-sitter-cpp-0.23.4.tgz", - "integrity": "sha512-qR5qUDyhZ5jJ6V8/umiBxokRbe89bCGmcq/dk94wI4kN86qfdV8k0GHIUEKaqWgcu42wKal5E97LKpLeVW8sKw==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/tree-sitter-cpp/-/tree-sitter-cpp-0.23.2.tgz", + "integrity": "sha512-GTa5Dx1O9ihzW70LvaUviTclh+wlBDRz6opR9Ij4NQIFmq/joeZ/k65UbLV4nLidR7xZ9eNNGT/SonCqAmjGVg==", "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.2.1", - "node-gyp-build": "^4.8.2", - "tree-sitter-c": "^0.23.1" + "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" @@ -5100,31 +5036,8 @@ } }, "node_modules/tree-sitter-dart": { - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", - "integrity": "sha512-Bs/1wAOIJ2akPEXlE/XVpuES19Oo3NqoSJRJ/0N2r38qAd9nTXdqmaGHQ44/JXnA6QHcbgD2YzCCc4wUc98cyQ==", - "hasInstallScript": true, - "license": "ISC", - "optional": true, - "dependencies": { - "node-addon-api": "^7.1.0", - "node-gyp-build": "^4.8.0" - }, - "peerDependencies": { - "tree-sitter": "^0.21.0" - }, - "peerDependenciesMeta": { - "tree_sitter": { - "optional": true - } - } - }, - "node_modules/tree-sitter-dart/node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT", - "optional": true + "resolved": "vendor/tree-sitter-dart", + "link": true }, "node_modules/tree-sitter-go": { "version": "0.23.4", @@ -5291,49 +5204,8 @@ } }, "node_modules/tree-sitter-swift": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tree-sitter-swift/-/tree-sitter-swift-0.6.0.tgz", - "integrity": "sha512-9vOJZes4/UFjBr4COHtp6ZHVuZYwfChSQbpneXQog04dAstfx5px3ybVX2cN+ylvLqsvVpmXLpidxxgF2rDQ7A==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0", - "tree-sitter-cli": "^0.23", - "which": "2.0.2" - }, - "peerDependencies": { - "tree-sitter": "^0.21.1" - }, - "peerDependenciesMeta": { - "tree_sitter": { - "optional": true - } - } - }, - "node_modules/tree-sitter-swift/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC", - "optional": true - }, - "node_modules/tree-sitter-swift/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "optional": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } + "resolved": "vendor/tree-sitter-swift", + "link": true }, "node_modules/tree-sitter-typescript": { "version": "0.23.2", @@ -5383,9 +5255,9 @@ } }, "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -5768,6 +5640,19 @@ "zod": "^3.25.28 || ^4" } }, + "vendor/tree-sitter-dart": { + "version": "1.0.0", + "license": "ISC", + "optional": true, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, "vendor/tree-sitter-proto": { "version": "0.4.1", "license": "MIT", @@ -5775,6 +5660,24 @@ "peerDependencies": { "tree-sitter": ">=0.21.0" } + }, + "vendor/tree-sitter-swift": { + "version": "0.7.1", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1 || ^0.22.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } } } } diff --git a/gitnexus/package.json b/gitnexus/package.json index 9045662aa..73ebd3c6c 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -47,13 +47,13 @@ "test:integration": "vitest run test/integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "postinstall": "node scripts/patch-tree-sitter-swift.cjs && node scripts/build-tree-sitter-proto.cjs", + "postinstall": "node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs", "prepare": "node scripts/build.js", "prepack": "node scripts/build.js" }, "dependencies": { "@huggingface/transformers": "^4.1.0", - "@ladybugdb/core": "^0.15.2", + "@ladybugdb/core": "^0.16.0", "@modelcontextprotocol/sdk": "^1.0.0", "@scarf/scarf": "^1.4.0", "cli-progress": "^3.12.0", @@ -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", @@ -88,10 +88,10 @@ "optionalDependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0", - "tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", + "tree-sitter-dart": "file:./vendor/tree-sitter-dart", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-proto": "file:./vendor/tree-sitter-proto", - "tree-sitter-swift": "^0.6.0" + "tree-sitter-swift": "file:./vendor/tree-sitter-swift" }, "devDependencies": { "@types/cli-progress": "^3.11.6", @@ -109,8 +109,7 @@ "overrides": { "@huggingface/transformers": { "onnxruntime-node": "$onnxruntime-node" - }, - "tree-sitter-c": "0.23.2" + } }, "engines": { "node": ">=20.0.0" diff --git a/gitnexus/scripts/build-tree-sitter-dart.cjs b/gitnexus/scripts/build-tree-sitter-dart.cjs new file mode 100644 index 000000000..60da9a751 --- /dev/null +++ b/gitnexus/scripts/build-tree-sitter-dart.cjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const dartDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-dart'); +const bindingGyp = path.join(dartDir, 'binding.gyp'); +const bindingNode = path.join(dartDir, 'build', 'Release', 'tree_sitter_dart_binding.node'); + +try { + if (!fs.existsSync(bindingGyp) || fs.existsSync(bindingNode)) { + process.exit(0); + } + + try { + require.resolve('node-addon-api'); + require.resolve('node-gyp-build'); + } catch (resolveErr) { + console.warn( + '[tree-sitter-dart] Skipping build: hoisted build deps not resolvable (%s).', + resolveErr.message, + ); + console.warn( + '[tree-sitter-dart] Dart parsing will be unavailable. Install without --no-optional and with scripts enabled to build.', + ); + process.exit(0); + } + + console.log('[tree-sitter-dart] Building native binding...'); + execSync('npx node-gyp rebuild', { + cwd: dartDir, + stdio: 'pipe', + timeout: 180000, + }); + console.log('[tree-sitter-dart] Native binding built successfully'); +} catch (err) { + console.warn('[tree-sitter-dart] Could not build native binding:', err.message); + console.warn( + '[tree-sitter-dart] Dart parsing will be unavailable. Non-Dart functionality is unaffected.', + ); + process.exit(0); +} diff --git a/gitnexus/scripts/build-tree-sitter-proto.cjs b/gitnexus/scripts/build-tree-sitter-proto.cjs index d2828d5ba..0690dfca3 100644 --- a/gitnexus/scripts/build-tree-sitter-proto.cjs +++ b/gitnexus/scripts/build-tree-sitter-proto.cjs @@ -26,7 +26,7 @@ * `node_modules/tree-sitter-proto/build/Release/tree_sitter_proto_binding.node` * — under npm-managed territory, safe on upgrade. * - * Mirrors scripts/patch-tree-sitter-swift.cjs. Best-effort: if any + * Mirrors the tree-sitter-dart build helper. Best-effort: if any * precondition fails (optional dep absent, no toolchain, --ignore-scripts), * warn and exit 0 so gitnexus install still succeeds. */ diff --git a/gitnexus/scripts/install-duckdb-extension.mjs b/gitnexus/scripts/install-duckdb-extension.mjs new file mode 100644 index 000000000..2bc65a05e --- /dev/null +++ b/gitnexus/scripts/install-duckdb-extension.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/; + +function parseLbugMaxDbSize(raw) { + const parsed = raw ? Number(raw) : NaN; + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid LadybugDB max DB size for extension installer: ${raw ?? ''}`); + } + return Math.floor(parsed); +} + +async function installDuckDbExtension(extensionName) { + if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) { + throw new Error(`Invalid DuckDB extension name: ${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'); + let db; + let conn; + + try { + db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize); + conn = new lbug.Connection(db); + await conn.query(`INSTALL ${extensionName}`); + } finally { + if (conn) await conn.close().catch(() => {}); + if (db) await db.close().catch(() => {}); + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } +} + +installDuckDbExtension(process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME).catch((err) => { + console.error(err instanceof Error ? (err.stack ?? err.message) : String(err)); + process.exitCode = 1; +}); diff --git a/gitnexus/scripts/patch-tree-sitter-swift.cjs b/gitnexus/scripts/patch-tree-sitter-swift.cjs deleted file mode 100644 index 6580b00e7..000000000 --- a/gitnexus/scripts/patch-tree-sitter-swift.cjs +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env node -/** - * WORKAROUND: tree-sitter-swift@0.6.0 binding.gyp build failure - * - * Background: - * tree-sitter-swift@0.6.0's binding.gyp contains an "actions" array that - * invokes `tree-sitter generate` to regenerate parser.c from grammar.js. - * This is intended for grammar developers, but the published npm package - * already ships pre-generated parser files (parser.c, scanner.c), so the - * actions are unnecessary for consumers. Since consumers don't have - * tree-sitter-cli installed, the actions always fail during `npm install`. - * - * Why we can't just upgrade: - * tree-sitter-swift@0.7.1 fixes this (removes postinstall, ships prebuilds), - * but it requires tree-sitter@^0.22.1. The upstream project pins tree-sitter - * to ^0.21.0 and all other grammar packages depend on that version. - * Upgrading tree-sitter would be a separate breaking change. - * - * How this workaround works: - * 1. tree-sitter-swift's own postinstall fails (npm warns but continues) - * 2. This script runs as gitnexus's postinstall - * 3. It removes the "actions" array from binding.gyp - * 4. It rebuilds the native binding with the cleaned binding.gyp - * - * TODO: Remove this script when tree-sitter is upgraded to ^0.22.x, - * which allows using tree-sitter-swift@0.7.1+ directly. - */ -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); - -const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift'); -const bindingPath = path.join(swiftDir, 'binding.gyp'); - -try { - if (!fs.existsSync(bindingPath)) { - process.exit(0); - } - - const content = fs.readFileSync(bindingPath, 'utf8'); - let needsRebuild = false; - - if (content.includes('"actions"')) { - // Strip Python-style comments (#) and trailing commas before JSON parsing - const cleaned = content - .replace(/#[^\n]*/g, '') // Remove # comments - .replace(/,(\s*[\]}])/g, '$1'); // Remove trailing commas before ] or } - const gyp = JSON.parse(cleaned); - - if (gyp.targets && gyp.targets[0] && gyp.targets[0].actions) { - delete gyp.targets[0].actions; - fs.writeFileSync(bindingPath, JSON.stringify(gyp, null, 2) + '\n'); - console.log('[tree-sitter-swift] Patched binding.gyp (removed actions array)'); - needsRebuild = true; - } - } - - // Check if native binding exists - const bindingNode = path.join(swiftDir, 'build', 'Release', 'tree_sitter_swift_binding.node'); - if (!fs.existsSync(bindingNode)) { - needsRebuild = true; - } - - if (needsRebuild) { - console.log('[tree-sitter-swift] Rebuilding native binding...'); - execSync('npx node-gyp rebuild', { - cwd: swiftDir, - stdio: 'pipe', - timeout: 120000, - }); - console.log('[tree-sitter-swift] Native binding built successfully'); - } -} catch (err) { - console.warn('[tree-sitter-swift] Could not build native binding:', err.message); - console.warn( - '[tree-sitter-swift] You may need to manually run: cd node_modules/tree-sitter-swift && npx node-gyp rebuild', - ); -} diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 712132ce4..2c100228d 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -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. */ @@ -93,11 +133,20 @@ export interface AnalyzeOptions { maxFileSize?: string; /** Override worker sub-batch idle timeout in seconds. */ workerTimeout?: string; + embeddingThreads?: string; + embeddingBatchSize?: string; + embeddingSubBatchSize?: string; + embeddingDevice?: string; } export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => { if (ensureHeap()) return; + // Install fatal handlers immediately after re-exec resolution so any + // async error that escapes the try/catch below (#1169) surfaces with + // a stack trace and a non-zero exit code instead of a silent exit 0. + installFatalHandlers(); + if (options?.verbose) { process.env.GITNEXUS_VERBOSE = '1'; } @@ -118,26 +167,70 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption ); } + const setPositiveEnv = ( + optionName: string, + envName: string, + value: string | undefined, + ): boolean => { + if (value === undefined) return true; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + console.error(` ${optionName} must be a positive integer.\n`); + process.exitCode = 1; + return false; + } + process.env[envName] = String(parsed); + return true; + }; + + if ( + !setPositiveEnv( + '--embedding-threads', + 'GITNEXUS_EMBEDDING_THREADS', + options?.embeddingThreads, + ) || + !setPositiveEnv( + '--embedding-batch-size', + 'GITNEXUS_EMBEDDING_BATCH_SIZE', + options?.embeddingBatchSize, + ) || + !setPositiveEnv( + '--embedding-sub-batch-size', + 'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE', + options?.embeddingSubBatchSize, + ) + ) { + return; + } + + if (options?.embeddingDevice) { + const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']); + if (!allowed.has(options.embeddingDevice)) { + console.error(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n'); + process.exitCode = 1; + return; + } + process.env.GITNEXUS_EMBEDDING_DEVICE = options.embeddingDevice; + } + console.log('\n GitNexus Analyzer\n'); let repoPath: string; if (inputPath) { repoPath = path.resolve(inputPath); + } else if (options?.skipGit) { + // --skip-git: treat cwd as the index root, do not walk up to a parent git repo. + repoPath = path.resolve(process.cwd()); } else { const gitRoot = getGitRoot(process.cwd()); if (!gitRoot) { - if (!options?.skipGit) { - console.log( - ' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n', - ); - process.exitCode = 1; - return; - } - // --skip-git: fall back to cwd as the root - repoPath = path.resolve(process.cwd()); - } else { - repoPath = gitRoot; + console.log( + ' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n', + ); + process.exitCode = 1; + return; } + repoPath = gitRoot; } const repoHasGit = hasGitDir(repoPath); @@ -266,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; @@ -278,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...'); @@ -379,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 ( diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts new file mode 100644 index 000000000..43866ae00 --- /dev/null +++ b/gitnexus/src/cli/doctor.ts @@ -0,0 +1,32 @@ +import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js'; +import { resolveEmbeddingConfig } from '../core/embeddings/config.js'; +import { isHttpMode } from '../core/embeddings/http-client.js'; + +export const doctorCommand = async () => { + const fingerprint = getRuntimeFingerprint(); + const capabilities = getRuntimeCapabilities(); + const embeddingConfig = resolveEmbeddingConfig(); + + console.log('GitNexus Doctor\n'); + console.log('Runtime'); + console.log(` OS: ${fingerprint.platform}/${fingerprint.arch}`); + console.log(` Node: ${fingerprint.node}`); + console.log(` GitNexus: ${fingerprint.gitnexus}`); + console.log(` LadybugDB: ${fingerprint.ladybugdb ?? 'unknown'}`); + console.log(` ONNX: ${fingerprint.onnxruntime ?? 'unknown'}`); + console.log(''); + console.log('Capabilities'); + console.log(` Graph store: ${capabilities.graph}`); + console.log(` Full-text search:${capabilities.fts.padStart(10)}`); + console.log(` VECTOR index: ${capabilities.vector}`); + console.log(` Semantic mode: ${capabilities.semanticMode}`); + console.log(` Exact scan limit:${String(capabilities.exactScanLimit).padStart(9)} chunks`); + if (capabilities.reason) console.log(` Note: ${capabilities.reason}`); + console.log(''); + console.log('Embeddings'); + console.log(` Backend: ${isHttpMode() ? 'http' : 'local'}`); + console.log(` Device: ${embeddingConfig.device}`); + console.log(` Threads: ${embeddingConfig.threads}`); + console.log(` Batch: ${embeddingConfig.batchSize} nodes`); + console.log(` Sub-batch: ${embeddingConfig.subBatchSize} chunks`); +}; diff --git a/gitnexus/src/cli/index-repo.ts b/gitnexus/src/cli/index-repo.ts index b909a40b5..52e8eb60d 100644 --- a/gitnexus/src/cli/index-repo.ts +++ b/gitnexus/src/cli/index-repo.ts @@ -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; diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 4e01fdeae..ca536dc80 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -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 ', 'Register this repo under a custom name in ~/.gitnexus/registry.json ' + @@ -52,6 +55,10 @@ program '--worker-timeout ', 'Worker sub-batch idle timeout before retry/fallback. Default: 30.', ) + .option('--embedding-threads ', 'Limit local ONNX embedding CPU threads') + .option('--embedding-batch-size ', 'Number of nodes per embedding batch') + .option('--embedding-sub-batch-size ', 'Number of chunks per embedding model call') + .option('--embedding-device ', 'Embedding device: auto, cpu, dml, cuda, or wasm') .addHelpText( 'after', '\nEnvironment variables:\n' + @@ -59,6 +66,8 @@ program ' GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n' + ' GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n' + ' GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n' + + ' GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n' + + ' GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n' + '\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n' + ' `!__tests__/` to index a directory that is auto-filtered by default (#771).', ) @@ -95,6 +104,11 @@ program .description('Show index status for current repo') .action(createLazyAction(() => import('./status.js'), 'statusCommand')); +program + .command('doctor') + .description('Show runtime platform capabilities and embedding configuration') + .action(createLazyAction(() => import('./doctor.js'), 'doctorCommand')); + program .command('clean') .description('Delete GitNexus index for current repo') diff --git a/gitnexus/src/core/embeddings/config.ts b/gitnexus/src/core/embeddings/config.ts new file mode 100644 index 000000000..7f09cf5af --- /dev/null +++ b/gitnexus/src/core/embeddings/config.ts @@ -0,0 +1,54 @@ +import { defaultEmbeddingThreads } from '../platform/capabilities.js'; +import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig } from './types.js'; + +const parsePositiveInt = (name: string, value: string | undefined, fallback: number): number => { + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer, got "${value}"`); + } + return parsed; +}; + +const parseDevice = (value: string | undefined): EmbeddingConfig['device'] | undefined => { + if (value === undefined) return undefined; + if ( + value === 'auto' || + value === 'dml' || + value === 'cuda' || + value === 'cpu' || + value === 'wasm' + ) { + return value; + } + throw new Error(`embedding device must be one of auto, dml, cuda, cpu, wasm; got "${value}"`); +}; + +export const resolveEmbeddingConfig = ( + overrides: Partial = {}, +): EmbeddingConfig => { + const env = process.env; + return { + ...DEFAULT_EMBEDDING_CONFIG, + ...overrides, + batchSize: parsePositiveInt( + 'GITNEXUS_EMBEDDING_BATCH_SIZE', + env.GITNEXUS_EMBEDDING_BATCH_SIZE, + overrides.batchSize ?? DEFAULT_EMBEDDING_CONFIG.batchSize, + ), + subBatchSize: parsePositiveInt( + 'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE', + env.GITNEXUS_EMBEDDING_SUB_BATCH_SIZE, + overrides.subBatchSize ?? DEFAULT_EMBEDDING_CONFIG.subBatchSize, + ), + threads: parsePositiveInt( + 'GITNEXUS_EMBEDDING_THREADS', + env.GITNEXUS_EMBEDDING_THREADS, + overrides.threads ?? defaultEmbeddingThreads(), + ), + device: + parseDevice(env.GITNEXUS_EMBEDDING_DEVICE) ?? + overrides.device ?? + DEFAULT_EMBEDDING_CONFIG.device, + }; +}; diff --git a/gitnexus/src/core/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index 66be646c1..0d7fe41df 100644 --- a/gitnexus/src/core/embeddings/embedder.ts +++ b/gitnexus/src/core/embeddings/embedder.ts @@ -15,13 +15,14 @@ if (!process.env.ORT_LOG_LEVEL) { } import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers'; -import os from 'os'; import { existsSync } from 'fs'; import { execFileSync } from 'child_process'; import { join, dirname } from 'path'; import { createRequire } from 'module'; import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js'; import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js'; +import { resolveEmbeddingConfig } from './config.js'; +import { applyHfEnvOverrides } from './hf-env.js'; /** * Check whether the onnxruntime-node package that @huggingface/transformers @@ -144,13 +145,12 @@ export const initEmbedder = async ( isInitializing = true; - const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config }; - // On Windows, use DirectML for GPU acceleration (via DirectX12) - // CUDA is only available on Linux x64 with onnxruntime-node + const finalConfig = resolveEmbeddingConfig(config); + // CUDA is probe-gated because ONNX Runtime can crash in native code when + // provider libraries are missing. DirectML stays opt-in for the same reason. // Probe for CUDA first — ONNX Runtime crashes (uncatchable native error) // if we attempt CUDA without the required shared libraries - const isWindows = process.platform === 'win32'; - const gpuDevice = isWindows ? 'dml' : isCudaAvailable() ? 'cuda' : 'cpu'; + const gpuDevice = isCudaAvailable() ? 'cuda' : 'cpu'; const requestedDevice = forceDevice || (finalConfig.device === 'auto' ? gpuDevice : finalConfig.device); @@ -158,11 +158,11 @@ export const initEmbedder = async ( try { // Configure transformers.js environment env.allowLocalModels = false; - // Default cache to user-writable location. transformers.js defaults to - // ./node_modules/.cache inside its own install dir, which is unwritable - // when gitnexus is installed globally (e.g. /usr/lib/node_modules/). - // Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface. - env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface'); + // Bridge user-controlled env vars to transformers.js: HF_HOME → + // env.cacheDir, HF_ENDPOINT → env.remoteHost (#1205). Centralised in + // applyHfEnvOverrides so the MCP embedder entry point behaves + // identically. + applyHfEnvOverrides(env); const isDev = process.env.NODE_ENV === 'development'; if (isDev) { @@ -205,7 +205,12 @@ export const initEmbedder = async ( device: device, dtype: 'fp32', progress_callback: progressCallback, - session_options: { logSeverityLevel: 3 }, + session_options: { + logSeverityLevel: 3, + intraOpNumThreads: finalConfig.threads, + interOpNumThreads: 1, + executionMode: 'sequential', + }, }); currentDevice = device; diff --git a/gitnexus/src/core/embeddings/embedding-pipeline.ts b/gitnexus/src/core/embeddings/embedding-pipeline.ts index be16789c2..82af563d4 100644 --- a/gitnexus/src/core/embeddings/embedding-pipeline.ts +++ b/gitnexus/src/core/embeddings/embedding-pipeline.ts @@ -27,7 +27,6 @@ import { type SemanticSearchResult, type ModelProgress, type EmbeddingContext, - DEFAULT_EMBEDDING_CONFIG, EMBEDDABLE_LABELS, isShortLabel, LABEL_METHOD, @@ -35,6 +34,8 @@ import { STRUCTURAL_LABELS, collectBestChunks, } from './types.js'; +import { resolveEmbeddingConfig } from './config.js'; +import { rankExactEmbeddingRows, type ExactEmbeddingRow } from './exact-search.js'; import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME, @@ -42,8 +43,20 @@ import { STALE_HASH_SENTINEL, } from '../lbug/schema.js'; import { loadVectorExtension } from '../lbug/lbug-adapter.js'; +import { getExactScanLimit } from '../platform/capabilities.js'; const isDev = process.env.NODE_ENV === 'development'; + +const vectorUnavailableMessage = + 'VECTOR extension is unavailable for this LadybugDB runtime; semantic search will use exact scan when embeddings exist.'; + +const ensureVectorExtensionAvailable = async (): Promise => { + const vectorReady = await loadVectorExtension(); + if (!vectorReady) { + return false; + } + return true; +}; /** * Bump this when the embedding text template changes in a way that should * invalidate existing vectors, such as metadata/header shape changes, @@ -192,19 +205,26 @@ export const batchInsertEmbeddings = async ( */ const createVectorIndex = async ( executeQuery: (cypher: string) => Promise, -): Promise => { - // Delegate to the adapter which tracks loaded state and handles DB reconnect resets - await loadVectorExtension(); - +): Promise => { + if (!(await ensureVectorExtensionAvailable())) return false; try { await executeQuery(CREATE_VECTOR_INDEX_QUERY); + return true; } catch (error) { if (isDev) { console.warn('Vector index creation warning:', error); } + return false; } }; +export interface EmbeddingPipelineResult { + nodesProcessed: number; + chunksProcessed: number; + vectorIndexReady: boolean; + semanticMode: 'vector-index' | 'exact-scan'; +} + /** * Run the embedding pipeline * @@ -230,10 +250,14 @@ export const runEmbeddingPipeline = async ( skipNodeIds?: Set, context?: EmbeddingContext, existingEmbeddings?: Map, -): Promise => { - const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config }; +): Promise => { + const finalConfig = resolveEmbeddingConfig(config); + let totalChunks = 0; try { + const vectorAvailable = await ensureVectorExtensionAvailable(); + if (!vectorAvailable && isDev) console.warn(vectorUnavailableMessage); + // Phase 1: Load embedding model onProgress({ phase: 'loading-model', @@ -338,7 +362,7 @@ export const runEmbeddingPipeline = async ( // Ensure the vector index exists even when no new nodes need embedding. // A prior crash or first-time incremental run may have left CodeEmbedding // rows without ever reaching index creation. - await createVectorIndex(executeQuery); + const vectorIndexReady = await createVectorIndex(executeQuery); onProgress({ phase: 'ready', @@ -346,7 +370,12 @@ export const runEmbeddingPipeline = async ( nodesProcessed: 0, totalNodes: 0, }); - return; + return { + nodesProcessed: 0, + chunksProcessed: 0, + vectorIndexReady, + semanticMode: vectorIndexReady ? 'vector-index' : 'exact-scan', + }; } // Phase 3: Chunk + embed nodes @@ -354,7 +383,6 @@ export const runEmbeddingPipeline = async ( const chunkSize = finalConfig.chunkSize; const overlap = finalConfig.overlap; let processedNodes = 0; - let totalChunks = 0; onProgress({ phase: 'embedding', @@ -445,7 +473,7 @@ export const runEmbeddingPipeline = async ( } // Embed chunk texts in sub-batches to control memory - const EMBED_SUB_BATCH = 8; + const EMBED_SUB_BATCH = finalConfig.subBatchSize; for (let si = 0; si < allTexts.length; si += EMBED_SUB_BATCH) { const subTexts = allTexts.slice(si, si + EMBED_SUB_BATCH); const subUpdates = allUpdates.slice(si, si + EMBED_SUB_BATCH); @@ -495,7 +523,7 @@ export const runEmbeddingPipeline = async ( console.log('📇 Creating vector index...'); } - await createVectorIndex(executeQuery); + const vectorIndexReady = await createVectorIndex(executeQuery); onProgress({ phase: 'ready', @@ -509,6 +537,12 @@ export const runEmbeddingPipeline = async ( `✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`, ); } + return { + nodesProcessed: totalNodes, + chunksProcessed: totalChunks, + vectorIndexReady, + semanticMode: vectorIndexReady ? 'vector-index' : 'exact-scan', + }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -543,27 +577,71 @@ export const semanticSearch = async ( const queryVec = embeddingToArray(queryEmbedding); const queryVecStr = `[${queryVec.join(',')}]`; - const bestChunks = await collectBestChunks(k, async (fetchLimit) => { - const vectorQuery = ` - CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}', - CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${fetchLimit}) - YIELD node AS emb, distance - WITH emb, distance - WHERE distance < ${maxDistance} - RETURN emb.nodeId AS nodeId, emb.chunkIndex AS chunkIndex, - emb.startLine AS startLine, emb.endLine AS endLine, distance - ORDER BY distance - `; + let bestChunks = new Map< + string, + { distance: number; chunkIndex: number; startLine: number; endLine: number } + >(); + if (await loadVectorExtension()) { + try { + bestChunks = await collectBestChunks(k, async (fetchLimit) => { + const vectorQuery = ` + CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}', + CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${fetchLimit}) + YIELD node AS emb, distance + WITH emb, distance + WHERE distance < ${maxDistance} + RETURN emb.nodeId AS nodeId, emb.chunkIndex AS chunkIndex, + emb.startLine AS startLine, emb.endLine AS endLine, distance + ORDER BY distance + `; - const embResults = await executeQuery(vectorQuery); - return embResults.map((row) => ({ - nodeId: row.nodeId ?? row[0], - chunkIndex: row.chunkIndex ?? row[1] ?? 0, - startLine: row.startLine ?? row[2] ?? 0, - endLine: row.endLine ?? row[3] ?? 0, - distance: row.distance ?? row[4], - })); - }); + const embResults = await executeQuery(vectorQuery); + return embResults.map((row) => ({ + nodeId: row.nodeId ?? row[0], + chunkIndex: row.chunkIndex ?? row[1] ?? 0, + startLine: row.startLine ?? row[2] ?? 0, + endLine: row.endLine ?? row[3] ?? 0, + distance: row.distance ?? row[4], + })); + }); + } catch { + bestChunks = new Map(); + } + } + + if (bestChunks.size === 0) { + const countRows = await executeQuery( + `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN count(e) AS cnt`, + ); + const countRow = countRows[0]; + const embeddingCount = Number(countRow?.cnt ?? countRow?.[0] ?? 0); + const exactLimit = getExactScanLimit(); + if (embeddingCount > 0 && embeddingCount <= exactLimit) { + const rows = await executeQuery(` + MATCH (e:${EMBEDDING_TABLE_NAME}) + RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, + e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding + `); + const exactRows: ExactEmbeddingRow[] = rows.map((row) => ({ + nodeId: row.nodeId ?? row[0], + chunkIndex: row.chunkIndex ?? row[1] ?? 0, + startLine: row.startLine ?? row[2] ?? 0, + endLine: row.endLine ?? row[3] ?? 0, + embedding: row.embedding ?? row[4] ?? [], + })); + bestChunks = new Map( + rankExactEmbeddingRows(exactRows, queryVec, k, maxDistance).map((row) => [ + row.nodeId, + { + distance: row.distance, + chunkIndex: row.chunkIndex, + startLine: row.startLine, + endLine: row.endLine, + }, + ]), + ); + } + } if (bestChunks.size === 0) { return []; diff --git a/gitnexus/src/core/embeddings/exact-search.ts b/gitnexus/src/core/embeddings/exact-search.ts new file mode 100644 index 000000000..e8db6ea91 --- /dev/null +++ b/gitnexus/src/core/embeddings/exact-search.ts @@ -0,0 +1,49 @@ +export interface ExactEmbeddingRow { + nodeId: string; + chunkIndex: number; + startLine: number; + endLine: number; + embedding: readonly number[]; +} + +export interface ExactSearchChunk { + nodeId: string; + chunkIndex: number; + startLine: number; + endLine: number; + distance: number; +} + +const cosineDistance = (a: readonly number[], b: readonly number[]): number => { + let dot = 0; + let aNorm = 0; + let bNorm = 0; + const len = Math.min(a.length, b.length); + for (let i = 0; i < len; i++) { + const av = a[i] ?? 0; + const bv = b[i] ?? 0; + dot += av * bv; + aNorm += av * av; + bNorm += bv * bv; + } + if (aNorm === 0 || bNorm === 0) return 1; + return 1 - dot / (Math.sqrt(aNorm) * Math.sqrt(bNorm)); +}; + +export const rankExactEmbeddingRows = ( + rows: readonly ExactEmbeddingRow[], + queryEmbedding: readonly number[], + limit: number, + maxDistance: number, +): ExactSearchChunk[] => + rows + .map((row) => ({ + nodeId: row.nodeId, + chunkIndex: row.chunkIndex, + startLine: row.startLine, + endLine: row.endLine, + distance: cosineDistance(row.embedding, queryEmbedding), + })) + .filter((row) => row.distance < maxDistance) + .sort((a, b) => a.distance - b.distance) + .slice(0, limit); diff --git a/gitnexus/src/core/embeddings/hf-env.ts b/gitnexus/src/core/embeddings/hf-env.ts new file mode 100644 index 000000000..6a977a76d --- /dev/null +++ b/gitnexus/src/core/embeddings/hf-env.ts @@ -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 + '/'; + } +} diff --git a/gitnexus/src/core/embeddings/types.ts b/gitnexus/src/core/embeddings/types.ts index 4156e9b64..309a3683e 100644 --- a/gitnexus/src/core/embeddings/types.ts +++ b/gitnexus/src/core/embeddings/types.ts @@ -207,6 +207,10 @@ export interface EmbeddingConfig { modelId: string; /** Number of nodes to embed in each batch */ batchSize: number; + /** Number of chunks passed to one local/HTTP embedding call */ + subBatchSize: number; + /** Maximum ONNX Runtime CPU threads for local inference */ + threads: number; /** Embedding vector dimensions */ dimensions: number; /** Device to use for inference: 'auto' tries GPU first (DirectML on Windows, CUDA on Linux), falls back to CPU */ @@ -229,6 +233,8 @@ export interface EmbeddingConfig { export const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = { modelId: 'Snowflake/snowflake-arctic-embed-xs', batchSize: 16, + subBatchSize: 8, + threads: 2, dimensions: 384, device: 'auto', maxSnippetLength: 500, diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts index 864a79599..76eb5c0f7 100644 --- a/gitnexus/src/core/group/bridge-db.ts +++ b/gitnexus/src/core/group/bridge-db.ts @@ -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 { + 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 { 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 { + // 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 { +/** + * 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 { 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 { + 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 { - 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; } diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts index bd803981c..d55969a73 100644 --- a/gitnexus/src/core/group/config-parser.ts +++ b/gitnexus/src/core/group/config-parser.ts @@ -13,12 +13,15 @@ const DEFAULT_DETECT = { topics: true, shared_libs: true, embedding_fallback: true, + workspace_deps: true, }; const DEFAULT_MATCHING = { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3, + exclude_links_paths: [] as string[], + exclude_links_param_only_paths: false, }; export function parseGroupConfig(yamlContent: string): GroupConfig { diff --git a/gitnexus/src/core/group/extractors/grpc-extractor.ts b/gitnexus/src/core/group/extractors/grpc-extractor.ts index b379a4dbd..b5782d9b3 100644 --- a/gitnexus/src/core/group/extractors/grpc-extractor.ts +++ b/gitnexus/src/core/group/extractors/grpc-extractor.ts @@ -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; }> { const servicesByName = new Map(); + // `.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(); @@ -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, }); diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index f2914613d..d989876a8 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -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 { + // 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, }); } diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts index 83f5cab5e..b65b7712d 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -268,6 +268,19 @@ export class ManifestExtractor { LIMIT 1`, { contract: link.contract }, ); + } else if (link.type === 'custom') { + // V1: exact name-only match on code-definition nodes. + // Positive allowlist mirrors other contract types. If multiple code + // symbols share the same name, ORDER BY filePath ASC LIMIT 1 picks + // the alphabetically-first occurrence deterministically. + rows = await executor( + `MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement) + WHERE n.name = $contract + RETURN n.id AS uid, n.name AS name, n.filePath AS filePath + ORDER BY n.filePath ASC + LIMIT 1`, + { contract: link.contract }, + ); } else { return null; } diff --git a/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts b/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts new file mode 100644 index 000000000..c19af07ca --- /dev/null +++ b/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts @@ -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 ::` 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 ::::` patterns + * where is a known workspace dependency. + */ +async function scanImports(repoPath: string, knownCrates: Set): Promise { + const results: ImportedSymbol[] = []; + + const normalizedCrates = new Map(); + 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 { + const results: string[] = []; + const ig = await loadIgnoreRules(repoPath); + + async function walk(dir: string, rel: string): Promise { + 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; +} + +/** + * Discover cross-crate contracts across all Rust repos in a group. + * + * Returns `GroupManifestLink[]` ready to feed into `ManifestExtractor`. + */ +export async function extractRustWorkspaceLinks( + repos: Record, + repoPaths: Map, + _dbExecutors?: Map, +): Promise { + // Phase 1: Parse all Cargo.toml files to build crate registry + const cratesByName = new Map(); + const cratesByGroupPath = new Map(); + + 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(); + + 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 }; +} diff --git a/gitnexus/src/core/group/extractors/topic-extractor.ts b/gitnexus/src/core/group/extractors/topic-extractor.ts index 1fbccac8a..4f2128a1c 100644 --- a/gitnexus/src/core/group/extractors/topic-extractor.ts +++ b/gitnexus/src/core/group/extractors/topic-extractor.ts @@ -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 { + // 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, }); diff --git a/gitnexus/src/core/group/matching.ts b/gitnexus/src/core/group/matching.ts index ec793968b..07f88a61a 100644 --- a/gitnexus/src/core/group/matching.ts +++ b/gitnexus/src/core/group/matching.ts @@ -1,4 +1,4 @@ -import type { StoredContract, CrossLink } from './types.js'; +import type { StoredContract, CrossLink, MatchingConfig } from './types.js'; export interface MatchResult { matched: CrossLink[]; @@ -14,6 +14,43 @@ function isGrpcWildcard(cid: string): boolean { return cid.startsWith('grpc::') && cid.endsWith('/*'); } +/** + * Detect HTTP contracts that are too generic or infrastructure-level to + * produce meaningful cross-repo links. These are still extracted (useful + * for documentation / route maps) but excluded from cross-link matching. + * + * Two categories: + * 1. Health-check / readiness endpoints — every service has one, matching + * them produces N×M false links. + * 2. Param-only paths — routes like `/{param}` or `/{param}/{param}` that + * collapse to a single catch-all after normalization. These match any + * service with a similar shape, producing false positives. + * + * Both are configurable via matching.exclude_links_paths and + * matching.exclude_links_param_only_paths in group.yaml. + */ +function buildNoisyContractFilter( + matchingConfig?: MatchingConfig, +): (contractId: string) => boolean { + const excludePaths = matchingConfig?.exclude_links_paths?.length + ? new Set(matchingConfig.exclude_links_paths.map((p) => p.replace(/\/+$/, ''))) + : new Set(); + const excludeParamOnly = matchingConfig?.exclude_links_param_only_paths === true; + + return function isNoisyHttpContract(contractId: string): boolean { + if (!contractId.startsWith('http::')) return false; + const parts = contractId.split('::'); + if (parts.length < 3) return false; + const pathPart = parts.slice(2).join('::').replace(/\/+$/, ''); + if (excludePaths.has(pathPart)) return true; + if (excludeParamOnly) { + const segments = pathPart.split('/').filter(Boolean); + if (segments.length > 0 && segments.every((s) => s === '{param}')) return true; + } + return false; + }; +} + export function normalizeContractId(id: string): string { const colonIdx = id.indexOf('::'); if (colonIdx === -1) return id; @@ -91,8 +128,12 @@ function findMatchingKeys(contractId: string, index: Map { - const providers = contracts.filter((c) => c.role === 'provider'); +export function buildProviderIndex( + contracts: StoredContract[], + matchingConfig?: MatchingConfig, +): Map { + const isNoisy = buildNoisyContractFilter(matchingConfig); + const providers = contracts.filter((c) => c.role === 'provider' && !isNoisy(c.contractId)); const index = new Map(); for (const p of providers) { const key = normalizeContractId(p.contractId); @@ -106,11 +147,14 @@ export function buildProviderIndex(contracts: StoredContract[]): Map, + matchingConfig?: MatchingConfig, ): MatchResult { - const index = providerIndex ?? buildProviderIndex(contracts); + const isNoisy = buildNoisyContractFilter(matchingConfig); + const index = providerIndex ?? buildProviderIndex(contracts, matchingConfig); - // Skip gRPC wildcard consumers — they go to wildcard pass only - const consumers = contracts.filter((c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId)); + const consumers = contracts.filter( + (c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId) && !isNoisy(c.contractId), + ); const matched: CrossLink[] = []; const matchedConsumerIds = new Set(); @@ -155,6 +199,7 @@ export function runExactMatch( // normalUnmatched: contracts that weren't matched in exact pass const normalUnmatched = contracts.filter((c) => { if (isGrpcWildcard(c.contractId)) return false; // excluded from exact, handled separately + if (isNoisy(c.contractId)) return false; // excluded from matching — don't surface as unmatched const id = `${c.repo}::${c.contractId}`; return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id); }); diff --git a/gitnexus/src/core/group/storage.ts b/gitnexus/src/core/group/storage.ts index aa6a781a5..5380b9867 100644 --- a/gitnexus/src/core/group/storage.ts +++ b/gitnexus/src/core/group/storage.ts @@ -103,6 +103,8 @@ matching: bm25_threshold: 0.7 embedding_threshold: 0.65 max_candidates_per_step: 3 + # exclude_links_paths: [/ping, /health, /healthcheck] + # exclude_links_param_only_paths: false `; await fsp.writeFile(path.join(groupDir, 'group.yaml'), template, 'utf-8'); return groupDir; diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index af7c3e686..a9ecb51f4 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -8,6 +8,7 @@ import { HttpRouteExtractor } from './extractors/http-route-extractor.js'; import { GrpcExtractor } from './extractors/grpc-extractor.js'; import { TopicExtractor } from './extractors/topic-extractor.js'; import { ManifestExtractor } from './extractors/manifest-extractor.js'; +import { extractRustWorkspaceLinks } from './extractors/rust-workspace-extractor.js'; import { runExactMatch } from './matching.js'; import { detectServiceBoundaries, assignService } from './service-boundary-detector.js'; import type { CypherExecutor } from './contract-extractor.js'; @@ -84,12 +85,14 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis let autoContracts: StoredContract[] = []; let manifestCrossLinks: CrossLink[] = []; let dbExecutors: Map | undefined; + let registryEntries: RegistryEntry[] | undefined; const eo = opts?.extractorOverride; if (eo && eo.length === 0) { autoContracts = await (eo as () => Promise)(); } else { - const entries = await readRegistry(); + registryEntries = await readRegistry(); + const entries = registryEntries; const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries); const httpEx = new HttpRouteExtractor(); const grpcEx = new GrpcExtractor(); @@ -177,18 +180,39 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } } - // Process manifest links declared in group.yaml. + // Auto-discover workspace dependency contracts (Rust Cargo workspaces, etc.) + // and merge them with explicit manifest links. Discovered links use the same + // ManifestExtractor pipeline as hand-written links in group.yaml. + let allLinks = [...config.links]; + + if (config.detect.workspace_deps) { + const repoPaths = new Map(); + if (!registryEntries) registryEntries = await readRegistry(); + for (const [groupPath, regName] of Object.entries(config.repos)) { + const e = registryEntries.find((en) => en.name === regName); + if (e) repoPaths.set(groupPath, e.path); + } + + const wsResult = await extractRustWorkspaceLinks(config.repos, repoPaths, dbExecutors); + if (wsResult.links.length > 0) { + allLinks = [...allLinks, ...wsResult.links]; + if (opts?.verbose) { + console.log( + ` workspace-deps: discovered ${wsResult.links.length} cross-crate links from ${wsResult.discoveredCrates.size} Rust crates`, + ); + } + } + } + + // Process manifest links declared in group.yaml (plus any auto-discovered). // ManifestExtractor is fully implemented but was never wired into this // pipeline — config.links were parsed and validated but silently dropped. // Placed after the DB try/finally: resolveSymbol falls back to synthetic // UIDs when dbExecutors is undefined or a pool is closed, so cross-links // are always generated regardless of whether real DB executors are available. - if (config.links.length > 0) { - // Warn about dangling links that reference repos not declared in config.repos. - // They still generate cross-links via synthetic UIDs (determinism is preserved), - // but the operator probably meant something that now silently does nothing useful. + if (allLinks.length > 0) { const knownRepos = new Set(Object.keys(config.repos)); - for (const link of config.links) { + for (const link of allLinks) { const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r)); if (dangling.length > 0) { console.warn( @@ -198,17 +222,17 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } const manifestEx = new ManifestExtractor(); - const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors); + const manifestResult = await manifestEx.extractFromManifest(allLinks, dbExecutors); autoContracts.push(...manifestResult.contracts); manifestCrossLinks = manifestResult.crossLinks; if (opts?.verbose) { console.log( - ` manifest: ${manifestCrossLinks.length} cross-links from ${config.links.length} declared links`, + ` manifest: ${manifestCrossLinks.length} cross-links from ${allLinks.length} links (${config.links.length} declared + ${allLinks.length - config.links.length} discovered)`, ); } } - const { matched, unmatched } = runExactMatch(autoContracts); + const { matched, unmatched } = runExactMatch(autoContracts, undefined, config.matching); // Dedupe cross-links. Manifest contracts participate in runExactMatch, so a // manifest-declared link can also emit a matchType:'exact' CrossLink with the diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index 793d3d0ad..895bef6dc 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -27,12 +27,31 @@ export interface DetectConfig { topics: boolean; shared_libs: boolean; embedding_fallback: boolean; + workspace_deps: boolean; } export interface MatchingConfig { bm25_threshold: number; embedding_threshold: number; max_candidates_per_step: number; + /** + * HTTP paths to exclude from cross-link matching. Contracts at these paths + * are still extracted and visible in the registry, but they don't produce + * cross-repo links. Useful for health-check endpoints (`/ping`, `/health`) + * that every service exposes and would otherwise create N×M false links. + * Trailing slashes are normalized before comparison. + * @default [] + */ + exclude_links_paths?: string[]; + /** + * When `true`, exclude HTTP routes where every path segment is `{param}` + * (e.g. `/{param}`, `/{param}/{param}`) from cross-link matching. Mixed + * routes like `/users/{param}` are not affected. These param-only routes + * collapse to a single catch-all after normalization and produce false + * positives across unrelated services. + * @default false + */ + exclude_links_param_only_paths?: boolean; } export interface SymbolRef { diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 4eb7ea6ce..1871d0605 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -12,7 +12,7 @@ type ReceiverSource = ReceiverEnriched['receiverSource']; * DAG stage 4 fallback: used when `selectDispatch` is absent or returns null. * Preserves pre-DAG dispatch semantics: * - 'constructor' → constructor branch - * - 'free' → free branch (admits Swift/Kotlin class-target fast path) + * - 'free' → free branch (admits class-target fast path) * - 'member' or undefined → owner-scoped branch * * `undefined` callForm MUST route through owner-scoped (not free) so bare @@ -1595,41 +1595,30 @@ const disambiguateByOverloadOrArgTypes = ( return null; }; -/** - * Collapse Swift-extension duplicate Class/Struct candidates to the primary - * definition, preferring the shortest file path. - * - * Swift extensions (`extension User { ... }` in a separate file) create - * multiple `Class` nodes sharing the same symbol name — one for the primary - * declaration and one per extension file. When overload disambiguation and - * receiver narrowing both fail to converge on a single candidate, this - * heuristic picks the primary definition based on the assumption that it - * lives at the shortest file path (e.g. `User.swift` over `UserExtensions.swift`). - * - * Intentionally narrower than {@link INSTANTIABLE_CLASS_TYPES}: only `Class` - * and `Struct` are considered, not `Record`. Swift extensions only produce - * `Class` duplicates in practice, and C#/Kotlin records do not exhibit the - * same multi-file-definition pattern, so widening this set risks accidental - * dedup of legitimately distinct record types. - * - * Returns a `ResolveResult` when the heuristic fires, `null` when the - * candidate pool does not match the shape (mixed types, non-Class/Struct - * kinds, or `length <= 1`). Callers should fall through to their own null - * return when this helper returns `null`. - * - * Used by `resolveFreeCall`. Having a single source of truth prevents - * duplication if the heuristic is ever tuned. - */ -const dedupSwiftExtensionCandidates = ( +const orderProviderSameNameTypeCandidates = ( + candidates: readonly SymbolDefinition[], + typeName: string, + filePath: string, +): readonly SymbolDefinition[] | null => { + const language = getLanguageFromFilename(filePath); + if (language == null) return null; + return ( + getProvider(language).orderSameNameTypeCandidates?.({ + typeName, + callSiteFilePath: filePath, + candidates, + }) ?? null + ); +}; + +const resolveProviderPrimaryTypeCandidate = ( candidates: readonly SymbolDefinition[], tier: ResolutionTier, + typeName: string, + filePath: string, ): ResolveResult | null => { - if (candidates.length <= 1) return null; - const allSameType = candidates.every((c) => c.type === candidates[0].type); - if (!allSameType) return null; - if (candidates[0].type !== 'Class' && candidates[0].type !== 'Struct') return null; - const sorted = [...candidates].sort((a, b) => a.filePath.length - b.filePath.length); - return toResolveResult(sorted[0], tier); + const ordered = orderProviderSameNameTypeCandidates(candidates, typeName, filePath); + return ordered && ordered.length > 0 ? toResolveResult(ordered[0], tier) : null; }; /** @@ -2223,6 +2212,35 @@ const resolveMethodByOwner = ( } } + if (!firstDef && !ambiguous) { + const orderedTypeCandidates = orderProviderSameNameTypeCandidates( + ctx.model.types.lookupClassByName(receiverTypeName), + receiverTypeName, + filePath, + ); + if (orderedTypeCandidates) { + for (const candidate of orderedTypeCandidates) { + const def = canWalkMRO + ? lookupMethodByOwnerWithMRO( + candidate.nodeId, + methodName, + heritageMap, + ctx.model, + mroStrategy, + argCount, + ) + : ctx.model.methods.lookupMethodByOwner(candidate.nodeId, methodName, argCount); + if (!def) continue; + if (!firstDef) { + firstDef = def; + } else if (def.nodeId !== firstDef.nodeId) { + ambiguous = true; + break; + } + } + } + } + if (!firstDef || ambiguous) return undefined; return { def: firstDef, tier: typeResolved.tier }; }; @@ -2290,9 +2308,9 @@ export const resolveMemberCall = ( * resolution via `ctx.resolve()`. * * Used for `foo()`, `doStuff()` — unqualified calls with no receiver. - * Also handles Swift/Kotlin implicit constructors (`User()` without `new`) - * by delegating to {@link resolveStaticCall} when the tiered pool contains - * class-like targets. + * Also handles implicit constructors (`User()` without `new`) by delegating + * to {@link resolveStaticCall} when the tiered pool contains class-like + * targets. * * {@link resolveCallTarget} delegates here for `callForm === 'free'`. * @@ -2324,33 +2342,30 @@ export const resolveFreeCall = ( let filteredCandidates = filterCallableCandidates(tiered.candidates, argCount, 'free'); - // Class-target fast path: Swift/Kotlin `User()` — free-form call targeting a - // class. Delegates to resolveStaticCall for O(1) class + constructor lookup. + // Class-target fast path: free-form call targeting a class. Delegates to + // resolveStaticCall for O(1) class + constructor lookup. // The `.some()` trigger must stay aligned with `INSTANTIABLE_CLASS_TYPES` — // any type admitted here that is not in that set will cause resolveStaticCall // to return null, wasting two lookup passes per call. `Enum` is deliberately - // excluded; `Record` is included so C# records and Kotlin data classes reach - // the fast path. + // excluded; `Record` is included so record-like class targets reach the fast + // path. // Align with INSTANTIABLE_CLASS_TYPES by reusing the set directly rather // than enumerating literal strings. This converts an invariant that was // previously enforced by a comment ("keep this list aligned with // INSTANTIABLE_CLASS_TYPES") into one enforced structurally — any future - // extension of the set (e.g. Kotlin `object`) propagates here automatically. - // The `dedupSwiftExtensionCandidates` helper used in the tail of this - // function deliberately uses a narrower literal `'Class' | 'Struct'` check - // — Swift extensions only produce Class duplicates in practice, so Record - // is excluded there by design. Do not collapse that helper into - // INSTANTIABLE_CLASS_TYPES. + // extension of the set propagates here automatically. + // Language providers can still choose a primary same-name type candidate in + // the tail of this function when their grammars index one logical type + // multiple times. const hasClassTarget = filteredCandidates.length === 0 && tiered.candidates.some((c) => INSTANTIABLE_CLASS_TYPES.has(c.type)); if (hasClassTarget) { const staticResult = resolveStaticCall(calledName, filePath, ctx, argCount, tiered); if (staticResult) return staticResult; - // Retry with constructor form: Swift/Kotlin constructor calls look like - // free function calls (no `new` keyword). If resolveStaticCall didn't - // match, re-filter with constructor form so CONSTRUCTOR_TARGET_TYPES - // applies. + // Retry with constructor form for languages whose constructor calls look + // like free function calls. If resolveStaticCall didn't match, re-filter + // with constructor form so CONSTRUCTOR_TARGET_TYPES applies. // // The retry fires for every null return from `resolveStaticCall`, which // can happen for three distinct reasons — all three are handled below: @@ -2364,9 +2379,8 @@ export const resolveFreeCall = ( // (b) Homonym ambiguity — two or more instantiable class candidates // share the name (e.g. `User` in two files, same tier). The // retry repopulates `filteredCandidates` with both Classes and - // they flow into `dedupSwiftExtensionCandidates` below, which - // either picks the shortest-path primary or null-routes. - // Covered by the R7 Swift-extension dedup test. + // they flow into the provider same-name candidate hook below, which + // can pick a primary definition or null-route. // // (c) `resolveStaticCall` step 4 bailed because the tiered pool // contains ownerless `Constructor` nodes (some extractors emit @@ -2391,10 +2405,13 @@ export const resolveFreeCall = ( } if (filteredCandidates.length !== 1) { - // See `dedupSwiftExtensionCandidates` — shared helper, single source of - // truth for the Swift-extension same-name collision heuristic. - const deduped = dedupSwiftExtensionCandidates(filteredCandidates, tiered.tier); - if (deduped) return deduped; + const primary = resolveProviderPrimaryTypeCandidate( + filteredCandidates, + tiered.tier, + calledName, + filePath, + ); + if (primary) return primary; return null; } @@ -2559,9 +2576,16 @@ export const resolveStaticCall = ( // Interface / Trait / Impl). Null-route via the fall-through `return // null` — this is the dominant Codex-fix case. // length === 1 → a single instantiable candidate remains, return it. - // length > 1 → two or more instantiable classes share the name (e.g. - // homonym classes across files with no import narrowing). Fall through - // to `return null` so the caller null-routes rather than guess. + // length > 1 → let the call-site provider choose a primary when it can + // prove the candidates are one logical type; otherwise null-route. + const primary = resolveProviderPrimaryTypeCandidate( + instantiableCandidates, + typeResolved.tier, + className, + currentFile, + ); + if (primary) return primary; + if (instantiableCandidates.length === 1) { return toResolveResult(instantiableCandidates[0], typeResolved.tier); } diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index e8bb332a7..92566d30e 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -498,6 +498,15 @@ interface LanguageProviderConfig { // ── Resolution phase (RFC §4v2) ──────────────────────────────────── + /** Order same-name type candidates when a language can index multiple + * definitions for one logical type. Return null to keep shared ambiguity + * handling. */ + readonly orderSameNameTypeCandidates?: (params: { + readonly typeName: string; + readonly callSiteFilePath: string; + readonly candidates: readonly SymbolDefinition[]; + }) => readonly SymbolDefinition[] | null; + /** * Is this callable definition compatible with the given call-site arity? * Language-specific rules: Python `*args`/`**kwargs`/defaults, JS default diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index 1685fa24c..be891b5cc 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -38,14 +38,24 @@ export function emitPythonScopeCaptures( // here at the use site. let tree = cachedTree as ReturnType['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['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`, + ); +} diff --git a/gitnexus/src/core/ingestion/languages/python/import-target.ts b/gitnexus/src/core/ingestion/languages/python/import-target.ts index 3905f3301..3a75868d9 100644 --- a/gitnexus/src/core/ingestion/languages/python/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/python/import-target.ts @@ -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 `/.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 (`.py`, `/__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 | null { +function resolveAbsoluteFromFiles( + pathLike: string, + allFilePaths: Set, + 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: `.py` root file, `/__init__.py` - * regular package, or any `/**.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 `/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): boolean { +function hasRepoCandidate( + leadingSegment: string, + allFilePaths: Set, + 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; } diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index 38f5d8147..7b74386a8 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -11,7 +11,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; -import type { NodeLabel } from 'gitnexus-shared'; +import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; import { swiftClassConfig } from '../class-extractors/configs/swift.js'; import { defineLanguage } from '../language-provider.js'; @@ -128,6 +128,24 @@ const swiftExtractFunctionName = ( return null; // fall through to generic }; +const orderSwiftSameNameTypeCandidates = ({ + callSiteFilePath, + candidates, +}: { + readonly typeName: string; + readonly callSiteFilePath: string; + readonly candidates: readonly SymbolDefinition[]; +}): readonly SymbolDefinition[] | null => { + if (!callSiteFilePath.endsWith('.swift')) return null; + if (candidates.length <= 1) return null; + if (!candidates.every((c) => c.type === candidates[0].type)) return null; + if (candidates[0].type !== 'Class' && candidates[0].type !== 'Struct') return null; + if (!candidates.every((c) => c.filePath.endsWith('.swift'))) return null; + return [...candidates].sort( + (a, b) => a.filePath.length - b.filePath.length || a.filePath.localeCompare(b.filePath), + ); +}; + const BUILT_INS: ReadonlySet = new Set([ 'print', 'debugPrint', @@ -257,5 +275,6 @@ export const swiftProvider = defineLanguage({ classExtractor: createClassExtractor(swiftClassConfig), heritageExtractor: createHeritageExtractor(SupportedLanguages.Swift), implicitImportWirer: wireSwiftImplicitImports, + orderSameNameTypeCandidates: orderSwiftSameNameTypeCandidates, builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index e9dc21ab4..e2ec9d23f 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -57,8 +57,20 @@ 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). + * + * Returns `null` for funcName when the arrow lives in a context that has + * no static name — call arguments, 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 +78,43 @@ 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' }; + } + + return { funcName: null, label: 'Function' }; }; export const BUILT_INS: ReadonlySet = new Set([ diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index e7bb91698..9d083c15c 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -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, `` 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 = diff --git a/gitnexus/src/core/ingestion/languages/typescript/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts index 5645b0868..07d02f507 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -136,25 +136,83 @@ 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) ;; Method definitions — regular + private (#field) methods. (method_definition @@ -723,6 +781,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: \`\` 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 \`
\`, \`\`, \` +
+); diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/member-usage.tsx b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/member-usage.tsx new file mode 100644 index 000000000..00f49c602 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/member-usage.tsx @@ -0,0 +1,12 @@ +import { Container } from './namespaced'; + +// Namespaced JSX — ``. 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 = () => ; diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/namespaced.tsx b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/namespaced.tsx new file mode 100644 index 000000000..2e83cec4c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/namespaced.tsx @@ -0,0 +1,14 @@ +// Namespaced component — the canonical `` 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 `` instead of importing each +// piece individually. +// +// The TSX grammar represents `` 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 }; diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/nested-usage.tsx b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/nested-usage.tsx new file mode 100644 index 000000000..29d1fb7ea --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/nested-usage.tsx @@ -0,0 +1,13 @@ +import { Inner, Outer } from './components'; + +// Nested JSX — ``. Both `` (paired) and +// `` (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 = () => ( + + + +); diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/paired-usage.tsx b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/paired-usage.tsx new file mode 100644 index 000000000..2776205eb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/paired-usage.tsx @@ -0,0 +1,6 @@ +import { Bar } from './components'; + +// Paired JSX element (`...`). 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 = () => child text; diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/simple-usage.tsx b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/simple-usage.tsx new file mode 100644 index 000000000..a41d2b082 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/simple-usage.tsx @@ -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 = () => ; diff --git a/gitnexus/test/global-setup.ts b/gitnexus/test/global-setup.ts deleted file mode 100644 index f5760a1b1..000000000 --- a/gitnexus/test/global-setup.ts +++ /dev/null @@ -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(); - }; -} diff --git a/gitnexus/test/helpers/test-db.ts b/gitnexus/test/helpers/test-db.ts index c700ac077..5818fdc8e 100644 --- a/gitnexus/test/helpers/test-db.ts +++ b/gitnexus/test/helpers/test-db.ts @@ -1,8 +1,8 @@ /** * Test helper: Temporary LadybugDB factory * - * Creates a temp directory, initializes LadybugDB with schema, and - * optionally loads minimal test data. Returns a cleanup function. + * Creates temporary directories for tests and provides cleanup that tolerates + * LadybugDB's known Windows handle-release lag after retries. */ import fs from 'fs/promises'; import os from 'os'; @@ -13,6 +13,27 @@ export interface TestDBHandle { cleanup: () => Promise; } +const WINDOWS_NATIVE_LOCK_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY']); + +export async function cleanupTempDir(tmpDir: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 5; attempt++) { + try { + await fs.rm(tmpDir, { recursive: true, force: true }); + return; + } catch (err) { + lastError = err; + await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1))); + } + } + + const code = (lastError as NodeJS.ErrnoException | undefined)?.code; + if (process.platform === 'win32' && WINDOWS_NATIVE_LOCK_CODES.has(code ?? '')) { + return; + } + throw lastError; +} + /** * Create a temporary directory for LadybugDB tests. * Returns the path and a cleanup function. @@ -23,7 +44,7 @@ export async function createTempDir(prefix: string = 'gitnexus-test-'): Promise< dbPath: tmpDir, cleanup: async () => { try { - await fs.rm(tmpDir, { recursive: true, force: true }); + await cleanupTempDir(tmpDir); } catch { // best-effort cleanup } diff --git a/gitnexus/test/helpers/test-indexed-db.ts b/gitnexus/test/helpers/test-indexed-db.ts index a8968e05f..d0eda43f0 100644 --- a/gitnexus/test/helpers/test-indexed-db.ts +++ b/gitnexus/test/helpers/test-indexed-db.ts @@ -1,19 +1,16 @@ /** * Test helper: Indexed LadybugDB lifecycle manager * - * Uses a shared LadybugDB created by globalSetup (test/global-setup.ts). - * Each test file clears all data, reseeds, and initializes adapters — - * avoiding per-file schema creation overhead. + * Creates an isolated LadybugDB per suite, reseeds, and initializes adapters. * * Cleanup properly closes adapters and releases native resources. * * Each test file gets a unique repoId to prevent MCP pool map collisions. * Seed data is NOT included — each test provides its own via options.seed. */ -/// import path from 'path'; -import { describe, beforeAll, afterAll, inject } from 'vitest'; -import type { TestDBHandle } from './test-db.js'; +import { describe, beforeAll, afterAll } from 'vitest'; +import { createTempDir, type TestDBHandle } from './test-db.js'; import { NODE_TABLES, EMBEDDING_TABLE_NAME } from '../../src/core/lbug/schema.js'; export interface IndexedDBHandle { @@ -56,8 +53,8 @@ export interface WithTestLbugDBOptions { } /** - * Manages the full LadybugDB test lifecycle using the shared global DB: - * data clearing, reseeding, FTS indexes, adapter init/teardown. + * Manages the full LadybugDB test lifecycle: + * database creation, data clearing, reseeding, FTS indexes, adapter init/teardown. * * All data operations go through the core adapter's writable connection — * no raw lbug.Database() connections are opened. This avoids file-lock @@ -77,8 +74,8 @@ export function withTestLbugDB( const timeout = options?.timeout ?? 120_000; const setup = async () => { - // Get shared DB path from globalSetup (created once with full schema) - const dbPath = inject<'lbugDbPath'>('lbugDbPath'); + const tmpHandle = await createTempDir('gitnexus-lbug-'); + const dbPath = path.join(tmpHandle.dbPath, 'lbug'); const repoId = `test-${prefix}-${Date.now()}-${repoCounter++}`; const adapter = await import('../../src/core/lbug/lbug-adapter.js'); @@ -137,12 +134,11 @@ export function withTestLbugDB( await poolAdapter.closeLbug(repoId); } await adapter.closeLbug(); + await tmpHandle.cleanup(); }; // tmpHandle.dbPath → parent temp dir (not the lbug file) so tests // that create sibling directories (e.g. 'storage') still work. - const tmpDir = path.dirname(dbPath); - const tmpHandle: TestDBHandle = { dbPath: tmpDir, cleanup: async () => {} }; ref.handle = { dbPath, repoId, tmpHandle, cleanup }; // 7. User's final setup (mocks, dynamic imports, etc.) diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index 00d2bbb36..8037511bf 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -198,6 +198,117 @@ describe('CLI end-to-end', () => { const gitnexusDir = path.join(MINI_REPO, '.gitnexus'); expect(fs.existsSync(gitnexusDir)).toBe(true); expect(fs.statSync(gitnexusDir).isDirectory()).toBe(true); + expect(fs.existsSync(path.join(MINI_REPO, '.gitignore'))).toBe(false); + expect(fs.readFileSync(path.join(gitnexusDir, '.gitignore'), 'utf-8')).toBe('*\n'); + }, 60_000); + + // Regression guard for issue #1169 — analyze must produce BOTH a + // meta.json AND a global-registry entry on success. The previous + // failure mode on Windows was banner-only output + exit 0 with + // neither artifact persisted; the new finalize invariant + // (assertAnalysisFinalized) makes that state a hard failure. + // + // Uses a fresh per-test repo copy (not the shared MINI_REPO) so + // an earlier sibling test's analyze cannot push this one onto the + // alreadyUpToDate fast path, which would skip the very wiring this + // test is here to protect. + it('analyze persists meta.json AND a matching registry entry (#1169)', () => { + const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-1169-home-')); + const repo = makeMiniRepoCopy('mini-repo', 'gn-1169-repo-'); + const repoParent = path.dirname(repo); + + try { + const result = runCliWithEnv(['analyze'], repo, { GITNEXUS_HOME: gnHome }, 60000); + + expect( + result.status, + [ + 'analyze timed out before asserting finalization artifacts — this test guards #1169 and must not pass silently', + `stdout: ${result.stdout}`, + `stderr: ${result.stderr}`, + ].join('\n'), + ).not.toBeNull(); + + expect( + result.status, + [ + `analyze exited with code ${result.status}`, + `stdout: ${result.stdout}`, + `stderr: ${result.stderr}`, + ].join('\n'), + ).toBe(0); + + const metaPath = path.join(repo, '.gitnexus', 'meta.json'); + expect( + fs.existsSync(metaPath), + `meta.json missing at ${metaPath} after analyze exited 0 — this is the #1169 silent-finalize symptom`, + ).toBe(true); + + const registryPath = path.join(gnHome, 'registry.json'); + expect( + fs.existsSync(registryPath), + `registry.json missing at ${registryPath} after analyze exited 0`, + ).toBe(true); + const entries = JSON.parse(fs.readFileSync(registryPath, 'utf-8')) as Array<{ + name: string; + path: string; + }>; + expect(entries.length).toBeGreaterThanOrEqual(1); + const matchesRepo = entries.some((e) => { + const a = fs.realpathSync.native(e.path); + const b = fs.realpathSync.native(repo); + return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b; + }); + expect( + matchesRepo, + `registry has no entry for ${repo}; entries: ${JSON.stringify(entries.map((e) => e.path))}`, + ).toBe(true); + } finally { + fs.rmSync(gnHome, { recursive: true, force: true }); + fs.rmSync(repoParent, { recursive: true, force: true }); + } + }, 60_000); + + it('already-up-to-date analyze fails when registry entry is missing (#1169)', () => { + const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-1169-fastpath-home-')); + const repo = makeMiniRepoCopy('mini-repo', 'gn-1169-fastpath-repo-'); + const repoParent = path.dirname(repo); + + try { + const first = runCliWithEnv(['analyze'], repo, { GITNEXUS_HOME: gnHome }, 60000); + expect( + first.status, + [ + `initial analyze exited with code ${first.status}`, + `stdout: ${first.stdout}`, + `stderr: ${first.stderr}`, + ].join('\n'), + ).toBe(0); + + const metaPath = path.join(repo, '.gitnexus', 'meta.json'); + expect(fs.existsSync(metaPath)).toBe(true); + + // Simulate the half-finalized state from the review: meta.json is + // present and lastCommit matches, but the repo is not discoverable + // because the global registry entry is missing. + fs.writeFileSync(path.join(gnHome, 'registry.json'), '[]', 'utf-8'); + + const second = runCliWithEnv(['analyze'], repo, { GITNEXUS_HOME: gnHome }, 60000); + expect( + second.status, + [ + 'second analyze timed out before proving alreadyUpToDate finalization', + `stdout: ${second.stdout}`, + `stderr: ${second.stderr}`, + ].join('\n'), + ).not.toBeNull(); + expect(`${second.stdout}${second.stderr}`).toMatch(/Analysis did not finalize/i); + expect(`${second.stdout}${second.stderr}`).toMatch(/registry entry/i); + expect(second.status).toBe(1); + } finally { + fs.rmSync(gnHome, { recursive: true, force: true }); + fs.rmSync(repoParent, { recursive: true, force: true }); + } }, 60_000); // ─── analyze --name + --allow-duplicate-name (#829) ────── diff --git a/gitnexus/test/integration/lbug-core-adapter.test.ts b/gitnexus/test/integration/lbug-core-adapter.test.ts index 825ec627c..296217e71 100644 --- a/gitnexus/test/integration/lbug-core-adapter.test.ts +++ b/gitnexus/test/integration/lbug-core-adapter.test.ts @@ -13,6 +13,16 @@ import fs from 'fs/promises'; import path from 'path'; import { withTestLbugDB } from '../helpers/test-indexed-db.js'; +/** + * LadybugDB 0.16.0 has a known Windows-only regression: `Database.close()` + * does not release the underlying file lock until the process exits, so any + * `closeLbug()` followed by `initLbug(samePath)` in the same process raises + * Win32 Error 33. Production paths are unaffected (single open per process). + * + * Tracking: kuzudb/kuzu#3872 / #3883 / #4730 (file-lock UX gaps on Windows). + */ +const itLbugReopen = process.platform === 'win32' ? it.skip : it; + // ─── Core LadybugDB Adapter ───────────────────────────────────────────── withTestLbugDB( @@ -70,20 +80,6 @@ withTestLbugDB( } }); - it('initLbug loads FTS so reopened HTTP-style sessions can query existing indexes', async () => { - const adapter = await import('../../src/core/lbug/lbug-adapter.js'); - const indexName = 'function_fts_init_probe'; - - await adapter.createFTSIndex('Function', indexName, ['name', 'content']); - await adapter.closeLbug(); - - await adapter.initLbug(handle.dbPath); - - await expect(adapter.queryFTS('Function', indexName, 'main', 5)).resolves.toEqual( - expect.arrayContaining([expect.objectContaining({ filePath: 'src/index.ts' })]), - ); - }); - it('getLbugStats: returns correct node and edge counts for seeded data', async () => { const { getLbugStats } = await import('../../src/core/lbug/lbug-adapter.js'); @@ -135,6 +131,20 @@ withTestLbugDB( ).resolves.toBeUndefined(); }); + it('ensureFTSIndex is idempotent and caches across writable calls (#1224)', async () => { + const { ensureFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js'); + + // First call creates the index. Second call must short-circuit on the + // in-process cache — guarantees the read-only guard added in #1224 + // still respects the success path. + await expect( + ensureFTSIndex('Function', 'function_fts_ensure', ['name', 'content']), + ).resolves.toBeUndefined(); + await expect( + ensureFTSIndex('Function', 'function_fts_ensure', ['name', 'content']), + ).resolves.toBeUndefined(); + }); + it('getLbugStats returns valid counts', async () => { const { getLbugStats } = await import('../../src/core/lbug/lbug-adapter.js'); @@ -161,6 +171,23 @@ withTestLbugDB( expect(result).toEqual({ deletedNodes: 0 }); }); }); + + itLbugReopen( + 'initLbug loads FTS so reopened HTTP-style sessions can query existing indexes', + async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const indexName = 'function_fts_init_probe'; + + await adapter.createFTSIndex('Function', indexName, ['name', 'content']); + await adapter.closeLbug(); + + await adapter.initLbug(handle.dbPath); + + await expect(adapter.queryFTS('Function', indexName, 'main', 5)).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ filePath: 'src/index.ts' })]), + ); + }, + ); }); }, { diff --git a/gitnexus/test/integration/lbug-vector-extension.test.ts b/gitnexus/test/integration/lbug-vector-extension.test.ts index feb51d08f..5eb966189 100644 --- a/gitnexus/test/integration/lbug-vector-extension.test.ts +++ b/gitnexus/test/integration/lbug-vector-extension.test.ts @@ -12,18 +12,17 @@ import { withTestLbugDB } from '../helpers/test-indexed-db.js'; withTestLbugDB('vector-extension', (handle) => { describe('loadVectorExtension', () => { - it('loads the VECTOR extension without error', async () => { + it('reports VECTOR availability without throwing', async () => { const { loadVectorExtension } = await import('../../src/core/lbug/lbug-adapter.js'); - // Should resolve without throwing -- idempotent if already loaded by doInitLbug - await expect(loadVectorExtension()).resolves.toBeUndefined(); + await expect(loadVectorExtension()).resolves.toEqual(expect.any(Boolean)); }); it('is idempotent -- calling twice does not throw', async () => { const { loadVectorExtension } = await import('../../src/core/lbug/lbug-adapter.js'); await loadVectorExtension(); - await expect(loadVectorExtension()).resolves.toBeUndefined(); + await expect(loadVectorExtension()).resolves.toEqual(expect.any(Boolean)); }); }); @@ -38,12 +37,13 @@ withTestLbugDB('vector-extension', (handle) => { await adapter.closeLbug(); expect(adapter.isLbugReady()).toBe(false); - // Re-initialize -- doInitLbug calls loadVectorExtension internally + // Re-initialize -- VECTOR is lazy, so the stale loaded flag must not mask + // a subsequent explicit availability check. await adapter.initLbug(handle.dbPath); expect(adapter.isLbugReady()).toBe(true); // loadVectorExtension should succeed (not skip due to stale flag) - await expect(adapter.loadVectorExtension()).resolves.toBeUndefined(); + await expect(adapter.loadVectorExtension()).resolves.toEqual(expect.any(Boolean)); }); }); @@ -69,7 +69,7 @@ withTestLbugDB('vector-extension', (handle) => { // After recovery, vector extension should still be loadable // (the flag was reset and re-loaded during re-init) - await expect(adapter.loadVectorExtension()).resolves.toBeUndefined(); + await expect(adapter.loadVectorExtension()).resolves.toEqual(expect.any(Boolean)); }); }); }); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 710fcddeb..427fd7e5f 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -12,6 +12,16 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly Models/User.cs through the scope-resolution path', ]), + python: new Set([ + // Suffix-fallback lex tiebreak depends on the registry-primary + // resolver's deterministic sort. The legacy resolver returns the + // first match in `Set` iteration order, which is insertion-order + // dependent and not aligned with this guarantee. Backporting the + // sort to legacy is out of scope. + 'picks the lexicographically smaller path on equal-depth ties', + 'binds the call to alpha/services/sync.py, not omega', + 'lex tiebreak still picks alpha/services/sync.py with reversed file-write order', + ]), }; type ResolverParityEnv = Readonly>; diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 817483849..450ba46fc 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -1,13 +1,14 @@ /** * Python: relative imports + class inheritance + ambiguous module disambiguation */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, expect, beforeAll, afterAll } from 'vitest'; import path from 'path'; import fs from 'node:fs'; import os from 'node:os'; import { FIXTURES, CROSS_FILE_FIXTURES, + createResolverParityIt, getRelationships, getNodesByLabel, getNodesByLabelFull, @@ -16,6 +17,11 @@ import { type PipelineResult, } from './helpers.js'; +// Mirrors `csharp.test.ts`: skips tests in `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.python` +// when the legacy-resolver parity sweep runs (`REGISTRY_PRIMARY_PYTHON=0`). For the +// default registry-primary CI run this is a transparent passthrough to vitest's `it`. +const it = createResolverParityIt('python'); + function writeFixtureRepo(root: string, files: Record): void { for (const [relPath, content] of Object.entries(files)) { const fullPath = path.join(root, relPath); @@ -416,6 +422,432 @@ describe('Python ancestor directory import resolution (Issue #417)', () => { }); }); +// --------------------------------------------------------------------------- +// Multi-segment ancestor walk: `from services.sync import X` style imports +// from a sibling sub-package nested under a shared root directory. +// +// Before this fix, single-segment ancestor walks worked (`from middleware +// import X` from `backend/services/auth.py` → `backend/middleware.py`) but +// multi-segment dotted imports were only resolved against the workspace +// root. In a `backend/`-prefixed repo, `from services.sync import X` from +// `backend/routers/cron.py` would silently drop because `services/sync.py` +// does not exist at the workspace root — only `backend/services/sync.py` +// does. The fix mirrors the single-segment ancestor walk for multi-segment +// paths. +// --------------------------------------------------------------------------- + +describe('Python multi-segment ancestor directory import resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-multi-segment-ancestor-import'), + () => {}, + ); + }, 60000); + + it('resolves from services.sync import to backend/services/sync.py via ancestor walk', () => { + const imports = getRelationships(result, 'IMPORTS'); + const syncImport = imports.find( + (i) => + i.sourceFilePath === 'backend/routers/cron.py' && + i.targetFilePath === 'backend/services/sync.py', + ); + expect(syncImport).toBeDefined(); + }); + + it('resolves from services.alerts import to backend/services/alerts.py via ancestor walk', () => { + const imports = getRelationships(result, 'IMPORTS'); + const alertsImport = imports.find( + (i) => + i.sourceFilePath === 'backend/routers/cron.py' && + i.targetFilePath === 'backend/services/alerts.py', + ); + expect(alertsImport).toBeDefined(); + }); + + it('resolves from routers.alerts import to backend/routers/alerts.py (sibling sub-package)', () => { + const imports = getRelationships(result, 'IMPORTS'); + const routerImport = imports.find( + (i) => + i.sourceFilePath === 'backend/routers/cron.py' && + i.targetFilePath === 'backend/routers/alerts.py', + ); + expect(routerImport).toBeDefined(); + }); + + it('emits CALLS edges for every multi-segment-imported callee', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'backend/routers/cron.py', + ); + + const startCronRunCalls = calls.filter((c) => c.target === '_start_cron_run'); + expect(startCronRunCalls.length).toBe(3); + expect(startCronRunCalls.every((c) => c.targetFilePath === 'backend/services/sync.py')).toBe( + true, + ); + + const completeCronRunCalls = calls.filter((c) => c.target === '_complete_cron_run'); + expect(completeCronRunCalls.length).toBe(1); + expect(completeCronRunCalls[0].targetFilePath).toBe('backend/services/sync.py'); + + const opsAlertCalls = calls.filter((c) => c.target === '_create_ops_alert'); + expect(opsAlertCalls.length).toBe(2); + expect(opsAlertCalls.every((c) => c.targetFilePath === 'backend/services/alerts.py')).toBe( + true, + ); + + const sendDailyCalls = calls.filter((c) => c.target === 'send_daily_alerts'); + expect(sendDailyCalls.length).toBe(2); + expect(sendDailyCalls.every((c) => c.targetFilePath === 'backend/routers/alerts.py')).toBe( + true, + ); + }); + + it('preserves single-segment ancestor walk (regression check for from auth_utils import X)', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'backend/routers/cron.py', + ); + + const verifyCalls = calls.filter((c) => c.target === 'verify_cron_secret'); + expect(verifyCalls.length).toBe(1); + expect(verifyCalls[0].targetFilePath).toBe('backend/auth_utils.py'); + + const orgCalls = calls.filter((c) => c.target === 'get_org_id_from_header'); + expect(orgCalls.length).toBe(1); + expect(orgCalls[0].targetFilePath).toBe('backend/auth_utils.py'); + }); +}); + +// --------------------------------------------------------------------------- +// Negative case for `hasRepoCandidate` widening: a vendored copy of an +// external package (e.g. `vendor/django/urls.py`) must not cause an external +// import like `from django.urls import path` issued from an unrelated file +// (`app/main.py`) to be treated as a local candidate. The ancestor-bounded +// nested check rejects vendored matches that don't sit on the importer's +// own ancestor path. +// --------------------------------------------------------------------------- + +describe('Python multi-segment widening: vendored external package false-positive guard', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-vendored-django-')); + writeFixtureRepo(repoDir, { + 'app/main.py': `from django.urls import path + +def boot(): + path("/") +`, + 'vendor/django/__init__.py': '', + 'vendor/django/urls.py': `def path(p): + return p +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('does not resolve from django.urls to vendor/django/urls.py from an unrelated importer', () => { + const imports = getRelationships(result, 'IMPORTS'); + const stray = imports.find( + (i) => i.sourceFilePath === 'app/main.py' && i.targetFilePath === 'vendor/django/urls.py', + ); + expect(stray).toBeUndefined(); + }); + + it('does not emit a CALLS edge from app/main.py:boot to vendor/django/urls.py:path', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'app/main.py', + ); + const stray = calls.find( + (c) => c.target === 'path' && c.targetFilePath === 'vendor/django/urls.py', + ); + expect(stray).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Workspace-root precedence: when both `services/sync.py` (root) and +// `backend/services/sync.py` (ancestor) exist, an importer at +// `backend/routers/cron.py` doing `from services.sync import X` resolves to +// the root file. Mirrors Python's `sys.path` semantics where the project +// root is searched before package-local namespaces. +// --------------------------------------------------------------------------- + +describe('Python multi-segment resolution: workspace root wins over ancestor', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-root-precedence-')); + writeFixtureRepo(repoDir, { + 'services/__init__.py': '', + 'services/sync.py': `def root_marker(): + return "root" +`, + 'backend/__init__.py': '', + 'backend/services/__init__.py': '', + 'backend/services/sync.py': `def ancestor_marker(): + return "ancestor" +`, + 'backend/routers/__init__.py': '', + 'backend/routers/cron.py': `from services.sync import root_marker + +def handler(): + return root_marker() +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('resolves the import edge to the root services/sync.py, not backend/services/sync.py', () => { + const imports = getRelationships(result, 'IMPORTS').filter( + (i) => i.sourceFilePath === 'backend/routers/cron.py', + ); + const rootEdge = imports.find((i) => i.targetFilePath === 'services/sync.py'); + expect(rootEdge).toBeDefined(); + + const ancestorEdge = imports.find((i) => i.targetFilePath === 'backend/services/sync.py'); + expect(ancestorEdge).toBeUndefined(); + }); + + it('binds the imported name to the root file, not the ancestor copy', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'backend/routers/cron.py' && c.target === 'root_marker', + ); + expect(calls.length).toBe(1); + expect(calls[0].targetFilePath).toBe('services/sync.py'); + }); +}); + +// --------------------------------------------------------------------------- +// Suffix-fallback determinism: when both root + ancestor walk miss but the +// suffix scan finds multiple candidates in unrelated trees, the resolver +// must pick the same file regardless of file-set insertion order. The +// previous implementation returned the first match in `Set` iteration +// order, which depended on file ingestion order and produced flapping +// edges across runs in multi-directory collision repos. +// +// Tie-break order: fewest path segments, then lexicographic. +// --------------------------------------------------------------------------- + +describe('Python multi-segment resolution: suffix fallback determinism', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-determinism-')); + writeFixtureRepo(repoDir, { + // Importer's package. The `app/services/marker.py` file makes the + // `services` segment gate-pass under the ancestor-bounded + // `hasRepoCandidate` check, but `app/services/sync.py` is + // intentionally absent so the ancestor walk misses and the suffix + // fallback fires. + 'app/services/marker.py': `def _marker(): return True +`, + 'app/main.py': `from services.sync import handler + +def boot(): + return handler() +`, + // Two suffix candidates outside the importer's ancestor tree. + // `lib/services/sync.py` has 3 path segments, the alternative has + // 4 — the deterministic pick is `lib/services/sync.py`. + 'lib/services/sync.py': `def handler(): + return "lib" +`, + 'tooling/extras/services/sync.py': `def handler(): + return "tooling" +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('picks the shortest-path candidate (lib/services/sync.py) and only that one', () => { + const imports = getRelationships(result, 'IMPORTS').filter( + (i) => i.sourceFilePath === 'app/main.py', + ); + + const libEdge = imports.find((i) => i.targetFilePath === 'lib/services/sync.py'); + expect(libEdge).toBeDefined(); + + const toolingEdge = imports.find((i) => i.targetFilePath === 'tooling/extras/services/sync.py'); + expect(toolingEdge).toBeUndefined(); + }); + + it('binds the call to the deterministic pick, not the alternate copy', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'app/main.py' && c.target === 'handler', + ); + expect(calls.length).toBe(1); + expect(calls[0].targetFilePath).toBe('lib/services/sync.py'); + }); +}); + +// --------------------------------------------------------------------------- +// Lexicographic tiebreak: when two suffix candidates have the same +// directory depth, the lexicographically smaller path wins. Without this, +// equal-depth collisions would still depend on file-set insertion order. +// --------------------------------------------------------------------------- + +describe('Python multi-segment resolution: suffix fallback lexicographic tiebreak', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-lex-tiebreak-')); + writeFixtureRepo(repoDir, { + // Same gate-passing pattern as the determinism test — non-init + // marker file makes the `services` segment satisfy + // `hasRepoCandidate` for an importer at `app/main.py`. + 'app/services/marker.py': `def _marker(): return True +`, + 'app/main.py': `from services.sync import handler + +def boot(): + return handler() +`, + // Both candidates have depth 3, so directory-depth alone cannot + // disambiguate. Lexicographic order picks `alpha/...` over + // `omega/...` regardless of which file was ingested first. + 'alpha/services/sync.py': `def handler(): + return "alpha" +`, + 'omega/services/sync.py': `def handler(): + return "omega" +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('picks the lexicographically smaller path on equal-depth ties', () => { + const imports = getRelationships(result, 'IMPORTS').filter( + (i) => i.sourceFilePath === 'app/main.py', + ); + + const alphaEdge = imports.find((i) => i.targetFilePath === 'alpha/services/sync.py'); + expect(alphaEdge).toBeDefined(); + + const omegaEdge = imports.find((i) => i.targetFilePath === 'omega/services/sync.py'); + expect(omegaEdge).toBeUndefined(); + }); + + it('binds the call to alpha/services/sync.py, not omega', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'app/main.py' && c.target === 'handler', + ); + expect(calls.length).toBe(1); + expect(calls[0].targetFilePath).toBe('alpha/services/sync.py'); + }); +}); + +// --------------------------------------------------------------------------- +// Insertion-order independence: re-runs the depth and lexicographic +// scenarios with the candidate files written in reverse order. The +// deterministic sort in `resolveAbsoluteFromFiles` should pick the same +// winner regardless. If a future refactor accidentally drops the sort +// and falls back to `Set` insertion order, these tests pin the +// regression directly. +// --------------------------------------------------------------------------- + +describe('Python multi-segment resolution: suffix fallback insertion-order independence', () => { + let depthRepoDir: string; + let lexRepoDir: string; + let depthResult: PipelineResult; + let lexResult: PipelineResult; + + beforeAll(async () => { + // Depth scenario, files written in reverse order: tooling first, lib + // second. `writeFixtureRepo` iterates in object-property insertion + // order, and the pipeline scanner's directory traversal is also + // affected by mtime/inode order on most filesystems. The expected + // winner is still `lib/services/sync.py` (depth 3 < depth 4). + depthRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-determinism-rev-')); + writeFixtureRepo(depthRepoDir, { + 'tooling/extras/services/sync.py': `def handler(): + return "tooling" +`, + 'lib/services/sync.py': `def handler(): + return "lib" +`, + 'app/services/marker.py': `def _marker(): return True +`, + 'app/main.py': `from services.sync import handler + +def boot(): + return handler() +`, + }); + depthResult = await runPipelineFromRepo(depthRepoDir, () => {}); + + // Lexicographic scenario, files written in reverse order: omega first. + // Expected winner is still `alpha/services/sync.py`. + lexRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-lex-rev-')); + writeFixtureRepo(lexRepoDir, { + 'omega/services/sync.py': `def handler(): + return "omega" +`, + 'alpha/services/sync.py': `def handler(): + return "alpha" +`, + 'app/services/marker.py': `def _marker(): return True +`, + 'app/main.py': `from services.sync import handler + +def boot(): + return handler() +`, + }); + lexResult = await runPipelineFromRepo(lexRepoDir, () => {}); + }, 120000); + + afterAll(() => { + if (depthRepoDir !== undefined) fs.rmSync(depthRepoDir, { recursive: true, force: true }); + if (lexRepoDir !== undefined) fs.rmSync(lexRepoDir, { recursive: true, force: true }); + }); + + it('depth tiebreak still picks lib/services/sync.py with reversed file-write order', () => { + const imports = getRelationships(depthResult, 'IMPORTS').filter( + (i) => i.sourceFilePath === 'app/main.py', + ); + + const libEdge = imports.find((i) => i.targetFilePath === 'lib/services/sync.py'); + expect(libEdge).toBeDefined(); + + const toolingEdge = imports.find((i) => i.targetFilePath === 'tooling/extras/services/sync.py'); + expect(toolingEdge).toBeUndefined(); + }); + + it('lex tiebreak still picks alpha/services/sync.py with reversed file-write order', () => { + const imports = getRelationships(lexResult, 'IMPORTS').filter( + (i) => i.sourceFilePath === 'app/main.py', + ); + + const alphaEdge = imports.find((i) => i.targetFilePath === 'alpha/services/sync.py'); + expect(alphaEdge).toBeDefined(); + + const omegaEdge = imports.find((i) => i.targetFilePath === 'omega/services/sync.py'); + expect(omegaEdge).toBeUndefined(); + }); +}); + // --------------------------------------------------------------------------- // Re-export chain: from .base import X barrel pattern via __init__.py // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index f00435165..2c654d5fc 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -3,8 +3,8 @@ * Verifies that `let user = User(name: "alice"); user.save()` resolves to User.save * without explicit type annotations, using SymbolTable verification. * - * NOTE: tree-sitter-swift has build issues on Node 22 — these tests skip gracefully - * when the Swift parser is not available. + * NOTE: Swift is installed as an optional dependency. These tests skip gracefully + * if a consumer installs without optional dependencies. */ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'path'; diff --git a/gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts b/gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts new file mode 100644 index 000000000..f23df9c11 --- /dev/null +++ b/gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts @@ -0,0 +1,245 @@ +/** + * TypeScript: CALLS edges from inside higher-order-function callbacks. + * + * Repro for the bug filed in `gitnexus-bug-report.md`: in a real + * TS+React monorepo, ~75% of `Function` nodes had no outgoing CALLS + * edges. The dominant pattern was call expressions nested inside + * callbacks passed as arguments to other functions: + * + * - `Promise.all(items.map(item => transform(item)))` + * - `useQuery({ queryFn: () => fetchData() })` + * - `new Promise((resolve) => { reader.readAsDataURL(file); ... })` + * - `create()(devtools(persist((set) => ({ ... }))))` (Zustand) + * + * Two underlying issues fixed by this PR (see `query.ts` and + * `finalize-algorithm.ts`): + * + * 1. **Caller attribution.** `pass2AttachDeclarations` placed the + * `Function` def for arrow-typed declarations on the wrapping + * module scope (the `@declaration.function` anchor was the outer + * `lexical_declaration`, whose start lies before the inner + * arrow's scope). `resolveCallerGraphId` walked up past the empty + * arrow scope into the module and grabbed the first Function-like + * def in `ownedDefs` — frequently the wrong function entirely. + * + * 2. **Cross-file callee discovery.** TypeScript emits BOTH + * `@declaration.function` (Function def) AND `@declaration.variable` + * (Variable def) for `const fn = () => {}`. With (1) fixed, the + * Function-def's anchor moved to the inner arrow, so the Variable + * capture began appearing FIRST in `localDefs` (its match starts + * earlier in the source). `findExportByName` returned the + * Variable, the consumer's import bound to a non-callable, and + * `findCallableBindingInScope` rejected it. + * + * Each test fixture below isolates one HOF-callback shape from the bug + * report with both caller and callee defined in-fixture. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, + getRelationships, + edgeSet, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +describe('TypeScript HOF-callback CALLS edges', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-hof-callbacks'), () => {}); + }, 60000); + + it('control: direct (x) => transform(x) emits direct → transform', () => { + const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'transform'); + expect(edgeSet(calls)).toContain('direct → transform'); + }); + + it('Promise.all(map(...)) emits fanOut → transform (call inside .map callback)', () => { + const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'transform'); + // `fanOut` is the named arrow declaration; the call to `transform` + // is syntactically nested inside `.map(...)` inside `Promise.all(...)`. + expect(edgeSet(calls)).toContain('fanOut → transform'); + }); + + it('new Promise((resolve) => { ... }) emits wrap → transform (call inside executor)', () => { + const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'transform'); + expect(edgeSet(calls)).toContain('wrap → transform'); + }); + + it('useQuery({ queryFn: () => fetchData() }) emits queryFn → fetchData (call inside named pair-arrow)', () => { + // The structurally correct attribution: `fetchData()` is called + // from inside the named pair-arrow `queryFn: () => fetchData()`. + // After moving `@declaration.function` to the inner arrow (mirroring + // the `lexical_declaration` fix), the pair-arrow becomes its own + // caller anchor — `resolveCallerGraphId`'s walk-up stops at + // `queryFn`'s scope rather than continuing into `useFeature`'s. + // + // Pre-fix this test asserted `useFeature → fetchData` because the + // pair pattern's `@declaration.function` anchor was on the outer + // `pair`, sending `queryFn`'s def into `useFeature`'s `ownedDefs` + // and bypassing `queryFn` as a caller anchor. That attribution + // was wrong twice over: it crossed a syntactic function boundary + // (the arrow body), and it depended on the pair-pattern bug to + // reroute the walk. Edges that capture intent should follow the + // syntax tree. + const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'fetchData'); + expect(edgeSet(calls)).toContain('queryFn → fetchData'); + }); + + it('useQuery({ queryFn: () => fetchData() }) emits useFeature → useQuery (direct call in body)', () => { + const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'useQuery'); + expect(edgeSet(calls)).toContain('useFeature → useQuery'); + }); + + it('Zustand create()(devtools(persist((set) => ({ ... })))) does NOT emit phantom self-loops', () => { + // The Zustand idiom `export const useStore = create()(devtools(persist((set) => ({ ... }))))` + // has its module-level call expressions (`create()`, `devtools(...)`, + // `persist(...)`) in `useStore`'s declaration RHS, syntactically + // outside any function body. The bug-report case + // (`grouped-file-uploads-store.tsx`, "0% capture") was driven by + // these calls being mis-attributed to a sibling Function (the + // first declared callable in the module's `ownedDefs`), producing + // bogus self-loops like `Function:create → Function:create`. The + // fix in `resolveCallerGraphId` excludes Variable defs from the + // walk-up's class-fallback branch — module-level calls now fall + // through to the File node like any other module-level reference. + // + // What this test asserts: NO phantom self-loops, and NO phantom + // edges where one local function "calls" a sibling local + // function via misattribution. + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/store.ts' && c.targetFilePath === 'src/store.ts', + ); + const phantomSelfLoops = calls.filter((c) => c.source === c.target); + expect(phantomSelfLoops, 'phantom self-loop CALLS edges').toEqual([]); + + // Specifically the regression: `create → create / devtools / persist`. + const fromCreate = calls.filter((c) => c.source === 'create'); + expect(fromCreate, 'create() must not be a phantom caller').toEqual([]); + }); + + it('Zustand module-level calls source from the File node (not a sibling Function)', () => { + // The positive complement to the anti-self-loop assertion above: + // module-level calls in `store.ts` (`create()`, `devtools(...)`, + // `persist(...)`) MUST attribute to the `File` node — that's the + // entire point of `isCallerAnchorLabel` excluding `Variable` from + // the caller-walk fallback. If the fix regresses (Variable defs + // re-enter the fallback, or the walk-up grabs a sibling Function), + // the source would change away from `File:store.ts`. + // + // Earlier formulation iterated `for (c of calls)` and asserted each + // edge sourced from File. That passed VACUOUSLY when `calls` was + // empty — any change that silenced ALL CALLS edges from `store.ts` + // would have slipped through. The structural assertion below is + // explicit: at least one File-rooted edge must exist (proving the + // fallback fired), and no edge may source from anything else + // (proving the fallback fired EXCLUSIVELY, not as one option + // alongside a buggy sibling-Function attribution). + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/store.ts', + ); + const fromFile = calls.filter((c) => c.sourceLabel === 'File' && c.source === 'store.ts'); + const fromOther = calls.filter((c) => !(c.sourceLabel === 'File' && c.source === 'store.ts')); + expect(fromOther, 'no module-level call may attribute to a non-File source').toEqual([]); + expect(fromFile.length, 'at least one File-rooted call edge must exist').toBeGreaterThan(0); + }); + + it('transform is reachable from at least 3 of {direct, fanOut, wrap}', () => { + // Catch-all: pre-fix, only `direct → transform` was captured (or + // even THAT was missing depending on file order). After fix, all + // three callers attribute their `transform` call correctly. + const callers = new Set( + getRelationships(result, 'CALLS') + .filter((c) => c.target === 'transform') + .map((c) => c.source), + ); + expect(callers).toContain('direct'); + expect(callers).toContain('fanOut'); + expect(callers).toContain('wrap'); + }); + + // ───────────────────────────────────────────────────────────────── + // Multi-pair object literal — regression case the single-pair `bump` + // fixture in `store.ts` masked. See `multi-action-store.ts` and the + // anchor-discipline comment in `query.ts` above the four pair-with- + // arrow patterns. PR #1175 review (medium finding) flagged this. + // ───────────────────────────────────────────────────────────────── + + it('multi-action store: addItem → doA (calls inside addItem attribute to addItem, not first sibling)', () => { + // The diagnostic test for the pair-anchor fix. With the broken + // anchor (on outer `pair`), all three pair-function defs (addItem, + // removeItem, fetchData) landed in the same `(set) => ({...})` + // callback's `ownedDefs`, and `resolveCallerGraphId.ownedDefs.find()` + // returned the FIRST one — `addItem` — for every walk-up. So + // calls inside `removeItem` and `fetchData` got mis-attributed. + // + // After the fix, each pair-arrow gets its def in its OWN arrow + // scope's `ownedDefs`; the walk-up stops one level earlier and + // resolves to the correct sibling. + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/multi-action-store.ts', + ); + const fromAddItem = calls.filter((c) => c.source === 'addItem' && c.target === 'doA'); + expect(fromAddItem.length, 'addItem must call doA').toBeGreaterThan(0); + }); + + it('multi-action store: removeItem → doB (NOT addItem → doB)', () => { + // The exact regression fingerprint. Pre-fix, `removeItem`'s body + // would attribute its `doB(item)` call to `addItem` (the first + // pair-function def in the parent `(set) => ({...})` scope), + // producing the bogus edge `addItem → doB` and zero outgoing + // edges from `removeItem`. The negative + positive assertion + // pinpoints both halves: no mis-attribution AND a real edge. + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/multi-action-store.ts' && c.target === 'doB', + ); + const fromRemoveItem = calls.filter((c) => c.source === 'removeItem'); + const fromAddItem = calls.filter((c) => c.source === 'addItem'); + expect( + fromAddItem, + 'doB must NOT be attributed to addItem (mis-attribution regression)', + ).toEqual([]); + expect(fromRemoveItem.length, 'removeItem must call doB').toBeGreaterThan(0); + }); + + it('multi-action store: fetchData → doC (third action also attributes correctly)', () => { + // Three actions in the same object guarantees the `find()`-returns- + // first defect would mis-attribute fetchData's call. With the fix, + // each action's body is its own caller anchor. + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/multi-action-store.ts' && c.target === 'doC', + ); + const fromFetch = calls.filter((c) => c.source === 'fetchData'); + const fromAddItem = calls.filter((c) => c.source === 'addItem'); + expect(fromAddItem, 'doC must NOT be attributed to addItem').toEqual([]); + expect(fromFetch.length, 'fetchData must call doC').toBeGreaterThan(0); + }); + + it('multi-action store: each action attributes calls to itself (no cross-sibling leakage)', () => { + // Whole-of-fixture invariant: the set of (source, target) pairs + // for the three action calls must be exactly {addItem→doA, + // removeItem→doB, fetchData→doC}. No sibling leakage allowed. + const calls = getRelationships(result, 'CALLS').filter( + (c) => + c.sourceFilePath === 'src/multi-action-store.ts' && + ['doA', 'doB', 'doC'].includes(c.target as string), + ); + const pairs = new Set(calls.map((c) => `${c.source} → ${c.target}`)); + expect(pairs).toContain('addItem → doA'); + expect(pairs).toContain('removeItem → doB'); + expect(pairs).toContain('fetchData → doC'); + // No cross-attribution like `addItem → doB`, `addItem → doC`, etc. + const crossLeaks = [...pairs].filter( + (p) => + p === 'addItem → doB' || + p === 'addItem → doC' || + p === 'removeItem → doA' || + p === 'removeItem → doC' || + p === 'fetchData → doA' || + p === 'fetchData → doB', + ); + expect(crossLeaks, 'no pair-arrow may attribute calls to a sibling action').toEqual([]); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript-jsx-as-call.test.ts b/gitnexus/test/integration/resolvers/typescript-jsx-as-call.test.ts new file mode 100644 index 000000000..d1fef4559 --- /dev/null +++ b/gitnexus/test/integration/resolvers/typescript-jsx-as-call.test.ts @@ -0,0 +1,128 @@ +/** + * TypeScript: CALLS edges from JSX element invocations. + * + * `` is syntactic sugar for `Foo(props)` — the React renderer + * invokes the component at runtime. For `gitnexus_impact` and + * `gitnexus_context` to give meaningful answers on `.tsx` codebases, + * JSX usage must surface as a CALLS edge. + * + * Pre-fix scope: in a real React monorepo (Sourcerer-fe), `.tsx` files + * had a 67.5% function-orphan rate vs 61.2% for plain `.ts`. Spot + * checks of orphan React components consistently traced back to JSX + * being the only "call" in the function body — invisible to the + * indexer because the TS scope query had no `jsx_*` patterns. + * + * Each test fixture below isolates one JSX shape: + * + * - self-closing `` — simple-usage.tsx + * - paired `...` — paired-usage.tsx + * - namespaced `` — member-usage.tsx + * - nested ``— nested-usage.tsx + * - HTML-only `
`/`` — html-only.tsx (negative test) + * - HOF + JSX `const F = () => ` — hof-jsx.tsx (combined-fix probe) + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, + getRelationships, + edgeSet, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +describe('TypeScript JSX-as-call CALLS edges', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-jsx-as-call'), () => {}); + }, 60000); + + it('self-closing emits useFoo → Foo', () => { + const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'Foo'); + expect(edgeSet(calls)).toContain('useFoo → Foo'); + }); + + it('paired ... emits useBar → Bar (closing tag does NOT double-count)', () => { + const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'Bar'); + // Exactly one CALLS edge from useBar to Bar — the query captures + // jsx_opening_element only, NOT jsx_closing_element. Multiple matches + // here would mean the closing tag is also being captured (a bug — + // each JSX element is one logical invocation, not two). + const useBarToBar = calls.filter((c) => c.source === 'useBar'); + expect(useBarToBar).toHaveLength(1); + expect(edgeSet(calls)).toContain('useBar → Bar'); + }); + + it('namespaced is captured (no phantom read, no edge to receiver)', () => { + // What this PR tests at the query level: the JSX-as-member capture + // intercepts `` BEFORE the generic + // `@reference.read.member` catch-all does. Two negative + // post-conditions verify the interception: + // + // (a) NO ACCESSES edge `useNamespaced → Title` (the phantom read + // suppression — see `shouldEmitReadMember`'s jsx-* cases). + // (b) NO CALLS edge `useNamespaced → Container` (the member call + // must NOT collapse to its receiver — that would mean we're + // dispatching off `Container` rather than off `Container.Title`). + // + // The positive CALLS edge `useNamespaced → Title` requires + // chasing the receiver chain through `Container = { Title }`, + // which is a pre-existing compound-receiver limitation (object- + // literal namespaces aren't fully chained today). That gap is + // orthogonal to JSX-as-call and is left as future work. + const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'useNamespaced'); + const callTargets = new Set(calls.map((c) => c.target)); + expect(callTargets).not.toContain('Container'); + }); + + it('nested emits both useNested → Outer AND useNested → Inner', () => { + const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'useNested'); + const targets = new Set(calls.map((c) => c.target)); + expect(targets).toContain('Outer'); + expect(targets).toContain('Inner'); + }); + + it('lowercase HTML elements (
, ,