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/.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/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index c3c4ca0de..7d8fbfda4 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -39,8 +39,12 @@ function isGlobalRegistryDir(candidate) { ); } -function findGitNexusDir(startDir) { - let dir = startDir || process.cwd(); +/** + * Walk up from `startDir` looking for a non-registry `.gitnexus/` folder. + * Returns the path to `.gitnexus/` or null if not found within 5 levels. + */ +function walkForGitNexusDir(startDir) { + let dir = startDir; for (let i = 0; i < 5; i++) { const candidate = path.join(dir, '.gitnexus'); if (fs.existsSync(candidate)) { @@ -53,6 +57,51 @@ function findGitNexusDir(startDir) { return null; } +/** + * Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside + * any git working tree — including a *linked* worktree created via + * `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the + * upward walk from cwd alone misses the index. Returns null when `cwd` is + * not inside a git repo or `git` is not available. + * + * Implementation: `git rev-parse --git-common-dir` resolves to the canonical + * `.git/` directory (or `.git/worktrees/...` parent) that is shared across + * all linked worktrees. The canonical repo root is its parent directory. + */ +function findCanonicalRepoRoot(cwd) { + try { + const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + if (result.error || result.status !== 0) return null; + const commonDir = (result.stdout || '').trim(); + if (!commonDir || !path.isAbsolute(commonDir)) return null; + return path.dirname(commonDir); + } catch { + return null; + } +} + +function findGitNexusDir(startDir) { + const cwd = startDir || process.cwd(); + + // Fast path: the cwd is inside the canonical repo (most common case). + const fromCwd = walkForGitNexusDir(cwd); + if (fromCwd) return fromCwd; + + // Fallback: cwd may be inside a linked git worktree whose `.gitnexus/` + // only lives in the canonical repo root. Resolve the shared git dir + // and retry from there. + const canonicalRoot = findCanonicalRepoRoot(cwd); + if (canonicalRoot && canonicalRoot !== cwd) { + return walkForGitNexusDir(canonicalRoot); + } + return null; +} + /** * Extract search pattern from tool input. */ 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 56509144c..61506f60c 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -32,15 +32,15 @@ "mermaid": "^11.14.0", "mnemonist": "^0.39.0", "pandemonium": "^2.4.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", - "react-zoom-pan-pinch": "^3.7.0", + "react-zoom-pan-pinch": "^4.0.3", "remark-gfm": "^4.0.1", "sigma": "^3.0.2", "tailwindcss": "^4.2.4", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "zod": "^3.25.76" }, "devDependencies": { @@ -50,9 +50,9 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/dompurify": "^3.0.5", - "@types/node": "^24.10.1", - "@types/react": "^18.3.5", - "@types/react-dom": "^18.3.0", + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.5.16", "@vitejs/plugin-react": "^5.1.4", @@ -1538,9 +1538,9 @@ } }, "node_modules/@langchain/langgraph-sdk": { - "version": "1.8.9", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.9.tgz", - "integrity": "sha512-vpz90auS4iFTNy2X/CFexOEoeFSvaK+MyI7iSmzYs9gGcfzwRjWUJ4MWsuc5ZNRecLStwho0PExVXRgGOXtcRw==", + "version": "1.8.10", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.10.tgz", + "integrity": "sha512-wrB3rkRw5KAmsqezwvKP3midT4qJrV6Hj9XJMYo+cbvXC4HYpSAmyY/VriSyeTFRbLG/OP/pY2Yz+9Z54nSaXQ==", "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.15", @@ -1580,12 +1580,12 @@ "license": "MIT" }, "node_modules/@langchain/langgraph-sdk/node_modules/p-queue": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.2.tgz", - "integrity": "sha512-ktsDOALzTYTWWF1PbkNVg2rOt+HaOaMWJMUnt7T3qf5tvZ1L8dBW3tObzprBcXNMKkwj+yFSLqHso0x+UFcJXw==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.2.0.tgz", + "integrity": "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==", "license": "MIT", "dependencies": { - "eventemitter3": "^5.0.1", + "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" }, "engines": { @@ -1607,6 +1607,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@langchain/langgraph-sdk/node_modules/uuid": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz", + "integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/@langchain/langgraph/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -2871,13 +2884,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", - "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/prismjs": { @@ -2886,30 +2899,23 @@ "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", "license": "MIT" }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", "dependencies": { - "@types/prop-types": "*", "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^18.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/react-syntax-highlighter": { @@ -5491,6 +5497,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/jsdom": { @@ -6033,18 +6040,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lowlight": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", @@ -7732,28 +7727,24 @@ "license": "MIT" }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.2.5" } }, "node_modules/react-is": { @@ -7822,9 +7813,9 @@ } }, "node_modules/react-zoom-pan-pinch": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz", - "integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-4.0.3.tgz", + "integrity": "sha512-N2Hi6L78fFmhRra+ORpFSW7WST5x6kxpOPplIvtB0b7b+U2anpo1z1wLgaWRPS2kUSqcraRG+JgBCIlDJnqqAg==", "license": "MIT", "engines": { "node": ">=8", @@ -8079,13 +8070,10 @@ } }, "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" }, "node_modules/semver": { "version": "7.7.3", @@ -8544,9 +8532,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "devOptional": true, "license": "MIT" }, @@ -8679,9 +8667,9 @@ } }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 463eb2d8e..6d08273f5 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -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..d3835d9c4 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.Ref; + messagesContainerRef: React.Ref; isAtBottom: boolean; scrollToBottom: (behavior?: ScrollBehavior) => void; } diff --git a/gitnexus-web/src/hooks/useSigma.ts b/gitnexus-web/src/hooks/useSigma.ts index 8f350f0d3..822cab383 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.Ref; sigmaRef: React.RefObject; setGraph: (graph: Graph) => void; zoomIn: () => void; diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 34283b48e..7bfa150cd 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -39,8 +39,12 @@ function isGlobalRegistryDir(candidate) { ); } -function findGitNexusDir(startDir) { - let dir = startDir || process.cwd(); +/** + * Walk up from `startDir` looking for a non-registry `.gitnexus/` folder. + * Returns the path to `.gitnexus/` or null if not found within 5 levels. + */ +function walkForGitNexusDir(startDir) { + let dir = startDir; for (let i = 0; i < 5; i++) { const candidate = path.join(dir, '.gitnexus'); if (fs.existsSync(candidate)) { @@ -53,6 +57,51 @@ function findGitNexusDir(startDir) { return null; } +/** + * Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside + * any git working tree — including a *linked* worktree created via + * `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the + * upward walk from cwd alone misses the index. Returns null when `cwd` is + * not inside a git repo or `git` is not available. + * + * Implementation: `git rev-parse --git-common-dir` resolves to the canonical + * `.git/` directory (or `.git/worktrees/...` parent) that is shared across + * all linked worktrees. The canonical repo root is its parent directory. + */ +function findCanonicalRepoRoot(cwd) { + try { + const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + if (result.error || result.status !== 0) return null; + const commonDir = (result.stdout || '').trim(); + if (!commonDir || !path.isAbsolute(commonDir)) return null; + return path.dirname(commonDir); + } catch { + return null; + } +} + +function findGitNexusDir(startDir) { + const cwd = startDir || process.cwd(); + + // Fast path: the cwd is inside the canonical repo (most common case). + const fromCwd = walkForGitNexusDir(cwd); + if (fromCwd) return fromCwd; + + // Fallback: cwd may be inside a linked git worktree whose `.gitnexus/` + // only lives in the canonical repo root. Resolve the shared git dir + // and retry from there. + const canonicalRoot = findCanonicalRepoRoot(cwd); + if (canonicalRoot && canonicalRoot !== cwd) { + return walkForGitNexusDir(canonicalRoot); + } + return null; +} + /** * Extract search pattern from tool input. */ diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 9b37042c3..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", @@ -75,7 +75,7 @@ "version": "1.0.0", "dev": true, "devDependencies": { - "typescript": "^6.0.2" + "typescript": "^6.0.3" } }, "node_modules/@babel/helper-string-parser": { @@ -1159,9 +1159,9 @@ } }, "node_modules/@ladybugdb/core": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.15.3.tgz", - "integrity": "sha512-Xa8VmWhMTvTCWmApnqm9FJtyxxV+CiMCokl1p9vEfXNuBz3SWXWGDmHlzKikswtQbUe9tTV3J9MxPdVFVE6/yg==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.16.0.tgz", + "integrity": "sha512-t/t4MPZmBMocFBzG5G3E3iHPwuIiXYEuLeW0CTOloGofkKQ7gHt3JlLzyDn2a+AHNQjr1YqlsodKKYQFhsFZXw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1169,16 +1169,17 @@ "node-addon-api": "^6.0.0" }, "optionalDependencies": { - "@ladybugdb/core-darwin-arm64": "0.15.3", - "@ladybugdb/core-linux-arm64": "0.15.3", - "@ladybugdb/core-linux-x64": "0.15.3", - "@ladybugdb/core-win32-x64": "0.15.3" + "@ladybugdb/core-darwin-arm64": "0.16.0", + "@ladybugdb/core-darwin-x64": "0.16.0", + "@ladybugdb/core-linux-arm64": "0.16.0", + "@ladybugdb/core-linux-x64": "0.16.0", + "@ladybugdb/core-win32-x64": "0.16.0" } }, "node_modules/@ladybugdb/core-darwin-arm64": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.15.3.tgz", - "integrity": "sha512-+bqAb3wbbmxPSeNQjbVd6Ek5K8GbHr1KlDr09YkNqZ7XWhKqWxbs097xAG9bynLcZh9oxok2PGCoK4w5YHs11w==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.16.0.tgz", + "integrity": "sha512-2IpiUbd6Lb50KRUkURk+PIgDRKume63uI4KYZNpjxNDwdHRXdadZTBZn74+DgK7IhpTyiPbtKddiXHKtSV2CWg==", "cpu": [ "arm64" ], @@ -1189,9 +1190,9 @@ ] }, "node_modules/@ladybugdb/core-linux-arm64": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.15.3.tgz", - "integrity": "sha512-Z8Ur6YbC5y6pgtKh/7b1/xdeRHy69sGhsoVJm1tc9xp9Zrar6G2A71bEdjOdDJ/mDRt6RtY0zdhUgIgQXYQtbQ==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.16.0.tgz", + "integrity": "sha512-l+lV7BXfnA0w1voApKblBaGE+bKQqSlOG+30HkSYOAW7POYv+OoydgY/BGwabBUTvcnhVyrNApvBsPF8G3Nm3g==", "cpu": [ "arm64" ], @@ -1202,9 +1203,9 @@ ] }, "node_modules/@ladybugdb/core-linux-x64": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.15.3.tgz", - "integrity": "sha512-DT9xBc91tuxzjRu1dJ3xGt/K/uR1Q8bX5+8tCtj66UbVIVvp1RWAAE9phq7eahcF/3zBuFRonkxW/tTyQdQIlQ==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.16.0.tgz", + "integrity": "sha512-XOL2H0y51e57dIFIHO8LHtN8Ner2qEyti6zAkxKr+w8LkvczHeVX910doz2de8+xvxDYJyzrcj2xWqDTxcK/Jg==", "cpu": [ "x64" ], @@ -1215,9 +1216,9 @@ ] }, "node_modules/@ladybugdb/core-win32-x64": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.15.3.tgz", - "integrity": "sha512-ymHC8nHGIT7M9aditBQFIystxW+WoqvI3xklz22BHaFpU9CrTNtdU20K6cuRZvqEA2//Edu7kMoP9OwLkIleCg==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.16.0.tgz", + "integrity": "sha512-MyKiELqPgzx9gVHmwxzptnToAcDtCN7dTP5Y4IPMYhc2QpNbZKCihmvdXjbOmdIOfIHW4fBp4vrzT8fVbdAMZw==", "cpu": [ "x64" ], @@ -1227,6 +1228,9 @@ "win32" ] }, + "node_modules/@ladybugdb/core/node_modules/@ladybugdb/core-darwin-x64": { + "optional": true + }, "node_modules/@ladybugdb/core/node_modules/node-addon-api": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", @@ -2428,13 +2432,6 @@ "node": ">= 0.8" } }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -2763,12 +2760,6 @@ "node": ">=8" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "license": "MIT" - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2841,12 +2832,6 @@ "node": ">= 0.4" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "license": "MIT" - }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -3270,17 +3255,15 @@ } }, "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz", + "integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==", "license": "BSD-3-Clause", "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" + "globalthis": "^1.0.2", + "matcher": "^4.0.0", + "semver": "^7.3.5", + "serialize-error": "^8.1.0" }, "engines": { "node": ">=10.0" @@ -3612,12 +3595,6 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC" - }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -3951,15 +3928,18 @@ } }, "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz", + "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==", "license": "MIT", "dependencies": { "escape-string-regexp": "^4.0.0" }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/math-intrinsics": { @@ -4077,9 +4057,9 @@ } }, "node_modules/mnemonist": { - "version": "0.40.3", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", - "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", + "version": "0.40.4", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz", + "integrity": "sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==", "license": "MIT", "dependencies": { "obliterator": "^2.0.4" @@ -4214,15 +4194,15 @@ } }, "node_modules/onnxruntime-common": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", - "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.25.1.tgz", + "integrity": "sha512-kKvYQFdos4LWJqhZ+nmKu3NT8NXzw8I5x9fNUKe1rNKcPfNKnYXUtW7JBpcKFsvLtrJashRgVYSbFap4cHxvNg==", "license": "MIT" }, "node_modules/onnxruntime-node": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", - "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.25.1.tgz", + "integrity": "sha512-N0M58CGTiTsLkPpx9bxmRFi24GT6r67Qei/GrBEIiDyntcYdXU5vQZp112ypydG9vEKRFgbgUYQJnEi+jll8dg==", "hasInstallScript": true, "license": "MIT", "os": [ @@ -4232,8 +4212,8 @@ ], "dependencies": { "adm-zip": "^0.5.16", - "global-agent": "^3.0.0", - "onnxruntime-common": "1.24.3" + "global-agent": "^4.1.3", + "onnxruntime-common": "1.25.1" } }, "node_modules/onnxruntime-web": { @@ -4520,23 +4500,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "license": "BSD-3-Clause", - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/rolldown": { "version": "1.0.0-rc.16", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", @@ -4635,12 +4598,6 @@ "node": ">=10" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "license": "MIT" - }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -4681,12 +4638,12 @@ "license": "MIT" }, "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", "license": "MIT", "dependencies": { - "type-fest": "^0.13.1" + "type-fest": "^0.20.2" }, "engines": { "node": ">=10" @@ -4870,12 +4827,6 @@ "node": ">=0.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause" - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -5028,20 +4979,20 @@ } }, "node_modules/tree-sitter-c": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/tree-sitter-c/-/tree-sitter-c-0.23.2.tgz", - "integrity": "sha512-9kADOx31AF94DHcrsMGW0zM/2LS6v7wFkPHPVm7RQU+vYVVZMKZ2FJ9e99pm5feqsAcjUzB9CarqDLgRT1Fe/w==", + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/tree-sitter-c/-/tree-sitter-c-0.21.4.tgz", + "integrity": "sha512-IahxFIhXiY15SUlrt2upBiKSBGdOaE1fjKLK1Ik5zxqGHf6T1rvr3IJrovbsE5sXhypx7Hnmf50gshsppaIihA==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "node-addon-api": "^8.2.2", - "node-gyp-build": "^4.8.2" + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.1" }, "peerDependencies": { - "tree-sitter": "^0.21.1" + "tree-sitter": "^0.21.0" }, "peerDependenciesMeta": { - "tree-sitter": { + "tree_sitter": { "optional": true } } @@ -5066,15 +5017,14 @@ } }, "node_modules/tree-sitter-cpp": { - "version": "0.23.4", - "resolved": "https://registry.npmjs.org/tree-sitter-cpp/-/tree-sitter-cpp-0.23.4.tgz", - "integrity": "sha512-qR5qUDyhZ5jJ6V8/umiBxokRbe89bCGmcq/dk94wI4kN86qfdV8k0GHIUEKaqWgcu42wKal5E97LKpLeVW8sKw==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/tree-sitter-cpp/-/tree-sitter-cpp-0.23.2.tgz", + "integrity": "sha512-GTa5Dx1O9ihzW70LvaUviTclh+wlBDRz6opR9Ij4NQIFmq/joeZ/k65UbLV4nLidR7xZ9eNNGT/SonCqAmjGVg==", "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.2.1", - "node-gyp-build": "^4.8.2", - "tree-sitter-c": "^0.23.1" + "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" @@ -5305,9 +5255,9 @@ } }, "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" diff --git a/gitnexus/package.json b/gitnexus/package.json index 59940e834..73ebd3c6c 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -53,7 +53,7 @@ }, "dependencies": { "@huggingface/transformers": "^4.1.0", - "@ladybugdb/core": "^0.15.2", + "@ladybugdb/core": "^0.16.0", "@modelcontextprotocol/sdk": "^1.0.0", "@scarf/scarf": "^1.4.0", "cli-progress": "^3.12.0", @@ -72,9 +72,9 @@ "onnxruntime-node": "^1.24.0", "pandemonium": "^2.4.0", "tree-sitter": "^0.21.1", - "tree-sitter-c": "0.23.2", + "tree-sitter-c": "0.21.4", "tree-sitter-c-sharp": "0.23.1", - "tree-sitter-cpp": "^0.23.4", + "tree-sitter-cpp": "0.23.2", "tree-sitter-go": "^0.23.0", "tree-sitter-java": "^0.23.5", "tree-sitter-javascript": "^0.23.0", @@ -109,8 +109,7 @@ "overrides": { "@huggingface/transformers": { "onnxruntime-node": "$onnxruntime-node" - }, - "tree-sitter-c": "0.23.2" + } }, "engines": { "node": ">=20.0.0" diff --git a/gitnexus/scripts/install-duckdb-extension.mjs b/gitnexus/scripts/install-duckdb-extension.mjs index 8b0b3324b..2bc65a05e 100644 --- a/gitnexus/scripts/install-duckdb-extension.mjs +++ b/gitnexus/scripts/install-duckdb-extension.mjs @@ -6,6 +6,14 @@ import { createRequire } from 'node:module'; const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/; +function parseLbugMaxDbSize(raw) { + const parsed = raw ? Number(raw) : NaN; + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid LadybugDB max DB size for extension installer: ${raw ?? ''}`); + } + return Math.floor(parsed); +} + async function installDuckDbExtension(extensionName) { if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) { throw new Error(`Invalid DuckDB extension name: ${extensionName ?? ''}`); @@ -14,6 +22,9 @@ async function installDuckDbExtension(extensionName) { const require = createRequire(import.meta.url); const lbugModule = require('@ladybugdb/core'); const lbug = lbugModule.default ?? lbugModule; + const lbugMaxDbSize = parseLbugMaxDbSize( + process.argv[3] ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE, + ); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ext-install-')); const dbPath = path.join(tmpDir, 'install.lbug'); @@ -21,7 +32,7 @@ async function installDuckDbExtension(extensionName) { let conn; try { - db = new lbug.Database(dbPath); + db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize); conn = new lbug.Connection(db); await conn.query(`INSTALL ${extensionName}`); } finally { diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index d7b293a9f..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. */ @@ -102,6 +142,11 @@ export interface AnalyzeOptions { export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => { if (ensureHeap()) return; + // Install fatal handlers immediately after re-exec resolution so any + // async error that escapes the try/catch below (#1169) surfaces with + // a stack trace and a non-zero exit code instead of a silent exit 0. + installFatalHandlers(); + if (options?.verbose) { process.env.GITNEXUS_VERBOSE = '1'; } @@ -173,21 +218,19 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption let repoPath: string; if (inputPath) { repoPath = path.resolve(inputPath); + } else if (options?.skipGit) { + // --skip-git: treat cwd as the index root, do not walk up to a parent git repo. + repoPath = path.resolve(process.cwd()); } else { const gitRoot = getGitRoot(process.cwd()); if (!gitRoot) { - if (!options?.skipGit) { - console.log( - ' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n', - ); - process.exitCode = 1; - return; - } - // --skip-git: fall back to cwd as the root - repoPath = path.resolve(process.cwd()); - } else { - repoPath = gitRoot; + console.log( + ' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n', + ); + process.exitCode = 1; + return; } + repoPath = gitRoot; } const repoHasGit = hasGitDir(repoPath); @@ -316,6 +359,11 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption ); if (result.alreadyUpToDate) { + // Even the fast path must prove the repo is discoverable. A prior + // run can write meta.json and then fail before registerRepo(); in + // that half-finalized state, runFullAnalysis returns alreadyUpToDate + // on the next invocation unless we check the registry here too. + await assertAnalysisFinalized(repoPath); clearInterval(elapsedTimer); process.removeListener('SIGINT', sigintHandler); console.log = origLog; @@ -328,6 +376,15 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption return; } + // Post-finalize invariant (#1169): runFullAnalysis nominally writes + // meta.json and registers the repo, but on Windows it has been + // observed to return successfully with neither artifact present + // (banner-only output, exit 0). Verify both before declaring + // success so the silent-finalize state surfaces with a non-zero + // exit code and an actionable error instead of being mistaken for + // a healthy index. + await assertAnalysisFinalized(repoPath); + // Skill generation (CLI-only, uses pipeline result from analysis) if (options?.skills && result.pipelineResult) { updateBar(99, 'Generating skill files...'); @@ -429,7 +486,29 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption return; } - console.error(`\n Analysis failed: ${msg}\n`); + // Finalize invariant failure (#1169) — keep the rich actionable + // message intact and write through realStderrWrite so it can't be + // erased by a leftover bar refresh on slow terminals. + if (err instanceof AnalysisNotFinalizedError) { + writeFatalToStderr('Analysis did not finalize', err); + realStderrWrite( + `\n Diagnostic checklist:\n` + + ` 1. Re-run "gitnexus analyze" - transient native errors often clear on retry.\n` + + ` 2. Inspect ${err.storagePath} - a leftover lbug.wal indicates an aborted write.\n` + + ` 3. If the failure persists, run with NODE_OPTIONS="--max-old-space-size=8192 --trace-exit"\n` + + ` and attach the trace to the GitNexus issue tracker.\n\n`, + ); + process.exitCode = 1; + return; + } + + // Bypass the redirected console.error and write the full stack to + // the real stderr captured at module load. The redirected + // console.error wraps every line with `\\x1b[2K\\r` (ANSI clear-line) + // and forces a bar.update() afterwards, which on some Windows + // terminals visually erases the failure message — the canonical + // shape of the silent-exit symptom in #1169. + writeFatalToStderr('Analysis failed', err); // Provide helpful guidance for known failure modes if ( 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 dcee1339a..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 ' + diff --git a/gitnexus/src/core/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index caab198b4..0d7fe41df 100644 --- a/gitnexus/src/core/embeddings/embedder.ts +++ b/gitnexus/src/core/embeddings/embedder.ts @@ -15,7 +15,6 @@ if (!process.env.ORT_LOG_LEVEL) { } import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers'; -import os from 'os'; import { existsSync } from 'fs'; import { execFileSync } from 'child_process'; import { join, dirname } from 'path'; @@ -23,6 +22,7 @@ import { createRequire } from 'module'; import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js'; import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js'; import { resolveEmbeddingConfig } from './config.js'; +import { applyHfEnvOverrides } from './hf-env.js'; /** * Check whether the onnxruntime-node package that @huggingface/transformers @@ -158,11 +158,11 @@ export const initEmbedder = async ( try { // Configure transformers.js environment env.allowLocalModels = false; - // Default cache to user-writable location. transformers.js defaults to - // ./node_modules/.cache inside its own install dir, which is unwritable - // when gitnexus is installed globally (e.g. /usr/lib/node_modules/). - // Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface. - env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface'); + // Bridge user-controlled env vars to transformers.js: HF_HOME → + // env.cacheDir, HF_ENDPOINT → env.remoteHost (#1205). Centralised in + // applyHfEnvOverrides so the MCP embedder entry point behaves + // identically. + applyHfEnvOverrides(env); const isDev = process.env.NODE_ENV === 'development'; if (isDev) { 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/git-staleness.ts b/gitnexus/src/core/git-staleness.ts index 93e556ab5..96f70ddd6 100644 --- a/gitnexus/src/core/git-staleness.ts +++ b/gitnexus/src/core/git-staleness.ts @@ -6,7 +6,7 @@ import { execFileSync } from 'node:child_process'; import path from 'path'; import { readRegistry, type RegistryEntry, type CwdMatch } from '../storage/repo-manager.js'; -import { getGitRoot, getCurrentCommit, getRemoteUrl } from '../storage/git.js'; +import { findGitRootByDotGit, getCurrentCommit, getRemoteUrl } from '../storage/git.js'; export interface StalenessInfo { isStale: boolean; @@ -101,9 +101,10 @@ export async function checkCwdMatch(cwd: string): Promise { } if (bestPath) return { match: 'path', entry: bestPath }; - // 2) Sibling-by-remote: locate the cwd's git root, get its remote - // URL, and look for any registered entry with the same fingerprint. - const cwdGitRoot = getGitRoot(cwdResolved); + // 2) Sibling-by-remote: locate the cwd's git root using only ancestor + // `.git` checks before shelling out. This keeps MCP startup from + // running git in an unrelated launch cwd such as $HOME (#1138). + const cwdGitRoot = findGitRootByDotGit(cwdResolved); if (!cwdGitRoot) return { match: 'none' }; const cwdRemote = getRemoteUrl(cwdGitRoot); 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 429c3a50d..b85fd4349 100644 --- a/gitnexus/src/core/group/config-parser.ts +++ b/gitnexus/src/core/group/config-parser.ts @@ -14,6 +14,7 @@ const DEFAULT_DETECT = { topics: true, shared_libs: true, embedding_fallback: true, + workspace_deps: true, }; const DEFAULT_MATCHING = { 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 08a86a477..e7d1f0611 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -273,6 +273,19 @@ export class ManifestExtractor { LIMIT 1`, { contract: link.contract }, ); + } else if (link.type === 'custom') { + // V1: exact name-only match on code-definition nodes. + // Positive allowlist mirrors other contract types. If multiple code + // symbols share the same name, ORDER BY filePath ASC LIMIT 1 picks + // the alphabetically-first occurrence deterministically. + rows = await executor( + `MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement) + WHERE n.name = $contract + RETURN n.id AS uid, n.name AS name, n.filePath AS filePath + ORDER BY n.filePath ASC + LIMIT 1`, + { contract: link.contract }, + ); } else { return null; } 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/sync.ts b/gitnexus/src/core/group/sync.ts index 2bffbeaf7..56c0d1833 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -10,6 +10,7 @@ import { ThriftExtractor } from './extractors/thrift-extractor.js'; import { TopicExtractor } from './extractors/topic-extractor.js'; import { ManifestExtractor } from './extractors/manifest-extractor.js'; import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js'; +import { extractRustWorkspaceLinks } from './extractors/rust-workspace-extractor.js'; import { detectServiceBoundaries, assignService } from './service-boundary-detector.js'; import type { CypherExecutor } from './contract-extractor.js'; import { writeContractRegistry } from './storage.js'; @@ -85,12 +86,14 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis let autoContracts: StoredContract[] = []; let manifestCrossLinks: CrossLink[] = []; let dbExecutors: Map | 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(); @@ -190,18 +193,39 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } } - // Process manifest links declared in group.yaml. + // Auto-discover workspace dependency contracts (Rust Cargo workspaces, etc.) + // and merge them with explicit manifest links. Discovered links use the same + // ManifestExtractor pipeline as hand-written links in group.yaml. + let allLinks = [...config.links]; + + if (config.detect.workspace_deps) { + const repoPaths = new Map(); + if (!registryEntries) registryEntries = await readRegistry(); + for (const [groupPath, regName] of Object.entries(config.repos)) { + const e = registryEntries.find((en) => en.name === regName); + if (e) repoPaths.set(groupPath, e.path); + } + + const wsResult = await extractRustWorkspaceLinks(config.repos, repoPaths, dbExecutors); + if (wsResult.links.length > 0) { + allLinks = [...allLinks, ...wsResult.links]; + if (opts?.verbose) { + console.log( + ` workspace-deps: discovered ${wsResult.links.length} cross-crate links from ${wsResult.discoveredCrates.size} Rust crates`, + ); + } + } + } + + // Process manifest links declared in group.yaml (plus any auto-discovered). // ManifestExtractor is fully implemented but was never wired into this // pipeline — config.links were parsed and validated but silently dropped. // Placed after the DB try/finally: resolveSymbol falls back to synthetic // UIDs when dbExecutors is undefined or a pool is closed, so cross-links // are always generated regardless of whether real DB executors are available. - if (config.links.length > 0) { - // Warn about dangling links that reference repos not declared in config.repos. - // They still generate cross-links via synthetic UIDs (determinism is preserved), - // but the operator probably meant something that now silently does nothing useful. + if (allLinks.length > 0) { const knownRepos = new Set(Object.keys(config.repos)); - for (const link of config.links) { + for (const link of allLinks) { const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r)); if (dangling.length > 0) { console.warn( @@ -211,12 +235,12 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } const manifestEx = new ManifestExtractor(); - const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors); + const manifestResult = await manifestEx.extractFromManifest(allLinks, dbExecutors); autoContracts.push(...manifestResult.contracts); manifestCrossLinks = manifestResult.crossLinks; if (opts?.verbose) { console.log( - ` manifest: ${manifestCrossLinks.length} cross-links from ${config.links.length} declared links`, + ` manifest: ${manifestCrossLinks.length} cross-links from ${allLinks.length} links (${config.links.length} declared + ${allLinks.length - config.links.length} discovered)`, ); } } diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index 38b487b20..7d0a14251 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -28,6 +28,7 @@ export interface DetectConfig { topics: boolean; shared_libs: boolean; embedding_fallback: boolean; + workspace_deps: boolean; } export interface MatchingConfig { diff --git a/gitnexus/src/core/ingestion/entry-point-scoring.ts b/gitnexus/src/core/ingestion/entry-point-scoring.ts index f8d85ae48..58cf9389c 100644 --- a/gitnexus/src/core/ingestion/entry-point-scoring.ts +++ b/gitnexus/src/core/ingestion/entry-point-scoring.ts @@ -12,18 +12,15 @@ import { detectFrameworkFromPath } from './framework-detection.js'; import { SupportedLanguages } from 'gitnexus-shared'; +import { providers } from './languages/index.js'; // ============================================================================ -// NAME PATTERNS - All 13 supported languages +// NAME PATTERNS // ============================================================================ /** - * Common entry point naming patterns by language. - * These patterns indicate functions that are likely feature entry points. - * - * Universal patterns are separated from per-language patterns so the per-language - * table can use `satisfies Record` for compile-time - * exhaustiveness — the compiler catches any missing language entry. + * Universal entry point naming patterns shared across all languages. + * Per-language patterns live on each LanguageProvider.entryPointPatterns. */ const UNIVERSAL_ENTRY_POINT_PATTERNS: RegExp[] = [ /^(main|init|bootstrap|start|run|setup|configure)$/i, @@ -40,201 +37,11 @@ const UNIVERSAL_ENTRY_POINT_PATTERNS: RegExp[] = [ /^emit[A-Z]/, // emitEvent ]; -export const ENTRY_POINT_PATTERNS = { - // JavaScript/TypeScript - [SupportedLanguages.JavaScript]: [ - /^use[A-Z]/, // React hooks (useEffect, etc.) - ], - [SupportedLanguages.TypeScript]: [ - /^use[A-Z]/, // React hooks - ], - - // Python - [SupportedLanguages.Python]: [ - /^app$/, // Flask/FastAPI app - /^(get|post|put|delete|patch)_/i, // REST conventions - /^api_/, // API functions - /^view_/, // Django views - ], - - // Java - [SupportedLanguages.Java]: [ - /^do[A-Z]/, // doGet, doPost (Servlets) - /^create[A-Z]/, // Factory patterns - /^build[A-Z]/, // Builder patterns - /Service$/, // UserService - ], - - // Kotlin - [SupportedLanguages.Kotlin]: [ - /^on(Create|Start|Resume|Pause|Stop|Destroy)$/, // Android lifecycle - /^do[A-Z]/, // doGet, doPost (shared JVM Servlet pattern) - /^create[A-Z]/, // Factory patterns - /^build[A-Z]/, // Builder patterns - /ViewModel$/, // MVVM pattern (Android) - /^module$/, // Ktor module entry point - /Service$/, // Service classes - ], - - // C# - [SupportedLanguages.CSharp]: [ - /^(Get|Post|Put|Delete|Patch)/, // ASP.NET action methods - /Action$/, // MVC actions - /^On[A-Z]/, // Event handlers / Blazor lifecycle - /Async$/, // Async entry points - /^Configure$/, // Startup.Configure - /^ConfigureServices$/, // Startup.ConfigureServices - /^Handle$/, // MediatR / generic handler - /^Execute$/, // Command pattern - /^Invoke$/, // Middleware Invoke - /^Map[A-Z]/, // Minimal API MapGet, MapPost - /Service$/, // Service classes - /^Seed/, // Database seeding - ], - - // Go - [SupportedLanguages.Go]: [ - /Handler$/, // http.Handler pattern - /^Serve/, // ServeHTTP - /^New[A-Z]/, // Constructor pattern (returns new instance) - /^Make[A-Z]/, // Make functions - ], - - // Rust - [SupportedLanguages.Rust]: [ - /^(get|post|put|delete)_handler$/i, - /^handle_/, // handle_request - /^new$/, // Constructor pattern - /^run$/, // run entry point - /^spawn/, // Async spawn - ], - - // C - explicit main() boost plus common C entry point conventions - [SupportedLanguages.C]: [ - /^main$/, // THE entry point - /^init_/, // init_server, init_client - /_init$/, // module_init, server_init - /^start_/, // start_server - /_start$/, // thread_start - /^run_/, // run_loop - /_run$/, // event_run - /^stop_/, // stop_server - /_stop$/, // service_stop - /^open_/, // open_connection - /_open$/, // file_open - /^close_/, // close_connection - /_close$/, // socket_close - /^create_/, // create_session - /_create$/, // object_create - /^destroy_/, // destroy_session - /_destroy$/, // object_destroy - /^handle_/, // handle_request - /_handler$/, // signal_handler - /_callback$/, // event_callback - /^cmd_/, // tmux: cmd_new_window, cmd_attach_session - /^server_/, // server_start, server_loop - /^client_/, // client_connect - /^session_/, // session_create - /^window_/, // window_resize (tmux) - /^key_/, // key_press - /^input_/, // input_parse - /^output_/, // output_write - /^notify_/, // notify_client - /^control_/, // control_start - ], - - // C++ - same as C plus OOP/template patterns - [SupportedLanguages.CPlusPlus]: [ - /^main$/, // THE entry point - /^init_/, - /_init$/, - /^Create[A-Z]/, // Factory patterns - /^create_/, - /^Run$/, // Run methods - /^run$/, - /^Start$/, // Start methods - /^start$/, - /^handle_/, - /_handler$/, - /_callback$/, - /^OnEvent/, // Event callbacks - /^on_/, - /::Run$/, // Class::Run - /::Start$/, // Class::Start - /::Init$/, // Class::Init - /::Execute$/, // Class::Execute - ], - - // Swift / iOS - [SupportedLanguages.Swift]: [ - /^viewDidLoad$/, // UIKit lifecycle - /^viewWillAppear$/, // UIKit lifecycle - /^viewDidAppear$/, // UIKit lifecycle - /^viewWillDisappear$/, // UIKit lifecycle - /^viewDidDisappear$/, // UIKit lifecycle - /^application\(/, // AppDelegate methods - /^scene\(/, // SceneDelegate methods - /^body$/, // SwiftUI View.body - /Coordinator$/, // Coordinator pattern - /^sceneDidBecomeActive$/, // SceneDelegate lifecycle - /^sceneWillResignActive$/, // SceneDelegate lifecycle - /^didFinishLaunchingWithOptions$/, // AppDelegate - /ViewController$/, // ViewController classes - /^configure[A-Z]/, // Configuration methods - /^setup[A-Z]/, // Setup methods - /^makeBody$/, // SwiftUI ViewModifier - ], - - // PHP / Laravel - [SupportedLanguages.PHP]: [ - /Controller$/, // UserController (class name convention) - /^handle$/, // Job::handle(), Listener::handle() - /^execute$/, // Command::execute() - /^boot$/, // ServiceProvider::boot() - /^register$/, // ServiceProvider::register() - /^__invoke$/, // Invokable controllers/actions - /^(index|show|store|update|destroy|create|edit)$/, // RESTful resource methods - /^(get|post|put|delete|patch)[A-Z]/, // Explicit HTTP method actions - /^run$/, // Command/Job run() - /^fire$/, // Event fire() - /^dispatch$/, // Dispatchable jobs - /Service$/, // UserService (Service layer) - /Repository$/, // UserRepository (Repository pattern) - /^find$/, // Repository::find() - /^findAll$/, // Repository::findAll() - /^save$/, // Repository::save() - /^delete$/, // Repository::delete() - ], - - // Ruby - [SupportedLanguages.Ruby]: [ - /^call$/, // Service objects (MyService.call) - /^perform$/, // Background jobs (Sidekiq, ActiveJob) - /^execute$/, // Command pattern - ], - - // Dart / Flutter - [SupportedLanguages.Dart]: [ - /^main$/, // App entry - /^build$/, // Widget.build — fundamental Flutter render entry point - /^createState$/, // StatefulWidget.createState - /^initState$/, // State lifecycle initialization - /^dispose$/, // State lifecycle teardown - /^didChangeDependencies$/, // State lifecycle — InheritedWidget changes - /^didUpdateWidget$/, // State lifecycle — widget rebuild with new config - /^runApp$/, // App entry point - /^onEvent$/, // BLoC event handler - /^mapEventToState$/, // Legacy BLoC pattern - ], - [SupportedLanguages.Vue]: [], // Vue uses TypeScript queries — entry points handled via TS patterns - [SupportedLanguages.Cobol]: [], // Standalone regex processor — no tree-sitter entry points -} satisfies Record; - -/** Pre-computed merged patterns (universal + language-specific) to avoid per-call array allocation. */ +/** Pre-computed merged patterns (universal + language-specific) from providers. */ const MERGED_ENTRY_POINT_PATTERNS = Object.fromEntries( - Object.values(SupportedLanguages).map((lang) => [ + Object.entries(providers).map(([lang, provider]) => [ lang, - [...UNIVERSAL_ENTRY_POINT_PATTERNS, ...(ENTRY_POINT_PATTERNS[lang] ?? [])], + [...UNIVERSAL_ENTRY_POINT_PATTERNS, ...(provider.entryPointPatterns ?? [])], ]), ) as Record; diff --git a/gitnexus/src/core/ingestion/framework-detection.ts b/gitnexus/src/core/ingestion/framework-detection.ts index 739f22967..fca116c22 100644 --- a/gitnexus/src/core/ingestion/framework-detection.ts +++ b/gitnexus/src/core/ingestion/framework-detection.ts @@ -11,6 +11,8 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import type { AstFrameworkPatternConfig } from './language-provider.js'; +import { providers } from './languages/index.js'; // ============================================================================ // TYPES @@ -518,395 +520,14 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null // AST-BASED FRAMEWORK DETECTION // ============================================================================ -/** - * Patterns that indicate framework entry points within code definitions. - * These are matched against AST node text (class/method/function declaration text). - */ -export const FRAMEWORK_AST_PATTERNS = { - // JavaScript/TypeScript decorators - nestjs: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'], - 'expo-router': [ - 'router.push', - 'router.replace', - 'router.navigate', - 'useRouter', - 'useLocalSearchParams', - 'useSegments', - 'expo-router', - ], - express: ['app.get', 'app.post', 'app.put', 'app.delete', 'router.get', 'router.post'], - - // Python decorators - fastapi: ['@app.get', '@app.post', '@app.put', '@app.delete', '@router.get'], - flask: ['@app.route', '@blueprint.route'], - - // Java annotations - spring: ['@RestController', '@Controller', '@GetMapping', '@PostMapping', '@RequestMapping'], - jaxrs: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'], - - // C# attributes - aspnet: [ - '[ApiController]', - '[HttpGet]', - '[HttpPost]', - '[HttpPut]', - '[HttpDelete]', - '[Route]', - '[Authorize]', - '[AllowAnonymous]', - ], - signalr: ['[HubMethodName]', ': Hub', ': Hub<'], - blazor: ['@page', '[Parameter]', '@inject'], - efcore: ['DbContext', 'DbSet<', 'OnModelCreating'], - - // Go patterns (function signatures include framework types) - 'go-http': [ - 'http.Handler', - 'http.HandlerFunc', - 'ServeHTTP', - 'http.ResponseWriter', - 'http.Request', - ], - gin: ['gin.Context', 'gin.Default', 'gin.New'], - echo: ['echo.Context', 'echo.New'], - fiber: ['fiber.Ctx', 'fiber.New', 'fiber.App'], - 'go-grpc': ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'], - - // ORM patterns - prisma: ['prisma.', 'PrismaClient', '@prisma/client'], - supabase: ['supabase.from', 'createClient', '@supabase/supabase-js'], - - // PHP/Laravel - laravel: [ - 'Route::get', - 'Route::post', - 'Route::put', - 'Route::delete', - 'Route::resource', - 'Route::apiResource', - '#[Route(', - ], - - // Rust macros (proc-macro attributes in definition text) - actix: ['#[get', '#[post', '#[put', '#[delete', '#[actix_web', 'HttpRequest', 'HttpResponse'], - axum: ['Router::new', 'axum::extract', 'axum::routing'], - rocket: ['#[get', '#[post', '#[launch', 'rocket::'], - tokio: ['#[tokio::main]', '#[tokio::test]'], - - // C++ patterns (Qt, Boost) - qt: [ - 'Q_OBJECT', - 'Q_INVOKABLE', - 'Q_PROPERTY', - 'Q_SIGNALS', - 'Q_SLOTS', - 'Q_SIGNAL', - 'Q_SLOT', - 'QWidget', - 'QApplication', - ], - - // Swift/iOS - uikit: [ - 'viewDidLoad', - 'viewWillAppear', - 'viewDidAppear', - 'UIViewController', - '@IBOutlet', - '@IBAction', - '@objc', - ], - swiftui: [ - '@main', - 'WindowGroup', - 'ContentView', - '@StateObject', - '@ObservedObject', - '@EnvironmentObject', - '@Published', - ], - vapor: ['app.get', 'app.post', 'req.content.decode', 'Vapor'], - - // Ruby patterns (class-level macros in definition text) - rails: [ - 'ApplicationController', - 'ApplicationRecord', - 'ActiveRecord::Base', - 'before_action', - 'after_action', - 'has_many', - 'belongs_to', - 'has_one', - 'validates', - ], - sinatra: ['Sinatra::Base', 'Sinatra::Application'], - - // Dart/Flutter - flutter: [ - 'StatelessWidget', - 'StatefulWidget', - 'BuildContext', - 'Widget build', - 'ChangeNotifier', - 'GetxController', - 'Cubit<', - 'Bloc<', - 'ConsumerWidget', - ], - riverpod: ['@riverpod', 'ref.watch', 'ref.read', 'AsyncNotifier', 'Notifier'], -}; - -interface AstFrameworkPatternConfig { - framework: string; - entryPointMultiplier: number; - reason: string; - patterns: string[]; -} - -export const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = { - [SupportedLanguages.JavaScript]: [ - { - framework: 'nestjs', - entryPointMultiplier: 3.2, - reason: 'nestjs-decorator', - patterns: FRAMEWORK_AST_PATTERNS.nestjs, - }, - { - framework: 'expo-router', - entryPointMultiplier: 2.5, - reason: 'expo-router-navigation', - patterns: FRAMEWORK_AST_PATTERNS['expo-router'], - }, - ], - [SupportedLanguages.TypeScript]: [ - { - framework: 'nestjs', - entryPointMultiplier: 3.2, - reason: 'nestjs-decorator', - patterns: FRAMEWORK_AST_PATTERNS.nestjs, - }, - { - framework: 'expo-router', - entryPointMultiplier: 2.5, - reason: 'expo-router-navigation', - patterns: FRAMEWORK_AST_PATTERNS['expo-router'], - }, - ], - [SupportedLanguages.Python]: [ - { - framework: 'fastapi', - entryPointMultiplier: 3.0, - reason: 'fastapi-decorator', - patterns: FRAMEWORK_AST_PATTERNS.fastapi, - }, - { - framework: 'flask', - entryPointMultiplier: 2.8, - reason: 'flask-decorator', - patterns: FRAMEWORK_AST_PATTERNS.flask, - }, - ], - [SupportedLanguages.Java]: [ - { - framework: 'spring', - entryPointMultiplier: 3.2, - reason: 'spring-annotation', - patterns: FRAMEWORK_AST_PATTERNS.spring, - }, - { - framework: 'jaxrs', - entryPointMultiplier: 3.0, - reason: 'jaxrs-annotation', - patterns: FRAMEWORK_AST_PATTERNS.jaxrs, - }, - ], - [SupportedLanguages.Kotlin]: [ - { - framework: 'spring-kotlin', - entryPointMultiplier: 3.2, - reason: 'spring-kotlin-annotation', - patterns: FRAMEWORK_AST_PATTERNS.spring, - }, - { - framework: 'jaxrs', - entryPointMultiplier: 3.0, - reason: 'jaxrs-annotation', - patterns: FRAMEWORK_AST_PATTERNS.jaxrs, - }, - { - framework: 'ktor', - entryPointMultiplier: 2.8, - reason: 'ktor-routing', - patterns: ['routing', 'embeddedServer', 'Application.module'], - }, - { - framework: 'android-kotlin', - entryPointMultiplier: 2.5, - reason: 'android-annotation', - patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('], - }, - ], - [SupportedLanguages.CSharp]: [ - { - framework: 'aspnet', - entryPointMultiplier: 3.2, - reason: 'aspnet-attribute', - patterns: FRAMEWORK_AST_PATTERNS.aspnet, - }, - { - framework: 'signalr', - entryPointMultiplier: 2.8, - reason: 'signalr-attribute', - patterns: FRAMEWORK_AST_PATTERNS.signalr, - }, - { - framework: 'blazor', - entryPointMultiplier: 2.5, - reason: 'blazor-attribute', - patterns: FRAMEWORK_AST_PATTERNS.blazor, - }, - { - framework: 'efcore', - entryPointMultiplier: 2.0, - reason: 'efcore-pattern', - patterns: FRAMEWORK_AST_PATTERNS.efcore, - }, - ], - [SupportedLanguages.PHP]: [ - { - framework: 'laravel', - entryPointMultiplier: 3.0, - reason: 'php-route-attribute', - patterns: FRAMEWORK_AST_PATTERNS.laravel, - }, - ], - [SupportedLanguages.Go]: [ - { - framework: 'go-http', - entryPointMultiplier: 2.5, - reason: 'go-http-handler', - patterns: FRAMEWORK_AST_PATTERNS['go-http'], - }, - { - framework: 'gin', - entryPointMultiplier: 3.0, - reason: 'gin-handler', - patterns: FRAMEWORK_AST_PATTERNS.gin, - }, - { - framework: 'echo', - entryPointMultiplier: 3.0, - reason: 'echo-handler', - patterns: FRAMEWORK_AST_PATTERNS.echo, - }, - { - framework: 'fiber', - entryPointMultiplier: 3.0, - reason: 'fiber-handler', - patterns: FRAMEWORK_AST_PATTERNS.fiber, - }, - { - framework: 'go-grpc', - entryPointMultiplier: 2.8, - reason: 'grpc-service', - patterns: FRAMEWORK_AST_PATTERNS['go-grpc'], - }, - ], - [SupportedLanguages.Rust]: [ - { - framework: 'actix-web', - entryPointMultiplier: 3.0, - reason: 'actix-attribute', - patterns: FRAMEWORK_AST_PATTERNS.actix, - }, - { - framework: 'axum', - entryPointMultiplier: 3.0, - reason: 'axum-routing', - patterns: FRAMEWORK_AST_PATTERNS.axum, - }, - { - framework: 'rocket', - entryPointMultiplier: 3.0, - reason: 'rocket-attribute', - patterns: FRAMEWORK_AST_PATTERNS.rocket, - }, - { - framework: 'tokio', - entryPointMultiplier: 2.5, - reason: 'tokio-runtime', - patterns: FRAMEWORK_AST_PATTERNS.tokio, - }, - ], - [SupportedLanguages.C]: [], // C has no framework-specific AST patterns (POSIX/socket patterns are in entry-point-scoring) - [SupportedLanguages.CPlusPlus]: [ - { - framework: 'qt', - entryPointMultiplier: 2.8, - reason: 'qt-macro', - patterns: FRAMEWORK_AST_PATTERNS.qt, - }, - ], - [SupportedLanguages.Swift]: [ - { - framework: 'uikit', - entryPointMultiplier: 2.5, - reason: 'uikit-lifecycle', - patterns: FRAMEWORK_AST_PATTERNS.uikit, - }, - { - framework: 'swiftui', - entryPointMultiplier: 2.8, - reason: 'swiftui-pattern', - patterns: FRAMEWORK_AST_PATTERNS.swiftui, - }, - { - framework: 'vapor', - entryPointMultiplier: 3.0, - reason: 'vapor-routing', - patterns: FRAMEWORK_AST_PATTERNS.vapor, - }, - ], - [SupportedLanguages.Ruby]: [ - { - framework: 'rails', - entryPointMultiplier: 3.0, - reason: 'rails-pattern', - patterns: FRAMEWORK_AST_PATTERNS.rails, - }, - { - framework: 'sinatra', - entryPointMultiplier: 2.8, - reason: 'sinatra-pattern', - patterns: FRAMEWORK_AST_PATTERNS.sinatra, - }, - ], - [SupportedLanguages.Dart]: [ - { - framework: 'flutter', - entryPointMultiplier: 2.5, - reason: 'flutter-widget', - patterns: FRAMEWORK_AST_PATTERNS.flutter, - }, - { - framework: 'riverpod', - entryPointMultiplier: 2.8, - reason: 'riverpod-pattern', - patterns: FRAMEWORK_AST_PATTERNS.riverpod, - }, - ], - [SupportedLanguages.Vue]: [], // Vue uses TypeScript AST framework detection - [SupportedLanguages.Cobol]: [], // Standalone regex processor — no AST framework patterns -} satisfies Record; - -/** Pre-lowercased patterns for O(1) pattern matching at runtime */ -const AST_PATTERNS_LOWERED: Record< - string, - Array<{ framework: string; entryPointMultiplier: number; reason: string; patterns: string[] }> -> = Object.fromEntries( - Object.entries(AST_FRAMEWORK_PATTERNS_BY_LANGUAGE).map(([lang, cfgs]) => [ +/** Pre-lowercased patterns for O(1) pattern matching at runtime — built from providers. */ +const AST_PATTERNS_LOWERED: Record = Object.fromEntries( + Object.entries(providers).map(([lang, provider]) => [ lang, - cfgs.map((cfg) => ({ ...cfg, patterns: cfg.patterns.map((p) => p.toLowerCase()) })), + (provider.astFrameworkPatterns ?? []).map((cfg) => ({ + ...cfg, + patterns: cfg.patterns.map((p) => p.toLowerCase()), + })), ]), ); diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 92566d30e..12ce0839b 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -78,6 +78,14 @@ export type ImportSemantics = | 'namespace' | 'explicit-reexport'; +/** Configuration for AST-based framework detection patterns. */ +export interface AstFrameworkPatternConfig { + framework: string; + entryPointMultiplier: number; + reason: string; + patterns: string[]; +} + /** * Everything a language needs to provide. * Required fields must be explicitly set; optional fields have defaults @@ -89,6 +97,16 @@ interface LanguageProviderConfig { /** File extensions that map to this language (e.g., ['.ts', '.tsx']) */ readonly extensions: readonly string[]; + /** Entry-point function name patterns specific to this language. + * Merged with universal patterns at runtime for process detection scoring. + * Default: [] (only universal patterns apply). */ + readonly entryPointPatterns?: readonly RegExp[]; + + /** AST-based framework detection patterns for this language. + * Used by detectFrameworkFromAST to identify framework entry points. + * Default: [] (no AST framework detection for this language). */ + readonly astFrameworkPatterns?: readonly AstFrameworkPatternConfig[]; + // ── Parser ──────────────────────────────────────────────────────── /** Parse strategy: 'tree-sitter' (default) uses AST parsing via tree-sitter. * 'standalone' means the language has its own regex-based processor and diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 37e6da732..a5b3e5729 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -12,6 +12,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; import { cClassConfig, cppClassConfig } from '../class-extractors/configs/c-cpp.js'; import { defineLanguage } from '../language-provider.js'; +import type { AstFrameworkPatternConfig } from '../language-provider.js'; import { typeConfig as cCppConfig } from '../type-extractors/c-cpp.js'; import { cCppExportChecker } from '../export-detection.js'; import { createImportResolver } from '../import-resolvers/resolver-factory.js'; @@ -317,6 +318,38 @@ const cppLabelOverride: NonNullable = ( export const cProvider = defineLanguage({ id: SupportedLanguages.C, extensions: ['.c'], + entryPointPatterns: [ + /^main$/, + /^init_/, + /_init$/, + /^start_/, + /_start$/, + /^run_/, + /_run$/, + /^stop_/, + /_stop$/, + /^open_/, + /_open$/, + /^close_/, + /_close$/, + /^create_/, + /_create$/, + /^destroy_/, + /_destroy$/, + /^handle_/, + /_handler$/, + /_callback$/, + /^cmd_/, + /^server_/, + /^client_/, + /^session_/, + /^window_/, + /^key_/, + /^input_/, + /^output_/, + /^notify_/, + /^control_/, + ], treeSitterQueries: C_QUERIES, typeConfig: cCppConfig, exportChecker: cCppExportChecker, @@ -338,6 +371,44 @@ export const cProvider = defineLanguage({ export const cppProvider = defineLanguage({ id: SupportedLanguages.CPlusPlus, extensions: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'], + entryPointPatterns: [ + /^main$/, + /^init_/, + /_init$/, + /^Create[A-Z]/, + /^create_/, + /^Run$/, + /^run$/, + /^Start$/, + /^start$/, + /^handle_/, + /_handler$/, + /_callback$/, + /^OnEvent/, + /^on_/, + /::Run$/, + /::Start$/, + /::Init$/, + /::Execute$/, + ], + astFrameworkPatterns: [ + { + framework: 'qt', + entryPointMultiplier: 2.8, + reason: 'qt-macro', + patterns: [ + 'Q_OBJECT', + 'Q_INVOKABLE', + 'Q_PROPERTY', + 'Q_SIGNALS', + 'Q_SLOTS', + 'Q_SIGNAL', + 'Q_SLOT', + 'QWidget', + 'QApplication', + ], + }, + ] satisfies AstFrameworkPatternConfig[], treeSitterQueries: CPP_QUERIES, typeConfig: cCppConfig, exportChecker: cCppExportChecker, diff --git a/gitnexus/src/core/ingestion/languages/cobol.ts b/gitnexus/src/core/ingestion/languages/cobol.ts index 78ee51ecc..6e54c2309 100644 --- a/gitnexus/src/core/ingestion/languages/cobol.ts +++ b/gitnexus/src/core/ingestion/languages/cobol.ts @@ -16,6 +16,8 @@ export const cobolProvider = defineLanguage({ id: SupportedLanguages.Cobol, parseStrategy: 'standalone', extensions: [], // COBOL files detected by cobol-processor's isCobolFile/isJclFile + entryPointPatterns: [], + astFrameworkPatterns: [], treeSitterQueries: '', typeConfig: { declarationNodeTypes: new Set(), diff --git a/gitnexus/src/core/ingestion/languages/csharp.ts b/gitnexus/src/core/ingestion/languages/csharp.ts index 837a7517b..32bf08779 100644 --- a/gitnexus/src/core/ingestion/languages/csharp.ts +++ b/gitnexus/src/core/ingestion/languages/csharp.ts @@ -16,6 +16,7 @@ import { createImportResolver } from '../import-resolvers/resolver-factory.js'; import { csharpImportConfig } from '../import-resolvers/configs/csharp.js'; import { extractCSharpNamedBindings } from '../named-bindings/csharp.js'; import { CSHARP_QUERIES } from '../tree-sitter-queries.js'; +import type { AstFrameworkPatternConfig } from '../language-provider.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { csharpCallConfig } from '../call-extractors/configs/csharp.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; @@ -135,6 +136,55 @@ const BUILT_INS: ReadonlySet = new Set([ export const csharpProvider = defineLanguage({ id: SupportedLanguages.CSharp, extensions: ['.cs'], + entryPointPatterns: [ + /^(Get|Post|Put|Delete|Patch)/, + /Action$/, + /^On[A-Z]/, + /Async$/, + /^Configure$/, + /^ConfigureServices$/, + /^Handle$/, + /^Execute$/, + /^Invoke$/, + /^Map[A-Z]/, + /Service$/, + /^Seed/, + ], + astFrameworkPatterns: [ + { + framework: 'aspnet', + entryPointMultiplier: 3.2, + reason: 'aspnet-attribute', + patterns: [ + '[ApiController]', + '[HttpGet]', + '[HttpPost]', + '[HttpPut]', + '[HttpDelete]', + '[Route]', + '[Authorize]', + '[AllowAnonymous]', + ], + }, + { + framework: 'signalr', + entryPointMultiplier: 2.8, + reason: 'signalr-attribute', + patterns: ['[HubMethodName]', ': Hub', ': Hub<'], + }, + { + framework: 'blazor', + entryPointMultiplier: 2.5, + reason: 'blazor-attribute', + patterns: ['@page', '[Parameter]', '@inject'], + }, + { + framework: 'efcore', + entryPointMultiplier: 2.0, + reason: 'efcore-pattern', + patterns: ['DbContext', 'DbSet<', 'OnModelCreating'], + }, + ] satisfies AstFrameworkPatternConfig[], treeSitterQueries: CSHARP_QUERIES, typeConfig: csharpConfig, exportChecker: csharpExportChecker, diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index 130d15deb..01d9bd31f 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -17,6 +17,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; import { dartClassConfig } from '../class-extractors/configs/dart.js'; import { defineLanguage } from '../language-provider.js'; +import type { AstFrameworkPatternConfig } from '../language-provider.js'; import { typeConfig as dartConfig } from '../type-extractors/dart.js'; import { dartExportChecker } from '../export-detection.js'; import { createImportResolver } from '../import-resolvers/resolver-factory.js'; @@ -93,6 +94,42 @@ const BUILT_INS: ReadonlySet = new Set([ export const dartProvider = defineLanguage({ id: SupportedLanguages.Dart, extensions: ['.dart'], + entryPointPatterns: [ + /^main$/, + /^build$/, + /^createState$/, + /^initState$/, + /^dispose$/, + /^didChangeDependencies$/, + /^didUpdateWidget$/, + /^runApp$/, + /^onEvent$/, + /^mapEventToState$/, + ], + astFrameworkPatterns: [ + { + framework: 'flutter', + entryPointMultiplier: 2.5, + reason: 'flutter-widget', + patterns: [ + 'StatelessWidget', + 'StatefulWidget', + 'BuildContext', + 'Widget build', + 'ChangeNotifier', + 'GetxController', + 'Cubit<', + 'Bloc<', + 'ConsumerWidget', + ], + }, + { + framework: 'riverpod', + entryPointMultiplier: 2.8, + reason: 'riverpod-pattern', + patterns: ['@riverpod', 'ref.watch', 'ref.read', 'AsyncNotifier', 'Notifier'], + }, + ] satisfies AstFrameworkPatternConfig[], treeSitterQueries: DART_QUERIES, typeConfig: dartConfig, exportChecker: dartExportChecker, diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 245dc197b..64b9e359a 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -18,6 +18,7 @@ import { goExportChecker } from '../export-detection.js'; import { createImportResolver } from '../import-resolvers/resolver-factory.js'; import { goImportConfig } from '../import-resolvers/configs/go.js'; import { GO_QUERIES } from '../tree-sitter-queries.js'; +import type { AstFrameworkPatternConfig } from '../language-provider.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { goConfig as goFieldConfig } from '../field-extractors/configs/go.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; @@ -32,6 +33,45 @@ import { goHeritageConfig } from '../heritage-extractors/configs/go.js'; export const goProvider = defineLanguage({ id: SupportedLanguages.Go, extensions: ['.go'], + entryPointPatterns: [/Handler$/, /^Serve/, /^New[A-Z]/, /^Make[A-Z]/], + astFrameworkPatterns: [ + { + framework: 'go-http', + entryPointMultiplier: 2.5, + reason: 'go-http-handler', + patterns: [ + 'http.Handler', + 'http.HandlerFunc', + 'ServeHTTP', + 'http.ResponseWriter', + 'http.Request', + ], + }, + { + framework: 'gin', + entryPointMultiplier: 3.0, + reason: 'gin-handler', + patterns: ['gin.Context', 'gin.Default', 'gin.New'], + }, + { + framework: 'echo', + entryPointMultiplier: 3.0, + reason: 'echo-handler', + patterns: ['echo.Context', 'echo.New'], + }, + { + framework: 'fiber', + entryPointMultiplier: 3.0, + reason: 'fiber-handler', + patterns: ['fiber.Ctx', 'fiber.New', 'fiber.App'], + }, + { + framework: 'go-grpc', + entryPointMultiplier: 2.8, + reason: 'grpc-service', + patterns: ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'], + }, + ] satisfies AstFrameworkPatternConfig[], treeSitterQueries: GO_QUERIES, typeConfig: goConfig, exportChecker: goExportChecker, diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index 4781219d3..96139ccda 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -11,6 +11,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; import { javaClassConfig } from '../class-extractors/configs/jvm.js'; import { defineLanguage } from '../language-provider.js'; +import type { AstFrameworkPatternConfig } from '../language-provider.js'; import { javaTypeConfig } from '../type-extractors/jvm.js'; import { javaExportChecker } from '../export-detection.js'; import { createImportResolver } from '../import-resolvers/resolver-factory.js'; @@ -30,6 +31,27 @@ import { createHeritageExtractor } from '../heritage-extractors/generic.js'; export const javaProvider = defineLanguage({ id: SupportedLanguages.Java, extensions: ['.java'], + entryPointPatterns: [/^do[A-Z]/, /^create[A-Z]/, /^build[A-Z]/, /Service$/], + astFrameworkPatterns: [ + { + framework: 'spring', + entryPointMultiplier: 3.2, + reason: 'spring-annotation', + patterns: [ + '@RestController', + '@Controller', + '@GetMapping', + '@PostMapping', + '@RequestMapping', + ], + }, + { + framework: 'jaxrs', + entryPointMultiplier: 3.0, + reason: 'jaxrs-annotation', + patterns: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'], + }, + ] satisfies AstFrameworkPatternConfig[], treeSitterQueries: JAVA_QUERIES, typeConfig: javaTypeConfig, exportChecker: javaExportChecker, diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index dd65e5d42..78d8726e6 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -18,6 +18,7 @@ import { kotlinImportConfig } from '../import-resolvers/configs/jvm.js'; import { extractKotlinNamedBindings } from '../named-bindings/kotlin.js'; import { appendKotlinWildcard } from '../import-resolvers/jvm.js'; import { KOTLIN_QUERIES } from '../tree-sitter-queries.js'; +import type { AstFrameworkPatternConfig } from '../language-provider.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { kotlinCallConfig } from '../call-extractors/configs/jvm.js'; @@ -105,6 +106,47 @@ const BUILT_INS: ReadonlySet = new Set([ export const kotlinProvider = defineLanguage({ id: SupportedLanguages.Kotlin, extensions: ['.kt', '.kts'], + entryPointPatterns: [ + /^on(Create|Start|Resume|Pause|Stop|Destroy)$/, + /^do[A-Z]/, + /^create[A-Z]/, + /^build[A-Z]/, + /ViewModel$/, + /^module$/, + /Service$/, + ], + astFrameworkPatterns: [ + { + framework: 'spring-kotlin', + entryPointMultiplier: 3.2, + reason: 'spring-kotlin-annotation', + patterns: [ + '@RestController', + '@Controller', + '@GetMapping', + '@PostMapping', + '@RequestMapping', + ], + }, + { + framework: 'jaxrs', + entryPointMultiplier: 3.0, + reason: 'jaxrs-annotation', + patterns: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'], + }, + { + framework: 'ktor', + entryPointMultiplier: 2.8, + reason: 'ktor-routing', + patterns: ['routing', 'embeddedServer', 'Application.module'], + }, + { + framework: 'android-kotlin', + entryPointMultiplier: 2.5, + reason: 'android-annotation', + patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('], + }, + ] satisfies AstFrameworkPatternConfig[], treeSitterQueries: KOTLIN_QUERIES, typeConfig: kotlinTypeConfig, exportChecker: kotlinExportChecker, diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index 736a63468..eb8f296f8 100644 --- a/gitnexus/src/core/ingestion/languages/php.ts +++ b/gitnexus/src/core/ingestion/languages/php.ts @@ -10,6 +10,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; import { phpClassConfig } from '../class-extractors/configs/php.js'; import { defineLanguage } from '../language-provider.js'; +import type { AstFrameworkPatternConfig } from '../language-provider.js'; import { typeConfig as phpConfig } from '../type-extractors/php.js'; import { phpExportChecker } from '../export-detection.js'; import { createImportResolver } from '../import-resolvers/resolver-factory.js'; @@ -239,6 +240,41 @@ function isPhpRouteFile(filePath: string): boolean { export const phpProvider = defineLanguage({ id: SupportedLanguages.PHP, extensions: ['.php', '.phtml', '.php3', '.php4', '.php5', '.php8'], + entryPointPatterns: [ + /Controller$/, + /^handle$/, + /^execute$/, + /^boot$/, + /^register$/, + /^__invoke$/, + /^(index|show|store|update|destroy|create|edit)$/, + /^(get|post|put|delete|patch)[A-Z]/, + /^run$/, + /^fire$/, + /^dispatch$/, + /Service$/, + /Repository$/, + /^find$/, + /^findAll$/, + /^save$/, + /^delete$/, + ], + astFrameworkPatterns: [ + { + framework: 'laravel', + entryPointMultiplier: 3.0, + reason: 'php-route-attribute', + patterns: [ + 'Route::get', + 'Route::post', + 'Route::put', + 'Route::delete', + 'Route::resource', + 'Route::apiResource', + '#[Route(', + ], + }, + ] satisfies AstFrameworkPatternConfig[], treeSitterQueries: PHP_QUERIES, typeConfig: phpConfig, exportChecker: phpExportChecker, diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index 2b37aab1b..a0e834030 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -15,6 +15,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; import { pythonClassConfig } from '../class-extractors/configs/python.js'; import { defineLanguage } from '../language-provider.js'; +import type { AstFrameworkPatternConfig } from '../language-provider.js'; import { typeConfig as pythonConfig } from '../type-extractors/python.js'; import { pythonExportChecker } from '../export-detection.js'; import { createImportResolver } from '../import-resolvers/resolver-factory.js'; @@ -106,6 +107,21 @@ function normalizePythonStringLiteral(text: string): string | undefined { export const pythonProvider = defineLanguage({ id: SupportedLanguages.Python, extensions: ['.py'], + entryPointPatterns: [/^app$/, /^(get|post|put|delete|patch)_/i, /^api_/, /^view_/], + astFrameworkPatterns: [ + { + framework: 'fastapi', + entryPointMultiplier: 3.0, + reason: 'fastapi-decorator', + patterns: ['@app.get', '@app.post', '@app.put', '@app.delete', '@router.get'], + }, + { + framework: 'flask', + entryPointMultiplier: 2.8, + reason: 'flask-decorator', + patterns: ['@app.route', '@blueprint.route'], + }, + ] satisfies AstFrameworkPatternConfig[], treeSitterQueries: PYTHON_QUERIES, typeConfig: pythonConfig, exportChecker: pythonExportChecker, 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/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index e538f3fd2..e5f71ee56 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -12,6 +12,7 @@ import type { NodeLabel } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; import { rubyClassConfig } from '../class-extractors/configs/ruby.js'; import { defineLanguage } from '../language-provider.js'; +import type { AstFrameworkPatternConfig } from '../language-provider.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; import { typeConfig as rubyConfig } from '../type-extractors/ruby.js'; import { routeRubyCall } from '../call-routing.js'; @@ -151,6 +152,31 @@ const rubyResolveEnclosingOwner = (node: SyntaxNode): SyntaxNode | null => { export const rubyProvider = defineLanguage({ id: SupportedLanguages.Ruby, extensions: ['.rb', '.rake', '.gemspec'], + entryPointPatterns: [/^call$/, /^perform$/, /^execute$/], + astFrameworkPatterns: [ + { + framework: 'rails', + entryPointMultiplier: 3.0, + reason: 'rails-pattern', + patterns: [ + 'ApplicationController', + 'ApplicationRecord', + 'ActiveRecord::Base', + 'before_action', + 'after_action', + 'has_many', + 'belongs_to', + 'has_one', + 'validates', + ], + }, + { + framework: 'sinatra', + entryPointMultiplier: 2.8, + reason: 'sinatra-pattern', + patterns: ['Sinatra::Base', 'Sinatra::Application'], + }, + ] satisfies AstFrameworkPatternConfig[], treeSitterQueries: RUBY_QUERIES, typeConfig: rubyConfig, exportChecker: rubyExportChecker, diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index ac0cd5788..e7638e4ea 100644 --- a/gitnexus/src/core/ingestion/languages/rust.ts +++ b/gitnexus/src/core/ingestion/languages/rust.ts @@ -22,6 +22,7 @@ import { createImportResolver } from '../import-resolvers/resolver-factory.js'; import { rustImportConfig } from '../import-resolvers/configs/rust.js'; import { extractRustNamedBindings } from '../named-bindings/rust.js'; import { RUST_QUERIES } from '../tree-sitter-queries.js'; +import type { AstFrameworkPatternConfig } from '../language-provider.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { rustConfig as rustFieldConfig } from '../field-extractors/configs/rust.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; @@ -121,6 +122,41 @@ const BUILT_INS: ReadonlySet = new Set([ export const rustProvider = defineLanguage({ id: SupportedLanguages.Rust, extensions: ['.rs'], + entryPointPatterns: [/^(get|post|put|delete)_handler$/i, /^handle_/, /^new$/, /^run$/, /^spawn/], + astFrameworkPatterns: [ + { + framework: 'actix-web', + entryPointMultiplier: 3.0, + reason: 'actix-attribute', + patterns: [ + '#[get', + '#[post', + '#[put', + '#[delete', + '#[actix_web', + 'HttpRequest', + 'HttpResponse', + ], + }, + { + framework: 'axum', + entryPointMultiplier: 3.0, + reason: 'axum-routing', + patterns: ['Router::new', 'axum::extract', 'axum::routing'], + }, + { + framework: 'rocket', + entryPointMultiplier: 3.0, + reason: 'rocket-attribute', + patterns: ['#[get', '#[post', '#[launch', 'rocket::'], + }, + { + framework: 'tokio', + entryPointMultiplier: 2.5, + reason: 'tokio-runtime', + patterns: ['#[tokio::main]', '#[tokio::test]'], + }, + ] satisfies AstFrameworkPatternConfig[], treeSitterQueries: RUST_QUERIES, typeConfig: rustConfig, exportChecker: rustExportChecker, diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index 7b74386a8..128809cba 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -15,6 +15,7 @@ import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; import { swiftClassConfig } from '../class-extractors/configs/swift.js'; import { defineLanguage } from '../language-provider.js'; +import type { AstFrameworkPatternConfig } from '../language-provider.js'; import { typeConfig as swiftConfig } from '../type-extractors/swift.js'; import { swiftExportChecker } from '../export-detection.js'; import { createImportResolver } from '../import-resolvers/resolver-factory.js'; @@ -259,6 +260,60 @@ const BUILT_INS: ReadonlySet = new Set([ export const swiftProvider = defineLanguage({ id: SupportedLanguages.Swift, extensions: ['.swift'], + entryPointPatterns: [ + /^viewDidLoad$/, + /^viewWillAppear$/, + /^viewDidAppear$/, + /^viewWillDisappear$/, + /^viewDidDisappear$/, + /^application\(/, + /^scene\(/, + /^body$/, + /Coordinator$/, + /^sceneDidBecomeActive$/, + /^sceneWillResignActive$/, + /^didFinishLaunchingWithOptions$/, + /ViewController$/, + /^configure[A-Z]/, + /^setup[A-Z]/, + /^makeBody$/, + ], + astFrameworkPatterns: [ + { + framework: 'uikit', + entryPointMultiplier: 2.5, + reason: 'uikit-lifecycle', + patterns: [ + 'viewDidLoad', + 'viewWillAppear', + 'viewDidAppear', + 'UIViewController', + '@IBOutlet', + '@IBAction', + '@objc', + ], + }, + { + framework: 'swiftui', + entryPointMultiplier: 2.8, + reason: 'swiftui-pattern', + patterns: [ + '@main', + 'WindowGroup', + 'ContentView', + '@StateObject', + '@ObservedObject', + '@EnvironmentObject', + '@Published', + ], + }, + { + framework: 'vapor', + entryPointMultiplier: 3.0, + reason: 'vapor-routing', + patterns: ['app.get', 'app.post', 'req.content.decode', 'Vapor'], + }, + ] satisfies AstFrameworkPatternConfig[], treeSitterQueries: SWIFT_QUERIES, typeConfig: swiftConfig, exportChecker: swiftExportChecker, diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index e9dc21ab4..c3fad3518 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -10,6 +10,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { NodeLabel } from 'gitnexus-shared'; import { defineLanguage } from '../language-provider.js'; +import type { AstFrameworkPatternConfig } from '../language-provider.js'; import { createClassExtractor } from '../class-extractors/generic.js'; import { typescriptClassConfig, @@ -57,8 +58,30 @@ import { } from './typescript/index.js'; /** - * TypeScript/JavaScript: arrow_function and function_expression get their name - * from the parent variable_declarator (e.g. `const foo = () => {}`). + * TypeScript/JavaScript: arrow_function and function_expression are + * anonymous AST nodes — they take their name from the surrounding + * declarative context. + * + * Recognised contexts: + * - `const foo = () => {}` (variable_declarator) → "foo" + * - `{ addItem: (item) => ... }` (pair / property_assignment) → "addItem" + * Covers Zustand stores, TanStack Query factories, React Context + * providers, and most other HOF-heavy idioms (issue #1166). + * - `const X = HOC((args) => { ... })` (arguments → call_expression → + * variable_declarator) → "X". Covers `React.forwardRef`, `memo`, + * `useCallback`, `useMemo`, `observer`, `debounce`, and other HOC + * factories that wrap their behaviour-defining arrow. Without this + * branch, every shadcn/Radix UI component (`const Button = + * React.forwardRef(...)`) registered as an anonymous arrow with + * calls inside falling back to File-level attribution. The same + * applied to all `useCallback` / `useMemo` callbacks bound to a + * const — the sole way to give them a named caller anchor. + * + * Returns `null` for funcName when the arrow lives in a context that has + * no static name — bare call arguments (not bound to a const), computed + * keys, return-from-arrow positions. The parent walk in + * findEnclosingFunctionId then continues up to the next named ancestor + * (or to the file). */ const tsExtractFunctionName = ( node: SyntaxNode, @@ -66,19 +89,74 @@ const tsExtractFunctionName = ( if (node.type !== 'arrow_function' && node.type !== 'function_expression') return null; const parent = node.parent; - if (parent?.type !== 'variable_declarator') return null; + if (!parent) return null; - let nameNode = parent.childForFieldName?.('name'); - if (!nameNode) { - for (let i = 0; i < parent.childCount; i++) { - const c = parent.child(i); - if (c?.type === 'identifier') { - nameNode = c; - break; + if (parent.type === 'variable_declarator') { + let nameNode = parent.childForFieldName?.('name'); + if (!nameNode) { + for (let i = 0; i < parent.childCount; i++) { + const c = parent.child(i); + if (c?.type === 'identifier') { + nameNode = c; + break; + } } } + return { funcName: nameNode?.text ?? null, label: 'Function' }; } - return { funcName: nameNode?.text ?? null, label: 'Function' }; + + // Object property pair: `{ addItem: (item) => ... }`. + // tree-sitter-typescript uses `pair`; tree-sitter-javascript also exposes + // `pair`. (Older grammars used `property_assignment`; we accept both.) + if (parent.type === 'pair' || parent.type === 'property_assignment') { + const keyNode = parent.childForFieldName?.('key'); + if (!keyNode) return { funcName: null, label: 'Function' }; + if (keyNode.type === 'property_identifier' || keyNode.type === 'identifier') { + return { funcName: keyNode.text, label: 'Function' }; + } + if (keyNode.type === 'string') { + // `"add-item": () => ...` — the literal text inside the quotes. + const fragment = keyNode.children?.find((c: SyntaxNode) => c.type === 'string_fragment'); + const text = fragment?.text ?? null; + return { funcName: text, label: 'Function' }; + } + // computed_property_name (`[ACTION_KEY]`) and other dynamic keys have + // no static name — fall through anonymous. + return { funcName: null, label: 'Function' }; + } + + // HOC-wrapped variable declarations: `const Button = forwardRef((p, r) => { ... })`, + // `const handleClick = useCallback(() => doStuff(), [deps])`, + // `const Card = React.memo((props) => { ... })`. The arrow's `parent` is + // `arguments`, grandparent is `call_expression`, great-grandparent is + // `variable_declarator`. Walk the chain up and take the variable's name + // — the meaningful identifier the developer wrote on the LHS. Mirrors + // the four registry-primary patterns in `typescript/query.ts`. The + // wrapping callee (`forwardRef`, `memo`, `React.memo`, `useCallback`, + // user-defined HOCs) is intentionally NOT constrained: any function + // call whose result is bound to a const and whose first/positional + // argument is an arrow takes the const's name. Chained array-method + // calls (`const x = arr.find((y) => p(y))`) match too and produce a + // mostly-harmless `Function:x` (consumed as a value, never invoked), + // accepted as a small false-positive cost vs. the much larger gain of + // capturing the React UI-component idiom. + if (parent.type === 'arguments') { + const callExpr = parent.parent; + if (!callExpr || callExpr.type !== 'call_expression') { + return { funcName: null, label: 'Function' }; + } + const declarator = callExpr.parent; + if (!declarator || declarator.type !== 'variable_declarator') { + return { funcName: null, label: 'Function' }; + } + const nameNode = declarator.childForFieldName?.('name'); + if (nameNode?.type === 'identifier') { + return { funcName: nameNode.text, label: 'Function' }; + } + return { funcName: null, label: 'Function' }; + } + + return { funcName: null, label: 'Function' }; }; export const BUILT_INS: ReadonlySet = new Set([ @@ -181,6 +259,29 @@ export const BUILT_INS: ReadonlySet = new Set([ export const typescriptProvider = defineLanguage({ id: SupportedLanguages.TypeScript, extensions: ['.ts', '.tsx'], + entryPointPatterns: [/^use[A-Z]/], + astFrameworkPatterns: [ + { + framework: 'nestjs', + entryPointMultiplier: 3.2, + reason: 'nestjs-decorator', + patterns: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'], + }, + { + framework: 'expo-router', + entryPointMultiplier: 2.5, + reason: 'expo-router-navigation', + patterns: [ + 'router.push', + 'router.replace', + 'router.navigate', + 'useRouter', + 'useLocalSearchParams', + 'useSegments', + 'expo-router', + ], + }, + ] satisfies AstFrameworkPatternConfig[], treeSitterQueries: TYPESCRIPT_QUERIES, typeConfig: typescriptConfig, exportChecker: tsExportChecker, @@ -220,6 +321,29 @@ export const typescriptProvider = defineLanguage({ export const javascriptProvider = defineLanguage({ id: SupportedLanguages.JavaScript, extensions: ['.js', '.jsx'], + entryPointPatterns: [/^use[A-Z]/], + astFrameworkPatterns: [ + { + framework: 'nestjs', + entryPointMultiplier: 3.2, + reason: 'nestjs-decorator', + patterns: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'], + }, + { + framework: 'expo-router', + entryPointMultiplier: 2.5, + reason: 'expo-router-navigation', + patterns: [ + 'router.push', + 'router.replace', + 'router.navigate', + 'useRouter', + 'useLocalSearchParams', + 'useSegments', + 'expo-router', + ], + }, + ] satisfies AstFrameworkPatternConfig[], treeSitterQueries: JAVASCRIPT_QUERIES, typeConfig: typescriptConfig, exportChecker: tsExportChecker, 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..9e0d9b809 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -136,25 +136,172 @@ const TYPESCRIPT_SCOPE_QUERY = ` ;; Arrow/function-expression assigned to a const/let/var — named by the ;; variable_declarator. Covers \`const fn = () => {}\` and its export ;; variant. Matches the legacy TYPESCRIPT_QUERIES pattern. +;; +;; The \`@declaration.function\` anchor sits on the INNER arrow_function / +;; function_expression node (NOT the wrapping lexical_declaration), so +;; \`anchor.range\` aligns with the corresponding \`@scope.function\` scope +;; range. \`pass2AttachDeclarations\` then resolves \`innermost\` to the +;; arrow's own scope (instead of the module scope) and the def is owned +;; by the arrow itself. Without this alignment, calls inside the arrow +;; body lose caller attribution: \`resolveCallerGraphId\` walks up past +;; the empty arrow scope into the module scope and grabs whichever +;; Function-like def appears first there — silently mis-attributing +;; every nested call (Zustand stores, TanStack hooks, Promise-all/map, +;; etc.). See \`typescript-hof-callbacks.test.ts\`. (lexical_declaration (variable_declarator name: (identifier) @declaration.name - value: (arrow_function))) @declaration.function + value: (arrow_function) @declaration.function)) (lexical_declaration (variable_declarator name: (identifier) @declaration.name - value: (function_expression))) @declaration.function + value: (function_expression) @declaration.function)) (variable_declaration (variable_declarator name: (identifier) @declaration.name - value: (arrow_function))) @declaration.function + value: (arrow_function) @declaration.function)) (variable_declaration (variable_declarator name: (identifier) @declaration.name - value: (function_expression))) @declaration.function + value: (function_expression) @declaration.function)) + +;; Object-property arrows / function expressions named by their pair key: +;; \`{ addItem: (item) => ..., removeItem: (item) => ... }\`. The legacy +;; TYPESCRIPT_QUERIES emits the same shape; mirroring it here keeps +;; scope-resolution declarations in sync (issue #1166). Computed keys +;; (\`[K]: () => ...\`) intentionally fall through anonymous. +;; +;; Same anchor discipline as the \`lexical_declaration\` block above: the +;; \`@declaration.function\` capture must sit on the INNER \`arrow_function\` +;; / \`function_expression\` node — NOT the outer \`pair\`. The pair node +;; starts at the property-key token, BEFORE the arrow's +;; \`@scope.function\` range. \`pass2AttachDeclarations.atPosition(pair.startLine, +;; pair.startCol)\` therefore resolves to the PARENT scope (the enclosing +;; function-like, e.g. the \`(set) => ({...})\` callback in +;; \`persist((set) => ({...}))\`), not the inner arrow's own scope. +;; +;; With the anchor on \`pair\`, ALL pair-function defs from the same object +;; literal land in the same parent scope's \`ownedDefs\`. \`resolveCallerGraphId\` +;; walking up from a call inside any of those arrows then matches the +;; FIRST Function-like def via \`ownedDefs.find()\` — silently mis-attributing +;; every call to the first sibling. Multi-action Zustand stores +;; (\`{ addItem, removeItem, fetchData, … }\`) — the dominant 0%-capture +;; pattern in the bug report — would land all calls on \`addItem\`. +;; +;; With the anchor on the inner \`arrow_function\` / \`function_expression\`, +;; \`anchor.range\` matches the arrow's own \`@scope.function\` range; the +;; def lands in the arrow scope's own \`ownedDefs\` and \`pass2AttachDeclarations\`'s +;; auto-hoist (\`rangesEqual(anchor.range, innermost.range)\`) promotes +;; the BINDING to the parent scope (so importers and lookups still find +;; the name in the object's surrounding scope). Each pair-arrow becomes +;; an independent caller anchor in the walk. +(pair + key: (property_identifier) @declaration.name + value: (arrow_function) @declaration.function) + +(pair + key: (property_identifier) @declaration.name + value: (function_expression) @declaration.function) + +(pair + key: (string (string_fragment) @declaration.name) + value: (arrow_function) @declaration.function) + +(pair + key: (string (string_fragment) @declaration.name) + value: (function_expression) @declaration.function) + +;; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`. +;; +;; Covers the dominant React UI idiom (\`React.forwardRef\`, \`React.memo\`, +;; bare \`forwardRef\` / \`memo\` / \`observer\`), Hook callbacks +;; (\`useCallback\`, \`useMemo\`), and library-wrapper factories (\`debounce\`, +;; \`throttle\`, user-defined \`withErrorBoundary\` / \`createHook\`, etc.). +;; All produce the same AST shape: +;; +;; lexical_declaration +;; variable_declarator +;; name: identifier "X" ← we want this name +;; value: call_expression +;; function: identifier | member_expression ← any callee +;; arguments: arguments +;; arrow_function | function_expression ← the actual code +;; +;; The pre-fix \`tsExtractFunctionName\` only handled \`variable_declarator\` +;; and \`pair\` parents, so HOC-wrapped arrows fell through anonymous. The +;; registry-primary \`query.ts\` had no pattern for this shape either — +;; \`const Button = forwardRef((p, r) => { ... })\` registered as a +;; \`Variable\` with no \`Function\` def, and every call inside the arrow +;; body lost caller attribution: \`resolveCallerGraphId\` walked up past +;; the empty arrow scope to the module's File fallback. Sourcerer-fe alone +;; has ~296 such declarations (57 forwardRef + 21 memo + 161 useCallback +;; + 57 useMemo) — all invisible to \`gitnexus_context\` / +;; \`gitnexus_impact\` for outgoing edges before this fix. +;; +;; Anchor discipline: same as the \`lexical_declaration\` / \`pair\` blocks +;; above — on the INNER \`arrow_function\` / \`function_expression\`, NOT +;; the outer \`call_expression\`. The arrow's range matches its own +;; \`@scope.function\` range, so \`pass2AttachDeclarations.atPosition\` +;; resolves \`innermost\` to the arrow's own scope and +;; \`rangesEqual(anchor.range, innermost.range)\` triggers the auto-hoist +;; that promotes the binding to the parent scope (where \`const X\` +;; lives). +;; +;; Trade-off — chained array-method form: \`const x = arr.find((y) => p(y))\` +;; has the same syntactic shape and would also match, naming the +;; \`.find\` callback as \`x\`. The resulting \`Function:x\` is mostly +;; harmless: \`x\` is consumed as a value (\`if (x) { ... }\`), never +;; invoked as a function, so it gets zero incoming \`CALLS\` edges. The +;; one outgoing edge \`Function:x → p\` is a minor mis-attribution that +;; could in principle be fixed by adding a \`function: [(identifier) +;; (member_expression)]\` predicate that excludes property-identifiers +;; matching a known array-method blocklist (\`map\` / \`filter\` / \`find\` +;; / \`reduce\` / \`forEach\` / \`some\` / \`every\`). We don't do that here +;; because (a) the false-positive cost is negligible, (b) the blocklist +;; would need maintenance, and (c) any user-defined fluent-API method +;; with a callback argument would still false-positive — there's no +;; clean syntactic line. +;; +;; Trade-off — multi-arrow arguments: \`const x = call(arrow1, arrow2)\` +;; would emit TWO matches with the same name \`x\`. tree-sitter-query +;; iterates all arrow_function direct children of \`arguments\`, so each +;; emits its own \`(name=x, function=...)\` pair. \`pass2AttachDeclarations\` +;; pushes both \`Function:x\` defs into the same arrow scopes (each in +;; its own arrow's \`ownedDefs\`) and hoists both bindings to the parent. +;; The downstream registry's qualified-name dedup then collapses them +;; via \`(filePath, type, qualifiedName)\` — second wins. Acceptable; +;; multi-arrow-callback APIs are rare (\`new Promise(executor)\` is the +;; main one and takes a single executor). +(lexical_declaration + (variable_declarator + name: (identifier) @declaration.name + value: (call_expression + arguments: (arguments + (arrow_function) @declaration.function)))) + +(lexical_declaration + (variable_declarator + name: (identifier) @declaration.name + value: (call_expression + arguments: (arguments + (function_expression) @declaration.function)))) + +(variable_declaration + (variable_declarator + name: (identifier) @declaration.name + value: (call_expression + arguments: (arguments + (arrow_function) @declaration.function)))) + +(variable_declaration + (variable_declarator + name: (identifier) @declaration.name + value: (call_expression + arguments: (arguments + (function_expression) @declaration.function)))) ;; Method definitions — regular + private (#field) methods. (method_definition @@ -723,6 +870,53 @@ const TYPESCRIPT_SCOPE_QUERY = ` property: (property_identifier) @reference.name) @reference.read.member `; +/** + * JSX-only query suffix. Appended to the base query when compiling + * against the TSX grammar; NOT compiled against the plain TS grammar + * (which has no \`jsx_*\` node types and would reject these patterns). + * + * Why JSX as a CALLS edge: \`\` 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/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/typescript-hoc-wrapped.test.ts b/gitnexus/test/integration/resolvers/typescript-hoc-wrapped.test.ts new file mode 100644 index 000000000..e39971f80 --- /dev/null +++ b/gitnexus/test/integration/resolvers/typescript-hoc-wrapped.test.ts @@ -0,0 +1,306 @@ +/** + * TypeScript: CALLS edges from inside HOC-wrapped variable declarations. + * + * Follow-up to issue #1166 / PR #1175. After fixing HOF callbacks (Promise + * fan-out, queryFn pair-arrows, Zustand actions) and JSX-as-call, the + * residual 0%-capture pattern in real React UI codebases (Sourcerer-fe + * audit) was the HOC-wrapped declaration: + * + * const Button = React.forwardRef((props, ref) => { ... }) + * const Card = memo(({ ... }) => { ... }) + * const handleClick = useCallback(() => { ... }, []) + * const computed = useMemo(() => { ... }, []) + * const Item = observer((props) => { ... }) + * const debouncedSearch = debounce((query) => { ... }, 250) + * + * All share the AST shape `lexical_declaration > variable_declarator > + * call_expression > arguments > arrow_function`. Pre-fix, none matched + * any `@declaration.function` pattern (the registry-primary `query.ts` + * only knew about `variable_declarator > arrow_function` directly), and + * the legacy DAG's `tsExtractFunctionName` only walked `pair` / + * `variable_declarator` parents — `arguments` parents fell through with + * `funcName = null`. + * + * Result: every shadcn/Radix component, every memoised React component, + * every useCallback/useMemo hook callback registered as anonymous, and + * calls inside their bodies attributed to the file. Sourcerer-fe alone + * had ~296 such declarations affected (57 forwardRef + 21 memo + 161 + * useCallback + 57 useMemo). + * + * Fix: + * - 4 new tree-sitter patterns in `typescript/query.ts` (registry). + * - 4 mirrored patterns in `tree-sitter-queries.ts` (legacy). + * - `tsExtractFunctionName` extended with a third branch that walks + * `arguments → call_expression → variable_declarator`. + * + * Each test fixture below isolates one wrapper shape with the call + * target defined in `helpers.ts` (cross-file resolution). + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, + getRelationships, + edgeSet, + getNodesByLabel, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +describe('TypeScript HOC-wrapped variable declarations', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-hoc-wrapped'), () => {}); + }, 60000); + + // ───────────────────────────────────────────────────────────────── + // Positive: each HOC-wrapped const becomes a named Function whose + // body's calls attribute to it (not File). + // ───────────────────────────────────────────────────────────────── + + it('React.forwardRef: Button → cn and Button → helper (member-expression callee)', () => { + // `const Button = React.forwardRef<...>(({ ... }, _ref) => { ... })`. + // The wrapping callee is a `member_expression` (`React.forwardRef`), + // exercising the "any callee" leniency in the new patterns. + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/forward-ref.tsx' && c.source === 'Button', + ); + const targets = new Set(calls.map((c) => c.target)); + expect(targets, 'Button must call cn').toContain('cn'); + expect(targets, 'Button must call helper').toContain('helper'); + }); + + it('memo (bare identifier): Card → cn and Card → helper', () => { + // `const Card = memo<...>((...) => { ... })`. Bare-identifier callee + // — the named-import form (`import { memo } from 'react'`). + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/memo.tsx' && c.source === 'Card', + ); + const targets = new Set(calls.map((c) => c.target)); + expect(targets, 'Card must call cn').toContain('cn'); + expect(targets, 'Card must call helper').toContain('helper'); + }); + + it('useCallback: handleClick → doStuff and handleClick → fmt', () => { + // `const handleClick = useCallback(() => { ... }, [])`. + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/use-callback.ts' && c.source === 'handleClick', + ); + const targets = new Set(calls.map((c) => c.target)); + expect(targets).toContain('doStuff'); + expect(targets).toContain('fmt'); + }); + + it('useCallback: handleSubmit → doStuff (sibling const, separate caller)', () => { + // Two useCallback consts in the same file — each must be its own + // caller anchor (no leakage to the first sibling, the analogue of + // the multi-action-store regression in PR #1175). + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/use-callback.ts' && c.target === 'doStuff', + ); + const fromHandleSubmit = calls.filter((c) => c.source === 'handleSubmit'); + expect(fromHandleSubmit.length, 'handleSubmit must call doStuff').toBeGreaterThan(0); + }); + + it('useMemo: computed → doStuff (returns-a-value variant)', () => { + // `const computed = useMemo(() => { return doStuff(42); }, [])`. + // The arrow's body has a `return` statement — irrelevant to call + // attribution but worth exercising as a separate fixture. + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/use-memo.ts' && c.source === 'computed', + ); + expect(edgeSet(calls)).toContain('computed → doStuff'); + }); + + it('observer (MobX): Item → helper', () => { + // Same shape as memo, different wrapper name. Exercises the "any + // callee" leniency for non-React HOCs. + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/observer.tsx' && c.source === 'Item', + ); + expect(edgeSet(calls)).toContain('Item → helper'); + }); + + it('debounce: debouncedSearch → doStuff (utility-HOC form)', () => { + // `const debouncedSearch = debounce((query) => { doStuff(...); }, 250)`. + // Pattern is identical to React HOCs — the wrapper just happens to + // be a debouncer, so this confirms the fix is wrapper-agnostic. + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/debounce.ts' && c.source === 'debouncedSearch', + ); + expect(edgeSet(calls)).toContain('debouncedSearch → doStuff'); + }); + + // ───────────────────────────────────────────────────────────────── + // Function-node assertions: each HOC-wrapped const must register as + // a Function (not just a Variable). Without this, gitnexus_context / + // gitnexus_impact see no symbol to walk from. + // ───────────────────────────────────────────────────────────────── + + it('each HOC-wrapped const registers as a Function node', () => { + const functions = new Set(getNodesByLabel(result, 'Function')); + // Every const we wrote in the fixtures must have a Function entry. + expect(functions, 'forwardRef-wrapped Button').toContain('Button'); + expect(functions, 'memo-wrapped Card').toContain('Card'); + expect(functions, 'useCallback-bound handleClick').toContain('handleClick'); + expect(functions, 'useCallback-bound handleSubmit').toContain('handleSubmit'); + expect(functions, 'useMemo-bound computed').toContain('computed'); + expect(functions, 'observer-wrapped Item').toContain('Item'); + expect(functions, 'debounce-wrapped debouncedSearch').toContain('debouncedSearch'); + }); + + // ───────────────────────────────────────────────────────────────── + // Negative: bare statement-level HOC calls (not bound to a const) + // must NOT produce phantom Function nodes named after some implicit + // anchor, and their inner calls must NOT attribute to a wrong source. + // ───────────────────────────────────────────────────────────────── + + it('bare statement-level HOC calls do not produce phantom Functions', () => { + // `negative-bare-call.ts` has three unbound HOC calls + // (useCallback / memo / wrap(memo(...))). None should become a + // named Function. The only Function-eligible def in the file is + // `wrap` (a regular `const wrap = (value: T): T => value`), + // exercised here as the negative-control's only legit Function. + const fileFns = getRelationships(result, 'CALLS') + .filter((c) => c.sourceFilePath === 'src/negative-bare-call.ts') + .map((c) => c.source); + const sources = new Set(fileFns); + // Only the file itself (or `wrap` if its body had calls — it + // doesn't) should appear as a source. Assert the phantom-prone + // names are absent. + expect(sources, 'no phantom useCallback as caller').not.toContain('useCallback'); + expect(sources, 'no phantom memo as caller').not.toContain('memo'); + // doStuff calls inside the bare HOCs fall back to File-level + // attribution (the arrow has no caller anchor). + const fromFile = getRelationships(result, 'CALLS').filter( + (c) => + c.sourceFilePath === 'src/negative-bare-call.ts' && + c.sourceLabel === 'File' && + c.target === 'doStuff', + ); + expect(fromFile.length, 'unbound HOC inner calls source from File').toBeGreaterThan(0); + }); + + it('no phantom self-loops in HOC-wrapped fixtures', () => { + // The Zustand-style regression from PR #1175 (CallerAnchorLabel + // exclusion of Variable defs) must continue to hold here. The + // outer module-level call (e.g., `React.forwardRef(...)`, + // `memo(...)`) should source from File, not from the const it + // declares. If the new patterns inadvertently re-promoted Variable + // defs to caller anchors, we'd see edges like `Button → forwardRef` + // (sourceLabel=Function). Filter to call edges where the SOURCE is + // the const we just declared — and check that the const's target + // set never includes the wrapper itself. + const buttonCalls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'Button' && c.target === 'forwardRef', + ); + expect(buttonCalls, 'Button must NOT call forwardRef (phantom self-loop)').toEqual([]); + const cardCalls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'Card' && c.target === 'memo', + ); + expect(cardCalls, 'Card must NOT call memo (phantom self-loop)').toEqual([]); + const handleClickCalls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'handleClick' && c.target === 'useCallback', + ); + expect(handleClickCalls, 'handleClick must NOT call useCallback (phantom self-loop)').toEqual( + [], + ); + }); + + // ───────────────────────────────────────────────────────────────── + // Cross-pollination: multiple HOC-wrapped consts in the same file + // must each be their own caller anchor (the multi-pair regression + // analogue, restated for HOC patterns). + // ───────────────────────────────────────────────────────────────── + + it('handleClick and handleSubmit do not cross-attribute (no first-sibling-wins)', () => { + // `use-callback.ts` has two useCallback-bound consts. Calls inside + // `handleSubmit` (`doStuff(value)`) must NOT appear as + // `handleClick → doStuff`. The fix in PR #1175 + // (`isCallerAnchorLabel` + per-arrow ownedDefs via inner anchor + // discipline) plus the new patterns here must compose: each + // useCallback callback gets its own arrow scope, each scope has + // its own def in `ownedDefs`, and `resolveCallerGraphId` walks + // up to the right one. + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/use-callback.ts' && c.target === 'doStuff', + ); + const fromHandleClick = calls.filter((c) => c.source === 'handleClick'); + const fromHandleSubmit = calls.filter((c) => c.source === 'handleSubmit'); + expect(fromHandleClick.length, 'handleClick must call doStuff').toBeGreaterThan(0); + expect(fromHandleSubmit.length, 'handleSubmit must call doStuff').toBeGreaterThan(0); + // Both consts call doStuff, but each must source from its OWN name. + // We assert there's no "spilled" edge that names the wrong const. + const stray = calls.filter((c) => c.source !== 'handleClick' && c.source !== 'handleSubmit'); + // Allow File-rooted edges as a fallback if the fix regresses; we + // explicitly disallow Function-rooted strays from siblings/ + // imported names (e.g., useCallback itself). + const functionStrays = stray.filter((c) => c.sourceLabel === 'Function'); + expect(functionStrays, 'no other Function sources for doStuff calls').toEqual([]); + }); + + // ───────────────────────────────────────────────────────────────── + // Documented limitation: deeply-nested HOCs (`memo(forwardRef(...))`). + // + // The fixture `nested.tsx` documents that the OUTER pattern requires + // the arrow to be a direct grandchild of the const's `call_expression` + // value — when the arrow is wrapped in another `call_expression` + // (`memo(forwardRef(arrow))`), the pattern misses and the deepest + // arrow stays anonymous. The const itself (`Wrapped`) is also NOT a + // Function: the immediate arg of the outer `memo(...)` call is a + // `call_expression` (`forwardRef(...)`), not an arrow / fn-expression, + // so no `@declaration.function` pattern matches the outer shape either. + // + // We assert ABSENCE here (rather than positive resolution) so that any + // future change to the patterns or to `tsExtractFunctionName` that + // accidentally starts matching nested HOCs surfaces immediately. A + // proper fix for nested HOCs would require deciding which level wins + // the name (outer wrapper? deepest behaviour-arrow?) and is out of + // scope for this PR. + // ───────────────────────────────────────────────────────────────── + + it('nested HOCs (memo(forwardRef(...))): Wrapped is NOT a Function (known limitation)', () => { + // The outer const `Wrapped` matches NO `@declaration.function` pattern + // because the outer call's first argument is itself a call_expression, + // not an arrow_function / function_expression. It should be picked up + // as a Variable by `@definition.const` (or skipped entirely) — but it + // must NOT appear as a Function node. + const functions = new Set(getNodesByLabel(result, 'Function')); + expect(functions, 'Wrapped (nested HOC) must NOT be a Function node').not.toContain('Wrapped'); + }); + + it('nested HOCs: helper() call inside the deepest arrow does NOT source from Function:Wrapped', () => { + // Calls inside the doubly-wrapped arrow have no named ancestor (deepest + // arrow is anonymous because `call_expression.parent` is `arguments`, + // not `variable_declarator`; the outer `memo` and `forwardRef` calls + // are themselves anonymous expressions). So calls in `nested.tsx` must + // either source from File or not be attributed to `Wrapped` at all. + // + // The negative assertion is what matters: a future change that wrongly + // attributes the deepest arrow to its outer const would silently corrupt + // impact analysis for any real code that nests HOCs (e.g., + // `memo(forwardRef(...))` UI primitives). + const helperCalls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/nested.tsx' && c.target === 'helper', + ); + expect(helperCalls.length, 'helper call must still be captured').toBeGreaterThan(0); + + const fromWrapped = helperCalls.filter((c) => c.source === 'Wrapped'); + expect( + fromWrapped, + 'helper call must NOT be attributed to Function:Wrapped (deepest arrow stays anonymous)', + ).toEqual([]); + + // Defensive: there should be no Function-sourced edges from anywhere in + // `nested.tsx` (everything is anonymous or module-level). + const allNestedCalls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/nested.tsx', + ); + const functionSourced = allNestedCalls.filter((c) => c.sourceLabel === 'Function'); + expect( + functionSourced, + 'no Function-sourced CALLS from nested.tsx (all anchors should be File)', + ).toEqual([]); + }); +}); 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 (
, ,