mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-28 01:31:23 +00:00
Merge remote-tracking branch 'origin/main' into codex/feat-thrift-contracts-impl
# Conflicts: # gitnexus/src/core/group/sync.ts # gitnexus/test/unit/group/manifest-extractor.test.ts # gitnexus/test/unit/group/sync.test.ts
This commit is contained in:
commit
91a0fd1cc8
141 changed files with 7827 additions and 1494 deletions
|
|
@ -36,7 +36,6 @@ import urllib.request
|
|||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
GITNEXUS_DIR = REPO_ROOT / "gitnexus"
|
||||
VENDOR_PROTO_DIR = GITNEXUS_DIR / "vendor" / "tree-sitter-proto"
|
||||
|
||||
# ── Upgrade target ──────────────────────────────────────────────────────
|
||||
# The runtime version we want to upgrade TO. Update this when the goal
|
||||
|
|
@ -73,18 +72,37 @@ GRAMMARS: dict[str, tuple[str, str, str]] = {
|
|||
"tree-sitter-rust": ("tree-sitter/tree-sitter-rust", "master", "src/parser.c"),
|
||||
"tree-sitter-swift": ("alex-pinkus/tree-sitter-swift", "main", "src/parser.c"),
|
||||
"tree-sitter-typescript": ("tree-sitter/tree-sitter-typescript", "master", "typescript/src/parser.c"),
|
||||
# Vendored parsers — kept here so the upstream coords for drift
|
||||
# detection are co-located with every other grammar's coords.
|
||||
"tree-sitter-proto": ("coder3101/tree-sitter-proto", "main", "src/parser.c"),
|
||||
}
|
||||
|
||||
UPSTREAM_PROTO_OWNER = "coder3101"
|
||||
UPSTREAM_PROTO_REPO = "tree-sitter-proto"
|
||||
UPSTREAM_PROTO_BRANCH = "main"
|
||||
# Grammars deliberately held below npm latest. The readiness report surfaces
|
||||
# these so reviewers can tell intentional pins apart from drift, and so the
|
||||
# context for each pin (which issue motivated it) is visible at a glance.
|
||||
# Add an entry whenever you pin a grammar below npm latest.
|
||||
INTENTIONAL_PINS: dict[str, str] = {
|
||||
"tree-sitter-c": (
|
||||
"#1242 — last release built against the tree-sitter@0.21 ABI; "
|
||||
"tree-sitter-c@0.23.x prebuilds segfault on Windows under tree-sitter@0.21.1"
|
||||
),
|
||||
"tree-sitter-cpp": (
|
||||
"#1242 — last 0.23.x release before tree-sitter-cpp added a runtime "
|
||||
"dep on the broken-ABI tree-sitter-c@^0.23.1; pinning here removes "
|
||||
"the need for a transitive override"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_package_json() -> dict:
|
||||
return json.loads((GITNEXUS_DIR / "package.json").read_text())
|
||||
|
||||
|
||||
def read_current_runtime() -> str:
|
||||
"""Return the tree-sitter runtime version pinned in package.json (e.g. '0.21')."""
|
||||
pkg = json.loads((GITNEXUS_DIR / "package.json").read_text())
|
||||
pkg = _load_package_json()
|
||||
raw = pkg["dependencies"]["tree-sitter"]
|
||||
match = re.search(r"(\d+)\.(\d+)", raw)
|
||||
if not match:
|
||||
|
|
@ -92,6 +110,22 @@ def read_current_runtime() -> str:
|
|||
return f"{match.group(1)}.{match.group(2)}"
|
||||
|
||||
|
||||
def read_pinned_grammar_versions() -> dict[str, str]:
|
||||
"""Return the grammar version range pinned in gitnexus/package.json.
|
||||
|
||||
Looks at both runtime and optional dependencies. Returns the raw range
|
||||
string (e.g. '0.21.4', '^0.23.0', 'file:./vendor/...') so the report can
|
||||
expose how flexible each pin is.
|
||||
"""
|
||||
pkg = _load_package_json()
|
||||
pinned: dict[str, str] = {}
|
||||
for section in ("dependencies", "optionalDependencies"):
|
||||
for name, spec in (pkg.get(section) or {}).items():
|
||||
if name.startswith("tree-sitter-"):
|
||||
pinned[name] = spec
|
||||
return pinned
|
||||
|
||||
|
||||
def npm_view_json(pkg: str) -> dict | None:
|
||||
"""Fetch package metadata from the npm registry via HTTPS.
|
||||
|
||||
|
|
@ -185,8 +219,178 @@ def md_h(text: str, level: int = 2) -> str:
|
|||
return f"{'#' * level} {text}\n"
|
||||
|
||||
|
||||
def _first_sentence(text: str) -> str:
|
||||
"""Return the leading sentence of a free-form rationale string.
|
||||
|
||||
Vendor package.json `_vendoredBy` fields often look like
|
||||
"<reason>. <install-script breadcrumb>. Do NOT <warning>." — the
|
||||
first sentence is what reviewers actually want to read; the rest is
|
||||
noise in this context. Match a sentence-ending '.' followed by
|
||||
whitespace; fall back to the whole string if nothing matches.
|
||||
"""
|
||||
text = text.strip()
|
||||
match = re.search(r"\.\s+[A-Z]", text)
|
||||
return text[: match.start() + 1] if match else text
|
||||
|
||||
|
||||
def range_includes(spec: str | None, version: str) -> bool:
|
||||
"""Return True if pinned-range `spec` accepts the concrete `version`.
|
||||
|
||||
Handles the spec shapes we actually use in package.json:
|
||||
- exact pins ('0.21.4')
|
||||
- caret / tilde ranges ('^0.23.0', '~0.23.5')
|
||||
- non-registry pins ('file:./vendor/...', 'git+...') — always False,
|
||||
because there's no meaningful "behind npm latest" comparison.
|
||||
"""
|
||||
if not spec or spec == "—":
|
||||
return False
|
||||
if spec.startswith(("file:", "git", "http")):
|
||||
return False
|
||||
if spec.startswith(("^", "~")):
|
||||
return satisfies_target(spec, version)
|
||||
return spec.strip() == version.strip()
|
||||
|
||||
|
||||
def is_vendored_pin(spec: str | None) -> bool:
|
||||
return bool(spec) and spec.startswith(("file:", "git", "http"))
|
||||
|
||||
|
||||
def vendored_drift_summary(
|
||||
name: str, upstream_repo: str, upstream_branch: str, parser_path: str
|
||||
) -> dict:
|
||||
"""Inspect a vendored grammar under gitnexus/vendor/<name>.
|
||||
|
||||
Returns the vendored package.json's ``version`` and ``_vendoredBy``
|
||||
fields (which carry the human rationale for vendoring), the vendored
|
||||
parser's ABI, and a comparison against upstream main. We deliberately
|
||||
rely on ``_vendoredBy`` rather than a parallel registry in this
|
||||
script: the rationale belongs next to the vendored sources, not in
|
||||
a daily-running CI script.
|
||||
"""
|
||||
vendor_dir = GITNEXUS_DIR / "vendor" / name
|
||||
pkg: dict = {}
|
||||
pkg_path = vendor_dir / "package.json"
|
||||
if pkg_path.is_file():
|
||||
try:
|
||||
pkg = json.loads(pkg_path.read_text(encoding="utf-8", errors="ignore"))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
vendored_parser = vendor_dir / parser_path
|
||||
if not vendored_parser.is_file():
|
||||
vendored_parser = vendor_dir / "src" / "parser.c"
|
||||
vendored_abi = extract_language_version(vendored_parser)
|
||||
|
||||
upstream_url = (
|
||||
f"https://raw.githubusercontent.com/{upstream_repo}/"
|
||||
f"{upstream_branch}/{parser_path}"
|
||||
)
|
||||
upstream_text = fetch_text(upstream_url)
|
||||
upstream_abi = extract_abi_from_text(upstream_text) if upstream_text else None
|
||||
|
||||
sha_text = fetch_text(
|
||||
f"https://api.github.com/repos/{upstream_repo}/commits/{upstream_branch}"
|
||||
)
|
||||
upstream_sha = "?"
|
||||
if sha_text:
|
||||
try:
|
||||
upstream_sha = json.loads(sha_text).get("sha", "?")[:12]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
local_text = (
|
||||
vendored_parser.read_text(encoding="utf-8", errors="ignore")
|
||||
if vendored_parser.is_file()
|
||||
else ""
|
||||
)
|
||||
in_sync = bool(
|
||||
upstream_text
|
||||
and local_text.replace("\r\n", "\n") == upstream_text.replace("\r\n", "\n")
|
||||
)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"vendored_version": pkg.get("version", "?"),
|
||||
"vendored_by": pkg.get("_vendoredBy"),
|
||||
"vendored_abi": vendored_abi,
|
||||
"upstream_repo": upstream_repo,
|
||||
"upstream_branch": upstream_branch,
|
||||
"upstream_sha": upstream_sha,
|
||||
"upstream_abi": upstream_abi,
|
||||
"in_sync": in_sync,
|
||||
}
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _classify_grammar(
|
||||
*,
|
||||
name: str,
|
||||
pinned_spec: str | None,
|
||||
npm_version: str,
|
||||
peer_range: str | None,
|
||||
fetch_failed: bool,
|
||||
target_compat: bool,
|
||||
current_compat: bool,
|
||||
upstream_progress: str | None,
|
||||
) -> dict:
|
||||
"""Decide a single primary disposition + a separate bump-now hint.
|
||||
|
||||
Buckets are mutually exclusive and ordered by what a reviewer should
|
||||
look at first:
|
||||
- fetch_failed : npm registry fetch failed (treat as blocker, but
|
||||
surface separately so reviewers don't confuse it
|
||||
with an upstream block)
|
||||
- intentional : pinned in INTENTIONAL_PINS — explicit choice
|
||||
- ready : npm-latest peer dep already accepts the target
|
||||
runtime; nothing to do
|
||||
- waiting : main has a fix (ABI 15 or relaxed peer) but no
|
||||
published npm release yet
|
||||
- blocked : peer dep too tight on both npm and main
|
||||
|
||||
Independently of bucket, `bump_now` reports whether reviewers can
|
||||
move the pin forward today without touching the runtime — we only
|
||||
suggest it when npm-latest's peer dep also accepts our *current*
|
||||
runtime, otherwise the bump would break `npm install`.
|
||||
"""
|
||||
is_vendored = is_vendored_pin(pinned_spec)
|
||||
behind_latest = (
|
||||
not is_vendored
|
||||
and npm_version != "?"
|
||||
and not range_includes(pinned_spec, npm_version)
|
||||
)
|
||||
# Intentional pins must never appear as actionable bumps — by definition
|
||||
# we're holding them back on purpose. The pin can only be lifted by
|
||||
# editing INTENTIONAL_PINS and package.json together.
|
||||
bump_now = behind_latest and current_compat and name not in INTENTIONAL_PINS
|
||||
|
||||
if fetch_failed:
|
||||
bucket = "fetch_failed"
|
||||
elif name in INTENTIONAL_PINS:
|
||||
bucket = "intentional"
|
||||
elif target_compat:
|
||||
bucket = "ready"
|
||||
elif upstream_progress:
|
||||
bucket = "waiting"
|
||||
else:
|
||||
bucket = "blocked"
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"pinned_spec": pinned_spec or "—",
|
||||
"npm_version": npm_version,
|
||||
"peer_range": peer_range,
|
||||
"target_compat": target_compat,
|
||||
"current_compat": current_compat,
|
||||
"upstream_progress": upstream_progress,
|
||||
"behind_latest": behind_latest,
|
||||
"bump_now": bump_now,
|
||||
"bucket": bucket,
|
||||
"is_vendored": is_vendored,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
blockers: dict[str, str] = {}
|
||||
lines: list[str] = []
|
||||
|
|
@ -196,20 +400,68 @@ def main() -> int:
|
|||
current_runtime = read_current_runtime()
|
||||
current_abi_range = RUNTIME_ABI_RANGES.get(current_runtime, (0, 0))
|
||||
target_abi_range = RUNTIME_ABI_RANGES.get(TARGET_RUNTIME_MAJOR_MINOR, (0, 0))
|
||||
pinned_versions = read_pinned_grammar_versions()
|
||||
|
||||
lines.append(f"- Current runtime: `tree-sitter@{current_runtime}.x` (ABI {current_abi_range[0]}..{current_abi_range[1]})")
|
||||
lines.append(f"- Target runtime: `tree-sitter@{TARGET_RUNTIME}` (ABI {target_abi_range[0]}..{target_abi_range[1]})")
|
||||
lines.append(
|
||||
f"`tree-sitter@{current_runtime}.x` (ABI {current_abi_range[0]}–{current_abi_range[1]}) "
|
||||
f"→ target `tree-sitter@{TARGET_RUNTIME}` "
|
||||
f"(ABI {target_abi_range[0]}–{target_abi_range[1]})."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# ── Grammar peer-dep compatibility ───────────────────────────────
|
||||
lines.append(md_h("Grammar compatibility", 2))
|
||||
lines.append("| Grammar | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |")
|
||||
lines.append("|---|---|---|---|---|---|---|")
|
||||
# First pass: gather raw data + classification per grammar. We render
|
||||
# the human-friendly buckets first, then the raw matrix in a <details>
|
||||
# block at the end. Status text in the matrix is preserved verbatim
|
||||
# so the workflow's row-diff change-detection keeps working.
|
||||
grammar_rows: list[dict] = []
|
||||
raw_matrix: list[str] = [
|
||||
"| Grammar | Pinned | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |",
|
||||
"|---|---|---|---|---|---|---|---|",
|
||||
]
|
||||
|
||||
ready_count = 0
|
||||
total_count = len(GRAMMARS)
|
||||
vendored_grammars: list[dict] = []
|
||||
|
||||
for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()):
|
||||
pinned_spec = pinned_versions.get(name, "—")
|
||||
|
||||
# Vendored grammars don't have an "npm latest" we install from —
|
||||
# we ship our own copy under gitnexus/vendor/<name>. Treat them
|
||||
# as a separate kind of artefact: their readiness for the runtime
|
||||
# upgrade depends on the vendored ABI being in the target range,
|
||||
# not on a peer-dep negotiation.
|
||||
if is_vendored_pin(pinned_spec):
|
||||
v = vendored_drift_summary(name, upstream_repo, upstream_branch, parser_path)
|
||||
v["pinned_spec"] = pinned_spec
|
||||
# Three-state classification: in-range, out-of-range, or
|
||||
# not-introspectable (e.g. tree-sitter-swift ships only
|
||||
# prebuilt .node binaries, no parser.c — assume compatible).
|
||||
if v["vendored_abi"] is None:
|
||||
v["target_compat"] = True
|
||||
v["abi_state"] = "prebuilt"
|
||||
status = "Vendored (prebuilt — ABI not introspectable)"
|
||||
elif target_abi_range[0] <= v["vendored_abi"] <= target_abi_range[1]:
|
||||
v["target_compat"] = True
|
||||
v["abi_state"] = "in_range"
|
||||
status = "Vendored (ABI in target range)"
|
||||
else:
|
||||
v["target_compat"] = False
|
||||
v["abi_state"] = "out_of_range"
|
||||
status = "Vendored (ABI out of range)"
|
||||
blockers[name] = (
|
||||
f"vendored `{name}`: ABI {v['vendored_abi']} outside target range "
|
||||
f"{target_abi_range[0]}..{target_abi_range[1]}"
|
||||
)
|
||||
# Keep vendored grammars in the raw matrix so the workflow's
|
||||
# row-diff change-detection picks up status transitions on
|
||||
# them too. npm-only columns get sentinels.
|
||||
raw_matrix.append(
|
||||
f"| `{name}` | {pinned_spec} | (vendored) | (vendored) | "
|
||||
f"{'Yes' if v['target_compat'] else '**No**'} | "
|
||||
f"{v['vendored_abi'] or '?'} | {v['upstream_abi'] or '?'} | {status} |"
|
||||
)
|
||||
vendored_grammars.append(v)
|
||||
continue
|
||||
|
||||
# Fetch latest npm metadata.
|
||||
info = npm_view_json(name)
|
||||
fetch_failed = info is None
|
||||
|
|
@ -226,12 +478,14 @@ def main() -> int:
|
|||
|
||||
if fetch_failed:
|
||||
peer_display = "? (fetch failed)"
|
||||
compatible = False
|
||||
target_compat = False
|
||||
current_compat = False
|
||||
else:
|
||||
peer_display = peer_range or "none"
|
||||
if peer_range and not peer_optional:
|
||||
peer_display += " (required)"
|
||||
compatible = satisfies_target(peer_range, TARGET_RUNTIME)
|
||||
target_compat = satisfies_target(peer_range, TARGET_RUNTIME)
|
||||
current_compat = satisfies_target(peer_range, f"{current_runtime}.0")
|
||||
|
||||
# Check installed ABI using the same parser_path from GRAMMARS.
|
||||
installed_parser = GITNEXUS_DIR / "node_modules" / name / parser_path
|
||||
|
|
@ -250,22 +504,40 @@ def main() -> int:
|
|||
upstream_abi = extract_abi_from_text(upstream_text) if upstream_text else None
|
||||
upstream_abi_display = str(upstream_abi) if upstream_abi else "?"
|
||||
|
||||
# Determine status.
|
||||
# Status text + upstream-progress detection. The Status column
|
||||
# values are preserved as-is to keep the workflow's row-diff
|
||||
# change-detection working on the raw matrix below.
|
||||
upstream_progress: str | None = None
|
||||
if fetch_failed:
|
||||
status = "Unknown (fetch failed)"
|
||||
blockers[name] = f"`{name}`: npm registry fetch failed — could not verify peer dep"
|
||||
elif compatible:
|
||||
elif name in INTENTIONAL_PINS:
|
||||
# An intentional pin is, by definition, a held-back grammar:
|
||||
# whatever npm-latest's peer dep says, our shipped version is
|
||||
# the one whose ABI/peer must accept the target runtime, and
|
||||
# the pin entry exists precisely because it does not. Treat
|
||||
# it as a blocker until the pin is lifted (entry removed from
|
||||
# INTENTIONAL_PINS), at which point this grammar falls back
|
||||
# to standard classification on the next run.
|
||||
status = "Intentionally pinned"
|
||||
blockers[name] = (
|
||||
f"`{name}` intentionally pinned at `{pinned_spec}` "
|
||||
f"({INTENTIONAL_PINS[name]}) — pin must be lifted "
|
||||
f"before the {TARGET_RUNTIME} runtime upgrade"
|
||||
)
|
||||
elif target_compat:
|
||||
status = "Ready"
|
||||
ready_count += 1
|
||||
elif upstream_abi and upstream_abi >= 15:
|
||||
status = "Unreleased (ABI 15 on main)"
|
||||
upstream_progress = f"ABI 15 on `{upstream_repo}@{upstream_branch}` not yet published"
|
||||
blockers[name] = f"`{name}`: ABI 15 on `{upstream_repo}` main but not published to npm"
|
||||
else:
|
||||
status = "Blocking"
|
||||
blockers[name] = f"`{name}@{npm_version}`: peer `{peer_display}` incompatible with 0.25"
|
||||
|
||||
# Also check upstream package.json for relaxed peer dep.
|
||||
if not compatible and not fetch_failed:
|
||||
# Also check upstream package.json for relaxed peer dep — beats
|
||||
# the ABI-15 hint when both are true.
|
||||
if not target_compat and not fetch_failed:
|
||||
upstream_pkg_url = (
|
||||
f"https://raw.githubusercontent.com/{upstream_repo}/"
|
||||
f"{upstream_branch}/package.json"
|
||||
|
|
@ -277,82 +549,250 @@ def main() -> int:
|
|||
upstream_peer = (upstream_pkg.get("peerDependencies") or {}).get("tree-sitter")
|
||||
if upstream_peer and satisfies_target(upstream_peer, TARGET_RUNTIME):
|
||||
status = "Unreleased (peer relaxed on main)"
|
||||
upstream_progress = (
|
||||
f"peer relaxed to `{upstream_peer}` on "
|
||||
f"`{upstream_repo}@{upstream_branch}` not yet published"
|
||||
)
|
||||
blockers[name] = f"`{name}`: peer dep relaxed on `{upstream_repo}` main but not published to npm"
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
compat_icon = "Yes" if compatible else "**No**"
|
||||
lines.append(
|
||||
f"| `{name}` | {npm_version} | {peer_display} | {compat_icon} | {abi_display} | {upstream_abi_display} | {status} |"
|
||||
pinned_spec = pinned_versions.get(name, "—")
|
||||
compat_icon = "Yes" if target_compat else "**No**"
|
||||
raw_matrix.append(
|
||||
f"| `{name}` | {pinned_spec} | {npm_version} | {peer_display} | "
|
||||
f"{compat_icon} | {abi_display} | {upstream_abi_display} | {status} |"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"**{ready_count}/{total_count}** grammars ready for `tree-sitter@{TARGET_RUNTIME}`.")
|
||||
lines.append("")
|
||||
grammar_rows.append(_classify_grammar(
|
||||
name=name,
|
||||
pinned_spec=pinned_spec,
|
||||
npm_version=npm_version,
|
||||
peer_range=peer_range,
|
||||
fetch_failed=fetch_failed,
|
||||
target_compat=target_compat,
|
||||
current_compat=current_compat,
|
||||
upstream_progress=upstream_progress,
|
||||
))
|
||||
|
||||
# ── Vendored proto drift ─────────────────────────────────────────
|
||||
lines.append(md_h("Vendored tree-sitter-proto", 2))
|
||||
vendored_abi = extract_language_version(VENDOR_PROTO_DIR / "src" / "parser.c")
|
||||
# ── Bucketize ────────────────────────────────────────────────────
|
||||
by_bucket: dict[str, list[dict]] = {
|
||||
k: [] for k in ("ready", "intentional", "waiting", "blocked", "fetch_failed")
|
||||
}
|
||||
for row in grammar_rows:
|
||||
by_bucket[row["bucket"]].append(row)
|
||||
bump_now = [r for r in grammar_rows if r["bump_now"]]
|
||||
ready_count = len(by_bucket["ready"])
|
||||
|
||||
upstream_proto_url = (
|
||||
f"https://raw.githubusercontent.com/{UPSTREAM_PROTO_OWNER}/"
|
||||
f"{UPSTREAM_PROTO_REPO}/{UPSTREAM_PROTO_BRANCH}/src/parser.c"
|
||||
)
|
||||
upstream_proto_text = fetch_text(upstream_proto_url)
|
||||
upstream_proto_abi = extract_abi_from_text(upstream_proto_text) if upstream_proto_text else None
|
||||
# ── TL;DR ────────────────────────────────────────────────────────
|
||||
npm_count = len(grammar_rows)
|
||||
vendored_count = len(vendored_grammars)
|
||||
vendored_ready = sum(1 for v in vendored_grammars if v["target_compat"])
|
||||
|
||||
sha_url = (
|
||||
f"https://api.github.com/repos/{UPSTREAM_PROTO_OWNER}/"
|
||||
f"{UPSTREAM_PROTO_REPO}/commits/{UPSTREAM_PROTO_BRANCH}"
|
||||
)
|
||||
sha_text = fetch_text(sha_url)
|
||||
upstream_sha = "?"
|
||||
if sha_text:
|
||||
try:
|
||||
upstream_sha = json.loads(sha_text).get("sha", "?")[:12]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
local_proto_path = VENDOR_PROTO_DIR / "src" / "parser.c"
|
||||
local_proto_text = local_proto_path.read_text(encoding="utf-8", errors="ignore") if local_proto_path.is_file() else ""
|
||||
in_sync = bool(
|
||||
upstream_proto_text
|
||||
and local_proto_text.replace("\r\n", "\n")
|
||||
== upstream_proto_text.replace("\r\n", "\n")
|
||||
)
|
||||
|
||||
lines.append(f"- Upstream: `{UPSTREAM_PROTO_OWNER}/{UPSTREAM_PROTO_REPO}@{UPSTREAM_PROTO_BRANCH}` (HEAD `{upstream_sha}`)")
|
||||
lines.append(f"- Upstream ABI: **{upstream_proto_abi}**")
|
||||
lines.append(f"- Vendored ABI: **{vendored_abi}**")
|
||||
lines.append(f"- In sync: {'yes' if in_sync else 'no — upstream has diverged'}")
|
||||
|
||||
if upstream_proto_abi and vendored_abi and upstream_proto_abi > vendored_abi:
|
||||
can_upgrade = upstream_proto_abi <= target_abi_range[1]
|
||||
lines.append(f"- Upstream ABI {upstream_proto_abi} {'is' if can_upgrade else 'is NOT'} within target runtime range ({target_abi_range[0]}..{target_abi_range[1]})")
|
||||
if can_upgrade:
|
||||
lines.append(f"- **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, regenerate vendored parser.c from upstream `{upstream_sha}`")
|
||||
else:
|
||||
lines.append(f"- **Action:** wait for runtime upgrade beyond {TARGET_RUNTIME} that supports ABI {upstream_proto_abi}")
|
||||
blockers["vendored-proto-abi"] = f"vendored tree-sitter-proto: upstream ABI {upstream_proto_abi} outside target range"
|
||||
elif not in_sync:
|
||||
lines.append("- **Action:** review upstream changes; vendored copy may need updating")
|
||||
blockers["vendored-proto-sync"] = "vendored tree-sitter-proto: out of sync with upstream"
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────
|
||||
lines.append("")
|
||||
lines.append(md_h("Summary", 2))
|
||||
if blockers:
|
||||
lines.append(f"**{len(blockers)} blocker(s) remaining:**\n")
|
||||
for b in blockers.values():
|
||||
lines.append(f"- {b}")
|
||||
lines.append("")
|
||||
lines.append("Upgrade to `tree-sitter@0.25` is **blocked**.")
|
||||
if not blockers:
|
||||
verdict = "**Ready** — all grammars are 0.25-compatible. The runtime upgrade can proceed."
|
||||
else:
|
||||
lines.append("All grammars are compatible. Upgrade to `tree-sitter@0.25` is **ready**.")
|
||||
moved = "no" if not by_bucket["waiting"] else f"yes — {len(by_bucket['waiting'])} grammars have unreleased fixes on main"
|
||||
verdict = (
|
||||
f"**Blocked** — {len(blockers)} grammars are not yet 0.25-compatible. "
|
||||
f"Upstream movement: {moved}."
|
||||
)
|
||||
|
||||
lines.append(md_h("TL;DR", 2))
|
||||
lines.append(verdict)
|
||||
lines.append("")
|
||||
lines.append(f"- {ready_count}/{npm_count} npm-installed grammars already accept tree-sitter@{TARGET_RUNTIME}")
|
||||
if vendored_count:
|
||||
lines.append(
|
||||
f"- {vendored_ready}/{vendored_count} vendored grammars at an ABI within the target runtime range"
|
||||
)
|
||||
lines.append(f"- {len(by_bucket['intentional'])} intentionally pinned (see below)")
|
||||
lines.append(f"- {len(by_bucket['waiting'])} waiting on an upstream npm release")
|
||||
lines.append(f"- {len(by_bucket['blocked'])} blocked on upstream (no fix even on main)")
|
||||
if by_bucket['fetch_failed']:
|
||||
lines.append(f"- {len(by_bucket['fetch_failed'])} could not be checked (npm registry unreachable)")
|
||||
if bump_now:
|
||||
lines.append(
|
||||
f"- **{len(bump_now)} bump candidate(s) you can take TODAY** (npm-latest "
|
||||
f"is newer than the pin AND its peer dep accepts our current runtime)"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# ── What you can do today ───────────────────────────────────────
|
||||
if bump_now:
|
||||
lines.append(md_h("What you can do today", 2))
|
||||
lines.append(
|
||||
"These pins lag npm latest and the latest version's peer dep already "
|
||||
"accepts our current `tree-sitter@" + current_runtime + ".x` runtime. "
|
||||
"Bumping is independent of the 0.25 upgrade and should be a quick PR."
|
||||
)
|
||||
lines.append("")
|
||||
for r in sorted(bump_now, key=lambda r: r["name"]):
|
||||
lines.append(
|
||||
f"- `{r['name']}`: `{r['pinned_spec']}` → `{r['npm_version']}` "
|
||||
f"(peer `{r['peer_range'] or 'none'}`)"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# ── Per-disposition sections ────────────────────────────────────
|
||||
def _emit_bucket(title: str, body_intro: str, rows: list[dict], render) -> None:
|
||||
if not rows:
|
||||
return
|
||||
lines.append(md_h(f"{title} ({len(rows)})", 3))
|
||||
lines.append(body_intro)
|
||||
lines.append("")
|
||||
for r in sorted(rows, key=lambda r: r["name"]):
|
||||
lines.append(render(r))
|
||||
lines.append("")
|
||||
|
||||
lines.append(md_h("Disposition", 2))
|
||||
|
||||
_emit_bucket(
|
||||
"Ready for 0.25",
|
||||
"These grammars' npm-latest peer dep already accepts the target runtime. No action needed for the upgrade.",
|
||||
by_bucket["ready"],
|
||||
lambda r: (
|
||||
f"- `{r['name']}` — pinned `{r['pinned_spec']}`, npm latest `{r['npm_version']}`"
|
||||
+ (" _(also a bump candidate — see above)_" if r["bump_now"] else "")
|
||||
),
|
||||
)
|
||||
|
||||
if by_bucket["intentional"]:
|
||||
lines.append(md_h(f"Intentionally pinned ({len(by_bucket['intentional'])})", 3))
|
||||
lines.append(
|
||||
"Deliberately held below npm latest. These are **not** drift — each entry "
|
||||
"lists the issue motivating the pin and the condition for unpinning."
|
||||
)
|
||||
lines.append("")
|
||||
for r in sorted(by_bucket["intentional"], key=lambda r: r["name"]):
|
||||
reason = INTENTIONAL_PINS.get(r["name"], "(no rationale recorded)")
|
||||
lines.append(
|
||||
f"- `{r['name']}` pinned at `{r['pinned_spec']}` "
|
||||
f"(npm latest `{r['npm_version']}`)\n {reason}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
_emit_bucket(
|
||||
"Waiting on upstream npm release",
|
||||
"Fixes are merged on the upstream main branch but not yet published to npm. "
|
||||
"We can move forward as soon as upstream cuts a release.",
|
||||
by_bucket["waiting"],
|
||||
lambda r: (
|
||||
f"- `{r['name']}@{r['npm_version']}` — peer `{r['peer_range'] or 'none'}`. "
|
||||
f"_{r['upstream_progress']}_"
|
||||
),
|
||||
)
|
||||
|
||||
_emit_bucket(
|
||||
"Blocked on upstream",
|
||||
"Peer dep is too tight on both the latest npm release and on upstream main. "
|
||||
"These need an upstream issue/PR before we can proceed.",
|
||||
by_bucket["blocked"],
|
||||
lambda r: (
|
||||
f"- `{r['name']}@{r['npm_version']}` — peer `{r['peer_range'] or 'none'}`"
|
||||
+ (" _(vendored)_" if r["is_vendored"] else "")
|
||||
),
|
||||
)
|
||||
|
||||
_emit_bucket(
|
||||
"Could not check",
|
||||
"npm registry fetch failed for these grammars. Re-run the workflow to retry.",
|
||||
by_bucket["fetch_failed"],
|
||||
lambda r: f"- `{r['name']}` (pinned `{r['pinned_spec']}`)",
|
||||
)
|
||||
|
||||
# ── Vendored parsers ────────────────────────────────────────────
|
||||
if vendored_grammars:
|
||||
lines.append(md_h(f"Vendored parsers ({len(vendored_grammars)})", 2))
|
||||
lines.append(
|
||||
"These grammars ship from `gitnexus/vendor/` rather than the npm "
|
||||
"registry. Their compatibility is governed by the **vendored "
|
||||
"ABI** (must lie in the target runtime's range), not by a peer-"
|
||||
"dep negotiation. The rationale for each vendored copy lives in "
|
||||
"its own `package.json` `_vendoredBy` field."
|
||||
)
|
||||
lines.append("")
|
||||
for v in sorted(vendored_grammars, key=lambda v: v["name"]):
|
||||
sync_label = (
|
||||
"in sync with upstream" if v["in_sync"] else "diverged from upstream"
|
||||
)
|
||||
if v["abi_state"] == "in_range":
|
||||
abi_label = f"ABI `{v['vendored_abi']}` (in target range)"
|
||||
elif v["abi_state"] == "prebuilt":
|
||||
abi_label = "ABI `prebuilt` (binary-only vendor, source not introspectable)"
|
||||
else:
|
||||
abi_label = (
|
||||
f"ABI `{v['vendored_abi']}` (**outside** target range "
|
||||
f"{target_abi_range[0]}..{target_abi_range[1]})"
|
||||
)
|
||||
upstream_abi_str = (
|
||||
f"ABI `{v['upstream_abi']}`" if v["upstream_abi"] else "ABI `?`"
|
||||
)
|
||||
lines.append(
|
||||
f"- **`{v['name']}`** `{v['vendored_version']}` — {abi_label}, "
|
||||
f"upstream `{v['upstream_repo']}@{v['upstream_sha']}` "
|
||||
f"{upstream_abi_str} · {sync_label}"
|
||||
)
|
||||
if v["vendored_by"]:
|
||||
# Show the first sentence — vendor package.json fields tend
|
||||
# to start with the rationale and tail off into install-
|
||||
# script breadcrumbs that aren't useful in this report.
|
||||
rationale = _first_sentence(v["vendored_by"])
|
||||
lines.append(f" - **Why vendored:** {rationale}")
|
||||
# Action computation: needs regen iff upstream ABI exceeds
|
||||
# vendored AND is still within target range. If upstream ABI
|
||||
# exceeds the target, that's a runtime-side blocker. For
|
||||
# prebuilt-only vendors we can't drive this from source ABI;
|
||||
# the action is a manual upstream-binary refresh, surfaced
|
||||
# via the in-sync flag instead.
|
||||
if v["abi_state"] == "prebuilt":
|
||||
if not v["in_sync"]:
|
||||
lines.append(
|
||||
" - **Action:** check whether upstream has shipped a new "
|
||||
"prebuilt release; this vendor ships binary-only artefacts."
|
||||
)
|
||||
elif v["upstream_abi"] and v["vendored_abi"] and v["upstream_abi"] > v["vendored_abi"]:
|
||||
if v["upstream_abi"] <= target_abi_range[1]:
|
||||
lines.append(
|
||||
f" - **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, "
|
||||
f"regenerate `parser.c` from upstream `{v['upstream_sha']}`."
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f" - **Action:** wait for a runtime supporting ABI "
|
||||
f"{v['upstream_abi']}; current target ({TARGET_RUNTIME}) only "
|
||||
f"goes up to ABI {target_abi_range[1]}."
|
||||
)
|
||||
blockers[f"vendored-{v['name']}-abi"] = (
|
||||
f"vendored {v['name']}: upstream ABI {v['upstream_abi']} outside target range"
|
||||
)
|
||||
elif not v["in_sync"]:
|
||||
lines.append(
|
||||
" - **Action:** review upstream changes; vendored copy may "
|
||||
"need a refresh (no ABI bump required)."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# ── Raw matrix (for completeness + workflow row-diff) ────────────
|
||||
lines.append(md_h("Full grammar matrix", 2))
|
||||
lines.append(
|
||||
"<details><summary>Click to expand the raw per-grammar table "
|
||||
"(used by the workflow's change-detection bot).</summary>\n"
|
||||
)
|
||||
lines.extend(raw_matrix)
|
||||
lines.append("\n</details>")
|
||||
lines.append("")
|
||||
|
||||
print("\n".join(lines))
|
||||
return 1 if blockers else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Force UTF-8 output: the report contains em-dashes and arrows that
|
||||
# Windows' default cp1252 codepage can't encode, while Linux runners
|
||||
# default to UTF-8 anyway.
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(main())
|
||||
|
|
|
|||
14
.github/workflows/ci-e2e.yml
vendored
14
.github/workflows/ci-e2e.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
|
|
@ -1,9 +1,6 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
|
|
@ -15,13 +12,12 @@ on:
|
|||
# called-workflow context `github.workflow` evaluation is ambiguous across GitHub
|
||||
# Actions versions, and a prefix that could resolve to the caller's name would
|
||||
# share a concurrency group with the caller → deadlock. A literal prefix is
|
||||
# immune. Direct `push`/`pull_request` invocations use `CI-<ref>`; invocations
|
||||
# from a reusable-workflow caller fall into a per-run-unique group that never
|
||||
# serializes with the caller.
|
||||
# cancel-in-progress is event-aware: cancel superseded PR runs, queue every other
|
||||
# event (push to main, workflow_call from publish.yml, etc.).
|
||||
# immune. Direct `pull_request` invocations use `CI-<ref>`; invocations from a
|
||||
# reusable-workflow caller fall into a per-run-unique group that never serializes
|
||||
# with the caller. `push` to main is handled by release-candidate.yml, which
|
||||
# calls this workflow once before publishing.
|
||||
concurrency:
|
||||
group: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && format('CI-{0}', github.ref) || format('CI-nested-{0}', github.run_id) }}
|
||||
group: ${{ github.event_name == 'pull_request' && format('CI-{0}', github.ref) || format('CI-nested-{0}', github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# ── Reusable workflow orchestration ─────────────────────────────────
|
||||
|
|
|
|||
96
.github/workflows/claude-code-review.yml
vendored
96
.github/workflows/claude-code-review.yml
vendored
|
|
@ -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 }}'
|
||||
77
.github/workflows/claude.yml
vendored
77
.github/workflows/claude.yml
vendored
|
|
@ -1,8 +1,17 @@
|
|||
name: Claude Code
|
||||
|
||||
# Label-triggered code-review requests use pull_request_target so the workflow
|
||||
# runs as defined on the default branch, which allows access to secrets for
|
||||
# posting review comments on fork PRs. SECURITY: PR checkouts pin the fork's
|
||||
# HEAD SHA (not the branch name) to prevent TOCTOU races.
|
||||
# The claude-code-action sandboxes execution; it does not run arbitrary code
|
||||
# from the checked-out source.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
|
|
@ -21,7 +30,10 @@ jobs:
|
|||
if: |
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
(
|
||||
contains(github.event.comment.body, '@claude') ||
|
||||
(github.event.issue.pull_request && contains(github.event.comment.body, '/review'))
|
||||
) &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')
|
||||
|
|
@ -46,6 +58,14 @@ jobs:
|
|||
(github.event.issue.author_association == 'OWNER' ||
|
||||
github.event.issue.author_association == 'MEMBER' ||
|
||||
github.event.issue.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.label.name == 'claude-review' &&
|
||||
github.event.pull_request.draft == false &&
|
||||
(github.event.pull_request.author_association == 'OWNER' ||
|
||||
github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'COLLABORATOR')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
|
@ -63,33 +83,48 @@ jobs:
|
|||
with:
|
||||
script: |
|
||||
// Determine if this event is PR-related
|
||||
let prNumber = null;
|
||||
let pr = null;
|
||||
if (context.eventName === 'issue_comment' && context.payload.issue.pull_request) {
|
||||
prNumber = context.payload.issue.number;
|
||||
const resp = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.issue.number,
|
||||
});
|
||||
pr = resp.data;
|
||||
} else if (context.eventName === 'pull_request_review_comment') {
|
||||
prNumber = context.payload.pull_request.number;
|
||||
pr = context.payload.pull_request;
|
||||
} else if (context.eventName === 'pull_request_review') {
|
||||
prNumber = context.payload.pull_request.number;
|
||||
pr = context.payload.pull_request;
|
||||
} else if (context.eventName === 'pull_request_target') {
|
||||
pr = context.payload.pull_request;
|
||||
}
|
||||
|
||||
if (!prNumber) {
|
||||
if (!pr) {
|
||||
core.setOutput('is_pr', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
const pr = resp.data;
|
||||
|
||||
core.setOutput('is_pr', 'true');
|
||||
core.setOutput('number', String(prNumber));
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('sha', pr.head.sha);
|
||||
core.setOutput('repo', pr.head.repo.full_name);
|
||||
core.setOutput('branch', pr.head.ref);
|
||||
|
||||
- name: Resolve Claude mode
|
||||
id: mode
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
|
||||
with:
|
||||
script: |
|
||||
const body = (context.payload.comment?.body ?? '').toLowerCase();
|
||||
const isCodeReview =
|
||||
(context.eventName === 'pull_request_target' &&
|
||||
context.payload.label?.name === 'claude-review') ||
|
||||
(context.eventName === 'issue_comment' &&
|
||||
Boolean(context.payload.issue?.pull_request) &&
|
||||
body.includes('/review'));
|
||||
|
||||
core.setOutput('code_review', isCodeReview ? 'true' : 'false');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
|
|
@ -98,6 +133,7 @@ jobs:
|
|||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
if: steps.mode.outputs.code_review != 'true'
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
|
||||
with:
|
||||
|
|
@ -109,3 +145,16 @@ jobs:
|
|||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
- name: Run Claude Code Review
|
||||
if: steps.mode.outputs.code_review == 'true'
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: '*'
|
||||
show_full_output: true
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -107,3 +107,4 @@ local_docs/
|
|||
.tmp/
|
||||
.agents/
|
||||
.context/
|
||||
gitnexus/web/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -740,10 +740,58 @@ function findExportByName(
|
|||
defs: readonly SymbolDefinition[],
|
||||
name: string,
|
||||
): SymbolDefinition | undefined {
|
||||
// GENERIC RULE (applies to every language using this finalize
|
||||
// algorithm): when MULTIPLE `SymbolDefinition`s share the same simple
|
||||
// name in `localDefs`, prefer callable / type-like defs over plain
|
||||
// value defs (`Variable`, `Property`, …). The CALLER side of an
|
||||
// import almost always wants the callable, not a value shadow that
|
||||
// happens to share the name — and without a deterministic
|
||||
// preference, capture order silently decides which def the import
|
||||
// binds to.
|
||||
//
|
||||
// The single-def case is unchanged: when only one def has the name,
|
||||
// it's returned regardless of its type (the `fallback` path below).
|
||||
//
|
||||
// TypeScript is the first known language where this matters in
|
||||
// practice: `const fn = () => {}` emits BOTH a `Function` def (from
|
||||
// `@declaration.function` on the inner arrow) AND a `Variable` def
|
||||
// (from the generic `@declaration.variable` pattern matching the
|
||||
// wrapping `lexical_declaration`), and consumers of `import { fn }`
|
||||
// need to bind to the callable. Other migrated languages don't
|
||||
// currently produce dual emits of this shape, so the rule is a no-op
|
||||
// for them today; future languages get the same correctness
|
||||
// guarantee for free if they ever do.
|
||||
//
|
||||
// See `gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts`
|
||||
// for the cross-file regression this rule prevents.
|
||||
let fallback: SymbolDefinition | undefined;
|
||||
for (const d of defs) {
|
||||
if (deriveSimpleName(d) === name) return d;
|
||||
if (deriveSimpleName(d) !== name) continue;
|
||||
if (isCallableOrTypeLike(d.type)) return d;
|
||||
if (fallback === undefined) fallback = d;
|
||||
}
|
||||
return undefined;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const CALLABLE_OR_TYPE_LIKE: ReadonlySet<string> = new Set([
|
||||
'Function',
|
||||
'Method',
|
||||
'Constructor',
|
||||
'Class',
|
||||
'Interface',
|
||||
'Enum',
|
||||
'Struct',
|
||||
'Record',
|
||||
'Trait',
|
||||
'Namespace',
|
||||
'Module',
|
||||
'TypeAlias',
|
||||
'Type',
|
||||
'Typedef',
|
||||
]);
|
||||
|
||||
function isCallableOrTypeLike(type: string): boolean {
|
||||
return CALLABLE_OR_TYPE_LIKE.has(type);
|
||||
}
|
||||
|
||||
function countEdgesWithin(edgeIndex: Map<string, ImportEdgeDraft[]>, files: Set<string>): number {
|
||||
|
|
|
|||
132
gitnexus-web/package-lock.json
generated
132
gitnexus-web/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -42,15 +42,15 @@
|
|||
"mermaid": "^11.14.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.2",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -60,9 +60,9 @@
|
|||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ const DEFAULT_BOTTOM_THRESHOLD = 100;
|
|||
const USER_SCROLL_EPSILON = 5;
|
||||
|
||||
export interface UseAutoScrollResult {
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement>;
|
||||
messagesContainerRef: React.RefObject<HTMLDivElement>;
|
||||
scrollContainerRef: React.Ref<HTMLDivElement>;
|
||||
messagesContainerRef: React.Ref<HTMLDivElement>;
|
||||
isAtBottom: boolean;
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ interface UseSigmaOptions {
|
|||
}
|
||||
|
||||
interface UseSigmaReturn {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
containerRef: React.Ref<HTMLDivElement>;
|
||||
sigmaRef: React.RefObject<Sigma | null>;
|
||||
setGraph: (graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>) => void;
|
||||
zoomIn: () => void;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
188
gitnexus/package-lock.json
generated
188
gitnexus/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,14 @@ import { createRequire } from 'node:module';
|
|||
|
||||
const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
|
||||
|
||||
function parseLbugMaxDbSize(raw) {
|
||||
const parsed = raw ? Number(raw) : NaN;
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`Invalid LadybugDB max DB size for extension installer: ${raw ?? '<missing>'}`);
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
async function installDuckDbExtension(extensionName) {
|
||||
if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) {
|
||||
throw new Error(`Invalid DuckDB extension name: ${extensionName ?? '<missing>'}`);
|
||||
|
|
@ -14,6 +22,9 @@ async function installDuckDbExtension(extensionName) {
|
|||
const require = createRequire(import.meta.url);
|
||||
const lbugModule = require('@ladybugdb/core');
|
||||
const lbug = lbugModule.default ?? lbugModule;
|
||||
const lbugMaxDbSize = parseLbugMaxDbSize(
|
||||
process.argv[3] ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE,
|
||||
);
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ext-install-'));
|
||||
const dbPath = path.join(tmpDir, 'install.lbug');
|
||||
|
|
@ -21,7 +32,7 @@ async function installDuckDbExtension(extensionName) {
|
|||
let conn;
|
||||
|
||||
try {
|
||||
db = new lbug.Database(dbPath);
|
||||
db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize);
|
||||
conn = new lbug.Connection(db);
|
||||
await conn.query(`INSTALL ${extensionName}`);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import fs from 'fs/promises';
|
|||
import {
|
||||
getStoragePaths,
|
||||
loadMeta,
|
||||
addToGitignore,
|
||||
ensureGitNexusIgnored,
|
||||
registerRepo,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { getGitRoot, getRemoteUrl, isGitRepo } from '../storage/git.js';
|
||||
|
|
@ -115,7 +115,7 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt
|
|||
meta.remoteUrl = getRemoteUrl(repoPath);
|
||||
}
|
||||
await registerRepo(repoPath, meta);
|
||||
await addToGitignore(repoPath);
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
|
||||
const projectName = path.basename(repoPath);
|
||||
const { stats } = meta;
|
||||
|
|
|
|||
|
|
@ -32,7 +32,10 @@ program
|
|||
.option('--skills', 'Generate repo-specific skill files from detected communities')
|
||||
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
|
||||
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
|
||||
.option('--skip-git', 'Index a folder without requiring a .git directory')
|
||||
.option(
|
||||
'--skip-git',
|
||||
'Treat the provided path/cwd as the index root and skip parent git-root discovery',
|
||||
)
|
||||
.option(
|
||||
'--name <alias>',
|
||||
'Register this repo under a custom name in ~/.gitnexus/registry.json ' +
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
62
gitnexus/src/core/embeddings/hf-env.ts
Normal file
62
gitnexus/src/core/embeddings/hf-env.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* @internal Exported only for unit tests and the two embedder entry points
|
||||
* (`core/embeddings/embedder.ts` + `mcp/core/embedder.ts`). Not part of the
|
||||
* public package API.
|
||||
*
|
||||
* Minimal subset of `@huggingface/transformers`' `env` object that gitnexus
|
||||
* mutates. Defining a local structural type keeps this helper free of a
|
||||
* transitive dependency on transformers' generated `.d.ts` while still
|
||||
* giving full type-checking on the two fields we actually touch.
|
||||
*/
|
||||
export interface HfEnvSubset {
|
||||
cacheDir: string;
|
||||
remoteHost: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Exported only for unit tests and the two embedder entry points
|
||||
* (`core/embeddings/embedder.ts` + `mcp/core/embedder.ts`). Not part of the
|
||||
* public package API.
|
||||
*
|
||||
* Apply user-controlled HuggingFace environment overrides to the
|
||||
* `@huggingface/transformers` `env` object. Centralises the two env-var
|
||||
* bridges so every gitnexus embedder entry point (the analyze pipeline
|
||||
* and the MCP server) behaves identically.
|
||||
*
|
||||
* - **`HF_HOME`** → `env.cacheDir` (default: `~/.cache/huggingface`).
|
||||
* transformers.js otherwise defaults to `./node_modules/.cache` inside
|
||||
* its own install dir, which is unwritable when gitnexus is installed
|
||||
* globally (e.g. `/usr/lib/node_modules/`).
|
||||
*
|
||||
* - **`HF_ENDPOINT`** → `env.remoteHost` (#1205). transformers.js does
|
||||
* not read `HF_ENDPOINT` on its own — it reads `env.remoteHost` —
|
||||
* even though `HF_ENDPOINT` is the standard env var the upstream
|
||||
* `huggingface_hub` Python client and the official HF mirror docs
|
||||
* tell users to set. Bridging the two unblocks `--embeddings` for
|
||||
* users behind networks where `huggingface.co` is unreachable
|
||||
* (corporate proxies, the GFW, air-gapped mirrors). The trailing
|
||||
* slash is normalised because transformers.js builds URLs by string
|
||||
* concatenation and a missing slash silently falls through to its
|
||||
* default `huggingface.co/...` host.
|
||||
*
|
||||
* Mutation rather than return-and-apply because callers already hold a
|
||||
* reference to the live `env` object imported from
|
||||
* `@huggingface/transformers` — passing the same reference in keeps the
|
||||
* call site a single line at each entry point.
|
||||
*/
|
||||
export function applyHfEnvOverrides(env: HfEnvSubset): void {
|
||||
env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface');
|
||||
// `.trim()` guards against the common copy-paste failure mode of
|
||||
// `HF_ENDPOINT=" https://hf-mirror.com "` (leading/trailing whitespace
|
||||
// from shell scripts or docs) — without it, a whitespace-only value
|
||||
// would be truthy and produce an invalid `env.remoteHost = ' /'` that
|
||||
// silently misroutes downloads. Empty string remains falsy in JS so the
|
||||
// truthy guard already handles the unset/empty cases.
|
||||
const endpoint = process.env.HF_ENDPOINT?.trim();
|
||||
if (endpoint) {
|
||||
env.remoteHost = endpoint.endsWith('/') ? endpoint : endpoint + '/';
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'path';
|
||||
import { readRegistry, type RegistryEntry, type CwdMatch } from '../storage/repo-manager.js';
|
||||
import { getGitRoot, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
|
||||
import { findGitRootByDotGit, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
|
||||
|
||||
export interface StalenessInfo {
|
||||
isStale: boolean;
|
||||
|
|
@ -101,9 +101,10 @@ export async function checkCwdMatch(cwd: string): Promise<CwdMatch> {
|
|||
}
|
||||
if (bestPath) return { match: 'path', entry: bestPath };
|
||||
|
||||
// 2) Sibling-by-remote: locate the cwd's git root, get its remote
|
||||
// URL, and look for any registered entry with the same fingerprint.
|
||||
const cwdGitRoot = getGitRoot(cwdResolved);
|
||||
// 2) Sibling-by-remote: locate the cwd's git root using only ancestor
|
||||
// `.git` checks before shelling out. This keeps MCP startup from
|
||||
// running git in an unrelated launch cwd such as $HOME (#1138).
|
||||
const cwdGitRoot = findGitRootByDotGit(cwdResolved);
|
||||
if (!cwdGitRoot) return { match: 'none' };
|
||||
|
||||
const cwdRemote = getRemoteUrl(cwdGitRoot);
|
||||
|
|
|
|||
|
|
@ -5,8 +5,42 @@ import lbug from '@ladybugdb/core';
|
|||
import type { LbugValue } from '@ladybugdb/core';
|
||||
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
|
||||
import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
|
||||
import {
|
||||
closeLbugConnection,
|
||||
openLbugConnection,
|
||||
type LbugConnectionHandle,
|
||||
} from '../lbug/lbug-config.js';
|
||||
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
|
||||
|
||||
/**
|
||||
* Sidecar files that LadybugDB creates next to a `bridge.lbug` file.
|
||||
*
|
||||
* - `.wal` — write-ahead log; persists across opens but must be associated
|
||||
* with the same database instance (LadybugDB 0.16.0 enforces this via a
|
||||
* database-id check and rejects opens with the diagnostic
|
||||
* `"Database ID for temporary file 'X.wal' does not match the current
|
||||
* database. This file may have been left behind from a previous database
|
||||
* with the same name"`).
|
||||
* - `.shadow` — non-blocking concurrent checkpoint sidecar (added in
|
||||
* LadybugDB 0.15.4); same pairing constraint as `.wal`.
|
||||
*
|
||||
* `bridge-db` writes to a `bridge.lbug.tmp` file and then atomically renames
|
||||
* it into place. The rename only moves the main file; sidecars must be
|
||||
* cleaned up explicitly or the next writer trips the database-id check.
|
||||
*/
|
||||
const LBUG_SIDECAR_SUFFIXES = ['.wal', '.shadow'] as const;
|
||||
|
||||
async function removeLbugFile(basePath: string): Promise<void> {
|
||||
const candidates = [basePath, ...LBUG_SIDECAR_SUFFIXES.map((s) => `${basePath}${s}`)];
|
||||
for (const f of candidates) {
|
||||
try {
|
||||
await fsp.rm(f, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best-effort: caller will surface real errors via the open path */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function contractNodeId(
|
||||
repo: string,
|
||||
contractId: string,
|
||||
|
|
@ -127,8 +161,7 @@ export function findContractNode(
|
|||
export async function openBridgeDb(dbPath: string): Promise<BridgeHandle> {
|
||||
const parentDir = path.dirname(dbPath);
|
||||
await fsp.mkdir(parentDir, { recursive: true });
|
||||
const db = new lbug.Database(dbPath, 0, false, false); // writable
|
||||
const conn = new lbug.Connection(db);
|
||||
const { db, conn } = await openLbugConnection(lbug, dbPath);
|
||||
return { _db: db, _conn: conn, groupDir: parentDir } as BridgeHandle;
|
||||
}
|
||||
|
||||
|
|
@ -195,6 +228,17 @@ function unwrapQueryResult(queryResult: lbug.QueryResult | lbug.QueryResult[]):
|
|||
}
|
||||
|
||||
export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
|
||||
// CHECKPOINT before close so the WAL/.shadow contents are flushed into
|
||||
// the main database file. Without this, LadybugDB 0.16.0's non-blocking
|
||||
// checkpoint thread can outlive the close call and leave sidecar pages
|
||||
// pending on disk, which makes a subsequent read-side open either race
|
||||
// with the WAL replay or trip the database-id check on the sidecars.
|
||||
// CHECKPOINT is a no-op when there's nothing pending, so it's cheap.
|
||||
try {
|
||||
await (handle._conn as lbug.Connection).query('CHECKPOINT');
|
||||
} catch {
|
||||
/* ignore — older LadybugDB or schemaless DB may not accept it */
|
||||
}
|
||||
try {
|
||||
await (handle._conn as lbug.Connection).close();
|
||||
} catch {
|
||||
|
|
@ -322,12 +366,11 @@ export async function writeBridge(
|
|||
}
|
||||
};
|
||||
|
||||
// Clean up any leftover tmp
|
||||
try {
|
||||
await fsp.rm(tmpPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Clean up any leftover tmp main file AND its `.wal` / `.shadow` sidecars.
|
||||
// LadybugDB 0.16.0 rejects opening a database whose sidecars belong to a
|
||||
// different database instance (database-id check), so any stale sidecar
|
||||
// from a crashed previous run will fail the next writeBridge.
|
||||
await removeLbugFile(tmpPath);
|
||||
|
||||
// 1. Create temp DB, insert all data.
|
||||
//
|
||||
|
|
@ -497,18 +540,43 @@ export async function writeBridge(
|
|||
}
|
||||
|
||||
// 3. Atomic swap: old→.bak, tmp→final, rm .bak
|
||||
//
|
||||
// The current database file (with its `.wal` / `.shadow` sidecars) is
|
||||
// moved aside, then the freshly built tmp database takes its place.
|
||||
// We move the sidecars together with the main file so the open below
|
||||
// and any external readers see a consistent set; orphan sidecars from
|
||||
// the tmp namespace are then removed because LadybugDB looks for them
|
||||
// under the renamed-to base name and would reject mismatching IDs.
|
||||
try {
|
||||
await fsp.access(finalPath);
|
||||
await retryRename(finalPath, bakPath);
|
||||
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
|
||||
try {
|
||||
await fsp.access(`${finalPath}${suffix}`);
|
||||
await retryRename(`${finalPath}${suffix}`, `${bakPath}${suffix}`);
|
||||
} catch {
|
||||
/* sidecar absent — nothing to move */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* no existing db */
|
||||
}
|
||||
await retryRename(tmpPath, finalPath);
|
||||
try {
|
||||
await fsp.rm(bakPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
|
||||
// Rename — not delete — so the WAL (which may carry uncommitted-at-
|
||||
// close-time pages on a graceful close, depending on
|
||||
// `autoCheckpoint` / `checkpointThreshold`) and the `.shadow`
|
||||
// checkpoint snapshot stay paired with the database file under its
|
||||
// final name. LadybugDB 0.16.0's database-id check rejects an open
|
||||
// when the sidecars belong to a different base name.
|
||||
try {
|
||||
await fsp.access(`${tmpPath}${suffix}`);
|
||||
await retryRename(`${tmpPath}${suffix}`, `${finalPath}${suffix}`);
|
||||
} catch {
|
||||
/* sidecar absent — nothing to move */
|
||||
}
|
||||
}
|
||||
await removeLbugFile(bakPath);
|
||||
|
||||
// 4. Write meta.json
|
||||
await writeBridgeMeta(groupDir, {
|
||||
|
|
@ -524,10 +592,38 @@ export async function writeBridge(
|
|||
/* openBridgeDbReadOnly */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHandle | null> {
|
||||
/**
|
||||
* Substrings observed in the message of an `Error` raised by the LadybugDB
|
||||
* native open path when Windows still holds an exclusive lock on the file
|
||||
* after a writer's `Database.close()` returned. LadybugDB 0.16.0's
|
||||
* non-blocking checkpoint thread can briefly outlive the close call, so a
|
||||
* read-side opener that races in immediately afterwards sees Win32 error
|
||||
* 33 ("The process cannot access the file because another process has
|
||||
* locked a portion of the file"). Retrying with a small back-off lets the
|
||||
* background thread settle and the OS release the handle.
|
||||
*/
|
||||
const LBUG_OPEN_RETRY_PATTERNS = [
|
||||
'process cannot access the file',
|
||||
'another process has locked',
|
||||
'could not set lock',
|
||||
'lock held by another process',
|
||||
];
|
||||
|
||||
const LBUG_OPEN_RETRY_ATTEMPTS = 10;
|
||||
const LBUG_OPEN_RETRY_BASE_MS = 100;
|
||||
/** Cap individual back-off delays so the total wait is bounded (~3s). */
|
||||
const LBUG_OPEN_RETRY_MAX_MS = 500;
|
||||
|
||||
function isTransientLockError(err: unknown): boolean {
|
||||
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||
return LBUG_OPEN_RETRY_PATTERNS.some((p) => msg.includes(p));
|
||||
}
|
||||
|
||||
async function ensureBridgeDbFileAvailable(groupDir: string): Promise<boolean> {
|
||||
const dbPath = path.join(groupDir, 'bridge.lbug');
|
||||
try {
|
||||
await fsp.access(dbPath);
|
||||
return true;
|
||||
} catch {
|
||||
// Check for .bak recovery. Use `retryRename` (not `fsp.rename`) for the
|
||||
// exact same reason the rest of this file does: the scenario that
|
||||
|
|
@ -538,42 +634,62 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
|
|||
try {
|
||||
await fsp.access(bakPath);
|
||||
await retryRename(bakPath, dbPath);
|
||||
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
|
||||
try {
|
||||
await fsp.access(`${bakPath}${suffix}`);
|
||||
await retryRename(`${bakPath}${suffix}`, `${dbPath}${suffix}`);
|
||||
} catch {
|
||||
/* sidecar absent */
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHandle | null> {
|
||||
const dbPath = path.join(groupDir, 'bridge.lbug');
|
||||
if (!(await ensureBridgeDbFileAvailable(groupDir))) return null;
|
||||
|
||||
// Version gate: check meta.json version compatibility
|
||||
const meta = await readBridgeMeta(groupDir);
|
||||
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
|
||||
return null; // incompatible schema version — fallback to JSON or re-sync
|
||||
}
|
||||
|
||||
// Open the native handle. If Connection construction throws AFTER
|
||||
// Database was successfully allocated, we'd leak the native Database
|
||||
// object. Wrap each step separately and tear down the partial handle.
|
||||
let db: lbug.Database | undefined;
|
||||
let conn: lbug.Connection | undefined;
|
||||
try {
|
||||
db = new lbug.Database(dbPath, 0, false, true); // readOnly
|
||||
conn = new lbug.Connection(db);
|
||||
return { _db: db, _conn: conn, groupDir } as BridgeHandle;
|
||||
} catch {
|
||||
if (conn) {
|
||||
try {
|
||||
await conn.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Open the native handle with a bounded retry on transient OS-level file
|
||||
// locks (see LBUG_OPEN_RETRY_PATTERNS). If Connection construction throws
|
||||
// AFTER Database was successfully allocated, we'd leak the native Database
|
||||
// object — wrap each step separately and tear down the partial handle.
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= LBUG_OPEN_RETRY_ATTEMPTS; attempt++) {
|
||||
let handle: LbugConnectionHandle | undefined;
|
||||
try {
|
||||
handle = await openLbugConnection(lbug, dbPath, { readOnly: true });
|
||||
// Force the lazy native init now so a transient lock surfaces here
|
||||
// (where we can retry) instead of on the first user query.
|
||||
await handle.db.init();
|
||||
await handle.conn.init();
|
||||
return { _db: handle.db, _conn: handle.conn, groupDir } as BridgeHandle;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (handle) await closeLbugConnection(handle);
|
||||
if (!isTransientLockError(err) || attempt === LBUG_OPEN_RETRY_ATTEMPTS) break;
|
||||
const delay = Math.min(LBUG_OPEN_RETRY_BASE_MS * attempt, LBUG_OPEN_RETRY_MAX_MS);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
if (db) {
|
||||
try {
|
||||
await db.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (process.env.GITNEXUS_DEBUG_BRIDGE) {
|
||||
console.warn(
|
||||
`[bridge-db] openBridgeDbReadOnly(${groupDir}) gave up after ` +
|
||||
`${LBUG_OPEN_RETRY_ATTEMPTS} attempts: ${
|
||||
lastErr instanceof Error ? lastErr.message : String(lastErr)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
@ -581,8 +697,7 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
|
|||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function bridgeExists(groupDir: string): Promise<boolean> {
|
||||
const handle = await openBridgeDbReadOnly(groupDir);
|
||||
if (!handle) return false;
|
||||
await closeBridgeDb(handle);
|
||||
return true;
|
||||
if (!(await ensureBridgeDbFileAvailable(groupDir))) return false;
|
||||
const meta = await readBridgeMeta(groupDir);
|
||||
return meta.version === 0 || meta.version === BRIDGE_SCHEMA_VERSION;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const DEFAULT_DETECT = {
|
|||
topics: true,
|
||||
shared_libs: true,
|
||||
embedding_fallback: true,
|
||||
workspace_deps: true,
|
||||
};
|
||||
|
||||
const DEFAULT_MATCHING = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as path from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
import Parser from 'tree-sitter';
|
||||
import { createIgnoreFilter } from '../../../config/ignore-service.js';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
|
|
@ -227,11 +228,16 @@ async function buildProtoContext(repoPath: string): Promise<{
|
|||
servicesByName: Map<string, ProtoServiceInfo[]>;
|
||||
}> {
|
||||
const servicesByName = new Map<string, ProtoServiceInfo[]>();
|
||||
// `.gitnexusignore` / `.gitignore` honoured via the shared IgnoreService —
|
||||
// see `filesystem-walker.ts` for the canonical pattern. Replaces a
|
||||
// hardcoded `[node_modules, .git, vendor]` array; those names plus the
|
||||
// rest of `DEFAULT_IGNORE_LIST` are still excluded by default (#1185).
|
||||
const protoIgnoreFilter = await createIgnoreFilter(repoPath);
|
||||
const protoFiles = await glob('**/*.proto', {
|
||||
cwd: repoPath,
|
||||
absolute: false,
|
||||
nodir: true,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'],
|
||||
ignore: protoIgnoreFilter,
|
||||
});
|
||||
const contents = new Map<string, string>();
|
||||
|
||||
|
|
@ -401,9 +407,14 @@ export class GrpcExtractor implements ContractExtractor {
|
|||
}
|
||||
|
||||
// ─── Source files (+ .proto when plugin available) ────────────
|
||||
// Honour `.gitnexusignore` / `.gitignore` via the shared IgnoreService —
|
||||
// mirrors `filesystem-walker.ts`. Replaces a hardcoded
|
||||
// `[node_modules, .git, vendor, dist, build]` array; those names are all
|
||||
// in `DEFAULT_IGNORE_LIST`, so default behaviour is preserved (#1185).
|
||||
const sourceIgnoreFilter = await createIgnoreFilter(repoPath);
|
||||
const sourceFiles = await glob(GRPC_SCAN_GLOB, {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
|
||||
ignore: sourceIgnoreFilter,
|
||||
nodir: true,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as path from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
import Parser from 'tree-sitter';
|
||||
import { createIgnoreFilter } from '../../../config/ignore-service.js';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
|
|
@ -208,9 +209,16 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
}
|
||||
|
||||
private async scanFiles(repoPath: string): Promise<string[]> {
|
||||
// Honour `.gitnexusignore` and `.gitignore` via the shared IgnoreService
|
||||
// so contract extraction respects the same exclusion rules as the rest of
|
||||
// the ingestion pipeline. Mirrors `filesystem-walker.ts` which uses the
|
||||
// same shape. Replaces a hardcoded `[node_modules, .git, dist, build,
|
||||
// vendor]` array — those names are still in `DEFAULT_IGNORE_LIST`, so
|
||||
// default behaviour is preserved (#1185).
|
||||
const ignoreFilter = await createIgnoreFilter(repoPath);
|
||||
return glob(HTTP_SCAN_GLOB, {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/vendor/**'],
|
||||
ignore: ignoreFilter,
|
||||
nodir: true,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
270
gitnexus/src/core/group/extractors/rust-workspace-extractor.ts
Normal file
270
gitnexus/src/core/group/extractors/rust-workspace-extractor.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { CypherExecutor } from '../contract-extractor.js';
|
||||
import type { GroupManifestLink, ContractRole } from '../types.js';
|
||||
import { shouldIgnorePath } from '../../../config/ignore-service.js';
|
||||
import { loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
||||
/**
|
||||
* Discover cross-crate contracts in a Rust workspace by reading each
|
||||
* member's `Cargo.toml` dependencies and scanning source files for
|
||||
* `use <workspace_dep>::<Type>` imports.
|
||||
*
|
||||
* Emits `GroupManifestLink[]` with `type: 'custom'` that feed into the
|
||||
* existing ManifestExtractor pipeline — no new matching logic needed.
|
||||
*
|
||||
* Designed for the group-level sync pipeline: it receives all repos in
|
||||
* a group and produces cross-repo links between them.
|
||||
*/
|
||||
|
||||
interface CrateMeta {
|
||||
name: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
workspaceDeps: string[];
|
||||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
crateName: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Cargo.toml to extract the crate name and workspace dependency
|
||||
* names. Uses simple line-based parsing — no TOML library needed for
|
||||
* the subset we care about.
|
||||
*/
|
||||
async function parseCrateManifest(
|
||||
repoPath: string,
|
||||
): Promise<{ name: string; workspaceDeps: string[] } | null> {
|
||||
const cargoPath = path.join(repoPath, 'Cargo.toml');
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(cargoPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let name = '';
|
||||
const workspaceDeps: string[] = [];
|
||||
|
||||
const nameMatch = content.match(/^\[package\]\s*\n(?:[^\[]*?\n)*?name\s*=\s*"([^"]+)"/m);
|
||||
if (nameMatch) name = nameMatch[1];
|
||||
|
||||
// Match dependencies that use workspace = true, which indicates they
|
||||
// are workspace-internal deps:
|
||||
// dep_name = { workspace = true }
|
||||
// dep_name.workspace = true
|
||||
//
|
||||
// Also match plain path dependencies:
|
||||
// dep_name = { path = "../other" }
|
||||
const depSections = content.matchAll(
|
||||
/\[(dependencies|dev-dependencies|build-dependencies)\]\s*\n([\s\S]*?)(?=\n\[|$)/g,
|
||||
);
|
||||
|
||||
for (const section of depSections) {
|
||||
const sectionBody = section[2];
|
||||
// workspace = true style
|
||||
const wsMatches = sectionBody.matchAll(
|
||||
/^(\w[\w-]*)\s*=\s*\{[^}]*workspace\s*=\s*true[^}]*\}/gm,
|
||||
);
|
||||
for (const m of wsMatches) workspaceDeps.push(m[1]);
|
||||
|
||||
// dotted workspace style: dep_name.workspace = true
|
||||
const dottedMatches = sectionBody.matchAll(/^(\w[\w-]*)\.workspace\s*=\s*true/gm);
|
||||
for (const m of dottedMatches) workspaceDeps.push(m[1]);
|
||||
|
||||
// path = "../other" style (local path deps within workspace)
|
||||
const pathMatches = sectionBody.matchAll(
|
||||
/^(\w[\w-]*)\s*=\s*\{[^}]*path\s*=\s*"[^"]*"[^}]*\}/gm,
|
||||
);
|
||||
for (const m of pathMatches) workspaceDeps.push(m[1]);
|
||||
}
|
||||
|
||||
if (!name) return null;
|
||||
return { name, workspaceDeps: [...new Set(workspaceDeps)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan Rust source files for `use <crate>::<path>::<Symbol>` patterns
|
||||
* where <crate> is a known workspace dependency.
|
||||
*/
|
||||
async function scanImports(repoPath: string, knownCrates: Set<string>): Promise<ImportedSymbol[]> {
|
||||
const results: ImportedSymbol[] = [];
|
||||
|
||||
const normalizedCrates = new Map<string, string>();
|
||||
for (const c of knownCrates) {
|
||||
normalizedCrates.set(c.replace(/-/g, '_'), c);
|
||||
}
|
||||
|
||||
const sourceFiles = await findRustFiles(repoPath);
|
||||
for (const relFile of sourceFiles) {
|
||||
const absPath = path.join(repoPath, relFile);
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match patterns:
|
||||
// use crate_name::Type;
|
||||
// use crate_name::module::Type;
|
||||
// use crate_name::{Type1, Type2};
|
||||
// use crate_name::module::{Type1, Type2};
|
||||
const useRegex = /^use\s+(\w+)::(.+);/gm;
|
||||
let match;
|
||||
while ((match = useRegex.exec(content)) !== null) {
|
||||
const crateName = match[1];
|
||||
const originalCrateName = normalizedCrates.get(crateName);
|
||||
if (!originalCrateName) continue;
|
||||
|
||||
const importPath = match[2].trim();
|
||||
|
||||
// Handle grouped imports: {Type1, Type2, module::Type3}
|
||||
const braceMatch = importPath.match(/\{([^}]+)\}/);
|
||||
if (braceMatch) {
|
||||
const items = braceMatch[1].split(',').map((s) => s.trim());
|
||||
for (const item of items) {
|
||||
const symbolName = extractSymbolName(item);
|
||||
if (symbolName && isTypeName(symbolName)) {
|
||||
results.push({ crateName: originalCrateName, symbolName, filePath: relFile });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const symbolName = extractSymbolName(importPath);
|
||||
if (symbolName && isTypeName(symbolName)) {
|
||||
results.push({ crateName: originalCrateName, symbolName, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Extract the final symbol name from a path like `module::submod::TypeName`. */
|
||||
function extractSymbolName(importPath: string): string | null {
|
||||
const trimmed = importPath.trim();
|
||||
if (!trimmed || trimmed === '*' || trimmed === 'self') return null;
|
||||
const parts = trimmed.split('::');
|
||||
return parts[parts.length - 1].trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic: in Rust, types (structs, enums, traits) are PascalCase.
|
||||
* Functions and modules are snake_case. We only want types as cross-crate
|
||||
* contracts — functions are too granular and modules too broad.
|
||||
*/
|
||||
function isTypeName(name: string): boolean {
|
||||
return /^[A-Z][A-Za-z0-9]*$/.test(name);
|
||||
}
|
||||
|
||||
async function findRustFiles(repoPath: string): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
const ig = await loadIgnoreRules(repoPath);
|
||||
|
||||
async function walk(dir: string, rel: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel + '/')) continue;
|
||||
await walk(path.join(dir, entry.name), childRel);
|
||||
} else if (entry.name.endsWith('.rs')) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel)) continue;
|
||||
results.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(repoPath, '');
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface RustWorkspaceResult {
|
||||
links: GroupManifestLink[];
|
||||
discoveredCrates: Map<string, CrateMeta>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover cross-crate contracts across all Rust repos in a group.
|
||||
*
|
||||
* Returns `GroupManifestLink[]` ready to feed into `ManifestExtractor`.
|
||||
*/
|
||||
export async function extractRustWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<RustWorkspaceResult> {
|
||||
// Phase 1: Parse all Cargo.toml files to build crate registry
|
||||
const cratesByName = new Map<string, CrateMeta>();
|
||||
const cratesByGroupPath = new Map<string, CrateMeta>();
|
||||
|
||||
for (const [groupPath] of Object.entries(repos)) {
|
||||
const repoPath = repoPaths.get(groupPath);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = await parseCrateManifest(repoPath);
|
||||
if (!manifest) continue;
|
||||
|
||||
const meta: CrateMeta = {
|
||||
name: manifest.name,
|
||||
groupPath,
|
||||
repoPath,
|
||||
workspaceDeps: manifest.workspaceDeps,
|
||||
};
|
||||
const existing = cratesByName.get(manifest.name);
|
||||
if (existing) {
|
||||
console.warn(
|
||||
`[rust-workspace-extractor] duplicate crate name "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
cratesByName.set(manifest.name, meta);
|
||||
cratesByGroupPath.set(groupPath, meta);
|
||||
}
|
||||
|
||||
// Phase 2: For each crate, identify which of its workspace deps are
|
||||
// also in this group (i.e., repos we can link to)
|
||||
const links: GroupManifestLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [, crate] of cratesByGroupPath) {
|
||||
const groupCrateDeps = crate.workspaceDeps.filter((d) => cratesByName.has(d));
|
||||
if (groupCrateDeps.length === 0) continue;
|
||||
|
||||
// Phase 3: Scan source files for imports from workspace deps
|
||||
const knownCrates = new Set(groupCrateDeps);
|
||||
const imports = await scanImports(crate.repoPath, knownCrates);
|
||||
|
||||
for (const imp of imports) {
|
||||
const providerCrate = cratesByName.get(imp.crateName);
|
||||
if (!providerCrate) continue;
|
||||
|
||||
const qualifiedContract = `${imp.crateName}::${imp.symbolName}`;
|
||||
const key = `${crate.groupPath}→${providerCrate.groupPath}::${qualifiedContract}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const link: GroupManifestLink = {
|
||||
from: providerCrate.groupPath,
|
||||
to: crate.groupPath,
|
||||
type: 'custom',
|
||||
contract: qualifiedContract,
|
||||
role: 'provider' as ContractRole,
|
||||
};
|
||||
links.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
return { links, discoveredCrates: cratesByGroupPath };
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { glob } from 'glob';
|
||||
import Parser from 'tree-sitter';
|
||||
import { createIgnoreFilter } from '../../../config/ignore-service.js';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
|
|
@ -56,22 +57,21 @@ export class TopicExtractor implements ContractExtractor {
|
|||
repoPath: string,
|
||||
_repo: RepoHandle,
|
||||
): Promise<ExtractedContract[]> {
|
||||
// Honour `.gitnexusignore` / `.gitignore` via the shared IgnoreService —
|
||||
// mirrors `filesystem-walker.ts`. The 5-name hardcoded list
|
||||
// (`node_modules, .git, vendor, dist, build`) is preserved because every
|
||||
// entry is in `DEFAULT_IGNORE_LIST`, so default behaviour is unchanged
|
||||
// (#1185). The Go-specific `**/*_test.go` filter is layered on top via a
|
||||
// small wrapper so glob-level pruning is preserved (we never read those
|
||||
// files); the wrapper short-circuits before calling the base filter.
|
||||
const baseFilter = await createIgnoreFilter(repoPath);
|
||||
const ignoreFilter: typeof baseFilter = {
|
||||
ignored: (p) => p.relative().endsWith('_test.go') || baseFilter.ignored(p),
|
||||
childrenIgnored: (p) => baseFilter.childrenIgnored(p),
|
||||
};
|
||||
const files = await glob(TOPIC_SCAN_GLOB, {
|
||||
cwd: repoPath,
|
||||
ignore: [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/vendor/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
// Language-level test file conventions. Go test files
|
||||
// `*_test.go` live next to source; other languages either use
|
||||
// separate test directories (Python's `tests/`, Java's
|
||||
// `src/test/`) or are already covered by the dist/build ignores.
|
||||
// Pushed to the glob level so the orchestrator stays
|
||||
// language-agnostic.
|
||||
'**/*_test.go',
|
||||
],
|
||||
ignore: ignoreFilter,
|
||||
nodir: true,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { ThriftExtractor } from './extractors/thrift-extractor.js';
|
|||
import { TopicExtractor } from './extractors/topic-extractor.js';
|
||||
import { ManifestExtractor } from './extractors/manifest-extractor.js';
|
||||
import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js';
|
||||
import { extractRustWorkspaceLinks } from './extractors/rust-workspace-extractor.js';
|
||||
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
|
||||
import type { CypherExecutor } from './contract-extractor.js';
|
||||
import { writeContractRegistry } from './storage.js';
|
||||
|
|
@ -85,12 +86,14 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
let autoContracts: StoredContract[] = [];
|
||||
let manifestCrossLinks: CrossLink[] = [];
|
||||
let dbExecutors: Map<string, CypherExecutor> | undefined;
|
||||
let registryEntries: RegistryEntry[] | undefined;
|
||||
|
||||
const eo = opts?.extractorOverride;
|
||||
if (eo && eo.length === 0) {
|
||||
autoContracts = await (eo as () => Promise<StoredContract[]>)();
|
||||
} else {
|
||||
const entries = await readRegistry();
|
||||
registryEntries = await readRegistry();
|
||||
const entries = registryEntries;
|
||||
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
|
||||
const httpEx = new HttpRouteExtractor();
|
||||
const grpcEx = new GrpcExtractor();
|
||||
|
|
@ -190,18 +193,39 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
}
|
||||
}
|
||||
|
||||
// Process manifest links declared in group.yaml.
|
||||
// Auto-discover workspace dependency contracts (Rust Cargo workspaces, etc.)
|
||||
// and merge them with explicit manifest links. Discovered links use the same
|
||||
// ManifestExtractor pipeline as hand-written links in group.yaml.
|
||||
let allLinks = [...config.links];
|
||||
|
||||
if (config.detect.workspace_deps) {
|
||||
const repoPaths = new Map<string, string>();
|
||||
if (!registryEntries) registryEntries = await readRegistry();
|
||||
for (const [groupPath, regName] of Object.entries(config.repos)) {
|
||||
const e = registryEntries.find((en) => en.name === regName);
|
||||
if (e) repoPaths.set(groupPath, e.path);
|
||||
}
|
||||
|
||||
const wsResult = await extractRustWorkspaceLinks(config.repos, repoPaths, dbExecutors);
|
||||
if (wsResult.links.length > 0) {
|
||||
allLinks = [...allLinks, ...wsResult.links];
|
||||
if (opts?.verbose) {
|
||||
console.log(
|
||||
` workspace-deps: discovered ${wsResult.links.length} cross-crate links from ${wsResult.discoveredCrates.size} Rust crates`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process manifest links declared in group.yaml (plus any auto-discovered).
|
||||
// ManifestExtractor is fully implemented but was never wired into this
|
||||
// pipeline — config.links were parsed and validated but silently dropped.
|
||||
// Placed after the DB try/finally: resolveSymbol falls back to synthetic
|
||||
// UIDs when dbExecutors is undefined or a pool is closed, so cross-links
|
||||
// are always generated regardless of whether real DB executors are available.
|
||||
if (config.links.length > 0) {
|
||||
// Warn about dangling links that reference repos not declared in config.repos.
|
||||
// They still generate cross-links via synthetic UIDs (determinism is preserved),
|
||||
// but the operator probably meant something that now silently does nothing useful.
|
||||
if (allLinks.length > 0) {
|
||||
const knownRepos = new Set(Object.keys(config.repos));
|
||||
for (const link of config.links) {
|
||||
for (const link of allLinks) {
|
||||
const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r));
|
||||
if (dangling.length > 0) {
|
||||
console.warn(
|
||||
|
|
@ -211,12 +235,12 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
}
|
||||
|
||||
const manifestEx = new ManifestExtractor();
|
||||
const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors);
|
||||
const manifestResult = await manifestEx.extractFromManifest(allLinks, dbExecutors);
|
||||
autoContracts.push(...manifestResult.contracts);
|
||||
manifestCrossLinks = manifestResult.crossLinks;
|
||||
if (opts?.verbose) {
|
||||
console.log(
|
||||
` manifest: ${manifestCrossLinks.length} cross-links from ${config.links.length} declared links`,
|
||||
` manifest: ${manifestCrossLinks.length} cross-links from ${allLinks.length} links (${config.links.length} declared + ${allLinks.length - config.links.length} discovered)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export interface DetectConfig {
|
|||
topics: boolean;
|
||||
shared_libs: boolean;
|
||||
embedding_fallback: boolean;
|
||||
workspace_deps: boolean;
|
||||
}
|
||||
|
||||
export interface MatchingConfig {
|
||||
|
|
|
|||
|
|
@ -12,18 +12,15 @@
|
|||
|
||||
import { detectFrameworkFromPath } from './framework-detection.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { providers } from './languages/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// NAME PATTERNS - All 13 supported languages
|
||||
// NAME PATTERNS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Common entry point naming patterns by language.
|
||||
* These patterns indicate functions that are likely feature entry points.
|
||||
*
|
||||
* Universal patterns are separated from per-language patterns so the per-language
|
||||
* table can use `satisfies Record<SupportedLanguages, RegExp[]>` for compile-time
|
||||
* exhaustiveness — the compiler catches any missing language entry.
|
||||
* Universal entry point naming patterns shared across all languages.
|
||||
* Per-language patterns live on each LanguageProvider.entryPointPatterns.
|
||||
*/
|
||||
const UNIVERSAL_ENTRY_POINT_PATTERNS: RegExp[] = [
|
||||
/^(main|init|bootstrap|start|run|setup|configure)$/i,
|
||||
|
|
@ -40,201 +37,11 @@ const UNIVERSAL_ENTRY_POINT_PATTERNS: RegExp[] = [
|
|||
/^emit[A-Z]/, // emitEvent
|
||||
];
|
||||
|
||||
export const ENTRY_POINT_PATTERNS = {
|
||||
// JavaScript/TypeScript
|
||||
[SupportedLanguages.JavaScript]: [
|
||||
/^use[A-Z]/, // React hooks (useEffect, etc.)
|
||||
],
|
||||
[SupportedLanguages.TypeScript]: [
|
||||
/^use[A-Z]/, // React hooks
|
||||
],
|
||||
|
||||
// Python
|
||||
[SupportedLanguages.Python]: [
|
||||
/^app$/, // Flask/FastAPI app
|
||||
/^(get|post|put|delete|patch)_/i, // REST conventions
|
||||
/^api_/, // API functions
|
||||
/^view_/, // Django views
|
||||
],
|
||||
|
||||
// Java
|
||||
[SupportedLanguages.Java]: [
|
||||
/^do[A-Z]/, // doGet, doPost (Servlets)
|
||||
/^create[A-Z]/, // Factory patterns
|
||||
/^build[A-Z]/, // Builder patterns
|
||||
/Service$/, // UserService
|
||||
],
|
||||
|
||||
// Kotlin
|
||||
[SupportedLanguages.Kotlin]: [
|
||||
/^on(Create|Start|Resume|Pause|Stop|Destroy)$/, // Android lifecycle
|
||||
/^do[A-Z]/, // doGet, doPost (shared JVM Servlet pattern)
|
||||
/^create[A-Z]/, // Factory patterns
|
||||
/^build[A-Z]/, // Builder patterns
|
||||
/ViewModel$/, // MVVM pattern (Android)
|
||||
/^module$/, // Ktor module entry point
|
||||
/Service$/, // Service classes
|
||||
],
|
||||
|
||||
// C#
|
||||
[SupportedLanguages.CSharp]: [
|
||||
/^(Get|Post|Put|Delete|Patch)/, // ASP.NET action methods
|
||||
/Action$/, // MVC actions
|
||||
/^On[A-Z]/, // Event handlers / Blazor lifecycle
|
||||
/Async$/, // Async entry points
|
||||
/^Configure$/, // Startup.Configure
|
||||
/^ConfigureServices$/, // Startup.ConfigureServices
|
||||
/^Handle$/, // MediatR / generic handler
|
||||
/^Execute$/, // Command pattern
|
||||
/^Invoke$/, // Middleware Invoke
|
||||
/^Map[A-Z]/, // Minimal API MapGet, MapPost
|
||||
/Service$/, // Service classes
|
||||
/^Seed/, // Database seeding
|
||||
],
|
||||
|
||||
// Go
|
||||
[SupportedLanguages.Go]: [
|
||||
/Handler$/, // http.Handler pattern
|
||||
/^Serve/, // ServeHTTP
|
||||
/^New[A-Z]/, // Constructor pattern (returns new instance)
|
||||
/^Make[A-Z]/, // Make functions
|
||||
],
|
||||
|
||||
// Rust
|
||||
[SupportedLanguages.Rust]: [
|
||||
/^(get|post|put|delete)_handler$/i,
|
||||
/^handle_/, // handle_request
|
||||
/^new$/, // Constructor pattern
|
||||
/^run$/, // run entry point
|
||||
/^spawn/, // Async spawn
|
||||
],
|
||||
|
||||
// C - explicit main() boost plus common C entry point conventions
|
||||
[SupportedLanguages.C]: [
|
||||
/^main$/, // THE entry point
|
||||
/^init_/, // init_server, init_client
|
||||
/_init$/, // module_init, server_init
|
||||
/^start_/, // start_server
|
||||
/_start$/, // thread_start
|
||||
/^run_/, // run_loop
|
||||
/_run$/, // event_run
|
||||
/^stop_/, // stop_server
|
||||
/_stop$/, // service_stop
|
||||
/^open_/, // open_connection
|
||||
/_open$/, // file_open
|
||||
/^close_/, // close_connection
|
||||
/_close$/, // socket_close
|
||||
/^create_/, // create_session
|
||||
/_create$/, // object_create
|
||||
/^destroy_/, // destroy_session
|
||||
/_destroy$/, // object_destroy
|
||||
/^handle_/, // handle_request
|
||||
/_handler$/, // signal_handler
|
||||
/_callback$/, // event_callback
|
||||
/^cmd_/, // tmux: cmd_new_window, cmd_attach_session
|
||||
/^server_/, // server_start, server_loop
|
||||
/^client_/, // client_connect
|
||||
/^session_/, // session_create
|
||||
/^window_/, // window_resize (tmux)
|
||||
/^key_/, // key_press
|
||||
/^input_/, // input_parse
|
||||
/^output_/, // output_write
|
||||
/^notify_/, // notify_client
|
||||
/^control_/, // control_start
|
||||
],
|
||||
|
||||
// C++ - same as C plus OOP/template patterns
|
||||
[SupportedLanguages.CPlusPlus]: [
|
||||
/^main$/, // THE entry point
|
||||
/^init_/,
|
||||
/_init$/,
|
||||
/^Create[A-Z]/, // Factory patterns
|
||||
/^create_/,
|
||||
/^Run$/, // Run methods
|
||||
/^run$/,
|
||||
/^Start$/, // Start methods
|
||||
/^start$/,
|
||||
/^handle_/,
|
||||
/_handler$/,
|
||||
/_callback$/,
|
||||
/^OnEvent/, // Event callbacks
|
||||
/^on_/,
|
||||
/::Run$/, // Class::Run
|
||||
/::Start$/, // Class::Start
|
||||
/::Init$/, // Class::Init
|
||||
/::Execute$/, // Class::Execute
|
||||
],
|
||||
|
||||
// Swift / iOS
|
||||
[SupportedLanguages.Swift]: [
|
||||
/^viewDidLoad$/, // UIKit lifecycle
|
||||
/^viewWillAppear$/, // UIKit lifecycle
|
||||
/^viewDidAppear$/, // UIKit lifecycle
|
||||
/^viewWillDisappear$/, // UIKit lifecycle
|
||||
/^viewDidDisappear$/, // UIKit lifecycle
|
||||
/^application\(/, // AppDelegate methods
|
||||
/^scene\(/, // SceneDelegate methods
|
||||
/^body$/, // SwiftUI View.body
|
||||
/Coordinator$/, // Coordinator pattern
|
||||
/^sceneDidBecomeActive$/, // SceneDelegate lifecycle
|
||||
/^sceneWillResignActive$/, // SceneDelegate lifecycle
|
||||
/^didFinishLaunchingWithOptions$/, // AppDelegate
|
||||
/ViewController$/, // ViewController classes
|
||||
/^configure[A-Z]/, // Configuration methods
|
||||
/^setup[A-Z]/, // Setup methods
|
||||
/^makeBody$/, // SwiftUI ViewModifier
|
||||
],
|
||||
|
||||
// PHP / Laravel
|
||||
[SupportedLanguages.PHP]: [
|
||||
/Controller$/, // UserController (class name convention)
|
||||
/^handle$/, // Job::handle(), Listener::handle()
|
||||
/^execute$/, // Command::execute()
|
||||
/^boot$/, // ServiceProvider::boot()
|
||||
/^register$/, // ServiceProvider::register()
|
||||
/^__invoke$/, // Invokable controllers/actions
|
||||
/^(index|show|store|update|destroy|create|edit)$/, // RESTful resource methods
|
||||
/^(get|post|put|delete|patch)[A-Z]/, // Explicit HTTP method actions
|
||||
/^run$/, // Command/Job run()
|
||||
/^fire$/, // Event fire()
|
||||
/^dispatch$/, // Dispatchable jobs
|
||||
/Service$/, // UserService (Service layer)
|
||||
/Repository$/, // UserRepository (Repository pattern)
|
||||
/^find$/, // Repository::find()
|
||||
/^findAll$/, // Repository::findAll()
|
||||
/^save$/, // Repository::save()
|
||||
/^delete$/, // Repository::delete()
|
||||
],
|
||||
|
||||
// Ruby
|
||||
[SupportedLanguages.Ruby]: [
|
||||
/^call$/, // Service objects (MyService.call)
|
||||
/^perform$/, // Background jobs (Sidekiq, ActiveJob)
|
||||
/^execute$/, // Command pattern
|
||||
],
|
||||
|
||||
// Dart / Flutter
|
||||
[SupportedLanguages.Dart]: [
|
||||
/^main$/, // App entry
|
||||
/^build$/, // Widget.build — fundamental Flutter render entry point
|
||||
/^createState$/, // StatefulWidget.createState
|
||||
/^initState$/, // State lifecycle initialization
|
||||
/^dispose$/, // State lifecycle teardown
|
||||
/^didChangeDependencies$/, // State lifecycle — InheritedWidget changes
|
||||
/^didUpdateWidget$/, // State lifecycle — widget rebuild with new config
|
||||
/^runApp$/, // App entry point
|
||||
/^onEvent$/, // BLoC event handler
|
||||
/^mapEventToState$/, // Legacy BLoC pattern
|
||||
],
|
||||
[SupportedLanguages.Vue]: [], // Vue uses TypeScript queries — entry points handled via TS patterns
|
||||
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no tree-sitter entry points
|
||||
} satisfies Record<SupportedLanguages, RegExp[]>;
|
||||
|
||||
/** Pre-computed merged patterns (universal + language-specific) to avoid per-call array allocation. */
|
||||
/** Pre-computed merged patterns (universal + language-specific) from providers. */
|
||||
const MERGED_ENTRY_POINT_PATTERNS = Object.fromEntries(
|
||||
Object.values(SupportedLanguages).map((lang) => [
|
||||
Object.entries(providers).map(([lang, provider]) => [
|
||||
lang,
|
||||
[...UNIVERSAL_ENTRY_POINT_PATTERNS, ...(ENTRY_POINT_PATTERNS[lang] ?? [])],
|
||||
[...UNIVERSAL_ENTRY_POINT_PATTERNS, ...(provider.entryPointPatterns ?? [])],
|
||||
]),
|
||||
) as Record<SupportedLanguages, RegExp[]>;
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { AstFrameworkPatternConfig } from './language-provider.js';
|
||||
import { providers } from './languages/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
|
|
@ -518,395 +520,14 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
|
|||
// AST-BASED FRAMEWORK DETECTION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Patterns that indicate framework entry points within code definitions.
|
||||
* These are matched against AST node text (class/method/function declaration text).
|
||||
*/
|
||||
export const FRAMEWORK_AST_PATTERNS = {
|
||||
// JavaScript/TypeScript decorators
|
||||
nestjs: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'],
|
||||
'expo-router': [
|
||||
'router.push',
|
||||
'router.replace',
|
||||
'router.navigate',
|
||||
'useRouter',
|
||||
'useLocalSearchParams',
|
||||
'useSegments',
|
||||
'expo-router',
|
||||
],
|
||||
express: ['app.get', 'app.post', 'app.put', 'app.delete', 'router.get', 'router.post'],
|
||||
|
||||
// Python decorators
|
||||
fastapi: ['@app.get', '@app.post', '@app.put', '@app.delete', '@router.get'],
|
||||
flask: ['@app.route', '@blueprint.route'],
|
||||
|
||||
// Java annotations
|
||||
spring: ['@RestController', '@Controller', '@GetMapping', '@PostMapping', '@RequestMapping'],
|
||||
jaxrs: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'],
|
||||
|
||||
// C# attributes
|
||||
aspnet: [
|
||||
'[ApiController]',
|
||||
'[HttpGet]',
|
||||
'[HttpPost]',
|
||||
'[HttpPut]',
|
||||
'[HttpDelete]',
|
||||
'[Route]',
|
||||
'[Authorize]',
|
||||
'[AllowAnonymous]',
|
||||
],
|
||||
signalr: ['[HubMethodName]', ': Hub', ': Hub<'],
|
||||
blazor: ['@page', '[Parameter]', '@inject'],
|
||||
efcore: ['DbContext', 'DbSet<', 'OnModelCreating'],
|
||||
|
||||
// Go patterns (function signatures include framework types)
|
||||
'go-http': [
|
||||
'http.Handler',
|
||||
'http.HandlerFunc',
|
||||
'ServeHTTP',
|
||||
'http.ResponseWriter',
|
||||
'http.Request',
|
||||
],
|
||||
gin: ['gin.Context', 'gin.Default', 'gin.New'],
|
||||
echo: ['echo.Context', 'echo.New'],
|
||||
fiber: ['fiber.Ctx', 'fiber.New', 'fiber.App'],
|
||||
'go-grpc': ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'],
|
||||
|
||||
// ORM patterns
|
||||
prisma: ['prisma.', 'PrismaClient', '@prisma/client'],
|
||||
supabase: ['supabase.from', 'createClient', '@supabase/supabase-js'],
|
||||
|
||||
// PHP/Laravel
|
||||
laravel: [
|
||||
'Route::get',
|
||||
'Route::post',
|
||||
'Route::put',
|
||||
'Route::delete',
|
||||
'Route::resource',
|
||||
'Route::apiResource',
|
||||
'#[Route(',
|
||||
],
|
||||
|
||||
// Rust macros (proc-macro attributes in definition text)
|
||||
actix: ['#[get', '#[post', '#[put', '#[delete', '#[actix_web', 'HttpRequest', 'HttpResponse'],
|
||||
axum: ['Router::new', 'axum::extract', 'axum::routing'],
|
||||
rocket: ['#[get', '#[post', '#[launch', 'rocket::'],
|
||||
tokio: ['#[tokio::main]', '#[tokio::test]'],
|
||||
|
||||
// C++ patterns (Qt, Boost)
|
||||
qt: [
|
||||
'Q_OBJECT',
|
||||
'Q_INVOKABLE',
|
||||
'Q_PROPERTY',
|
||||
'Q_SIGNALS',
|
||||
'Q_SLOTS',
|
||||
'Q_SIGNAL',
|
||||
'Q_SLOT',
|
||||
'QWidget',
|
||||
'QApplication',
|
||||
],
|
||||
|
||||
// Swift/iOS
|
||||
uikit: [
|
||||
'viewDidLoad',
|
||||
'viewWillAppear',
|
||||
'viewDidAppear',
|
||||
'UIViewController',
|
||||
'@IBOutlet',
|
||||
'@IBAction',
|
||||
'@objc',
|
||||
],
|
||||
swiftui: [
|
||||
'@main',
|
||||
'WindowGroup',
|
||||
'ContentView',
|
||||
'@StateObject',
|
||||
'@ObservedObject',
|
||||
'@EnvironmentObject',
|
||||
'@Published',
|
||||
],
|
||||
vapor: ['app.get', 'app.post', 'req.content.decode', 'Vapor'],
|
||||
|
||||
// Ruby patterns (class-level macros in definition text)
|
||||
rails: [
|
||||
'ApplicationController',
|
||||
'ApplicationRecord',
|
||||
'ActiveRecord::Base',
|
||||
'before_action',
|
||||
'after_action',
|
||||
'has_many',
|
||||
'belongs_to',
|
||||
'has_one',
|
||||
'validates',
|
||||
],
|
||||
sinatra: ['Sinatra::Base', 'Sinatra::Application'],
|
||||
|
||||
// Dart/Flutter
|
||||
flutter: [
|
||||
'StatelessWidget',
|
||||
'StatefulWidget',
|
||||
'BuildContext',
|
||||
'Widget build',
|
||||
'ChangeNotifier',
|
||||
'GetxController',
|
||||
'Cubit<',
|
||||
'Bloc<',
|
||||
'ConsumerWidget',
|
||||
],
|
||||
riverpod: ['@riverpod', 'ref.watch', 'ref.read', 'AsyncNotifier', 'Notifier'],
|
||||
};
|
||||
|
||||
interface AstFrameworkPatternConfig {
|
||||
framework: string;
|
||||
entryPointMultiplier: number;
|
||||
reason: string;
|
||||
patterns: string[];
|
||||
}
|
||||
|
||||
export const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = {
|
||||
[SupportedLanguages.JavaScript]: [
|
||||
{
|
||||
framework: 'nestjs',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'nestjs-decorator',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.nestjs,
|
||||
},
|
||||
{
|
||||
framework: 'expo-router',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'expo-router-navigation',
|
||||
patterns: FRAMEWORK_AST_PATTERNS['expo-router'],
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.TypeScript]: [
|
||||
{
|
||||
framework: 'nestjs',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'nestjs-decorator',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.nestjs,
|
||||
},
|
||||
{
|
||||
framework: 'expo-router',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'expo-router-navigation',
|
||||
patterns: FRAMEWORK_AST_PATTERNS['expo-router'],
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Python]: [
|
||||
{
|
||||
framework: 'fastapi',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'fastapi-decorator',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.fastapi,
|
||||
},
|
||||
{
|
||||
framework: 'flask',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'flask-decorator',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.flask,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Java]: [
|
||||
{
|
||||
framework: 'spring',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'spring-annotation',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.spring,
|
||||
},
|
||||
{
|
||||
framework: 'jaxrs',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'jaxrs-annotation',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.jaxrs,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Kotlin]: [
|
||||
{
|
||||
framework: 'spring-kotlin',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'spring-kotlin-annotation',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.spring,
|
||||
},
|
||||
{
|
||||
framework: 'jaxrs',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'jaxrs-annotation',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.jaxrs,
|
||||
},
|
||||
{
|
||||
framework: 'ktor',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'ktor-routing',
|
||||
patterns: ['routing', 'embeddedServer', 'Application.module'],
|
||||
},
|
||||
{
|
||||
framework: 'android-kotlin',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'android-annotation',
|
||||
patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('],
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.CSharp]: [
|
||||
{
|
||||
framework: 'aspnet',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'aspnet-attribute',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.aspnet,
|
||||
},
|
||||
{
|
||||
framework: 'signalr',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'signalr-attribute',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.signalr,
|
||||
},
|
||||
{
|
||||
framework: 'blazor',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'blazor-attribute',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.blazor,
|
||||
},
|
||||
{
|
||||
framework: 'efcore',
|
||||
entryPointMultiplier: 2.0,
|
||||
reason: 'efcore-pattern',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.efcore,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.PHP]: [
|
||||
{
|
||||
framework: 'laravel',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'php-route-attribute',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.laravel,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Go]: [
|
||||
{
|
||||
framework: 'go-http',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'go-http-handler',
|
||||
patterns: FRAMEWORK_AST_PATTERNS['go-http'],
|
||||
},
|
||||
{
|
||||
framework: 'gin',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'gin-handler',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.gin,
|
||||
},
|
||||
{
|
||||
framework: 'echo',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'echo-handler',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.echo,
|
||||
},
|
||||
{
|
||||
framework: 'fiber',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'fiber-handler',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.fiber,
|
||||
},
|
||||
{
|
||||
framework: 'go-grpc',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'grpc-service',
|
||||
patterns: FRAMEWORK_AST_PATTERNS['go-grpc'],
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Rust]: [
|
||||
{
|
||||
framework: 'actix-web',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'actix-attribute',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.actix,
|
||||
},
|
||||
{
|
||||
framework: 'axum',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'axum-routing',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.axum,
|
||||
},
|
||||
{
|
||||
framework: 'rocket',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'rocket-attribute',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.rocket,
|
||||
},
|
||||
{
|
||||
framework: 'tokio',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'tokio-runtime',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.tokio,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.C]: [], // C has no framework-specific AST patterns (POSIX/socket patterns are in entry-point-scoring)
|
||||
[SupportedLanguages.CPlusPlus]: [
|
||||
{
|
||||
framework: 'qt',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'qt-macro',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.qt,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Swift]: [
|
||||
{
|
||||
framework: 'uikit',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'uikit-lifecycle',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.uikit,
|
||||
},
|
||||
{
|
||||
framework: 'swiftui',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'swiftui-pattern',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.swiftui,
|
||||
},
|
||||
{
|
||||
framework: 'vapor',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'vapor-routing',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.vapor,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Ruby]: [
|
||||
{
|
||||
framework: 'rails',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'rails-pattern',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.rails,
|
||||
},
|
||||
{
|
||||
framework: 'sinatra',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'sinatra-pattern',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.sinatra,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Dart]: [
|
||||
{
|
||||
framework: 'flutter',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'flutter-widget',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.flutter,
|
||||
},
|
||||
{
|
||||
framework: 'riverpod',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'riverpod-pattern',
|
||||
patterns: FRAMEWORK_AST_PATTERNS.riverpod,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Vue]: [], // Vue uses TypeScript AST framework detection
|
||||
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no AST framework patterns
|
||||
} satisfies Record<SupportedLanguages, AstFrameworkPatternConfig[]>;
|
||||
|
||||
/** Pre-lowercased patterns for O(1) pattern matching at runtime */
|
||||
const AST_PATTERNS_LOWERED: Record<
|
||||
string,
|
||||
Array<{ framework: string; entryPointMultiplier: number; reason: string; patterns: string[] }>
|
||||
> = Object.fromEntries(
|
||||
Object.entries(AST_FRAMEWORK_PATTERNS_BY_LANGUAGE).map(([lang, cfgs]) => [
|
||||
/** Pre-lowercased patterns for O(1) pattern matching at runtime — built from providers. */
|
||||
const AST_PATTERNS_LOWERED: Record<string, AstFrameworkPatternConfig[]> = Object.fromEntries(
|
||||
Object.entries(providers).map(([lang, provider]) => [
|
||||
lang,
|
||||
cfgs.map((cfg) => ({ ...cfg, patterns: cfg.patterns.map((p) => p.toLowerCase()) })),
|
||||
(provider.astFrameworkPatterns ?? []).map((cfg) => ({
|
||||
...cfg,
|
||||
patterns: cfg.patterns.map((p) => p.toLowerCase()),
|
||||
})),
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,14 @@ export type ImportSemantics =
|
|||
| 'namespace'
|
||||
| 'explicit-reexport';
|
||||
|
||||
/** Configuration for AST-based framework detection patterns. */
|
||||
export interface AstFrameworkPatternConfig {
|
||||
framework: string;
|
||||
entryPointMultiplier: number;
|
||||
reason: string;
|
||||
patterns: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a language needs to provide.
|
||||
* Required fields must be explicitly set; optional fields have defaults
|
||||
|
|
@ -89,6 +97,16 @@ interface LanguageProviderConfig {
|
|||
/** File extensions that map to this language (e.g., ['.ts', '.tsx']) */
|
||||
readonly extensions: readonly string[];
|
||||
|
||||
/** Entry-point function name patterns specific to this language.
|
||||
* Merged with universal patterns at runtime for process detection scoring.
|
||||
* Default: [] (only universal patterns apply). */
|
||||
readonly entryPointPatterns?: readonly RegExp[];
|
||||
|
||||
/** AST-based framework detection patterns for this language.
|
||||
* Used by detectFrameworkFromAST to identify framework entry points.
|
||||
* Default: [] (no AST framework detection for this language). */
|
||||
readonly astFrameworkPatterns?: readonly AstFrameworkPatternConfig[];
|
||||
|
||||
// ── Parser ────────────────────────────────────────────────────────
|
||||
/** Parse strategy: 'tree-sitter' (default) uses AST parsing via tree-sitter.
|
||||
* 'standalone' means the language has its own regex-based processor and
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
|||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { cClassConfig, cppClassConfig } from '../class-extractors/configs/c-cpp.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { typeConfig as cCppConfig } from '../type-extractors/c-cpp.js';
|
||||
import { cCppExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
|
|
@ -317,6 +318,38 @@ const cppLabelOverride: NonNullable<LanguageProvider['labelOverride']> = (
|
|||
export const cProvider = defineLanguage({
|
||||
id: SupportedLanguages.C,
|
||||
extensions: ['.c'],
|
||||
entryPointPatterns: [
|
||||
/^main$/,
|
||||
/^init_/,
|
||||
/_init$/,
|
||||
/^start_/,
|
||||
/_start$/,
|
||||
/^run_/,
|
||||
/_run$/,
|
||||
/^stop_/,
|
||||
/_stop$/,
|
||||
/^open_/,
|
||||
/_open$/,
|
||||
/^close_/,
|
||||
/_close$/,
|
||||
/^create_/,
|
||||
/_create$/,
|
||||
/^destroy_/,
|
||||
/_destroy$/,
|
||||
/^handle_/,
|
||||
/_handler$/,
|
||||
/_callback$/,
|
||||
/^cmd_/,
|
||||
/^server_/,
|
||||
/^client_/,
|
||||
/^session_/,
|
||||
/^window_/,
|
||||
/^key_/,
|
||||
/^input_/,
|
||||
/^output_/,
|
||||
/^notify_/,
|
||||
/^control_/,
|
||||
],
|
||||
treeSitterQueries: C_QUERIES,
|
||||
typeConfig: cCppConfig,
|
||||
exportChecker: cCppExportChecker,
|
||||
|
|
@ -338,6 +371,44 @@ export const cProvider = defineLanguage({
|
|||
export const cppProvider = defineLanguage({
|
||||
id: SupportedLanguages.CPlusPlus,
|
||||
extensions: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'],
|
||||
entryPointPatterns: [
|
||||
/^main$/,
|
||||
/^init_/,
|
||||
/_init$/,
|
||||
/^Create[A-Z]/,
|
||||
/^create_/,
|
||||
/^Run$/,
|
||||
/^run$/,
|
||||
/^Start$/,
|
||||
/^start$/,
|
||||
/^handle_/,
|
||||
/_handler$/,
|
||||
/_callback$/,
|
||||
/^OnEvent/,
|
||||
/^on_/,
|
||||
/::Run$/,
|
||||
/::Start$/,
|
||||
/::Init$/,
|
||||
/::Execute$/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'qt',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'qt-macro',
|
||||
patterns: [
|
||||
'Q_OBJECT',
|
||||
'Q_INVOKABLE',
|
||||
'Q_PROPERTY',
|
||||
'Q_SIGNALS',
|
||||
'Q_SLOTS',
|
||||
'Q_SIGNAL',
|
||||
'Q_SLOT',
|
||||
'QWidget',
|
||||
'QApplication',
|
||||
],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: CPP_QUERIES,
|
||||
typeConfig: cCppConfig,
|
||||
exportChecker: cCppExportChecker,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export const cobolProvider = defineLanguage({
|
|||
id: SupportedLanguages.Cobol,
|
||||
parseStrategy: 'standalone',
|
||||
extensions: [], // COBOL files detected by cobol-processor's isCobolFile/isJclFile
|
||||
entryPointPatterns: [],
|
||||
astFrameworkPatterns: [],
|
||||
treeSitterQueries: '',
|
||||
typeConfig: {
|
||||
declarationNodeTypes: new Set(),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
|||
import { csharpImportConfig } from '../import-resolvers/configs/csharp.js';
|
||||
import { extractCSharpNamedBindings } from '../named-bindings/csharp.js';
|
||||
import { CSHARP_QUERIES } from '../tree-sitter-queries.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { csharpCallConfig } from '../call-extractors/configs/csharp.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
|
|
@ -135,6 +136,55 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
|||
export const csharpProvider = defineLanguage({
|
||||
id: SupportedLanguages.CSharp,
|
||||
extensions: ['.cs'],
|
||||
entryPointPatterns: [
|
||||
/^(Get|Post|Put|Delete|Patch)/,
|
||||
/Action$/,
|
||||
/^On[A-Z]/,
|
||||
/Async$/,
|
||||
/^Configure$/,
|
||||
/^ConfigureServices$/,
|
||||
/^Handle$/,
|
||||
/^Execute$/,
|
||||
/^Invoke$/,
|
||||
/^Map[A-Z]/,
|
||||
/Service$/,
|
||||
/^Seed/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'aspnet',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'aspnet-attribute',
|
||||
patterns: [
|
||||
'[ApiController]',
|
||||
'[HttpGet]',
|
||||
'[HttpPost]',
|
||||
'[HttpPut]',
|
||||
'[HttpDelete]',
|
||||
'[Route]',
|
||||
'[Authorize]',
|
||||
'[AllowAnonymous]',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'signalr',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'signalr-attribute',
|
||||
patterns: ['[HubMethodName]', ': Hub', ': Hub<'],
|
||||
},
|
||||
{
|
||||
framework: 'blazor',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'blazor-attribute',
|
||||
patterns: ['@page', '[Parameter]', '@inject'],
|
||||
},
|
||||
{
|
||||
framework: 'efcore',
|
||||
entryPointMultiplier: 2.0,
|
||||
reason: 'efcore-pattern',
|
||||
patterns: ['DbContext', 'DbSet<', 'OnModelCreating'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: CSHARP_QUERIES,
|
||||
typeConfig: csharpConfig,
|
||||
exportChecker: csharpExportChecker,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
|||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { dartClassConfig } from '../class-extractors/configs/dart.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { typeConfig as dartConfig } from '../type-extractors/dart.js';
|
||||
import { dartExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
|
|
@ -93,6 +94,42 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
|||
export const dartProvider = defineLanguage({
|
||||
id: SupportedLanguages.Dart,
|
||||
extensions: ['.dart'],
|
||||
entryPointPatterns: [
|
||||
/^main$/,
|
||||
/^build$/,
|
||||
/^createState$/,
|
||||
/^initState$/,
|
||||
/^dispose$/,
|
||||
/^didChangeDependencies$/,
|
||||
/^didUpdateWidget$/,
|
||||
/^runApp$/,
|
||||
/^onEvent$/,
|
||||
/^mapEventToState$/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'flutter',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'flutter-widget',
|
||||
patterns: [
|
||||
'StatelessWidget',
|
||||
'StatefulWidget',
|
||||
'BuildContext',
|
||||
'Widget build',
|
||||
'ChangeNotifier',
|
||||
'GetxController',
|
||||
'Cubit<',
|
||||
'Bloc<',
|
||||
'ConsumerWidget',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'riverpod',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'riverpod-pattern',
|
||||
patterns: ['@riverpod', 'ref.watch', 'ref.read', 'AsyncNotifier', 'Notifier'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: DART_QUERIES,
|
||||
typeConfig: dartConfig,
|
||||
exportChecker: dartExportChecker,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { goExportChecker } from '../export-detection.js';
|
|||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
import { goImportConfig } from '../import-resolvers/configs/go.js';
|
||||
import { GO_QUERIES } from '../tree-sitter-queries.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { goConfig as goFieldConfig } from '../field-extractors/configs/go.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
|
|
@ -32,6 +33,45 @@ import { goHeritageConfig } from '../heritage-extractors/configs/go.js';
|
|||
export const goProvider = defineLanguage({
|
||||
id: SupportedLanguages.Go,
|
||||
extensions: ['.go'],
|
||||
entryPointPatterns: [/Handler$/, /^Serve/, /^New[A-Z]/, /^Make[A-Z]/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'go-http',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'go-http-handler',
|
||||
patterns: [
|
||||
'http.Handler',
|
||||
'http.HandlerFunc',
|
||||
'ServeHTTP',
|
||||
'http.ResponseWriter',
|
||||
'http.Request',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'gin',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'gin-handler',
|
||||
patterns: ['gin.Context', 'gin.Default', 'gin.New'],
|
||||
},
|
||||
{
|
||||
framework: 'echo',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'echo-handler',
|
||||
patterns: ['echo.Context', 'echo.New'],
|
||||
},
|
||||
{
|
||||
framework: 'fiber',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'fiber-handler',
|
||||
patterns: ['fiber.Ctx', 'fiber.New', 'fiber.App'],
|
||||
},
|
||||
{
|
||||
framework: 'go-grpc',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'grpc-service',
|
||||
patterns: ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: GO_QUERIES,
|
||||
typeConfig: goConfig,
|
||||
exportChecker: goExportChecker,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
|||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { javaClassConfig } from '../class-extractors/configs/jvm.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { javaTypeConfig } from '../type-extractors/jvm.js';
|
||||
import { javaExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
|
|
@ -30,6 +31,27 @@ import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
|||
export const javaProvider = defineLanguage({
|
||||
id: SupportedLanguages.Java,
|
||||
extensions: ['.java'],
|
||||
entryPointPatterns: [/^do[A-Z]/, /^create[A-Z]/, /^build[A-Z]/, /Service$/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'spring',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'spring-annotation',
|
||||
patterns: [
|
||||
'@RestController',
|
||||
'@Controller',
|
||||
'@GetMapping',
|
||||
'@PostMapping',
|
||||
'@RequestMapping',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'jaxrs',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'jaxrs-annotation',
|
||||
patterns: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: JAVA_QUERIES,
|
||||
typeConfig: javaTypeConfig,
|
||||
exportChecker: javaExportChecker,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { kotlinImportConfig } from '../import-resolvers/configs/jvm.js';
|
|||
import { extractKotlinNamedBindings } from '../named-bindings/kotlin.js';
|
||||
import { appendKotlinWildcard } from '../import-resolvers/jvm.js';
|
||||
import { KOTLIN_QUERIES } from '../tree-sitter-queries.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { kotlinCallConfig } from '../call-extractors/configs/jvm.js';
|
||||
|
|
@ -105,6 +106,47 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
|||
export const kotlinProvider = defineLanguage({
|
||||
id: SupportedLanguages.Kotlin,
|
||||
extensions: ['.kt', '.kts'],
|
||||
entryPointPatterns: [
|
||||
/^on(Create|Start|Resume|Pause|Stop|Destroy)$/,
|
||||
/^do[A-Z]/,
|
||||
/^create[A-Z]/,
|
||||
/^build[A-Z]/,
|
||||
/ViewModel$/,
|
||||
/^module$/,
|
||||
/Service$/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'spring-kotlin',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'spring-kotlin-annotation',
|
||||
patterns: [
|
||||
'@RestController',
|
||||
'@Controller',
|
||||
'@GetMapping',
|
||||
'@PostMapping',
|
||||
'@RequestMapping',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'jaxrs',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'jaxrs-annotation',
|
||||
patterns: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'],
|
||||
},
|
||||
{
|
||||
framework: 'ktor',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'ktor-routing',
|
||||
patterns: ['routing', 'embeddedServer', 'Application.module'],
|
||||
},
|
||||
{
|
||||
framework: 'android-kotlin',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'android-annotation',
|
||||
patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: KOTLIN_QUERIES,
|
||||
typeConfig: kotlinTypeConfig,
|
||||
exportChecker: kotlinExportChecker,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
|||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { phpClassConfig } from '../class-extractors/configs/php.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { typeConfig as phpConfig } from '../type-extractors/php.js';
|
||||
import { phpExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
|
|
@ -239,6 +240,41 @@ function isPhpRouteFile(filePath: string): boolean {
|
|||
export const phpProvider = defineLanguage({
|
||||
id: SupportedLanguages.PHP,
|
||||
extensions: ['.php', '.phtml', '.php3', '.php4', '.php5', '.php8'],
|
||||
entryPointPatterns: [
|
||||
/Controller$/,
|
||||
/^handle$/,
|
||||
/^execute$/,
|
||||
/^boot$/,
|
||||
/^register$/,
|
||||
/^__invoke$/,
|
||||
/^(index|show|store|update|destroy|create|edit)$/,
|
||||
/^(get|post|put|delete|patch)[A-Z]/,
|
||||
/^run$/,
|
||||
/^fire$/,
|
||||
/^dispatch$/,
|
||||
/Service$/,
|
||||
/Repository$/,
|
||||
/^find$/,
|
||||
/^findAll$/,
|
||||
/^save$/,
|
||||
/^delete$/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'laravel',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'php-route-attribute',
|
||||
patterns: [
|
||||
'Route::get',
|
||||
'Route::post',
|
||||
'Route::put',
|
||||
'Route::delete',
|
||||
'Route::resource',
|
||||
'Route::apiResource',
|
||||
'#[Route(',
|
||||
],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: PHP_QUERIES,
|
||||
typeConfig: phpConfig,
|
||||
exportChecker: phpExportChecker,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -38,14 +38,24 @@ export function emitPythonScopeCaptures(
|
|||
// here at the use site.
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getPythonParser>['parse']> | undefined;
|
||||
if (tree === undefined) {
|
||||
tree = getPythonParser().parse(sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
try {
|
||||
tree = getPythonParser().parse(sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
} catch (err) {
|
||||
throw scopeExtractionError('parse', _filePath, err);
|
||||
}
|
||||
recordCacheMiss();
|
||||
} else {
|
||||
recordCacheHit();
|
||||
}
|
||||
const rawMatches = getPythonScopeQuery().matches(tree.rootNode);
|
||||
|
||||
let rawMatches: ReturnType<ReturnType<typeof getPythonScopeQuery>['matches']>;
|
||||
try {
|
||||
rawMatches = getPythonScopeQuery().matches(tree.rootNode);
|
||||
} catch (err) {
|
||||
throw scopeExtractionError('scope query', _filePath, err);
|
||||
}
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
|
|
@ -138,3 +148,10 @@ export function emitPythonScopeCaptures(
|
|||
|
||||
return out;
|
||||
}
|
||||
|
||||
function scopeExtractionError(stage: string, filePath: string, err: unknown): Error {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
return new Error(
|
||||
`[python] tree-sitter ${stage} failed for ${filePath}: ${reason}; skipping scope extraction for this file`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,60 +61,159 @@ export function resolvePythonImportTarget(
|
|||
const pathLike = parsedImport.targetRaw.replace(/\./g, '/');
|
||||
if (pathLike.includes('/')) {
|
||||
const [leadingSegment] = pathLike.split('/').filter(Boolean);
|
||||
if (!leadingSegment || !hasRepoCandidate(leadingSegment, ctx.allFilePaths)) {
|
||||
if (!leadingSegment || !hasRepoCandidate(leadingSegment, ctx.allFilePaths, ctx.fromFile)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-segment absolute resolve: try exact paths first, then suffix
|
||||
// match in nested repos. Using direct `Set.has` + `endsWith` instead of
|
||||
// `suffixResolve`'s shared helper because that helper requires a
|
||||
// pre-built `SuffixIndex` to disambiguate ties — without one it falls
|
||||
// back to an O(files) scan that silently picks the wrong file when
|
||||
// the last segment collides across directories (e.g. `accounts.models`
|
||||
// matching `billing/models.py` when both files exist).
|
||||
return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths);
|
||||
// Multi-segment absolute resolve: try exact paths first, then ancestor
|
||||
// walk (mirrors the single-segment ancestor walk in
|
||||
// `resolvePythonImportInternal`), then a suffix match in nested repos.
|
||||
// Using direct `Set.has` + `endsWith` instead of `suffixResolve`'s shared
|
||||
// helper because that helper requires a pre-built `SuffixIndex` to
|
||||
// disambiguate ties — without one it falls back to an O(files) scan that
|
||||
// silently picks the wrong file when the last segment collides across
|
||||
// directories (e.g. `accounts.models` matching `billing/models.py` when
|
||||
// both files exist).
|
||||
return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths, ctx.fromFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `package/sub/module` style paths (already dot-flattened) to a
|
||||
* concrete file in `allFilePaths`. Tries the exact path first, then the
|
||||
* `__init__.py` variant, then a suffix match for nested layouts.
|
||||
* concrete file in `allFilePaths`. Tries the exact path first, then walks
|
||||
* ancestors of `fromFile` looking for `<ancestor>/<pathLike>.py` (or
|
||||
* `__init__.py`), then falls back to a suffix match for nested layouts.
|
||||
* Returns the original (un-normalized) path from the set.
|
||||
*
|
||||
* Precedence order:
|
||||
* 1. Workspace-root direct hit (`<pathLike>.py`, `<pathLike>/__init__.py`).
|
||||
* 2. Closest-ancestor match walking up from the importer's directory.
|
||||
* 3. Suffix fallback (deterministic: fewest path segments, then
|
||||
* lexicographic on the normalized path).
|
||||
*
|
||||
* Root wins over ancestor by construction — if both `services/sync.py` and
|
||||
* `backend/services/sync.py` exist, `backend/routers/cron.py`'s
|
||||
* `from services.sync import X` resolves to the root file. This mirrors
|
||||
* Python's `sys.path` semantics where the project root is searched first.
|
||||
*
|
||||
* The ancestor walk mirrors the single-segment behavior in
|
||||
* `resolvePythonImportInternal`. For `from services.sync import X` in
|
||||
* `backend/routers/cron.py`, walk up: `backend/routers/services/sync.py` →
|
||||
* `backend/services/sync.py` ✓.
|
||||
*/
|
||||
function resolveAbsoluteFromFiles(pathLike: string, allFilePaths: Set<string>): string | null {
|
||||
function resolveAbsoluteFromFiles(
|
||||
pathLike: string,
|
||||
allFilePaths: Set<string>,
|
||||
fromFile: string,
|
||||
): string | null {
|
||||
const directFile = `${pathLike}.py`;
|
||||
const directPkg = `${pathLike}/__init__.py`;
|
||||
const suffixFile = `/${directFile}`;
|
||||
const suffixPkg = `/${directPkg}`;
|
||||
|
||||
let suffixMatch: string | null = null;
|
||||
for (const raw of allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (f === directFile || f === directPkg) return raw;
|
||||
if (suffixMatch === null && (f.endsWith(suffixFile) || f.endsWith(suffixPkg))) {
|
||||
suffixMatch = raw;
|
||||
// Direct hit at workspace root.
|
||||
if (allFilePaths.has(directFile)) return directFile;
|
||||
if (allFilePaths.has(directPkg)) return directPkg;
|
||||
|
||||
// Ancestor walk — match the single-segment resolver's behavior at
|
||||
// multi-segment granularity. Closest match wins. Stop at `i > 0` because
|
||||
// `i === 0` would re-check the workspace-root candidates already covered
|
||||
// by the direct check above.
|
||||
const importerDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
|
||||
if (importerDir) {
|
||||
const dirParts = importerDir.split('/').filter(Boolean);
|
||||
for (let i = dirParts.length; i > 0; i--) {
|
||||
const ancestor = dirParts.slice(0, i).join('/');
|
||||
const prefix = `${ancestor}/`;
|
||||
const candidateFile = `${prefix}${directFile}`;
|
||||
const candidatePkg = `${prefix}${directPkg}`;
|
||||
if (allFilePaths.has(candidateFile)) return candidateFile;
|
||||
if (allFilePaths.has(candidatePkg)) return candidatePkg;
|
||||
}
|
||||
}
|
||||
return suffixMatch;
|
||||
|
||||
// Suffix-match fallback (preserved for monorepo/nested-repo layouts
|
||||
// that don't share a directory ancestor with the importer).
|
||||
//
|
||||
// Tie-break order when multiple files match the same suffix:
|
||||
// 1. Fewest path segments (shorter, more canonical paths win — `lib/x.py`
|
||||
// beats `tooling/extras/x.py`).
|
||||
// 2. Lexicographic order over the normalized path (final stable
|
||||
// tiebreak independent of file-set insertion order).
|
||||
//
|
||||
// Without an explicit tie-break the previous implementation returned
|
||||
// the first match in `Set` iteration order, which depended on file
|
||||
// ingestion order and produced non-deterministic edges across runs in
|
||||
// multi-directory collision repos.
|
||||
const suffixFile = `/${directFile}`;
|
||||
const suffixPkg = `/${directPkg}`;
|
||||
const matches: { raw: string; norm: string }[] = [];
|
||||
for (const raw of allFilePaths) {
|
||||
const norm = raw.replace(/\\/g, '/');
|
||||
if (norm.endsWith(suffixFile) || norm.endsWith(suffixPkg)) {
|
||||
matches.push({ raw, norm });
|
||||
}
|
||||
}
|
||||
if (matches.length === 0) return null;
|
||||
if (matches.length === 1) return matches[0].raw;
|
||||
matches.sort((a, b) => {
|
||||
const aDepth = a.norm.split('/').length;
|
||||
const bDepth = b.norm.split('/').length;
|
||||
if (aDepth !== bDepth) return aDepth - bDepth;
|
||||
if (a.norm < b.norm) return -1;
|
||||
if (a.norm > b.norm) return 1;
|
||||
return 0;
|
||||
});
|
||||
return matches[0].raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the repo contain a module/package named `leadingSegment` at the top
|
||||
* level? Used to guard against false-positive suffix matches on external
|
||||
* dotted imports (e.g. `django.apps` matching a local `accounts/apps.py`).
|
||||
* Does the repo contain a module/package named `leadingSegment` somewhere
|
||||
* the importer can plausibly reach?
|
||||
*
|
||||
* Checks, in order: `<segment>.py` root file, `<segment>/__init__.py`
|
||||
* regular package, or any `<segment>/**.py` file (namespace package).
|
||||
* Used to guard against false-positive suffix matches on external dotted
|
||||
* imports (e.g. `django.apps` matching a local `accounts/apps.py`).
|
||||
*
|
||||
* Checks, in order:
|
||||
* 1. `SEGMENT.py` root file or `SEGMENT/__init__.py` regular package.
|
||||
* 2. Any `SEGMENT/...py` file at the workspace root (namespace package).
|
||||
* 3. Any `<importer-ancestor>/SEGMENT/...py` file (nested namespace
|
||||
* package the importer could reach via an ancestor walk, e.g.
|
||||
* `backend/services/sync.py` from `backend/routers/cron.py`).
|
||||
*
|
||||
* The nested case is bounded to the importer's own ancestors so a
|
||||
* vendored copy of an external package (e.g. `vendor/django/urls.py`)
|
||||
* does not gate-pass external imports like `from django.urls import path`
|
||||
* issued from `app/main.py`. Files inside the vendored tree itself
|
||||
* (importer under `vendor/django/...`) still resolve correctly because
|
||||
* the ancestor walk includes their own parents.
|
||||
*/
|
||||
function hasRepoCandidate(leadingSegment: string, allFilePaths: Set<string>): boolean {
|
||||
function hasRepoCandidate(
|
||||
leadingSegment: string,
|
||||
allFilePaths: Set<string>,
|
||||
fromFile: string,
|
||||
): boolean {
|
||||
const prefix = `${leadingSegment}/`;
|
||||
const rootFile = `${leadingSegment}.py`;
|
||||
const initFile = `${leadingSegment}/__init__.py`;
|
||||
|
||||
// Build importer-ancestor prefixes: for `backend/routers/cron.py`,
|
||||
// produces `["backend/routers/services/", "backend/services/"]` for
|
||||
// segment `services` (closest first, root excluded — covered above).
|
||||
const importerDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
|
||||
const dirParts = importerDir ? importerDir.split('/').filter(Boolean) : [];
|
||||
const ancestorPrefixes: string[] = [];
|
||||
for (let i = dirParts.length; i > 0; i--) {
|
||||
ancestorPrefixes.push(`${dirParts.slice(0, i).join('/')}/${leadingSegment}/`);
|
||||
}
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (f === rootFile || f === initFile) return true;
|
||||
if (f.startsWith(prefix) && f.endsWith('.py')) return true;
|
||||
if (f.endsWith('.py')) {
|
||||
for (const ap of ancestorPrefixes) {
|
||||
if (f.startsWith(ap)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { NodeLabel } from 'gitnexus-shared';
|
|||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { rubyClassConfig } from '../class-extractors/configs/ruby.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { typeConfig as rubyConfig } from '../type-extractors/ruby.js';
|
||||
import { routeRubyCall } from '../call-routing.js';
|
||||
|
|
@ -151,6 +152,31 @@ const rubyResolveEnclosingOwner = (node: SyntaxNode): SyntaxNode | null => {
|
|||
export const rubyProvider = defineLanguage({
|
||||
id: SupportedLanguages.Ruby,
|
||||
extensions: ['.rb', '.rake', '.gemspec'],
|
||||
entryPointPatterns: [/^call$/, /^perform$/, /^execute$/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'rails',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'rails-pattern',
|
||||
patterns: [
|
||||
'ApplicationController',
|
||||
'ApplicationRecord',
|
||||
'ActiveRecord::Base',
|
||||
'before_action',
|
||||
'after_action',
|
||||
'has_many',
|
||||
'belongs_to',
|
||||
'has_one',
|
||||
'validates',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'sinatra',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'sinatra-pattern',
|
||||
patterns: ['Sinatra::Base', 'Sinatra::Application'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: RUBY_QUERIES,
|
||||
typeConfig: rubyConfig,
|
||||
exportChecker: rubyExportChecker,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
|||
import { rustImportConfig } from '../import-resolvers/configs/rust.js';
|
||||
import { extractRustNamedBindings } from '../named-bindings/rust.js';
|
||||
import { RUST_QUERIES } from '../tree-sitter-queries.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { rustConfig as rustFieldConfig } from '../field-extractors/configs/rust.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
|
|
@ -121,6 +122,41 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
|||
export const rustProvider = defineLanguage({
|
||||
id: SupportedLanguages.Rust,
|
||||
extensions: ['.rs'],
|
||||
entryPointPatterns: [/^(get|post|put|delete)_handler$/i, /^handle_/, /^new$/, /^run$/, /^spawn/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'actix-web',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'actix-attribute',
|
||||
patterns: [
|
||||
'#[get',
|
||||
'#[post',
|
||||
'#[put',
|
||||
'#[delete',
|
||||
'#[actix_web',
|
||||
'HttpRequest',
|
||||
'HttpResponse',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'axum',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'axum-routing',
|
||||
patterns: ['Router::new', 'axum::extract', 'axum::routing'],
|
||||
},
|
||||
{
|
||||
framework: 'rocket',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'rocket-attribute',
|
||||
patterns: ['#[get', '#[post', '#[launch', 'rocket::'],
|
||||
},
|
||||
{
|
||||
framework: 'tokio',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'tokio-runtime',
|
||||
patterns: ['#[tokio::main]', '#[tokio::test]'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: RUST_QUERIES,
|
||||
typeConfig: rustConfig,
|
||||
exportChecker: rustExportChecker,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared';
|
|||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { swiftClassConfig } from '../class-extractors/configs/swift.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { typeConfig as swiftConfig } from '../type-extractors/swift.js';
|
||||
import { swiftExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
|
|
@ -259,6 +260,60 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
|||
export const swiftProvider = defineLanguage({
|
||||
id: SupportedLanguages.Swift,
|
||||
extensions: ['.swift'],
|
||||
entryPointPatterns: [
|
||||
/^viewDidLoad$/,
|
||||
/^viewWillAppear$/,
|
||||
/^viewDidAppear$/,
|
||||
/^viewWillDisappear$/,
|
||||
/^viewDidDisappear$/,
|
||||
/^application\(/,
|
||||
/^scene\(/,
|
||||
/^body$/,
|
||||
/Coordinator$/,
|
||||
/^sceneDidBecomeActive$/,
|
||||
/^sceneWillResignActive$/,
|
||||
/^didFinishLaunchingWithOptions$/,
|
||||
/ViewController$/,
|
||||
/^configure[A-Z]/,
|
||||
/^setup[A-Z]/,
|
||||
/^makeBody$/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'uikit',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'uikit-lifecycle',
|
||||
patterns: [
|
||||
'viewDidLoad',
|
||||
'viewWillAppear',
|
||||
'viewDidAppear',
|
||||
'UIViewController',
|
||||
'@IBOutlet',
|
||||
'@IBAction',
|
||||
'@objc',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'swiftui',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'swiftui-pattern',
|
||||
patterns: [
|
||||
'@main',
|
||||
'WindowGroup',
|
||||
'ContentView',
|
||||
'@StateObject',
|
||||
'@ObservedObject',
|
||||
'@EnvironmentObject',
|
||||
'@Published',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'vapor',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'vapor-routing',
|
||||
patterns: ['app.get', 'app.post', 'req.content.decode', 'Vapor'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: SWIFT_QUERIES,
|
||||
typeConfig: swiftConfig,
|
||||
exportChecker: swiftExportChecker,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import {
|
||||
typescriptClassConfig,
|
||||
|
|
@ -57,8 +58,30 @@ import {
|
|||
} from './typescript/index.js';
|
||||
|
||||
/**
|
||||
* TypeScript/JavaScript: arrow_function and function_expression get their name
|
||||
* from the parent variable_declarator (e.g. `const foo = () => {}`).
|
||||
* TypeScript/JavaScript: arrow_function and function_expression are
|
||||
* anonymous AST nodes — they take their name from the surrounding
|
||||
* declarative context.
|
||||
*
|
||||
* Recognised contexts:
|
||||
* - `const foo = () => {}` (variable_declarator) → "foo"
|
||||
* - `{ addItem: (item) => ... }` (pair / property_assignment) → "addItem"
|
||||
* Covers Zustand stores, TanStack Query factories, React Context
|
||||
* providers, and most other HOF-heavy idioms (issue #1166).
|
||||
* - `const X = HOC((args) => { ... })` (arguments → call_expression →
|
||||
* variable_declarator) → "X". Covers `React.forwardRef`, `memo`,
|
||||
* `useCallback`, `useMemo`, `observer`, `debounce`, and other HOC
|
||||
* factories that wrap their behaviour-defining arrow. Without this
|
||||
* branch, every shadcn/Radix UI component (`const Button =
|
||||
* React.forwardRef(...)`) registered as an anonymous arrow with
|
||||
* calls inside falling back to File-level attribution. The same
|
||||
* applied to all `useCallback` / `useMemo` callbacks bound to a
|
||||
* const — the sole way to give them a named caller anchor.
|
||||
*
|
||||
* Returns `null` for funcName when the arrow lives in a context that has
|
||||
* no static name — bare call arguments (not bound to a const), computed
|
||||
* keys, return-from-arrow positions. The parent walk in
|
||||
* findEnclosingFunctionId then continues up to the next named ancestor
|
||||
* (or to the file).
|
||||
*/
|
||||
const tsExtractFunctionName = (
|
||||
node: SyntaxNode,
|
||||
|
|
@ -66,19 +89,74 @@ const tsExtractFunctionName = (
|
|||
if (node.type !== 'arrow_function' && node.type !== 'function_expression') return null;
|
||||
|
||||
const parent = node.parent;
|
||||
if (parent?.type !== 'variable_declarator') return null;
|
||||
if (!parent) return null;
|
||||
|
||||
let nameNode = parent.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < parent.childCount; i++) {
|
||||
const c = parent.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
if (parent.type === 'variable_declarator') {
|
||||
let nameNode = parent.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < parent.childCount; i++) {
|
||||
const c = parent.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { funcName: nameNode?.text ?? null, label: 'Function' };
|
||||
}
|
||||
return { funcName: nameNode?.text ?? null, label: 'Function' };
|
||||
|
||||
// Object property pair: `{ addItem: (item) => ... }`.
|
||||
// tree-sitter-typescript uses `pair`; tree-sitter-javascript also exposes
|
||||
// `pair`. (Older grammars used `property_assignment`; we accept both.)
|
||||
if (parent.type === 'pair' || parent.type === 'property_assignment') {
|
||||
const keyNode = parent.childForFieldName?.('key');
|
||||
if (!keyNode) return { funcName: null, label: 'Function' };
|
||||
if (keyNode.type === 'property_identifier' || keyNode.type === 'identifier') {
|
||||
return { funcName: keyNode.text, label: 'Function' };
|
||||
}
|
||||
if (keyNode.type === 'string') {
|
||||
// `"add-item": () => ...` — the literal text inside the quotes.
|
||||
const fragment = keyNode.children?.find((c: SyntaxNode) => c.type === 'string_fragment');
|
||||
const text = fragment?.text ?? null;
|
||||
return { funcName: text, label: 'Function' };
|
||||
}
|
||||
// computed_property_name (`[ACTION_KEY]`) and other dynamic keys have
|
||||
// no static name — fall through anonymous.
|
||||
return { funcName: null, label: 'Function' };
|
||||
}
|
||||
|
||||
// HOC-wrapped variable declarations: `const Button = forwardRef((p, r) => { ... })`,
|
||||
// `const handleClick = useCallback(() => doStuff(), [deps])`,
|
||||
// `const Card = React.memo((props) => { ... })`. The arrow's `parent` is
|
||||
// `arguments`, grandparent is `call_expression`, great-grandparent is
|
||||
// `variable_declarator`. Walk the chain up and take the variable's name
|
||||
// — the meaningful identifier the developer wrote on the LHS. Mirrors
|
||||
// the four registry-primary patterns in `typescript/query.ts`. The
|
||||
// wrapping callee (`forwardRef`, `memo`, `React.memo`, `useCallback`,
|
||||
// user-defined HOCs) is intentionally NOT constrained: any function
|
||||
// call whose result is bound to a const and whose first/positional
|
||||
// argument is an arrow takes the const's name. Chained array-method
|
||||
// calls (`const x = arr.find((y) => p(y))`) match too and produce a
|
||||
// mostly-harmless `Function:x` (consumed as a value, never invoked),
|
||||
// accepted as a small false-positive cost vs. the much larger gain of
|
||||
// capturing the React UI-component idiom.
|
||||
if (parent.type === 'arguments') {
|
||||
const callExpr = parent.parent;
|
||||
if (!callExpr || callExpr.type !== 'call_expression') {
|
||||
return { funcName: null, label: 'Function' };
|
||||
}
|
||||
const declarator = callExpr.parent;
|
||||
if (!declarator || declarator.type !== 'variable_declarator') {
|
||||
return { funcName: null, label: 'Function' };
|
||||
}
|
||||
const nameNode = declarator.childForFieldName?.('name');
|
||||
if (nameNode?.type === 'identifier') {
|
||||
return { funcName: nameNode.text, label: 'Function' };
|
||||
}
|
||||
return { funcName: null, label: 'Function' };
|
||||
}
|
||||
|
||||
return { funcName: null, label: 'Function' };
|
||||
};
|
||||
|
||||
export const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
|
|
@ -181,6 +259,29 @@ export const BUILT_INS: ReadonlySet<string> = new Set([
|
|||
export const typescriptProvider = defineLanguage({
|
||||
id: SupportedLanguages.TypeScript,
|
||||
extensions: ['.ts', '.tsx'],
|
||||
entryPointPatterns: [/^use[A-Z]/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'nestjs',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'nestjs-decorator',
|
||||
patterns: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'],
|
||||
},
|
||||
{
|
||||
framework: 'expo-router',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'expo-router-navigation',
|
||||
patterns: [
|
||||
'router.push',
|
||||
'router.replace',
|
||||
'router.navigate',
|
||||
'useRouter',
|
||||
'useLocalSearchParams',
|
||||
'useSegments',
|
||||
'expo-router',
|
||||
],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: TYPESCRIPT_QUERIES,
|
||||
typeConfig: typescriptConfig,
|
||||
exportChecker: tsExportChecker,
|
||||
|
|
@ -220,6 +321,29 @@ export const typescriptProvider = defineLanguage({
|
|||
export const javascriptProvider = defineLanguage({
|
||||
id: SupportedLanguages.JavaScript,
|
||||
extensions: ['.js', '.jsx'],
|
||||
entryPointPatterns: [/^use[A-Z]/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'nestjs',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'nestjs-decorator',
|
||||
patterns: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'],
|
||||
},
|
||||
{
|
||||
framework: 'expo-router',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'expo-router-navigation',
|
||||
patterns: [
|
||||
'router.push',
|
||||
'router.replace',
|
||||
'router.navigate',
|
||||
'useRouter',
|
||||
'useLocalSearchParams',
|
||||
'useSegments',
|
||||
'expo-router',
|
||||
],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: JAVASCRIPT_QUERIES,
|
||||
typeConfig: typescriptConfig,
|
||||
exportChecker: tsExportChecker,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ function pickFirstDefined(grouped: CaptureMatch, tags: readonly string[]): Captu
|
|||
* as `@reference.write.member`).
|
||||
* 4. The member_expression is the `function:` of an `await_expression`
|
||||
* being called (handled by the member-call capture).
|
||||
* 5. The member_expression is the `name:` of a `jsx_self_closing_element`
|
||||
* or `jsx_opening_element` (it's a JSX component invocation, already
|
||||
* captured as `@reference.call.member` by the TSX-only query suffix).
|
||||
* Without this filter, `<Foo.Bar />` would emit a phantom ACCESSES
|
||||
* edge to `Foo.Bar` IN ADDITION to the CALLS edge.
|
||||
*
|
||||
* Returns `true` when the capture should be kept as a read reference,
|
||||
* `false` when it should be dropped.
|
||||
|
|
@ -99,6 +104,9 @@ function shouldEmitReadMember(memberNode: SyntaxNode): boolean {
|
|||
case 'assignment_expression':
|
||||
case 'augmented_assignment_expression':
|
||||
return parent.childForFieldName('left')?.id !== memberNode.id;
|
||||
case 'jsx_self_closing_element':
|
||||
case 'jsx_opening_element':
|
||||
return parent.childForFieldName('name')?.id !== memberNode.id;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
|
|
@ -232,6 +240,20 @@ export function emitTsScopeCaptures(
|
|||
// arity filter can narrow overloads. Count the `argument` named
|
||||
// children of the backing `arguments` node. TypeScript constructor
|
||||
// calls use `new_expression`; regular calls use `call_expression`.
|
||||
//
|
||||
// JSX call anchors (`jsx_self_closing_element` / `jsx_opening_element`
|
||||
// captured by the TSX-only suffix in `query.ts`) intentionally do
|
||||
// NOT carry arity metadata. The lookup below would resolve `callNode`
|
||||
// to `null` for a JSX anchor (the anchor is neither a call_expression
|
||||
// nor a new_expression), so the synthesis branch silently no-ops and
|
||||
// the JSX call enters the registry with name-only resolution. This
|
||||
// is acceptable for React: components are virtually never
|
||||
// overloaded in the current GitNexus graph model, so name-only
|
||||
// dispatch matches the single component definition. If a future
|
||||
// codebase introduces overloaded React components AND needs JSX
|
||||
// calls to disambiguate by props-arity, a JSX-aware arity
|
||||
// synthesizer would need to count `jsx_attribute` children of the
|
||||
// opening tag instead of `arguments`.
|
||||
const callAnchor = pickFirstDefined(grouped, CALL_TAGS);
|
||||
if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
const callNode =
|
||||
|
|
|
|||
|
|
@ -136,25 +136,172 @@ const TYPESCRIPT_SCOPE_QUERY = `
|
|||
;; Arrow/function-expression assigned to a const/let/var — named by the
|
||||
;; variable_declarator. Covers \`const fn = () => {}\` and its export
|
||||
;; variant. Matches the legacy TYPESCRIPT_QUERIES pattern.
|
||||
;;
|
||||
;; The \`@declaration.function\` anchor sits on the INNER arrow_function /
|
||||
;; function_expression node (NOT the wrapping lexical_declaration), so
|
||||
;; \`anchor.range\` aligns with the corresponding \`@scope.function\` scope
|
||||
;; range. \`pass2AttachDeclarations\` then resolves \`innermost\` to the
|
||||
;; arrow's own scope (instead of the module scope) and the def is owned
|
||||
;; by the arrow itself. Without this alignment, calls inside the arrow
|
||||
;; body lose caller attribution: \`resolveCallerGraphId\` walks up past
|
||||
;; the empty arrow scope into the module scope and grabs whichever
|
||||
;; Function-like def appears first there — silently mis-attributing
|
||||
;; every nested call (Zustand stores, TanStack hooks, Promise-all/map,
|
||||
;; etc.). See \`typescript-hof-callbacks.test.ts\`.
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (arrow_function))) @declaration.function
|
||||
value: (arrow_function) @declaration.function))
|
||||
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (function_expression))) @declaration.function
|
||||
value: (function_expression) @declaration.function))
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (arrow_function))) @declaration.function
|
||||
value: (arrow_function) @declaration.function))
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (function_expression))) @declaration.function
|
||||
value: (function_expression) @declaration.function))
|
||||
|
||||
;; Object-property arrows / function expressions named by their pair key:
|
||||
;; \`{ addItem: (item) => ..., removeItem: (item) => ... }\`. The legacy
|
||||
;; TYPESCRIPT_QUERIES emits the same shape; mirroring it here keeps
|
||||
;; scope-resolution declarations in sync (issue #1166). Computed keys
|
||||
;; (\`[K]: () => ...\`) intentionally fall through anonymous.
|
||||
;;
|
||||
;; Same anchor discipline as the \`lexical_declaration\` block above: the
|
||||
;; \`@declaration.function\` capture must sit on the INNER \`arrow_function\`
|
||||
;; / \`function_expression\` node — NOT the outer \`pair\`. The pair node
|
||||
;; starts at the property-key token, BEFORE the arrow's
|
||||
;; \`@scope.function\` range. \`pass2AttachDeclarations.atPosition(pair.startLine,
|
||||
;; pair.startCol)\` therefore resolves to the PARENT scope (the enclosing
|
||||
;; function-like, e.g. the \`(set) => ({...})\` callback in
|
||||
;; \`persist((set) => ({...}))\`), not the inner arrow's own scope.
|
||||
;;
|
||||
;; With the anchor on \`pair\`, ALL pair-function defs from the same object
|
||||
;; literal land in the same parent scope's \`ownedDefs\`. \`resolveCallerGraphId\`
|
||||
;; walking up from a call inside any of those arrows then matches the
|
||||
;; FIRST Function-like def via \`ownedDefs.find()\` — silently mis-attributing
|
||||
;; every call to the first sibling. Multi-action Zustand stores
|
||||
;; (\`{ addItem, removeItem, fetchData, … }\`) — the dominant 0%-capture
|
||||
;; pattern in the bug report — would land all calls on \`addItem\`.
|
||||
;;
|
||||
;; With the anchor on the inner \`arrow_function\` / \`function_expression\`,
|
||||
;; \`anchor.range\` matches the arrow's own \`@scope.function\` range; the
|
||||
;; def lands in the arrow scope's own \`ownedDefs\` and \`pass2AttachDeclarations\`'s
|
||||
;; auto-hoist (\`rangesEqual(anchor.range, innermost.range)\`) promotes
|
||||
;; the BINDING to the parent scope (so importers and lookups still find
|
||||
;; the name in the object's surrounding scope). Each pair-arrow becomes
|
||||
;; an independent caller anchor in the walk.
|
||||
(pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (arrow_function) @declaration.function)
|
||||
|
||||
(pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (function_expression) @declaration.function)
|
||||
|
||||
(pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (arrow_function) @declaration.function)
|
||||
|
||||
(pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (function_expression) @declaration.function)
|
||||
|
||||
;; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`.
|
||||
;;
|
||||
;; Covers the dominant React UI idiom (\`React.forwardRef\`, \`React.memo\`,
|
||||
;; bare \`forwardRef\` / \`memo\` / \`observer\`), Hook callbacks
|
||||
;; (\`useCallback\`, \`useMemo\`), and library-wrapper factories (\`debounce\`,
|
||||
;; \`throttle\`, user-defined \`withErrorBoundary\` / \`createHook\`, etc.).
|
||||
;; All produce the same AST shape:
|
||||
;;
|
||||
;; lexical_declaration
|
||||
;; variable_declarator
|
||||
;; name: identifier "X" ← we want this name
|
||||
;; value: call_expression
|
||||
;; function: identifier | member_expression ← any callee
|
||||
;; arguments: arguments
|
||||
;; arrow_function | function_expression ← the actual code
|
||||
;;
|
||||
;; The pre-fix \`tsExtractFunctionName\` only handled \`variable_declarator\`
|
||||
;; and \`pair\` parents, so HOC-wrapped arrows fell through anonymous. The
|
||||
;; registry-primary \`query.ts\` had no pattern for this shape either —
|
||||
;; \`const Button = forwardRef((p, r) => { ... })\` registered as a
|
||||
;; \`Variable\` with no \`Function\` def, and every call inside the arrow
|
||||
;; body lost caller attribution: \`resolveCallerGraphId\` walked up past
|
||||
;; the empty arrow scope to the module's File fallback. Sourcerer-fe alone
|
||||
;; has ~296 such declarations (57 forwardRef + 21 memo + 161 useCallback
|
||||
;; + 57 useMemo) — all invisible to \`gitnexus_context\` /
|
||||
;; \`gitnexus_impact\` for outgoing edges before this fix.
|
||||
;;
|
||||
;; Anchor discipline: same as the \`lexical_declaration\` / \`pair\` blocks
|
||||
;; above — on the INNER \`arrow_function\` / \`function_expression\`, NOT
|
||||
;; the outer \`call_expression\`. The arrow's range matches its own
|
||||
;; \`@scope.function\` range, so \`pass2AttachDeclarations.atPosition\`
|
||||
;; resolves \`innermost\` to the arrow's own scope and
|
||||
;; \`rangesEqual(anchor.range, innermost.range)\` triggers the auto-hoist
|
||||
;; that promotes the binding to the parent scope (where \`const X\`
|
||||
;; lives).
|
||||
;;
|
||||
;; Trade-off — chained array-method form: \`const x = arr.find((y) => p(y))\`
|
||||
;; has the same syntactic shape and would also match, naming the
|
||||
;; \`.find\` callback as \`x\`. The resulting \`Function:x\` is mostly
|
||||
;; harmless: \`x\` is consumed as a value (\`if (x) { ... }\`), never
|
||||
;; invoked as a function, so it gets zero incoming \`CALLS\` edges. The
|
||||
;; one outgoing edge \`Function:x → p\` is a minor mis-attribution that
|
||||
;; could in principle be fixed by adding a \`function: [(identifier)
|
||||
;; (member_expression)]\` predicate that excludes property-identifiers
|
||||
;; matching a known array-method blocklist (\`map\` / \`filter\` / \`find\`
|
||||
;; / \`reduce\` / \`forEach\` / \`some\` / \`every\`). We don't do that here
|
||||
;; because (a) the false-positive cost is negligible, (b) the blocklist
|
||||
;; would need maintenance, and (c) any user-defined fluent-API method
|
||||
;; with a callback argument would still false-positive — there's no
|
||||
;; clean syntactic line.
|
||||
;;
|
||||
;; Trade-off — multi-arrow arguments: \`const x = call(arrow1, arrow2)\`
|
||||
;; would emit TWO matches with the same name \`x\`. tree-sitter-query
|
||||
;; iterates all arrow_function direct children of \`arguments\`, so each
|
||||
;; emits its own \`(name=x, function=...)\` pair. \`pass2AttachDeclarations\`
|
||||
;; pushes both \`Function:x\` defs into the same arrow scopes (each in
|
||||
;; its own arrow's \`ownedDefs\`) and hoists both bindings to the parent.
|
||||
;; The downstream registry's qualified-name dedup then collapses them
|
||||
;; via \`(filePath, type, qualifiedName)\` — second wins. Acceptable;
|
||||
;; multi-arrow-callback APIs are rare (\`new Promise(executor)\` is the
|
||||
;; main one and takes a single executor).
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function))))
|
||||
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function))))
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function))))
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function))))
|
||||
|
||||
;; Method definitions — regular + private (#field) methods.
|
||||
(method_definition
|
||||
|
|
@ -723,6 +870,53 @@ const TYPESCRIPT_SCOPE_QUERY = `
|
|||
property: (property_identifier) @reference.name) @reference.read.member
|
||||
`;
|
||||
|
||||
/**
|
||||
* JSX-only query suffix. Appended to the base query when compiling
|
||||
* against the TSX grammar; NOT compiled against the plain TS grammar
|
||||
* (which has no \`jsx_*\` node types and would reject these patterns).
|
||||
*
|
||||
* Why JSX as a CALLS edge: \`<Foo />\` is syntactic sugar for \`Foo(props)\`
|
||||
* and the React component is invoked by the renderer, so for blast-radius
|
||||
* (\`gitnexus_impact("Badge", direction: "upstream")\`) and call-graph
|
||||
* (\`gitnexus_context("Foo")\`) purposes JSX usage IS a call. Routing
|
||||
* through \`@reference.call.free\` / \`@reference.call.member\` makes the
|
||||
* downstream caller-walk + edge-emission paths handle JSX uniformly with
|
||||
* ordinary call expressions — no new edge type, no schema changes.
|
||||
*
|
||||
* Identifier-only JSX is filtered to PascalCase via \`(#match? ... "^[A-Z]")\`
|
||||
* so \`<div>\`, \`<span>\`, \`<button>\` and other native HTML elements (which
|
||||
* by JSX convention start lowercase) don't emit edges to nonexistent
|
||||
* "div" / "span" symbols. Member-form JSX (\`<Foo.Bar />\`) is always a
|
||||
* component (HTML element names can't contain dots), so no predicate
|
||||
* filter is applied there.
|
||||
*
|
||||
* Both \`jsx_self_closing_element\` (\`<Foo />\`) and \`jsx_opening_element\`
|
||||
* (\`<Foo>...</Foo>\`) emit; the closing tag is intentionally NOT captured —
|
||||
* each JSX element should emit exactly one CALLS edge per use site.
|
||||
*/
|
||||
const TSX_JSX_QUERY_SUFFIX = `
|
||||
;; <Foo />
|
||||
((jsx_self_closing_element
|
||||
name: (identifier) @reference.name) @reference.call.free
|
||||
(#match? @reference.name "^[A-Z]"))
|
||||
|
||||
;; <Foo> ... </Foo> (paired form — match the opening tag only)
|
||||
((jsx_opening_element
|
||||
name: (identifier) @reference.name) @reference.call.free
|
||||
(#match? @reference.name "^[A-Z]"))
|
||||
|
||||
;; <Foo.Bar /> / <Container.Section.Title /> — namespaced JSX
|
||||
(jsx_self_closing_element
|
||||
name: (member_expression
|
||||
object: (_) @reference.receiver
|
||||
property: (property_identifier) @reference.name)) @reference.call.member
|
||||
|
||||
(jsx_opening_element
|
||||
name: (member_expression
|
||||
object: (_) @reference.receiver
|
||||
property: (property_identifier) @reference.name)) @reference.call.member
|
||||
`;
|
||||
|
||||
let _tsParser: Parser | null = null;
|
||||
let _tsxParser: Parser | null = null;
|
||||
let _tsQuery: Parser.Query | null = null;
|
||||
|
|
@ -753,11 +947,18 @@ export function getTsParser(filePath?: string): Parser {
|
|||
* executed against a Tree produced by the `tsx` grammar — tree-sitter
|
||||
* matches by node-type id, and the two grammars have separate id
|
||||
* spaces.
|
||||
*
|
||||
* The TSX query is compiled with the JSX-as-call patterns appended.
|
||||
* Those patterns reference `jsx_self_closing_element` /
|
||||
* `jsx_opening_element` which exist only in the TSX grammar — embedding
|
||||
* them in the plain TS query would throw `Query.InvalidNodeType` at
|
||||
* compile time (and even if it didn't, the patterns would never fire on
|
||||
* `.ts` source).
|
||||
*/
|
||||
export function getTsScopeQuery(filePath?: string): Parser.Query {
|
||||
if (filePath !== undefined && isTsxFile(filePath)) {
|
||||
if (_tsxQuery === null) {
|
||||
_tsxQuery = new Parser.Query(TSX_GRAMMAR, TYPESCRIPT_SCOPE_QUERY);
|
||||
_tsxQuery = new Parser.Query(TSX_GRAMMAR, TYPESCRIPT_SCOPE_QUERY + TSX_JSX_QUERY_SUFFIX);
|
||||
}
|
||||
return _tsxQuery;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ const vueClassExtractor = createClassExtractor(vueClassConfig);
|
|||
export const vueProvider = defineLanguage({
|
||||
id: SupportedLanguages.Vue,
|
||||
extensions: ['.vue'],
|
||||
entryPointPatterns: [],
|
||||
astFrameworkPatterns: [],
|
||||
treeSitterQueries: TYPESCRIPT_QUERIES,
|
||||
typeConfig: typescriptConfig,
|
||||
exportChecker: tsExportChecker,
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ export function extract(
|
|||
|
||||
// ── Pass 1: build the scope tree ─────────────────────────────────────
|
||||
const scopeDrafts = pass1BuildScopes(partitioned.scope, filePath, provider);
|
||||
const moduleScope = ensureModuleScope(scopeDrafts, matches.length, filePath);
|
||||
const scopes = scopeDrafts.map(draftToScope);
|
||||
// buildScopeTree validates invariants (throws on violation) and exposes
|
||||
// the lookup contract consumed by Passes 2-5.
|
||||
|
|
@ -136,14 +137,6 @@ export function extract(
|
|||
const scopeTree = buildScopeTree(scopes);
|
||||
const positionIndex = buildPositionIndex(scopes);
|
||||
|
||||
const moduleScope = scopeDrafts.find((s) => s.kind === 'Module');
|
||||
if (moduleScope === undefined) {
|
||||
throw new Error(
|
||||
`ScopeExtractor: no Module scope found for '${filePath}'. ` +
|
||||
`Provider must emit at least one @scope.module capture per file.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pass 2: attach declarations + local bindings ────────────────────
|
||||
const localDefs: SymbolDefinition[] = [];
|
||||
pass2AttachDeclarations(
|
||||
|
|
@ -283,6 +276,33 @@ interface ScopeDraft {
|
|||
readonly typeBindings: Map<string, TypeRef>;
|
||||
}
|
||||
|
||||
function ensureModuleScope(
|
||||
scopeDrafts: ScopeDraft[],
|
||||
matchCount: number,
|
||||
filePath: string,
|
||||
): ScopeDraft {
|
||||
const moduleScope = scopeDrafts.find((s) => s.kind === 'Module');
|
||||
if (moduleScope !== undefined) return moduleScope;
|
||||
|
||||
if (scopeDrafts.length === 0 && matchCount === 0) {
|
||||
const range: Range = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 };
|
||||
const synthetic = makeDraft(
|
||||
makeScopeId({ filePath, range, kind: 'Module' }),
|
||||
null,
|
||||
'Module',
|
||||
range,
|
||||
filePath,
|
||||
);
|
||||
scopeDrafts.push(synthetic);
|
||||
return synthetic;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`ScopeExtractor: no Module scope found for '${filePath}'. ` +
|
||||
`Provider must emit at least one @scope.module capture per file.`,
|
||||
);
|
||||
}
|
||||
|
||||
function draftToScope(draft: ScopeDraft): Scope {
|
||||
const frozenBindings = new Map<string, readonly BindingRef[]>();
|
||||
for (const [name, refs] of draft.bindings) {
|
||||
|
|
|
|||
|
|
@ -20,12 +20,38 @@
|
|||
import type { NodeLabel, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import {
|
||||
isLinkableLabel,
|
||||
qualifiedKey,
|
||||
simpleKey,
|
||||
type GraphNodeLookup,
|
||||
} from '../graph-bridge/node-lookup.js';
|
||||
import { qualifiedKey, simpleKey, type GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
|
||||
/**
|
||||
* Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the
|
||||
* source ("caller"). A Variable / Property can be the TARGET of an
|
||||
* edge (e.g., a write-access to `user.name`), but it cannot be a
|
||||
* caller — variables don't execute code, so attributing a call to a
|
||||
* sibling Variable in the same scope produces nonsense edges like
|
||||
* `Variable:create → Function:create` (which the simpleKey fallback
|
||||
* in `resolveDefGraphId` then silently rewrites to
|
||||
* `Function:create → Function:create`, a self-loop that doesn't exist
|
||||
* in the source).
|
||||
*
|
||||
* Module-level call expressions inside a `const X = expr(args)`
|
||||
* declaration are the canonical case where this used to fail: the
|
||||
* walk-up over module scope's ownedDefs (only Variables) would land
|
||||
* on the FIRST Variable, get name-aliased to its sibling Function
|
||||
* with the same simple name, and emit a self-CALLS. With this label
|
||||
* restricted to function/class-likes, those calls correctly fall
|
||||
* through to the File-node fallback at the bottom of the walk.
|
||||
*/
|
||||
function isCallerAnchorLabel(label: NodeLabel): boolean {
|
||||
return (
|
||||
label === 'Function' ||
|
||||
label === 'Method' ||
|
||||
label === 'Constructor' ||
|
||||
label === 'Class' ||
|
||||
label === 'Interface' ||
|
||||
label === 'Struct' ||
|
||||
label === 'Enum'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a `SymbolDefinition` in the graph node lookup.
|
||||
|
|
@ -105,7 +131,9 @@ export function resolveCallerGraphId(
|
|||
if (scope === undefined) break;
|
||||
lastFilePath = scope.filePath;
|
||||
|
||||
// Prefer Function/Method anchors; fall back to Class.
|
||||
// Prefer Function/Method/Constructor anchors; fall back to
|
||||
// Class/Interface/Struct/Enum. Variable/Property are NOT valid
|
||||
// caller anchors — see `isCallerAnchorLabel` for why.
|
||||
const fnDef = scope.ownedDefs.find(
|
||||
(d) => d.type === 'Function' || d.type === 'Method' || d.type === 'Constructor',
|
||||
);
|
||||
|
|
@ -113,7 +141,7 @@ export function resolveCallerGraphId(
|
|||
const id = resolveDefGraphId(scope.filePath, fnDef, nodeLookup);
|
||||
if (id !== undefined) return id;
|
||||
}
|
||||
const classDef = scope.ownedDefs.find((d) => isLinkableLabel(d.type));
|
||||
const classDef = scope.ownedDefs.find((d) => isCallerAnchorLabel(d.type));
|
||||
if (classDef !== undefined) {
|
||||
const id = resolveDefGraphId(scope.filePath, classDef, nodeLookup);
|
||||
if (id !== undefined) return id;
|
||||
|
|
|
|||
|
|
@ -61,6 +61,86 @@ export const TYPESCRIPT_QUERIES = `
|
|||
name: (identifier) @name
|
||||
value: (function_expression)))) @definition.function
|
||||
|
||||
; Object-property arrows / function expressions: \`{ addItem: () => ... }\`.
|
||||
; The pair's key field carries the meaningful name. Without these patterns,
|
||||
; calls inside the arrow are attributed to the file (issue #1166), and the
|
||||
; arrow itself is invisible to context() / impact() despite carrying real
|
||||
; behaviour (Zustand actions, TanStack queryFn, React Context providers).
|
||||
; String-key variant covers \`"add-item": () => ...\`; computed keys
|
||||
; (\`[K]: () => ...\`) intentionally fall through anonymous.
|
||||
(pair
|
||||
key: (property_identifier) @name
|
||||
value: (arrow_function)) @definition.function
|
||||
|
||||
(pair
|
||||
key: (property_identifier) @name
|
||||
value: (function_expression)) @definition.function
|
||||
|
||||
(pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (arrow_function)) @definition.function
|
||||
|
||||
(pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (function_expression)) @definition.function
|
||||
|
||||
; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`.
|
||||
; Mirrors the registry-primary patterns in \`languages/typescript/query.ts\`
|
||||
; so the legacy Call-Resolution DAG and the registry-primary pipeline
|
||||
; produce the same set of \`Function\` nodes — required for the CI parity
|
||||
; gate. Covers React.forwardRef / memo / useCallback / useMemo / observer
|
||||
; / debounce / user-defined HOC factories. The \`var X = HOC(...)\` form is
|
||||
; mirrored too (registry-primary has it) so that codebases mixing \`var\` and
|
||||
; \`const\` see identical attribution on both pipelines. See
|
||||
; \`tsExtractFunctionName\` for the resolution logic and the \`query.ts\`
|
||||
; comment for the full anchor-discipline rationale and the chained-
|
||||
; array-method trade-off.
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function))))) @definition.function
|
||||
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression))))) @definition.function
|
||||
|
||||
(export_statement
|
||||
declaration: (lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function)))))) @definition.function
|
||||
|
||||
(export_statement
|
||||
declaration: (lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression)))))) @definition.function
|
||||
|
||||
; \`var X = HOC(...)\` parity with registry-primary. Legacy code (and any
|
||||
; transpiler output that downlevels \`const\` to \`var\`) hits this shape.
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function))))) @definition.function
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression))))) @definition.function
|
||||
|
||||
; Variable/constant declarations (non-function values).
|
||||
; Overlap with @definition.function patterns is handled by parse-worker dedup.
|
||||
(lexical_declaration
|
||||
|
|
@ -219,6 +299,75 @@ export const JAVASCRIPT_QUERIES = `
|
|||
name: (identifier) @name
|
||||
value: (function_expression)))) @definition.function
|
||||
|
||||
; Object-property arrows / function expressions: \`{ addItem: () => ... }\`.
|
||||
; See TYPESCRIPT_QUERIES for rationale (issue #1166).
|
||||
(pair
|
||||
key: (property_identifier) @name
|
||||
value: (arrow_function)) @definition.function
|
||||
|
||||
(pair
|
||||
key: (property_identifier) @name
|
||||
value: (function_expression)) @definition.function
|
||||
|
||||
(pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (arrow_function)) @definition.function
|
||||
|
||||
(pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (function_expression)) @definition.function
|
||||
|
||||
; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`.
|
||||
; See TYPESCRIPT_QUERIES section above for the full rationale (issue #1166
|
||||
; follow-up — covers forwardRef / memo / useCallback / useMemo / observer
|
||||
; / debounce / user-defined HOC factories). Both \`const\` and \`var\` forms
|
||||
; are mirrored so JS code that uses \`var\` (or transpiler output) gets the
|
||||
; same attribution as the registry-primary path.
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function))))) @definition.function
|
||||
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression))))) @definition.function
|
||||
|
||||
(export_statement
|
||||
declaration: (lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function)))))) @definition.function
|
||||
|
||||
(export_statement
|
||||
declaration: (lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression)))))) @definition.function
|
||||
|
||||
; \`var X = HOC(...)\` parity with registry-primary.
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function))))) @definition.function
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression))))) @definition.function
|
||||
|
||||
; Variable/constant declarations (non-function values).
|
||||
; Overlap with @definition.function patterns is handled by parse-worker dedup.
|
||||
(lexical_declaration
|
||||
|
|
|
|||
|
|
@ -429,10 +429,24 @@ export const findSiblingChild = (
|
|||
|
||||
/** Generic name extraction from a function-like AST node.
|
||||
* Tries `node.childForFieldName('name')?.text`, then scans children for
|
||||
* `identifier` / `property_identifier` / `simple_identifier`. */
|
||||
* `identifier` / `property_identifier` / `simple_identifier`.
|
||||
*
|
||||
* `arrow_function` and `function_expression` (TS/JS) are inherently
|
||||
* anonymous — they have no `name` field, and their first identifier
|
||||
* child is a *parameter*, not a function name. Returning a parameter
|
||||
* identifier here would synthesize phantom Function IDs (e.g. callers
|
||||
* walking up from a call inside `arr.map(x => fn(x))` would get
|
||||
* attributed to a non-existent "Function x"). The language's
|
||||
* `methodExtractor.extractFunctionName` hook is responsible for naming
|
||||
* these via parent context (variable_declarator, pair, etc.); when it
|
||||
* declines, the parent walk should continue rather than fall through
|
||||
* here. See issue #1166. */
|
||||
export const genericFuncName = (node: SyntaxNode): string | null => {
|
||||
const nameField = node.childForFieldName?.('name');
|
||||
if (nameField) return nameField.text;
|
||||
if (node.type === 'arrow_function' || node.type === 'function_expression') {
|
||||
return null;
|
||||
}
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { spawn } from 'child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { LBUG_MAX_DB_SIZE } from './lbug-config.js';
|
||||
|
||||
const DEFAULT_EXTENSION_INSTALL_TIMEOUT_MS = 15_000;
|
||||
const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
|
||||
|
|
@ -60,9 +61,12 @@ export const getExtensionInstallTimeoutMs = (): number => {
|
|||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_EXTENSION_INSTALL_TIMEOUT_MS;
|
||||
};
|
||||
|
||||
export const getExtensionInstallChildProcessArgs = (extensionName: string): string[] => {
|
||||
export const getExtensionInstallChildProcessArgs = (
|
||||
extensionName: string,
|
||||
maxDbSize: number = LBUG_MAX_DB_SIZE,
|
||||
): string[] => {
|
||||
const childScript = new URL('../../../scripts/install-duckdb-extension.mjs', import.meta.url);
|
||||
return [fileURLToPath(childScript), extensionName];
|
||||
return [fileURLToPath(childScript), extensionName, String(maxDbSize)];
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ import {
|
|||
import { streamAllCSVsToDisk } from './csv-generator.js';
|
||||
import type { CachedEmbedding } from '../embeddings/types.js';
|
||||
import { extensionManager, type ExtensionEnsureOptions } from './extension-loader.js';
|
||||
import {
|
||||
closeLbugConnection,
|
||||
openLbugConnection,
|
||||
type LbugConnectionHandle,
|
||||
} from './lbug-config.js';
|
||||
import { isVectorExtensionSupportedByPlatform } from '../platform/capabilities.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -147,15 +152,16 @@ let ftsLoaded = false;
|
|||
let vectorExtensionLoaded = false;
|
||||
|
||||
/**
|
||||
* In-process cache of FTS indexes that have been ensured against the current
|
||||
* writable connection. Prevents repeated `CALL CREATE_FTS_INDEX` round-trips
|
||||
* for callers that explicitly opt into `ensureFTSIndex`. Cleared by
|
||||
* `closeLbug` so a re-init starts fresh.
|
||||
* In-process cache of FTS indexes observed against the current singleton
|
||||
* connection. Avoids repeated `CALL CREATE_FTS_INDEX` calls, which can trip
|
||||
* native duplicate-index/WAL edge cases. Cleared on re-init and close.
|
||||
*
|
||||
* Key format: `${tableName}:${indexName}`.
|
||||
*/
|
||||
const ensuredFTSIndexes = new Set<string>();
|
||||
|
||||
const ftsIndexKey = (tableName: string, indexName: string): string => `${tableName}:${indexName}`;
|
||||
|
||||
/**
|
||||
* Check if an error indicates a missing column or table (schema-level problem)
|
||||
* rather than a transient/connection error. Used for legacy DB fallback logic.
|
||||
|
|
@ -193,6 +199,19 @@ export const isDbBusyError = (err: unknown): boolean => {
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return true when the error message indicates a write was attempted against
|
||||
* a read-only LadybugDB connection. The MCP query pool opens DBs read-only,
|
||||
* so any path that calls a `CREATE_*` procedure there will surface this
|
||||
* (e.g. defensive `ensureFTSIndex` calls). Owners of the writable analyze
|
||||
* path should ignore this error — index creation is owned by `gitnexus
|
||||
* analyze` and either already happened or will happen on the next run.
|
||||
*/
|
||||
export const isReadOnlyDbError = (err: unknown): boolean => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return /read-only database/i.test(msg);
|
||||
};
|
||||
|
||||
const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const previous = sessionLock;
|
||||
let release: (() => void) | null = null;
|
||||
|
|
@ -253,6 +272,7 @@ export const withLbugDb = async <T>(dbPath: string, operation: () => Promise<T>)
|
|||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
vectorExtensionLoaded = false;
|
||||
ensuredFTSIndexes.clear();
|
||||
});
|
||||
// Sleep outside the lock — no need to block others while waiting
|
||||
await new Promise((resolve) => setTimeout(resolve, DB_LOCK_RETRY_DELAY_MS * attempt));
|
||||
|
|
@ -285,6 +305,7 @@ const doInitLbug = async (dbPath: string) => {
|
|||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
vectorExtensionLoaded = false;
|
||||
ensuredFTSIndexes.clear();
|
||||
}
|
||||
|
||||
// LadybugDB stores the database as a single file (not a directory).
|
||||
|
|
@ -317,14 +338,14 @@ const doInitLbug = async (dbPath: string) => {
|
|||
const parentDir = path.dirname(dbPath);
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
|
||||
db = new lbug.Database(dbPath);
|
||||
conn = new lbug.Connection(db);
|
||||
const opened = await openLbugConnection(lbug, dbPath);
|
||||
db = opened.db;
|
||||
conn = opened.conn;
|
||||
|
||||
for (const schemaQuery of SCHEMA_QUERIES) {
|
||||
try {
|
||||
await conn.query(schemaQuery);
|
||||
} catch (err) {
|
||||
// Only ignore "already exists" errors - log everything else
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes('already exists')) {
|
||||
console.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
|
||||
|
|
@ -664,18 +685,12 @@ export const insertNodeToLbug = async (
|
|||
|
||||
// Use per-query connection if dbPath provided (avoids lock conflicts)
|
||||
if (targetDbPath) {
|
||||
const tempDb = new lbug.Database(targetDbPath);
|
||||
const tempConn = new lbug.Connection(tempDb);
|
||||
const tempHandle = await openLbugConnection(lbug, targetDbPath);
|
||||
try {
|
||||
await tempConn.query(query);
|
||||
await tempHandle.conn.query(query);
|
||||
return true;
|
||||
} finally {
|
||||
try {
|
||||
await tempConn.close();
|
||||
} catch {}
|
||||
try {
|
||||
await tempDb.close();
|
||||
} catch {}
|
||||
await closeLbugConnection(tempHandle);
|
||||
}
|
||||
} else if (conn) {
|
||||
// Use existing persistent connection (when called from analyze)
|
||||
|
|
@ -711,8 +726,8 @@ export const batchInsertNodesToLbug = async (
|
|||
};
|
||||
|
||||
// Open a single connection for all inserts
|
||||
const tempDb = new lbug.Database(dbPath);
|
||||
const tempConn = new lbug.Connection(tempDb);
|
||||
const tempHandle = await openLbugConnection(lbug, dbPath);
|
||||
const tempConn = tempHandle.conn;
|
||||
|
||||
let inserted = 0;
|
||||
let failed = 0;
|
||||
|
|
@ -753,12 +768,7 @@ export const batchInsertNodesToLbug = async (
|
|||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await tempConn.close();
|
||||
} catch {}
|
||||
try {
|
||||
await tempDb.close();
|
||||
} catch {}
|
||||
await closeLbugConnection(tempHandle);
|
||||
}
|
||||
|
||||
return { inserted, failed };
|
||||
|
|
@ -1071,13 +1081,13 @@ export const deleteNodesForFile = async (
|
|||
const usePerQuery = !!dbPath;
|
||||
|
||||
// Set up connection (either use existing or create per-query)
|
||||
let tempDb: lbug.Database | null = null;
|
||||
let tempHandle: LbugConnectionHandle | null = null;
|
||||
let tempConn: lbug.Connection | null = null;
|
||||
let targetConn: lbug.Connection | null = conn;
|
||||
|
||||
if (usePerQuery) {
|
||||
tempDb = new lbug.Database(dbPath);
|
||||
tempConn = new lbug.Connection(tempDb);
|
||||
tempHandle = await openLbugConnection(lbug, dbPath);
|
||||
tempConn = tempHandle.conn;
|
||||
targetConn = tempConn;
|
||||
} else if (!conn) {
|
||||
throw new Error('LadybugDB not initialized. Provide dbPath or call initLbug first.');
|
||||
|
|
@ -1127,16 +1137,7 @@ export const deleteNodesForFile = async (
|
|||
return { deletedNodes };
|
||||
} finally {
|
||||
// Close per-query connection if used
|
||||
if (tempConn) {
|
||||
try {
|
||||
await tempConn.close();
|
||||
} catch {}
|
||||
}
|
||||
if (tempDb) {
|
||||
try {
|
||||
await tempDb.close();
|
||||
} catch {}
|
||||
}
|
||||
if (tempHandle) await closeLbugConnection(tempHandle);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1214,6 +1215,9 @@ export const createFTSIndex = async (
|
|||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
}
|
||||
|
||||
const key = ftsIndexKey(tableName, indexName);
|
||||
if (ensuredFTSIndexes.has(key)) return;
|
||||
|
||||
if (!(await loadFTSExtension())) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1223,10 +1227,13 @@ export const createFTSIndex = async (
|
|||
|
||||
try {
|
||||
await conn.query(query);
|
||||
ensuredFTSIndexes.add(key);
|
||||
} catch (e: any) {
|
||||
if (!e.message?.includes('already exists')) {
|
||||
throw e;
|
||||
if (e.message?.includes('already exists')) {
|
||||
ensuredFTSIndexes.add(key);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1239,6 +1246,13 @@ export const createFTSIndex = async (
|
|||
*
|
||||
* Safe to call repeatedly — the in-process Set guarantees only the first
|
||||
* call hits LadybugDB. `closeLbug` clears the cache so re-init starts fresh.
|
||||
*
|
||||
* Defense in depth: if the active connection is read-only (e.g. the MCP
|
||||
* pool adapter), `CREATE_FTS_INDEX` will fail with "Cannot execute write
|
||||
* operations in a read-only database". Treat that as a no-op and cache
|
||||
* the key so callers don't loop on a path that can never succeed here —
|
||||
* the index is owned by `gitnexus analyze` (writable) and either already
|
||||
* exists or will be created on the next analyze.
|
||||
*/
|
||||
export const ensureFTSIndex = async (
|
||||
tableName: string,
|
||||
|
|
@ -1246,10 +1260,20 @@ export const ensureFTSIndex = async (
|
|||
properties: string[],
|
||||
stemmer: string = 'porter',
|
||||
): Promise<void> => {
|
||||
const key = `${tableName}:${indexName}`;
|
||||
const key = ftsIndexKey(tableName, indexName);
|
||||
if (ensuredFTSIndexes.has(key)) return;
|
||||
await createFTSIndex(tableName, indexName, properties, stemmer);
|
||||
ensuredFTSIndexes.add(key);
|
||||
try {
|
||||
await createFTSIndex(tableName, indexName, properties, stemmer);
|
||||
} catch (e) {
|
||||
// Read-only DB: writable analyze owns index creation; silently skip
|
||||
// and cache so callers don't loop on a path that can never succeed
|
||||
// here (the MCP query pool opens DBs read-only by design).
|
||||
if (isReadOnlyDbError(e)) {
|
||||
ensuredFTSIndexes.add(key);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1321,5 +1345,7 @@ export const dropFTSIndex = async (tableName: string, indexName: string): Promis
|
|||
await conn.query(`CALL DROP_FTS_INDEX('${tableName}', '${indexName}')`);
|
||||
} catch {
|
||||
// Index may not exist
|
||||
} finally {
|
||||
ensuredFTSIndexes.delete(ftsIndexKey(tableName, indexName));
|
||||
}
|
||||
};
|
||||
|
|
|
|||
88
gitnexus/src/core/lbug/lbug-config.ts
Normal file
88
gitnexus/src/core/lbug/lbug-config.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import type lbug from '@ladybugdb/core';
|
||||
|
||||
/**
|
||||
* Shared configuration for `@ladybugdb/core` `Database` construction.
|
||||
*
|
||||
* Two values changed meaningfully in `@ladybugdb/core` 0.16.0 and need to be
|
||||
* pinned explicitly by every caller, otherwise GitNexus regresses:
|
||||
*
|
||||
* 1. `maxDBSize` defaults to `0`, which the native runtime interprets as
|
||||
* "use the platform's full mmap address space" — typically 8 TB on
|
||||
* 64-bit Linux. Constrained environments (CI runners, containers, WSL)
|
||||
* cannot reserve that much address space and crash with
|
||||
* `Buffer manager exception: Mmap for size 8796093022208 failed.`
|
||||
* See LadybugDB upstream JSDoc:
|
||||
* > "introduced temporarily for now to get around with the default 8TB
|
||||
* > mmap address space limit some environment".
|
||||
*
|
||||
* 2. `enableCompression` flipped its default from `false` (0.15.x) to
|
||||
* `true` (0.16.0). Existing call sites that relied on the positional
|
||||
* default must now pass `false` explicitly to preserve behaviour.
|
||||
*
|
||||
* Putting both in one shared module guarantees every `new lbug.Database(...)`
|
||||
* call site agrees on the same ceiling and behaviour.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Upper bound for any single GitNexus LadybugDB file (graph index, group
|
||||
* bridge, install scratch, test fixture). 16 GiB is intentionally generous
|
||||
* for real-world code graphs (the GitNexus self-index uses < 50 MiB) while
|
||||
* remaining far below any 64-bit OS mmap ceiling.
|
||||
*
|
||||
* Override with the `GITNEXUS_LBUG_MAX_DB_SIZE` environment variable when
|
||||
* indexing genuinely huge monorepos. Values are coerced to a positive
|
||||
* integer; anything invalid falls back to the default.
|
||||
*/
|
||||
export const LBUG_MAX_DB_SIZE: number = (() => {
|
||||
const raw = process.env.GITNEXUS_LBUG_MAX_DB_SIZE;
|
||||
if (raw) {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
|
||||
}
|
||||
return 16 * 1024 * 1024 * 1024;
|
||||
})();
|
||||
|
||||
type LbugModule = typeof lbug;
|
||||
|
||||
export interface LbugDatabaseOptions {
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface LbugConnectionHandle {
|
||||
db: lbug.Database;
|
||||
conn: lbug.Connection;
|
||||
}
|
||||
|
||||
export function createLbugDatabase(
|
||||
lbugModule: LbugModule,
|
||||
databasePath: string,
|
||||
options: LbugDatabaseOptions = {},
|
||||
): lbug.Database {
|
||||
return new lbugModule.Database(
|
||||
databasePath,
|
||||
0,
|
||||
false,
|
||||
options.readOnly ?? false,
|
||||
LBUG_MAX_DB_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
export async function openLbugConnection(
|
||||
lbugModule: LbugModule,
|
||||
databasePath: string,
|
||||
options: LbugDatabaseOptions = {},
|
||||
): Promise<LbugConnectionHandle> {
|
||||
let db: lbug.Database | undefined;
|
||||
try {
|
||||
db = createLbugDatabase(lbugModule, databasePath, options);
|
||||
return { db, conn: new lbugModule.Connection(db) };
|
||||
} catch (err) {
|
||||
if (db) await db.close().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeLbugConnection(handle: LbugConnectionHandle): Promise<void> {
|
||||
await handle.conn.close().catch(() => {});
|
||||
await handle.db.close().catch(() => {});
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
import fs from 'fs/promises';
|
||||
import lbug from '@ladybugdb/core';
|
||||
import { loadFTSExtension } from './lbug-adapter.js';
|
||||
import { createLbugDatabase } from './lbug-config.js';
|
||||
|
||||
/** Per-repo pool: one Database, many Connections */
|
||||
interface PoolEntry {
|
||||
|
|
@ -305,12 +306,7 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
|
|||
for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
|
||||
silenceStdout();
|
||||
try {
|
||||
const db = new lbug.Database(
|
||||
dbPath,
|
||||
0, // bufferManagerSize (default)
|
||||
false, // enableCompression (default)
|
||||
true, // readOnly
|
||||
);
|
||||
const db = createLbugDatabase(lbug, dbPath, { readOnly: true });
|
||||
restoreStdout();
|
||||
shared = { db, refCount: 0, ftsLoaded: false };
|
||||
dbCache.set(dbPath, shared);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
getStoragePaths,
|
||||
saveMeta,
|
||||
loadMeta,
|
||||
addToGitignore,
|
||||
ensureGitNexusIgnored,
|
||||
registerRepo,
|
||||
cleanupOldKuzuFiles,
|
||||
} from '../storage/repo-manager.js';
|
||||
|
|
@ -166,6 +166,7 @@ export async function runFullAnalysis(
|
|||
if (existingMeta && !options.force && existingMeta.lastCommit === currentCommit) {
|
||||
// Non-git folders have currentCommit = '' — always rebuild since we can't detect changes
|
||||
if (currentCommit !== '') {
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
return {
|
||||
repoName: options.registryName ?? getInferredRepoName(repoPath) ?? path.basename(repoPath),
|
||||
repoPath,
|
||||
|
|
@ -447,10 +448,8 @@ export async function runFullAnalysis(
|
|||
allowDuplicateName: options.allowDuplicateName,
|
||||
});
|
||||
|
||||
// Only attempt to update .gitignore when a .git directory is present.
|
||||
if (hasGitDir(repoPath)) {
|
||||
await addToGitignore(repoPath);
|
||||
}
|
||||
// Keep generated .gitnexus contents ignored without editing the user's root .gitignore.
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
|
||||
// ── Generate AI context files (best-effort) ───────────────────────
|
||||
let aggregatedClusterCount = 0;
|
||||
|
|
|
|||
|
|
@ -1,97 +1,255 @@
|
|||
import Parser from 'tree-sitter';
|
||||
import JavaScript from 'tree-sitter-javascript';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
import Python from 'tree-sitter-python';
|
||||
import Java from 'tree-sitter-java';
|
||||
import C from 'tree-sitter-c';
|
||||
import CPP from 'tree-sitter-cpp';
|
||||
// Explicit subpath import: tree-sitter-c-sharp declares `type: "module"` with
|
||||
// `main: "bindings/node"` (no extension) and no `exports` field, which triggers
|
||||
// Node 22's DEP0151 deprecation warning on the bare-package import. Importing
|
||||
// the built entrypoint directly bypasses the deprecated ESM main-field
|
||||
// resolution. (#1013)
|
||||
import CSharp from 'tree-sitter-c-sharp/bindings/node/index.js';
|
||||
import Go from 'tree-sitter-go';
|
||||
import Rust from 'tree-sitter-rust';
|
||||
import PHP from 'tree-sitter-php';
|
||||
import Ruby from 'tree-sitter-ruby';
|
||||
import { createRequire } from 'node:module';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
|
||||
// tree-sitter-swift and tree-sitter-dart are optionalDependencies — may not be installed
|
||||
const _require = createRequire(import.meta.url);
|
||||
let Swift: any = null;
|
||||
try {
|
||||
Swift = _require('tree-sitter-swift');
|
||||
} catch {}
|
||||
let Dart: any = null;
|
||||
try {
|
||||
Dart = _require('tree-sitter-dart');
|
||||
} catch {}
|
||||
|
||||
// tree-sitter-kotlin is an optionalDependency — may not be installed
|
||||
let Kotlin: any = null;
|
||||
try {
|
||||
Kotlin = _require('tree-sitter-kotlin');
|
||||
} catch {}
|
||||
/**
|
||||
* One row per (language, optional variant) describes how to obtain a
|
||||
* grammar object suitable for `Parser.setLanguage`.
|
||||
*
|
||||
* - `load` — returns the grammar object (lazy, called on
|
||||
* first use, then cached).
|
||||
* - `unavailableNote` — actionable message surfaced *whenever* the
|
||||
* grammar can't be loaded. Mandatory for every
|
||||
* row so failures are never silent and never
|
||||
* generic.
|
||||
* - `optional` — when true, a load failure does not throw:
|
||||
* we report the language as unavailable and
|
||||
* let callers skip files of this language.
|
||||
* When false (the default), a load failure
|
||||
* re-throws the original error so the
|
||||
* pipeline halts loudly.
|
||||
* - `severity` — log level for failure diagnostics. Defaults
|
||||
* to `error` for required grammars and `warn`
|
||||
* for optional ones. Set explicitly to `error`
|
||||
* on optional rows whose package is listed in
|
||||
* `dependencies` (not `optionalDependencies`):
|
||||
* those failures indicate a real install
|
||||
* problem and should never be hidden behind
|
||||
* a low-severity warning.
|
||||
*
|
||||
* Adding or removing a grammar is one entry in this table — there is
|
||||
* no second list, no conditional spread, and no per-grammar branch in
|
||||
* the resolver.
|
||||
*/
|
||||
interface GrammarSource {
|
||||
load: () => unknown;
|
||||
unavailableNote: string;
|
||||
optional?: boolean;
|
||||
severity?: 'warn' | 'error';
|
||||
}
|
||||
|
||||
let parser: Parser | null = null;
|
||||
const ISSUES_URL = 'https://github.com/abhigyanpatwari/GitNexus/issues';
|
||||
|
||||
const languageMap: Record<string, any> = {
|
||||
[SupportedLanguages.JavaScript]: JavaScript,
|
||||
[SupportedLanguages.TypeScript]: TypeScript.typescript,
|
||||
[`${SupportedLanguages.TypeScript}:tsx`]: TypeScript.tsx,
|
||||
[SupportedLanguages.Python]: Python,
|
||||
[SupportedLanguages.Java]: Java,
|
||||
[SupportedLanguages.C]: C,
|
||||
[SupportedLanguages.CPlusPlus]: CPP,
|
||||
[SupportedLanguages.CSharp]: CSharp,
|
||||
[SupportedLanguages.Go]: Go,
|
||||
[SupportedLanguages.Rust]: Rust,
|
||||
...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}),
|
||||
[SupportedLanguages.PHP]: PHP.php_only,
|
||||
[SupportedLanguages.Ruby]: Ruby,
|
||||
[SupportedLanguages.Vue]: TypeScript.typescript,
|
||||
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
|
||||
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
|
||||
const SOURCES: Record<string, GrammarSource> = {
|
||||
[SupportedLanguages.JavaScript]: {
|
||||
load: () => _require('tree-sitter-javascript'),
|
||||
unavailableNote:
|
||||
'JavaScript parsing requires `tree-sitter-javascript`. ' +
|
||||
'Check that the package and its native binding installed cleanly (`npm ci`).',
|
||||
},
|
||||
[SupportedLanguages.TypeScript]: {
|
||||
load: () => _require('tree-sitter-typescript').typescript,
|
||||
unavailableNote:
|
||||
'TypeScript parsing requires `tree-sitter-typescript`. ' +
|
||||
'Check that the package and its native binding installed cleanly (`npm ci`).',
|
||||
},
|
||||
[`${SupportedLanguages.TypeScript}:tsx`]: {
|
||||
load: () => _require('tree-sitter-typescript').tsx,
|
||||
unavailableNote:
|
||||
'TSX parsing requires `tree-sitter-typescript` (re-uses the same native binding as TS).',
|
||||
},
|
||||
[SupportedLanguages.Python]: {
|
||||
load: () => _require('tree-sitter-python'),
|
||||
unavailableNote:
|
||||
'Python parsing requires `tree-sitter-python`. Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.Java]: {
|
||||
load: () => _require('tree-sitter-java'),
|
||||
unavailableNote:
|
||||
'Java parsing requires `tree-sitter-java`. Check the install and native binding.',
|
||||
},
|
||||
// tree-sitter-c-sharp declares `type: "module"` with `main: "bindings/node"`
|
||||
// (no extension) and no `exports` field, which triggers Node 22's DEP0151
|
||||
// deprecation warning on the bare-package import. The explicit subpath
|
||||
// bypasses the deprecated ESM main-field resolution. (#1013)
|
||||
[SupportedLanguages.CSharp]: {
|
||||
load: () => _require('tree-sitter-c-sharp/bindings/node/index.js'),
|
||||
unavailableNote:
|
||||
'C# parsing requires `tree-sitter-c-sharp/bindings/node/index.js`. ' +
|
||||
`If the subpath is missing, see ${ISSUES_URL}/1013.`,
|
||||
},
|
||||
[SupportedLanguages.CPlusPlus]: {
|
||||
load: () => _require('tree-sitter-cpp'),
|
||||
unavailableNote:
|
||||
'C++ parsing requires `tree-sitter-cpp`. Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.Go]: {
|
||||
load: () => _require('tree-sitter-go'),
|
||||
unavailableNote: 'Go parsing requires `tree-sitter-go`. Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.Rust]: {
|
||||
load: () => _require('tree-sitter-rust'),
|
||||
unavailableNote:
|
||||
'Rust parsing requires `tree-sitter-rust`. Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.PHP]: {
|
||||
load: () => _require('tree-sitter-php').php_only,
|
||||
unavailableNote:
|
||||
'PHP parsing requires `tree-sitter-php` (the `php_only` export). ' +
|
||||
'Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.Ruby]: {
|
||||
load: () => _require('tree-sitter-ruby'),
|
||||
unavailableNote:
|
||||
'Ruby parsing requires `tree-sitter-ruby`. Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.Vue]: {
|
||||
load: () => _require('tree-sitter-typescript').typescript,
|
||||
unavailableNote:
|
||||
'Vue parsing piggybacks on `tree-sitter-typescript`. Check the install and native binding.',
|
||||
},
|
||||
|
||||
// tree-sitter-c is a required dependency, but its native binding has
|
||||
// historically been ABI-incompatible with the bundled tree-sitter@0.21.1
|
||||
// runtime on some platforms (#1242, #858). Loading it through the
|
||||
// optional machinery turns a would-be segfault into a clean degradation
|
||||
// while preserving every other language's analysis. Severity is pinned
|
||||
// to `error` because the package is in `dependencies`: a failure here
|
||||
// is always an install/platform problem the user needs to see, never an
|
||||
// expected "user opted out" condition like Swift/Dart/Kotlin.
|
||||
[SupportedLanguages.C]: {
|
||||
load: () => _require('tree-sitter-c'),
|
||||
optional: true,
|
||||
severity: 'error',
|
||||
unavailableNote:
|
||||
'C parsing disabled: `tree-sitter-c` could not be loaded. ' +
|
||||
'This package is in `dependencies` and prebuilds ship for all supported ' +
|
||||
'platforms (win32/darwin/linux x64+arm64, Node 18/20/22), so this ' +
|
||||
'usually indicates a corrupted install, an unsupported Node version, ' +
|
||||
'or a native ABI mismatch with the bundled tree-sitter runtime. ' +
|
||||
'Try `npm rebuild tree-sitter-c` or reinstalling, then re-run analyze. ' +
|
||||
`If the failure persists, file details at ${ISSUES_URL}/1242.`,
|
||||
},
|
||||
|
||||
// optionalDependencies — may be absent on platforms without prebuilds
|
||||
// or when users skip optional installs.
|
||||
[SupportedLanguages.Swift]: {
|
||||
load: () => _require('tree-sitter-swift'),
|
||||
optional: true,
|
||||
unavailableNote:
|
||||
'Swift parsing disabled: vendored `tree-sitter-swift` (under ' +
|
||||
'`gitnexus/vendor/tree-sitter-swift`) failed to load. ' +
|
||||
'Likely cause: no prebuilt `.node` for this platform/architecture. ' +
|
||||
`See ${ISSUES_URL}/1130.`,
|
||||
},
|
||||
[SupportedLanguages.Dart]: {
|
||||
load: () => _require('tree-sitter-dart'),
|
||||
optional: true,
|
||||
unavailableNote:
|
||||
'Dart parsing disabled: vendored `tree-sitter-dart` (under ' +
|
||||
'`gitnexus/vendor/tree-sitter-dart`) failed to load. ' +
|
||||
'Likely cause: native compile failed at install (missing python3/make/g++). ' +
|
||||
`See ${ISSUES_URL}/1125.`,
|
||||
},
|
||||
[SupportedLanguages.Kotlin]: {
|
||||
load: () => _require('tree-sitter-kotlin'),
|
||||
optional: true,
|
||||
unavailableNote:
|
||||
'Kotlin parsing disabled: `tree-sitter-kotlin` is an optionalDependency ' +
|
||||
'and is not installed (or its native binding failed to build).',
|
||||
},
|
||||
};
|
||||
|
||||
export const isLanguageAvailable = (language: SupportedLanguages): boolean =>
|
||||
language in languageMap;
|
||||
type LoadResult =
|
||||
| { ok: true; grammar: unknown }
|
||||
| { ok: false; error: Error; note: string; fatal: boolean; severity: 'warn' | 'error' };
|
||||
|
||||
const loadCache = new Map<string, LoadResult>();
|
||||
const logged = new Set<string>();
|
||||
|
||||
const logFailure = (key: string, result: LoadResult): void => {
|
||||
if (result.ok === true) return;
|
||||
if (logged.has(key)) return;
|
||||
logged.add(key);
|
||||
const message = `[gitnexus] ${result.note} (${result.error.message})`;
|
||||
|
||||
if (result.severity === 'error') console.error(message);
|
||||
else console.warn(message);
|
||||
};
|
||||
|
||||
export const resolveLanguageKey = (language: SupportedLanguages, filePath?: string): string =>
|
||||
language === SupportedLanguages.TypeScript && filePath?.endsWith('.tsx')
|
||||
? `${language}:tsx`
|
||||
: language;
|
||||
|
||||
export const getLanguageGrammar = (language: SupportedLanguages, filePath?: string): any => {
|
||||
const key = resolveLanguageKey(language, filePath);
|
||||
const lang = languageMap[key];
|
||||
if (!lang) {
|
||||
throw new Error(`Unsupported language: ${language}`);
|
||||
const loadGrammar = (key: string): LoadResult => {
|
||||
const cached = loadCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const source = SOURCES[key];
|
||||
if (!source) {
|
||||
const result: LoadResult = {
|
||||
ok: false,
|
||||
error: new Error(`Unsupported language: ${key}`),
|
||||
note: `No grammar registered for language key \`${key}\`. Add a row to SOURCES.`,
|
||||
fatal: true,
|
||||
severity: 'error',
|
||||
};
|
||||
loadCache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
return lang;
|
||||
|
||||
let result: LoadResult;
|
||||
try {
|
||||
result = { ok: true, grammar: source.load() };
|
||||
} catch (err) {
|
||||
const fatal = !source.optional;
|
||||
result = {
|
||||
ok: false,
|
||||
error: err as Error,
|
||||
note: source.unavailableNote,
|
||||
fatal,
|
||||
severity: source.severity ?? (fatal ? 'error' : 'warn'),
|
||||
};
|
||||
}
|
||||
loadCache.set(key, result);
|
||||
if (result.ok === false) logFailure(key, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const loadParser = async (): Promise<Parser> => {
|
||||
if (parser) return parser;
|
||||
parser = new Parser();
|
||||
return parser;
|
||||
export const isLanguageAvailable = (language: SupportedLanguages, filePath?: string): boolean =>
|
||||
loadGrammar(resolveLanguageKey(language, filePath)).ok;
|
||||
|
||||
export const getLanguageGrammar = (language: SupportedLanguages, filePath?: string): unknown => {
|
||||
const key = resolveLanguageKey(language, filePath);
|
||||
const result = loadGrammar(key);
|
||||
if (result.ok === true) return result.grammar;
|
||||
// Fatal failures throw the original underlying error (preserving stack)
|
||||
// after the note has been logged. Optional failures fall through to the
|
||||
// standard "Unsupported language" message that callers already handle.
|
||||
if (result.fatal) throw result.error;
|
||||
throw new Error(`Unsupported language: ${language}`);
|
||||
};
|
||||
|
||||
let sharedParser: Parser | null = null;
|
||||
|
||||
export const loadParser = async (): Promise<Parser> => (sharedParser ??= new Parser());
|
||||
|
||||
export const loadLanguage = async (
|
||||
language: SupportedLanguages,
|
||||
filePath?: string,
|
||||
): Promise<void> => {
|
||||
if (!parser) await loadParser();
|
||||
parser!.setLanguage(getLanguageGrammar(language, filePath));
|
||||
const parser = await loadParser();
|
||||
parser.setLanguage(getLanguageGrammar(language, filePath));
|
||||
};
|
||||
|
||||
export const createParserForLanguage = async (
|
||||
language: SupportedLanguages,
|
||||
filePath?: string,
|
||||
): Promise<Parser> => {
|
||||
const freshParser = new Parser();
|
||||
freshParser.setLanguage(getLanguageGrammar(language, filePath));
|
||||
return freshParser;
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(getLanguageGrammar(language, filePath));
|
||||
return parser;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,14 +6,13 @@
|
|||
*/
|
||||
|
||||
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
|
||||
import os from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
isHttpMode,
|
||||
getHttpDimensions,
|
||||
httpEmbedQuery,
|
||||
} from '../../core/embeddings/http-client.js';
|
||||
import { resolveEmbeddingConfig } from '../../core/embeddings/config.js';
|
||||
import { applyHfEnvOverrides } from '../../core/embeddings/hf-env.js';
|
||||
import { silenceStdout, restoreStdout, realStderrWrite } from '../../core/lbug/pool-adapter.js';
|
||||
|
||||
// Model config
|
||||
|
|
@ -45,11 +44,11 @@ export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
|
|||
initPromise = (async () => {
|
||||
try {
|
||||
env.allowLocalModels = false;
|
||||
// Default cache to user-writable location. transformers.js defaults to
|
||||
// ./node_modules/.cache inside its own install dir, which is unwritable
|
||||
// when gitnexus is installed globally (e.g. /usr/lib/node_modules/).
|
||||
// Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface.
|
||||
env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface');
|
||||
// Bridge user-controlled env vars to transformers.js: HF_HOME →
|
||||
// env.cacheDir, HF_ENDPOINT → env.remoteHost (#1205). Centralised in
|
||||
// applyHfEnvOverrides so this MCP entry point behaves identically to
|
||||
// the analyze pipeline embedder.
|
||||
applyHfEnvOverrides(env);
|
||||
const embeddingConfig = resolveEmbeddingConfig();
|
||||
|
||||
console.error('GitNexus: Loading embedding model (first search may take a moment)...');
|
||||
|
|
|
|||
|
|
@ -1110,13 +1110,12 @@ export class LocalBackend {
|
|||
}
|
||||
} else if (!this.warnedVectorUnsupported) {
|
||||
// Rare diagnostic: surface why we fell back to the exact scan path so
|
||||
// operators can see at a glance that the VECTOR extension is missing on
|
||||
// this runtime (e.g. Windows builds without the optional native
|
||||
// dependency). Emitted once per `LocalBackend` instance lifetime to
|
||||
// avoid noisy stderr on hot semantic-search paths (DoD §2.8).
|
||||
// operators can see at a glance that VECTOR is disabled by platform
|
||||
// policy. Emitted once per `LocalBackend` instance lifetime to avoid
|
||||
// noisy stderr on hot semantic-search paths (DoD §2.8).
|
||||
this.warnedVectorUnsupported = true;
|
||||
console.error(
|
||||
'GitNexus [query:vector]: VECTOR index unavailable for this runtime; using exact scan fallback',
|
||||
'GitNexus [query:vector]: VECTOR extension not supported on this platform; using exact scan fallback',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ export function createMCPServer(backend: LocalBackend): Server {
|
|||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
annotations: tool.annotations,
|
||||
})),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@
|
|||
* All tools support an optional `repo` parameter for multi-repo setups.
|
||||
*/
|
||||
|
||||
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
annotations: ToolAnnotations;
|
||||
inputSchema: {
|
||||
type: 'object';
|
||||
properties: Record<
|
||||
|
|
@ -27,6 +30,27 @@ export interface ToolDefinition {
|
|||
};
|
||||
}
|
||||
|
||||
const READ_ONLY_TOOL_ANNOTATIONS: ToolAnnotations = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
};
|
||||
|
||||
const QUERY_TOOL_ANNOTATIONS: ToolAnnotations = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
};
|
||||
|
||||
const DESTRUCTIVE_TOOL_ANNOTATIONS: ToolAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
};
|
||||
|
||||
export const GITNEXUS_TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
name: 'list_repos',
|
||||
|
|
@ -39,6 +63,7 @@ AFTER THIS: READ gitnexus://repo/{name}/context for the repo you want to work wi
|
|||
|
||||
When multiple repos are indexed, you MUST specify the "repo" parameter
|
||||
on other tools (query, context, impact, etc.) to target the correct one.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
|
|
@ -63,6 +88,7 @@ Hybrid ranking: BM25 keyword + semantic vector search, ranked by Reciprocal Rank
|
|||
GROUP MODE: set "repo" to "@<groupName>" to search all member repos in that group (merged via RRF), or "@<groupName>/<groupRepoPath>" to run against a single member (same path keys as in group.yaml). If you use "@<groupName>" only, the member repo defaults to the lexicographically first key in group.yaml "repos". Prefer resources for contracts/status (see migration from legacy group_* tools).
|
||||
|
||||
SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). When "repo" starts with "@", only processes whose symbols fall under that prefix are included. For a normal indexed repo name (no leading @), this field is currently ignored by the server.`,
|
||||
annotations: QUERY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -156,6 +182,7 @@ TIPS:
|
|||
- Community = auto-detected functional area (Leiden algorithm). Properties: heuristicLabel, cohesion, symbolCount, keywords, description, enrichedBy
|
||||
- Process = execution flow trace from entry point to terminal. Properties: heuristicLabel, processType, stepCount, communities, entryPointId, terminalId
|
||||
- Use heuristicLabel (not label) for human-readable community/process names`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -183,6 +210,7 @@ NOTE: ACCESSES edges (field read/write tracking) are included in context results
|
|||
GROUP MODE: set "repo" to "@<groupName>" to run context in each member repo (aggregated list), or "@<groupName>/<groupRepoPath>" for one member. If you use "@<groupName>" only, the member defaults to the lexicographically first key in group.yaml "repos".
|
||||
|
||||
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", prefix-matches resolved symbol file paths; when a hit is outside the prefix, that member returns an empty payload for the symbol. Ignored for a normal indexed repo name.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -226,6 +254,7 @@ WHEN TO USE: Before committing — to understand what your changes affect. Pre-c
|
|||
AFTER THIS: Review affected processes. Use context() on high-risk symbols. READ gitnexus://repo/{name}/process/{name} for full traces.
|
||||
|
||||
Returns: changed symbols, affected processes, and a risk summary.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -258,6 +287,7 @@ AFTER THIS: Run detect_changes() to verify no unexpected side effects.
|
|||
Each edit is tagged with confidence:
|
||||
- "graph": found via knowledge graph relationships (high confidence, safe to accept)
|
||||
- "text_search": found via regex text search (lower confidence, review carefully)`,
|
||||
annotations: DESTRUCTIVE_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -311,6 +341,7 @@ Confidence: 1.0 = certain, <0.8 = fuzzy match
|
|||
GROUP MODE: set "repo" to "@<groupName>" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@<groupName>/<groupRepoPath>" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge.
|
||||
|
||||
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", scopes the local impact walk and cross-repo symbol paths to files under that prefix; ignored for a normal indexed repo name.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -404,6 +435,7 @@ WHEN TO USE: Understanding API consumption patterns, finding orphaned routes. Fo
|
|||
AFTER THIS: Use impact() on specific route handlers to see full blast radius.
|
||||
|
||||
Returns: route nodes with their handlers, middleware wrapper chains (e.g., withAuth, withRateLimit), and consumers.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -426,6 +458,7 @@ Returns: route nodes with their handlers, middleware wrapper chains (e.g., withA
|
|||
WHEN TO USE: Understanding tool APIs, finding tool implementations, impact analysis for tool changes.
|
||||
|
||||
Returns: tool nodes with their handler files and descriptions.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -443,6 +476,7 @@ WHEN TO USE: Detecting mismatches between what an API route returns and what con
|
|||
REQUIRES: Route nodes with responseKeys (extracted from .json({...}) calls during indexing).
|
||||
|
||||
Returns routes that have both detected response keys AND consumers. Shows top-level keys each endpoint returns (e.g., data, pagination, error) and what keys each consumer accesses. Reports MISMATCH status when a consumer accesses keys not present in the route's response shape.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -467,6 +501,7 @@ WHEN TO USE: BEFORE modifying any API route handler. Shows what consumers depend
|
|||
Risk levels: LOW (0-3 consumers), MEDIUM (4-9 or any mismatches), HIGH (10+ consumers or mismatches with 4+ consumers). Mismatches with confidence "low" indicate the consumer file fetches multiple routes — property attribution is approximate.
|
||||
|
||||
Returns: single route object when one match, or { routes: [...], total: N } for multiple matches. Combines route_map, shape_check, and impact data.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -482,6 +517,7 @@ Returns: single route object when one match, or { routes: [...], total: N } for
|
|||
description: `List all configured repository groups, or return details for one group (repos, manifest links).
|
||||
|
||||
WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single group's config.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -495,6 +531,9 @@ WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single
|
|||
description: `Rebuild the Contract Registry (contracts.json) for a group: extract HTTP contracts, apply manifest links, exact-match cross-links.
|
||||
|
||||
WHEN TO USE: After changing group.yaml or re-indexing member repos.`,
|
||||
// Writes contracts.json on every call; conservatively non-idempotent
|
||||
// even though output is deterministic for identical input.
|
||||
annotations: DESTRUCTIVE_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
|
|||
|
|
@ -93,6 +93,34 @@ export const getGitRoot = (fromPath: string): string | null => {
|
|||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find a git root by checking only `.git` entries on the ancestor chain.
|
||||
*
|
||||
* Unlike `getGitRoot`, this does not spawn `git`, so MCP can cheaply decide
|
||||
* whether a launch cwd is a worktree before running any subprocess there.
|
||||
*/
|
||||
export const findGitRootByDotGit = (fromPath: string): string | null => {
|
||||
let current = path.resolve(fromPath);
|
||||
try {
|
||||
if (!statSync(current).isDirectory()) {
|
||||
current = path.dirname(current);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
statSync(path.join(current, '.git'));
|
||||
return current;
|
||||
} catch {
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Check whether a directory contains a .git entry (file or folder).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export interface RegistryEntry {
|
|||
}
|
||||
|
||||
const GITNEXUS_DIR = '.gitnexus';
|
||||
const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`;
|
||||
|
||||
// ─── Local Storage Helpers ─────────────────────────────────────────────
|
||||
|
||||
|
|
@ -238,23 +239,45 @@ export const findRepo = async (startPath: string): Promise<IndexedRepo | null> =
|
|||
};
|
||||
|
||||
/**
|
||||
* Add .gitnexus to .gitignore if not already present
|
||||
* Keep generated index files ignored without modifying the user's root .gitignore.
|
||||
*/
|
||||
export const addToGitignore = async (repoPath: string): Promise<void> => {
|
||||
const gitignorePath = path.join(repoPath, '.gitignore');
|
||||
export const ensureGitNexusIgnored = async (repoPath: string): Promise<void> => {
|
||||
const gitignorePath = path.join(getStoragePath(repoPath), '.gitignore');
|
||||
|
||||
await fs.mkdir(path.dirname(gitignorePath), { recursive: true });
|
||||
await fs.writeFile(gitignorePath, '*\n', 'utf-8');
|
||||
|
||||
await ensureGitInfoExclude(repoPath);
|
||||
};
|
||||
|
||||
const ensureGitInfoExclude = async (repoPath: string): Promise<void> => {
|
||||
const gitDirPath = path.join(path.resolve(repoPath), '.git');
|
||||
const excludePath = path.join(gitDirPath, 'info', 'exclude');
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(gitignorePath, 'utf-8');
|
||||
if (content.includes(GITNEXUS_DIR)) return;
|
||||
|
||||
const newContent = content.endsWith('\n')
|
||||
? `${content}${GITNEXUS_DIR}\n`
|
||||
: `${content}\n${GITNEXUS_DIR}\n`;
|
||||
await fs.writeFile(gitignorePath, newContent, 'utf-8');
|
||||
const gitDir = await fs.stat(gitDirPath);
|
||||
if (!gitDir.isDirectory()) return;
|
||||
} catch {
|
||||
// .gitignore doesn't exist, create it
|
||||
await fs.writeFile(gitignorePath, `${GITNEXUS_DIR}\n`, 'utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(excludePath), { recursive: true });
|
||||
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(excludePath, 'utf-8');
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT') throw err;
|
||||
}
|
||||
|
||||
const excludes = content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#'));
|
||||
if (excludes.includes(GITNEXUS_DIR) || excludes.includes(GITNEXUS_EXCLUDE_ENTRY)) return;
|
||||
|
||||
const separator = content.length === 0 || content.endsWith('\n') ? '' : '\n';
|
||||
await fs.writeFile(excludePath, `${content}${separator}${GITNEXUS_EXCLUDE_ENTRY}\n`, 'utf-8');
|
||||
};
|
||||
|
||||
// ─── Global Registry (~/.gitnexus/registry.json) ───────────────────────
|
||||
|
|
@ -561,6 +584,84 @@ export class RegistryAmbiguousTargetError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by {@link assertAnalysisFinalized} when a successful `analyze`
|
||||
* run did not actually persist `meta.json` or did not register the repo
|
||||
* in `~/.gitnexus/registry.json` (#1169).
|
||||
*
|
||||
* Why this exists: on Windows, `gitnexus analyze` has been observed to
|
||||
* exit cleanly (code 0) with `lbug.wal` written but no `meta.json`,
|
||||
* leaving the repo invisible to `gitnexus list`/`status` and downstream
|
||||
* MCP discovery. The only signal to the user was an empty banner —
|
||||
* which is indistinguishable from a no-op early return. This invariant
|
||||
* fails loudly with an actionable diagnostic so the silent-finalize bug
|
||||
* surfaces with a non-zero exit code and a recoverable error message
|
||||
* regardless of the upstream root cause (re-exec churn, native module
|
||||
* side effects, antivirus, or future regressions).
|
||||
*/
|
||||
export class AnalysisNotFinalizedError extends Error {
|
||||
readonly kind = 'AnalysisNotFinalizedError' as const;
|
||||
constructor(
|
||||
public readonly repoPath: string,
|
||||
public readonly storagePath: string,
|
||||
public readonly missing: 'meta' | 'registry-entry',
|
||||
public readonly registryPath: string,
|
||||
) {
|
||||
const detail =
|
||||
missing === 'meta'
|
||||
? `meta.json was not written to ${path.join(storagePath, 'meta.json')}`
|
||||
: `registry entry for ${repoPath} was not added to ${registryPath}`;
|
||||
super(
|
||||
`Analysis did not finalize for ${repoPath}: ${detail}. ` +
|
||||
`The on-disk index is incomplete and was not registered. ` +
|
||||
`Re-run "gitnexus analyze" — if the problem persists, inspect ` +
|
||||
`${storagePath} for a stale lbug.wal that signals an aborted write.`,
|
||||
);
|
||||
this.name = 'AnalysisNotFinalizedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a successful `analyze` call actually produced an indexed,
|
||||
* registered repo on disk. Two checks, both strictly required:
|
||||
*
|
||||
* 1. `meta.json` must exist at `<repoPath>/.gitnexus/meta.json`.
|
||||
* 2. The global registry (`getGlobalRegistryPath()`) must contain an
|
||||
* entry whose canonical path matches `repoPath`.
|
||||
*
|
||||
* Throws {@link AnalysisNotFinalizedError} on the first failure with the
|
||||
* specific missing artifact. Pure read — does not mutate disk state.
|
||||
*
|
||||
* Callers must skip this assertion on the `alreadyUpToDate` early-return
|
||||
* path, where the rebuild was deliberately not run.
|
||||
*/
|
||||
export const assertAnalysisFinalized = async (repoPath: string): Promise<void> => {
|
||||
const resolved = path.resolve(repoPath);
|
||||
const { storagePath, metaPath } = getStoragePaths(resolved);
|
||||
|
||||
try {
|
||||
await fs.access(metaPath);
|
||||
} catch {
|
||||
throw new AnalysisNotFinalizedError(resolved, storagePath, 'meta', getGlobalRegistryPath());
|
||||
}
|
||||
|
||||
const entries = await readRegistry();
|
||||
const canonicalInput = canonicalizePath(resolved);
|
||||
const isWin = process.platform === 'win32';
|
||||
const found = entries.some((e) => {
|
||||
const a = canonicalizePath(e.path);
|
||||
return isWin ? a.toLowerCase() === canonicalInput.toLowerCase() : a === canonicalInput;
|
||||
});
|
||||
if (!found) {
|
||||
throw new AnalysisNotFinalizedError(
|
||||
resolved,
|
||||
storagePath,
|
||||
'registry-entry',
|
||||
getGlobalRegistryPath(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Thrown by {@link assertSafeStoragePath} when a registry entry's
|
||||
* `storagePath` does NOT point at the expected `<entry.path>/.gitnexus`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
def verify_cron_secret(token):
|
||||
return token == "expected"
|
||||
|
||||
|
||||
def get_org_id_from_header(headers):
|
||||
return headers.get("x-org-id") if headers else None
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
def send_daily_alerts():
|
||||
return "sent"
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
from auth_utils import verify_cron_secret, get_org_id_from_header
|
||||
from services.sync import _start_cron_run, _complete_cron_run
|
||||
from services.alerts import _create_ops_alert
|
||||
from routers.alerts import send_daily_alerts
|
||||
|
||||
|
||||
def handler_a():
|
||||
if verify_cron_secret("x"):
|
||||
org = get_org_id_from_header(None)
|
||||
_start_cron_run("a")
|
||||
_create_ops_alert("a")
|
||||
send_daily_alerts()
|
||||
_complete_cron_run("a")
|
||||
return org
|
||||
|
||||
|
||||
def handler_b():
|
||||
_start_cron_run("b")
|
||||
_create_ops_alert("b")
|
||||
send_daily_alerts()
|
||||
|
||||
|
||||
def handler_c():
|
||||
_start_cron_run("c")
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
def _create_ops_alert(name):
|
||||
return f"alert:{name}"
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
def _start_cron_run(name):
|
||||
return f"started:{name}"
|
||||
|
||||
|
||||
def _complete_cron_run(name):
|
||||
return f"completed:{name}"
|
||||
11
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/debounce.ts
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/debounce.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Library-wrapper / utility-HOC form: `debounce`, `throttle`, `once`,
|
||||
// `memoize` — all share the same shape `const X = wrap(arrow)` and should
|
||||
// produce a `Function:X` def named after the const.
|
||||
|
||||
import { doStuff } from './helpers';
|
||||
|
||||
const debounce = <F extends (...args: unknown[]) => unknown>(fn: F, _ms: number): F => fn;
|
||||
|
||||
export const debouncedSearch = debounce((query: string) => {
|
||||
doStuff(query.length);
|
||||
}, 250);
|
||||
29
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/forward-ref.tsx
vendored
Normal file
29
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/forward-ref.tsx
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// shadcn/Radix UI canonical pattern: every primitive component is wrapped
|
||||
// in `React.forwardRef` so callers can attach a ref. The arrow inside is
|
||||
// where the actual rendering logic lives — every call inside its body
|
||||
// (cn(), helper(), JSX renders) should attribute to `Button`, not File.
|
||||
//
|
||||
// Pre-fix: `Button` was a Variable; calls inside attributed to File.
|
||||
// Post-fix: `Button` is a Function; calls attribute to `Button`.
|
||||
|
||||
import { helper, cn } from './helpers';
|
||||
|
||||
// Stand-in for React.forwardRef — defined locally so the outer call_expression
|
||||
// is in-fixture and we don't need to mock the React types. Same shape as
|
||||
// the real React.forwardRef<T, P>.
|
||||
const React = {
|
||||
forwardRef: <T, P>(render: (props: P, ref: T | null) => unknown) => render,
|
||||
};
|
||||
|
||||
interface ButtonProps {
|
||||
className?: string;
|
||||
variant?: 'default' | 'ghost';
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant }, _ref) => {
|
||||
const cls = cn('btn', variant ?? 'default', className ?? '');
|
||||
helper(cls);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
11
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/helpers.ts
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/helpers.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Shared helpers used as call targets in HOC-wrapped fixture files. Each
|
||||
// helper is a plain named arrow so we can assert exact `Caller → helper`
|
||||
// edges without confounding cross-file resolution.
|
||||
|
||||
export const helper = (label: string): string => label.toUpperCase();
|
||||
|
||||
export const doStuff = (n: number): number => n + 1;
|
||||
|
||||
export const cn = (...classes: string[]): string => classes.filter(Boolean).join(' ');
|
||||
|
||||
export const fmt = (value: number): string => `[${value}]`;
|
||||
19
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/memo.tsx
vendored
Normal file
19
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/memo.tsx
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Bare-identifier HOC form: `const Card = memo((props) => { ... })`.
|
||||
// Common when the HOC is named-imported (`import { memo } from 'react'`)
|
||||
// rather than accessed via a namespace (`React.memo`). Both should work.
|
||||
|
||||
import { helper, cn } from './helpers';
|
||||
|
||||
const memo = <P,>(render: (props: P) => unknown) => render;
|
||||
|
||||
interface CardProps {
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Card = memo<CardProps>(({ title, className }) => {
|
||||
const cls = cn('card', className ?? '');
|
||||
helper(title);
|
||||
helper(cls);
|
||||
return null;
|
||||
});
|
||||
30
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/negative-bare-call.ts
vendored
Normal file
30
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/negative-bare-call.ts
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Negative-control: bare statement-level HOC calls (NOT bound to a
|
||||
// `const`/`let`/`var`) must NOT produce phantom Function nodes.
|
||||
//
|
||||
// This exercises the `parent.type === 'arguments'` branch in
|
||||
// `tsExtractFunctionName`: the walk-up `arguments → call_expression →
|
||||
// (program | expression_statement)` short-circuits because the parent
|
||||
// of `call_expression` is NOT `variable_declarator`. The arrow stays
|
||||
// anonymous and calls inside fall back to the enclosing module scope.
|
||||
|
||||
import { doStuff } from './helpers';
|
||||
|
||||
const useCallback = <F extends (...args: unknown[]) => unknown>(fn: F, _deps: unknown[]): F => fn;
|
||||
const memo = <P,>(render: (props: P) => unknown) => render;
|
||||
|
||||
// Statement-level: result is discarded.
|
||||
useCallback(() => {
|
||||
doStuff(1);
|
||||
}, []);
|
||||
|
||||
memo<{ x: number }>(({ x }) => {
|
||||
doStuff(x);
|
||||
});
|
||||
|
||||
// Function-arg position (passed to another call): also unbound.
|
||||
const wrap = <T>(value: T): T => value;
|
||||
wrap(
|
||||
memo<{ y: number }>(({ y }) => {
|
||||
doStuff(y);
|
||||
}),
|
||||
);
|
||||
60
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/nested.tsx
vendored
Normal file
60
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/nested.tsx
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// Nested HOCs: `const X = memo(forwardRef((p, r) => { ... }))`.
|
||||
//
|
||||
// Tree-sitter shape:
|
||||
// variable_declarator
|
||||
// value: call_expression (memo(...))
|
||||
// arguments: arguments
|
||||
// call_expression (forwardRef(arrow)) ← FIRST positional arg
|
||||
// arguments: arguments
|
||||
// arrow_function ← deepest behaviour-arrow
|
||||
//
|
||||
// Our outermost lexical_declaration → call_expression → arguments →
|
||||
// arrow_function pattern requires the arrow to be a direct child of the
|
||||
// outermost call's `arguments` — which it is NOT here (it's two levels
|
||||
// deeper). So the OUTER pattern misses. But the same pattern, anchored
|
||||
// on the INNER call (`forwardRef(arrow)`), wouldn't match either: the
|
||||
// inner call_expression is a child of `arguments`, not of a
|
||||
// `variable_declarator`.
|
||||
//
|
||||
// So the question is: which Function name does `arrow_function`'s
|
||||
// `tsExtractFunctionName` resolve to in the legacy DAG?
|
||||
//
|
||||
// arrow.parent = arguments (forwardRef's args)
|
||||
// arguments.parent = call_expression (forwardRef(...))
|
||||
// call_expr.parent = arguments (memo's args) ← NOT variable_declarator!
|
||||
//
|
||||
// The legacy walker requires `call_expression.parent === variable_declarator`,
|
||||
// so it returns null → arrow stays anonymous. Same for the registry-primary
|
||||
// query (the lexical_declaration shape doesn't match because the arrow
|
||||
// isn't a direct grandchild of the outer call).
|
||||
//
|
||||
// CURRENT STATE (post-fix): the deepest arrow is anonymous, calls inside
|
||||
// fall back to the next named ancestor. There IS no named ancestor here
|
||||
// (the outer `forwardRef` and `memo` calls don't have variable_declarator
|
||||
// `value:` containing them — they ARE that value). So calls walk up to
|
||||
// File. The OUTER `Wrapped` const is named `Variable:Wrapped`, not a
|
||||
// Function — because no Function pattern matches the OUTER shape either
|
||||
// (the immediate arg of `memo(...)` is a `call_expression`, not an arrow).
|
||||
//
|
||||
// This file documents a known limitation: deep-nested HOCs (memo of
|
||||
// forwardRef of arrow) lose attribution at depth ≥ 2. The test below
|
||||
// asserts the ABSENCE of phantom edges (we don't make this worse) rather
|
||||
// than positive resolution. A future fix would require recursing into
|
||||
// arguments-containing-call_expression on the value side, which has its
|
||||
// own trade-offs (which level wins the name?).
|
||||
|
||||
import { helper } from './helpers';
|
||||
|
||||
const memo = <P,>(render: (props: P) => unknown) => render;
|
||||
const forwardRef = <T, P>(render: (props: P, ref: T | null) => unknown) => render;
|
||||
|
||||
interface DeepProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const Wrapped = memo(
|
||||
forwardRef<HTMLDivElement, DeepProps>(({ label }, _ref) => {
|
||||
helper(label);
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
16
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/observer.tsx
vendored
Normal file
16
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/observer.tsx
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// MobX `observer` HOC: `const Card = observer((props) => { ... })`. Same
|
||||
// shape as `memo` but the wrapper is named `observer`. Used heavily in
|
||||
// MobX-based React codebases.
|
||||
|
||||
import { helper } from './helpers';
|
||||
|
||||
const observer = <P,>(render: (props: P) => unknown) => render;
|
||||
|
||||
interface ItemProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const Item = observer<ItemProps>(({ label }) => {
|
||||
helper(label);
|
||||
return null;
|
||||
});
|
||||
16
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/use-callback.ts
vendored
Normal file
16
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/use-callback.ts
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// React Hook callback bound to a const — `useCallback((...) => ..., [deps])`.
|
||||
// Calls inside the callback body should attribute to `handleClick` /
|
||||
// `handleSubmit`, the names the developer wrote on the LHS.
|
||||
|
||||
import { doStuff, fmt } from './helpers';
|
||||
|
||||
const useCallback = <F extends (...args: unknown[]) => unknown>(fn: F, _deps: unknown[]): F => fn;
|
||||
|
||||
export const handleClick = useCallback(() => {
|
||||
const n = doStuff(1);
|
||||
fmt(n);
|
||||
}, []);
|
||||
|
||||
export const handleSubmit = useCallback((value: number) => {
|
||||
doStuff(value);
|
||||
}, []);
|
||||
12
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/use-memo.ts
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/typescript-hoc-wrapped/src/use-memo.ts
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// `useMemo((...) => value, [deps])` — same shape as useCallback but the
|
||||
// arrow returns a value instead of a callable. The shape we care about is
|
||||
// `const X = useMemo(() => { ... }, [...])` and the test is symmetric:
|
||||
// calls inside should attribute to `computed`.
|
||||
|
||||
import { doStuff } from './helpers';
|
||||
|
||||
const useMemo = <T>(factory: () => T, _deps: unknown[]): T => factory();
|
||||
|
||||
export const computed = useMemo(() => {
|
||||
return doStuff(42);
|
||||
}, []);
|
||||
6
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/control.ts
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/control.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { transform } from './helpers';
|
||||
|
||||
// Control: a "plain helper" pattern that the bug report says hits ~100%
|
||||
// capture. If THIS edge is missing, the bug is more fundamental than
|
||||
// HOF-callback attribution.
|
||||
export const direct = (x: string): string => transform(x);
|
||||
6
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/helpers.ts
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/helpers.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Plain helpers — the targets of HOF-callback calls.
|
||||
// `transform` is intentionally not a Node.js / browser global so naming
|
||||
// collisions with built-ins don't pollute the resolution test.
|
||||
export const fetchData = (): string => 'data';
|
||||
|
||||
export const transform = (x: string): string => x.toUpperCase();
|
||||
53
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/multi-action-store.ts
vendored
Normal file
53
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/multi-action-store.ts
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// Multi-action Zustand-shape store: the regression case the single-pair
|
||||
// `bump` fixture in `store.ts` masked.
|
||||
//
|
||||
// Mirrors the dominant Zustand idiom from the bug report:
|
||||
//
|
||||
// create<State>()(persist((set) => ({
|
||||
// addItem: (item) => doA(item),
|
||||
// removeItem: (item) => doB(item),
|
||||
// fetchData: async () => doC(),
|
||||
// })))
|
||||
//
|
||||
// With the pre-fix `pair`-with-arrow query patterns, all three
|
||||
// pair-function defs landed in the same `(set) => ({...})` callback's
|
||||
// `ownedDefs`. `resolveCallerGraphId.ownedDefs.find(d => d.type === 'Function')`
|
||||
// then returned `addItem` for every walk-up — every call inside
|
||||
// `removeItem` and `fetchData` mis-attributed to `addItem`. `gitnexus_context("removeItem")`
|
||||
// returned zero outgoing edges; `gitnexus_impact("doB", direction:"upstream")`
|
||||
// missed `removeItem` entirely.
|
||||
//
|
||||
// After moving `@declaration.function` to the inner `arrow_function`,
|
||||
// each pair-arrow gets its own scope's `ownedDefs` populated with its own
|
||||
// def. Calls inside `removeItem` now stop at `removeItem`'s arrow scope,
|
||||
// resolve to `removeItem`, and the per-action attribution is correct.
|
||||
|
||||
import { create, persist } from './store';
|
||||
|
||||
interface MultiAction {
|
||||
readonly count: number;
|
||||
readonly addItem: (item: number) => void;
|
||||
readonly removeItem: (item: number) => void;
|
||||
readonly fetchData: () => void;
|
||||
}
|
||||
|
||||
export const doA = (_item: number): void => {};
|
||||
export const doB = (_item: number): void => {};
|
||||
export const doC = (): void => {};
|
||||
|
||||
export const useMultiActionStore = create<MultiAction>()(
|
||||
persist((set) => ({
|
||||
count: 0,
|
||||
addItem: (item) => {
|
||||
doA(item);
|
||||
set({ count: 1 });
|
||||
},
|
||||
removeItem: (item) => {
|
||||
doB(item);
|
||||
set({ count: 0 });
|
||||
},
|
||||
fetchData: () => {
|
||||
doC();
|
||||
},
|
||||
})),
|
||||
);
|
||||
11
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/promise-ctor.ts
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/promise-ctor.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { transform } from './helpers';
|
||||
|
||||
// Pattern: call inside `new Promise((resolve, reject) => ...)` callback.
|
||||
// Mirrors `fileToDataUrl` from the bug report. The synchronous body of
|
||||
// the executor invokes `transform` directly — the edge `wrap → transform`
|
||||
// should exist regardless of the surrounding HOF.
|
||||
export const wrap = (x: string): Promise<string> =>
|
||||
new Promise<string>((resolve) => {
|
||||
const v = transform(x);
|
||||
resolve(v);
|
||||
});
|
||||
9
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/promise-map.ts
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/promise-map.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { transform } from './helpers';
|
||||
|
||||
// Pattern: call inside `.map` callback inside `Promise.all(...)`.
|
||||
// Mirrors `clients/web/src/utils/file-upload.ts` `processSelectedFiles`
|
||||
// from the bug report — `transform` should be reachable as
|
||||
// `fanOut → transform`.
|
||||
export const fanOut = async (xs: string[]): Promise<string[]> => {
|
||||
return Promise.all(xs.map((x) => transform(x)));
|
||||
};
|
||||
18
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/query-hook.ts
vendored
Normal file
18
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/query-hook.ts
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { fetchData } from './helpers';
|
||||
|
||||
// Stand-in for TanStack Query's `useQuery` — accepts a config object.
|
||||
// Defined in-fixture (rather than imported from an external lib) so the
|
||||
// `useQuery` reference resolves to a real graph node and we can also
|
||||
// assert the `useFeature → useQuery` edge.
|
||||
export const useQuery = <T>(opts: { queryFn: () => T; queryKey: readonly string[] }): T => {
|
||||
return opts.queryFn();
|
||||
};
|
||||
|
||||
// Pattern: call inside `queryFn` callback passed to a HOF.
|
||||
// Mirrors `clients/*/src/hooks/use-gateway-queries.ts` from the bug
|
||||
// report (4% capture rate). `useFeature → fetchData` should be reachable.
|
||||
export const useFeature = (): string =>
|
||||
useQuery({
|
||||
queryFn: () => fetchData(),
|
||||
queryKey: ['feature'],
|
||||
});
|
||||
46
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/store.ts
vendored
Normal file
46
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/store.ts
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Stand-ins for Zustand's `create / persist / devtools`. Defined locally
|
||||
// so the references resolve to real graph nodes and the test doesn't
|
||||
// depend on an external library.
|
||||
//
|
||||
// The shape mirrors the actual Zustand API closely enough that the
|
||||
// nested-arrow-in-arguments pattern (which was the worst-case repro in
|
||||
// the bug report — Zustand store files hit 0% CALLS-edge capture) is
|
||||
// faithfully reproduced.
|
||||
|
||||
type StateCreator<T> = (set: (next: Partial<T>) => void) => T;
|
||||
|
||||
export const create = <T>() => {
|
||||
return (initializer: (...wrappers: never[]) => StateCreator<T>): T => {
|
||||
return initializer()(() => {
|
||||
/* runtime no-op for this fixture */
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const persist = <T>(creator: StateCreator<T>): StateCreator<T> => creator;
|
||||
|
||||
export const devtools = <T>(creator: StateCreator<T>): StateCreator<T> => creator;
|
||||
|
||||
interface Counter {
|
||||
readonly count: number;
|
||||
readonly bump: () => void;
|
||||
}
|
||||
|
||||
// The classic Zustand shape:
|
||||
//
|
||||
// const useStore = create<State>()(devtools(persist((set) => ({ ... }))))
|
||||
//
|
||||
// Pre-fix: `useStore` lived as a Variable on the module scope; the
|
||||
// nested arrow callbacks had empty `ownedDefs`; calls inside them
|
||||
// walked up to the module's first Function-like def and silently
|
||||
// mis-attributed (or dropped). After the fix, each named arrow gets
|
||||
// its def attached to its own scope and HOF-wrapped declarations
|
||||
// participate in the call graph normally.
|
||||
export const useStore = create<Counter>()(
|
||||
devtools(
|
||||
persist((set) => ({
|
||||
count: 0,
|
||||
bump: () => set({ count: 1 }),
|
||||
})),
|
||||
),
|
||||
);
|
||||
12
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/components.tsx
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/components.tsx
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Component targets used by the JSX-as-call tests. The bodies are
|
||||
// trivial — what matters is that they're declared as named arrow
|
||||
// functions returning JSX (the canonical React component shape) so the
|
||||
// scope-resolution pipeline emits Function defs for them.
|
||||
|
||||
export const Foo = (): string => 'foo';
|
||||
|
||||
export const Bar = (): string => 'bar';
|
||||
|
||||
export const Inner = (): string => 'inner';
|
||||
|
||||
export const Outer = (): string => 'outer';
|
||||
9
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/hof-jsx.tsx
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/hof-jsx.tsx
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Foo } from './components';
|
||||
|
||||
// Combined fix verification: an arrow-typed component (the HOF-callback
|
||||
// fix's central case — `const fn = () => ...`) whose body returns JSX
|
||||
// (the JSX-as-call fix). The HOF fix anchors the Function def on the
|
||||
// inner arrow so caller-attribution lands on `Wrapped`; the JSX fix
|
||||
// emits the `<Foo />` reference site as `@reference.call.free`. The
|
||||
// combined effect: `Wrapped → Foo` is captured.
|
||||
export const Wrapped = () => <Foo />;
|
||||
11
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/html-only.tsx
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/html-only.tsx
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Pure-HTML JSX — `<div>`, `<span>`, `<button>`. By JSX convention,
|
||||
// lowercase-first-character identifiers are native DOM elements (NOT
|
||||
// React components). The query's `(#match? @reference.name "^[A-Z]")`
|
||||
// predicate filters these out, so this caller must NOT emit any CALLS
|
||||
// edges to identifiers `div` / `span` / `button`.
|
||||
export const useHtml = () => (
|
||||
<div>
|
||||
<span>text</span>
|
||||
<button>click</button>
|
||||
</div>
|
||||
);
|
||||
12
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/member-usage.tsx
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/member-usage.tsx
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Container } from './namespaced';
|
||||
|
||||
// Namespaced JSX — `<Container.Title />`. The query's
|
||||
// `@reference.call.member` capture splits this into:
|
||||
//
|
||||
// receiver: `Container` (an identifier)
|
||||
// property: `Title` (the leaf identifier)
|
||||
//
|
||||
// Note: Member-form JSX is NOT filtered by the PascalCase predicate —
|
||||
// HTML element names can't contain dots, so any `.`-form is unambiguously
|
||||
// a component reference.
|
||||
export const useNamespaced = () => <Container.Title />;
|
||||
14
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/namespaced.tsx
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/namespaced.tsx
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Namespaced component — the canonical `<Container.Title />` idiom
|
||||
// (used by libraries like Radix UI, shadcn/ui, Headless UI). Exposes a
|
||||
// `Container` object whose members are themselves React components, so
|
||||
// JSX consumers write `<Container.Title>` instead of importing each
|
||||
// piece individually.
|
||||
//
|
||||
// The TSX grammar represents `<Foo.Bar />` as `jsx_self_closing_element
|
||||
// name: (member_expression ...)`. Our query's `@reference.call.member`
|
||||
// capture decomposes the member chain so the downstream member-call
|
||||
// resolver can route the edge to the right `Title` definition.
|
||||
|
||||
const Title = () => 'title';
|
||||
|
||||
export const Container = { Title };
|
||||
13
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/nested-usage.tsx
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/nested-usage.tsx
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Inner, Outer } from './components';
|
||||
|
||||
// Nested JSX — `<Outer><Inner /></Outer>`. Both `<Outer>` (paired) and
|
||||
// `<Inner />` (self-closing) are reference sites for the same enclosing
|
||||
// caller (`useNested`). Should emit TWO CALLS edges from `useNested`:
|
||||
//
|
||||
// useNested → Outer
|
||||
// useNested → Inner
|
||||
export const useNested = () => (
|
||||
<Outer>
|
||||
<Inner />
|
||||
</Outer>
|
||||
);
|
||||
6
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/paired-usage.tsx
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/paired-usage.tsx
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { Bar } from './components';
|
||||
|
||||
// Paired JSX element (`<Bar>...</Bar>`). The query captures
|
||||
// `jsx_opening_element` (NOT `jsx_closing_element`) so each JSX use
|
||||
// emits exactly one CALLS edge — the closing tag would double-count.
|
||||
export const useBar = () => <Bar>child text</Bar>;
|
||||
5
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/simple-usage.tsx
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/simple-usage.tsx
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Foo } from './components';
|
||||
|
||||
// Self-closing JSX element — the most common React-component invocation
|
||||
// shape. Should emit `useFoo → Foo` as a CALLS edge.
|
||||
export const useFoo = () => <Foo />;
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
/**
|
||||
* Vitest globalSetup — runs once in the MAIN process before any forks.
|
||||
*
|
||||
* Creates a single shared LadybugDB with full schema so that forked test
|
||||
* files only need to clear + reseed data instead of recreating the
|
||||
* entire schema each time (~29 DDL queries per file eliminated).
|
||||
*
|
||||
* The dbPath is shared with test files via vitest's provide/inject API.
|
||||
*/
|
||||
import path from 'path';
|
||||
import lbug from '@ladybugdb/core';
|
||||
import type { GlobalSetupContext } from 'vitest/node';
|
||||
import { createTempDir } from './helpers/test-db.js';
|
||||
import {
|
||||
NODE_SCHEMA_QUERIES,
|
||||
REL_SCHEMA_QUERIES,
|
||||
EMBEDDING_SCHEMA,
|
||||
} from '../src/core/lbug/schema.js';
|
||||
|
||||
export default async function setup({ provide }: GlobalSetupContext) {
|
||||
const tmpHandle = await createTempDir('gitnexus-shared-');
|
||||
const dbPath = path.join(tmpHandle.dbPath, 'lbug');
|
||||
|
||||
// Create DB with full schema
|
||||
const db = new lbug.Database(dbPath);
|
||||
const conn = new lbug.Connection(db);
|
||||
|
||||
for (const q of NODE_SCHEMA_QUERIES) {
|
||||
await conn.query(q);
|
||||
}
|
||||
for (const q of REL_SCHEMA_QUERIES) {
|
||||
await conn.query(q);
|
||||
}
|
||||
await conn.query(EMBEDDING_SCHEMA);
|
||||
|
||||
// Pre-install FTS extension so forks don't need to download it
|
||||
try {
|
||||
await conn.query('INSTALL fts');
|
||||
await conn.query('LOAD EXTENSION fts');
|
||||
} catch {
|
||||
// FTS may already be installed system-wide — not fatal
|
||||
}
|
||||
|
||||
await conn.close();
|
||||
await db.close();
|
||||
|
||||
// Share the dbPath with all test files via inject('lbugDbPath')
|
||||
provide('lbugDbPath', dbPath);
|
||||
|
||||
// Teardown: remove temp directory after all tests complete
|
||||
return async () => {
|
||||
await tmpHandle.cleanup();
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue