mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-22 00:31:17 +00:00
Merge : Updating my branch from main branch
This commit is contained in:
parent
7ee45d6396
commit
b432045aa9
317 changed files with 279474 additions and 2726 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
|
||||
|
||||
|
|
|
|||
3
.github/workflows/ci-scope-parity.yml
vendored
3
.github/workflows/ci-scope-parity.yml
vendored
|
|
@ -11,7 +11,8 @@ name: Scope Resolution Parity
|
|||
# TWICE on every PR:
|
||||
#
|
||||
# 1. `REGISTRY_PRIMARY_<LANG>=0` — legacy DAG path (guarantees we haven't
|
||||
# broken the old path while migrating).
|
||||
# broken the old path while migrating). Known legacy gaps may be skipped
|
||||
# through the resolver test helper's expected-failure list.
|
||||
# 2. `REGISTRY_PRIMARY_<LANG>=1` — registry-primary path (guarantees the
|
||||
# new path carries the same behavior — the parity gate).
|
||||
#
|
||||
|
|
|
|||
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 }}'
|
||||
|
|
|
|||
2
.github/workflows/pr-labeler.yml
vendored
2
.github/workflows/pr-labeler.yml
vendored
|
|
@ -105,7 +105,7 @@ jobs:
|
|||
# Pinned to v7.2.0. Verify SHA via:
|
||||
# gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0
|
||||
# v7 removed `disable-releaser`; use `dry-run: true` to only autolabel.
|
||||
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
|
||||
- uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
dry-run: true
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -109,3 +109,4 @@ _bmad/
|
|||
.tmp/
|
||||
.agents/
|
||||
.context/
|
||||
gitnexus/web/
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ fi
|
|||
|
||||
if [ -n "$CLI_CHANGED" ]; then
|
||||
echo "pre-commit: typechecking gitnexus..."
|
||||
cd "$ROOT/gitnexus" && ./node_modules/.bin/tsc --noEmit || exit 1
|
||||
cd "$ROOT" && ./node_modules/.bin/tsc -p gitnexus/tsconfig.json --noEmit || exit 1
|
||||
fi
|
||||
|
||||
echo "pre-commit: all checks passed"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<!-- version: 1.6.0 -->
|
||||
<!-- Last updated: 2026-04-20 -->
|
||||
<!-- version: 1.7.0 -->
|
||||
<!-- Last updated: 2026-04-23 -->
|
||||
|
||||
Last reviewed: 2026-04-20
|
||||
Last reviewed: 2026-04-23
|
||||
|
||||
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
|
|||
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
|
||||
- **Call-resolution DAG (legacy path):** See ARCHITECTURE.md § Call-Resolution DAG. Typed 6-stage DAG inside the `parse` phase; language-specific behavior behind `inferImplicitReceiver` / `selectDispatch` hooks on `LanguageProvider`. Shared code in `gitnexus/src/core/ingestion/` must not name languages. Types: `gitnexus/src/core/ingestion/call-types.ts`.
|
||||
- **Scope-resolution pipeline (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. Replaces the legacy DAG for languages in `MIGRATED_LANGUAGES` (currently Python). A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. CI parity gate runs BOTH paths per migrated language on every PR.
|
||||
- **Scope-resolution pipeline (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. Replaces the legacy DAG for languages in `MIGRATED_LANGUAGES` (see `registry-primary-flag.ts`). A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. CI parity gate runs BOTH paths per migrated language on every PR.
|
||||
- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated.
|
||||
- **GitNexus:** skills in `.claude/skills/gitnexus/`; MCP rules in `gitnexus:start` block below.
|
||||
|
||||
|
|
@ -48,6 +48,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
|
|||
|
||||
| Date | Version | Change |
|
||||
|------|---------|--------|
|
||||
| 2026-04-23 | 1.7.0 | TypeScript added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). |
|
||||
| 2026-04-20 | 1.6.0 | Added scope-resolution pipeline pointer (RFC #909 Ring 3); Python migrated to registry-primary. |
|
||||
| 2026-04-19 | 1.5.0 | Cross-repo impact (#794): `impact`/`query`/`context` accept `repo: "@<group>"` + `service`. Removed `group_query`/`group_contracts`/`group_status` MCP tools; added `gitnexus://group/{name}/contracts` and `gitnexus://group/{name}/status` resources. |
|
||||
| 2026-04-16 | 1.4.0 | Fixed: web UI description, pre-commit behavior, MCP tools (7->16), added gitnexus-shared, removed stale vite-plugin-wasm gotcha. |
|
||||
|
|
|
|||
|
|
@ -340,6 +340,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
|||
- **Cross-phase Tree cache**: parse phase writes Trees into `scopeTreeCache` (separate from the chunk-local `astCache`) ONLY for languages with `emitScopeCaptures`. Scope-resolution reads from it to skip the second parse. Cleared at end of the phase. Workers leave the cache empty — Trees can't cross MessageChannels; cache miss = fresh parse. `PROF_SCOPE_RESOLUTION=1` emits hit/miss counters and a worker-engaged warning.
|
||||
- **Typed relationship iteration**: heritage + MRO walk only the EXTENDS / IMPLEMENTS / HAS_METHOD edges via `iterRelationshipsByType`, not the full relationship map.
|
||||
- **Workspace-resolution-index**: O(1) `findOwnedMember` / `findExportedDef` / `classScopeByDefId` built once per run.
|
||||
- **SCC-ordered cross-file return-type propagation** (PR #1050): `propagateImportedReturnTypes` walks `indexes.sccs` in reverse-topological order (leaves first), so multi-hop alias chains like `models.User → service.user → app.user` collapse to the terminal class in a single linear pass. Within each importer, the source module's `typeBindings` is chain-followed BEFORE mirroring (so we mirror terminal types, not intermediate refs), and the importer's own `typeBindings` is chain-followed AFTER mirroring (so local `const x = importedFn()` resolves before downstream importers run). Cyclic SCCs reach a partial fixpoint within a single pass without iterating to convergence — see the `ts-circular` cross-file-binding fixture which only asserts pipeline-no-throw. PROF output (`PROF_SCOPE_RESOLUTION=1`) splits `finalize` from `propagate` so quadratic regressions in the chain-follow surface independently.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -1,5 +1,5 @@
|
|||
# GitNexus
|
||||
⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
|
||||
**⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
|||
|
||||
> *Like DeepWiki, but deeper.* DeepWiki helps you *understand* code. GitNexus lets you *analyze* it — because a knowledge graph tracks every relationship, not just descriptions.
|
||||
|
||||
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with goliath models.
|
||||
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with Goliath models.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -197,6 +197,7 @@ gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexu
|
|||
gitnexus analyze --skip-git # Index folders that are not Git repositories
|
||||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
|
||||
gitnexus list # List all indexed repositories
|
||||
|
|
@ -218,6 +219,8 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
|
|||
gitnexus group status <name> # Check staleness of repos in a group
|
||||
```
|
||||
|
||||
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
|
||||
|
||||
### What Your AI Agent Gets
|
||||
|
||||
**16 tools** exposed via MCP (11 per-repo + 5 group):
|
||||
|
|
@ -321,19 +324,21 @@ flowchart TD
|
|||
|
||||
## Web UI (browser-based)
|
||||
|
||||
A fully client-side graph explorer and AI chat. No server, no install — your code never leaves the browser.
|
||||
A client-side graph explorer and AI chat — your code never leaves your machine.
|
||||
|
||||
**Try it now:** [gitnexus.vercel.app](https://gitnexus.vercel.app) — drag & drop a ZIP and start exploring.
|
||||
**Try it now:** [gitnexus.vercel.app](https://gitnexus.vercel.app) — run `npx gitnexus@latest serve` locally and the page auto-connects to your local backend.
|
||||
|
||||
<img width="2550" height="1343" alt="gitnexus_img" src="https://github.com/user-attachments/assets/cc5d637d-e0e5-48e6-93ff-5bcfdb929285" />
|
||||
|
||||
Or run locally:
|
||||
Or run the frontend locally:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/abhigyanpatwari/gitnexus.git
|
||||
cd gitnexus/gitnexus-shared && npm install && npm run build
|
||||
cd ../gitnexus-web && npm install
|
||||
npm run dev
|
||||
# Then in another terminal, start the backend the frontend connects to:
|
||||
npx gitnexus@latest serve
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ services:
|
|||
- ${WORKSPACE_DIR:-./workspace}:/workspace:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-fsS', 'http://localhost:4747/api/heartbeat']
|
||||
test: ['CMD', 'curl', '-fsSI', 'http://localhost:4747/api/heartbeat']
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ matching:
|
|||
bm25_threshold: 0.7
|
||||
embedding_threshold: 0.65
|
||||
max_candidates_per_step: 3
|
||||
# Exclude noisy paths from cross-link matching (contracts are still extracted)
|
||||
exclude_links_paths: [/ping, /health, /healthcheck]
|
||||
exclude_links_param_only_paths: true
|
||||
```
|
||||
|
||||
Field notes (schema in [`types.ts`](../../gitnexus/src/core/group/types.ts)):
|
||||
|
|
@ -91,7 +94,9 @@ Field notes (schema in [`types.ts`](../../gitnexus/src/core/group/types.ts)):
|
|||
- `repos` — a mapping from **group path** (a logical name you choose; can be a hierarchy like `backend/orders`) to **registry name** (the name shown by `npx gitnexus list`). Both sides appear throughout the tooling: contract rows use the group path; `@<group>/<groupPath>` routes tools to a single member.
|
||||
- `links` — optional manifest escape hatch, one entry per explicit cross-repo contract. Validated by the parser: `from` and `to` must be known repo paths, `type` must be one of `http | grpc | topic | lib | custom`, and `role` must be `provider | consumer`.
|
||||
- `detect` — toggles per extractor family. Defaults (set in `config-parser.ts`) turn `http`, `grpc`, `topics`, and `shared_libs` on; disable the ones you don't use to speed up sync.
|
||||
- `matching` — thresholds for the matching cascade. The exact match is always run; other strategies depend on indexer state.
|
||||
- `matching` — thresholds for the matching cascade. The exact match is always run; other strategies depend on indexer state. Two optional fields reduce false-positive cross-links in large groups:
|
||||
- `exclude_links_paths` — list of HTTP paths to exclude from cross-link matching (default `[]`). Contracts at these paths are still extracted and visible in the registry, but they don't produce cross-repo links. Useful for health-check endpoints (`/ping`, `/health`) that every service exposes. Trailing slashes are normalized.
|
||||
- `exclude_links_param_only_paths` — when `true`, exclude routes where every segment is `{param}` (e.g. `/{param}`, `/{param}/{param}`) from cross-link matching (default `false`). Mixed routes like `/users/{param}` are not affected.
|
||||
|
||||
### 3. Sync the group
|
||||
|
||||
|
|
|
|||
|
|
@ -53,13 +53,13 @@ class MCPBridge:
|
|||
|
||||
try:
|
||||
# Find gitnexus binary
|
||||
gitnexus_bin = self._find_gitnexus()
|
||||
if not gitnexus_bin:
|
||||
gitnexus_cmd = self._find_gitnexus_command()
|
||||
if not gitnexus_cmd:
|
||||
logger.error("GitNexus not found. Install with: npm install -g gitnexus")
|
||||
return False
|
||||
|
||||
self.process = subprocess.Popen(
|
||||
[gitnexus_bin, "mcp"],
|
||||
[*gitnexus_cmd, "mcp"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
|
|
@ -152,33 +152,34 @@ class MCPBridge:
|
|||
return contents[0].get("text", "")
|
||||
return None
|
||||
|
||||
def _find_gitnexus(self) -> str | None:
|
||||
"""Find the gitnexus CLI binary."""
|
||||
def _find_gitnexus_command(self) -> list[str] | None:
|
||||
"""Find the gitnexus CLI command prefix."""
|
||||
# Check if npx is available (preferred - uses local install)
|
||||
for cmd in ["npx"]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[cmd, "gitnexus", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=MCP_FIND_GITNEXUS_TIMEOUT_SECONDS,
|
||||
cwd=self.repo_path,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return cmd # Will use "npx gitnexus mcp"
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["npx", "gitnexus", "--version"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=MCP_FIND_GITNEXUS_TIMEOUT_SECONDS,
|
||||
cwd=self.repo_path,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return ["npx", "gitnexus"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check for global install
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["gitnexus", "--version"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=MCP_FIND_GITNEXUS_FALLBACK_TIMEOUT_SECONDS,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return "gitnexus"
|
||||
return ["gitnexus"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
170
eval/tests/test_mcp_bridge.py
Normal file
170
eval/tests/test_mcp_bridge.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""Tests for MCPBridge._find_gitnexus_command() and subprocess spawn."""
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
|
||||
class TestFindGitnexusCommand(unittest.TestCase):
|
||||
"""Verify _find_gitnexus_command() returns the correct command prefix."""
|
||||
|
||||
def _make_bridge(self):
|
||||
from bridge.mcp_bridge import MCPBridge
|
||||
return MCPBridge(repo_path="/fake/repo")
|
||||
|
||||
def _success(self):
|
||||
r = MagicMock()
|
||||
r.returncode = 0
|
||||
return r
|
||||
|
||||
def _failure(self):
|
||||
r = MagicMock()
|
||||
r.returncode = 1
|
||||
return r
|
||||
|
||||
def test_npx_path_returns_npx_gitnexus(self):
|
||||
"""When npx probe succeeds, command prefix is ['npx', 'gitnexus']."""
|
||||
with patch("subprocess.run", return_value=self._success()) as mock_run:
|
||||
bridge = self._make_bridge()
|
||||
result = bridge._find_gitnexus_command()
|
||||
|
||||
self.assertEqual(result, ["npx", "gitnexus"])
|
||||
mock_run.assert_called_once()
|
||||
args = mock_run.call_args[0][0]
|
||||
self.assertEqual(args, ["npx", "gitnexus", "--version"])
|
||||
|
||||
def test_global_path_returns_gitnexus(self):
|
||||
"""When npx probe fails but global install exists, prefix is ['gitnexus']."""
|
||||
with patch("subprocess.run", side_effect=[self._failure(), self._success()]) as mock_run:
|
||||
bridge = self._make_bridge()
|
||||
result = bridge._find_gitnexus_command()
|
||||
|
||||
self.assertEqual(result, ["gitnexus"])
|
||||
self.assertEqual(mock_run.call_count, 2)
|
||||
|
||||
def test_both_fail_returns_none(self):
|
||||
"""When both probes fail, returns None."""
|
||||
with patch("subprocess.run", return_value=self._failure()):
|
||||
bridge = self._make_bridge()
|
||||
result = bridge._find_gitnexus_command()
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_npx_exception_falls_back_to_global(self):
|
||||
"""When npx raises (not installed), falls back to global probe."""
|
||||
with patch("subprocess.run", side_effect=[FileNotFoundError, self._success()]):
|
||||
bridge = self._make_bridge()
|
||||
result = bridge._find_gitnexus_command()
|
||||
|
||||
self.assertEqual(result, ["gitnexus"])
|
||||
|
||||
def test_both_raise_returns_none(self):
|
||||
"""When both probes raise exceptions, returns None."""
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError):
|
||||
bridge = self._make_bridge()
|
||||
result = bridge._find_gitnexus_command()
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_stdin_devnull_on_npx_probe(self):
|
||||
"""npx probe must pass stdin=DEVNULL to prevent interactive blocking."""
|
||||
with patch("subprocess.run", return_value=self._success()) as mock_run:
|
||||
bridge = self._make_bridge()
|
||||
bridge._find_gitnexus_command()
|
||||
|
||||
kwargs = mock_run.call_args[1]
|
||||
self.assertEqual(kwargs.get("stdin"), subprocess.DEVNULL)
|
||||
|
||||
def test_stdin_devnull_on_global_probe(self):
|
||||
"""global probe must pass stdin=DEVNULL to prevent interactive blocking."""
|
||||
with patch("subprocess.run", side_effect=[self._failure(), self._success()]) as mock_run:
|
||||
bridge = self._make_bridge()
|
||||
bridge._find_gitnexus_command()
|
||||
|
||||
global_call_kwargs = mock_run.call_args_list[1][1]
|
||||
self.assertEqual(global_call_kwargs.get("stdin"), subprocess.DEVNULL)
|
||||
|
||||
|
||||
class TestStartSpawnCommand(unittest.TestCase):
|
||||
"""Verify start() spawns Popen with the correct argv."""
|
||||
|
||||
def _make_bridge(self):
|
||||
from bridge.mcp_bridge import MCPBridge
|
||||
return MCPBridge(repo_path="/fake/repo")
|
||||
|
||||
def test_npx_path_spawns_npx_gitnexus_mcp(self):
|
||||
"""When npx path found, Popen must receive ['npx', 'gitnexus', 'mcp']."""
|
||||
bridge = self._make_bridge()
|
||||
|
||||
with patch.object(bridge, "_find_gitnexus_command", return_value=["npx", "gitnexus"]), \
|
||||
patch("subprocess.Popen") as mock_popen, \
|
||||
patch.object(bridge, "_send_request", return_value={"protocolVersion": "2024-11-05"}), \
|
||||
patch.object(bridge, "_send_notification"):
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.stdin = MagicMock()
|
||||
mock_proc.stdout = MagicMock()
|
||||
mock_proc.stderr = MagicMock()
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
bridge.start()
|
||||
|
||||
mock_popen.assert_called_once()
|
||||
argv = mock_popen.call_args[0][0]
|
||||
self.assertEqual(argv, ["npx", "gitnexus", "mcp"])
|
||||
|
||||
def test_global_path_spawns_gitnexus_mcp(self):
|
||||
"""When global path found, Popen must receive ['gitnexus', 'mcp']."""
|
||||
bridge = self._make_bridge()
|
||||
|
||||
with patch.object(bridge, "_find_gitnexus_command", return_value=["gitnexus"]), \
|
||||
patch("subprocess.Popen") as mock_popen, \
|
||||
patch.object(bridge, "_send_request", return_value={"protocolVersion": "2024-11-05"}), \
|
||||
patch.object(bridge, "_send_notification"):
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.stdin = MagicMock()
|
||||
mock_proc.stdout = MagicMock()
|
||||
mock_proc.stderr = MagicMock()
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
bridge.start()
|
||||
|
||||
mock_popen.assert_called_once()
|
||||
argv = mock_popen.call_args[0][0]
|
||||
self.assertEqual(argv, ["gitnexus", "mcp"])
|
||||
|
||||
def test_no_shell_true(self):
|
||||
"""Popen must never be called with shell=True."""
|
||||
bridge = self._make_bridge()
|
||||
|
||||
with patch.object(bridge, "_find_gitnexus_command", return_value=["npx", "gitnexus"]), \
|
||||
patch("subprocess.Popen") as mock_popen, \
|
||||
patch.object(bridge, "_send_request", return_value={"protocolVersion": "2024-11-05"}), \
|
||||
patch.object(bridge, "_send_notification"):
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.stdin = MagicMock()
|
||||
mock_proc.stdout = MagicMock()
|
||||
mock_proc.stderr = MagicMock()
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
bridge.start()
|
||||
|
||||
kwargs = mock_popen.call_args[1]
|
||||
self.assertNotEqual(kwargs.get("shell"), True)
|
||||
|
||||
def test_gitnexus_not_found_returns_false(self):
|
||||
"""start() returns False and does not call Popen when gitnexus not found."""
|
||||
bridge = self._make_bridge()
|
||||
|
||||
with patch.object(bridge, "_find_gitnexus_command", return_value=None), \
|
||||
patch("subprocess.Popen") as mock_popen:
|
||||
|
||||
result = bridge.start()
|
||||
|
||||
self.assertFalse(result)
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -31,11 +31,25 @@ function readInput() {
|
|||
* Find the .gitnexus directory by walking up from startDir.
|
||||
* Returns the path to .gitnexus/ or null if not found.
|
||||
*/
|
||||
function findGitNexusDir(startDir) {
|
||||
let dir = startDir || process.cwd();
|
||||
function isGlobalRegistryDir(candidate) {
|
||||
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
|
||||
return (
|
||||
fs.existsSync(path.join(candidate, 'registry.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'repos'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from `startDir` looking for a non-registry `.gitnexus/` folder.
|
||||
* Returns the path to `.gitnexus/` or null if not found within 5 levels.
|
||||
*/
|
||||
function walkForGitNexusDir(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
if (fs.existsSync(candidate)) {
|
||||
if (!isGlobalRegistryDir(candidate)) return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
|
|
@ -43,6 +57,51 @@ function findGitNexusDir(startDir) {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside
|
||||
* any git working tree — including a *linked* worktree created via
|
||||
* `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the
|
||||
* upward walk from cwd alone misses the index. Returns null when `cwd` is
|
||||
* not inside a git repo or `git` is not available.
|
||||
*
|
||||
* Implementation: `git rev-parse --git-common-dir` resolves to the canonical
|
||||
* `.git/` directory (or `.git/worktrees/...` parent) that is shared across
|
||||
* all linked worktrees. The canonical repo root is its parent directory.
|
||||
*/
|
||||
function findCanonicalRepoRoot(cwd) {
|
||||
try {
|
||||
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const commonDir = (result.stdout || '').trim();
|
||||
if (!commonDir || !path.isAbsolute(commonDir)) return null;
|
||||
return path.dirname(commonDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findGitNexusDir(startDir) {
|
||||
const cwd = startDir || process.cwd();
|
||||
|
||||
// Fast path: the cwd is inside the canonical repo (most common case).
|
||||
const fromCwd = walkForGitNexusDir(cwd);
|
||||
if (fromCwd) return fromCwd;
|
||||
|
||||
// Fallback: cwd may be inside a linked git worktree whose `.gitnexus/`
|
||||
// only lives in the canonical repo root. Resolve the shared git dir
|
||||
// and retry from there.
|
||||
const canonicalRoot = findCanonicalRepoRoot(cwd);
|
||||
if (canonicalRoot && canonicalRoot !== cwd) {
|
||||
return walkForGitNexusDir(canonicalRoot);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract search pattern from tool input.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -134,7 +134,11 @@ export type {
|
|||
// Scope tree spine + position lookup (RFC §2.2 + §3.1; Ring 2 SHARED #912)
|
||||
export { makeScopeId, clearScopeIdInternPool } from './scope-resolution/scope-id.js';
|
||||
export type { ScopeIdInput } from './scope-resolution/scope-id.js';
|
||||
export { buildScopeTree, ScopeTreeInvariantError } from './scope-resolution/scope-tree.js';
|
||||
export {
|
||||
buildScopeTree,
|
||||
canParentScope,
|
||||
ScopeTreeInvariantError,
|
||||
} from './scope-resolution/scope-tree.js';
|
||||
export type { ScopeTree } from './scope-resolution/scope-tree.js';
|
||||
export { buildPositionIndex } from './scope-resolution/position-index.js';
|
||||
export type { PositionIndex } from './scope-resolution/position-index.js';
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@
|
|||
* (`resolveImportTarget`, `expandsWildcardTo`, `mergeBindings`) that
|
||||
* match the LanguageProvider surface from #911.
|
||||
*
|
||||
* **Dynamic imports rule.** `kind === 'dynamic-unresolved'` passes through
|
||||
* as an `ImportEdge { kind: 'dynamic-unresolved', targetFile: null }`
|
||||
* with no `BindingRef`. They are parse-time signals, not linkable targets.
|
||||
* **Non-binding imports rule.** `dynamic-unresolved` passes through with
|
||||
* `targetFile: null`; `dynamic-resolved` and `side-effect` resolve to
|
||||
* file-level `ImportEdge`s. None of these materialize `BindingRef`s.
|
||||
*/
|
||||
|
||||
import type { SymbolDefinition } from './symbol-definition.js';
|
||||
|
|
@ -45,20 +45,28 @@ export interface FinalizeFile {
|
|||
/**
|
||||
* Defs exported from this file — the "what other files can import by name"
|
||||
* surface. Typically those with `isExported: true` (the module's own
|
||||
* declarations) plus, for multi-hop re-export chains, the re-exported
|
||||
* names the parser chose to surface here.
|
||||
* declarations); parsers MAY also surface re-exported names here as a
|
||||
* shortcut, but it is no longer required for correctness.
|
||||
*
|
||||
* **Multi-hop re-export contract.** `finalize` resolves an edge
|
||||
* `A → B (importedName: 'X')` by looking up `X` in `B.localDefs`. If B
|
||||
* only has `export { X } from './C'` and the parser *does not* include
|
||||
* `X` in `B.localDefs`, A's edge hits the fixpoint cap and is marked
|
||||
* `linkStatus: 'unresolved'`. The fixpoint does NOT mutate `localDefs`
|
||||
* across iterations — it is static input.
|
||||
* `A → B (importedName: 'X')` by first looking up `X` in `B.localDefs`.
|
||||
* If `B` only has `export { X } from './C'` and does NOT surface `X` in
|
||||
* its own `localDefs`, `finalize` falls back to the precomputed
|
||||
* per-file re-export closure (`buildReexportClosures`), which encodes
|
||||
* every name reachable through `B`'s named and wildcard re-exports —
|
||||
* including transitively through cyclic SCCs. The lookup is O(1) and
|
||||
* inherits the upstream `targetDefId`, populating `transitiveVia` with
|
||||
* the file paths traversed to reach the leaf def.
|
||||
*
|
||||
* Parsers that want multi-hop re-export chains to settle end-to-end must
|
||||
* include re-exported names in the intermediate file's `localDefs` (with
|
||||
* the original `DefId` of the source symbol). This keeps the algorithm
|
||||
* O(1) per lookup and avoids graph-crawl during finalize.
|
||||
* Surfacing re-exported names in `localDefs` is still a valid (and
|
||||
* slightly cheaper) optimization: the direct lookup short-circuits the
|
||||
* closure consult. Parsers SHOULD prefer surfacing names they can resolve
|
||||
* statically (e.g., `export { X } from './c'` when `c.ts` is parsed in
|
||||
* the same workspace), and rely on the closure for the long tail of
|
||||
* barrel patterns.
|
||||
*
|
||||
* The fixpoint does NOT mutate `localDefs` across iterations — it is
|
||||
* static input.
|
||||
*/
|
||||
readonly localDefs: readonly SymbolDefinition[];
|
||||
}
|
||||
|
|
@ -186,7 +194,8 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
graph.set(file.filePath, new Set());
|
||||
}
|
||||
for (const [fromFile, drafts] of edgeIndex) {
|
||||
const edges = graph.get(fromFile)!;
|
||||
const edges = graph.get(fromFile);
|
||||
if (edges === undefined) continue;
|
||||
for (const d of drafts) {
|
||||
if (d.targetFile !== null && byFilePath.has(d.targetFile)) {
|
||||
edges.add(d.targetFile);
|
||||
|
|
@ -197,6 +206,12 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
// ── Phase 2: Tarjan SCC → reverse-topological list of SCCs.
|
||||
const sccs = tarjanSccs(graph);
|
||||
|
||||
// ── Phase 2.5: precompute the per-file re-export closure (iterative,
|
||||
// SCC-condensed). Eliminates the recursive crawl that the per-edge
|
||||
// `tryFinalize` call site used to do; lookups are O(1) afterwards.
|
||||
// See `buildReexportClosures` for the algorithm.
|
||||
const reexportClosures = buildReexportClosures(input.files, byFilePath, edgeIndex);
|
||||
|
||||
// ── Phase 3: process SCCs in reverse-topological order (leaves first).
|
||||
// Within each SCC, run a bounded fixpoint that resolves intra-SCC edges.
|
||||
// Edges leaving the SCC are already resolved (their target SCC is
|
||||
|
|
@ -217,10 +232,11 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
progressed = false;
|
||||
iterations++;
|
||||
for (const filePath of scc.files) {
|
||||
const drafts = edgeIndex.get(filePath)!;
|
||||
const drafts = edgeIndex.get(filePath);
|
||||
if (drafts === undefined) continue;
|
||||
for (const draft of drafts) {
|
||||
if (draft.finalized !== null) continue;
|
||||
const finalized = tryFinalize(draft, byFilePath);
|
||||
const finalized = tryFinalize(draft, byFilePath, reexportClosures);
|
||||
if (finalized !== null) {
|
||||
draft.finalized = finalized;
|
||||
progressed = true;
|
||||
|
|
@ -231,7 +247,8 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
|
||||
// Any drafts still not finalized within this SCC hit the cap → unresolved.
|
||||
for (const filePath of scc.files) {
|
||||
const drafts = edgeIndex.get(filePath)!;
|
||||
const drafts = edgeIndex.get(filePath);
|
||||
if (drafts === undefined) continue;
|
||||
for (const draft of drafts) {
|
||||
if (draft.finalized !== null) continue;
|
||||
draft.finalized = {
|
||||
|
|
@ -245,10 +262,14 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
// ── Phase 4: collect finalized `ImportEdge[]` per module scope, preserving
|
||||
// input order within each file, and wildcard-expand where applicable.
|
||||
for (const file of input.files) {
|
||||
const drafts = edgeIndex.get(file.filePath)!;
|
||||
const drafts = edgeIndex.get(file.filePath);
|
||||
if (drafts === undefined) continue;
|
||||
const finalized: ImportEdge[] = [];
|
||||
for (const d of drafts) {
|
||||
const edge = d.finalized!;
|
||||
const edge = d.finalized;
|
||||
if (edge === null) {
|
||||
throw new Error(`Invariant violated: import edge was not finalized for ${file.filePath}`);
|
||||
}
|
||||
if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') {
|
||||
// Produce one `wildcard-expanded` ImportEdge per exported name.
|
||||
const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex);
|
||||
|
|
@ -327,14 +348,11 @@ function makeEdgeDraft(
|
|||
|
||||
// Edge is unresolvable at the file level — mark unresolved now.
|
||||
if (targetFile === null) {
|
||||
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
|
||||
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
|
||||
const targetExportedName = extractExportedName(parsed);
|
||||
const base: ImportEdge = {
|
||||
localName,
|
||||
localName: extractLocalName(parsed),
|
||||
targetFile: null,
|
||||
targetExportedName,
|
||||
kind: edgeKind,
|
||||
targetExportedName: extractExportedName(parsed),
|
||||
kind: edgeKindFor(parsed),
|
||||
linkStatus: 'unresolved',
|
||||
};
|
||||
return {
|
||||
|
|
@ -348,26 +366,43 @@ function makeEdgeDraft(
|
|||
}
|
||||
|
||||
// Resolvable at the file level; intra-SCC fixpoint may still fail to fill
|
||||
// in `targetDefId` (e.g., symbol not exported from target).
|
||||
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
|
||||
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
|
||||
const targetExportedName = extractExportedName(parsed);
|
||||
// in `targetDefId` (e.g., symbol not exported from target). Side-effect
|
||||
// and resolved-dynamic imports are terminal at the file level — no
|
||||
// `targetDefId` needed since they materialize no `BindingRef`. Pre-
|
||||
// finalize them here so the fixpoint loop skips them entirely.
|
||||
const base: ImportEdge = {
|
||||
localName,
|
||||
localName: extractLocalName(parsed),
|
||||
targetFile,
|
||||
targetExportedName,
|
||||
kind: edgeKind,
|
||||
targetExportedName: extractExportedName(parsed),
|
||||
kind: edgeKindFor(parsed),
|
||||
};
|
||||
const isFileLevelTerminal = parsed.kind === 'side-effect' || parsed.kind === 'dynamic-resolved';
|
||||
return {
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
targetFile,
|
||||
base,
|
||||
finalized: null,
|
||||
finalized: isFileLevelTerminal ? base : null,
|
||||
};
|
||||
}
|
||||
|
||||
function edgeKindFor(parsed: ParsedImport): ImportEdge['kind'] {
|
||||
if (parsed.kind === 'wildcard') return 'wildcard-expanded';
|
||||
return parsed.kind;
|
||||
}
|
||||
|
||||
function extractLocalName(parsed: ParsedImport): string {
|
||||
switch (parsed.kind) {
|
||||
case 'wildcard':
|
||||
case 'side-effect':
|
||||
case 'dynamic-resolved':
|
||||
return '';
|
||||
default:
|
||||
return parsed.localName;
|
||||
}
|
||||
}
|
||||
|
||||
function extractExportedName(parsed: ParsedImport): string {
|
||||
switch (parsed.kind) {
|
||||
case 'named':
|
||||
|
|
@ -377,6 +412,8 @@ function extractExportedName(parsed: ParsedImport): string {
|
|||
return parsed.importedName;
|
||||
case 'wildcard':
|
||||
case 'dynamic-unresolved':
|
||||
case 'dynamic-resolved':
|
||||
case 'side-effect':
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
@ -386,6 +423,7 @@ function extractExportedName(parsed: ParsedImport): string {
|
|||
function tryFinalize(
|
||||
draft: ImportEdgeDraft,
|
||||
byFilePath: Map<string, FinalizeFile>,
|
||||
reexportClosures: ReadonlyMap<string, FileReexportClosure>,
|
||||
): ImportEdge | null {
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null) return draft.base; // already terminal
|
||||
|
|
@ -423,22 +461,265 @@ function tryFinalize(
|
|||
const importedName = extractExportedName(draft.source);
|
||||
const exported = findExportByName(targetModule.localDefs, importedName);
|
||||
|
||||
if (exported === undefined) {
|
||||
if (exported !== undefined) {
|
||||
const transitiveVia =
|
||||
draft.source.kind === 'reexport' ? Object.freeze([targetFile]) : undefined;
|
||||
return {
|
||||
...draft.base,
|
||||
targetModuleScope: targetModule.moduleScope,
|
||||
targetDefId: exported.nodeId,
|
||||
...(transitiveVia !== undefined ? { transitiveVia } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Multi-hop re-export follow. Barrel modules like
|
||||
// // models.ts
|
||||
// export { User } from './base';
|
||||
// emit no local def for `User`; the name surfaces only via their own
|
||||
// `reexport` edge. The per-file re-export closure built in phase 2.5
|
||||
// already encodes every name reachable through that file's named and
|
||||
// wildcard re-exports — including transitively through cyclic SCCs —
|
||||
// so the lookup is O(1) and never recurses.
|
||||
const followed = lookupReexportedName(reexportClosures, targetFile, importedName);
|
||||
if (followed === null) {
|
||||
// Target resolvable but the name isn't exported — keep trying in case a
|
||||
// re-export inside the target's SCC surfaces it in a later iteration.
|
||||
return null;
|
||||
}
|
||||
|
||||
const transitiveVia = draft.source.kind === 'reexport' ? Object.freeze([targetFile]) : undefined;
|
||||
const viaFiles = [targetFile, ...followed.via];
|
||||
const transitiveVia =
|
||||
draft.source.kind === 'reexport' || viaFiles.length > 1 ? Object.freeze(viaFiles) : undefined;
|
||||
|
||||
return {
|
||||
...draft.base,
|
||||
targetModuleScope: targetModule.moduleScope,
|
||||
targetDefId: exported.nodeId,
|
||||
targetDefId: followed.def.nodeId,
|
||||
...(transitiveVia !== undefined ? { transitiveVia } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal: re-export closure (phase 2.5) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Per-file map of `name → terminal def + via path` — i.e. every name
|
||||
* importable from this file via its named/wildcard re-export chain
|
||||
* (excluding the file's own `localDefs`, which the caller checks first
|
||||
* via `findExportByName`). `via` is the ordered list of intermediate
|
||||
* files traversed to reach the def.
|
||||
*
|
||||
* Built once per finalize pass. Lookups are O(1).
|
||||
*/
|
||||
type ReexportClosureEntry = { readonly def: SymbolDefinition; readonly via: readonly string[] };
|
||||
type FileReexportClosure = ReadonlyMap<string, ReexportClosureEntry>;
|
||||
|
||||
/**
|
||||
* Build per-file re-export closures.
|
||||
*
|
||||
* **Algorithm.** Iterative SCC-condensed reverse-topological propagation,
|
||||
* structurally identical to how `finalize` itself processes the file-
|
||||
* level import graph. Replaces the legacy recursive
|
||||
* `followReexportChain` crawl with a bounded, stack-safe pass:
|
||||
*
|
||||
* 1. **Sub-graph.** Build a directed graph whose edges are
|
||||
* `reexport` and `wildcard` drafts only (regular imports do not
|
||||
* contribute to the export surface, and `namespace`/
|
||||
* `reexport-namespace` are terminal — their target def lives in
|
||||
* `localDefs`).
|
||||
* 2. **SCC condensation.** Run the same iterative `tarjanSccs` over
|
||||
* the sub-graph. Output is in reverse-topological order (leaves
|
||||
* first), so when we process an SCC every out-of-SCC neighbor
|
||||
* already has its closure populated.
|
||||
* 3. **Per-SCC propagation.**
|
||||
* * Acyclic singleton: one pass — read neighbors' (already
|
||||
* fully populated) closures.
|
||||
* * Cyclic SCC (cycle ≥ 2 files, or self-loop): bounded
|
||||
* fixpoint inside the SCC, capped at `|SCC| + 1` iterations
|
||||
* (each iteration propagates names one hop further around
|
||||
* the cycle; first-wins precedence keeps the map monotone
|
||||
* so the fixpoint converges in at most |SCC| hops).
|
||||
*
|
||||
* **Precedence semantics — preserved from the recursive crawl.**
|
||||
* * Named re-exports take precedence over wildcards.
|
||||
* * Within each kind, declaration order wins (first match for a
|
||||
* given exported name is kept; later drafts skip).
|
||||
*
|
||||
* **Complexity.**
|
||||
* * Pre-pass: O(V + E_re) for SCC, plus O(|SCC| × Σ drafts) per cyclic
|
||||
* SCC. For tree-shaped barrel graphs (the common case) it
|
||||
* collapses to O(E_re) total.
|
||||
* * Per-edge lookup at finalize time: O(1).
|
||||
* * `transitiveVia` preserves the exact file path chain for diagnostics
|
||||
* and graph provenance. Building those arrays copies the inherited path,
|
||||
* which is O(depth²) in a pathological single-name barrel chain; practical
|
||||
* TypeScript barrel chains are shallow enough that we keep exact paths
|
||||
* instead of capping or summarizing them.
|
||||
* * Pathological deep chains that previously needed
|
||||
* `MAX_REEXPORT_DEPTH=100` to bound stack growth now resolve
|
||||
* in full and are bounded only by available memory — the
|
||||
* iterative formulation has no call-stack ceiling.
|
||||
*/
|
||||
function buildReexportClosures(
|
||||
files: readonly FinalizeFile[],
|
||||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
|
||||
): ReadonlyMap<string, FileReexportClosure> {
|
||||
const closures = new Map<string, Map<string, ReexportClosureEntry>>();
|
||||
for (const file of files) closures.set(file.filePath, new Map());
|
||||
|
||||
// ── Step 1: build the re-export sub-graph (only resolvable
|
||||
// reexport/wildcard targets contribute edges).
|
||||
const subGraph = new Map<string, Set<string>>();
|
||||
for (const file of files) {
|
||||
const targets = new Set<string>();
|
||||
const drafts = edgeIndex.get(file.filePath);
|
||||
if (drafts !== undefined) {
|
||||
for (const d of drafts) {
|
||||
if (d.source.kind !== 'reexport' && d.source.kind !== 'wildcard') continue;
|
||||
if (d.targetFile === null) continue;
|
||||
if (!byFilePath.has(d.targetFile)) continue;
|
||||
targets.add(d.targetFile);
|
||||
}
|
||||
}
|
||||
subGraph.set(file.filePath, targets);
|
||||
}
|
||||
|
||||
// ── Step 2: SCC over the sub-graph. Reuses the same iterative Tarjan
|
||||
// implementation that drives the file-level finalize loop, so any
|
||||
// call-stack-safety guarantees there transfer here unchanged.
|
||||
const subSccs = tarjanSccs(subGraph);
|
||||
|
||||
// ── Step 3: process SCCs in reverse-topological order. Acyclic
|
||||
// singletons settle in one pass; cyclic SCCs run a bounded fixpoint.
|
||||
for (const scc of subSccs) {
|
||||
if (!scc.isCycle) {
|
||||
const filePath = scc.files[0];
|
||||
if (filePath !== undefined) {
|
||||
populateFileClosure(filePath, byFilePath, edgeIndex, closures);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Cap = |SCC| + 1. With first-wins precedence each name needs at
|
||||
// most |SCC| iterations to propagate fully around the cycle; the
|
||||
// extra iteration confirms no progress and breaks the loop.
|
||||
const cap = scc.files.length + 1;
|
||||
let progressed = true;
|
||||
let iter = 0;
|
||||
while (progressed && iter < cap) {
|
||||
progressed = false;
|
||||
iter++;
|
||||
for (const filePath of scc.files) {
|
||||
if (populateFileClosure(filePath, byFilePath, edgeIndex, closures)) {
|
||||
progressed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return closures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate one file's re-export closure for one pass. Returns `true`
|
||||
* iff the closure grew (signalling fixpoint progress to the caller).
|
||||
*
|
||||
* Walks the file's drafts in declaration order, named re-exports first
|
||||
* (precedence), then wildcards. For each draft, attempts:
|
||||
* 1. **Direct hit** — name exists in the target file's `localDefs`.
|
||||
* 2. **Inherited** — name exists in the target file's already-populated
|
||||
* closure (which encodes the target's own re-export chain).
|
||||
*
|
||||
* `closures.get(targetFile)` may itself still be empty for in-SCC
|
||||
* targets on the first iteration; the outer fixpoint loop handles
|
||||
* that by re-invoking this function.
|
||||
*/
|
||||
function populateFileClosure(
|
||||
filePath: string,
|
||||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
|
||||
closures: Map<string, Map<string, ReexportClosureEntry>>,
|
||||
): boolean {
|
||||
const myClosure = closures.get(filePath);
|
||||
if (myClosure === undefined) return false;
|
||||
const before = myClosure.size;
|
||||
const drafts = edgeIndex.get(filePath);
|
||||
if (drafts === undefined) return false;
|
||||
|
||||
// Named re-exports — precedence over wildcards, declaration order
|
||||
// first-wins for duplicates of the same exported name.
|
||||
for (const draft of drafts) {
|
||||
if (draft.source.kind !== 'reexport') continue;
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null) continue;
|
||||
const targetModule = byFilePath.get(targetFile);
|
||||
if (targetModule === undefined) continue;
|
||||
|
||||
const localName = draft.source.localName;
|
||||
if (myClosure.has(localName)) continue;
|
||||
|
||||
const importedName = draft.source.importedName;
|
||||
const direct = findExportByName(targetModule.localDefs, importedName);
|
||||
if (direct !== undefined) {
|
||||
myClosure.set(localName, { def: direct, via: Object.freeze([targetFile]) });
|
||||
continue;
|
||||
}
|
||||
const inherited = closures.get(targetFile)?.get(importedName);
|
||||
if (inherited !== undefined) {
|
||||
myClosure.set(localName, {
|
||||
def: inherited.def,
|
||||
via: Object.freeze([targetFile, ...inherited.via]),
|
||||
});
|
||||
}
|
||||
// Else: target's closure is still empty (in-SCC, awaiting next
|
||||
// iteration). Outer loop will revisit.
|
||||
}
|
||||
|
||||
// Wildcard re-exports — fan out the target's own surface (localDefs
|
||||
// + transitive closure). `myClosure.has(name)` checks below preserve
|
||||
// the named-precedence and first-wins semantics from above.
|
||||
for (const draft of drafts) {
|
||||
if (draft.source.kind !== 'wildcard') continue;
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null) continue;
|
||||
const targetModule = byFilePath.get(targetFile);
|
||||
if (targetModule === undefined) continue;
|
||||
|
||||
for (const def of targetModule.localDefs) {
|
||||
const name = deriveSimpleName(def);
|
||||
if (name === null || myClosure.has(name)) continue;
|
||||
myClosure.set(name, { def, via: Object.freeze([targetFile]) });
|
||||
}
|
||||
const targetClosure = closures.get(targetFile);
|
||||
if (targetClosure !== undefined) {
|
||||
for (const [name, entry] of targetClosure) {
|
||||
if (myClosure.has(name)) continue;
|
||||
myClosure.set(name, {
|
||||
def: entry.def,
|
||||
via: Object.freeze([targetFile, ...entry.via]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return myClosure.size > before;
|
||||
}
|
||||
|
||||
/**
|
||||
* O(1) lookup into a precomputed re-export closure. Replaces the legacy
|
||||
* recursive `followReexportChain` traversal with a single map indexing.
|
||||
*/
|
||||
function lookupReexportedName(
|
||||
closures: ReadonlyMap<string, FileReexportClosure>,
|
||||
filePath: string,
|
||||
name: string,
|
||||
): { def: SymbolDefinition; via: readonly string[] } | null {
|
||||
const closure = closures.get(filePath);
|
||||
if (closure === undefined) return null;
|
||||
const entry = closure.get(name);
|
||||
if (entry === undefined) return null;
|
||||
return { def: entry.def, via: entry.via };
|
||||
}
|
||||
|
||||
/**
|
||||
* The "simple" (unqualified) name of a def, for import-name matching.
|
||||
*
|
||||
|
|
@ -459,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 {
|
||||
|
|
@ -522,6 +851,17 @@ function materializeBindings(
|
|||
): ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>> {
|
||||
const out = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
|
||||
|
||||
// Build a `nodeId → SymbolDefinition` index once across all files
|
||||
// (O(N_files × D_defs)) so the per-edge lookup below is O(1) instead
|
||||
// of a full linear scan. At realistic TypeScript monorepo scale
|
||||
// (~5k files × ~50 defs × ~100k linked import edges) this is the
|
||||
// difference between ~25 s and a few ms inside finalize. The map
|
||||
// is local to this pass — no cross-pass state leaks.
|
||||
const defById = new Map<string, SymbolDefinition>();
|
||||
for (const f of files) {
|
||||
for (const d of f.localDefs) defById.set(d.nodeId, d);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const scopeBindings = new Map<string, readonly BindingRef[]>();
|
||||
|
||||
|
|
@ -538,10 +878,7 @@ function materializeBindings(
|
|||
const imports = linkedByScope.get(file.moduleScope) ?? [];
|
||||
for (const edge of imports) {
|
||||
if (edge.targetDefId === undefined || edge.linkStatus === 'unresolved') continue;
|
||||
// Every def the importing file needs to reach is in some other file's
|
||||
// `localDefs`; walk all files to find it. In practice we could index
|
||||
// this, but at finalize-time N(files) is small per workspace pass.
|
||||
const def = findDefById(files, edge.targetDefId);
|
||||
const def = defById.get(edge.targetDefId);
|
||||
if (def === undefined) continue;
|
||||
|
||||
const origin: BindingRef['origin'] =
|
||||
|
|
@ -571,15 +908,6 @@ function materializeBindings(
|
|||
return out;
|
||||
}
|
||||
|
||||
function findDefById(files: readonly FinalizeFile[], defId: string): SymbolDefinition | undefined {
|
||||
for (const f of files) {
|
||||
for (const d of f.localDefs) {
|
||||
if (d.nodeId === defId) return d;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Internal: Tarjan SCC ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -607,7 +935,8 @@ function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedS
|
|||
entered: false,
|
||||
});
|
||||
while (iterStack.length > 0) {
|
||||
const frame = iterStack[iterStack.length - 1]!;
|
||||
const frame = iterStack[iterStack.length - 1];
|
||||
if (frame === undefined) break;
|
||||
|
||||
if (!frame.entered) {
|
||||
frame.entered = true;
|
||||
|
|
@ -625,7 +954,10 @@ function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedS
|
|||
const scc: string[] = [];
|
||||
let selfInCycle = false;
|
||||
while (true) {
|
||||
const w = stack.pop()!;
|
||||
const w = stack.pop();
|
||||
if (w === undefined) {
|
||||
throw new Error(`Invariant violated: Tarjan stack exhausted at ${frame.node}`);
|
||||
}
|
||||
onStack.delete(w);
|
||||
scc.push(w);
|
||||
// A single-file self-loop counts as a cycle.
|
||||
|
|
@ -640,8 +972,16 @@ function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedS
|
|||
iterStack.pop();
|
||||
// Propagate lowlink to parent.
|
||||
if (iterStack.length > 0) {
|
||||
const parent = iterStack[iterStack.length - 1]!;
|
||||
lowlink.set(parent.node, Math.min(lowlink.get(parent.node)!, lowlink.get(frame.node)!));
|
||||
const parent = iterStack[iterStack.length - 1];
|
||||
if (parent !== undefined) {
|
||||
lowlink.set(
|
||||
parent.node,
|
||||
Math.min(
|
||||
requiredNumber(lowlink, parent.node, 'lowlink'),
|
||||
requiredNumber(lowlink, frame.node, 'lowlink'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -654,10 +994,24 @@ function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedS
|
|||
entered: false,
|
||||
});
|
||||
} else if (onStack.has(child)) {
|
||||
lowlink.set(frame.node, Math.min(lowlink.get(frame.node)!, index.get(child)!));
|
||||
lowlink.set(
|
||||
frame.node,
|
||||
Math.min(
|
||||
requiredNumber(lowlink, frame.node, 'lowlink'),
|
||||
requiredNumber(index, child, 'index'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sccs;
|
||||
}
|
||||
|
||||
function requiredNumber(map: ReadonlyMap<string, number>, key: string, label: string): number {
|
||||
const value = map.get(key);
|
||||
if (value === undefined) {
|
||||
throw new Error(`Invariant violated: missing Tarjan ${label} for ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,10 +119,10 @@ export function buildScopeTree(scopes: readonly Scope[]): ScopeTree {
|
|||
`Scope '${scope.id}' (${scope.filePath}) has parent '${parent.id}' in a different file (${parent.filePath}). Parent/child scopes must share filePath.`,
|
||||
);
|
||||
}
|
||||
if (!rangeStrictlyContains(parent.range, scope.range)) {
|
||||
if (!canParentScope(parent.range, scope.range, parent.kind, scope.kind)) {
|
||||
throw new ScopeTreeInvariantError(
|
||||
'parent-must-contain-child',
|
||||
`Parent scope '${parent.id}' at ${formatRange(parent.range)} does not strictly contain child '${scope.id}' at ${formatRange(scope.range)}.`,
|
||||
`Parent scope '${parent.id}' at ${formatRange(parent.range)} does not contain child '${scope.id}' at ${formatRange(scope.range)} (allowed: strict containment, or equal-range Module-as-parent).`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -230,6 +230,47 @@ function rangeStrictlyContains(outer: Range, inner: Range): boolean {
|
|||
return outerStartsAtOrBefore && outerEndsAtOrAfter;
|
||||
}
|
||||
|
||||
function rangesEqual(a: Range, b: Range): boolean {
|
||||
return (
|
||||
a.startLine === b.startLine &&
|
||||
a.startCol === b.startCol &&
|
||||
a.endLine === b.endLine &&
|
||||
a.endCol === b.endCol
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `outer` (kind `outerKind`) is a valid parent for `inner` (kind
|
||||
* `innerKind`).
|
||||
*
|
||||
* Strict containment is the general rule. The single carve-out is the
|
||||
* `Module`/non-`Module` pair whose ranges are exactly equal — this happens
|
||||
* naturally when tree-sitter reports identical byte spans for the
|
||||
* `compilation_unit` (or equivalent file-root construct) and the file's
|
||||
* single top-level scope. Common shape: a C# file consisting of nothing
|
||||
* but `namespace X { ... }` with no leading or trailing trivia outside the
|
||||
* namespace's `{}` body — `compilation_unit` and `namespace_declaration`
|
||||
* both span exactly the same byte range. The `Module` is the universal
|
||||
* outer of any file-level scope by language semantics, so coincident
|
||||
* ranges should not break the parent chain.
|
||||
*
|
||||
* The carve-out is direction-asymmetric: only `Module`-as-outer parents a
|
||||
* same-range non-`Module`, never the reverse. This preserves the
|
||||
* acyclicity buildScopeTree relies on, and matches the corresponding
|
||||
* helper in `scope-extractor.ts` so `pass1BuildScopes` and the validator
|
||||
* agree on what a well-formed parent edge looks like.
|
||||
*/
|
||||
export function canParentScope(
|
||||
outer: Range,
|
||||
inner: Range,
|
||||
outerKind: Scope['kind'],
|
||||
innerKind: Scope['kind'],
|
||||
): boolean {
|
||||
if (rangeStrictlyContains(outer, inner)) return true;
|
||||
if (outerKind === 'Module' && innerKind !== 'Module' && rangesEqual(outer, inner)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two ranges overlap when neither finishes before the other begins. Ranges
|
||||
* that merely touch at a single boundary point (`a.end === b.start`) do
|
||||
|
|
|
|||
|
|
@ -10,8 +10,16 @@
|
|||
* Lifecycle contract (RFC §2.8): scopes are **constructed during extraction,
|
||||
* linked during finalize, immutable after finalize**. All fields are
|
||||
* `readonly` at the type level; `Object.freeze` is applied at runtime in dev
|
||||
* builds. `ReferenceIndex` is the sole structure populated after freeze — by
|
||||
* resolution, before emission.
|
||||
* builds.
|
||||
*
|
||||
* Two structures are populated after freeze:
|
||||
* 1. `ReferenceIndex` — by resolution, before emission.
|
||||
* 2. `ScopeResolutionIndexes.bindingAugmentations` — the dedicated
|
||||
* append-only post-finalize binding channel (e.g. C# same-namespace
|
||||
* cross-file fanout). The companion `indexes.bindings` is the
|
||||
* finalize-output channel and is deep-frozen by `materializeBindings`;
|
||||
* walkers consult both via `lookupBindingsAt`. See `ScopeResolver`
|
||||
* Invariant I8 for the full lifecycle contract.
|
||||
*/
|
||||
|
||||
import type { NodeLabel } from '../graph/types.js';
|
||||
|
|
@ -182,6 +190,42 @@ export type ParsedImport =
|
|||
readonly localName: string;
|
||||
/** Source text of the unresolved expression when available; `null` otherwise. */
|
||||
readonly targetRaw: string | null;
|
||||
}
|
||||
/**
|
||||
* Lazy / dynamic import whose target IS a static string literal at parse
|
||||
* time, so it can be linked to a concrete `targetFile`. No local name
|
||||
* binding is materialized — `import('./m')` returns `Promise<Module>` and
|
||||
* any consumer-visible names appear via subsequent `.then(({ X }) => …)`
|
||||
* destructuring, which is outside the static-import surface. The edge
|
||||
* exists for module-reachability and impact analysis (so editing `./m`
|
||||
* still flags the dynamic importer as affected).
|
||||
*
|
||||
* Providers MUST only emit this kind when `targetRaw` is a literal
|
||||
* string they can hand to `resolveImportTarget`; expression arguments
|
||||
* stay `dynamic-unresolved`.
|
||||
*
|
||||
* Examples:
|
||||
* - JS `import('./feature')` → `{ kind: 'dynamic-resolved', targetRaw: './feature' }`
|
||||
* - JS `await import('@scope/pkg/sub')` → `{ kind: 'dynamic-resolved', targetRaw: '@scope/pkg/sub' }`
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'dynamic-resolved';
|
||||
readonly targetRaw: string;
|
||||
}
|
||||
/**
|
||||
* Bare-source / side-effect import that introduces no local name binding
|
||||
* but still establishes a file-level dependency. Resolves to a concrete
|
||||
* `targetFile` via `resolveImportTarget` and produces a file→file
|
||||
* `ImportEdge` for module-reachability and impact analysis, with no
|
||||
* `BindingRef` materialized.
|
||||
*
|
||||
* Examples:
|
||||
* - JS / TS `import './polyfill'` → `{ kind: 'side-effect', targetRaw: './polyfill' }`
|
||||
* - Rust `use foo::bar as _` → side-effect (binding hidden under `_`)
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'side-effect';
|
||||
readonly targetRaw: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -253,7 +297,9 @@ export interface ImportEdge {
|
|||
| 'namespace'
|
||||
| 'wildcard-expanded'
|
||||
| 'reexport'
|
||||
| 'dynamic-unresolved';
|
||||
| 'dynamic-unresolved'
|
||||
| 'dynamic-resolved'
|
||||
| 'side-effect';
|
||||
/** Re-export chain, for provenance (e.g., `['./y']` when re-exported via `./y`). */
|
||||
readonly transitiveVia?: readonly string[];
|
||||
/** Set to `'unresolved'` when the SCC fixpoint could not link this edge. */
|
||||
|
|
|
|||
164
gitnexus-web/package-lock.json
generated
164
gitnexus-web/package-lock.json
generated
|
|
@ -13,7 +13,7 @@
|
|||
"@langchain/google-genai": "^2.1.28",
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/ollama": "^1.2.6",
|
||||
"@langchain/openai": "^1.4.4",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"axios": "^1.13.2",
|
||||
|
|
@ -32,15 +32,15 @@
|
|||
"mermaid": "^11.14.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.2",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -50,9 +50,9 @@
|
|||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
|
|
@ -1435,9 +1435,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "1.1.41",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.41.tgz",
|
||||
"integrity": "sha512-KdoNEf1YVJ9jnOP+smq4O6teu63tE7GDUryOnZ2lVfooHLrHK/ECUadjOcDSCK/yk/xBw/8nexJ3ZNBMtKnstw==",
|
||||
"version": "1.1.42",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.42.tgz",
|
||||
"integrity": "sha512-d0tN96BrwPMryYyWR9VfyAntSivn7EQrZCe5Kpxum93tcjTXbKKmKvItFec8AluQt88iTcmAJrahUZUNfzGwTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.0.2",
|
||||
|
|
@ -1449,26 +1449,12 @@
|
|||
"langsmith": ">=0.5.0 <1.0.0",
|
||||
"mustache": "^4.2.0",
|
||||
"p-queue": "^6.6.2",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/core/node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/google-genai": {
|
||||
"version": "2.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.1.28.tgz",
|
||||
|
|
@ -1552,9 +1538,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk": {
|
||||
"version": "1.8.9",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.9.tgz",
|
||||
"integrity": "sha512-vpz90auS4iFTNy2X/CFexOEoeFSvaK+MyI7iSmzYs9gGcfzwRjWUJ4MWsuc5ZNRecLStwho0PExVXRgGOXtcRw==",
|
||||
"version": "1.8.10",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.10.tgz",
|
||||
"integrity": "sha512-wrB3rkRw5KAmsqezwvKP3midT4qJrV6Hj9XJMYo+cbvXC4HYpSAmyY/VriSyeTFRbLG/OP/pY2Yz+9Z54nSaXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15",
|
||||
|
|
@ -1594,12 +1580,12 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk/node_modules/p-queue": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.2.tgz",
|
||||
"integrity": "sha512-ktsDOALzTYTWWF1PbkNVg2rOt+HaOaMWJMUnt7T3qf5tvZ1L8dBW3tObzprBcXNMKkwj+yFSLqHso0x+UFcJXw==",
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.2.0.tgz",
|
||||
"integrity": "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"p-timeout": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -1621,6 +1607,19 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk/node_modules/uuid": {
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz",
|
||||
"integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph/node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
|
|
@ -1664,20 +1663,20 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/openai": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.4.4.tgz",
|
||||
"integrity": "sha512-mRr/X5rvlwPj6cSXPxbL+CtOqYANO1/+CQ3Z+5t48kWnrlgPYOazmA+UAWvqQOuwJ6LaYn3SFrt43rR4lte/Ow==",
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.4.5.tgz",
|
||||
"integrity": "sha512-bQ2WMIZfSh02trJLYSAtiIcD3j6EBCiAm9nw0dZWQsVaUxmWc3JJqs8uUte6AkMazmLHzcUIw+14UkXO5fRJvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"openai": "^6.32.0",
|
||||
"openai": "^6.34.0",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.39"
|
||||
"@langchain/core": "^1.1.42"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
|
|
@ -2885,13 +2884,13 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
|
||||
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
|
|
@ -2900,30 +2899,23 @@
|
|||
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-syntax-highlighter": {
|
||||
|
|
@ -5505,6 +5497,7 @@
|
|||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
|
|
@ -6047,18 +6040,6 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lowlight": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
|
||||
|
|
@ -7746,28 +7727,24 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"react": "^19.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
|
|
@ -7836,9 +7813,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-zoom-pan-pinch": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz",
|
||||
"integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-4.0.3.tgz",
|
||||
"integrity": "sha512-N2Hi6L78fFmhRra+ORpFSW7WST5x6kxpOPplIvtB0b7b+U2anpo1z1wLgaWRPS2kUSqcraRG+JgBCIlDJnqqAg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
|
|
@ -8093,13 +8070,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
|
|
@ -8558,9 +8532,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
|
@ -8693,9 +8667,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
|
||||
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
"@langchain/google-genai": "^2.1.28",
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/ollama": "^1.2.6",
|
||||
"@langchain/openai": "^1.4.4",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"axios": "^1.13.2",
|
||||
|
|
@ -42,15 +42,15 @@
|
|||
"mermaid": "^11.14.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.2",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -60,9 +60,9 @@
|
|||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ WORKDIR /app
|
|||
RUN apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
COPY . .
|
||||
RUN npm ci --ignore-scripts \
|
||||
&& node scripts/patch-tree-sitter-swift.cjs \
|
||||
&& npm rebuild tree-sitter-swift 2>&1 \
|
||||
&& node -e "require('tree-sitter-swift')" \
|
||||
&& (npm rebuild 2>&1 || true) \
|
||||
&& cd node_modules/tree-sitter-kotlin && npx --yes node-gyp rebuild 2>&1
|
||||
CMD ["npx", "vitest", "run", "test/integration", "--reporter=verbose"]
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ gitnexus analyze --embeddings # Enable embedding generation (slower, better s
|
|||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768)
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI
|
||||
gitnexus index # Register an existing .gitnexus/ folder into the global registry
|
||||
|
|
@ -295,6 +296,25 @@ If `npm install -g gitnexus` fails on native modules:
|
|||
npm install -g gitnexus
|
||||
```
|
||||
|
||||
### Analyze warns about unavailable FTS or VECTOR extensions
|
||||
|
||||
GitNexus uses optional DuckDB extensions for BM25 and vector search. The `gitnexus serve` and MCP read paths only ever try to `LOAD` the extensions — they never block on a network install. The `analyze` command, by default, attempts one bounded out-of-process `INSTALL` if `LOAD` fails and proceeds even when that install times out, so the index is always written to disk; BM25/vector search degrade gracefully until the extensions become available.
|
||||
|
||||
Configure the behavior with two environment variables:
|
||||
|
||||
| Variable | Values | Default | Effect |
|
||||
|----------|--------|---------|--------|
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded INSTALL if LOAD fails. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. |
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process `INSTALL` child before it is killed. |
|
||||
|
||||
```bash
|
||||
# Offline/airgapped: never reach the network for extensions
|
||||
GITNEXUS_LBUG_EXTENSION_INSTALL=load-only npx gitnexus analyze
|
||||
|
||||
# Slow network: give extension downloads more time
|
||||
GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS=30000 npx gitnexus analyze
|
||||
```
|
||||
|
||||
### Analysis runs out of memory
|
||||
|
||||
For very large repositories:
|
||||
|
|
@ -323,6 +343,21 @@ npx gitnexus analyze
|
|||
|
||||
Values above **32768 KB (32 MB)** are clamped to the tree-sitter parser ceiling; invalid values fall back to the 512 KB default with a one-time warning. When an override is active, `analyze` prints the effective threshold in its startup banner (e.g. `GITNEXUS_MAX_FILE_SIZE: effective threshold 2048KB (default 512KB)`).
|
||||
|
||||
### Analyze reports a worker timeout
|
||||
|
||||
Worker parse timeouts are recoverable. GitNexus retries stalled worker jobs with backoff, splits large jobs to isolate slow files, and falls back to the sequential parser when needed. If a large repository needs more time per worker job, use either:
|
||||
|
||||
```bash
|
||||
# CLI flag, in seconds
|
||||
npx gitnexus analyze --worker-timeout 60
|
||||
|
||||
# Environment variable, in milliseconds
|
||||
export GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
For repositories with very large source files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget. The default is **8388608 bytes (8 MB)**.
|
||||
|
||||
## Privacy
|
||||
|
||||
- All processing happens locally on your machine
|
||||
|
|
|
|||
|
|
@ -31,11 +31,25 @@ function readInput() {
|
|||
* Find the .gitnexus directory by walking up from startDir.
|
||||
* Returns the path to .gitnexus/ or null if not found.
|
||||
*/
|
||||
function findGitNexusDir(startDir) {
|
||||
let dir = startDir || process.cwd();
|
||||
function isGlobalRegistryDir(candidate) {
|
||||
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
|
||||
return (
|
||||
fs.existsSync(path.join(candidate, 'registry.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'repos'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from `startDir` looking for a non-registry `.gitnexus/` folder.
|
||||
* Returns the path to `.gitnexus/` or null if not found within 5 levels.
|
||||
*/
|
||||
function walkForGitNexusDir(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
if (fs.existsSync(candidate)) {
|
||||
if (!isGlobalRegistryDir(candidate)) return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
|
|
@ -43,6 +57,51 @@ function findGitNexusDir(startDir) {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside
|
||||
* any git working tree — including a *linked* worktree created via
|
||||
* `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the
|
||||
* upward walk from cwd alone misses the index. Returns null when `cwd` is
|
||||
* not inside a git repo or `git` is not available.
|
||||
*
|
||||
* Implementation: `git rev-parse --git-common-dir` resolves to the canonical
|
||||
* `.git/` directory (or `.git/worktrees/...` parent) that is shared across
|
||||
* all linked worktrees. The canonical repo root is its parent directory.
|
||||
*/
|
||||
function findCanonicalRepoRoot(cwd) {
|
||||
try {
|
||||
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const commonDir = (result.stdout || '').trim();
|
||||
if (!commonDir || !path.isAbsolute(commonDir)) return null;
|
||||
return path.dirname(commonDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findGitNexusDir(startDir) {
|
||||
const cwd = startDir || process.cwd();
|
||||
|
||||
// Fast path: the cwd is inside the canonical repo (most common case).
|
||||
const fromCwd = walkForGitNexusDir(cwd);
|
||||
if (fromCwd) return fromCwd;
|
||||
|
||||
// Fallback: cwd may be inside a linked git worktree whose `.gitnexus/`
|
||||
// only lives in the canonical repo root. Resolve the shared git dir
|
||||
// and retry from there.
|
||||
const canonicalRoot = findCanonicalRepoRoot(cwd);
|
||||
if (canonicalRoot && canonicalRoot !== cwd) {
|
||||
return walkForGitNexusDir(canonicalRoot);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract search pattern from tool input.
|
||||
*/
|
||||
|
|
|
|||
309
gitnexus/package-lock.json
generated
309
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",
|
||||
|
|
@ -65,17 +65,17 @@
|
|||
"optionalDependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.0",
|
||||
"tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4",
|
||||
"tree-sitter-dart": "file:./vendor/tree-sitter-dart",
|
||||
"tree-sitter-kotlin": "^0.3.8",
|
||||
"tree-sitter-proto": "file:./vendor/tree-sitter-proto",
|
||||
"tree-sitter-swift": "^0.6.0"
|
||||
"tree-sitter-swift": "file:./vendor/tree-sitter-swift"
|
||||
}
|
||||
},
|
||||
"../gitnexus-shared": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
|
|
@ -1159,9 +1159,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@ladybugdb/core": {
|
||||
"version": "0.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.15.3.tgz",
|
||||
"integrity": "sha512-Xa8VmWhMTvTCWmApnqm9FJtyxxV+CiMCokl1p9vEfXNuBz3SWXWGDmHlzKikswtQbUe9tTV3J9MxPdVFVE6/yg==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.16.0.tgz",
|
||||
"integrity": "sha512-t/t4MPZmBMocFBzG5G3E3iHPwuIiXYEuLeW0CTOloGofkKQ7gHt3JlLzyDn2a+AHNQjr1YqlsodKKYQFhsFZXw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1169,16 +1169,17 @@
|
|||
"node-addon-api": "^6.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ladybugdb/core-darwin-arm64": "0.15.3",
|
||||
"@ladybugdb/core-linux-arm64": "0.15.3",
|
||||
"@ladybugdb/core-linux-x64": "0.15.3",
|
||||
"@ladybugdb/core-win32-x64": "0.15.3"
|
||||
"@ladybugdb/core-darwin-arm64": "0.16.0",
|
||||
"@ladybugdb/core-darwin-x64": "0.16.0",
|
||||
"@ladybugdb/core-linux-arm64": "0.16.0",
|
||||
"@ladybugdb/core-linux-x64": "0.16.0",
|
||||
"@ladybugdb/core-win32-x64": "0.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ladybugdb/core-darwin-arm64": {
|
||||
"version": "0.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.15.3.tgz",
|
||||
"integrity": "sha512-+bqAb3wbbmxPSeNQjbVd6Ek5K8GbHr1KlDr09YkNqZ7XWhKqWxbs097xAG9bynLcZh9oxok2PGCoK4w5YHs11w==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.16.0.tgz",
|
||||
"integrity": "sha512-2IpiUbd6Lb50KRUkURk+PIgDRKume63uI4KYZNpjxNDwdHRXdadZTBZn74+DgK7IhpTyiPbtKddiXHKtSV2CWg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1189,9 +1190,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-linux-arm64": {
|
||||
"version": "0.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.15.3.tgz",
|
||||
"integrity": "sha512-Z8Ur6YbC5y6pgtKh/7b1/xdeRHy69sGhsoVJm1tc9xp9Zrar6G2A71bEdjOdDJ/mDRt6RtY0zdhUgIgQXYQtbQ==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.16.0.tgz",
|
||||
"integrity": "sha512-l+lV7BXfnA0w1voApKblBaGE+bKQqSlOG+30HkSYOAW7POYv+OoydgY/BGwabBUTvcnhVyrNApvBsPF8G3Nm3g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1202,9 +1203,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-linux-x64": {
|
||||
"version": "0.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.15.3.tgz",
|
||||
"integrity": "sha512-DT9xBc91tuxzjRu1dJ3xGt/K/uR1Q8bX5+8tCtj66UbVIVvp1RWAAE9phq7eahcF/3zBuFRonkxW/tTyQdQIlQ==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.16.0.tgz",
|
||||
"integrity": "sha512-XOL2H0y51e57dIFIHO8LHtN8Ner2qEyti6zAkxKr+w8LkvczHeVX910doz2de8+xvxDYJyzrcj2xWqDTxcK/Jg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1215,9 +1216,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-win32-x64": {
|
||||
"version": "0.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.15.3.tgz",
|
||||
"integrity": "sha512-ymHC8nHGIT7M9aditBQFIystxW+WoqvI3xklz22BHaFpU9CrTNtdU20K6cuRZvqEA2//Edu7kMoP9OwLkIleCg==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.16.0.tgz",
|
||||
"integrity": "sha512-MyKiELqPgzx9gVHmwxzptnToAcDtCN7dTP5Y4IPMYhc2QpNbZKCihmvdXjbOmdIOfIHW4fBp4vrzT8fVbdAMZw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1227,6 +1228,9 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core/node_modules/@ladybugdb/core-darwin-x64": {
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@ladybugdb/core/node_modules/node-addon-api": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
|
||||
|
|
@ -2428,13 +2432,6 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/boolean": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
|
|
@ -2763,12 +2760,6 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
|
||||
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
|
@ -2841,12 +2832,6 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es6-error": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
|
||||
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
|
|
@ -3270,17 +3255,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/global-agent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
|
||||
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz",
|
||||
"integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"boolean": "^3.0.1",
|
||||
"es6-error": "^4.1.1",
|
||||
"matcher": "^3.0.0",
|
||||
"roarr": "^2.15.3",
|
||||
"semver": "^7.3.2",
|
||||
"serialize-error": "^7.0.1"
|
||||
"globalthis": "^1.0.2",
|
||||
"matcher": "^4.0.0",
|
||||
"semver": "^7.3.5",
|
||||
"serialize-error": "^8.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0"
|
||||
|
|
@ -3612,12 +3595,6 @@
|
|||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/json-stringify-safe": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||
|
|
@ -3951,15 +3928,18 @@
|
|||
}
|
||||
},
|
||||
"node_modules/matcher": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
||||
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz",
|
||||
"integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
|
|
@ -4077,9 +4057,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/mnemonist": {
|
||||
"version": "0.40.3",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
|
||||
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
|
||||
"version": "0.40.4",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz",
|
||||
"integrity": "sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"obliterator": "^2.0.4"
|
||||
|
|
@ -4214,15 +4194,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/onnxruntime-common": {
|
||||
"version": "1.24.3",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
|
||||
"integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.25.1.tgz",
|
||||
"integrity": "sha512-kKvYQFdos4LWJqhZ+nmKu3NT8NXzw8I5x9fNUKe1rNKcPfNKnYXUtW7JBpcKFsvLtrJashRgVYSbFap4cHxvNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/onnxruntime-node": {
|
||||
"version": "1.24.3",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz",
|
||||
"integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.25.1.tgz",
|
||||
"integrity": "sha512-N0M58CGTiTsLkPpx9bxmRFi24GT6r67Qei/GrBEIiDyntcYdXU5vQZp112ypydG9vEKRFgbgUYQJnEi+jll8dg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
|
|
@ -4232,8 +4212,8 @@
|
|||
],
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"global-agent": "^3.0.0",
|
||||
"onnxruntime-common": "1.24.3"
|
||||
"global-agent": "^4.1.3",
|
||||
"onnxruntime-common": "1.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-web": {
|
||||
|
|
@ -4520,23 +4500,6 @@
|
|||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/roarr": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
|
||||
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"boolean": "^3.0.1",
|
||||
"detect-node": "^2.0.4",
|
||||
"globalthis": "^1.0.1",
|
||||
"json-stringify-safe": "^5.0.1",
|
||||
"semver-compare": "^1.0.0",
|
||||
"sprintf-js": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
|
||||
|
|
@ -4635,12 +4598,6 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/semver-compare": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
|
||||
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
|
|
@ -4681,12 +4638,12 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serialize-error": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
|
||||
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
|
||||
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"type-fest": "^0.13.1"
|
||||
"type-fest": "^0.20.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
|
|
@ -4870,12 +4827,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
|
|
@ -5028,20 +4979,20 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tree-sitter-c": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-c/-/tree-sitter-c-0.23.2.tgz",
|
||||
"integrity": "sha512-9kADOx31AF94DHcrsMGW0zM/2LS6v7wFkPHPVm7RQU+vYVVZMKZ2FJ9e99pm5feqsAcjUzB9CarqDLgRT1Fe/w==",
|
||||
"version": "0.21.4",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-c/-/tree-sitter-c-0.21.4.tgz",
|
||||
"integrity": "sha512-IahxFIhXiY15SUlrt2upBiKSBGdOaE1fjKLK1Ik5zxqGHf6T1rvr3IJrovbsE5sXhypx7Hnmf50gshsppaIihA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.2.2",
|
||||
"node-gyp-build": "^4.8.2"
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.1"
|
||||
"tree-sitter": "^0.21.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tree-sitter": {
|
||||
"tree_sitter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
|
@ -5065,30 +5016,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-cli": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-cli/-/tree-sitter-cli-0.23.2.tgz",
|
||||
"integrity": "sha512-kPPXprOqREX+C/FgUp2Qpt9jd0vSwn+hOgjzVv/7hapdoWpa+VeWId53rf4oNNd29ikheF12BYtGD/W90feMbA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"tree-sitter": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-cpp": {
|
||||
"version": "0.23.4",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-cpp/-/tree-sitter-cpp-0.23.4.tgz",
|
||||
"integrity": "sha512-qR5qUDyhZ5jJ6V8/umiBxokRbe89bCGmcq/dk94wI4kN86qfdV8k0GHIUEKaqWgcu42wKal5E97LKpLeVW8sKw==",
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-cpp/-/tree-sitter-cpp-0.23.2.tgz",
|
||||
"integrity": "sha512-GTa5Dx1O9ihzW70LvaUviTclh+wlBDRz6opR9Ij4NQIFmq/joeZ/k65UbLV4nLidR7xZ9eNNGT/SonCqAmjGVg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.2.1",
|
||||
"node-gyp-build": "^4.8.2",
|
||||
"tree-sitter-c": "^0.23.1"
|
||||
"node-gyp-build": "^4.8.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.1"
|
||||
|
|
@ -5100,31 +5036,8 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tree-sitter-dart": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "git+ssh://git@github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4",
|
||||
"integrity": "sha512-Bs/1wAOIJ2akPEXlE/XVpuES19Oo3NqoSJRJ/0N2r38qAd9nTXdqmaGHQ44/JXnA6QHcbgD2YzCCc4wUc98cyQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0",
|
||||
"node-gyp-build": "^4.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tree_sitter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-dart/node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"resolved": "vendor/tree-sitter-dart",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/tree-sitter-go": {
|
||||
"version": "0.23.4",
|
||||
|
|
@ -5291,49 +5204,8 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tree-sitter-swift": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-swift/-/tree-sitter-swift-0.6.0.tgz",
|
||||
"integrity": "sha512-9vOJZes4/UFjBr4COHtp6ZHVuZYwfChSQbpneXQog04dAstfx5px3ybVX2cN+ylvLqsvVpmXLpidxxgF2rDQ7A==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.0",
|
||||
"tree-sitter-cli": "^0.23",
|
||||
"which": "2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tree_sitter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-swift/node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tree-sitter-swift/node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
"resolved": "vendor/tree-sitter-swift",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/tree-sitter-typescript": {
|
||||
"version": "0.23.2",
|
||||
|
|
@ -5383,9 +5255,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
||||
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
|
||||
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
|
|
@ -5768,6 +5640,19 @@
|
|||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
},
|
||||
"vendor/tree-sitter-dart": {
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tree_sitter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"vendor/tree-sitter-proto": {
|
||||
"version": "0.4.1",
|
||||
"license": "MIT",
|
||||
|
|
@ -5775,6 +5660,24 @@
|
|||
"peerDependencies": {
|
||||
"tree-sitter": ">=0.21.0"
|
||||
}
|
||||
},
|
||||
"vendor/tree-sitter-swift": {
|
||||
"version": "0.7.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.1 || ^0.22.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tree-sitter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@
|
|||
"hooks",
|
||||
"scripts",
|
||||
"skills",
|
||||
"vendor"
|
||||
"vendor",
|
||||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node scripts/build.js",
|
||||
|
|
@ -46,13 +47,13 @@
|
|||
"test:integration": "vitest run test/integration",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"postinstall": "node scripts/patch-tree-sitter-swift.cjs && node scripts/build-tree-sitter-proto.cjs",
|
||||
"postinstall": "node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs",
|
||||
"prepare": "node scripts/build.js",
|
||||
"prepack": "node scripts/build.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^4.1.0",
|
||||
"@ladybugdb/core": "^0.15.2",
|
||||
"@ladybugdb/core": "^0.16.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
|
@ -71,9 +72,9 @@
|
|||
"onnxruntime-node": "^1.24.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"tree-sitter": "^0.21.1",
|
||||
"tree-sitter-c": "0.23.2",
|
||||
"tree-sitter-c": "0.21.4",
|
||||
"tree-sitter-c-sharp": "0.23.1",
|
||||
"tree-sitter-cpp": "^0.23.4",
|
||||
"tree-sitter-cpp": "0.23.2",
|
||||
"tree-sitter-go": "^0.23.0",
|
||||
"tree-sitter-java": "^0.23.5",
|
||||
"tree-sitter-javascript": "^0.23.0",
|
||||
|
|
@ -87,10 +88,10 @@
|
|||
"optionalDependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.0",
|
||||
"tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4",
|
||||
"tree-sitter-dart": "file:./vendor/tree-sitter-dart",
|
||||
"tree-sitter-kotlin": "^0.3.8",
|
||||
"tree-sitter-proto": "file:./vendor/tree-sitter-proto",
|
||||
"tree-sitter-swift": "^0.6.0"
|
||||
"tree-sitter-swift": "file:./vendor/tree-sitter-swift"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
|
|
@ -108,8 +109,7 @@
|
|||
"overrides": {
|
||||
"@huggingface/transformers": {
|
||||
"onnxruntime-node": "$onnxruntime-node"
|
||||
},
|
||||
"tree-sitter-c": "0.23.2"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
|
|
|||
42
gitnexus/scripts/build-tree-sitter-dart.cjs
Normal file
42
gitnexus/scripts/build-tree-sitter-dart.cjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const dartDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-dart');
|
||||
const bindingGyp = path.join(dartDir, 'binding.gyp');
|
||||
const bindingNode = path.join(dartDir, 'build', 'Release', 'tree_sitter_dart_binding.node');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(bindingGyp) || fs.existsSync(bindingNode)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
require.resolve('node-addon-api');
|
||||
require.resolve('node-gyp-build');
|
||||
} catch (resolveErr) {
|
||||
console.warn(
|
||||
'[tree-sitter-dart] Skipping build: hoisted build deps not resolvable (%s).',
|
||||
resolveErr.message,
|
||||
);
|
||||
console.warn(
|
||||
'[tree-sitter-dart] Dart parsing will be unavailable. Install without --no-optional and with scripts enabled to build.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('[tree-sitter-dart] Building native binding...');
|
||||
execSync('npx node-gyp rebuild', {
|
||||
cwd: dartDir,
|
||||
stdio: 'pipe',
|
||||
timeout: 180000,
|
||||
});
|
||||
console.log('[tree-sitter-dart] Native binding built successfully');
|
||||
} catch (err) {
|
||||
console.warn('[tree-sitter-dart] Could not build native binding:', err.message);
|
||||
console.warn(
|
||||
'[tree-sitter-dart] Dart parsing will be unavailable. Non-Dart functionality is unaffected.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
* `node_modules/tree-sitter-proto/build/Release/tree_sitter_proto_binding.node`
|
||||
* — under npm-managed territory, safe on upgrade.
|
||||
*
|
||||
* Mirrors scripts/patch-tree-sitter-swift.cjs. Best-effort: if any
|
||||
* Mirrors the tree-sitter-dart build helper. Best-effort: if any
|
||||
* precondition fails (optional dep absent, no toolchain, --ignore-scripts),
|
||||
* warn and exit 0 so gitnexus install still succeeds.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -33,11 +33,11 @@ function compileTypeScriptProject(projectRoot) {
|
|||
|
||||
// ── 1. Build gitnexus-shared ───────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus-shared…');
|
||||
compileTypeScriptProject(SHARED_ROOT);
|
||||
execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// ── 2. Build gitnexus ──────────────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus…');
|
||||
compileTypeScriptProject(ROOT);
|
||||
execSync('npx tsc', { cwd: ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// ── 3. Copy shared dist ────────────────────────────────────────────
|
||||
console.log('[build] copying shared module into dist/_shared…');
|
||||
|
|
@ -82,4 +82,24 @@ walk(DIST, ['.js', '.d.ts'], rewriteFile);
|
|||
const cliEntry = path.join(DIST, 'cli', 'index.js');
|
||||
if (fs.existsSync(cliEntry)) fs.chmodSync(cliEntry, 0o755);
|
||||
|
||||
// ── 6. Build & copy web UI ──────────────────────────────────────────
|
||||
const WEB_ROOT = path.resolve(ROOT, '..', 'gitnexus-web');
|
||||
const WEB_DEST = path.join(DIST, '..', 'web');
|
||||
|
||||
if (fs.existsSync(path.join(WEB_ROOT, 'package.json'))) {
|
||||
console.log('[build] building gitnexus-web…');
|
||||
if (!fs.existsSync(path.join(WEB_ROOT, 'node_modules'))) {
|
||||
console.log('[build] installing gitnexus-web dependencies…');
|
||||
execSync('npm ci', { cwd: WEB_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
}
|
||||
execSync('npm run build', { cwd: WEB_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// Copy dist → gitnexus/web/ (shipped in the npm package)
|
||||
fs.rmSync(WEB_DEST, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(WEB_ROOT, 'dist'), WEB_DEST, { recursive: true });
|
||||
console.log('[build] copied web UI → gitnexus/web/');
|
||||
} else {
|
||||
console.log('[build] skipping web UI (gitnexus-web not found)');
|
||||
}
|
||||
|
||||
console.log(`[build] done — rewrote ${rewritten} files.`);
|
||||
|
|
|
|||
48
gitnexus/scripts/install-duckdb-extension.mjs
Normal file
48
gitnexus/scripts/install-duckdb-extension.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env node
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
|
||||
|
||||
function parseLbugMaxDbSize(raw) {
|
||||
const parsed = raw ? Number(raw) : NaN;
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`Invalid LadybugDB max DB size for extension installer: ${raw ?? '<missing>'}`);
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
async function installDuckDbExtension(extensionName) {
|
||||
if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) {
|
||||
throw new Error(`Invalid DuckDB extension name: ${extensionName ?? '<missing>'}`);
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const lbugModule = require('@ladybugdb/core');
|
||||
const lbug = lbugModule.default ?? lbugModule;
|
||||
const lbugMaxDbSize = parseLbugMaxDbSize(
|
||||
process.argv[3] ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE,
|
||||
);
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ext-install-'));
|
||||
const dbPath = path.join(tmpDir, 'install.lbug');
|
||||
let db;
|
||||
let conn;
|
||||
|
||||
try {
|
||||
db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize);
|
||||
conn = new lbug.Connection(db);
|
||||
await conn.query(`INSTALL ${extensionName}`);
|
||||
} finally {
|
||||
if (conn) await conn.close().catch(() => {});
|
||||
if (db) await db.close().catch(() => {});
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
installDuckDbExtension(process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME).catch((err) => {
|
||||
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* WORKAROUND: tree-sitter-swift@0.6.0 binding.gyp build failure
|
||||
*
|
||||
* Background:
|
||||
* tree-sitter-swift@0.6.0's binding.gyp contains an "actions" array that
|
||||
* invokes `tree-sitter generate` to regenerate parser.c from grammar.js.
|
||||
* This is intended for grammar developers, but the published npm package
|
||||
* already ships pre-generated parser files (parser.c, scanner.c), so the
|
||||
* actions are unnecessary for consumers. Since consumers don't have
|
||||
* tree-sitter-cli installed, the actions always fail during `npm install`.
|
||||
*
|
||||
* Why we can't just upgrade:
|
||||
* tree-sitter-swift@0.7.1 fixes this (removes postinstall, ships prebuilds),
|
||||
* but it requires tree-sitter@^0.22.1. The upstream project pins tree-sitter
|
||||
* to ^0.21.0 and all other grammar packages depend on that version.
|
||||
* Upgrading tree-sitter would be a separate breaking change.
|
||||
*
|
||||
* How this workaround works:
|
||||
* 1. tree-sitter-swift's own postinstall fails (npm warns but continues)
|
||||
* 2. This script runs as gitnexus's postinstall
|
||||
* 3. It removes the "actions" array from binding.gyp
|
||||
* 4. It rebuilds the native binding with the cleaned binding.gyp
|
||||
*
|
||||
* TODO: Remove this script when tree-sitter is upgraded to ^0.22.x,
|
||||
* which allows using tree-sitter-swift@0.7.1+ directly.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift');
|
||||
const bindingPath = path.join(swiftDir, 'binding.gyp');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(bindingPath)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(bindingPath, 'utf8');
|
||||
let needsRebuild = false;
|
||||
|
||||
if (content.includes('"actions"')) {
|
||||
// Strip Python-style comments (#) and trailing commas before JSON parsing
|
||||
const cleaned = content
|
||||
.replace(/#[^\n]*/g, '') // Remove # comments
|
||||
.replace(/,(\s*[\]}])/g, '$1'); // Remove trailing commas before ] or }
|
||||
const gyp = JSON.parse(cleaned);
|
||||
|
||||
if (gyp.targets && gyp.targets[0] && gyp.targets[0].actions) {
|
||||
delete gyp.targets[0].actions;
|
||||
fs.writeFileSync(bindingPath, JSON.stringify(gyp, null, 2) + '\n');
|
||||
console.log('[tree-sitter-swift] Patched binding.gyp (removed actions array)');
|
||||
needsRebuild = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if native binding exists
|
||||
const bindingNode = path.join(swiftDir, 'build', 'Release', 'tree_sitter_swift_binding.node');
|
||||
if (!fs.existsSync(bindingNode)) {
|
||||
needsRebuild = true;
|
||||
}
|
||||
|
||||
if (needsRebuild) {
|
||||
console.log('[tree-sitter-swift] Rebuilding native binding...');
|
||||
execSync('npx node-gyp rebuild', {
|
||||
cwd: swiftDir,
|
||||
stdio: 'pipe',
|
||||
timeout: 120000,
|
||||
});
|
||||
console.log('[tree-sitter-swift] Native binding built successfully');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[tree-sitter-swift] Could not build native binding:', err.message);
|
||||
console.warn(
|
||||
'[tree-sitter-swift] You may need to manually run: cd node_modules/tree-sitter-swift && npx node-gyp rebuild',
|
||||
);
|
||||
}
|
||||
|
|
@ -17,12 +17,52 @@ import {
|
|||
getStoragePaths,
|
||||
getGlobalRegistryPath,
|
||||
RegistryNameCollisionError,
|
||||
AnalysisNotFinalizedError,
|
||||
assertAnalysisFinalized,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { getGitRoot, hasGitDir } from '../storage/git.js';
|
||||
import { runFullAnalysis } from '../core/run-analyze.js';
|
||||
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
// Capture stderr.write at module load BEFORE anything (LadybugDB native
|
||||
// init, progress bar, console redirection) can monkey-patch it. The
|
||||
// fatal handlers below MUST reach the user even when the analyze path
|
||||
// has redirected console.* through the progress bar's bar.log() — the
|
||||
// previous behaviour silently swallowed stack traces and made #1169
|
||||
// indistinguishable from a no-op success on Windows.
|
||||
const realStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
|
||||
const writeFatalToStderr = (label: string, err: unknown): void => {
|
||||
const isErr = err instanceof Error;
|
||||
const message = isErr ? err.message : String(err);
|
||||
realStderrWrite(`\n ${label}: ${message}\n`);
|
||||
if (isErr && err.stack) realStderrWrite(`${err.stack}\n`);
|
||||
};
|
||||
|
||||
let fatalHandlersInstalled = false;
|
||||
|
||||
/**
|
||||
* Install one-shot `unhandledRejection` / `uncaughtException` handlers
|
||||
* that surface the failure to the real stderr (bypassing any console
|
||||
* redirection installed by the progress bar) and force a non-zero exit
|
||||
* code. Without these, an async error escaping {@link analyzeCommand}'s
|
||||
* try/catch was reported as exit 0 with no diagnostic — the silent
|
||||
* failure mode tracked in #1169.
|
||||
*/
|
||||
const installFatalHandlers = (): void => {
|
||||
if (fatalHandlersInstalled) return;
|
||||
fatalHandlersInstalled = true;
|
||||
process.on('unhandledRejection', (err) => {
|
||||
writeFatalToStderr('Analysis failed (unhandled rejection)', err);
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('uncaughtException', (err) => {
|
||||
writeFatalToStderr('Analysis failed (uncaught exception)', err);
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
|
||||
const HEAP_MB = 8192;
|
||||
const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`;
|
||||
/** Increase default stack size (KB) to prevent stack overflow on deep class hierarchies. */
|
||||
|
|
@ -91,11 +131,22 @@ export interface AnalyzeOptions {
|
|||
* `GITNEXUS_MAX_FILE_SIZE` for the rest of the pipeline.
|
||||
*/
|
||||
maxFileSize?: string;
|
||||
/** Override worker sub-batch idle timeout in seconds. */
|
||||
workerTimeout?: string;
|
||||
embeddingThreads?: string;
|
||||
embeddingBatchSize?: string;
|
||||
embeddingSubBatchSize?: string;
|
||||
embeddingDevice?: string;
|
||||
}
|
||||
|
||||
export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
|
||||
if (ensureHeap()) return;
|
||||
|
||||
// Install fatal handlers immediately after re-exec resolution so any
|
||||
// async error that escapes the try/catch below (#1169) surfaces with
|
||||
// a stack trace and a non-zero exit code instead of a silent exit 0.
|
||||
installFatalHandlers();
|
||||
|
||||
if (options?.verbose) {
|
||||
process.env.GITNEXUS_VERBOSE = '1';
|
||||
}
|
||||
|
|
@ -104,26 +155,82 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
process.env.GITNEXUS_MAX_FILE_SIZE = options.maxFileSize;
|
||||
}
|
||||
|
||||
if (options?.workerTimeout) {
|
||||
const workerTimeoutSeconds = Number(options.workerTimeout);
|
||||
if (!Number.isFinite(workerTimeoutSeconds) || workerTimeoutSeconds < 1) {
|
||||
console.error(' --worker-timeout must be at least 1 second.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(
|
||||
Math.round(workerTimeoutSeconds * 1000),
|
||||
);
|
||||
}
|
||||
|
||||
const setPositiveEnv = (
|
||||
optionName: string,
|
||||
envName: string,
|
||||
value: string | undefined,
|
||||
): boolean => {
|
||||
if (value === undefined) return true;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
console.error(` ${optionName} must be a positive integer.\n`);
|
||||
process.exitCode = 1;
|
||||
return false;
|
||||
}
|
||||
process.env[envName] = String(parsed);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (
|
||||
!setPositiveEnv(
|
||||
'--embedding-threads',
|
||||
'GITNEXUS_EMBEDDING_THREADS',
|
||||
options?.embeddingThreads,
|
||||
) ||
|
||||
!setPositiveEnv(
|
||||
'--embedding-batch-size',
|
||||
'GITNEXUS_EMBEDDING_BATCH_SIZE',
|
||||
options?.embeddingBatchSize,
|
||||
) ||
|
||||
!setPositiveEnv(
|
||||
'--embedding-sub-batch-size',
|
||||
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
|
||||
options?.embeddingSubBatchSize,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.embeddingDevice) {
|
||||
const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']);
|
||||
if (!allowed.has(options.embeddingDevice)) {
|
||||
console.error(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.env.GITNEXUS_EMBEDDING_DEVICE = options.embeddingDevice;
|
||||
}
|
||||
|
||||
console.log('\n GitNexus Analyzer\n');
|
||||
|
||||
let repoPath: string;
|
||||
if (inputPath) {
|
||||
repoPath = path.resolve(inputPath);
|
||||
} else if (options?.skipGit) {
|
||||
// --skip-git: treat cwd as the index root, do not walk up to a parent git repo.
|
||||
repoPath = path.resolve(process.cwd());
|
||||
} else {
|
||||
const gitRoot = getGitRoot(process.cwd());
|
||||
if (!gitRoot) {
|
||||
if (!options?.skipGit) {
|
||||
console.log(
|
||||
' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// --skip-git: fall back to cwd as the root
|
||||
repoPath = path.resolve(process.cwd());
|
||||
} else {
|
||||
repoPath = gitRoot;
|
||||
console.log(
|
||||
' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
repoPath = gitRoot;
|
||||
}
|
||||
|
||||
const repoHasGit = hasGitDir(repoPath);
|
||||
|
|
@ -252,6 +359,11 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
);
|
||||
|
||||
if (result.alreadyUpToDate) {
|
||||
// Even the fast path must prove the repo is discoverable. A prior
|
||||
// run can write meta.json and then fail before registerRepo(); in
|
||||
// that half-finalized state, runFullAnalysis returns alreadyUpToDate
|
||||
// on the next invocation unless we check the registry here too.
|
||||
await assertAnalysisFinalized(repoPath);
|
||||
clearInterval(elapsedTimer);
|
||||
process.removeListener('SIGINT', sigintHandler);
|
||||
console.log = origLog;
|
||||
|
|
@ -264,6 +376,15 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
return;
|
||||
}
|
||||
|
||||
// Post-finalize invariant (#1169): runFullAnalysis nominally writes
|
||||
// meta.json and registers the repo, but on Windows it has been
|
||||
// observed to return successfully with neither artifact present
|
||||
// (banner-only output, exit 0). Verify both before declaring
|
||||
// success so the silent-finalize state surfaces with a non-zero
|
||||
// exit code and an actionable error instead of being mistaken for
|
||||
// a healthy index.
|
||||
await assertAnalysisFinalized(repoPath);
|
||||
|
||||
// Skill generation (CLI-only, uses pipeline result from analysis)
|
||||
if (options?.skills && result.pipelineResult) {
|
||||
updateBar(99, 'Generating skill files...');
|
||||
|
|
@ -365,7 +486,29 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
return;
|
||||
}
|
||||
|
||||
console.error(`\n Analysis failed: ${msg}\n`);
|
||||
// Finalize invariant failure (#1169) — keep the rich actionable
|
||||
// message intact and write through realStderrWrite so it can't be
|
||||
// erased by a leftover bar refresh on slow terminals.
|
||||
if (err instanceof AnalysisNotFinalizedError) {
|
||||
writeFatalToStderr('Analysis did not finalize', err);
|
||||
realStderrWrite(
|
||||
`\n Diagnostic checklist:\n` +
|
||||
` 1. Re-run "gitnexus analyze" - transient native errors often clear on retry.\n` +
|
||||
` 2. Inspect ${err.storagePath} - a leftover lbug.wal indicates an aborted write.\n` +
|
||||
` 3. If the failure persists, run with NODE_OPTIONS="--max-old-space-size=8192 --trace-exit"\n` +
|
||||
` and attach the trace to the GitNexus issue tracker.\n\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Bypass the redirected console.error and write the full stack to
|
||||
// the real stderr captured at module load. The redirected
|
||||
// console.error wraps every line with `\\x1b[2K\\r` (ANSI clear-line)
|
||||
// and forces a bar.update() afterwards, which on some Windows
|
||||
// terminals visually erases the failure message — the canonical
|
||||
// shape of the silent-exit symptom in #1169.
|
||||
writeFatalToStderr('Analysis failed', err);
|
||||
|
||||
// Provide helpful guidance for known failure modes
|
||||
if (
|
||||
|
|
|
|||
32
gitnexus/src/cli/doctor.ts
Normal file
32
gitnexus/src/cli/doctor.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js';
|
||||
import { resolveEmbeddingConfig } from '../core/embeddings/config.js';
|
||||
import { isHttpMode } from '../core/embeddings/http-client.js';
|
||||
|
||||
export const doctorCommand = async () => {
|
||||
const fingerprint = getRuntimeFingerprint();
|
||||
const capabilities = getRuntimeCapabilities();
|
||||
const embeddingConfig = resolveEmbeddingConfig();
|
||||
|
||||
console.log('GitNexus Doctor\n');
|
||||
console.log('Runtime');
|
||||
console.log(` OS: ${fingerprint.platform}/${fingerprint.arch}`);
|
||||
console.log(` Node: ${fingerprint.node}`);
|
||||
console.log(` GitNexus: ${fingerprint.gitnexus}`);
|
||||
console.log(` LadybugDB: ${fingerprint.ladybugdb ?? 'unknown'}`);
|
||||
console.log(` ONNX: ${fingerprint.onnxruntime ?? 'unknown'}`);
|
||||
console.log('');
|
||||
console.log('Capabilities');
|
||||
console.log(` Graph store: ${capabilities.graph}`);
|
||||
console.log(` Full-text search:${capabilities.fts.padStart(10)}`);
|
||||
console.log(` VECTOR index: ${capabilities.vector}`);
|
||||
console.log(` Semantic mode: ${capabilities.semanticMode}`);
|
||||
console.log(` Exact scan limit:${String(capabilities.exactScanLimit).padStart(9)} chunks`);
|
||||
if (capabilities.reason) console.log(` Note: ${capabilities.reason}`);
|
||||
console.log('');
|
||||
console.log('Embeddings');
|
||||
console.log(` Backend: ${isHttpMode() ? 'http' : 'local'}`);
|
||||
console.log(` Device: ${embeddingConfig.device}`);
|
||||
console.log(` Threads: ${embeddingConfig.threads}`);
|
||||
console.log(` Batch: ${embeddingConfig.batchSize} nodes`);
|
||||
console.log(` Sub-batch: ${embeddingConfig.subBatchSize} chunks`);
|
||||
};
|
||||
|
|
@ -14,7 +14,7 @@ import fs from 'fs/promises';
|
|||
import {
|
||||
getStoragePaths,
|
||||
loadMeta,
|
||||
addToGitignore,
|
||||
ensureGitNexusIgnored,
|
||||
registerRepo,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { getGitRoot, getRemoteUrl, isGitRepo } from '../storage/git.js';
|
||||
|
|
@ -115,7 +115,7 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt
|
|||
meta.remoteUrl = getRemoteUrl(repoPath);
|
||||
}
|
||||
await registerRepo(repoPath, meta);
|
||||
await addToGitignore(repoPath);
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
|
||||
const projectName = path.basename(repoPath);
|
||||
const { stats } = meta;
|
||||
|
|
|
|||
|
|
@ -32,7 +32,10 @@ program
|
|||
.option('--skills', 'Generate repo-specific skill files from detected communities')
|
||||
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
|
||||
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
|
||||
.option('--skip-git', 'Index a folder without requiring a .git directory')
|
||||
.option(
|
||||
'--skip-git',
|
||||
'Treat the provided path/cwd as the index root and skip parent git-root discovery',
|
||||
)
|
||||
.option(
|
||||
'--name <alias>',
|
||||
'Register this repo under a custom name in ~/.gitnexus/registry.json ' +
|
||||
|
|
@ -48,11 +51,23 @@ program
|
|||
'--max-file-size <kb>',
|
||||
'Skip files larger than this (KB). Default: 512. Hard cap: 32768 (tree-sitter limit).',
|
||||
)
|
||||
.option(
|
||||
'--worker-timeout <seconds>',
|
||||
'Worker sub-batch idle timeout before retry/fallback. Default: 30.',
|
||||
)
|
||||
.option('--embedding-threads <n>', 'Limit local ONNX embedding CPU threads')
|
||||
.option('--embedding-batch-size <n>', 'Number of nodes per embedding batch')
|
||||
.option('--embedding-sub-batch-size <n>', 'Number of chunks per embedding model call')
|
||||
.option('--embedding-device <device>', 'Embedding device: auto, cpu, dml, cuda, or wasm')
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nEnvironment variables:\n' +
|
||||
' GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n' +
|
||||
' GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n' +
|
||||
' GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n' +
|
||||
' GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n' +
|
||||
' GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n' +
|
||||
' GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n' +
|
||||
'\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n' +
|
||||
' `!__tests__/` to index a directory that is auto-filtered by default (#771).',
|
||||
)
|
||||
|
|
@ -89,6 +104,11 @@ program
|
|||
.description('Show index status for current repo')
|
||||
.action(createLazyAction(() => import('./status.js'), 'statusCommand'));
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.description('Show runtime platform capabilities and embedding configuration')
|
||||
.action(createLazyAction(() => import('./doctor.js'), 'doctorCommand'));
|
||||
|
||||
program
|
||||
.command('clean')
|
||||
.description('Delete GitNexus index for current repo')
|
||||
|
|
|
|||
|
|
@ -581,13 +581,13 @@ async function installCursorSkills(result: SetupResult): Promise<void> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Install global OpenCode skills to ~/.config/opencode/skill/gitnexus/
|
||||
* Install global OpenCode skills to ~/.config/opencode/skills/gitnexus/
|
||||
*/
|
||||
async function installOpenCodeSkills(result: SetupResult): Promise<void> {
|
||||
const opencodeDir = path.join(os.homedir(), '.config', 'opencode');
|
||||
if (!(await dirExists(opencodeDir))) return;
|
||||
|
||||
const skillsDir = path.join(opencodeDir, 'skill');
|
||||
const skillsDir = path.join(opencodeDir, 'skills');
|
||||
try {
|
||||
const installed = await installSkillsTo(skillsDir);
|
||||
if (installed.length > 0) {
|
||||
|
|
|
|||
54
gitnexus/src/core/embeddings/config.ts
Normal file
54
gitnexus/src/core/embeddings/config.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { defaultEmbeddingThreads } from '../platform/capabilities.js';
|
||||
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig } from './types.js';
|
||||
|
||||
const parsePositiveInt = (name: string, value: string | undefined, fallback: number): number => {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, got "${value}"`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const parseDevice = (value: string | undefined): EmbeddingConfig['device'] | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
if (
|
||||
value === 'auto' ||
|
||||
value === 'dml' ||
|
||||
value === 'cuda' ||
|
||||
value === 'cpu' ||
|
||||
value === 'wasm'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`embedding device must be one of auto, dml, cuda, cpu, wasm; got "${value}"`);
|
||||
};
|
||||
|
||||
export const resolveEmbeddingConfig = (
|
||||
overrides: Partial<EmbeddingConfig> = {},
|
||||
): EmbeddingConfig => {
|
||||
const env = process.env;
|
||||
return {
|
||||
...DEFAULT_EMBEDDING_CONFIG,
|
||||
...overrides,
|
||||
batchSize: parsePositiveInt(
|
||||
'GITNEXUS_EMBEDDING_BATCH_SIZE',
|
||||
env.GITNEXUS_EMBEDDING_BATCH_SIZE,
|
||||
overrides.batchSize ?? DEFAULT_EMBEDDING_CONFIG.batchSize,
|
||||
),
|
||||
subBatchSize: parsePositiveInt(
|
||||
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
|
||||
env.GITNEXUS_EMBEDDING_SUB_BATCH_SIZE,
|
||||
overrides.subBatchSize ?? DEFAULT_EMBEDDING_CONFIG.subBatchSize,
|
||||
),
|
||||
threads: parsePositiveInt(
|
||||
'GITNEXUS_EMBEDDING_THREADS',
|
||||
env.GITNEXUS_EMBEDDING_THREADS,
|
||||
overrides.threads ?? defaultEmbeddingThreads(),
|
||||
),
|
||||
device:
|
||||
parseDevice(env.GITNEXUS_EMBEDDING_DEVICE) ??
|
||||
overrides.device ??
|
||||
DEFAULT_EMBEDDING_CONFIG.device,
|
||||
};
|
||||
};
|
||||
|
|
@ -21,6 +21,8 @@ import { join, dirname } from 'path';
|
|||
import { createRequire } from 'module';
|
||||
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js';
|
||||
import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js';
|
||||
import { resolveEmbeddingConfig } from './config.js';
|
||||
import { applyHfEnvOverrides } from './hf-env.js';
|
||||
|
||||
/**
|
||||
* Check whether the onnxruntime-node package that @huggingface/transformers
|
||||
|
|
@ -143,13 +145,12 @@ export const initEmbedder = async (
|
|||
|
||||
isInitializing = true;
|
||||
|
||||
const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config };
|
||||
// On Windows, use DirectML for GPU acceleration (via DirectX12)
|
||||
// CUDA is only available on Linux x64 with onnxruntime-node
|
||||
const finalConfig = resolveEmbeddingConfig(config);
|
||||
// CUDA is probe-gated because ONNX Runtime can crash in native code when
|
||||
// provider libraries are missing. DirectML stays opt-in for the same reason.
|
||||
// Probe for CUDA first — ONNX Runtime crashes (uncatchable native error)
|
||||
// if we attempt CUDA without the required shared libraries
|
||||
const isWindows = process.platform === 'win32';
|
||||
const gpuDevice = isWindows ? 'dml' : isCudaAvailable() ? 'cuda' : 'cpu';
|
||||
const gpuDevice = isCudaAvailable() ? 'cuda' : 'cpu';
|
||||
const requestedDevice =
|
||||
forceDevice || (finalConfig.device === 'auto' ? gpuDevice : finalConfig.device);
|
||||
|
||||
|
|
@ -157,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 ?? `${process.env.HOME}/.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) {
|
||||
|
|
@ -204,7 +205,12 @@ export const initEmbedder = async (
|
|||
device: device,
|
||||
dtype: 'fp32',
|
||||
progress_callback: progressCallback,
|
||||
session_options: { logSeverityLevel: 3 },
|
||||
session_options: {
|
||||
logSeverityLevel: 3,
|
||||
intraOpNumThreads: finalConfig.threads,
|
||||
interOpNumThreads: 1,
|
||||
executionMode: 'sequential',
|
||||
},
|
||||
});
|
||||
currentDevice = device;
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import {
|
|||
type SemanticSearchResult,
|
||||
type ModelProgress,
|
||||
type EmbeddingContext,
|
||||
DEFAULT_EMBEDDING_CONFIG,
|
||||
EMBEDDABLE_LABELS,
|
||||
isShortLabel,
|
||||
LABEL_METHOD,
|
||||
|
|
@ -35,6 +34,8 @@ import {
|
|||
STRUCTURAL_LABELS,
|
||||
collectBestChunks,
|
||||
} from './types.js';
|
||||
import { resolveEmbeddingConfig } from './config.js';
|
||||
import { rankExactEmbeddingRows, type ExactEmbeddingRow } from './exact-search.js';
|
||||
import {
|
||||
EMBEDDING_TABLE_NAME,
|
||||
EMBEDDING_INDEX_NAME,
|
||||
|
|
@ -42,8 +43,20 @@ import {
|
|||
STALE_HASH_SENTINEL,
|
||||
} from '../lbug/schema.js';
|
||||
import { loadVectorExtension } from '../lbug/lbug-adapter.js';
|
||||
import { getExactScanLimit } from '../platform/capabilities.js';
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
const vectorUnavailableMessage =
|
||||
'VECTOR extension is unavailable for this LadybugDB runtime; semantic search will use exact scan when embeddings exist.';
|
||||
|
||||
const ensureVectorExtensionAvailable = async (): Promise<boolean> => {
|
||||
const vectorReady = await loadVectorExtension();
|
||||
if (!vectorReady) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
/**
|
||||
* Bump this when the embedding text template changes in a way that should
|
||||
* invalidate existing vectors, such as metadata/header shape changes,
|
||||
|
|
@ -192,19 +205,26 @@ export const batchInsertEmbeddings = async (
|
|||
*/
|
||||
const createVectorIndex = async (
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
): Promise<void> => {
|
||||
// Delegate to the adapter which tracks loaded state and handles DB reconnect resets
|
||||
await loadVectorExtension();
|
||||
|
||||
): Promise<boolean> => {
|
||||
if (!(await ensureVectorExtensionAvailable())) return false;
|
||||
try {
|
||||
await executeQuery(CREATE_VECTOR_INDEX_QUERY);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isDev) {
|
||||
console.warn('Vector index creation warning:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export interface EmbeddingPipelineResult {
|
||||
nodesProcessed: number;
|
||||
chunksProcessed: number;
|
||||
vectorIndexReady: boolean;
|
||||
semanticMode: 'vector-index' | 'exact-scan';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the embedding pipeline
|
||||
*
|
||||
|
|
@ -230,10 +250,14 @@ export const runEmbeddingPipeline = async (
|
|||
skipNodeIds?: Set<string>,
|
||||
context?: EmbeddingContext,
|
||||
existingEmbeddings?: Map<string, string>,
|
||||
): Promise<void> => {
|
||||
const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config };
|
||||
): Promise<EmbeddingPipelineResult> => {
|
||||
const finalConfig = resolveEmbeddingConfig(config);
|
||||
let totalChunks = 0;
|
||||
|
||||
try {
|
||||
const vectorAvailable = await ensureVectorExtensionAvailable();
|
||||
if (!vectorAvailable && isDev) console.warn(vectorUnavailableMessage);
|
||||
|
||||
// Phase 1: Load embedding model
|
||||
onProgress({
|
||||
phase: 'loading-model',
|
||||
|
|
@ -338,7 +362,7 @@ export const runEmbeddingPipeline = async (
|
|||
// Ensure the vector index exists even when no new nodes need embedding.
|
||||
// A prior crash or first-time incremental run may have left CodeEmbedding
|
||||
// rows without ever reaching index creation.
|
||||
await createVectorIndex(executeQuery);
|
||||
const vectorIndexReady = await createVectorIndex(executeQuery);
|
||||
|
||||
onProgress({
|
||||
phase: 'ready',
|
||||
|
|
@ -346,7 +370,12 @@ export const runEmbeddingPipeline = async (
|
|||
nodesProcessed: 0,
|
||||
totalNodes: 0,
|
||||
});
|
||||
return;
|
||||
return {
|
||||
nodesProcessed: 0,
|
||||
chunksProcessed: 0,
|
||||
vectorIndexReady,
|
||||
semanticMode: vectorIndexReady ? 'vector-index' : 'exact-scan',
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 3: Chunk + embed nodes
|
||||
|
|
@ -354,7 +383,6 @@ export const runEmbeddingPipeline = async (
|
|||
const chunkSize = finalConfig.chunkSize;
|
||||
const overlap = finalConfig.overlap;
|
||||
let processedNodes = 0;
|
||||
let totalChunks = 0;
|
||||
|
||||
onProgress({
|
||||
phase: 'embedding',
|
||||
|
|
@ -445,7 +473,7 @@ export const runEmbeddingPipeline = async (
|
|||
}
|
||||
|
||||
// Embed chunk texts in sub-batches to control memory
|
||||
const EMBED_SUB_BATCH = 8;
|
||||
const EMBED_SUB_BATCH = finalConfig.subBatchSize;
|
||||
for (let si = 0; si < allTexts.length; si += EMBED_SUB_BATCH) {
|
||||
const subTexts = allTexts.slice(si, si + EMBED_SUB_BATCH);
|
||||
const subUpdates = allUpdates.slice(si, si + EMBED_SUB_BATCH);
|
||||
|
|
@ -495,7 +523,7 @@ export const runEmbeddingPipeline = async (
|
|||
console.log('📇 Creating vector index...');
|
||||
}
|
||||
|
||||
await createVectorIndex(executeQuery);
|
||||
const vectorIndexReady = await createVectorIndex(executeQuery);
|
||||
|
||||
onProgress({
|
||||
phase: 'ready',
|
||||
|
|
@ -509,6 +537,12 @@ export const runEmbeddingPipeline = async (
|
|||
`✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
nodesProcessed: totalNodes,
|
||||
chunksProcessed: totalChunks,
|
||||
vectorIndexReady,
|
||||
semanticMode: vectorIndexReady ? 'vector-index' : 'exact-scan',
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
|
|
@ -543,27 +577,71 @@ export const semanticSearch = async (
|
|||
const queryVec = embeddingToArray(queryEmbedding);
|
||||
const queryVecStr = `[${queryVec.join(',')}]`;
|
||||
|
||||
const bestChunks = await collectBestChunks(k, async (fetchLimit) => {
|
||||
const vectorQuery = `
|
||||
CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}',
|
||||
CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${fetchLimit})
|
||||
YIELD node AS emb, distance
|
||||
WITH emb, distance
|
||||
WHERE distance < ${maxDistance}
|
||||
RETURN emb.nodeId AS nodeId, emb.chunkIndex AS chunkIndex,
|
||||
emb.startLine AS startLine, emb.endLine AS endLine, distance
|
||||
ORDER BY distance
|
||||
`;
|
||||
let bestChunks = new Map<
|
||||
string,
|
||||
{ distance: number; chunkIndex: number; startLine: number; endLine: number }
|
||||
>();
|
||||
if (await loadVectorExtension()) {
|
||||
try {
|
||||
bestChunks = await collectBestChunks(k, async (fetchLimit) => {
|
||||
const vectorQuery = `
|
||||
CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}',
|
||||
CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${fetchLimit})
|
||||
YIELD node AS emb, distance
|
||||
WITH emb, distance
|
||||
WHERE distance < ${maxDistance}
|
||||
RETURN emb.nodeId AS nodeId, emb.chunkIndex AS chunkIndex,
|
||||
emb.startLine AS startLine, emb.endLine AS endLine, distance
|
||||
ORDER BY distance
|
||||
`;
|
||||
|
||||
const embResults = await executeQuery(vectorQuery);
|
||||
return embResults.map((row) => ({
|
||||
nodeId: row.nodeId ?? row[0],
|
||||
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
|
||||
startLine: row.startLine ?? row[2] ?? 0,
|
||||
endLine: row.endLine ?? row[3] ?? 0,
|
||||
distance: row.distance ?? row[4],
|
||||
}));
|
||||
});
|
||||
const embResults = await executeQuery(vectorQuery);
|
||||
return embResults.map((row) => ({
|
||||
nodeId: row.nodeId ?? row[0],
|
||||
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
|
||||
startLine: row.startLine ?? row[2] ?? 0,
|
||||
endLine: row.endLine ?? row[3] ?? 0,
|
||||
distance: row.distance ?? row[4],
|
||||
}));
|
||||
});
|
||||
} catch {
|
||||
bestChunks = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
if (bestChunks.size === 0) {
|
||||
const countRows = await executeQuery(
|
||||
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN count(e) AS cnt`,
|
||||
);
|
||||
const countRow = countRows[0];
|
||||
const embeddingCount = Number(countRow?.cnt ?? countRow?.[0] ?? 0);
|
||||
const exactLimit = getExactScanLimit();
|
||||
if (embeddingCount > 0 && embeddingCount <= exactLimit) {
|
||||
const rows = await executeQuery(`
|
||||
MATCH (e:${EMBEDDING_TABLE_NAME})
|
||||
RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex,
|
||||
e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding
|
||||
`);
|
||||
const exactRows: ExactEmbeddingRow[] = rows.map((row) => ({
|
||||
nodeId: row.nodeId ?? row[0],
|
||||
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
|
||||
startLine: row.startLine ?? row[2] ?? 0,
|
||||
endLine: row.endLine ?? row[3] ?? 0,
|
||||
embedding: row.embedding ?? row[4] ?? [],
|
||||
}));
|
||||
bestChunks = new Map(
|
||||
rankExactEmbeddingRows(exactRows, queryVec, k, maxDistance).map((row) => [
|
||||
row.nodeId,
|
||||
{
|
||||
distance: row.distance,
|
||||
chunkIndex: row.chunkIndex,
|
||||
startLine: row.startLine,
|
||||
endLine: row.endLine,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (bestChunks.size === 0) {
|
||||
return [];
|
||||
|
|
|
|||
49
gitnexus/src/core/embeddings/exact-search.ts
Normal file
49
gitnexus/src/core/embeddings/exact-search.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export interface ExactEmbeddingRow {
|
||||
nodeId: string;
|
||||
chunkIndex: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
embedding: readonly number[];
|
||||
}
|
||||
|
||||
export interface ExactSearchChunk {
|
||||
nodeId: string;
|
||||
chunkIndex: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
const cosineDistance = (a: readonly number[], b: readonly number[]): number => {
|
||||
let dot = 0;
|
||||
let aNorm = 0;
|
||||
let bNorm = 0;
|
||||
const len = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const av = a[i] ?? 0;
|
||||
const bv = b[i] ?? 0;
|
||||
dot += av * bv;
|
||||
aNorm += av * av;
|
||||
bNorm += bv * bv;
|
||||
}
|
||||
if (aNorm === 0 || bNorm === 0) return 1;
|
||||
return 1 - dot / (Math.sqrt(aNorm) * Math.sqrt(bNorm));
|
||||
};
|
||||
|
||||
export const rankExactEmbeddingRows = (
|
||||
rows: readonly ExactEmbeddingRow[],
|
||||
queryEmbedding: readonly number[],
|
||||
limit: number,
|
||||
maxDistance: number,
|
||||
): ExactSearchChunk[] =>
|
||||
rows
|
||||
.map((row) => ({
|
||||
nodeId: row.nodeId,
|
||||
chunkIndex: row.chunkIndex,
|
||||
startLine: row.startLine,
|
||||
endLine: row.endLine,
|
||||
distance: cosineDistance(row.embedding, queryEmbedding),
|
||||
}))
|
||||
.filter((row) => row.distance < maxDistance)
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.slice(0, limit);
|
||||
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 + '/';
|
||||
}
|
||||
}
|
||||
|
|
@ -207,6 +207,10 @@ export interface EmbeddingConfig {
|
|||
modelId: string;
|
||||
/** Number of nodes to embed in each batch */
|
||||
batchSize: number;
|
||||
/** Number of chunks passed to one local/HTTP embedding call */
|
||||
subBatchSize: number;
|
||||
/** Maximum ONNX Runtime CPU threads for local inference */
|
||||
threads: number;
|
||||
/** Embedding vector dimensions */
|
||||
dimensions: number;
|
||||
/** Device to use for inference: 'auto' tries GPU first (DirectML on Windows, CUDA on Linux), falls back to CPU */
|
||||
|
|
@ -229,6 +233,8 @@ export interface EmbeddingConfig {
|
|||
export const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = {
|
||||
modelId: 'Snowflake/snowflake-arctic-embed-xs',
|
||||
batchSize: 16,
|
||||
subBatchSize: 8,
|
||||
threads: 2,
|
||||
dimensions: 384,
|
||||
device: 'auto',
|
||||
maxSnippetLength: 500,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'path';
|
||||
import { readRegistry, type RegistryEntry, type CwdMatch } from '../storage/repo-manager.js';
|
||||
import { getGitRoot, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
|
||||
import { findGitRootByDotGit, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
|
||||
|
||||
export interface StalenessInfo {
|
||||
isStale: boolean;
|
||||
|
|
@ -101,9 +101,10 @@ export async function checkCwdMatch(cwd: string): Promise<CwdMatch> {
|
|||
}
|
||||
if (bestPath) return { match: 'path', entry: bestPath };
|
||||
|
||||
// 2) Sibling-by-remote: locate the cwd's git root, get its remote
|
||||
// URL, and look for any registered entry with the same fingerprint.
|
||||
const cwdGitRoot = getGitRoot(cwdResolved);
|
||||
// 2) Sibling-by-remote: locate the cwd's git root using only ancestor
|
||||
// `.git` checks before shelling out. This keeps MCP startup from
|
||||
// running git in an unrelated launch cwd such as $HOME (#1138).
|
||||
const cwdGitRoot = findGitRootByDotGit(cwdResolved);
|
||||
if (!cwdGitRoot) return { match: 'none' };
|
||||
|
||||
const cwdRemote = getRemoteUrl(cwdGitRoot);
|
||||
|
|
|
|||
|
|
@ -5,8 +5,42 @@ import lbug from '@ladybugdb/core';
|
|||
import type { LbugValue } from '@ladybugdb/core';
|
||||
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
|
||||
import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
|
||||
import {
|
||||
closeLbugConnection,
|
||||
openLbugConnection,
|
||||
type LbugConnectionHandle,
|
||||
} from '../lbug/lbug-config.js';
|
||||
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
|
||||
|
||||
/**
|
||||
* Sidecar files that LadybugDB creates next to a `bridge.lbug` file.
|
||||
*
|
||||
* - `.wal` — write-ahead log; persists across opens but must be associated
|
||||
* with the same database instance (LadybugDB 0.16.0 enforces this via a
|
||||
* database-id check and rejects opens with the diagnostic
|
||||
* `"Database ID for temporary file 'X.wal' does not match the current
|
||||
* database. This file may have been left behind from a previous database
|
||||
* with the same name"`).
|
||||
* - `.shadow` — non-blocking concurrent checkpoint sidecar (added in
|
||||
* LadybugDB 0.15.4); same pairing constraint as `.wal`.
|
||||
*
|
||||
* `bridge-db` writes to a `bridge.lbug.tmp` file and then atomically renames
|
||||
* it into place. The rename only moves the main file; sidecars must be
|
||||
* cleaned up explicitly or the next writer trips the database-id check.
|
||||
*/
|
||||
const LBUG_SIDECAR_SUFFIXES = ['.wal', '.shadow'] as const;
|
||||
|
||||
async function removeLbugFile(basePath: string): Promise<void> {
|
||||
const candidates = [basePath, ...LBUG_SIDECAR_SUFFIXES.map((s) => `${basePath}${s}`)];
|
||||
for (const f of candidates) {
|
||||
try {
|
||||
await fsp.rm(f, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best-effort: caller will surface real errors via the open path */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function contractNodeId(
|
||||
repo: string,
|
||||
contractId: string,
|
||||
|
|
@ -127,8 +161,7 @@ export function findContractNode(
|
|||
export async function openBridgeDb(dbPath: string): Promise<BridgeHandle> {
|
||||
const parentDir = path.dirname(dbPath);
|
||||
await fsp.mkdir(parentDir, { recursive: true });
|
||||
const db = new lbug.Database(dbPath, 0, false, false); // writable
|
||||
const conn = new lbug.Connection(db);
|
||||
const { db, conn } = await openLbugConnection(lbug, dbPath);
|
||||
return { _db: db, _conn: conn, groupDir: parentDir } as BridgeHandle;
|
||||
}
|
||||
|
||||
|
|
@ -195,6 +228,17 @@ function unwrapQueryResult(queryResult: lbug.QueryResult | lbug.QueryResult[]):
|
|||
}
|
||||
|
||||
export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
|
||||
// CHECKPOINT before close so the WAL/.shadow contents are flushed into
|
||||
// the main database file. Without this, LadybugDB 0.16.0's non-blocking
|
||||
// checkpoint thread can outlive the close call and leave sidecar pages
|
||||
// pending on disk, which makes a subsequent read-side open either race
|
||||
// with the WAL replay or trip the database-id check on the sidecars.
|
||||
// CHECKPOINT is a no-op when there's nothing pending, so it's cheap.
|
||||
try {
|
||||
await (handle._conn as lbug.Connection).query('CHECKPOINT');
|
||||
} catch {
|
||||
/* ignore — older LadybugDB or schemaless DB may not accept it */
|
||||
}
|
||||
try {
|
||||
await (handle._conn as lbug.Connection).close();
|
||||
} catch {
|
||||
|
|
@ -322,12 +366,11 @@ export async function writeBridge(
|
|||
}
|
||||
};
|
||||
|
||||
// Clean up any leftover tmp
|
||||
try {
|
||||
await fsp.rm(tmpPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Clean up any leftover tmp main file AND its `.wal` / `.shadow` sidecars.
|
||||
// LadybugDB 0.16.0 rejects opening a database whose sidecars belong to a
|
||||
// different database instance (database-id check), so any stale sidecar
|
||||
// from a crashed previous run will fail the next writeBridge.
|
||||
await removeLbugFile(tmpPath);
|
||||
|
||||
// 1. Create temp DB, insert all data.
|
||||
//
|
||||
|
|
@ -497,18 +540,43 @@ export async function writeBridge(
|
|||
}
|
||||
|
||||
// 3. Atomic swap: old→.bak, tmp→final, rm .bak
|
||||
//
|
||||
// The current database file (with its `.wal` / `.shadow` sidecars) is
|
||||
// moved aside, then the freshly built tmp database takes its place.
|
||||
// We move the sidecars together with the main file so the open below
|
||||
// and any external readers see a consistent set; orphan sidecars from
|
||||
// the tmp namespace are then removed because LadybugDB looks for them
|
||||
// under the renamed-to base name and would reject mismatching IDs.
|
||||
try {
|
||||
await fsp.access(finalPath);
|
||||
await retryRename(finalPath, bakPath);
|
||||
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
|
||||
try {
|
||||
await fsp.access(`${finalPath}${suffix}`);
|
||||
await retryRename(`${finalPath}${suffix}`, `${bakPath}${suffix}`);
|
||||
} catch {
|
||||
/* sidecar absent — nothing to move */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* no existing db */
|
||||
}
|
||||
await retryRename(tmpPath, finalPath);
|
||||
try {
|
||||
await fsp.rm(bakPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
|
||||
// Rename — not delete — so the WAL (which may carry uncommitted-at-
|
||||
// close-time pages on a graceful close, depending on
|
||||
// `autoCheckpoint` / `checkpointThreshold`) and the `.shadow`
|
||||
// checkpoint snapshot stay paired with the database file under its
|
||||
// final name. LadybugDB 0.16.0's database-id check rejects an open
|
||||
// when the sidecars belong to a different base name.
|
||||
try {
|
||||
await fsp.access(`${tmpPath}${suffix}`);
|
||||
await retryRename(`${tmpPath}${suffix}`, `${finalPath}${suffix}`);
|
||||
} catch {
|
||||
/* sidecar absent — nothing to move */
|
||||
}
|
||||
}
|
||||
await removeLbugFile(bakPath);
|
||||
|
||||
// 4. Write meta.json
|
||||
await writeBridgeMeta(groupDir, {
|
||||
|
|
@ -524,10 +592,38 @@ export async function writeBridge(
|
|||
/* openBridgeDbReadOnly */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHandle | null> {
|
||||
/**
|
||||
* Substrings observed in the message of an `Error` raised by the LadybugDB
|
||||
* native open path when Windows still holds an exclusive lock on the file
|
||||
* after a writer's `Database.close()` returned. LadybugDB 0.16.0's
|
||||
* non-blocking checkpoint thread can briefly outlive the close call, so a
|
||||
* read-side opener that races in immediately afterwards sees Win32 error
|
||||
* 33 ("The process cannot access the file because another process has
|
||||
* locked a portion of the file"). Retrying with a small back-off lets the
|
||||
* background thread settle and the OS release the handle.
|
||||
*/
|
||||
const LBUG_OPEN_RETRY_PATTERNS = [
|
||||
'process cannot access the file',
|
||||
'another process has locked',
|
||||
'could not set lock',
|
||||
'lock held by another process',
|
||||
];
|
||||
|
||||
const LBUG_OPEN_RETRY_ATTEMPTS = 10;
|
||||
const LBUG_OPEN_RETRY_BASE_MS = 100;
|
||||
/** Cap individual back-off delays so the total wait is bounded (~3s). */
|
||||
const LBUG_OPEN_RETRY_MAX_MS = 500;
|
||||
|
||||
function isTransientLockError(err: unknown): boolean {
|
||||
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||
return LBUG_OPEN_RETRY_PATTERNS.some((p) => msg.includes(p));
|
||||
}
|
||||
|
||||
async function ensureBridgeDbFileAvailable(groupDir: string): Promise<boolean> {
|
||||
const dbPath = path.join(groupDir, 'bridge.lbug');
|
||||
try {
|
||||
await fsp.access(dbPath);
|
||||
return true;
|
||||
} catch {
|
||||
// Check for .bak recovery. Use `retryRename` (not `fsp.rename`) for the
|
||||
// exact same reason the rest of this file does: the scenario that
|
||||
|
|
@ -538,42 +634,62 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
|
|||
try {
|
||||
await fsp.access(bakPath);
|
||||
await retryRename(bakPath, dbPath);
|
||||
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
|
||||
try {
|
||||
await fsp.access(`${bakPath}${suffix}`);
|
||||
await retryRename(`${bakPath}${suffix}`, `${dbPath}${suffix}`);
|
||||
} catch {
|
||||
/* sidecar absent */
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHandle | null> {
|
||||
const dbPath = path.join(groupDir, 'bridge.lbug');
|
||||
if (!(await ensureBridgeDbFileAvailable(groupDir))) return null;
|
||||
|
||||
// Version gate: check meta.json version compatibility
|
||||
const meta = await readBridgeMeta(groupDir);
|
||||
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
|
||||
return null; // incompatible schema version — fallback to JSON or re-sync
|
||||
}
|
||||
|
||||
// Open the native handle. If Connection construction throws AFTER
|
||||
// Database was successfully allocated, we'd leak the native Database
|
||||
// object. Wrap each step separately and tear down the partial handle.
|
||||
let db: lbug.Database | undefined;
|
||||
let conn: lbug.Connection | undefined;
|
||||
try {
|
||||
db = new lbug.Database(dbPath, 0, false, true); // readOnly
|
||||
conn = new lbug.Connection(db);
|
||||
return { _db: db, _conn: conn, groupDir } as BridgeHandle;
|
||||
} catch {
|
||||
if (conn) {
|
||||
try {
|
||||
await conn.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Open the native handle with a bounded retry on transient OS-level file
|
||||
// locks (see LBUG_OPEN_RETRY_PATTERNS). If Connection construction throws
|
||||
// AFTER Database was successfully allocated, we'd leak the native Database
|
||||
// object — wrap each step separately and tear down the partial handle.
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= LBUG_OPEN_RETRY_ATTEMPTS; attempt++) {
|
||||
let handle: LbugConnectionHandle | undefined;
|
||||
try {
|
||||
handle = await openLbugConnection(lbug, dbPath, { readOnly: true });
|
||||
// Force the lazy native init now so a transient lock surfaces here
|
||||
// (where we can retry) instead of on the first user query.
|
||||
await handle.db.init();
|
||||
await handle.conn.init();
|
||||
return { _db: handle.db, _conn: handle.conn, groupDir } as BridgeHandle;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (handle) await closeLbugConnection(handle);
|
||||
if (!isTransientLockError(err) || attempt === LBUG_OPEN_RETRY_ATTEMPTS) break;
|
||||
const delay = Math.min(LBUG_OPEN_RETRY_BASE_MS * attempt, LBUG_OPEN_RETRY_MAX_MS);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
if (db) {
|
||||
try {
|
||||
await db.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (process.env.GITNEXUS_DEBUG_BRIDGE) {
|
||||
console.warn(
|
||||
`[bridge-db] openBridgeDbReadOnly(${groupDir}) gave up after ` +
|
||||
`${LBUG_OPEN_RETRY_ATTEMPTS} attempts: ${
|
||||
lastErr instanceof Error ? lastErr.message : String(lastErr)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
@ -581,8 +697,7 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
|
|||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function bridgeExists(groupDir: string): Promise<boolean> {
|
||||
const handle = await openBridgeDbReadOnly(groupDir);
|
||||
if (!handle) return false;
|
||||
await closeBridgeDb(handle);
|
||||
return true;
|
||||
if (!(await ensureBridgeDbFileAvailable(groupDir))) return false;
|
||||
const meta = await readBridgeMeta(groupDir);
|
||||
return meta.version === 0 || meta.version === BRIDGE_SCHEMA_VERSION;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,12 +13,15 @@ const DEFAULT_DETECT = {
|
|||
topics: true,
|
||||
shared_libs: true,
|
||||
embedding_fallback: true,
|
||||
workspace_deps: true,
|
||||
};
|
||||
|
||||
const DEFAULT_MATCHING = {
|
||||
bm25_threshold: 0.7,
|
||||
embedding_threshold: 0.65,
|
||||
max_candidates_per_step: 3,
|
||||
exclude_links_paths: [] as string[],
|
||||
exclude_links_param_only_paths: false,
|
||||
};
|
||||
|
||||
export function parseGroupConfig(yamlContent: string): GroupConfig {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as path from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
import Parser from 'tree-sitter';
|
||||
import { createIgnoreFilter } from '../../../config/ignore-service.js';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
|
|
@ -227,11 +228,16 @@ async function buildProtoContext(repoPath: string): Promise<{
|
|||
servicesByName: Map<string, ProtoServiceInfo[]>;
|
||||
}> {
|
||||
const servicesByName = new Map<string, ProtoServiceInfo[]>();
|
||||
// `.gitnexusignore` / `.gitignore` honoured via the shared IgnoreService —
|
||||
// see `filesystem-walker.ts` for the canonical pattern. Replaces a
|
||||
// hardcoded `[node_modules, .git, vendor]` array; those names plus the
|
||||
// rest of `DEFAULT_IGNORE_LIST` are still excluded by default (#1185).
|
||||
const protoIgnoreFilter = await createIgnoreFilter(repoPath);
|
||||
const protoFiles = await glob('**/*.proto', {
|
||||
cwd: repoPath,
|
||||
absolute: false,
|
||||
nodir: true,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'],
|
||||
ignore: protoIgnoreFilter,
|
||||
});
|
||||
const contents = new Map<string, string>();
|
||||
|
||||
|
|
@ -401,9 +407,14 @@ export class GrpcExtractor implements ContractExtractor {
|
|||
}
|
||||
|
||||
// ─── Source files (+ .proto when plugin available) ────────────
|
||||
// Honour `.gitnexusignore` / `.gitignore` via the shared IgnoreService —
|
||||
// mirrors `filesystem-walker.ts`. Replaces a hardcoded
|
||||
// `[node_modules, .git, vendor, dist, build]` array; those names are all
|
||||
// in `DEFAULT_IGNORE_LIST`, so default behaviour is preserved (#1185).
|
||||
const sourceIgnoreFilter = await createIgnoreFilter(repoPath);
|
||||
const sourceFiles = await glob(GRPC_SCAN_GLOB, {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
|
||||
ignore: sourceIgnoreFilter,
|
||||
nodir: true,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as path from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
import Parser from 'tree-sitter';
|
||||
import { createIgnoreFilter } from '../../../config/ignore-service.js';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
|
|
@ -208,9 +209,16 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
}
|
||||
|
||||
private async scanFiles(repoPath: string): Promise<string[]> {
|
||||
// Honour `.gitnexusignore` and `.gitignore` via the shared IgnoreService
|
||||
// so contract extraction respects the same exclusion rules as the rest of
|
||||
// the ingestion pipeline. Mirrors `filesystem-walker.ts` which uses the
|
||||
// same shape. Replaces a hardcoded `[node_modules, .git, dist, build,
|
||||
// vendor]` array — those names are still in `DEFAULT_IGNORE_LIST`, so
|
||||
// default behaviour is preserved (#1185).
|
||||
const ignoreFilter = await createIgnoreFilter(repoPath);
|
||||
return glob(HTTP_SCAN_GLOB, {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/vendor/**'],
|
||||
ignore: ignoreFilter,
|
||||
nodir: true,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,6 +268,19 @@ export class ManifestExtractor {
|
|||
LIMIT 1`,
|
||||
{ contract: link.contract },
|
||||
);
|
||||
} else if (link.type === 'custom') {
|
||||
// V1: exact name-only match on code-definition nodes.
|
||||
// Positive allowlist mirrors other contract types. If multiple code
|
||||
// symbols share the same name, ORDER BY filePath ASC LIMIT 1 picks
|
||||
// the alphabetically-first occurrence deterministically.
|
||||
rows = await executor(
|
||||
`MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement)
|
||||
WHERE n.name = $contract
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
ORDER BY n.filePath ASC
|
||||
LIMIT 1`,
|
||||
{ contract: link.contract },
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { StoredContract, CrossLink } from './types.js';
|
||||
import type { StoredContract, CrossLink, MatchingConfig } from './types.js';
|
||||
|
||||
export interface MatchResult {
|
||||
matched: CrossLink[];
|
||||
|
|
@ -14,6 +14,43 @@ function isGrpcWildcard(cid: string): boolean {
|
|||
return cid.startsWith('grpc::') && cid.endsWith('/*');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect HTTP contracts that are too generic or infrastructure-level to
|
||||
* produce meaningful cross-repo links. These are still extracted (useful
|
||||
* for documentation / route maps) but excluded from cross-link matching.
|
||||
*
|
||||
* Two categories:
|
||||
* 1. Health-check / readiness endpoints — every service has one, matching
|
||||
* them produces N×M false links.
|
||||
* 2. Param-only paths — routes like `/{param}` or `/{param}/{param}` that
|
||||
* collapse to a single catch-all after normalization. These match any
|
||||
* service with a similar shape, producing false positives.
|
||||
*
|
||||
* Both are configurable via matching.exclude_links_paths and
|
||||
* matching.exclude_links_param_only_paths in group.yaml.
|
||||
*/
|
||||
function buildNoisyContractFilter(
|
||||
matchingConfig?: MatchingConfig,
|
||||
): (contractId: string) => boolean {
|
||||
const excludePaths = matchingConfig?.exclude_links_paths?.length
|
||||
? new Set(matchingConfig.exclude_links_paths.map((p) => p.replace(/\/+$/, '')))
|
||||
: new Set<string>();
|
||||
const excludeParamOnly = matchingConfig?.exclude_links_param_only_paths === true;
|
||||
|
||||
return function isNoisyHttpContract(contractId: string): boolean {
|
||||
if (!contractId.startsWith('http::')) return false;
|
||||
const parts = contractId.split('::');
|
||||
if (parts.length < 3) return false;
|
||||
const pathPart = parts.slice(2).join('::').replace(/\/+$/, '');
|
||||
if (excludePaths.has(pathPart)) return true;
|
||||
if (excludeParamOnly) {
|
||||
const segments = pathPart.split('/').filter(Boolean);
|
||||
if (segments.length > 0 && segments.every((s) => s === '{param}')) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeContractId(id: string): string {
|
||||
const colonIdx = id.indexOf('::');
|
||||
if (colonIdx === -1) return id;
|
||||
|
|
@ -91,8 +128,12 @@ function findMatchingKeys(contractId: string, index: Map<string, StoredContract[
|
|||
return [];
|
||||
}
|
||||
|
||||
export function buildProviderIndex(contracts: StoredContract[]): Map<string, StoredContract[]> {
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
export function buildProviderIndex(
|
||||
contracts: StoredContract[],
|
||||
matchingConfig?: MatchingConfig,
|
||||
): Map<string, StoredContract[]> {
|
||||
const isNoisy = buildNoisyContractFilter(matchingConfig);
|
||||
const providers = contracts.filter((c) => c.role === 'provider' && !isNoisy(c.contractId));
|
||||
const index = new Map<string, StoredContract[]>();
|
||||
for (const p of providers) {
|
||||
const key = normalizeContractId(p.contractId);
|
||||
|
|
@ -106,11 +147,14 @@ export function buildProviderIndex(contracts: StoredContract[]): Map<string, Sto
|
|||
export function runExactMatch(
|
||||
contracts: StoredContract[],
|
||||
providerIndex?: Map<string, StoredContract[]>,
|
||||
matchingConfig?: MatchingConfig,
|
||||
): MatchResult {
|
||||
const index = providerIndex ?? buildProviderIndex(contracts);
|
||||
const isNoisy = buildNoisyContractFilter(matchingConfig);
|
||||
const index = providerIndex ?? buildProviderIndex(contracts, matchingConfig);
|
||||
|
||||
// Skip gRPC wildcard consumers — they go to wildcard pass only
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId));
|
||||
const consumers = contracts.filter(
|
||||
(c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId) && !isNoisy(c.contractId),
|
||||
);
|
||||
|
||||
const matched: CrossLink[] = [];
|
||||
const matchedConsumerIds = new Set<string>();
|
||||
|
|
@ -155,6 +199,7 @@ export function runExactMatch(
|
|||
// normalUnmatched: contracts that weren't matched in exact pass
|
||||
const normalUnmatched = contracts.filter((c) => {
|
||||
if (isGrpcWildcard(c.contractId)) return false; // excluded from exact, handled separately
|
||||
if (isNoisy(c.contractId)) return false; // excluded from matching — don't surface as unmatched
|
||||
const id = `${c.repo}::${c.contractId}`;
|
||||
return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -103,6 +103,8 @@ matching:
|
|||
bm25_threshold: 0.7
|
||||
embedding_threshold: 0.65
|
||||
max_candidates_per_step: 3
|
||||
# exclude_links_paths: [/ping, /health, /healthcheck]
|
||||
# exclude_links_param_only_paths: false
|
||||
`;
|
||||
await fsp.writeFile(path.join(groupDir, 'group.yaml'), template, 'utf-8');
|
||||
return groupDir;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
|
|||
import { GrpcExtractor } from './extractors/grpc-extractor.js';
|
||||
import { TopicExtractor } from './extractors/topic-extractor.js';
|
||||
import { ManifestExtractor } from './extractors/manifest-extractor.js';
|
||||
import { extractRustWorkspaceLinks } from './extractors/rust-workspace-extractor.js';
|
||||
import { runExactMatch } from './matching.js';
|
||||
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
|
||||
import type { CypherExecutor } from './contract-extractor.js';
|
||||
|
|
@ -84,12 +85,14 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
let autoContracts: StoredContract[] = [];
|
||||
let manifestCrossLinks: CrossLink[] = [];
|
||||
let dbExecutors: Map<string, CypherExecutor> | undefined;
|
||||
let registryEntries: RegistryEntry[] | undefined;
|
||||
|
||||
const eo = opts?.extractorOverride;
|
||||
if (eo && eo.length === 0) {
|
||||
autoContracts = await (eo as () => Promise<StoredContract[]>)();
|
||||
} else {
|
||||
const entries = await readRegistry();
|
||||
registryEntries = await readRegistry();
|
||||
const entries = registryEntries;
|
||||
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
|
||||
const httpEx = new HttpRouteExtractor();
|
||||
const grpcEx = new GrpcExtractor();
|
||||
|
|
@ -177,18 +180,39 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
}
|
||||
}
|
||||
|
||||
// Process manifest links declared in group.yaml.
|
||||
// Auto-discover workspace dependency contracts (Rust Cargo workspaces, etc.)
|
||||
// and merge them with explicit manifest links. Discovered links use the same
|
||||
// ManifestExtractor pipeline as hand-written links in group.yaml.
|
||||
let allLinks = [...config.links];
|
||||
|
||||
if (config.detect.workspace_deps) {
|
||||
const repoPaths = new Map<string, string>();
|
||||
if (!registryEntries) registryEntries = await readRegistry();
|
||||
for (const [groupPath, regName] of Object.entries(config.repos)) {
|
||||
const e = registryEntries.find((en) => en.name === regName);
|
||||
if (e) repoPaths.set(groupPath, e.path);
|
||||
}
|
||||
|
||||
const wsResult = await extractRustWorkspaceLinks(config.repos, repoPaths, dbExecutors);
|
||||
if (wsResult.links.length > 0) {
|
||||
allLinks = [...allLinks, ...wsResult.links];
|
||||
if (opts?.verbose) {
|
||||
console.log(
|
||||
` workspace-deps: discovered ${wsResult.links.length} cross-crate links from ${wsResult.discoveredCrates.size} Rust crates`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process manifest links declared in group.yaml (plus any auto-discovered).
|
||||
// ManifestExtractor is fully implemented but was never wired into this
|
||||
// pipeline — config.links were parsed and validated but silently dropped.
|
||||
// Placed after the DB try/finally: resolveSymbol falls back to synthetic
|
||||
// UIDs when dbExecutors is undefined or a pool is closed, so cross-links
|
||||
// are always generated regardless of whether real DB executors are available.
|
||||
if (config.links.length > 0) {
|
||||
// Warn about dangling links that reference repos not declared in config.repos.
|
||||
// They still generate cross-links via synthetic UIDs (determinism is preserved),
|
||||
// but the operator probably meant something that now silently does nothing useful.
|
||||
if (allLinks.length > 0) {
|
||||
const knownRepos = new Set(Object.keys(config.repos));
|
||||
for (const link of config.links) {
|
||||
for (const link of allLinks) {
|
||||
const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r));
|
||||
if (dangling.length > 0) {
|
||||
console.warn(
|
||||
|
|
@ -198,17 +222,17 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
}
|
||||
|
||||
const manifestEx = new ManifestExtractor();
|
||||
const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors);
|
||||
const manifestResult = await manifestEx.extractFromManifest(allLinks, dbExecutors);
|
||||
autoContracts.push(...manifestResult.contracts);
|
||||
manifestCrossLinks = manifestResult.crossLinks;
|
||||
if (opts?.verbose) {
|
||||
console.log(
|
||||
` manifest: ${manifestCrossLinks.length} cross-links from ${config.links.length} declared links`,
|
||||
` manifest: ${manifestCrossLinks.length} cross-links from ${allLinks.length} links (${config.links.length} declared + ${allLinks.length - config.links.length} discovered)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { matched, unmatched } = runExactMatch(autoContracts);
|
||||
const { matched, unmatched } = runExactMatch(autoContracts, undefined, config.matching);
|
||||
|
||||
// Dedupe cross-links. Manifest contracts participate in runExactMatch, so a
|
||||
// manifest-declared link can also emit a matchType:'exact' CrossLink with the
|
||||
|
|
|
|||
|
|
@ -27,12 +27,31 @@ export interface DetectConfig {
|
|||
topics: boolean;
|
||||
shared_libs: boolean;
|
||||
embedding_fallback: boolean;
|
||||
workspace_deps: boolean;
|
||||
}
|
||||
|
||||
export interface MatchingConfig {
|
||||
bm25_threshold: number;
|
||||
embedding_threshold: number;
|
||||
max_candidates_per_step: number;
|
||||
/**
|
||||
* HTTP paths to exclude from cross-link matching. Contracts at these paths
|
||||
* are still extracted and visible in the registry, but they don't produce
|
||||
* cross-repo links. Useful for health-check endpoints (`/ping`, `/health`)
|
||||
* that every service exposes and would otherwise create N×M false links.
|
||||
* Trailing slashes are normalized before comparison.
|
||||
* @default []
|
||||
*/
|
||||
exclude_links_paths?: string[];
|
||||
/**
|
||||
* When `true`, exclude HTTP routes where every path segment is `{param}`
|
||||
* (e.g. `/{param}`, `/{param}/{param}`) from cross-link matching. Mixed
|
||||
* routes like `/users/{param}` are not affected. These param-only routes
|
||||
* collapse to a single catch-all after normalization and produce false
|
||||
* positives across unrelated services.
|
||||
* @default false
|
||||
*/
|
||||
exclude_links_param_only_paths?: boolean;
|
||||
}
|
||||
|
||||
export interface SymbolRef {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ type ReceiverSource = ReceiverEnriched['receiverSource'];
|
|||
* DAG stage 4 fallback: used when `selectDispatch` is absent or returns null.
|
||||
* Preserves pre-DAG dispatch semantics:
|
||||
* - 'constructor' → constructor branch
|
||||
* - 'free' → free branch (admits Swift/Kotlin class-target fast path)
|
||||
* - 'free' → free branch (admits class-target fast path)
|
||||
* - 'member' or undefined → owner-scoped branch
|
||||
*
|
||||
* `undefined` callForm MUST route through owner-scoped (not free) so bare
|
||||
|
|
@ -770,7 +770,7 @@ export const processCalls = async (
|
|||
if (!tree) {
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content.length),
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
});
|
||||
} catch (parseError) {
|
||||
continue;
|
||||
|
|
@ -1595,41 +1595,30 @@ const disambiguateByOverloadOrArgTypes = (
|
|||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapse Swift-extension duplicate Class/Struct candidates to the primary
|
||||
* definition, preferring the shortest file path.
|
||||
*
|
||||
* Swift extensions (`extension User { ... }` in a separate file) create
|
||||
* multiple `Class` nodes sharing the same symbol name — one for the primary
|
||||
* declaration and one per extension file. When overload disambiguation and
|
||||
* receiver narrowing both fail to converge on a single candidate, this
|
||||
* heuristic picks the primary definition based on the assumption that it
|
||||
* lives at the shortest file path (e.g. `User.swift` over `UserExtensions.swift`).
|
||||
*
|
||||
* Intentionally narrower than {@link INSTANTIABLE_CLASS_TYPES}: only `Class`
|
||||
* and `Struct` are considered, not `Record`. Swift extensions only produce
|
||||
* `Class` duplicates in practice, and C#/Kotlin records do not exhibit the
|
||||
* same multi-file-definition pattern, so widening this set risks accidental
|
||||
* dedup of legitimately distinct record types.
|
||||
*
|
||||
* Returns a `ResolveResult` when the heuristic fires, `null` when the
|
||||
* candidate pool does not match the shape (mixed types, non-Class/Struct
|
||||
* kinds, or `length <= 1`). Callers should fall through to their own null
|
||||
* return when this helper returns `null`.
|
||||
*
|
||||
* Used by `resolveFreeCall`. Having a single source of truth prevents
|
||||
* duplication if the heuristic is ever tuned.
|
||||
*/
|
||||
const dedupSwiftExtensionCandidates = (
|
||||
const orderProviderSameNameTypeCandidates = (
|
||||
candidates: readonly SymbolDefinition[],
|
||||
typeName: string,
|
||||
filePath: string,
|
||||
): readonly SymbolDefinition[] | null => {
|
||||
const language = getLanguageFromFilename(filePath);
|
||||
if (language == null) return null;
|
||||
return (
|
||||
getProvider(language).orderSameNameTypeCandidates?.({
|
||||
typeName,
|
||||
callSiteFilePath: filePath,
|
||||
candidates,
|
||||
}) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const resolveProviderPrimaryTypeCandidate = (
|
||||
candidates: readonly SymbolDefinition[],
|
||||
tier: ResolutionTier,
|
||||
typeName: string,
|
||||
filePath: string,
|
||||
): ResolveResult | null => {
|
||||
if (candidates.length <= 1) return null;
|
||||
const allSameType = candidates.every((c) => c.type === candidates[0].type);
|
||||
if (!allSameType) return null;
|
||||
if (candidates[0].type !== 'Class' && candidates[0].type !== 'Struct') return null;
|
||||
const sorted = [...candidates].sort((a, b) => a.filePath.length - b.filePath.length);
|
||||
return toResolveResult(sorted[0], tier);
|
||||
const ordered = orderProviderSameNameTypeCandidates(candidates, typeName, filePath);
|
||||
return ordered && ordered.length > 0 ? toResolveResult(ordered[0], tier) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -2223,6 +2212,35 @@ const resolveMethodByOwner = (
|
|||
}
|
||||
}
|
||||
|
||||
if (!firstDef && !ambiguous) {
|
||||
const orderedTypeCandidates = orderProviderSameNameTypeCandidates(
|
||||
ctx.model.types.lookupClassByName(receiverTypeName),
|
||||
receiverTypeName,
|
||||
filePath,
|
||||
);
|
||||
if (orderedTypeCandidates) {
|
||||
for (const candidate of orderedTypeCandidates) {
|
||||
const def = canWalkMRO
|
||||
? lookupMethodByOwnerWithMRO(
|
||||
candidate.nodeId,
|
||||
methodName,
|
||||
heritageMap,
|
||||
ctx.model,
|
||||
mroStrategy,
|
||||
argCount,
|
||||
)
|
||||
: ctx.model.methods.lookupMethodByOwner(candidate.nodeId, methodName, argCount);
|
||||
if (!def) continue;
|
||||
if (!firstDef) {
|
||||
firstDef = def;
|
||||
} else if (def.nodeId !== firstDef.nodeId) {
|
||||
ambiguous = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstDef || ambiguous) return undefined;
|
||||
return { def: firstDef, tier: typeResolved.tier };
|
||||
};
|
||||
|
|
@ -2290,9 +2308,9 @@ export const resolveMemberCall = (
|
|||
* resolution via `ctx.resolve()`.
|
||||
*
|
||||
* Used for `foo()`, `doStuff()` — unqualified calls with no receiver.
|
||||
* Also handles Swift/Kotlin implicit constructors (`User()` without `new`)
|
||||
* by delegating to {@link resolveStaticCall} when the tiered pool contains
|
||||
* class-like targets.
|
||||
* Also handles implicit constructors (`User()` without `new`) by delegating
|
||||
* to {@link resolveStaticCall} when the tiered pool contains class-like
|
||||
* targets.
|
||||
*
|
||||
* {@link resolveCallTarget} delegates here for `callForm === 'free'`.
|
||||
*
|
||||
|
|
@ -2324,33 +2342,30 @@ export const resolveFreeCall = (
|
|||
|
||||
let filteredCandidates = filterCallableCandidates(tiered.candidates, argCount, 'free');
|
||||
|
||||
// Class-target fast path: Swift/Kotlin `User()` — free-form call targeting a
|
||||
// class. Delegates to resolveStaticCall for O(1) class + constructor lookup.
|
||||
// Class-target fast path: free-form call targeting a class. Delegates to
|
||||
// resolveStaticCall for O(1) class + constructor lookup.
|
||||
// The `.some()` trigger must stay aligned with `INSTANTIABLE_CLASS_TYPES` —
|
||||
// any type admitted here that is not in that set will cause resolveStaticCall
|
||||
// to return null, wasting two lookup passes per call. `Enum` is deliberately
|
||||
// excluded; `Record` is included so C# records and Kotlin data classes reach
|
||||
// the fast path.
|
||||
// excluded; `Record` is included so record-like class targets reach the fast
|
||||
// path.
|
||||
// Align with INSTANTIABLE_CLASS_TYPES by reusing the set directly rather
|
||||
// than enumerating literal strings. This converts an invariant that was
|
||||
// previously enforced by a comment ("keep this list aligned with
|
||||
// INSTANTIABLE_CLASS_TYPES") into one enforced structurally — any future
|
||||
// extension of the set (e.g. Kotlin `object`) propagates here automatically.
|
||||
// The `dedupSwiftExtensionCandidates` helper used in the tail of this
|
||||
// function deliberately uses a narrower literal `'Class' | 'Struct'` check
|
||||
// — Swift extensions only produce Class duplicates in practice, so Record
|
||||
// is excluded there by design. Do not collapse that helper into
|
||||
// INSTANTIABLE_CLASS_TYPES.
|
||||
// extension of the set propagates here automatically.
|
||||
// Language providers can still choose a primary same-name type candidate in
|
||||
// the tail of this function when their grammars index one logical type
|
||||
// multiple times.
|
||||
const hasClassTarget =
|
||||
filteredCandidates.length === 0 &&
|
||||
tiered.candidates.some((c) => INSTANTIABLE_CLASS_TYPES.has(c.type));
|
||||
if (hasClassTarget) {
|
||||
const staticResult = resolveStaticCall(calledName, filePath, ctx, argCount, tiered);
|
||||
if (staticResult) return staticResult;
|
||||
// Retry with constructor form: Swift/Kotlin constructor calls look like
|
||||
// free function calls (no `new` keyword). If resolveStaticCall didn't
|
||||
// match, re-filter with constructor form so CONSTRUCTOR_TARGET_TYPES
|
||||
// applies.
|
||||
// Retry with constructor form for languages whose constructor calls look
|
||||
// like free function calls. If resolveStaticCall didn't match, re-filter
|
||||
// with constructor form so CONSTRUCTOR_TARGET_TYPES applies.
|
||||
//
|
||||
// The retry fires for every null return from `resolveStaticCall`, which
|
||||
// can happen for three distinct reasons — all three are handled below:
|
||||
|
|
@ -2364,9 +2379,8 @@ export const resolveFreeCall = (
|
|||
// (b) Homonym ambiguity — two or more instantiable class candidates
|
||||
// share the name (e.g. `User` in two files, same tier). The
|
||||
// retry repopulates `filteredCandidates` with both Classes and
|
||||
// they flow into `dedupSwiftExtensionCandidates` below, which
|
||||
// either picks the shortest-path primary or null-routes.
|
||||
// Covered by the R7 Swift-extension dedup test.
|
||||
// they flow into the provider same-name candidate hook below, which
|
||||
// can pick a primary definition or null-route.
|
||||
//
|
||||
// (c) `resolveStaticCall` step 4 bailed because the tiered pool
|
||||
// contains ownerless `Constructor` nodes (some extractors emit
|
||||
|
|
@ -2391,10 +2405,13 @@ export const resolveFreeCall = (
|
|||
}
|
||||
|
||||
if (filteredCandidates.length !== 1) {
|
||||
// See `dedupSwiftExtensionCandidates` — shared helper, single source of
|
||||
// truth for the Swift-extension same-name collision heuristic.
|
||||
const deduped = dedupSwiftExtensionCandidates(filteredCandidates, tiered.tier);
|
||||
if (deduped) return deduped;
|
||||
const primary = resolveProviderPrimaryTypeCandidate(
|
||||
filteredCandidates,
|
||||
tiered.tier,
|
||||
calledName,
|
||||
filePath,
|
||||
);
|
||||
if (primary) return primary;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -2559,9 +2576,16 @@ export const resolveStaticCall = (
|
|||
// Interface / Trait / Impl). Null-route via the fall-through `return
|
||||
// null` — this is the dominant Codex-fix case.
|
||||
// length === 1 → a single instantiable candidate remains, return it.
|
||||
// length > 1 → two or more instantiable classes share the name (e.g.
|
||||
// homonym classes across files with no import narrowing). Fall through
|
||||
// to `return null` so the caller null-routes rather than guess.
|
||||
// length > 1 → let the call-site provider choose a primary when it can
|
||||
// prove the candidates are one logical type; otherwise null-route.
|
||||
const primary = resolveProviderPrimaryTypeCandidate(
|
||||
instantiableCandidates,
|
||||
typeResolved.tier,
|
||||
className,
|
||||
currentFile,
|
||||
);
|
||||
if (primary) return primary;
|
||||
|
||||
if (instantiableCandidates.length === 1) {
|
||||
return toResolveResult(instantiableCandidates[0], typeResolved.tier);
|
||||
}
|
||||
|
|
@ -3257,7 +3281,7 @@ export const extractFetchCallsFromFiles = async (
|
|||
if (!tree) {
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content.length),
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { Buffer } from 'node:buffer';
|
||||
|
||||
/**
|
||||
* Default minimum buffer size for tree-sitter parsing (512 KB).
|
||||
* tree-sitter requires bufferSize >= file size in bytes.
|
||||
|
|
@ -12,8 +14,13 @@ export const TREE_SITTER_MAX_BUFFER = 32 * 1024 * 1024;
|
|||
|
||||
/**
|
||||
* Compute adaptive buffer size for tree-sitter parsing.
|
||||
* Uses 2× file size, clamped between 512 KB and 32 MB.
|
||||
* Previous 256 KB fixed limit silently skipped files > ~200 KB (e.g., imgui.h at 411 KB).
|
||||
* Uses 2x UTF-8 byte size, clamped between 512 KB and 32 MB.
|
||||
* Keeps tree-sitter's byte-sized buffer above large ASCII and multibyte sources.
|
||||
*/
|
||||
export const getTreeSitterBufferSize = (contentLength: number): number =>
|
||||
Math.min(Math.max(contentLength * 2, TREE_SITTER_BUFFER_SIZE), TREE_SITTER_MAX_BUFFER);
|
||||
export const getTreeSitterContentByteLength = (sourceText: string): number =>
|
||||
Buffer.byteLength(sourceText, 'utf8');
|
||||
|
||||
export const getTreeSitterBufferSize = (sourceText: string): number => {
|
||||
const byteLength = getTreeSitterContentByteLength(sourceText);
|
||||
return Math.min(Math.max(byteLength * 2, TREE_SITTER_BUFFER_SIZE), TREE_SITTER_MAX_BUFFER);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,18 +12,15 @@
|
|||
|
||||
import { detectFrameworkFromPath } from './framework-detection.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { providers } from './languages/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// NAME PATTERNS - All 13 supported languages
|
||||
// NAME PATTERNS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Common entry point naming patterns by language.
|
||||
* These patterns indicate functions that are likely feature entry points.
|
||||
*
|
||||
* Universal patterns are separated from per-language patterns so the per-language
|
||||
* table can use `satisfies Record<SupportedLanguages, RegExp[]>` for compile-time
|
||||
* exhaustiveness — the compiler catches any missing language entry.
|
||||
* Universal entry point naming patterns shared across all languages.
|
||||
* Per-language patterns live on each LanguageProvider.entryPointPatterns.
|
||||
*/
|
||||
const UNIVERSAL_ENTRY_POINT_PATTERNS: RegExp[] = [
|
||||
/^(main|init|bootstrap|start|run|setup|configure)$/i,
|
||||
|
|
@ -40,201 +37,11 @@ const UNIVERSAL_ENTRY_POINT_PATTERNS: RegExp[] = [
|
|||
/^emit[A-Z]/, // emitEvent
|
||||
];
|
||||
|
||||
export const ENTRY_POINT_PATTERNS = {
|
||||
// JavaScript/TypeScript
|
||||
[SupportedLanguages.JavaScript]: [
|
||||
/^use[A-Z]/, // React hooks (useEffect, etc.)
|
||||
],
|
||||
[SupportedLanguages.TypeScript]: [
|
||||
/^use[A-Z]/, // React hooks
|
||||
],
|
||||
|
||||
// Python
|
||||
[SupportedLanguages.Python]: [
|
||||
/^app$/, // Flask/FastAPI app
|
||||
/^(get|post|put|delete|patch)_/i, // REST conventions
|
||||
/^api_/, // API functions
|
||||
/^view_/, // Django views
|
||||
],
|
||||
|
||||
// Java
|
||||
[SupportedLanguages.Java]: [
|
||||
/^do[A-Z]/, // doGet, doPost (Servlets)
|
||||
/^create[A-Z]/, // Factory patterns
|
||||
/^build[A-Z]/, // Builder patterns
|
||||
/Service$/, // UserService
|
||||
],
|
||||
|
||||
// Kotlin
|
||||
[SupportedLanguages.Kotlin]: [
|
||||
/^on(Create|Start|Resume|Pause|Stop|Destroy)$/, // Android lifecycle
|
||||
/^do[A-Z]/, // doGet, doPost (shared JVM Servlet pattern)
|
||||
/^create[A-Z]/, // Factory patterns
|
||||
/^build[A-Z]/, // Builder patterns
|
||||
/ViewModel$/, // MVVM pattern (Android)
|
||||
/^module$/, // Ktor module entry point
|
||||
/Service$/, // Service classes
|
||||
],
|
||||
|
||||
// C#
|
||||
[SupportedLanguages.CSharp]: [
|
||||
/^(Get|Post|Put|Delete|Patch)/, // ASP.NET action methods
|
||||
/Action$/, // MVC actions
|
||||
/^On[A-Z]/, // Event handlers / Blazor lifecycle
|
||||
/Async$/, // Async entry points
|
||||
/^Configure$/, // Startup.Configure
|
||||
/^ConfigureServices$/, // Startup.ConfigureServices
|
||||
/^Handle$/, // MediatR / generic handler
|
||||
/^Execute$/, // Command pattern
|
||||
/^Invoke$/, // Middleware Invoke
|
||||
/^Map[A-Z]/, // Minimal API MapGet, MapPost
|
||||
/Service$/, // Service classes
|
||||
/^Seed/, // Database seeding
|
||||
],
|
||||
|
||||
// Go
|
||||
[SupportedLanguages.Go]: [
|
||||
/Handler$/, // http.Handler pattern
|
||||
/^Serve/, // ServeHTTP
|
||||
/^New[A-Z]/, // Constructor pattern (returns new instance)
|
||||
/^Make[A-Z]/, // Make functions
|
||||
],
|
||||
|
||||
// Rust
|
||||
[SupportedLanguages.Rust]: [
|
||||
/^(get|post|put|delete)_handler$/i,
|
||||
/^handle_/, // handle_request
|
||||
/^new$/, // Constructor pattern
|
||||
/^run$/, // run entry point
|
||||
/^spawn/, // Async spawn
|
||||
],
|
||||
|
||||
// C - explicit main() boost plus common C entry point conventions
|
||||
[SupportedLanguages.C]: [
|
||||
/^main$/, // THE entry point
|
||||
/^init_/, // init_server, init_client
|
||||
/_init$/, // module_init, server_init
|
||||
/^start_/, // start_server
|
||||
/_start$/, // thread_start
|
||||
/^run_/, // run_loop
|
||||
/_run$/, // event_run
|
||||
/^stop_/, // stop_server
|
||||
/_stop$/, // service_stop
|
||||
/^open_/, // open_connection
|
||||
/_open$/, // file_open
|
||||
/^close_/, // close_connection
|
||||
/_close$/, // socket_close
|
||||
/^create_/, // create_session
|
||||
/_create$/, // object_create
|
||||
/^destroy_/, // destroy_session
|
||||
/_destroy$/, // object_destroy
|
||||
/^handle_/, // handle_request
|
||||
/_handler$/, // signal_handler
|
||||
/_callback$/, // event_callback
|
||||
/^cmd_/, // tmux: cmd_new_window, cmd_attach_session
|
||||
/^server_/, // server_start, server_loop
|
||||
/^client_/, // client_connect
|
||||
/^session_/, // session_create
|
||||
/^window_/, // window_resize (tmux)
|
||||
/^key_/, // key_press
|
||||
/^input_/, // input_parse
|
||||
/^output_/, // output_write
|
||||
/^notify_/, // notify_client
|
||||
/^control_/, // control_start
|
||||
],
|
||||
|
||||
// C++ - same as C plus OOP/template patterns
|
||||
[SupportedLanguages.CPlusPlus]: [
|
||||
/^main$/, // THE entry point
|
||||
/^init_/,
|
||||
/_init$/,
|
||||
/^Create[A-Z]/, // Factory patterns
|
||||
/^create_/,
|
||||
/^Run$/, // Run methods
|
||||
/^run$/,
|
||||
/^Start$/, // Start methods
|
||||
/^start$/,
|
||||
/^handle_/,
|
||||
/_handler$/,
|
||||
/_callback$/,
|
||||
/^OnEvent/, // Event callbacks
|
||||
/^on_/,
|
||||
/::Run$/, // Class::Run
|
||||
/::Start$/, // Class::Start
|
||||
/::Init$/, // Class::Init
|
||||
/::Execute$/, // Class::Execute
|
||||
],
|
||||
|
||||
// Swift / iOS
|
||||
[SupportedLanguages.Swift]: [
|
||||
/^viewDidLoad$/, // UIKit lifecycle
|
||||
/^viewWillAppear$/, // UIKit lifecycle
|
||||
/^viewDidAppear$/, // UIKit lifecycle
|
||||
/^viewWillDisappear$/, // UIKit lifecycle
|
||||
/^viewDidDisappear$/, // UIKit lifecycle
|
||||
/^application\(/, // AppDelegate methods
|
||||
/^scene\(/, // SceneDelegate methods
|
||||
/^body$/, // SwiftUI View.body
|
||||
/Coordinator$/, // Coordinator pattern
|
||||
/^sceneDidBecomeActive$/, // SceneDelegate lifecycle
|
||||
/^sceneWillResignActive$/, // SceneDelegate lifecycle
|
||||
/^didFinishLaunchingWithOptions$/, // AppDelegate
|
||||
/ViewController$/, // ViewController classes
|
||||
/^configure[A-Z]/, // Configuration methods
|
||||
/^setup[A-Z]/, // Setup methods
|
||||
/^makeBody$/, // SwiftUI ViewModifier
|
||||
],
|
||||
|
||||
// PHP / Laravel
|
||||
[SupportedLanguages.PHP]: [
|
||||
/Controller$/, // UserController (class name convention)
|
||||
/^handle$/, // Job::handle(), Listener::handle()
|
||||
/^execute$/, // Command::execute()
|
||||
/^boot$/, // ServiceProvider::boot()
|
||||
/^register$/, // ServiceProvider::register()
|
||||
/^__invoke$/, // Invokable controllers/actions
|
||||
/^(index|show|store|update|destroy|create|edit)$/, // RESTful resource methods
|
||||
/^(get|post|put|delete|patch)[A-Z]/, // Explicit HTTP method actions
|
||||
/^run$/, // Command/Job run()
|
||||
/^fire$/, // Event fire()
|
||||
/^dispatch$/, // Dispatchable jobs
|
||||
/Service$/, // UserService (Service layer)
|
||||
/Repository$/, // UserRepository (Repository pattern)
|
||||
/^find$/, // Repository::find()
|
||||
/^findAll$/, // Repository::findAll()
|
||||
/^save$/, // Repository::save()
|
||||
/^delete$/, // Repository::delete()
|
||||
],
|
||||
|
||||
// Ruby
|
||||
[SupportedLanguages.Ruby]: [
|
||||
/^call$/, // Service objects (MyService.call)
|
||||
/^perform$/, // Background jobs (Sidekiq, ActiveJob)
|
||||
/^execute$/, // Command pattern
|
||||
],
|
||||
|
||||
// Dart / Flutter
|
||||
[SupportedLanguages.Dart]: [
|
||||
/^main$/, // App entry
|
||||
/^build$/, // Widget.build — fundamental Flutter render entry point
|
||||
/^createState$/, // StatefulWidget.createState
|
||||
/^initState$/, // State lifecycle initialization
|
||||
/^dispose$/, // State lifecycle teardown
|
||||
/^didChangeDependencies$/, // State lifecycle — InheritedWidget changes
|
||||
/^didUpdateWidget$/, // State lifecycle — widget rebuild with new config
|
||||
/^runApp$/, // App entry point
|
||||
/^onEvent$/, // BLoC event handler
|
||||
/^mapEventToState$/, // Legacy BLoC pattern
|
||||
],
|
||||
[SupportedLanguages.Vue]: [], // Vue uses TypeScript queries — entry points handled via TS patterns
|
||||
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no tree-sitter entry points
|
||||
} satisfies Record<SupportedLanguages, RegExp[]>;
|
||||
|
||||
/** Pre-computed merged patterns (universal + language-specific) to avoid per-call array allocation. */
|
||||
/** Pre-computed merged patterns (universal + language-specific) from providers. */
|
||||
const MERGED_ENTRY_POINT_PATTERNS = Object.fromEntries(
|
||||
Object.values(SupportedLanguages).map((lang) => [
|
||||
Object.entries(providers).map(([lang, provider]) => [
|
||||
lang,
|
||||
[...UNIVERSAL_ENTRY_POINT_PATTERNS, ...(ENTRY_POINT_PATTERNS[lang] ?? [])],
|
||||
[...UNIVERSAL_ENTRY_POINT_PATTERNS, ...(provider.entryPointPatterns ?? [])],
|
||||
]),
|
||||
) as Record<SupportedLanguages, RegExp[]>;
|
||||
|
||||
|
|
|
|||
|
|
@ -106,15 +106,13 @@ export function finalizeScopeModel(
|
|||
const allScopes: Scope[] = [];
|
||||
const allDefs: SymbolDefinition[] = [];
|
||||
const moduleEntries: { filePath: string; moduleScopeId: ScopeId }[] = [];
|
||||
const allReferenceSites = [] as ReturnType<typeof collectReferenceSites>;
|
||||
const allReferenceSites = collectReferenceSites(parsedFiles);
|
||||
|
||||
for (const file of parsedFiles) {
|
||||
for (const s of file.scopes) allScopes.push(s);
|
||||
for (const d of file.localDefs) allDefs.push(d);
|
||||
moduleEntries.push({ filePath: file.filePath, moduleScopeId: file.moduleScope });
|
||||
}
|
||||
// References kept out of the loop above to centralize list-init.
|
||||
allReferenceSites.push(...collectReferenceSites(parsedFiles));
|
||||
|
||||
const scopeTree = buildScopeTree(allScopes);
|
||||
const defs = buildDefIndex(allDefs);
|
||||
|
|
@ -141,6 +139,11 @@ export function finalizeScopeModel(
|
|||
methodDispatch,
|
||||
imports: finalizeOut.imports,
|
||||
bindings: finalizeOut.bindings,
|
||||
// Empty post-finalize augmentation channel. Populated (if at all)
|
||||
// by language hooks like `populateCsharpNamespaceSiblings` running
|
||||
// AFTER `finalizeScopeModel` returns, before `resolveReferenceSites`
|
||||
// consumes the bundle. Most languages leave it empty.
|
||||
bindingAugmentations: new Map(),
|
||||
referenceSites: Object.freeze([...allReferenceSites]),
|
||||
sccs: finalizeOut.sccs,
|
||||
stats: finalizeOut.stats,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
})),
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ export const processHeritage = async (
|
|||
// Use larger bufferSize for files > 32KB
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content.length),
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
});
|
||||
} catch (parseError) {
|
||||
// Skip files that can't be parsed
|
||||
|
|
@ -414,7 +414,7 @@ export async function extractExtractedHeritageFromFiles(
|
|||
if (!tree) {
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content.length),
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -306,7 +306,7 @@ export const processImports = async (
|
|||
if (!tree) {
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content.length),
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
});
|
||||
} catch (parseError) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -78,6 +78,14 @@ export type ImportSemantics =
|
|||
| 'namespace'
|
||||
| 'explicit-reexport';
|
||||
|
||||
/** Configuration for AST-based framework detection patterns. */
|
||||
export interface AstFrameworkPatternConfig {
|
||||
framework: string;
|
||||
entryPointMultiplier: number;
|
||||
reason: string;
|
||||
patterns: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a language needs to provide.
|
||||
* Required fields must be explicitly set; optional fields have defaults
|
||||
|
|
@ -89,6 +97,16 @@ interface LanguageProviderConfig {
|
|||
/** File extensions that map to this language (e.g., ['.ts', '.tsx']) */
|
||||
readonly extensions: readonly string[];
|
||||
|
||||
/** Entry-point function name patterns specific to this language.
|
||||
* Merged with universal patterns at runtime for process detection scoring.
|
||||
* Default: [] (only universal patterns apply). */
|
||||
readonly entryPointPatterns?: readonly RegExp[];
|
||||
|
||||
/** AST-based framework detection patterns for this language.
|
||||
* Used by detectFrameworkFromAST to identify framework entry points.
|
||||
* Default: [] (no AST framework detection for this language). */
|
||||
readonly astFrameworkPatterns?: readonly AstFrameworkPatternConfig[];
|
||||
|
||||
// ── Parser ────────────────────────────────────────────────────────
|
||||
/** Parse strategy: 'tree-sitter' (default) uses AST parsing via tree-sitter.
|
||||
* 'standalone' means the language has its own regex-based processor and
|
||||
|
|
@ -498,6 +516,15 @@ interface LanguageProviderConfig {
|
|||
|
||||
// ── Resolution phase (RFC §4v2) ────────────────────────────────────
|
||||
|
||||
/** Order same-name type candidates when a language can index multiple
|
||||
* definitions for one logical type. Return null to keep shared ambiguity
|
||||
* handling. */
|
||||
readonly orderSameNameTypeCandidates?: (params: {
|
||||
readonly typeName: string;
|
||||
readonly callSiteFilePath: string;
|
||||
readonly candidates: readonly SymbolDefinition[];
|
||||
}) => readonly SymbolDefinition[] | null;
|
||||
|
||||
/**
|
||||
* Is this callable definition compatible with the given call-site arity?
|
||||
* Language-specific rules: Python `*args`/`**kwargs`/defaults, JS default
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
|||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { cClassConfig, cppClassConfig } from '../class-extractors/configs/c-cpp.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { typeConfig as cCppConfig } from '../type-extractors/c-cpp.js';
|
||||
import { cCppExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
|
|
@ -317,6 +318,38 @@ const cppLabelOverride: NonNullable<LanguageProvider['labelOverride']> = (
|
|||
export const cProvider = defineLanguage({
|
||||
id: SupportedLanguages.C,
|
||||
extensions: ['.c'],
|
||||
entryPointPatterns: [
|
||||
/^main$/,
|
||||
/^init_/,
|
||||
/_init$/,
|
||||
/^start_/,
|
||||
/_start$/,
|
||||
/^run_/,
|
||||
/_run$/,
|
||||
/^stop_/,
|
||||
/_stop$/,
|
||||
/^open_/,
|
||||
/_open$/,
|
||||
/^close_/,
|
||||
/_close$/,
|
||||
/^create_/,
|
||||
/_create$/,
|
||||
/^destroy_/,
|
||||
/_destroy$/,
|
||||
/^handle_/,
|
||||
/_handler$/,
|
||||
/_callback$/,
|
||||
/^cmd_/,
|
||||
/^server_/,
|
||||
/^client_/,
|
||||
/^session_/,
|
||||
/^window_/,
|
||||
/^key_/,
|
||||
/^input_/,
|
||||
/^output_/,
|
||||
/^notify_/,
|
||||
/^control_/,
|
||||
],
|
||||
treeSitterQueries: C_QUERIES,
|
||||
typeConfig: cCppConfig,
|
||||
exportChecker: cCppExportChecker,
|
||||
|
|
@ -338,6 +371,44 @@ export const cProvider = defineLanguage({
|
|||
export const cppProvider = defineLanguage({
|
||||
id: SupportedLanguages.CPlusPlus,
|
||||
extensions: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'],
|
||||
entryPointPatterns: [
|
||||
/^main$/,
|
||||
/^init_/,
|
||||
/_init$/,
|
||||
/^Create[A-Z]/,
|
||||
/^create_/,
|
||||
/^Run$/,
|
||||
/^run$/,
|
||||
/^Start$/,
|
||||
/^start$/,
|
||||
/^handle_/,
|
||||
/_handler$/,
|
||||
/_callback$/,
|
||||
/^OnEvent/,
|
||||
/^on_/,
|
||||
/::Run$/,
|
||||
/::Start$/,
|
||||
/::Init$/,
|
||||
/::Execute$/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'qt',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'qt-macro',
|
||||
patterns: [
|
||||
'Q_OBJECT',
|
||||
'Q_INVOKABLE',
|
||||
'Q_PROPERTY',
|
||||
'Q_SIGNALS',
|
||||
'Q_SLOTS',
|
||||
'Q_SIGNAL',
|
||||
'Q_SLOT',
|
||||
'QWidget',
|
||||
'QApplication',
|
||||
],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: CPP_QUERIES,
|
||||
typeConfig: cCppConfig,
|
||||
exportChecker: cCppExportChecker,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export const cobolProvider = defineLanguage({
|
|||
id: SupportedLanguages.Cobol,
|
||||
parseStrategy: 'standalone',
|
||||
extensions: [], // COBOL files detected by cobol-processor's isCobolFile/isJclFile
|
||||
entryPointPatterns: [],
|
||||
astFrameworkPatterns: [],
|
||||
treeSitterQueries: '',
|
||||
typeConfig: {
|
||||
declarationNodeTypes: new Set(),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
|||
import { csharpImportConfig } from '../import-resolvers/configs/csharp.js';
|
||||
import { extractCSharpNamedBindings } from '../named-bindings/csharp.js';
|
||||
import { CSHARP_QUERIES } from '../tree-sitter-queries.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { csharpCallConfig } from '../call-extractors/configs/csharp.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
|
|
@ -135,6 +136,55 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
|||
export const csharpProvider = defineLanguage({
|
||||
id: SupportedLanguages.CSharp,
|
||||
extensions: ['.cs'],
|
||||
entryPointPatterns: [
|
||||
/^(Get|Post|Put|Delete|Patch)/,
|
||||
/Action$/,
|
||||
/^On[A-Z]/,
|
||||
/Async$/,
|
||||
/^Configure$/,
|
||||
/^ConfigureServices$/,
|
||||
/^Handle$/,
|
||||
/^Execute$/,
|
||||
/^Invoke$/,
|
||||
/^Map[A-Z]/,
|
||||
/Service$/,
|
||||
/^Seed/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'aspnet',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'aspnet-attribute',
|
||||
patterns: [
|
||||
'[ApiController]',
|
||||
'[HttpGet]',
|
||||
'[HttpPost]',
|
||||
'[HttpPut]',
|
||||
'[HttpDelete]',
|
||||
'[Route]',
|
||||
'[Authorize]',
|
||||
'[AllowAnonymous]',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'signalr',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'signalr-attribute',
|
||||
patterns: ['[HubMethodName]', ': Hub', ': Hub<'],
|
||||
},
|
||||
{
|
||||
framework: 'blazor',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'blazor-attribute',
|
||||
patterns: ['@page', '[Parameter]', '@inject'],
|
||||
},
|
||||
{
|
||||
framework: 'efcore',
|
||||
entryPointMultiplier: 2.0,
|
||||
reason: 'efcore-pattern',
|
||||
patterns: ['DbContext', 'DbSet<', 'OnModelCreating'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: CSHARP_QUERIES,
|
||||
typeConfig: csharpConfig,
|
||||
exportChecker: csharpExportChecker,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { computeCsharpArityMetadata } from './arity-metadata.js';
|
|||
import { synthesizeCsharpReceiverBinding } from './receiver-binding.js';
|
||||
import { getCsharpParser, getCsharpScopeQuery } from './query.js';
|
||||
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
|
||||
/** Declaration anchors that carry function-like arity metadata. */
|
||||
const FUNCTION_DECL_TAGS = [
|
||||
|
|
@ -52,7 +53,9 @@ export function emitCsharpScopeCaptures(
|
|||
// the LanguageProvider contract layer; cast here at the use site.
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getCsharpParser>['parse']> | undefined;
|
||||
if (tree === undefined) {
|
||||
tree = getCsharpParser().parse(sourceText);
|
||||
tree = getCsharpParser().parse(sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
recordCacheMiss();
|
||||
} else {
|
||||
recordCacheHit();
|
||||
|
|
|
|||
|
|
@ -11,17 +11,18 @@
|
|||
* field-chain resolution fails at `findClassBindingInScope('User')`
|
||||
* in the Service.cs scope chain.
|
||||
*
|
||||
* Implementation: after the finalize pass populates `indexes.bindings`
|
||||
* (from explicit `using` directives), walk each file's tree-sitter
|
||||
* AST for `namespace_declaration` / `file_scoped_namespace_declaration`
|
||||
* and `using_directive` nodes. The orchestrator hands us its
|
||||
* `treeCache` so files already parsed by `extractParsedFile` are
|
||||
* re-used instead of re-parsed — `ParsedFile`'s underlying tree is
|
||||
* the single source of truth. Group classes by namespace, and inject
|
||||
* cross-file sibling classes into each Namespace scope's finalized
|
||||
* bindings with `origin: 'namespace'` — a tier below `local` so a
|
||||
* local declaration still shadows a cross-file sibling with the same
|
||||
* name.
|
||||
* Implementation: after the finalize pass populates immutable
|
||||
* `indexes.bindings` (from explicit `using` directives), walk each
|
||||
* file's tree-sitter AST for `namespace_declaration` /
|
||||
* `file_scoped_namespace_declaration` and `using_directive` nodes.
|
||||
* The orchestrator hands us its `treeCache` so files already parsed
|
||||
* by `extractParsedFile` are re-used instead of re-parsed —
|
||||
* `ParsedFile`'s underlying tree is the single source of truth.
|
||||
* Group classes by namespace, and append cross-file sibling classes
|
||||
* into each Namespace scope's `bindingAugmentations` bucket with
|
||||
* `origin: 'namespace'`. Finalized bindings remain first in
|
||||
* `lookupBindingsAt`, and local lexical `Scope.bindings` remains the
|
||||
* first-tier shadowing channel.
|
||||
*
|
||||
* The tree-sitter walk is authoritative: it sees `global using static`,
|
||||
* aliased `using static X = Y.Z;`, attributed namespace declarations,
|
||||
|
|
@ -34,6 +35,7 @@ import type { SyntaxNode } from 'tree-sitter';
|
|||
import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { getCsharpParser } from './query.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
|
||||
interface CsharpFileStructure {
|
||||
/** Declared namespace names in file source order. Empty array means
|
||||
|
|
@ -52,7 +54,11 @@ interface CsharpFileStructure {
|
|||
* shared across calls. */
|
||||
function extractFileStructure(content: string, cachedTree: unknown): CsharpFileStructure {
|
||||
type CsharpTree = ReturnType<ReturnType<typeof getCsharpParser>['parse']>;
|
||||
const tree = (cachedTree as CsharpTree | undefined) ?? getCsharpParser().parse(content);
|
||||
const tree =
|
||||
(cachedTree as CsharpTree | undefined) ??
|
||||
getCsharpParser().parse(content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(content),
|
||||
});
|
||||
const namespaces: string[] = [];
|
||||
const usingStaticPaths: string[] = [];
|
||||
|
||||
|
|
@ -106,8 +112,8 @@ export interface CsharpSiblingInputs {
|
|||
}
|
||||
|
||||
/**
|
||||
* Mutate `indexes.bindings` in-place, adding cross-file sibling class
|
||||
* defs to each Namespace scope. Class-like defs (Class / Interface /
|
||||
* Append cross-file sibling class defs to each Namespace scope's
|
||||
* `bindingAugmentations` bucket. Class-like defs (Class / Interface /
|
||||
* Struct / Record / Enum) are visible cross-file; method / field
|
||||
* members are not.
|
||||
*/
|
||||
|
|
@ -198,12 +204,15 @@ export function populateCsharpNamespaceSiblings(
|
|||
}
|
||||
}
|
||||
|
||||
// Inject cross-file siblings into each namespace scope's finalized
|
||||
// bindings. `indexes.bindings` is typed `ReadonlyMap<ScopeId, ...>`
|
||||
// but is a plain Map at runtime; mutating here is the established
|
||||
// pattern (see `propagateImportedReturnTypes` which does the same
|
||||
// for module-scope typeBindings).
|
||||
const finalized = indexes.bindings as Map<ScopeId, Map<string, BindingRef[]>>;
|
||||
// Inject cross-file siblings into each namespace scope's
|
||||
// post-finalize augmentation channel (per I8). The
|
||||
// `indexes.bindingAugmentations` map is the dedicated mutable
|
||||
// append-only buffer for post-finalize hooks: inner `BindingRef[]`
|
||||
// arrays here are NEVER frozen (unlike `indexes.bindings`, which
|
||||
// `materializeBindings` freezes). Walkers consult both channels
|
||||
// via `lookupBindingsAt`; we never need to consult or mutate
|
||||
// `indexes.bindings`.
|
||||
const augmentations = indexes.bindingAugmentations as Map<ScopeId, Map<string, BindingRef[]>>;
|
||||
|
||||
// Cross-namespace type-binding propagation: for each file, mirror
|
||||
// method return-type bindings from same-namespace sibling files and
|
||||
|
|
@ -301,17 +310,13 @@ export function populateCsharpNamespaceSiblings(
|
|||
const simpleName = mq.includes('.') ? mq.slice(mq.lastIndexOf('.') + 1) : mq;
|
||||
if (simpleName === '') continue;
|
||||
|
||||
// Add to `indexes.bindings[moduleScope]` so
|
||||
// `findCallableBindingInScope` picks it up.
|
||||
let scopeBindings = finalized.get(moduleScope.id);
|
||||
if (scopeBindings === undefined) {
|
||||
scopeBindings = new Map<string, BindingRef[]>();
|
||||
finalized.set(moduleScope.id, scopeBindings);
|
||||
}
|
||||
const existing = scopeBindings.get(simpleName) ?? [];
|
||||
if (existing.some((b) => b.def.nodeId === memberDef.nodeId)) continue;
|
||||
existing.push({ def: memberDef, origin: 'import' });
|
||||
scopeBindings.set(simpleName, existing);
|
||||
// Append to the augmentation bucket for the importer's module
|
||||
// scope. `findCallableBindingInScope` reads via
|
||||
// `lookupBindingsAt`, which fans out across `bindings` +
|
||||
// `bindingAugmentations`.
|
||||
const bucketArr = getAugmentationBucket(augmentations, moduleScope.id, simpleName);
|
||||
if (bucketArr.some((b) => b.def.nodeId === memberDef.nodeId)) continue;
|
||||
bucketArr.push({ def: memberDef, origin: 'import' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -337,15 +342,9 @@ export function populateCsharpNamespaceSiblings(
|
|||
const q = def.qualifiedName ?? '';
|
||||
const simpleName = q.includes('.') ? q.slice(q.lastIndexOf('.') + 1) : q;
|
||||
if (simpleName === '') continue;
|
||||
let scopeBindings = finalized.get(moduleScope.id);
|
||||
if (scopeBindings === undefined) {
|
||||
scopeBindings = new Map<string, BindingRef[]>();
|
||||
finalized.set(moduleScope.id, scopeBindings);
|
||||
}
|
||||
const existing = scopeBindings.get(simpleName) ?? [];
|
||||
if (existing.some((b) => b.def.nodeId === def.nodeId)) continue;
|
||||
existing.push({ def, origin: 'namespace' });
|
||||
scopeBindings.set(simpleName, existing);
|
||||
const bucketArr = getAugmentationBucket(augmentations, moduleScope.id, simpleName);
|
||||
if (bucketArr.some((b) => b.def.nodeId === def.nodeId)) continue;
|
||||
bucketArr.push({ def, origin: 'namespace' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -366,11 +365,6 @@ export function populateCsharpNamespaceSiblings(
|
|||
}
|
||||
|
||||
for (const { scopeId, filePath } of bucket.scopes) {
|
||||
let scopeBindings = finalized.get(scopeId);
|
||||
if (scopeBindings === undefined) {
|
||||
scopeBindings = new Map<string, BindingRef[]>();
|
||||
finalized.set(scopeId, scopeBindings);
|
||||
}
|
||||
for (const [name, defs] of defsByName) {
|
||||
// Skip names already present locally — `origin: 'local'` in
|
||||
// scope.bindings would naturally shadow the cross-file
|
||||
|
|
@ -378,18 +372,42 @@ export function populateCsharpNamespaceSiblings(
|
|||
const local = bucket.scopes.find((s) => s.filePath === filePath)?.scope.bindings.get(name);
|
||||
if (local !== undefined && local.some((b) => b.origin === 'local')) continue;
|
||||
|
||||
const existing = scopeBindings.get(name) ?? [];
|
||||
let bucketArr: BindingRef[] | null = null;
|
||||
for (const def of defs) {
|
||||
if (def.filePath === filePath) continue; // don't self-reference
|
||||
if (existing.some((b) => b.def.nodeId === def.nodeId)) continue;
|
||||
existing.push({ def, origin: 'namespace' });
|
||||
if (bucketArr === null) bucketArr = getAugmentationBucket(augmentations, scopeId, name);
|
||||
if (bucketArr.some((b) => b.def.nodeId === def.nodeId)) continue;
|
||||
bucketArr.push({ def, origin: 'namespace' });
|
||||
}
|
||||
if (existing.length > 0) scopeBindings.set(name, existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get-or-create a mutable inner bucket inside the `bindingAugmentations`
|
||||
* channel. The inner arrays here are mutable by contract (see
|
||||
* `ScopeResolutionIndexes.bindingAugmentations` doc + scope-resolver I8);
|
||||
* callers may `push` directly. Allocating the outer/inner Maps lazily
|
||||
* keeps the augmentation footprint zero for files with no cross-file
|
||||
* fanout. */
|
||||
function getAugmentationBucket(
|
||||
augmentations: Map<ScopeId, Map<string, BindingRef[]>>,
|
||||
scopeId: ScopeId,
|
||||
name: string,
|
||||
): BindingRef[] {
|
||||
let scopeBindings = augmentations.get(scopeId);
|
||||
if (scopeBindings === undefined) {
|
||||
scopeBindings = new Map<string, BindingRef[]>();
|
||||
augmentations.set(scopeId, scopeBindings);
|
||||
}
|
||||
let bucketArr = scopeBindings.get(name);
|
||||
if (bucketArr === undefined) {
|
||||
bucketArr = [];
|
||||
scopeBindings.set(name, bucketArr);
|
||||
}
|
||||
return bucketArr;
|
||||
}
|
||||
|
||||
function isTypeDef(def: SymbolDefinition): boolean {
|
||||
return (
|
||||
def.type === 'Class' ||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
|||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { dartClassConfig } from '../class-extractors/configs/dart.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { typeConfig as dartConfig } from '../type-extractors/dart.js';
|
||||
import { dartExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
|
|
@ -93,6 +94,42 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
|||
export const dartProvider = defineLanguage({
|
||||
id: SupportedLanguages.Dart,
|
||||
extensions: ['.dart'],
|
||||
entryPointPatterns: [
|
||||
/^main$/,
|
||||
/^build$/,
|
||||
/^createState$/,
|
||||
/^initState$/,
|
||||
/^dispose$/,
|
||||
/^didChangeDependencies$/,
|
||||
/^didUpdateWidget$/,
|
||||
/^runApp$/,
|
||||
/^onEvent$/,
|
||||
/^mapEventToState$/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'flutter',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'flutter-widget',
|
||||
patterns: [
|
||||
'StatelessWidget',
|
||||
'StatefulWidget',
|
||||
'BuildContext',
|
||||
'Widget build',
|
||||
'ChangeNotifier',
|
||||
'GetxController',
|
||||
'Cubit<',
|
||||
'Bloc<',
|
||||
'ConsumerWidget',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'riverpod',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'riverpod-pattern',
|
||||
patterns: ['@riverpod', 'ref.watch', 'ref.read', 'AsyncNotifier', 'Notifier'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: DART_QUERIES,
|
||||
typeConfig: dartConfig,
|
||||
exportChecker: dartExportChecker,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { goExportChecker } from '../export-detection.js';
|
|||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
import { goImportConfig } from '../import-resolvers/configs/go.js';
|
||||
import { GO_QUERIES } from '../tree-sitter-queries.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { goConfig as goFieldConfig } from '../field-extractors/configs/go.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
|
|
@ -32,6 +33,45 @@ import { goHeritageConfig } from '../heritage-extractors/configs/go.js';
|
|||
export const goProvider = defineLanguage({
|
||||
id: SupportedLanguages.Go,
|
||||
extensions: ['.go'],
|
||||
entryPointPatterns: [/Handler$/, /^Serve/, /^New[A-Z]/, /^Make[A-Z]/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'go-http',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'go-http-handler',
|
||||
patterns: [
|
||||
'http.Handler',
|
||||
'http.HandlerFunc',
|
||||
'ServeHTTP',
|
||||
'http.ResponseWriter',
|
||||
'http.Request',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'gin',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'gin-handler',
|
||||
patterns: ['gin.Context', 'gin.Default', 'gin.New'],
|
||||
},
|
||||
{
|
||||
framework: 'echo',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'echo-handler',
|
||||
patterns: ['echo.Context', 'echo.New'],
|
||||
},
|
||||
{
|
||||
framework: 'fiber',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'fiber-handler',
|
||||
patterns: ['fiber.Ctx', 'fiber.New', 'fiber.App'],
|
||||
},
|
||||
{
|
||||
framework: 'go-grpc',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'grpc-service',
|
||||
patterns: ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: GO_QUERIES,
|
||||
typeConfig: goConfig,
|
||||
exportChecker: goExportChecker,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
|||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { javaClassConfig } from '../class-extractors/configs/jvm.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { javaTypeConfig } from '../type-extractors/jvm.js';
|
||||
import { javaExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
|
|
@ -30,6 +31,27 @@ import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
|||
export const javaProvider = defineLanguage({
|
||||
id: SupportedLanguages.Java,
|
||||
extensions: ['.java'],
|
||||
entryPointPatterns: [/^do[A-Z]/, /^create[A-Z]/, /^build[A-Z]/, /Service$/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'spring',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'spring-annotation',
|
||||
patterns: [
|
||||
'@RestController',
|
||||
'@Controller',
|
||||
'@GetMapping',
|
||||
'@PostMapping',
|
||||
'@RequestMapping',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'jaxrs',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'jaxrs-annotation',
|
||||
patterns: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: JAVA_QUERIES,
|
||||
typeConfig: javaTypeConfig,
|
||||
exportChecker: javaExportChecker,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { kotlinImportConfig } from '../import-resolvers/configs/jvm.js';
|
|||
import { extractKotlinNamedBindings } from '../named-bindings/kotlin.js';
|
||||
import { appendKotlinWildcard } from '../import-resolvers/jvm.js';
|
||||
import { KOTLIN_QUERIES } from '../tree-sitter-queries.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { kotlinCallConfig } from '../call-extractors/configs/jvm.js';
|
||||
|
|
@ -105,6 +106,47 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
|||
export const kotlinProvider = defineLanguage({
|
||||
id: SupportedLanguages.Kotlin,
|
||||
extensions: ['.kt', '.kts'],
|
||||
entryPointPatterns: [
|
||||
/^on(Create|Start|Resume|Pause|Stop|Destroy)$/,
|
||||
/^do[A-Z]/,
|
||||
/^create[A-Z]/,
|
||||
/^build[A-Z]/,
|
||||
/ViewModel$/,
|
||||
/^module$/,
|
||||
/Service$/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'spring-kotlin',
|
||||
entryPointMultiplier: 3.2,
|
||||
reason: 'spring-kotlin-annotation',
|
||||
patterns: [
|
||||
'@RestController',
|
||||
'@Controller',
|
||||
'@GetMapping',
|
||||
'@PostMapping',
|
||||
'@RequestMapping',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'jaxrs',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'jaxrs-annotation',
|
||||
patterns: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'],
|
||||
},
|
||||
{
|
||||
framework: 'ktor',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'ktor-routing',
|
||||
patterns: ['routing', 'embeddedServer', 'Application.module'],
|
||||
},
|
||||
{
|
||||
framework: 'android-kotlin',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'android-annotation',
|
||||
patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: KOTLIN_QUERIES,
|
||||
typeConfig: kotlinTypeConfig,
|
||||
exportChecker: kotlinExportChecker,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
|||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { phpClassConfig } from '../class-extractors/configs/php.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { typeConfig as phpConfig } from '../type-extractors/php.js';
|
||||
import { phpExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
|
|
@ -239,6 +240,41 @@ function isPhpRouteFile(filePath: string): boolean {
|
|||
export const phpProvider = defineLanguage({
|
||||
id: SupportedLanguages.PHP,
|
||||
extensions: ['.php', '.phtml', '.php3', '.php4', '.php5', '.php8'],
|
||||
entryPointPatterns: [
|
||||
/Controller$/,
|
||||
/^handle$/,
|
||||
/^execute$/,
|
||||
/^boot$/,
|
||||
/^register$/,
|
||||
/^__invoke$/,
|
||||
/^(index|show|store|update|destroy|create|edit)$/,
|
||||
/^(get|post|put|delete|patch)[A-Z]/,
|
||||
/^run$/,
|
||||
/^fire$/,
|
||||
/^dispatch$/,
|
||||
/Service$/,
|
||||
/Repository$/,
|
||||
/^find$/,
|
||||
/^findAll$/,
|
||||
/^save$/,
|
||||
/^delete$/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'laravel',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'php-route-attribute',
|
||||
patterns: [
|
||||
'Route::get',
|
||||
'Route::post',
|
||||
'Route::put',
|
||||
'Route::delete',
|
||||
'Route::resource',
|
||||
'Route::apiResource',
|
||||
'#[Route(',
|
||||
],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: PHP_QUERIES,
|
||||
typeConfig: phpConfig,
|
||||
exportChecker: phpExportChecker,
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@
|
|||
* - namedBindingExtractor: present (from X import Y)
|
||||
*/
|
||||
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { pythonClassConfig } from '../class-extractors/configs/python.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { typeConfig as pythonConfig } from '../type-extractors/python.js';
|
||||
import { pythonExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
|
|
@ -29,8 +31,11 @@ import { pythonVariableConfig } from '../variable-extractors/configs/python.js';
|
|||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { pythonCallConfig } from '../call-extractors/configs/python.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import type { CaptureMap } from '../language-provider.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import {
|
||||
emitPythonScopeCaptures,
|
||||
pythonFunctionDefinitionLabel,
|
||||
interpretPythonImport,
|
||||
interpretPythonTypeBinding,
|
||||
pythonArityCompatibility,
|
||||
|
|
@ -71,9 +76,52 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
|||
'abs',
|
||||
]);
|
||||
|
||||
function pythonDescriptionExtractor(
|
||||
nodeLabel: NodeLabel,
|
||||
_nodeName: string,
|
||||
captureMap: CaptureMap,
|
||||
): string | undefined {
|
||||
if (nodeLabel !== 'Function' && nodeLabel !== 'Method') return undefined;
|
||||
const functionNode = captureMap['definition.function'] ?? captureMap['definition.method'];
|
||||
if (functionNode === undefined) return undefined;
|
||||
return extractPythonDocstring(functionNode);
|
||||
}
|
||||
|
||||
function extractPythonDocstring(functionNode: SyntaxNode): string | undefined {
|
||||
const body = functionNode.childForFieldName('body');
|
||||
const firstStatement = body?.namedChild(0);
|
||||
if (firstStatement?.type !== 'expression_statement') return undefined;
|
||||
|
||||
const literal = firstStatement.namedChild(0);
|
||||
if (literal?.type !== 'string') return undefined;
|
||||
return normalizePythonStringLiteral(literal.text);
|
||||
}
|
||||
|
||||
function normalizePythonStringLiteral(text: string): string | undefined {
|
||||
const match = text.match(/^[rRuUbBfF]*("""|'''|"|')([\s\S]*)\1$/);
|
||||
const raw = match?.[2]?.trim();
|
||||
if (!raw) return undefined;
|
||||
return raw.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export const pythonProvider = defineLanguage({
|
||||
id: SupportedLanguages.Python,
|
||||
extensions: ['.py'],
|
||||
entryPointPatterns: [/^app$/, /^(get|post|put|delete|patch)_/i, /^api_/, /^view_/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'fastapi',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'fastapi-decorator',
|
||||
patterns: ['@app.get', '@app.post', '@app.put', '@app.delete', '@router.get'],
|
||||
},
|
||||
{
|
||||
framework: 'flask',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'flask-decorator',
|
||||
patterns: ['@app.route', '@blueprint.route'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: PYTHON_QUERIES,
|
||||
typeConfig: pythonConfig,
|
||||
exportChecker: pythonExportChecker,
|
||||
|
|
@ -87,7 +135,9 @@ export const pythonProvider = defineLanguage({
|
|||
variableExtractor: createVariableExtractor(pythonVariableConfig),
|
||||
classExtractor: createClassExtractor(pythonClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.Python),
|
||||
descriptionExtractor: pythonDescriptionExtractor,
|
||||
builtInNames: BUILT_INS,
|
||||
labelOverride: pythonFunctionDefinitionLabel,
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
|
||||
// Python is the first migration. See ./python/index.ts for the
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import { getPythonParser, getPythonScopeQuery } from './query.js';
|
|||
import { synthesizeReceiverTypeBinding } from './receiver-binding.js';
|
||||
import { computePythonArityMetadata } from './arity-metadata.js';
|
||||
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { pythonFunctionDefinitionLabel } from './simple-hooks.js';
|
||||
|
||||
export function emitPythonScopeCaptures(
|
||||
sourceText: string,
|
||||
|
|
@ -36,12 +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);
|
||||
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[] = [];
|
||||
|
||||
|
|
@ -95,6 +109,10 @@ export function emitPythonScopeCaptures(
|
|||
const anchorCap = grouped['@declaration.function']!;
|
||||
const fnNode = findNodeAtRange(tree.rootNode, anchorCap.range, 'function_definition');
|
||||
if (fnNode !== null) {
|
||||
if (pythonFunctionDefinitionLabel(fnNode, 'Function') === 'Method') {
|
||||
delete grouped['@declaration.function'];
|
||||
grouped['@declaration.method'] = { ...anchorCap, name: '@declaration.method' };
|
||||
}
|
||||
const arity = computePythonArityMetadata(fnNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
grouped['@declaration.parameter-count'] = syntheticCapture(
|
||||
|
|
@ -130,3 +148,10 @@ export function emitPythonScopeCaptures(
|
|||
|
||||
return out;
|
||||
}
|
||||
|
||||
function scopeExtractionError(stage: string, filePath: string, err: unknown): Error {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
return new Error(
|
||||
`[python] tree-sitter ${stage} failed for ${filePath}: ${reason}; skipping scope extraction for this file`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,60 +61,159 @@ export function resolvePythonImportTarget(
|
|||
const pathLike = parsedImport.targetRaw.replace(/\./g, '/');
|
||||
if (pathLike.includes('/')) {
|
||||
const [leadingSegment] = pathLike.split('/').filter(Boolean);
|
||||
if (!leadingSegment || !hasRepoCandidate(leadingSegment, ctx.allFilePaths)) {
|
||||
if (!leadingSegment || !hasRepoCandidate(leadingSegment, ctx.allFilePaths, ctx.fromFile)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-segment absolute resolve: try exact paths first, then suffix
|
||||
// match in nested repos. Using direct `Set.has` + `endsWith` instead of
|
||||
// `suffixResolve`'s shared helper because that helper requires a
|
||||
// pre-built `SuffixIndex` to disambiguate ties — without one it falls
|
||||
// back to an O(files) scan that silently picks the wrong file when
|
||||
// the last segment collides across directories (e.g. `accounts.models`
|
||||
// matching `billing/models.py` when both files exist).
|
||||
return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths);
|
||||
// Multi-segment absolute resolve: try exact paths first, then ancestor
|
||||
// walk (mirrors the single-segment ancestor walk in
|
||||
// `resolvePythonImportInternal`), then a suffix match in nested repos.
|
||||
// Using direct `Set.has` + `endsWith` instead of `suffixResolve`'s shared
|
||||
// helper because that helper requires a pre-built `SuffixIndex` to
|
||||
// disambiguate ties — without one it falls back to an O(files) scan that
|
||||
// silently picks the wrong file when the last segment collides across
|
||||
// directories (e.g. `accounts.models` matching `billing/models.py` when
|
||||
// both files exist).
|
||||
return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths, ctx.fromFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `package/sub/module` style paths (already dot-flattened) to a
|
||||
* concrete file in `allFilePaths`. Tries the exact path first, then the
|
||||
* `__init__.py` variant, then a suffix match for nested layouts.
|
||||
* concrete file in `allFilePaths`. Tries the exact path first, then walks
|
||||
* ancestors of `fromFile` looking for `<ancestor>/<pathLike>.py` (or
|
||||
* `__init__.py`), then falls back to a suffix match for nested layouts.
|
||||
* Returns the original (un-normalized) path from the set.
|
||||
*
|
||||
* Precedence order:
|
||||
* 1. Workspace-root direct hit (`<pathLike>.py`, `<pathLike>/__init__.py`).
|
||||
* 2. Closest-ancestor match walking up from the importer's directory.
|
||||
* 3. Suffix fallback (deterministic: fewest path segments, then
|
||||
* lexicographic on the normalized path).
|
||||
*
|
||||
* Root wins over ancestor by construction — if both `services/sync.py` and
|
||||
* `backend/services/sync.py` exist, `backend/routers/cron.py`'s
|
||||
* `from services.sync import X` resolves to the root file. This mirrors
|
||||
* Python's `sys.path` semantics where the project root is searched first.
|
||||
*
|
||||
* The ancestor walk mirrors the single-segment behavior in
|
||||
* `resolvePythonImportInternal`. For `from services.sync import X` in
|
||||
* `backend/routers/cron.py`, walk up: `backend/routers/services/sync.py` →
|
||||
* `backend/services/sync.py` ✓.
|
||||
*/
|
||||
function resolveAbsoluteFromFiles(pathLike: string, allFilePaths: Set<string>): string | null {
|
||||
function resolveAbsoluteFromFiles(
|
||||
pathLike: string,
|
||||
allFilePaths: Set<string>,
|
||||
fromFile: string,
|
||||
): string | null {
|
||||
const directFile = `${pathLike}.py`;
|
||||
const directPkg = `${pathLike}/__init__.py`;
|
||||
const suffixFile = `/${directFile}`;
|
||||
const suffixPkg = `/${directPkg}`;
|
||||
|
||||
let suffixMatch: string | null = null;
|
||||
for (const raw of allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (f === directFile || f === directPkg) return raw;
|
||||
if (suffixMatch === null && (f.endsWith(suffixFile) || f.endsWith(suffixPkg))) {
|
||||
suffixMatch = raw;
|
||||
// Direct hit at workspace root.
|
||||
if (allFilePaths.has(directFile)) return directFile;
|
||||
if (allFilePaths.has(directPkg)) return directPkg;
|
||||
|
||||
// Ancestor walk — match the single-segment resolver's behavior at
|
||||
// multi-segment granularity. Closest match wins. Stop at `i > 0` because
|
||||
// `i === 0` would re-check the workspace-root candidates already covered
|
||||
// by the direct check above.
|
||||
const importerDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
|
||||
if (importerDir) {
|
||||
const dirParts = importerDir.split('/').filter(Boolean);
|
||||
for (let i = dirParts.length; i > 0; i--) {
|
||||
const ancestor = dirParts.slice(0, i).join('/');
|
||||
const prefix = `${ancestor}/`;
|
||||
const candidateFile = `${prefix}${directFile}`;
|
||||
const candidatePkg = `${prefix}${directPkg}`;
|
||||
if (allFilePaths.has(candidateFile)) return candidateFile;
|
||||
if (allFilePaths.has(candidatePkg)) return candidatePkg;
|
||||
}
|
||||
}
|
||||
return suffixMatch;
|
||||
|
||||
// Suffix-match fallback (preserved for monorepo/nested-repo layouts
|
||||
// that don't share a directory ancestor with the importer).
|
||||
//
|
||||
// Tie-break order when multiple files match the same suffix:
|
||||
// 1. Fewest path segments (shorter, more canonical paths win — `lib/x.py`
|
||||
// beats `tooling/extras/x.py`).
|
||||
// 2. Lexicographic order over the normalized path (final stable
|
||||
// tiebreak independent of file-set insertion order).
|
||||
//
|
||||
// Without an explicit tie-break the previous implementation returned
|
||||
// the first match in `Set` iteration order, which depended on file
|
||||
// ingestion order and produced non-deterministic edges across runs in
|
||||
// multi-directory collision repos.
|
||||
const suffixFile = `/${directFile}`;
|
||||
const suffixPkg = `/${directPkg}`;
|
||||
const matches: { raw: string; norm: string }[] = [];
|
||||
for (const raw of allFilePaths) {
|
||||
const norm = raw.replace(/\\/g, '/');
|
||||
if (norm.endsWith(suffixFile) || norm.endsWith(suffixPkg)) {
|
||||
matches.push({ raw, norm });
|
||||
}
|
||||
}
|
||||
if (matches.length === 0) return null;
|
||||
if (matches.length === 1) return matches[0].raw;
|
||||
matches.sort((a, b) => {
|
||||
const aDepth = a.norm.split('/').length;
|
||||
const bDepth = b.norm.split('/').length;
|
||||
if (aDepth !== bDepth) return aDepth - bDepth;
|
||||
if (a.norm < b.norm) return -1;
|
||||
if (a.norm > b.norm) return 1;
|
||||
return 0;
|
||||
});
|
||||
return matches[0].raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the repo contain a module/package named `leadingSegment` at the top
|
||||
* level? Used to guard against false-positive suffix matches on external
|
||||
* dotted imports (e.g. `django.apps` matching a local `accounts/apps.py`).
|
||||
* Does the repo contain a module/package named `leadingSegment` somewhere
|
||||
* the importer can plausibly reach?
|
||||
*
|
||||
* Checks, in order: `<segment>.py` root file, `<segment>/__init__.py`
|
||||
* regular package, or any `<segment>/**.py` file (namespace package).
|
||||
* Used to guard against false-positive suffix matches on external dotted
|
||||
* imports (e.g. `django.apps` matching a local `accounts/apps.py`).
|
||||
*
|
||||
* Checks, in order:
|
||||
* 1. `SEGMENT.py` root file or `SEGMENT/__init__.py` regular package.
|
||||
* 2. Any `SEGMENT/...py` file at the workspace root (namespace package).
|
||||
* 3. Any `<importer-ancestor>/SEGMENT/...py` file (nested namespace
|
||||
* package the importer could reach via an ancestor walk, e.g.
|
||||
* `backend/services/sync.py` from `backend/routers/cron.py`).
|
||||
*
|
||||
* The nested case is bounded to the importer's own ancestors so a
|
||||
* vendored copy of an external package (e.g. `vendor/django/urls.py`)
|
||||
* does not gate-pass external imports like `from django.urls import path`
|
||||
* issued from `app/main.py`. Files inside the vendored tree itself
|
||||
* (importer under `vendor/django/...`) still resolve correctly because
|
||||
* the ancestor walk includes their own parents.
|
||||
*/
|
||||
function hasRepoCandidate(leadingSegment: string, allFilePaths: Set<string>): boolean {
|
||||
function hasRepoCandidate(
|
||||
leadingSegment: string,
|
||||
allFilePaths: Set<string>,
|
||||
fromFile: string,
|
||||
): boolean {
|
||||
const prefix = `${leadingSegment}/`;
|
||||
const rootFile = `${leadingSegment}.py`;
|
||||
const initFile = `${leadingSegment}/__init__.py`;
|
||||
|
||||
// Build importer-ancestor prefixes: for `backend/routers/cron.py`,
|
||||
// produces `["backend/routers/services/", "backend/services/"]` for
|
||||
// segment `services` (closest first, root excluded — covered above).
|
||||
const importerDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
|
||||
const dirParts = importerDir ? importerDir.split('/').filter(Boolean) : [];
|
||||
const ancestorPrefixes: string[] = [];
|
||||
for (let i = dirParts.length; i > 0; i--) {
|
||||
ancestorPrefixes.push(`${dirParts.slice(0, i).join('/')}/${leadingSegment}/`);
|
||||
}
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (f === rootFile || f === initFile) return true;
|
||||
if (f.startsWith(prefix) && f.endsWith('.py')) return true;
|
||||
if (f.endsWith('.py')) {
|
||||
for (const ap of ancestorPrefixes) {
|
||||
if (f.startsWith(ap)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export { pythonArityCompatibility } from './arity.js';
|
|||
export { resolvePythonImportTarget, type PythonResolveContext } from './import-target.js';
|
||||
export {
|
||||
pythonBindingScopeFor,
|
||||
pythonFunctionDefinitionLabel,
|
||||
pythonImportOwningScope,
|
||||
pythonReceiverBinding,
|
||||
} from './simple-hooks.js';
|
||||
|
|
|
|||
|
|
@ -8,12 +8,30 @@
|
|||
|
||||
import type {
|
||||
CaptureMatch,
|
||||
NodeLabel,
|
||||
ParsedImport,
|
||||
Scope,
|
||||
ScopeId,
|
||||
ScopeTree,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
import type { SyntaxNode } from 'tree-sitter';
|
||||
import { findAncestorBeforeBoundary, FUNCTION_NODE_TYPES } from '../../utils/ast-helpers.js';
|
||||
|
||||
const PYTHON_METHOD_CONTAINER_TYPES: ReadonlySet<string> = new Set(['class_definition']);
|
||||
|
||||
export function pythonFunctionDefinitionLabel(
|
||||
functionNode: SyntaxNode,
|
||||
defaultLabel: NodeLabel,
|
||||
): NodeLabel {
|
||||
if (defaultLabel !== 'Function') return defaultLabel;
|
||||
const ancestor = findAncestorBeforeBoundary(
|
||||
functionNode,
|
||||
PYTHON_METHOD_CONTAINER_TYPES,
|
||||
FUNCTION_NODE_TYPES,
|
||||
);
|
||||
return ancestor === null ? 'Function' : 'Method';
|
||||
}
|
||||
|
||||
// ─── bindingScopeFor ──────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { NodeLabel } from 'gitnexus-shared';
|
|||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { rubyClassConfig } from '../class-extractors/configs/ruby.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { typeConfig as rubyConfig } from '../type-extractors/ruby.js';
|
||||
import { routeRubyCall } from '../call-routing.js';
|
||||
|
|
@ -151,6 +152,31 @@ const rubyResolveEnclosingOwner = (node: SyntaxNode): SyntaxNode | null => {
|
|||
export const rubyProvider = defineLanguage({
|
||||
id: SupportedLanguages.Ruby,
|
||||
extensions: ['.rb', '.rake', '.gemspec'],
|
||||
entryPointPatterns: [/^call$/, /^perform$/, /^execute$/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'rails',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'rails-pattern',
|
||||
patterns: [
|
||||
'ApplicationController',
|
||||
'ApplicationRecord',
|
||||
'ActiveRecord::Base',
|
||||
'before_action',
|
||||
'after_action',
|
||||
'has_many',
|
||||
'belongs_to',
|
||||
'has_one',
|
||||
'validates',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'sinatra',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'sinatra-pattern',
|
||||
patterns: ['Sinatra::Base', 'Sinatra::Application'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: RUBY_QUERIES,
|
||||
typeConfig: rubyConfig,
|
||||
exportChecker: rubyExportChecker,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
|||
import { rustImportConfig } from '../import-resolvers/configs/rust.js';
|
||||
import { extractRustNamedBindings } from '../named-bindings/rust.js';
|
||||
import { RUST_QUERIES } from '../tree-sitter-queries.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { rustConfig as rustFieldConfig } from '../field-extractors/configs/rust.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
|
|
@ -121,6 +122,41 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
|||
export const rustProvider = defineLanguage({
|
||||
id: SupportedLanguages.Rust,
|
||||
extensions: ['.rs'],
|
||||
entryPointPatterns: [/^(get|post|put|delete)_handler$/i, /^handle_/, /^new$/, /^run$/, /^spawn/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'actix-web',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'actix-attribute',
|
||||
patterns: [
|
||||
'#[get',
|
||||
'#[post',
|
||||
'#[put',
|
||||
'#[delete',
|
||||
'#[actix_web',
|
||||
'HttpRequest',
|
||||
'HttpResponse',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'axum',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'axum-routing',
|
||||
patterns: ['Router::new', 'axum::extract', 'axum::routing'],
|
||||
},
|
||||
{
|
||||
framework: 'rocket',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'rocket-attribute',
|
||||
patterns: ['#[get', '#[post', '#[launch', 'rocket::'],
|
||||
},
|
||||
{
|
||||
framework: 'tokio',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'tokio-runtime',
|
||||
patterns: ['#[tokio::main]', '#[tokio::test]'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: RUST_QUERIES,
|
||||
typeConfig: rustConfig,
|
||||
exportChecker: rustExportChecker,
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@
|
|||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { swiftClassConfig } from '../class-extractors/configs/swift.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { typeConfig as swiftConfig } from '../type-extractors/swift.js';
|
||||
import { swiftExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
|
|
@ -128,6 +129,24 @@ const swiftExtractFunctionName = (
|
|||
return null; // fall through to generic
|
||||
};
|
||||
|
||||
const orderSwiftSameNameTypeCandidates = ({
|
||||
callSiteFilePath,
|
||||
candidates,
|
||||
}: {
|
||||
readonly typeName: string;
|
||||
readonly callSiteFilePath: string;
|
||||
readonly candidates: readonly SymbolDefinition[];
|
||||
}): readonly SymbolDefinition[] | null => {
|
||||
if (!callSiteFilePath.endsWith('.swift')) return null;
|
||||
if (candidates.length <= 1) return null;
|
||||
if (!candidates.every((c) => c.type === candidates[0].type)) return null;
|
||||
if (candidates[0].type !== 'Class' && candidates[0].type !== 'Struct') return null;
|
||||
if (!candidates.every((c) => c.filePath.endsWith('.swift'))) return null;
|
||||
return [...candidates].sort(
|
||||
(a, b) => a.filePath.length - b.filePath.length || a.filePath.localeCompare(b.filePath),
|
||||
);
|
||||
};
|
||||
|
||||
const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'print',
|
||||
'debugPrint',
|
||||
|
|
@ -241,6 +260,60 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
|||
export const swiftProvider = defineLanguage({
|
||||
id: SupportedLanguages.Swift,
|
||||
extensions: ['.swift'],
|
||||
entryPointPatterns: [
|
||||
/^viewDidLoad$/,
|
||||
/^viewWillAppear$/,
|
||||
/^viewDidAppear$/,
|
||||
/^viewWillDisappear$/,
|
||||
/^viewDidDisappear$/,
|
||||
/^application\(/,
|
||||
/^scene\(/,
|
||||
/^body$/,
|
||||
/Coordinator$/,
|
||||
/^sceneDidBecomeActive$/,
|
||||
/^sceneWillResignActive$/,
|
||||
/^didFinishLaunchingWithOptions$/,
|
||||
/ViewController$/,
|
||||
/^configure[A-Z]/,
|
||||
/^setup[A-Z]/,
|
||||
/^makeBody$/,
|
||||
],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
framework: 'uikit',
|
||||
entryPointMultiplier: 2.5,
|
||||
reason: 'uikit-lifecycle',
|
||||
patterns: [
|
||||
'viewDidLoad',
|
||||
'viewWillAppear',
|
||||
'viewDidAppear',
|
||||
'UIViewController',
|
||||
'@IBOutlet',
|
||||
'@IBAction',
|
||||
'@objc',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'swiftui',
|
||||
entryPointMultiplier: 2.8,
|
||||
reason: 'swiftui-pattern',
|
||||
patterns: [
|
||||
'@main',
|
||||
'WindowGroup',
|
||||
'ContentView',
|
||||
'@StateObject',
|
||||
'@ObservedObject',
|
||||
'@EnvironmentObject',
|
||||
'@Published',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'vapor',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'vapor-routing',
|
||||
patterns: ['app.get', 'app.post', 'req.content.decode', 'Vapor'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: SWIFT_QUERIES,
|
||||
typeConfig: swiftConfig,
|
||||
exportChecker: swiftExportChecker,
|
||||
|
|
@ -257,5 +330,6 @@ export const swiftProvider = defineLanguage({
|
|||
classExtractor: createClassExtractor(swiftClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.Swift),
|
||||
implicitImportWirer: wireSwiftImplicitImports,
|
||||
orderSameNameTypeCandidates: orderSwiftSameNameTypeCandidates,
|
||||
builtInNames: BUILT_INS,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import {
|
||||
typescriptClassConfig,
|
||||
|
|
@ -44,10 +45,43 @@ import {
|
|||
javascriptCallConfig,
|
||||
} from '../call-extractors/configs/typescript-javascript.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import {
|
||||
emitTsScopeCaptures,
|
||||
interpretTsImport,
|
||||
interpretTsTypeBinding,
|
||||
tsBindingScopeFor,
|
||||
tsImportOwningScope,
|
||||
tsReceiverBinding,
|
||||
typescriptMergeBindings,
|
||||
typescriptArityCompatibility,
|
||||
resolveTsImportTarget,
|
||||
} 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,
|
||||
|
|
@ -55,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([
|
||||
|
|
@ -170,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,
|
||||
|
|
@ -185,11 +297,53 @@ export const typescriptProvider = defineLanguage({
|
|||
classExtractor: createClassExtractor(typescriptClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.TypeScript),
|
||||
builtInNames: BUILT_INS,
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
|
||||
// TypeScript is the third migration after Python and C#. See
|
||||
// ./typescript/index.ts for the full per-hook rationale and the
|
||||
// canonical capture vocabulary in ./typescript/query.ts
|
||||
// (TYPESCRIPT_SCOPE_QUERY constant).
|
||||
emitScopeCaptures: emitTsScopeCaptures,
|
||||
interpretImport: interpretTsImport,
|
||||
interpretTypeBinding: interpretTsTypeBinding,
|
||||
bindingScopeFor: tsBindingScopeFor,
|
||||
importOwningScope: tsImportOwningScope,
|
||||
// Merge precedence is decided from BindingRef origin + declaration
|
||||
// space only. The central finalizer already calls this per (scope,
|
||||
// name), so the Scope object itself intentionally does not affect
|
||||
// TypeScript declaration merging.
|
||||
mergeBindings: (_scope, bindings) => typescriptMergeBindings(bindings),
|
||||
receiverBinding: tsReceiverBinding,
|
||||
arityCompatibility: typescriptArityCompatibility,
|
||||
resolveImportTarget: resolveTsImportTarget,
|
||||
});
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Extract TypeScript arity metadata from a method-like tree-sitter node —
|
||||
* `method_definition`, `method_signature`, `abstract_method_signature`,
|
||||
* `function_declaration`, `generator_function_declaration`, or
|
||||
* `function_signature` (overload signature).
|
||||
*
|
||||
* Reuses `typescriptMethodConfig.extractParameters` so scope-extracted defs
|
||||
* carry the same arity semantics as the legacy parse-worker path:
|
||||
* - Rest parameters (`...args: T[]`) collapse `parameterCount` to
|
||||
* `undefined`, which `typescriptArityCompatibility` treats as
|
||||
* "max unknown" — the candidate stays eligible at
|
||||
* `argCount >= required` (mirrors Python `*args` / C# `params`).
|
||||
* - Optional (`p?: T`) and defaulted (`p: T = …`) parameters both
|
||||
* contribute to `optionalCount`;
|
||||
* `requiredParameterCount = total − optionalCount`.
|
||||
* - `parameterTypes` collects declared type-annotation text for
|
||||
* overload narrowing; TypeScript supports function overloading
|
||||
* (`function f(x: string); function f(x: number); function f(x) {}`),
|
||||
* so populated types let the registry disambiguate same-arity
|
||||
* siblings by declared types.
|
||||
* - A literal `'params'` marker is appended for variadic methods so
|
||||
* `typescriptArityCompatibility` can detect rest params without
|
||||
* re-reading the AST.
|
||||
*
|
||||
* ## Generics stripping
|
||||
*
|
||||
* TypeScript parameter types frequently contain generic instantiations
|
||||
* (`User<string>`, `Array<User>`, `Promise<User[]>`). For overload
|
||||
* narrowing by declared type, we want the "head" name — `User`,
|
||||
* `Array`, `Promise` — so `arity-metadata` applies a light strip to
|
||||
* each `parameterTypes[i]`:
|
||||
*
|
||||
* - `Foo<Bar>` → `Foo`
|
||||
* - `Foo<Bar, Baz>` → `Foo`
|
||||
* - `Foo[]` → `Foo`
|
||||
* - `Foo<Bar>[]` → `Foo`
|
||||
* - `Foo<Bar<Baz>>` → `Foo` (greedy — strip the outermost once)
|
||||
* - plain `Foo` → `Foo`
|
||||
*
|
||||
* We do NOT strip unions / intersections at this layer — those stay
|
||||
* intact because the registry's overload narrowing is a string
|
||||
* equality check; union types shouldn't match anything and we prefer
|
||||
* "unknown" to "accidental match". `undefined` / `null` in unions
|
||||
* (TS strict mode) is handled by `interpret.ts`'s `stripNullableUnion`
|
||||
* when the name would be consumed as a receiver type — that path is
|
||||
* separate from this arity-metadata path.
|
||||
*
|
||||
* Generic type parameters on the function itself (`function f<T>(x: T)`)
|
||||
* do NOT enter here — the method extractor reads the `parameters`
|
||||
* field only, which contains value parameters, not type parameters.
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { typescriptMethodConfig } from '../../method-extractors/configs/typescript-javascript.js';
|
||||
|
||||
interface TsArityMetadata {
|
||||
readonly parameterCount: number | undefined;
|
||||
readonly requiredParameterCount: number | undefined;
|
||||
readonly parameterTypes: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
export function computeTsArityMetadata(fnNode: SyntaxNode): TsArityMetadata {
|
||||
const params = typescriptMethodConfig.extractParameters?.(fnNode) ?? [];
|
||||
|
||||
let hasRest = false;
|
||||
let optionalCount = 0;
|
||||
const types: string[] = [];
|
||||
for (const p of params) {
|
||||
if (p.isVariadic) hasRest = true;
|
||||
else if (p.isOptional) optionalCount++;
|
||||
const t = p.type !== null && p.type !== undefined ? stripGenericsAndArraySuffix(p.type) : '';
|
||||
types.push(t);
|
||||
}
|
||||
if (hasRest) types.push('params');
|
||||
|
||||
const total = params.length;
|
||||
const parameterCount = hasRest ? undefined : total;
|
||||
const requiredParameterCount = hasRest ? undefined : total - optionalCount;
|
||||
|
||||
// Only emit parameterTypes when at least one param carries a non-
|
||||
// empty type name. An array of all empty strings adds noise to the
|
||||
// registry without aiding narrowing — callers treat absence as
|
||||
// "types unknown".
|
||||
const hasAnyType = types.some((t) => t !== '' && t !== 'params');
|
||||
const parameterTypes = hasAnyType || hasRest ? (types.length > 0 ? types : undefined) : undefined;
|
||||
|
||||
return {
|
||||
parameterCount,
|
||||
requiredParameterCount,
|
||||
parameterTypes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Light generic + array-suffix strip used only for registry overload
|
||||
* narrowing. See file-level JSDoc for the exact transformation table.
|
||||
*
|
||||
* Handles nesting greedily at the outermost level:
|
||||
* `Foo<Bar<Baz>>[]` — strip `[]` → `Foo<Bar<Baz>>`, then strip
|
||||
* outermost `<>` → `Foo`.
|
||||
*/
|
||||
function stripGenericsAndArraySuffix(raw: string): string {
|
||||
let t = raw.trim();
|
||||
// Repeatedly peel trailing `[]` pairs, then peel the outermost `<…>`
|
||||
// block once. We don't loop the `<>` peel since nesting is rare and
|
||||
// the head name is already reached after one peel.
|
||||
while (t.endsWith('[]')) t = t.slice(0, -2).trim();
|
||||
const lt = t.indexOf('<');
|
||||
if (lt > 0 && t.endsWith('>')) {
|
||||
t = t.slice(0, lt).trim();
|
||||
}
|
||||
return t;
|
||||
}
|
||||
61
gitnexus/src/core/ingestion/languages/typescript/arity.ts
Normal file
61
gitnexus/src/core/ingestion/languages/typescript/arity.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* TypeScript arity check, accommodating rest parameters and optional
|
||||
* (`p?: T`) / defaulted (`p: T = …`) parameters.
|
||||
*
|
||||
* TypeScript-specific semantics vs C#:
|
||||
*
|
||||
* - **Optional** — `p?: T` collapses to `isOptional` in the extractor
|
||||
* and contributes to `optionalCount`, so `requiredParameterCount`
|
||||
* excludes it. Same wire shape as a default-valued parameter.
|
||||
* - **Rest** — `...args: T[]` makes `parameterCount` undefined (max
|
||||
* unknown) and `parameterTypes` carries a literal `'params'` marker
|
||||
* so this hook can detect variadic calls without re-reading the AST
|
||||
* (mirrors the C# convention for cross-language consistency).
|
||||
* - **Generics** — function-level generic type parameters (`<T, U>`)
|
||||
* do NOT count toward arity; the method-extractor reads the
|
||||
* `parameters` field and ignores `type_parameters`, so generic
|
||||
* count never enters the metadata.
|
||||
*
|
||||
* The metadata shape (`parameterCount`, `requiredParameterCount`,
|
||||
* `parameterTypes`) is synthesized by `arity-metadata.ts` and stored
|
||||
* on `SymbolDefinition`. This file consumes that metadata.
|
||||
*
|
||||
* Verdicts:
|
||||
* - `'compatible'` — `requiredParameterCount <= argCount <=
|
||||
* parameterCount`, OR the def has rest params
|
||||
* (any `argCount >= required`).
|
||||
* - `'incompatible'` — argCount is below required, OR above max with
|
||||
* no rest params.
|
||||
* - `'unknown'` — metadata is absent / incomplete (treated as
|
||||
* neutral by the registry).
|
||||
*
|
||||
* `'incompatible'` is a soft signal in `Registry.lookup` (penalized
|
||||
* but still considered when no compatible candidate exists), per
|
||||
* RFC §4.
|
||||
*/
|
||||
|
||||
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
export function typescriptArityCompatibility(
|
||||
def: SymbolDefinition,
|
||||
callsite: Callsite,
|
||||
): 'compatible' | 'unknown' | 'incompatible' {
|
||||
const max = def.parameterCount;
|
||||
const min = def.requiredParameterCount;
|
||||
if (max === undefined && min === undefined) return 'unknown';
|
||||
|
||||
const argCount = callsite.arity;
|
||||
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
|
||||
|
||||
// Variadic detection: the `arity-metadata` synthesizer appends the
|
||||
// literal `'params'` marker to `parameterTypes` when the def has a
|
||||
// rest parameter, to avoid re-parsing the AST here.
|
||||
const hasRest =
|
||||
def.parameterTypes !== undefined &&
|
||||
def.parameterTypes.some((t) => t === 'params' || t.startsWith('params '));
|
||||
|
||||
if (min !== undefined && argCount < min) return 'incompatible';
|
||||
if (max !== undefined && argCount > max && !hasRest) return 'incompatible';
|
||||
|
||||
return 'compatible';
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Dev-mode counters for the TypeScript cross-phase scope-captures parse cache.
|
||||
*
|
||||
* Gated by `PROF_SCOPE_RESOLUTION=1`. In production the module-level `PROF`
|
||||
* constant is `false` and V8 folds every increment site into dead code, so the
|
||||
* hot path in `captures.ts` stays branch-free.
|
||||
*
|
||||
* Extracted from `captures.ts` so the production hot-path module doesn't carry
|
||||
* a module-global counter and its reset/export surface.
|
||||
*/
|
||||
|
||||
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
|
||||
|
||||
let CACHE_HITS = 0;
|
||||
let CACHE_MISSES = 0;
|
||||
|
||||
export function recordCacheHit(): void {
|
||||
if (PROF) CACHE_HITS++;
|
||||
}
|
||||
|
||||
export function recordCacheMiss(): void {
|
||||
if (PROF) CACHE_MISSES++;
|
||||
}
|
||||
|
||||
export function getTypescriptCaptureCacheStats(): { hits: number; misses: number } {
|
||||
return { hits: CACHE_HITS, misses: CACHE_MISSES };
|
||||
}
|
||||
|
||||
export function resetTypescriptCaptureCacheStats(): void {
|
||||
CACHE_HITS = 0;
|
||||
CACHE_MISSES = 0;
|
||||
}
|
||||
526
gitnexus/src/core/ingestion/languages/typescript/captures.ts
Normal file
526
gitnexus/src/core/ingestion/languages/typescript/captures.ts
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
/**
|
||||
* `emitScopeCaptures` for TypeScript.
|
||||
*
|
||||
* Drives the TypeScript scope query against tree-sitter-typescript and groups
|
||||
* raw matches into `CaptureMatch[]` for the central extractor. Layers
|
||||
* synthesized streams on top:
|
||||
*
|
||||
* 1. **Import decomposition** — each `import_statement` / re-export is
|
||||
* re-emitted with `@import.kind/source/name/alias/typeOnly` markers so
|
||||
* `interpretTsImport` can recover the `ParsedImport` shape without
|
||||
* re-parsing raw text (see `import-decomposer.ts`). Unit 2 adds this;
|
||||
* until then, raw `@import.statement` matches flow through as-is.
|
||||
* 2. **Dynamic imports** — `import('./m')` is re-emitted as a
|
||||
* decomposed `@import.statement` with `@import.kind=dynamic` so the
|
||||
* central extractor treats it uniformly with static imports.
|
||||
* 3. **Function-decl arity metadata** (Unit 5) — `@declaration.parameter-count`
|
||||
* / `@declaration.required-parameter-count` / `@declaration.parameter-types`
|
||||
* synthesized onto function-like declarations so the registry can narrow
|
||||
* overloads.
|
||||
* 4. **Callsite arity metadata** (Unit 5) — `@reference.arity` /
|
||||
* `@reference.parameter-types` on every callsite.
|
||||
* 5. **Receiver-binding synthesis** (Unit 3) — `this` type anchors on
|
||||
* instance methods, with arrow-function lexical-this walk-up.
|
||||
*
|
||||
* Pure given the input source text. No I/O, no globals consulted.
|
||||
*/
|
||||
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import {
|
||||
findNodeAtRange,
|
||||
nodeToCapture,
|
||||
syntheticCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { splitImportStatement } from './import-decomposer.js';
|
||||
import { getTsParser, getTsScopeQuery, tsCachedTreeMatchesGrammar } from './query.js';
|
||||
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
|
||||
import { synthesizeTsReceiverBinding } from './receiver-binding.js';
|
||||
import { computeTsArityMetadata } from './arity-metadata.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
|
||||
/** tree-sitter-typescript node types for function-like scopes that may
|
||||
* carry a synthesized `this` binding. Kept in sync with the
|
||||
* `@scope.function` patterns in `query.ts`. */
|
||||
const FUNCTION_NODE_TYPES = [
|
||||
'method_definition',
|
||||
'method_signature',
|
||||
'abstract_method_signature',
|
||||
'arrow_function',
|
||||
'function_expression',
|
||||
'function_declaration',
|
||||
'generator_function_declaration',
|
||||
'function_signature',
|
||||
] as const;
|
||||
|
||||
/** Declaration anchors that carry function-like arity metadata. */
|
||||
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.function'] as const;
|
||||
|
||||
/** Callsite anchors that should carry `@reference.arity` + param types. */
|
||||
const CALL_TAGS = [
|
||||
'@reference.call.free',
|
||||
'@reference.call.member',
|
||||
'@reference.call.constructor',
|
||||
] as const;
|
||||
|
||||
function pickFirstDefined(grouped: CaptureMatch, tags: readonly string[]): Capture | undefined {
|
||||
for (const tag of tags) {
|
||||
const cap = grouped[tag];
|
||||
if (cap !== undefined) return cap;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop `@reference.read.member` matches whose underlying `member_expression`
|
||||
* is NOT actually a read context:
|
||||
*
|
||||
* 1. The member_expression is the `function:` of a `call_expression`
|
||||
* (it's a call, already captured as `@reference.call.member`).
|
||||
* 2. The member_expression is the `constructor:` of a `new_expression`
|
||||
* (already captured as `@reference.call.constructor.qualified`).
|
||||
* 3. The member_expression is the `left:` of an `assignment_expression` /
|
||||
* `augmented_assignment_expression` (it's a write, already captured
|
||||
* 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.
|
||||
*/
|
||||
function shouldEmitReadMember(memberNode: SyntaxNode): boolean {
|
||||
const parent = memberNode.parent;
|
||||
if (parent === null) return true;
|
||||
switch (parent.type) {
|
||||
case 'call_expression':
|
||||
return parent.childForFieldName('function')?.id !== memberNode.id;
|
||||
case 'new_expression':
|
||||
return parent.childForFieldName('constructor')?.id !== memberNode.id;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export function emitTsScopeCaptures(
|
||||
sourceText: string,
|
||||
filePath: string,
|
||||
cachedTree?: unknown,
|
||||
): readonly CaptureMatch[] {
|
||||
// Skip the parse when the caller (parse phase's scopeTreeCache) already
|
||||
// produced a Tree for this source. Cache miss = re-parse, same as before.
|
||||
// The cachedTree parameter is typed as `unknown` at the LanguageProvider
|
||||
// contract layer; cast here at the use site.
|
||||
//
|
||||
// Grammar selection: `.tsx` files are parsed with the TSX grammar,
|
||||
// `.ts` files with the TypeScript grammar. The two grammars have
|
||||
// separate node-type id spaces, so a Query compiled against one
|
||||
// cannot match a Tree produced by the other. We validate the cached
|
||||
// tree's grammar against the file extension and fall back to a
|
||||
// fresh parse if they disagree (e.g. a worker-mode parse landed
|
||||
// with the wrong grammar pinned).
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getTsParser>['parse']> | undefined;
|
||||
if (tree !== undefined && !tsCachedTreeMatchesGrammar(tree, filePath)) {
|
||||
tree = undefined;
|
||||
}
|
||||
if (tree === undefined) {
|
||||
tree = getTsParser(filePath).parse(sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
recordCacheMiss();
|
||||
} else {
|
||||
recordCacheHit();
|
||||
}
|
||||
|
||||
const rawMatches = getTsScopeQuery(filePath).matches(tree.rootNode);
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (const m of rawMatches) {
|
||||
// Group captures by their tag name. Tree-sitter strips the leading
|
||||
// `@`; we put it back so the central extractor's prefix lookups
|
||||
// (`@scope.`, `@declaration.`, …) work.
|
||||
const grouped: Record<string, Capture> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
// Decompose each `import_statement` / re-export `export_statement`
|
||||
// so `interpretTsImport` sees the kind/source/name/alias markers
|
||||
// it consumes. The raw query anchor carries only @import.statement.
|
||||
// Side-effect imports emit a non-binding marker so finalize can keep
|
||||
// the file-level dependency.
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const stmtCapture = grouped['@import.statement'];
|
||||
const stmtNode =
|
||||
findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_statement') ??
|
||||
findNodeAtRange(tree.rootNode, stmtCapture.range, 'export_statement');
|
||||
if (stmtNode !== null) {
|
||||
const decomposed = splitImportStatement(stmtNode);
|
||||
for (const d of decomposed) out.push(d);
|
||||
}
|
||||
// If decomposition yielded nothing (malformed/bare anchor), drop
|
||||
// the match. Emitting a bare
|
||||
// @import.statement without kind/source would confuse the
|
||||
// central extractor.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dynamic imports — decompose via the same path. `@import.dynamic`
|
||||
// is anchored on a `call_expression`, which the decomposer's
|
||||
// `splitDynamicImport` branch consumes.
|
||||
if (grouped['@import.dynamic'] !== undefined) {
|
||||
const dynCapture = grouped['@import.dynamic'];
|
||||
const callNode = findNodeAtRange(tree.rootNode, dynCapture.range, 'call_expression');
|
||||
if (callNode !== null) {
|
||||
const decomposed = splitImportStatement(callNode);
|
||||
for (const d of decomposed) out.push(d);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter out `@reference.read.member` matches whose AST parent tells
|
||||
// us they are actually calls / writes / constructor invocations. The
|
||||
// tree-sitter pattern is context-free and matches every member_expression;
|
||||
// we rely on this emit-side filter so the query stays simple.
|
||||
if (grouped['@reference.read.member'] !== undefined) {
|
||||
const anchor = grouped['@reference.read.member'];
|
||||
const memberNode = findNodeAtRange(tree.rootNode, anchor.range, 'member_expression');
|
||||
if (memberNode === null || !shouldEmitReadMember(memberNode)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesize arity metadata on function-like declaration anchors
|
||||
// before pushing the match. The registry uses these to narrow
|
||||
// overloads — TypeScript supports overload signatures via
|
||||
// function_signature, so `parameterTypes` is populated when
|
||||
// available.
|
||||
const declAnchor = pickFirstDefined(grouped, FUNCTION_DECL_TAGS);
|
||||
if (declAnchor !== undefined) {
|
||||
const fnNode = findFunctionNode(tree.rootNode, declAnchor.range);
|
||||
if (fnNode !== null) {
|
||||
const arity = computeTsArityMetadata(fnNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
grouped['@declaration.parameter-count'] = syntheticCapture(
|
||||
'@declaration.parameter-count',
|
||||
fnNode,
|
||||
String(arity.parameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.requiredParameterCount !== undefined) {
|
||||
grouped['@declaration.required-parameter-count'] = syntheticCapture(
|
||||
'@declaration.required-parameter-count',
|
||||
fnNode,
|
||||
String(arity.requiredParameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.parameterTypes !== undefined) {
|
||||
grouped['@declaration.parameter-types'] = syntheticCapture(
|
||||
'@declaration.parameter-types',
|
||||
fnNode,
|
||||
JSON.stringify(arity.parameterTypes),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesize `@reference.arity` on every callsite so the registry's
|
||||
// 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 =
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'new_expression');
|
||||
if (callNode !== null) {
|
||||
const argList = callNode.childForFieldName('arguments');
|
||||
const args: SyntaxNode[] =
|
||||
argList === null
|
||||
? []
|
||||
: argList.namedChildren.filter(
|
||||
(c): c is SyntaxNode => c !== null && c.type !== 'comment',
|
||||
);
|
||||
grouped['@reference.arity'] = syntheticCapture(
|
||||
'@reference.arity',
|
||||
callNode,
|
||||
String(args.length),
|
||||
);
|
||||
|
||||
const argTypes = args.map((arg) => inferArgType(arg));
|
||||
grouped['@reference.parameter-types'] = syntheticCapture(
|
||||
'@reference.parameter-types',
|
||||
callNode,
|
||||
JSON.stringify(argTypes),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
out.push(grouped);
|
||||
|
||||
// Synthesize `this` receiver type-bindings on every function-like
|
||||
// scope that is structurally a class member. `receiver-binding.ts`
|
||||
// handles the walk-up (method, method_signature, abstract
|
||||
// signature, arrow/function-expression assigned to a class field).
|
||||
// Arrow functions nested inside method bodies rely on scope-chain
|
||||
// lookup instead of synthesis — covered by `tsReceiverBinding`.
|
||||
const scopeFnAnchor = grouped['@scope.function'];
|
||||
if (scopeFnAnchor !== undefined) {
|
||||
const fnNode = findFunctionNode(tree.rootNode, scopeFnAnchor.range);
|
||||
if (fnNode !== null) {
|
||||
const synth = synthesizeTsReceiverBinding(fnNode);
|
||||
if (synth !== null) out.push(synth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesize object-destructuring type bindings. The tree-sitter query
|
||||
// alone can't express "give me the field NAME and the RHS identifier
|
||||
// together" in a way that produces usable @type-binding.name /
|
||||
// @type-binding.type captures, so we walk `variable_declarator` nodes
|
||||
// whose `name:` is an `object_pattern` and synthesize per-field
|
||||
// bindings keyed to the receiver-path `rhsName.fieldName`. The
|
||||
// compound-receiver resolver's Case 3b then walks that path when the
|
||||
// destructured local is used as a receiver (e.g. `address.save()`).
|
||||
synthesizeDestructuringBindings(tree.rootNode, out);
|
||||
synthesizeForOfMapTupleBindings(tree.rootNode, out);
|
||||
synthesizeInstanceofNarrowings(tree.rootNode, out);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the AST and synthesize type-binding captures for object
|
||||
* destructuring of the form `const { field } = rhs` or
|
||||
* `const { field: alias } = rhs`. Pushes one synthetic CaptureMatch
|
||||
* per destructured identifier with:
|
||||
*
|
||||
* - `@type-binding.name` → the local identifier
|
||||
* - `@type-binding.type` → the compound path `rhs.field`
|
||||
* - `@type-binding.destructured` anchor
|
||||
*
|
||||
* Only fires when the RHS is a bare identifier — more complex RHS
|
||||
* shapes (call_expression, member_expression) resolve via the normal
|
||||
* type-alias + chain-follow paths on the RHS first, then the field
|
||||
* walk catches the destructured identifier on a second fixpoint pass.
|
||||
* Left as a follow-up optimization.
|
||||
*/
|
||||
function synthesizeDestructuringBindings(root: SyntaxNode, out: CaptureMatch[]): void {
|
||||
const stack: SyntaxNode[] = [root];
|
||||
for (;;) {
|
||||
const node = stack.pop();
|
||||
if (node === undefined) break;
|
||||
for (const child of node.namedChildren) {
|
||||
if (child !== null) stack.push(child);
|
||||
}
|
||||
if (node.type !== 'variable_declarator') continue;
|
||||
const nameNode = node.childForFieldName('name');
|
||||
const valueNode = node.childForFieldName('value');
|
||||
if (nameNode === null || valueNode === null) continue;
|
||||
if (nameNode.type !== 'object_pattern') continue;
|
||||
if (valueNode.type !== 'identifier') continue;
|
||||
const rhsName = valueNode.text;
|
||||
for (const fieldNode of nameNode.namedChildren) {
|
||||
if (fieldNode === null) continue;
|
||||
if (fieldNode.type === 'shorthand_property_identifier_pattern') {
|
||||
// `const { address } = user`
|
||||
const localName = fieldNode.text;
|
||||
out.push({
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', fieldNode, localName),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
fieldNode,
|
||||
`${rhsName}.${localName}`,
|
||||
),
|
||||
'@type-binding.destructured': syntheticCapture(
|
||||
'@type-binding.destructured',
|
||||
fieldNode,
|
||||
fieldNode.text,
|
||||
),
|
||||
});
|
||||
} else if (fieldNode.type === 'pair_pattern') {
|
||||
// `const { address: addr } = user`
|
||||
const key = fieldNode.childForFieldName('key');
|
||||
const value = fieldNode.childForFieldName('value');
|
||||
if (key === null || value === null) continue;
|
||||
if (value.type !== 'identifier') continue;
|
||||
const fieldName = key.text;
|
||||
const localName = value.text;
|
||||
out.push({
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', value, localName),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
fieldNode,
|
||||
`${rhsName}.${fieldName}`,
|
||||
),
|
||||
'@type-binding.destructured': syntheticCapture(
|
||||
'@type-binding.destructured',
|
||||
fieldNode,
|
||||
fieldNode.text,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `for (const [k, v] of mapId)` over a `Map<K,V>` — synthesize per-slot
|
||||
* type bindings so `v` resolves like a `Map` iterator tuple element.
|
||||
* Uses sentinel `__MAP_TUPLE_i__:rhs` consumed by compound-receiver.
|
||||
*/
|
||||
function synthesizeForOfMapTupleBindings(root: SyntaxNode, out: CaptureMatch[]): void {
|
||||
const stack: SyntaxNode[] = [root];
|
||||
for (;;) {
|
||||
const node = stack.pop();
|
||||
if (node === undefined) break;
|
||||
for (const child of node.namedChildren) {
|
||||
if (child !== null) stack.push(child);
|
||||
}
|
||||
if (node.type !== 'for_in_statement') continue;
|
||||
const left = node.childForFieldName('left');
|
||||
const right = node.childForFieldName('right');
|
||||
if (left === null || right === null) continue;
|
||||
if (left.type !== 'array_pattern' || right.type !== 'identifier') continue;
|
||||
const rhs = right.text;
|
||||
let slot = 0;
|
||||
for (const child of left.namedChildren) {
|
||||
if (child === null || child.type !== 'identifier') continue;
|
||||
const localName = child.text;
|
||||
out.push({
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', child, localName),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
child,
|
||||
`__MAP_TUPLE_${slot}__:${rhs}`,
|
||||
),
|
||||
'@type-binding.map-tuple-entry': syntheticCapture(
|
||||
'@type-binding.map-tuple-entry',
|
||||
child,
|
||||
String(slot),
|
||||
),
|
||||
});
|
||||
slot++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `if (x instanceof User) { x.save() }` — synthesize a `User` type binding
|
||||
* for `x` anchored in the consequence block so scope-chain lookup inside
|
||||
* the then-branch sees the narrowed type.
|
||||
*
|
||||
* **Known limitation:** the LHS must be a bare `identifier` and the RHS
|
||||
* an `identifier`/`type_identifier`. Member-expression LHS such as
|
||||
* `if (user.address instanceof Address)` is intentionally NOT synthesized
|
||||
* — narrowing a property-access target requires a stable storage key
|
||||
* the binding layer can hold, which member chains don't supply. Field-
|
||||
* type resolution covers the common case for those receivers via
|
||||
* declared types instead.
|
||||
*/
|
||||
function synthesizeInstanceofNarrowings(root: SyntaxNode, out: CaptureMatch[]): void {
|
||||
const stack: SyntaxNode[] = [root];
|
||||
for (;;) {
|
||||
const node = stack.pop();
|
||||
if (node === undefined) break;
|
||||
for (const child of node.namedChildren) {
|
||||
if (child !== null) stack.push(child);
|
||||
}
|
||||
if (node.type !== 'if_statement') continue;
|
||||
const cond = node.childForFieldName('condition');
|
||||
if (cond === null) continue;
|
||||
const inner = cond.type === 'parenthesized_expression' ? cond.namedChildren[0] : cond;
|
||||
if (inner === null || inner.type !== 'binary_expression') continue;
|
||||
const op = inner.childForFieldName('operator');
|
||||
const left = inner.childForFieldName('left');
|
||||
const right = inner.childForFieldName('right');
|
||||
if (op === null || left === null || right === null) continue;
|
||||
if (op.type !== 'instanceof') continue;
|
||||
if (left.type !== 'identifier') continue;
|
||||
if (right.type !== 'identifier' && right.type !== 'type_identifier') continue;
|
||||
const varName = left.text;
|
||||
const typeName = right.text;
|
||||
const cons = node.childForFieldName('consequence');
|
||||
if (cons === null) continue;
|
||||
out.push({
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', cons, varName),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', right, typeName),
|
||||
'@type-binding.instanceof-narrow': syntheticCapture(
|
||||
'@type-binding.instanceof-narrow',
|
||||
cons,
|
||||
'1',
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Infer a TypeScript argument expression's static type from literal
|
||||
* shapes. Returns `''` when the arg has no statically-derivable type
|
||||
* (identifiers, member accesses, etc.) — consumers treat unknown as
|
||||
* any-match during overload narrowing. */
|
||||
function inferArgType(argNode: SyntaxNode): string {
|
||||
switch (argNode.type) {
|
||||
case 'number':
|
||||
return 'number';
|
||||
case 'string':
|
||||
case 'template_string':
|
||||
return 'string';
|
||||
case 'true':
|
||||
case 'false':
|
||||
return 'boolean';
|
||||
case 'null':
|
||||
return 'null';
|
||||
case 'undefined':
|
||||
return 'undefined';
|
||||
case 'array':
|
||||
return 'Array';
|
||||
case 'object':
|
||||
return 'object';
|
||||
case 'regex':
|
||||
return 'RegExp';
|
||||
case 'new_expression': {
|
||||
const ctor = argNode.childForFieldName('constructor');
|
||||
return ctor?.text ?? '';
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the first TypeScript function-like node at the given range.
|
||||
* The `@scope.function` anchor range covers the whole node, but the
|
||||
* tag alone doesn't identify which node type among the many TS
|
||||
* function-likes. */
|
||||
function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
|
||||
for (const nodeType of FUNCTION_NODE_TYPES) {
|
||||
const n = findNodeAtRange(rootNode, range, nodeType);
|
||||
if (n !== null) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,438 @@
|
|||
/**
|
||||
* Decompose a TypeScript `import_statement` / re-export `export_statement` /
|
||||
* dynamic `call_expression(import)` into one `CaptureMatch` per imported
|
||||
* name.
|
||||
*
|
||||
* Why split here? The `LanguageProvider.interpretImport` contract is
|
||||
* one `ParsedImport` per call. Tree-sitter delivers
|
||||
*
|
||||
* import D, { X as Y, type Z } from './m'
|
||||
*
|
||||
* as a single `import_statement` match, so without decomposition we'd
|
||||
* lose names. The synthesized markers (`@import.kind` / `@import.name`
|
||||
* / `@import.alias` / `@import.source`) carry everything
|
||||
* `interpretTsImport` needs to recover the `ParsedImport` shape —
|
||||
* see `interpret.ts`.
|
||||
*
|
||||
* Kinds we emit and how `interpret.ts` maps them to `ParsedImport`:
|
||||
*
|
||||
* - `default` : `import D from './m'` → alias (importedName=default)
|
||||
* - `named` : `import { X } from './m'` → named
|
||||
* - `named-alias` : `import { X as Y } from './m'` → alias
|
||||
* - `namespace` : `import * as N from './m'` → namespace
|
||||
* - `reexport` : `export { X } from './m'` → reexport
|
||||
* - `reexport-alias` : `export { X as Y } from './m'` → reexport (with alias)
|
||||
* - `reexport-wildcard` : `export * from './m'` → wildcard
|
||||
* - `reexport-namespace` : `export * as ns from './m'` → namespace (local=ns,imported=source)
|
||||
* - `dynamic` : `import('./m')` / `import(x)` → dynamic-resolved or dynamic-unresolved
|
||||
*
|
||||
* Type-only constructs (`import type { X }`, `import { type X }`,
|
||||
* `export type { X }`) emit the same kinds as runtime forms — at the
|
||||
* TypeScript scope-resolution layer, types and values share the same
|
||||
* lookup; runtime-emission is a downstream concern.
|
||||
*
|
||||
* Side-effect imports (`import './polyfill'`) produce a single match
|
||||
* with `kind: 'side-effect'`. The shared finalize algorithm resolves
|
||||
* the target file and emits a file-level IMPORTS edge, but
|
||||
* materializes no `BindingRef` (matching the legacy DAG, which counts
|
||||
* `import './polyfill'` as a module-reachability dependency only).
|
||||
*/
|
||||
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import {
|
||||
findChild,
|
||||
nodeToCapture,
|
||||
syntheticCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
|
||||
type ImportKind =
|
||||
| 'default'
|
||||
| 'named'
|
||||
| 'named-alias'
|
||||
| 'namespace'
|
||||
| 'reexport'
|
||||
| 'reexport-alias'
|
||||
| 'reexport-wildcard'
|
||||
| 'reexport-namespace'
|
||||
| 'dynamic'
|
||||
| 'side-effect';
|
||||
|
||||
interface ImportSpec {
|
||||
readonly kind: ImportKind;
|
||||
/** Module path as written (quotes stripped): `./m`, `numpy`, `@scope/pkg`.
|
||||
* `null` only for dynamic imports whose argument isn't a string literal. */
|
||||
readonly source: string | null;
|
||||
/** Imported name from the source (or `''` when N/A, e.g. default imports
|
||||
* use `'default'`, wildcards use `'*'`). */
|
||||
readonly name: string;
|
||||
/** Local alias — only present for aliased forms. */
|
||||
readonly alias?: string;
|
||||
/** Node to anchor the synthesized captures (for range + match provenance). */
|
||||
readonly atNode: SyntaxNode;
|
||||
/** Set on `dynamic` kind imports when the argument is a string literal —
|
||||
* enables `interpretTsImport` to emit `dynamic-resolved`. */
|
||||
readonly literalSource?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompose an import anchor. Handles three node types:
|
||||
*
|
||||
* - `import_statement` : all static import forms (incl. side-effect)
|
||||
* - `export_statement` (w/ source) : re-exports
|
||||
* - `call_expression` (import fn) : dynamic `import()`
|
||||
*/
|
||||
export function splitImportStatement(stmtNode: SyntaxNode): CaptureMatch[] {
|
||||
if (stmtNode.type === 'import_statement') return splitImport(stmtNode);
|
||||
if (stmtNode.type === 'export_statement') return splitReexport(stmtNode);
|
||||
if (stmtNode.type === 'call_expression') return splitDynamicImport(stmtNode);
|
||||
return [];
|
||||
}
|
||||
|
||||
// ─── static imports ─────────────────────────────────────────────────────
|
||||
|
||||
function splitImport(stmtNode: SyntaxNode): CaptureMatch[] {
|
||||
// `import_statement` shape:
|
||||
// import_clause? "from" string (static form with bindings)
|
||||
// string (side-effect `import './m'`)
|
||||
//
|
||||
// The `source` field is the string literal — we strip its surrounding
|
||||
// quotes. An import without an `import_clause` child is side-effect
|
||||
// only and still emits one non-binding match.
|
||||
const source = extractSource(stmtNode);
|
||||
if (source === null) return [];
|
||||
|
||||
const importClause = findChild(stmtNode, 'import_clause');
|
||||
if (importClause === null) {
|
||||
// `import './polyfill'` — no clause, no local binding. Emit a
|
||||
// side-effect match so the finalize layer still produces a
|
||||
// file-level IMPORTS edge (parity with the legacy DAG).
|
||||
return [
|
||||
buildImportMatch(stmtNode, {
|
||||
kind: 'side-effect',
|
||||
source,
|
||||
name: '',
|
||||
atNode: stmtNode,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
// An import_clause can have any combination of:
|
||||
// - leading identifier (default import)
|
||||
// - namespace_import (* as N)
|
||||
// - named_imports ({ X, Y as Z })
|
||||
for (let i = 0; i < importClause.namedChildCount; i++) {
|
||||
const child = importClause.namedChild(i);
|
||||
if (child === null) continue;
|
||||
|
||||
if (child.type === 'identifier') {
|
||||
// Default import: `import D from './m'`.
|
||||
out.push(
|
||||
buildImportMatch(stmtNode, {
|
||||
kind: 'default',
|
||||
source,
|
||||
name: 'default',
|
||||
alias: child.text,
|
||||
atNode: child,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.type === 'namespace_import') {
|
||||
// `* as N` — the identifier child is the local binding.
|
||||
const aliasId = findChild(child, 'identifier');
|
||||
if (aliasId !== null) {
|
||||
out.push(
|
||||
buildImportMatch(stmtNode, {
|
||||
kind: 'namespace',
|
||||
source,
|
||||
name: source,
|
||||
alias: aliasId.text,
|
||||
atNode: child,
|
||||
}),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.type === 'named_imports') {
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const spec = child.namedChild(j);
|
||||
if (spec === null || spec.type !== 'import_specifier') continue;
|
||||
const decomposed = decomposeNamedSpecifier(spec, source, stmtNode);
|
||||
if (decomposed !== null) out.push(decomposed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Other children (e.g. `type` keyword token for `import type { ... }`)
|
||||
// are ignored — they carry no per-specifier info; we fold type-only
|
||||
// semantics into the same emitted kinds.
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompose a single `import_specifier` into one match. Handles:
|
||||
*
|
||||
* - `{ X }` → named
|
||||
* - `{ X as Y }` → named-alias
|
||||
* - `{ type X }` → named (type-only; same shape)
|
||||
* - `{ type X as Y }` → named-alias (type-only)
|
||||
*/
|
||||
function decomposeNamedSpecifier(
|
||||
spec: SyntaxNode,
|
||||
source: string,
|
||||
stmtNode: SyntaxNode,
|
||||
): CaptureMatch | null {
|
||||
// `import_specifier` layout:
|
||||
// name: identifier
|
||||
// alias: identifier? (only when `as` is present)
|
||||
// plus an optional `type` keyword token in front (per-specifier type-only)
|
||||
//
|
||||
// tree-sitter-typescript exposes `name` and `alias` as named fields.
|
||||
// If `name` is absent, fail closed rather than guessing positionally:
|
||||
// binding the alias as the imported name would invert the edge.
|
||||
const nameNode = spec.childForFieldName('name');
|
||||
const aliasNode = spec.childForFieldName('alias');
|
||||
if (nameNode === null) return null;
|
||||
const name = nameNode.text;
|
||||
|
||||
if (aliasNode !== null && aliasNode.startIndex !== nameNode.startIndex) {
|
||||
return buildImportMatch(stmtNode, {
|
||||
kind: 'named-alias',
|
||||
source,
|
||||
name,
|
||||
alias: aliasNode.text,
|
||||
atNode: spec,
|
||||
});
|
||||
}
|
||||
return buildImportMatch(stmtNode, {
|
||||
kind: 'named',
|
||||
source,
|
||||
name,
|
||||
atNode: spec,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── re-exports ──────────────────────────────────────────────────────────
|
||||
|
||||
function splitReexport(stmtNode: SyntaxNode): CaptureMatch[] {
|
||||
// `export_statement` with a `source:` field is a re-export. Forms:
|
||||
//
|
||||
// export { X, Y as Z } from './m' → export_clause children
|
||||
// export * from './m' → no clause
|
||||
// export * as ns from './m' → namespace_export child
|
||||
// export type { X } from './m' → same clause path
|
||||
//
|
||||
// Local `export { X }` (no `from`) is visibility metadata, not an
|
||||
// import; the captures-layer query guards with a `source: (string)`
|
||||
// predicate so we always have a source here — but we defend
|
||||
// structurally anyway.
|
||||
const source = extractSource(stmtNode);
|
||||
if (source === null) return [];
|
||||
|
||||
const exportClause = findChild(stmtNode, 'export_clause');
|
||||
if (exportClause !== null) {
|
||||
const out: CaptureMatch[] = [];
|
||||
for (let i = 0; i < exportClause.namedChildCount; i++) {
|
||||
const spec = exportClause.namedChild(i);
|
||||
if (spec === null || spec.type !== 'export_specifier') continue;
|
||||
const decomposed = decomposeReexportSpecifier(spec, source, stmtNode);
|
||||
if (decomposed !== null) out.push(decomposed);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// `export * as ns from './m'` — tree-sitter-typescript emits a
|
||||
// `namespace_export` child whose identifier is the local re-export
|
||||
// name. Two facts are emitted:
|
||||
//
|
||||
// 1. An `@import.statement` (kind `reexport-namespace`) so finalize
|
||||
// knows the barrel imports `./m` as `ns` (binds `ns` locally
|
||||
// inside the barrel for consumers like `barrel.ts` calling
|
||||
// `ns.X()`).
|
||||
// 2. A synthetic `@declaration.namespace` so the central
|
||||
// scope-extractor adds a `Namespace` SymbolDefinition for `ns`
|
||||
// to the barrel's `localDefs`. Without this, downstream files
|
||||
// doing `import { ns } from './barrel'` cannot resolve `ns`:
|
||||
// `findExportByName` and the precomputed re-export closure only
|
||||
// consult `localDefs` / `reexport` / `wildcard` drafts, never
|
||||
// `namespace`-kind imports. The synthetic declaration fixes that
|
||||
// without growing the shared finalizer's surface.
|
||||
const namespaceExport = findChild(stmtNode, 'namespace_export');
|
||||
if (namespaceExport !== null) {
|
||||
const aliasId = findChild(namespaceExport, 'identifier');
|
||||
if (aliasId !== null) {
|
||||
return [
|
||||
buildImportMatch(stmtNode, {
|
||||
kind: 'reexport-namespace',
|
||||
source,
|
||||
name: source,
|
||||
alias: aliasId.text,
|
||||
atNode: namespaceExport,
|
||||
}),
|
||||
buildNamespaceDeclarationMatch(namespaceExport, aliasId),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// `export * from './m'` — no clause, no namespace_export. The bare
|
||||
// `*` token is the only remaining marker; we don't need to inspect
|
||||
// it since the shape alone says "wildcard".
|
||||
return [
|
||||
buildImportMatch(stmtNode, {
|
||||
kind: 'reexport-wildcard',
|
||||
source,
|
||||
name: '*',
|
||||
atNode: stmtNode,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function decomposeReexportSpecifier(
|
||||
spec: SyntaxNode,
|
||||
source: string,
|
||||
stmtNode: SyntaxNode,
|
||||
): CaptureMatch | null {
|
||||
const nameNode = spec.childForFieldName('name');
|
||||
const aliasNode = spec.childForFieldName('alias');
|
||||
if (nameNode === null) return null;
|
||||
const name = nameNode.text;
|
||||
|
||||
if (aliasNode !== null && aliasNode.startIndex !== nameNode.startIndex) {
|
||||
return buildImportMatch(stmtNode, {
|
||||
kind: 'reexport-alias',
|
||||
source,
|
||||
name,
|
||||
alias: aliasNode.text,
|
||||
atNode: spec,
|
||||
});
|
||||
}
|
||||
return buildImportMatch(stmtNode, {
|
||||
kind: 'reexport',
|
||||
source,
|
||||
name,
|
||||
atNode: spec,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── dynamic imports ─────────────────────────────────────────────────────
|
||||
|
||||
function splitDynamicImport(callNode: SyntaxNode): CaptureMatch[] {
|
||||
// `call_expression` shape for dynamic imports:
|
||||
// function: (import) — named leaf node in tree-sitter-typescript
|
||||
// arguments: (arguments (string) ...) — first arg is the path
|
||||
//
|
||||
// When the argument is a string literal, preserve its value. When it's
|
||||
// anything else (variable, template literal, member access), surface
|
||||
// the raw text for diagnostics and let `interpretTsImport` emit
|
||||
// `dynamic-unresolved` with a `targetRaw` hint.
|
||||
const args = callNode.childForFieldName('arguments');
|
||||
if (args === null) {
|
||||
return [
|
||||
buildImportMatch(callNode, {
|
||||
kind: 'dynamic',
|
||||
source: null,
|
||||
name: '',
|
||||
atNode: callNode,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
const firstArg = args.namedChild(0);
|
||||
if (firstArg === null) {
|
||||
return [
|
||||
buildImportMatch(callNode, {
|
||||
kind: 'dynamic',
|
||||
source: null,
|
||||
name: '',
|
||||
atNode: callNode,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
if (firstArg.type === 'string') {
|
||||
const source = stripQuotes(firstArg.text);
|
||||
return [
|
||||
buildImportMatch(callNode, {
|
||||
kind: 'dynamic',
|
||||
source,
|
||||
name: '',
|
||||
atNode: callNode,
|
||||
literalSource: true,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// Non-literal argument — preserve source text so downstream
|
||||
// diagnostics show what the user wrote.
|
||||
return [
|
||||
buildImportMatch(callNode, {
|
||||
kind: 'dynamic',
|
||||
source: firstArg.text,
|
||||
name: '',
|
||||
atNode: callNode,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function extractSource(stmtNode: SyntaxNode): string | null {
|
||||
// Both `import_statement` and `export_statement` expose the module
|
||||
// path through the `source:` field. It's typed as `string` in the
|
||||
// grammar; we strip its surrounding quotes.
|
||||
const sourceField = stmtNode.childForFieldName('source');
|
||||
if (sourceField === null || sourceField.type !== 'string') return null;
|
||||
return stripQuotes(sourceField.text);
|
||||
}
|
||||
|
||||
function stripQuotes(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length < 2) return trimmed;
|
||||
const first = trimmed.charAt(0);
|
||||
const last = trimmed.charAt(trimmed.length - 1);
|
||||
if (
|
||||
(first === '"' && last === '"') ||
|
||||
(first === "'" && last === "'") ||
|
||||
(first === '`' && last === '`')
|
||||
) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch {
|
||||
const m: Record<string, Capture> = {
|
||||
'@import.statement': nodeToCapture('@import.statement', stmtNode),
|
||||
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
|
||||
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
|
||||
};
|
||||
if (spec.source !== null) {
|
||||
m['@import.source'] = syntheticCapture('@import.source', spec.atNode, spec.source);
|
||||
}
|
||||
if (spec.alias !== undefined) {
|
||||
m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias);
|
||||
}
|
||||
if (spec.literalSource === true) {
|
||||
m['@import.literal'] = syntheticCapture('@import.literal', spec.atNode, '');
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Synthesize a `@declaration.namespace` match for `export * as ns from './m'`.
|
||||
* The central scope-extractor turns this into a `SymbolDefinition` of type
|
||||
* `Namespace` in the barrel's `localDefs`, which makes `findExportByName`
|
||||
* resolve `ns` for downstream `import { ns } from './barrel'` consumers. */
|
||||
function buildNamespaceDeclarationMatch(
|
||||
namespaceExportNode: SyntaxNode,
|
||||
aliasId: SyntaxNode,
|
||||
): CaptureMatch {
|
||||
return {
|
||||
'@declaration.namespace': nodeToCapture('@declaration.namespace', namespaceExportNode),
|
||||
'@declaration.name': nodeToCapture('@declaration.name', aliasId),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path.
|
||||
*
|
||||
* Delegates to the existing standard-strategy resolver
|
||||
* (`resolveImportPath`) so tsconfig path aliases (`@/`, `~/`, …) and
|
||||
* suffix-based resolution follow the same rules as the legacy path.
|
||||
*
|
||||
* The `WorkspaceIndex` is opaque at the shared contract layer; we
|
||||
* narrow it to a TypeScript-shaped context that carries `fromFile` +
|
||||
* the full `allFilePaths` set + the optional `tsconfigPaths` the
|
||||
* resolver reads.
|
||||
*
|
||||
* Returning `null` lets the finalize algorithm mark the edge as
|
||||
* `linkStatus: 'unresolved'`.
|
||||
*/
|
||||
|
||||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { resolveImportPath } from '../../import-resolvers/standard.js';
|
||||
import type { TsconfigPaths } from '../../language-config.js';
|
||||
|
||||
export interface TsResolveContext {
|
||||
readonly fromFile: string;
|
||||
/** Mutable `Set` because the standard resolver consumes `Set<string>`.
|
||||
* Callers holding a `ReadonlySet` should copy via `new Set(...)`. */
|
||||
readonly allFilePaths: Set<string>;
|
||||
/** Repo file list, normalized (lowercased) for suffix matching. May
|
||||
* be supplied by the orchestrator; if absent we derive it on the
|
||||
* fly from `allFilePaths`. */
|
||||
readonly allFileList?: readonly string[];
|
||||
readonly normalizedFileList?: readonly string[];
|
||||
/** Per-call resolution cache to dedupe repeated lookups. */
|
||||
readonly resolveCache?: Map<string, string | null>;
|
||||
/** Parsed tsconfig path-aliases. `null` = no aliases configured. */
|
||||
readonly tsconfigPaths?: TsconfigPaths | null;
|
||||
/** JavaScript vs TypeScript switch — affects the extensions the
|
||||
* resolver tries. Defaults to TypeScript. */
|
||||
readonly language?: SupportedLanguages.TypeScript | SupportedLanguages.JavaScript;
|
||||
}
|
||||
|
||||
export function resolveTsImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null {
|
||||
const ctx = narrowTsContext(workspaceIndex);
|
||||
if (ctx === null) return null;
|
||||
|
||||
// Dynamic imports carry `targetRaw` only for diagnostics; when the
|
||||
// expression isn't a string literal we can't resolve a file.
|
||||
// A string-literal dynamic import (`import('./m')`) resolves like a
|
||||
// static import — fall through to the shared path resolver.
|
||||
if (parsedImport.kind === 'dynamic-unresolved' && parsedImport.targetRaw === null) return null;
|
||||
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
|
||||
|
||||
return resolveTsTarget(parsedImport.targetRaw, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a raw module-path string to a workspace file path using the
|
||||
* same standard-strategy resolver as the legacy DAG. Operates directly on
|
||||
* the source string without requiring a `ParsedImport`, so the
|
||||
* `ScopeResolver.resolveImportTarget` adapter doesn't need to construct
|
||||
* a fake `ParsedImport` to reach the resolver.
|
||||
*
|
||||
* Returns `null` when:
|
||||
* - the context is malformed (missing `fromFile` / `allFilePaths`)
|
||||
* - `targetRaw` is empty
|
||||
* - the resolver finds no matching file
|
||||
*/
|
||||
export function resolveTsTarget(targetRaw: string, ctx: TsResolveContext): string | null {
|
||||
if (targetRaw === '') return null;
|
||||
|
||||
const language = ctx.language ?? SupportedLanguages.TypeScript;
|
||||
const allFileList = ctx.allFileList ?? Array.from(ctx.allFilePaths);
|
||||
const normalizedFileList = ctx.normalizedFileList ?? allFileList.map((f) => f.toLowerCase());
|
||||
const resolveCache = ctx.resolveCache ?? new Map<string, string | null>();
|
||||
|
||||
return resolveImportPath(
|
||||
ctx.fromFile,
|
||||
targetRaw,
|
||||
ctx.allFilePaths,
|
||||
allFileList as string[],
|
||||
normalizedFileList as string[],
|
||||
resolveCache,
|
||||
language,
|
||||
ctx.tsconfigPaths ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
function narrowTsContext(workspaceIndex: WorkspaceIndex): TsResolveContext | null {
|
||||
const ctx = workspaceIndex as TsResolveContext | undefined;
|
||||
if (
|
||||
ctx === undefined ||
|
||||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
|
||||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
95
gitnexus/src/core/ingestion/languages/typescript/index.ts
Normal file
95
gitnexus/src/core/ingestion/languages/typescript/index.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* TypeScript scope-resolution hooks (RFC #909 Ring 3, RFC §5).
|
||||
*
|
||||
* Public API barrel. Consumers should import from this file rather
|
||||
* than the individual modules.
|
||||
*
|
||||
* Module layout (each file is a single concern):
|
||||
*
|
||||
* - `query.ts` — tree-sitter query + lazy parser/query singletons
|
||||
* - `captures.ts` — `emitTsScopeCaptures` orchestrator
|
||||
* - `import-decomposer.ts` — each import/re-export/dynamic-import →
|
||||
* ParsedImport-shaped captures
|
||||
* - `interpret.ts` — capture-match → `ParsedImport` /
|
||||
* `ParsedTypeBinding`
|
||||
* - `simple-hooks.ts` — `bindingScopeFor` (var hoisting + return-
|
||||
* type hoisting), `importOwningScope`
|
||||
* (module/namespace default), `receiverBinding`
|
||||
* (`this` lookup on Function scope)
|
||||
* - `receiver-binding.ts` — synthesize `this` type-bindings on
|
||||
* instance-method entry (methods, interface
|
||||
* signatures, class-field arrow functions)
|
||||
* - `merge-bindings.ts` — TypeScript declaration merging
|
||||
* (value / type / namespace spaces) + LEGB
|
||||
* tier shadowing
|
||||
* - `arity.ts` — TypeScript arity compatibility (rest,
|
||||
* optional, default params)
|
||||
* - `arity-metadata.ts` — synthesize arity metadata from
|
||||
* declarations; includes generics + array-
|
||||
* suffix stripping on parameter types
|
||||
* - `import-target.ts` — `(ParsedImport, WorkspaceIndex) → file path`
|
||||
* adapter delegating to the shared standard
|
||||
* resolver (tsconfig paths, node_modules,
|
||||
* relative/extension suffix matching)
|
||||
* - `cache-stats.ts` — PROF_SCOPE_RESOLUTION cache hit/miss
|
||||
* counters
|
||||
*
|
||||
* ## Known limitations
|
||||
*
|
||||
* The TypeScript registry-primary path intentionally does NOT resolve
|
||||
* the following. Each is a conscious trade-off at migration time.
|
||||
*
|
||||
* 1. **Type-only import / export separation** — `import type { X }`
|
||||
* and `import { X }` produce the same `ParsedImport` shape today;
|
||||
* `def.type` on the resolved symbol is the only discriminator.
|
||||
* Parity with the legacy path is preserved. Tracking in #927.
|
||||
* 2. **Declaration merging for imports** — when `import { Foo }`
|
||||
* brings in a symbol that is BOTH a class and a namespace in the
|
||||
* source module, we currently surface a single binding per the
|
||||
* target `def.type`. Downstream type/value-space lookups still
|
||||
* work for the primary space; the other space's members resolve
|
||||
* via the same target (class statics reachable via dotted access).
|
||||
* 3. **Overload narrowing by argument type** — `@reference.parameter-
|
||||
* types` carries static literal types inferred from the callsite
|
||||
* (`string`, `number`, `Array`, etc.). Identifier / member-access
|
||||
* arguments emit empty strings (unknown type); the registry's
|
||||
* narrowing treats them as any-match. Full control-flow type
|
||||
* narrowing is out of scope.
|
||||
* 4. **Computed member access** — `obj[key]()` / `obj['method']()`
|
||||
* is classified as an index-access call; member-call resolution
|
||||
* falls back to the identifier-indexed branch and matches only
|
||||
* when the key is a string literal.
|
||||
* 5. **`this` for nested regular functions inside methods** — our
|
||||
* scope-chain lookup returns the enclosing method's `this`, which
|
||||
* is technically incorrect at runtime (a non-arrow nested function
|
||||
* has its own `this` binding). Accepted false-positive; see
|
||||
* `simple-hooks.ts` docstring.
|
||||
* 6. **`class_expression` receiver types** — `const C = class { }`
|
||||
* skips `this` synthesis when the expression is anonymous (no
|
||||
* type name to propagate). `const C = class Named { }` works via
|
||||
* the class's own `name` field.
|
||||
* 7. **JSX element types** — JSX-specific constructs are ignored by
|
||||
* the scope query; component references resolve via regular
|
||||
* identifier / member-expression paths.
|
||||
* 8. **Ambient module declarations** (`declare module '…'`) — parsed
|
||||
* but not indexed at this layer; same as today's legacy path.
|
||||
* 9. **Intersection types on parameters** (`(a: A & B)`) — treated
|
||||
* as opaque (no strip); overload narrowing on intersections
|
||||
* won't match.
|
||||
* 10. **`instanceof` member-expression narrowing** — only bare
|
||||
* identifiers are narrowed (`user instanceof User`). Member paths
|
||||
* such as `user.address instanceof Address` remain unresolved.
|
||||
*
|
||||
* Shadow-harness corpus parity on `test/integration/resolvers/
|
||||
* typescript.test.ts` is the authoritative signal for which of these
|
||||
* matter in practice. The CI parity gate blocks any PR that regresses
|
||||
* either the legacy or registry-primary run.
|
||||
*/
|
||||
|
||||
export { emitTsScopeCaptures } from './captures.js';
|
||||
export { getTypescriptCaptureCacheStats, resetTypescriptCaptureCacheStats } from './cache-stats.js';
|
||||
export { interpretTsImport, interpretTsTypeBinding } from './interpret.js';
|
||||
export { typescriptMergeBindings } from './merge-bindings.js';
|
||||
export { typescriptArityCompatibility } from './arity.js';
|
||||
export { resolveTsImportTarget, resolveTsTarget, type TsResolveContext } from './import-target.js';
|
||||
export { tsBindingScopeFor, tsImportOwningScope, tsReceiverBinding } from './simple-hooks.js';
|
||||
312
gitnexus/src/core/ingestion/languages/typescript/interpret.ts
Normal file
312
gitnexus/src/core/ingestion/languages/typescript/interpret.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
/**
|
||||
* Capture-match → semantic-shape interpreters for TypeScript.
|
||||
*
|
||||
* Two pure functions, both consumed by the central scope extractor:
|
||||
*
|
||||
* - `interpretTsImport` → `ParsedImport`
|
||||
* - `interpretTsTypeBinding` → `ParsedTypeBinding` (wired in Unit 6)
|
||||
*
|
||||
* The import matches arrive pre-decomposed by `emitTsScopeCaptures`
|
||||
* (one imported name per match, with synthesized
|
||||
* `@import.kind/source/name/alias` markers — see `import-decomposer.ts`).
|
||||
* The type-binding matches arrive straight from the raw query captures —
|
||||
* each `@type-binding.*` anchor carries `@type-binding.name` +
|
||||
* `@type-binding.type`.
|
||||
*/
|
||||
|
||||
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
|
||||
|
||||
// ─── interpretImport ──────────────────────────────────────────────────────
|
||||
|
||||
export function interpretTsImport(captures: CaptureMatch): ParsedImport | null {
|
||||
// Markers attached by `splitImportStatement` (import-decomposer.ts):
|
||||
// @import.kind : one of the kinds documented there
|
||||
// @import.name : imported name from the source module
|
||||
// @import.alias : local alias name (for default / aliased / namespace forms)
|
||||
// @import.source : module path (always present except dynamic-unresolved)
|
||||
const kindCap = captures['@import.kind'];
|
||||
const nameCap = captures['@import.name'];
|
||||
const aliasCap = captures['@import.alias'];
|
||||
const sourceCap = captures['@import.source'];
|
||||
|
||||
const kind = kindCap?.text;
|
||||
if (kind === undefined) return null;
|
||||
|
||||
switch (kind) {
|
||||
case 'default': {
|
||||
// `import D from './m'` — semantically "alias for the module's
|
||||
// default export". We map to ParsedImport `alias` with
|
||||
// importedName='default' so the finalize algorithm looks up the
|
||||
// target module's `default` export for cross-file resolution.
|
||||
if (sourceCap === undefined || aliasCap === undefined) return null;
|
||||
return {
|
||||
kind: 'alias',
|
||||
localName: aliasCap.text,
|
||||
importedName: 'default',
|
||||
alias: aliasCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
};
|
||||
}
|
||||
case 'named': {
|
||||
// `import { X } from './m'` (plus type-only forms).
|
||||
if (sourceCap === undefined || nameCap === undefined) return null;
|
||||
return {
|
||||
kind: 'named',
|
||||
localName: nameCap.text,
|
||||
importedName: nameCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
};
|
||||
}
|
||||
case 'named-alias': {
|
||||
// `import { X as Y } from './m'`.
|
||||
if (sourceCap === undefined || nameCap === undefined || aliasCap === undefined) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: 'alias',
|
||||
localName: aliasCap.text,
|
||||
importedName: nameCap.text,
|
||||
alias: aliasCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
};
|
||||
}
|
||||
case 'namespace': {
|
||||
// `import * as N from './m'` — `N` binds the whole module.
|
||||
if (sourceCap === undefined || aliasCap === undefined) return null;
|
||||
return {
|
||||
kind: 'namespace',
|
||||
localName: aliasCap.text,
|
||||
importedName: sourceCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
};
|
||||
}
|
||||
case 'reexport': {
|
||||
// `export { X } from './m'`.
|
||||
if (sourceCap === undefined || nameCap === undefined) return null;
|
||||
return {
|
||||
kind: 'reexport',
|
||||
localName: nameCap.text,
|
||||
importedName: nameCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
};
|
||||
}
|
||||
case 'reexport-alias': {
|
||||
// `export { X as Y } from './m'`.
|
||||
if (sourceCap === undefined || nameCap === undefined || aliasCap === undefined) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: 'reexport',
|
||||
localName: aliasCap.text,
|
||||
importedName: nameCap.text,
|
||||
alias: aliasCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
};
|
||||
}
|
||||
case 'reexport-wildcard': {
|
||||
// `export * from './m'` — no local name, just a blanket passthrough.
|
||||
if (sourceCap === undefined) return null;
|
||||
return { kind: 'wildcard', targetRaw: sourceCap.text };
|
||||
}
|
||||
case 'reexport-namespace': {
|
||||
// `export * as ns from './m'` — creates a local binding `ns`
|
||||
// that exposes the whole module, while also re-exporting it.
|
||||
// Closest ParsedImport fit is `namespace`; the re-export side
|
||||
// of this edge is tracked by the export detector downstream.
|
||||
if (sourceCap === undefined || aliasCap === undefined) return null;
|
||||
return {
|
||||
kind: 'namespace',
|
||||
localName: aliasCap.text,
|
||||
importedName: sourceCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
};
|
||||
}
|
||||
case 'dynamic': {
|
||||
// `import('./m')` / `import(x)`. The decomposer marks literal-
|
||||
// string arguments with `@import.literal` so we can promote them
|
||||
// to `dynamic-resolved` here — that lets the shared finalizer
|
||||
// produce a file-level IMPORTS edge for lazy-loaded modules.
|
||||
// Non-literal arguments stay `dynamic-unresolved` (target is
|
||||
// runtime-computed and unreachable to the static finalizer).
|
||||
const isLiteral = captures['@import.literal'] !== undefined;
|
||||
if (isLiteral && sourceCap !== undefined) {
|
||||
return { kind: 'dynamic-resolved', targetRaw: sourceCap.text };
|
||||
}
|
||||
return {
|
||||
kind: 'dynamic-unresolved',
|
||||
localName: '',
|
||||
targetRaw: sourceCap?.text ?? null,
|
||||
};
|
||||
}
|
||||
case 'side-effect': {
|
||||
// `import './polyfill'` — bare-source, no local binding. The
|
||||
// finalize layer resolves to a target file and emits a
|
||||
// file-level IMPORTS edge; no `BindingRef` is materialized.
|
||||
if (sourceCap === undefined) return null;
|
||||
return { kind: 'side-effect', targetRaw: sourceCap.text };
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── interpretTypeBinding ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Interpret a `@type-binding.*` capture-match into a `ParsedTypeBinding`.
|
||||
*
|
||||
* TypeScript-specific strips:
|
||||
*
|
||||
* - Trailing `?` on optional parameters: `(u?: User)` → `User`
|
||||
* - `Promise<User>` / `Array<User>` / `ReadonlyArray<User>` / `Readonly<User>`
|
||||
* → `User` (wrappers that are transparent to chain propagation)
|
||||
* - Single-arg `List<User>` / `Iterable<User>` / `Iterator<User>` —
|
||||
* mirrors Python/C#'s generic-collection strip for for-of loops
|
||||
* - Trailing `[]` on array types: `User[]` → `User`
|
||||
* - Nullable unions: `User | null` / `User | undefined` / `null | User`
|
||||
* → `User`
|
||||
* - Dotted qualifiers: `models.User` → `User` (unless the suffix is
|
||||
* a known collection accessor we'd want to preserve — none apply
|
||||
* to TS today, since TS uses `.values()` / `.keys()` call syntax)
|
||||
*/
|
||||
export function interpretTsTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
|
||||
const nameCap = captures['@type-binding.name'];
|
||||
const typeCap = captures['@type-binding.type'];
|
||||
if (nameCap === undefined || typeCap === undefined) return null;
|
||||
|
||||
// Readonly/array/nullable wrappers can stack; apply passes until a
|
||||
// fixed point (bounded by the text length since every strip monotonically
|
||||
// shrinks the string).
|
||||
let prev = '';
|
||||
let rawType = typeCap.text.trim();
|
||||
while (prev !== rawType) {
|
||||
prev = rawType;
|
||||
rawType = stripReadonly(rawType);
|
||||
rawType = stripNullableUnion(rawType);
|
||||
rawType = stripGeneric(rawType);
|
||||
rawType = stripArraySuffix(rawType);
|
||||
}
|
||||
// Destructuring / member-alias / map-tuple / dotted-call-alias bindings
|
||||
// carry receiver paths or sentinel strings that must survive verbatim.
|
||||
// Also preserve dotted member-call callee text (`svc.getUser`) for
|
||||
// `@type-binding.alias` — stripQualifier would reduce it to `getUser`,
|
||||
// breaking compound-receiver's `obj.method()` split.
|
||||
const isDestructured = captures['@type-binding.destructured'] !== undefined;
|
||||
const isMemberAlias = captures['@type-binding.member-alias'] !== undefined;
|
||||
const isMapTupleEntry = captures['@type-binding.map-tuple-entry'] !== undefined;
|
||||
const isInstanceofNarrow = captures['@type-binding.instanceof-narrow'] !== undefined;
|
||||
const isAlias = captures['@type-binding.alias'] !== undefined;
|
||||
const preserveRawTypeName =
|
||||
isDestructured ||
|
||||
isMemberAlias ||
|
||||
isMapTupleEntry ||
|
||||
isInstanceofNarrow ||
|
||||
(isAlias && rawType.includes('.'));
|
||||
if (!preserveRawTypeName) {
|
||||
rawType = stripQualifier(rawType);
|
||||
}
|
||||
|
||||
// Drop non-discriminating / wildcard types — `as any` / `as unknown`
|
||||
// should not block a more-informative sibling binding (typically the
|
||||
// constructor-inferred capture from the inner `new_expression`). By
|
||||
// returning null here we let the scope-extractor's tie-break select
|
||||
// the next-best binding for the same name.
|
||||
if (UNINFORMATIVE_TYPES.has(rawType)) return null;
|
||||
|
||||
// Anchor captures distinguish the source of the binding. Order
|
||||
// matters: more-specific anchors take precedence. `this` is a
|
||||
// TypeScript-specific receiver synthesized in `receiver-binding.ts`
|
||||
// (Unit 3); treat it as `self` for Registry.lookup parity with
|
||||
// Python/C#.
|
||||
let source: TypeRef['source'] = 'parameter-annotation';
|
||||
if (captures['@type-binding.this'] !== undefined) source = 'self';
|
||||
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
|
||||
else if (captures['@type-binding.assertion'] !== undefined) source = 'annotation';
|
||||
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
|
||||
else if (captures['@type-binding.member-alias'] !== undefined) source = 'assignment-inferred';
|
||||
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
|
||||
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
|
||||
else if (captures['@type-binding.parameter-property'] !== undefined) source = 'annotation';
|
||||
else if (captures['@type-binding.destructured'] !== undefined) source = 'assignment-inferred';
|
||||
else if (captures['@type-binding.map-tuple-entry'] !== undefined) source = 'assignment-inferred';
|
||||
else if (captures['@type-binding.instanceof-narrow'] !== undefined) source = 'annotation';
|
||||
|
||||
return { boundName: nameCap.text, rawTypeName: rawType, source };
|
||||
}
|
||||
|
||||
/** Types that carry no discriminating information for chain resolution.
|
||||
* `any` / `unknown` / `object` / `never` / `void` match anything, so a
|
||||
* sibling capture (e.g. a constructor-inferred type from `new X() as any`)
|
||||
* is strictly preferable. Empty string emerges from malformed captures
|
||||
* and is also useless. `null` / `undefined` shouldn't survive
|
||||
* stripNullableUnion but are listed here for defense-in-depth. */
|
||||
const UNINFORMATIVE_TYPES: ReadonlySet<string> = new Set([
|
||||
'',
|
||||
'any',
|
||||
'unknown',
|
||||
'object',
|
||||
'never',
|
||||
'void',
|
||||
'null',
|
||||
'undefined',
|
||||
]);
|
||||
|
||||
/** `readonly User[]` → `User[]`. Applied before stripArraySuffix so
|
||||
* `readonly User[]` reduces through the same pipeline. */
|
||||
function stripReadonly(text: string): string {
|
||||
if (text.startsWith('readonly ')) return text.slice('readonly '.length).trim();
|
||||
return text;
|
||||
}
|
||||
|
||||
/** `User | null` / `User | undefined` / `null | User | undefined` → `User`.
|
||||
* Any number of `null` / `undefined` arms may appear; collapse to the
|
||||
* single remaining discriminating arm. Preserves multi-arm unions
|
||||
* of real types (`User | Admin`) since the concrete receiver type is
|
||||
* ambiguous. */
|
||||
function stripNullableUnion(text: string): string {
|
||||
const parts = text.split('|').map((p) => p.trim());
|
||||
if (parts.length < 2) return text;
|
||||
const NULLS = new Set(['null', 'undefined']);
|
||||
const nonNull = parts.filter((p) => !NULLS.has(p));
|
||||
if (nonNull.length === 1) return nonNull[0];
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Single-arg generic wrappers transparent to receiver-type chain
|
||||
* propagation: `Promise<X>`, `Array<X>`, `ReadonlyArray<X>`,
|
||||
* `Readonly<X>`, `Iterable<X>`, `Iterator<X>`, `Set<X>`, `List<X>`,
|
||||
* `Map<X>` (single-arg form rare but kept for completeness), etc.
|
||||
* Multi-arg generics (`Map<K, V>`, `Record<K, V>`) are left alone —
|
||||
* element semantics aren't unambiguous. */
|
||||
function stripGeneric(text: string): string {
|
||||
const single = text.match(
|
||||
/^(?:[A-Za-z_][A-Za-z0-9_]*\.)?(?:Promise|Array|ReadonlyArray|Readonly|Iterable|Iterator|AsyncIterable|AsyncIterator|AsyncGenerator|Generator|Set|ReadonlySet|List|Awaited)<([^,<>]+)>$/,
|
||||
);
|
||||
if (single !== null) return single[1].trim();
|
||||
return text;
|
||||
}
|
||||
|
||||
/** `User[]` / `(User)[]` → `User`. Chained `User[][]` unwraps one
|
||||
* level at a time per resolve pass. */
|
||||
function stripArraySuffix(text: string): string {
|
||||
if (text.endsWith('[]')) {
|
||||
const inner = text.slice(0, -2).trim();
|
||||
// Unwrap a single pair of parentheses introduced for precedence
|
||||
// disambiguation: `(User | Admin)[]` — we leave the union intact
|
||||
// but drop the parens.
|
||||
if (inner.startsWith('(') && inner.endsWith(')')) {
|
||||
return inner.slice(1, -1).trim();
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** `models.User` → `User`. TS doesn't carry a qualified-suffix exception
|
||||
* list today — `.values()` / `.keys()` use method-call syntax and are
|
||||
* resolved via the member-call chain, not via a dotted type. */
|
||||
function stripQualifier(text: string): string {
|
||||
const lastDot = text.lastIndexOf('.');
|
||||
if (lastDot === -1) return text;
|
||||
return text.slice(lastDot + 1);
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* TypeScript declaration-merging + LEGB precedence for the `mergeBindings`
|
||||
* hook.
|
||||
*
|
||||
* TypeScript has a unique wrinkle that Python / C# don't: **declaration
|
||||
* merging**. The same name can legally coexist in several "declaration
|
||||
* spaces" simultaneously:
|
||||
*
|
||||
* - **value** space — `class X`, `function X`, `const X`, `var X`,
|
||||
* `let X`, `enum X`, `namespace X` (adds runtime object)
|
||||
* - **type** space — `interface X`, `type X`, `class X`, `enum X`
|
||||
* - **namespace** space — `namespace X`, `class X` (static-accessed
|
||||
* members are reachable via dotted name)
|
||||
*
|
||||
* Classes and enums are unique in that each declaration occupies both
|
||||
* the value AND type spaces. This lets:
|
||||
*
|
||||
* class Foo {}
|
||||
* interface Foo { bar: number; } // merges additional type members
|
||||
* namespace Foo { export const X = 1; } // adds static-like value
|
||||
*
|
||||
* all coexist for the same name.
|
||||
*
|
||||
* ## Algorithm
|
||||
*
|
||||
* For each declaration space independently:
|
||||
* 1. Tier bindings by origin (lower wins):
|
||||
* 0 — `local`
|
||||
* 1 — `import` / `namespace` / `reexport`
|
||||
* 2 — `wildcard` (`export * from …`)
|
||||
* 2. Keep only bindings at the best (lowest) tier in that space.
|
||||
*
|
||||
* Then union survivors across spaces and dedupe by `DefId`.
|
||||
*
|
||||
* ## Shadowing examples
|
||||
*
|
||||
* - `class Foo {}` + `function Foo() {}` in same scope → COMPILE ERROR
|
||||
* in TS source, but if both reach us with distinct DefIds we keep
|
||||
* both (value space has two locals at tier 0 — de-dup by nodeId
|
||||
* preserves both). No worse than C#-style merge.
|
||||
* - `class Foo {}` (local, value+type) + `import type { Foo } from './a'`
|
||||
* (tier-1, type-only) → local wins in both type AND value spaces;
|
||||
* the import is not kept.
|
||||
* - `interface Foo {}` (local, type-only) + `import { Foo } from './a'`
|
||||
* (tier-1, value+type) → local wins in type space; import wins in
|
||||
* value space (local doesn't occupy it). Both kept.
|
||||
* - `namespace Foo {}` (local, namespace+value) + `class Foo {}` (local,
|
||||
* value+type) → both at tier 0 in their respective spaces, kept.
|
||||
*
|
||||
* ## Limitations
|
||||
*
|
||||
* - We classify imports by their `def.type` just like locals. Without
|
||||
* a space-annotation on `ParsedImport`, `import type { Foo }` looks
|
||||
* the same as `import { Foo }` at this layer — the parse phase
|
||||
* decomposer marks type-only imports so the extractor CAN annotate
|
||||
* `def.type = 'Type'` downstream if desired. Today it doesn't, so
|
||||
* `import type` imports and value imports fall in the same bucket
|
||||
* per their target def's NodeLabel. Parity with legacy behavior
|
||||
* (which also doesn't track type-only separately) is preserved.
|
||||
*/
|
||||
|
||||
import type { BindingRef, NodeLabel } from 'gitnexus-shared';
|
||||
|
||||
/** Declaration spaces a TypeScript binding can occupy. */
|
||||
type Space = 'value' | 'type' | 'namespace';
|
||||
|
||||
const TIER_LOCAL = 0;
|
||||
const TIER_IMPORT = 1;
|
||||
const TIER_WILDCARD = 2;
|
||||
const TIER_UNKNOWN = 3;
|
||||
|
||||
function tierOf(b: BindingRef): number {
|
||||
switch (b.origin) {
|
||||
case 'local':
|
||||
return TIER_LOCAL;
|
||||
case 'reexport':
|
||||
case 'import':
|
||||
case 'namespace':
|
||||
return TIER_IMPORT;
|
||||
case 'wildcard':
|
||||
return TIER_WILDCARD;
|
||||
default:
|
||||
return TIER_UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a `SymbolDefinition.type` (`NodeLabel`) to the set of TypeScript
|
||||
* declaration spaces the binding occupies.
|
||||
*
|
||||
* Unknown / unused labels default to `['value']` — the permissive choice,
|
||||
* matching legacy behavior where everything lives in a single flat bucket.
|
||||
*/
|
||||
function spacesOf(type: NodeLabel): readonly Space[] {
|
||||
switch (type) {
|
||||
// value-only
|
||||
case 'Function':
|
||||
case 'Method':
|
||||
case 'Variable':
|
||||
case 'Const':
|
||||
case 'Static':
|
||||
case 'Property':
|
||||
case 'Constructor':
|
||||
case 'Macro':
|
||||
return ['value'];
|
||||
|
||||
// type-only
|
||||
case 'Interface':
|
||||
case 'Type':
|
||||
case 'TypeAlias':
|
||||
case 'Typedef':
|
||||
case 'Trait':
|
||||
case 'Annotation':
|
||||
case 'Decorator':
|
||||
return ['type'];
|
||||
|
||||
// dual: value AND type
|
||||
case 'Class':
|
||||
case 'Enum':
|
||||
case 'Struct':
|
||||
case 'Record':
|
||||
case 'Union':
|
||||
return ['value', 'type'];
|
||||
|
||||
// namespace AND value (namespaces introduce a runtime object AND a
|
||||
// named scope for static-style access)
|
||||
case 'Namespace':
|
||||
case 'Module':
|
||||
return ['namespace', 'value'];
|
||||
|
||||
// catch-all — treat as value to match legacy permissive behavior
|
||||
default:
|
||||
return ['value'];
|
||||
}
|
||||
}
|
||||
|
||||
export function typescriptMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
|
||||
if (bindings.length === 0) return bindings;
|
||||
|
||||
// Partition bindings by space. A single binding occupying two spaces
|
||||
// (e.g. a class) is duplicated into both partitions; the final dedupe
|
||||
// by nodeId collapses it back.
|
||||
const perSpace = new Map<Space, BindingRef[]>();
|
||||
for (const b of bindings) {
|
||||
const spaces = spacesOf(b.def.type);
|
||||
for (const s of spaces) {
|
||||
const list = perSpace.get(s);
|
||||
if (list === undefined) perSpace.set(s, [b]);
|
||||
else list.push(b);
|
||||
}
|
||||
}
|
||||
|
||||
// Within each space, keep only the best-tier bindings.
|
||||
const survivorsSet = new Set<BindingRef>();
|
||||
for (const list of perSpace.values()) {
|
||||
let bestTier = Number.POSITIVE_INFINITY;
|
||||
for (const b of list) bestTier = Math.min(bestTier, tierOf(b));
|
||||
for (const b of list) {
|
||||
if (tierOf(b) === bestTier) survivorsSet.add(b);
|
||||
}
|
||||
}
|
||||
|
||||
// Dedupe by def.nodeId. If the same binding survived in multiple
|
||||
// spaces (e.g. a class in both value + type) we keep a single entry.
|
||||
const seen = new Map<string, BindingRef>();
|
||||
for (const b of survivorsSet) seen.set(b.def.nodeId, b);
|
||||
return [...seen.values()];
|
||||
}
|
||||
986
gitnexus/src/core/ingestion/languages/typescript/query.ts
Normal file
986
gitnexus/src/core/ingestion/languages/typescript/query.ts
Normal file
|
|
@ -0,0 +1,986 @@
|
|||
/**
|
||||
* Tree-sitter query for TypeScript scope captures (RFC §5.1).
|
||||
*
|
||||
* Captures the structural skeleton the generic scope-resolution pipeline
|
||||
* consumes: scopes (module/namespace/class/function), declarations (class-
|
||||
* likes, method-likes, properties, variables), imports (one anchor per
|
||||
* statement — decomposed in `import-decomposer.ts`), type bindings
|
||||
* (parameter annotations, variable annotations, constructor inference,
|
||||
* return types), and references (call sites, member writes).
|
||||
*
|
||||
* TypeScript specifics that shape this query:
|
||||
*
|
||||
* - **Namespaces** (`namespace Foo { }`) use `internal_module` with a
|
||||
* `namespace` anon keyword + `identifier` or `nested_identifier` name +
|
||||
* `statement_block` body. Verified via Unit 1 probe.
|
||||
* - **`this` / `super`** are NAMED nodes `(this)` / `(super)` — unlike
|
||||
* C#'s `this`/`base` which are anonymous tokens. `(_)` wildcard matches
|
||||
* them as the receiver child of `member_expression`, so we don't need
|
||||
* explicit string patterns.
|
||||
* - **Optional chaining** (`obj?.m()`) still matches the regular
|
||||
* `member_expression > object: (_) / property: (property_identifier)`
|
||||
* pattern; the `(optional_chain)` child sits between them but doesn't
|
||||
* occupy a named field. Same query handles both.
|
||||
* - **Dynamic imports** (`import('./mod')`) are `call_expression` whose
|
||||
* `function` field is a named `import` node (not a regular identifier).
|
||||
* Captured via a dedicated pattern.
|
||||
* - **Function overloads** — `function f(x:string); function f(x:number);
|
||||
* function f(x) { … }` emits two `function_signature` nodes plus one
|
||||
* `function_declaration`. All three emit `@declaration.function`;
|
||||
* arity metadata synthesis merges parameterTypes.
|
||||
* - **Parameter properties** (`constructor(public name: string)`) — each
|
||||
* parameter emits `@declaration.property` on the enclosing class; the
|
||||
* same identifier also binds as a parameter in the constructor scope
|
||||
* via the normal `required_parameter` → `@type-binding.parameter` path.
|
||||
* - **Enum** — dual type+value. Emits `@scope.class` (enum body contains
|
||||
* member declarations) + `@declaration.enum`. Members are captured as
|
||||
* `@declaration.property` via the generic property_identifier pattern
|
||||
* inside enum_body.
|
||||
*
|
||||
* Node types pinned via `scripts/_probe_typescript_grammar.ts`:
|
||||
* internal_module, namespace_export, namespace_import, import_specifier,
|
||||
* export_specifier, enum_declaration, type_alias_declaration,
|
||||
* abstract_class_declaration, abstract_method_signature, method_signature,
|
||||
* generator_function_declaration, optional_parameter, rest_parameter,
|
||||
* required_parameter, public_field_definition, private_property_identifier,
|
||||
* new_expression (constructor field), call_expression with (import) fn.
|
||||
*
|
||||
* Grammar version: tree-sitter-typescript pinned in gitnexus/package.json.
|
||||
*
|
||||
* Exposes lazy `Parser` and `Query` singletons so callers don't pay tree-
|
||||
* sitter init cost per file.
|
||||
*/
|
||||
|
||||
import Parser from 'tree-sitter';
|
||||
import TS from 'tree-sitter-typescript';
|
||||
|
||||
// tree-sitter-typescript exports both `typescript` and `tsx` grammars on
|
||||
// the default export. The package's `.d.ts` types the default export
|
||||
// loosely; we narrow at the use site. The two grammars are NOT
|
||||
// interchangeable: feeding a `.tsx` source to the `typescript` grammar
|
||||
// mis-parses JSX as a sequence of less-than/greater-than expressions
|
||||
// and silently drops every capture inside JSX elements. We therefore
|
||||
// pick the grammar by file extension.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const TS_GRAMMAR = (TS as any).typescript as Parameters<Parser['setLanguage']>[0];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const TSX_GRAMMAR = (TS as any).tsx as Parameters<Parser['setLanguage']>[0];
|
||||
|
||||
/** True when the file should be parsed with the TSX grammar. The TSX
|
||||
* grammar is a superset of TypeScript that adds JSX productions; it
|
||||
* parses plain `.ts` files correctly too, but we keep `.ts` on the
|
||||
* `typescript` grammar so the parser cache stays small and so any
|
||||
* subtle TSX-only mis-parses don't bleed into non-TSX files. */
|
||||
function isTsxFile(filePath: string): boolean {
|
||||
return filePath.endsWith('.tsx');
|
||||
}
|
||||
|
||||
const TYPESCRIPT_SCOPE_QUERY = `
|
||||
;; Scopes — module / namespace / class-likes / function-likes
|
||||
(program) @scope.module
|
||||
|
||||
(internal_module) @scope.namespace
|
||||
|
||||
(class_declaration) @scope.class
|
||||
(abstract_class_declaration) @scope.class
|
||||
(interface_declaration) @scope.class
|
||||
(enum_declaration) @scope.class
|
||||
|
||||
(function_declaration) @scope.function
|
||||
(generator_function_declaration) @scope.function
|
||||
(function_signature) @scope.function
|
||||
(method_definition) @scope.function
|
||||
(method_signature) @scope.function
|
||||
(abstract_method_signature) @scope.function
|
||||
(arrow_function) @scope.function
|
||||
(function_expression) @scope.function
|
||||
|
||||
;; Type aliases that contain an object_type are structurally class-like —
|
||||
;; they define a shape with named members. Emit @scope.class so the
|
||||
;; field-extractor's type-alias-with-object-type handling (in
|
||||
;; field-extractors/typescript.ts) finds a scope for its members.
|
||||
(type_alias_declaration
|
||||
value: (object_type)) @scope.class
|
||||
|
||||
;; Declarations — types
|
||||
(class_declaration
|
||||
name: (type_identifier) @declaration.name) @declaration.class
|
||||
|
||||
(abstract_class_declaration
|
||||
name: (type_identifier) @declaration.name) @declaration.class
|
||||
|
||||
(interface_declaration
|
||||
name: (type_identifier) @declaration.name) @declaration.interface
|
||||
|
||||
(enum_declaration
|
||||
name: (identifier) @declaration.name) @declaration.enum
|
||||
|
||||
(type_alias_declaration
|
||||
name: (type_identifier) @declaration.name) @declaration.type
|
||||
|
||||
(internal_module
|
||||
name: (identifier) @declaration.name) @declaration.namespace
|
||||
|
||||
;; Declarations — methods / functions / constructors
|
||||
(function_declaration
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
(generator_function_declaration
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
;; Function overload signatures (declaration-only; body in a separate
|
||||
;; function_declaration). Extractors dedup by (name, parameterTypes).
|
||||
(function_signature
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
;; 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))
|
||||
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (function_expression) @declaration.function))
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (arrow_function) @declaration.function))
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
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
|
||||
name: (property_identifier) @declaration.name) @declaration.method
|
||||
|
||||
(method_definition
|
||||
name: (private_property_identifier) @declaration.name) @declaration.method
|
||||
|
||||
;; Abstract method signatures in abstract classes.
|
||||
(abstract_method_signature
|
||||
name: (property_identifier) @declaration.name) @declaration.method
|
||||
|
||||
;; Interface method signatures.
|
||||
(method_signature
|
||||
name: (property_identifier) @declaration.name) @declaration.method
|
||||
|
||||
;; Declarations — class fields
|
||||
(public_field_definition
|
||||
name: (property_identifier) @declaration.name) @declaration.property
|
||||
|
||||
(public_field_definition
|
||||
name: (private_property_identifier) @declaration.name) @declaration.property
|
||||
|
||||
;; Declarations — parameter properties: \`constructor(public name: string)\`.
|
||||
;; The accessibility_modifier presence distinguishes these from regular
|
||||
;; parameters. The identifier is also bound as a parameter in the
|
||||
;; constructor's scope via @type-binding.parameter below (dual binding).
|
||||
(required_parameter
|
||||
(accessibility_modifier)
|
||||
pattern: (identifier) @declaration.name) @declaration.property
|
||||
|
||||
;; Declarations — variables (let / const / var)
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name)) @declaration.variable
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name)) @declaration.variable
|
||||
|
||||
;; Imports — single anchor per statement; decomposer emits per-specifier markers.
|
||||
(import_statement) @import.statement
|
||||
|
||||
;; Re-exports: \`export { X } from './y'\` / \`export * from './y'\` /
|
||||
;; \`export * as ns from './y'\` / \`export type { X } from './y'\`.
|
||||
;; Only re-exports (those with a \`from\` clause) emit @import.statement;
|
||||
;; local \`export { X }\` (no source) is just visibility metadata, not an
|
||||
;; import. The decomposer filters by source presence.
|
||||
(export_statement
|
||||
source: (string)) @import.statement
|
||||
|
||||
;; Dynamic imports: \`import('./m')\` / \`await import(x)\`. tree-sitter-
|
||||
;; typescript represents \`import\` as a named leaf node; the call_expression's
|
||||
;; function field points at it.
|
||||
(call_expression
|
||||
function: (import)) @import.dynamic
|
||||
|
||||
;; Type bindings — parameter annotations: \`function f(u: User)\`
|
||||
(required_parameter
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(type_identifier) @type-binding.type)) @type-binding.parameter
|
||||
|
||||
(required_parameter
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(generic_type) @type-binding.type)) @type-binding.parameter
|
||||
|
||||
(required_parameter
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(predefined_type) @type-binding.type)) @type-binding.parameter
|
||||
|
||||
;; Parameter with union / array / readonly wrappers: \`users: readonly User[]\`,
|
||||
;; \`x: User | null\`, \`xs: User[]\`. interpret strips wrappers to the
|
||||
;; discriminating type.
|
||||
(required_parameter
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(union_type) @type-binding.type)) @type-binding.parameter
|
||||
|
||||
(required_parameter
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(array_type) @type-binding.type)) @type-binding.parameter
|
||||
|
||||
(required_parameter
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(readonly_type) @type-binding.type)) @type-binding.parameter
|
||||
|
||||
;; Type bindings — parameter properties:
|
||||
;; \`constructor(public address: Address)\` — each parameter with an
|
||||
;; accessibility modifier is ALSO a class field. We emit a second
|
||||
;; capture so \`tsBindingScopeFor\` can hoist these to the Class scope,
|
||||
;; enabling \`user.address\` field access resolution. The regular
|
||||
;; @type-binding.parameter above still fires for the constructor
|
||||
;; scope binding — both bindings coexist, which is correct.
|
||||
(required_parameter
|
||||
(accessibility_modifier)
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(type_identifier) @type-binding.type)) @type-binding.parameter-property
|
||||
|
||||
(required_parameter
|
||||
(accessibility_modifier)
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(generic_type) @type-binding.type)) @type-binding.parameter-property
|
||||
|
||||
(required_parameter
|
||||
(accessibility_modifier)
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(predefined_type) @type-binding.type)) @type-binding.parameter-property
|
||||
|
||||
(required_parameter
|
||||
(accessibility_modifier)
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(union_type) @type-binding.type)) @type-binding.parameter-property
|
||||
|
||||
(required_parameter
|
||||
(accessibility_modifier)
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(array_type) @type-binding.type)) @type-binding.parameter-property
|
||||
|
||||
(required_parameter
|
||||
(accessibility_modifier)
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(readonly_type) @type-binding.type)) @type-binding.parameter-property
|
||||
|
||||
(optional_parameter
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(type_identifier) @type-binding.type)) @type-binding.parameter
|
||||
|
||||
(optional_parameter
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(generic_type) @type-binding.type)) @type-binding.parameter
|
||||
|
||||
;; Type bindings — variable annotations: \`let u: User = ...\` / \`const u: User\`.
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(type_identifier) @type-binding.type)) @type-binding.annotation
|
||||
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(generic_type) @type-binding.type)) @type-binding.annotation
|
||||
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(predefined_type) @type-binding.type)) @type-binding.annotation
|
||||
|
||||
;; Union types like \`User | null\` / \`User | undefined\` — interpret's
|
||||
;; stripNullableUnion collapses to the discriminating arm.
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(union_type) @type-binding.type)) @type-binding.annotation
|
||||
|
||||
;; Array types: \`User[]\` / \`readonly User[]\` — stripArraySuffix unwraps.
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(array_type) @type-binding.type)) @type-binding.annotation
|
||||
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(readonly_type) @type-binding.type)) @type-binding.annotation
|
||||
|
||||
;; Type bindings — constructor-inferred: \`const u = new User()\`.
|
||||
;; The variable_declarator's \`value\` field carries the new_expression; its
|
||||
;; \`constructor\` field is the type identifier. Covers both typed (\`:User = \`)
|
||||
;; and untyped declarations — the annotation pattern above wins if both
|
||||
;; fire, via the scope-extractor's source-strength tie-break in
|
||||
;; pass4CollectTypeBindings.
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (new_expression
|
||||
constructor: (identifier) @type-binding.type)) @type-binding.constructor
|
||||
|
||||
;; Qualified constructor: \`const u = new models.User()\`. Captures the
|
||||
;; member_expression's text as the type — resolver's QualifiedNameIndex
|
||||
;; handles the dotted lookup.
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (new_expression
|
||||
constructor: (member_expression) @type-binding.type)) @type-binding.constructor
|
||||
|
||||
;; Cast-wrapped constructor: \`const u = new User() as any\` /
|
||||
;; \`const u = new User()!\`. The \`as T\` pattern also captures T itself
|
||||
;; via the assertion clause above, but T is usually a non-discriminating
|
||||
;; type (\`any\`, \`unknown\`) in these idioms; interpretTsTypeBinding
|
||||
;; drops those so the constructor-inferred binding survives.
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (as_expression
|
||||
(new_expression
|
||||
constructor: (identifier) @type-binding.type))) @type-binding.constructor
|
||||
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (non_null_expression
|
||||
(new_expression
|
||||
constructor: (identifier) @type-binding.type))) @type-binding.constructor
|
||||
|
||||
;; Double-cast: \`const u = new User() as unknown as any\` — as_expression
|
||||
;; nested inside as_expression, with new_expression at the core.
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (as_expression
|
||||
(as_expression
|
||||
(new_expression
|
||||
constructor: (identifier) @type-binding.type)))) @type-binding.constructor
|
||||
|
||||
;; Type bindings — call-result alias: \`const u = find()\`. Chain-follow
|
||||
;; walks \`find\`'s return type via propagateImportedReturnTypes for cross-
|
||||
;; file; same-file covered by explicit return annotations.
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (call_expression
|
||||
function: (identifier) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; Type bindings — member-call alias: \`const u = svc.getUser()\`. The
|
||||
;; callee is captured as a full \`member_expression\` text (\`svc.getUser\`)
|
||||
;; so compound-receiver can resolve the receiver object before looking up
|
||||
;; the method's hoisted return-type binding.
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (call_expression
|
||||
function: (member_expression) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; Type bindings — await chain: \`const u = await find()\` / \`await svc.m()\`.
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (await_expression
|
||||
(call_expression
|
||||
function: (identifier) @type-binding.type))) @type-binding.alias
|
||||
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (await_expression
|
||||
(call_expression
|
||||
function: (member_expression) @type-binding.type))) @type-binding.alias
|
||||
|
||||
;; Awaited generic calls re-associate: \`await fn<T>(...)\` parses as
|
||||
;; \`call_expression(function: await_expression(identifier), type_arguments, arguments)\`
|
||||
;; — NOT as an await_expression wrapping a call_expression. Handle both
|
||||
;; free and member forms so the chain-follow picks up the inner callee.
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (call_expression
|
||||
function: (await_expression
|
||||
(identifier) @type-binding.type))) @type-binding.alias
|
||||
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (call_expression
|
||||
function: (await_expression
|
||||
(member_expression) @type-binding.type))) @type-binding.alias
|
||||
|
||||
;; Type bindings — member-access alias: \`const addr = user.address\`.
|
||||
;; Full \`member_expression\` text feeds compound-receiver Case 3b.
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (member_expression) @type-binding.type) @type-binding.member-alias
|
||||
|
||||
;; Type bindings — identifier alias: \`const alias = user\`. Chain-follow
|
||||
;; resolves alias via user's binding.
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (identifier) @type-binding.type) @type-binding.alias
|
||||
|
||||
;; Type bindings — \`as\` assertion: \`const u = x as User\`. Prefer
|
||||
;; the assertion's target type over RHS inference. as_expression's right
|
||||
;; child is the target type (positional; no field name).
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (as_expression
|
||||
(_)
|
||||
(type_identifier) @type-binding.type)) @type-binding.assertion
|
||||
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (as_expression
|
||||
(_)
|
||||
(generic_type) @type-binding.type)) @type-binding.assertion
|
||||
|
||||
;; Type bindings — non-null assertion: \`const u = find()!\`. Unwrap to the
|
||||
;; underlying call's function identifier (matches the call-alias pattern).
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (non_null_expression
|
||||
(call_expression
|
||||
function: (identifier) @type-binding.type))) @type-binding.alias
|
||||
|
||||
;; Type bindings — for-of element: \`for (const u of users)\` — bind u to
|
||||
;; users (chain-follow unwraps to element type via stripGeneric).
|
||||
(for_in_statement
|
||||
left: (identifier) @type-binding.name
|
||||
right: (identifier) @type-binding.type) @type-binding.alias
|
||||
|
||||
;; Type bindings — for-of call iterable: \`for (const u of getUsers())\`.
|
||||
(for_in_statement
|
||||
left: (identifier) @type-binding.name
|
||||
right: (call_expression
|
||||
function: (identifier) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; Type bindings — for-of member-call iterable: \`for (const u of svc.getUsers())\`.
|
||||
(for_in_statement
|
||||
left: (identifier) @type-binding.name
|
||||
right: (call_expression
|
||||
function: (member_expression) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; Type bindings — for-of member-access iterable: \`for (const u of this.users)\`.
|
||||
;; Bind u to \`users\` (the attribute name); chain-follow resolves users
|
||||
;; via the enclosing class's field binding.
|
||||
(for_in_statement
|
||||
left: (identifier) @type-binding.name
|
||||
right: (member_expression
|
||||
property: (property_identifier) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; Type bindings — class field annotation: \`private city: City\`.
|
||||
(public_field_definition
|
||||
name: (property_identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(type_identifier) @type-binding.type)) @type-binding.annotation
|
||||
|
||||
(public_field_definition
|
||||
name: (property_identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(generic_type) @type-binding.type)) @type-binding.annotation
|
||||
|
||||
(public_field_definition
|
||||
name: (property_identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(predefined_type) @type-binding.type)) @type-binding.annotation
|
||||
|
||||
;; Class field with union / array / readonly wrappers:
|
||||
;; \`private users: User[]\`, \`private repos: readonly Repo[]\`,
|
||||
;; \`private x: City | null\`. interpret strips wrappers to the
|
||||
;; discriminating type so chain-follow unwraps to the element.
|
||||
(public_field_definition
|
||||
name: (property_identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(union_type) @type-binding.type)) @type-binding.annotation
|
||||
|
||||
(public_field_definition
|
||||
name: (property_identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(array_type) @type-binding.type)) @type-binding.annotation
|
||||
|
||||
(public_field_definition
|
||||
name: (property_identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(readonly_type) @type-binding.type)) @type-binding.annotation
|
||||
|
||||
;; Private class field annotation: \`#city: City\`.
|
||||
(public_field_definition
|
||||
name: (private_property_identifier) @type-binding.name
|
||||
type: (type_annotation
|
||||
(type_identifier) @type-binding.type)) @type-binding.annotation
|
||||
|
||||
;; Type bindings — method return type: \`save(): User { … }\` / \`function f(): User { … }\`.
|
||||
;; Function/method return-type is the type_annotation that is a direct
|
||||
;; child of the function node (not the parameter's annotation). Anchor on
|
||||
;; the function node so bindingScopeFor can hoist if the language requests
|
||||
;; (TS keeps it on the method scope; we emit here and let the resolver
|
||||
;; decide via hoistTypeBindingsToModule).
|
||||
;;
|
||||
;; Wrapper forms covered: plain \`User\`, generic \`Promise<User>\`,
|
||||
;; array \`User[]\`, readonly \`readonly User[]\`, union \`User | null\`.
|
||||
;; \`stripArraySuffix\` / \`stripReadonly\` / \`stripNullableUnion\` in
|
||||
;; interpret reduce these to the discriminating element so chain-follow
|
||||
;; can unwrap iterators returned from \`getUsers(): User[]\`.
|
||||
(function_declaration
|
||||
name: (identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(type_identifier) @type-binding.type)) @type-binding.return
|
||||
|
||||
(function_declaration
|
||||
name: (identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(generic_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(function_declaration
|
||||
name: (identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(array_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(function_declaration
|
||||
name: (identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(readonly_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(function_declaration
|
||||
name: (identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(union_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(function_signature
|
||||
name: (identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(type_identifier) @type-binding.type)) @type-binding.return
|
||||
|
||||
(function_signature
|
||||
name: (identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(generic_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(function_signature
|
||||
name: (identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(array_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(function_signature
|
||||
name: (identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(readonly_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(function_signature
|
||||
name: (identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(union_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(method_definition
|
||||
name: (property_identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(type_identifier) @type-binding.type)) @type-binding.return
|
||||
|
||||
(method_definition
|
||||
name: (property_identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(generic_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(method_definition
|
||||
name: (property_identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(array_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(method_definition
|
||||
name: (property_identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(readonly_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(method_definition
|
||||
name: (property_identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(union_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(method_signature
|
||||
name: (property_identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(type_identifier) @type-binding.type)) @type-binding.return
|
||||
|
||||
(method_signature
|
||||
name: (property_identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(generic_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(method_signature
|
||||
name: (property_identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(array_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(method_signature
|
||||
name: (property_identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(readonly_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(method_signature
|
||||
name: (property_identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(union_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
(abstract_method_signature
|
||||
name: (property_identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(type_identifier) @type-binding.type)) @type-binding.return
|
||||
|
||||
(abstract_method_signature
|
||||
name: (property_identifier) @type-binding.name
|
||||
return_type: (type_annotation
|
||||
(generic_type) @type-binding.type)) @type-binding.return
|
||||
|
||||
;; Type bindings — assignment rebind: \`u = new User()\` (no \`const\`).
|
||||
(assignment_expression
|
||||
left: (identifier) @type-binding.name
|
||||
right: (new_expression
|
||||
constructor: (identifier) @type-binding.type)) @type-binding.constructor
|
||||
|
||||
(assignment_expression
|
||||
left: (identifier) @type-binding.name
|
||||
right: (call_expression
|
||||
function: (identifier) @type-binding.type)) @type-binding.alias
|
||||
|
||||
(assignment_expression
|
||||
left: (identifier) @type-binding.name
|
||||
right: (identifier) @type-binding.type) @type-binding.alias
|
||||
|
||||
;; References — free calls: \`fn(args)\`. Exclude the dynamic-import form,
|
||||
;; which would otherwise double-classify as a call to a built-in \`import\`.
|
||||
;; tree-sitter can't negate (import) with #not-eq?; the captures.ts layer
|
||||
;; filters dynamic-imports BEFORE the free-call is consumed.
|
||||
(call_expression
|
||||
function: (identifier) @reference.name) @reference.call.free
|
||||
|
||||
;; Awaited free call with generics: \`await fn<T>(...)\` — re-associated
|
||||
;; by tree-sitter as \`call_expression(function: await_expression(identifier))\`.
|
||||
(call_expression
|
||||
function: (await_expression
|
||||
(identifier) @reference.name)) @reference.call.free
|
||||
|
||||
;; References — member calls: \`obj.method()\` (includes optional chain).
|
||||
;; The (_) wildcard matches any named receiver including \`this\` /
|
||||
;; \`super\` (both are named nodes in tree-sitter-typescript, unlike C#'s
|
||||
;; anonymous tokens).
|
||||
(call_expression
|
||||
function: (member_expression
|
||||
object: (_) @reference.receiver
|
||||
property: (property_identifier) @reference.name)) @reference.call.member
|
||||
|
||||
;; Awaited member call with generics: \`await svc.m<T>(...)\` — re-associated
|
||||
;; as \`call_expression(function: await_expression(member_expression))\`.
|
||||
(call_expression
|
||||
function: (await_expression
|
||||
(member_expression
|
||||
object: (_) @reference.receiver
|
||||
property: (property_identifier) @reference.name))) @reference.call.member
|
||||
|
||||
;; References — constructor calls: \`new User()\` / \`new ns.User()\`.
|
||||
(new_expression
|
||||
constructor: (identifier) @reference.name) @reference.call.constructor
|
||||
|
||||
(new_expression
|
||||
constructor: (member_expression) @reference.call.constructor.qualified) @reference.call.constructor
|
||||
|
||||
;; References — write access: \`obj.field = value\`.
|
||||
(assignment_expression
|
||||
left: (member_expression
|
||||
object: (_) @reference.receiver
|
||||
property: (property_identifier) @reference.name)) @reference.write.member
|
||||
|
||||
(augmented_assignment_expression
|
||||
left: (member_expression
|
||||
object: (_) @reference.receiver
|
||||
property: (property_identifier) @reference.name)) @reference.write.member
|
||||
|
||||
;; References — read access: \`obj.field\` used in a read context.
|
||||
;; Fires on EVERY member_expression; \`emitTsScopeCaptures\` filters out
|
||||
;; contexts that shouldn't emit a read ACCESSES edge (LHS of assignment,
|
||||
;; the \`function:\` of a call_expression, property_identifier inside a
|
||||
;; computed member name, etc.). Keeping the filter on the emit side lets
|
||||
;; tree-sitter's pattern stay simple and we don't replicate AST-context
|
||||
;; predicates in the query itself.
|
||||
(member_expression
|
||||
object: (_) @reference.receiver
|
||||
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;
|
||||
let _tsxQuery: Parser.Query | null = null;
|
||||
|
||||
/**
|
||||
* Return the right tree-sitter parser for `filePath` (or the TS parser
|
||||
* when no path is given — the legacy callsite shape).
|
||||
*/
|
||||
export function getTsParser(filePath?: string): Parser {
|
||||
if (filePath !== undefined && isTsxFile(filePath)) {
|
||||
if (_tsxParser === null) {
|
||||
_tsxParser = new Parser();
|
||||
_tsxParser.setLanguage(TSX_GRAMMAR);
|
||||
}
|
||||
return _tsxParser;
|
||||
}
|
||||
if (_tsParser === null) {
|
||||
_tsParser = new Parser();
|
||||
_tsParser.setLanguage(TS_GRAMMAR);
|
||||
}
|
||||
return _tsParser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the right tree-sitter Query (compiled against the same grammar
|
||||
* as the parser). A Query bound to the `typescript` grammar can NOT be
|
||||
* 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 + TSX_JSX_QUERY_SUFFIX);
|
||||
}
|
||||
return _tsxQuery;
|
||||
}
|
||||
if (_tsQuery === null) {
|
||||
_tsQuery = new Parser.Query(TS_GRAMMAR, TYPESCRIPT_SCOPE_QUERY);
|
||||
}
|
||||
return _tsQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a cached `Tree` was produced by the grammar matching
|
||||
* `filePath` (TSX vs TypeScript). The runtime tree-sitter `Tree` exposes
|
||||
* `getLanguage()` (returning the grammar object the parser was bound
|
||||
* to); the .d.ts is incomplete, so we reach via a cast. Identity
|
||||
* comparison against `TSX_GRAMMAR` / `TS_GRAMMAR` is exact: the same
|
||||
* module instance produces both. If `getLanguage` is unavailable for
|
||||
* any reason, return true to keep behavior backwards-compatible (the
|
||||
* original code never validated grammar at all).
|
||||
*/
|
||||
export function tsCachedTreeMatchesGrammar(tree: unknown, filePath: string): boolean {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const lang = (tree as any)?.getLanguage?.();
|
||||
if (lang === undefined || lang === null) return true;
|
||||
return isTsxFile(filePath) ? lang === TSX_GRAMMAR : lang === TS_GRAMMAR;
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* Synthesize `@type-binding.this` captures for TypeScript instance-like
|
||||
* methods.
|
||||
*
|
||||
* Tree-sitter can't cleanly express "the implicit `this` receiver of a
|
||||
* non-static member of a class / interface / abstract class" via a
|
||||
* static `.scm` pattern, so we walk up the AST in code — mirrors
|
||||
* Python's `self` / `cls` and C#'s `this` / `base` synthesis.
|
||||
*
|
||||
* Scope coverage:
|
||||
*
|
||||
* - `method_definition` inside `class_declaration`,
|
||||
* `abstract_class_declaration`, or `class_expression` → synthesize
|
||||
* `this` → enclosing class name.
|
||||
* - `method_signature` / `abstract_method_signature` inside
|
||||
* `interface_declaration` or `abstract_class_declaration` →
|
||||
* synthesize `this` → enclosing type's name (so interface method
|
||||
* bodies' `this.x` chains resolve via the interface's field
|
||||
* annotations).
|
||||
* - `arrow_function` / `function_expression` that is a direct value
|
||||
* of a `public_field_definition` (class field) — `m = () => {}` —
|
||||
* synthesize `this` → enclosing class name. These capture `this`
|
||||
* lexically; without synthesis, their body's `this.foo` wouldn't
|
||||
* resolve.
|
||||
*
|
||||
* Not synthesized (intentionally):
|
||||
*
|
||||
* - `static` methods / static fields. `this` in a static context
|
||||
* refers to the class constructor, not an instance; we leave the
|
||||
* binding empty and let chain resolution fall through to the
|
||||
* class's static members lookup.
|
||||
* - Regular `function_declaration` / `function_expression` at
|
||||
* module level or in a non-class context. No enclosing type, no
|
||||
* `this` semantics.
|
||||
* - Arrow functions nested inside a method body. The scope-chain
|
||||
* walk in `tsReceiverBinding` finds the outer method's `this`
|
||||
* naturally, matching TS's lexical-this rule for arrow functions.
|
||||
*
|
||||
* Each synthesized match emits the anchor captures needed by
|
||||
* `interpretTsTypeBinding`:
|
||||
*
|
||||
* `@type-binding.this` (source discriminator — interpret maps to 'self')
|
||||
* `@type-binding.name` (the literal `'this'`)
|
||||
* `@type-binding.type` (the enclosing type's name)
|
||||
*/
|
||||
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
/** Node types that define a TypeScript "type with instance members". */
|
||||
const TYPE_DECL_NODE_TYPES = new Set([
|
||||
'class_declaration',
|
||||
'abstract_class_declaration',
|
||||
'class',
|
||||
'class_expression',
|
||||
'interface_declaration',
|
||||
]);
|
||||
|
||||
/** Scope function nodes that could be a class method body. */
|
||||
const CLASS_MEMBER_FUNCTION_TYPES = new Set([
|
||||
'method_definition',
|
||||
'method_signature',
|
||||
'abstract_method_signature',
|
||||
]);
|
||||
|
||||
/** Function-like values that can back a class field (`m = () => {}`). */
|
||||
const CLASS_FIELD_FUNCTION_TYPES = new Set(['arrow_function', 'function_expression']);
|
||||
|
||||
/**
|
||||
* Produce zero or one `CaptureMatch` synthesizing `this` for `fnNode`.
|
||||
*
|
||||
* - `null` — function has no synthetic `this` (free / static /
|
||||
* not-in-class / no name on enclosing type).
|
||||
* - One match — anchor on the function body so the synthetic binding
|
||||
* attaches to the function's scope (not the outer class scope).
|
||||
*
|
||||
* The caller is responsible for passing a `fnNode` whose type is one
|
||||
* of the scope function nodes the scope query emits.
|
||||
*/
|
||||
export function synthesizeTsReceiverBinding(fnNode: SyntaxNode): CaptureMatch | null {
|
||||
// Classify the function's role.
|
||||
const role = classifyFunctionRole(fnNode);
|
||||
if (role === null) return null;
|
||||
|
||||
// Static methods / static fields don't have an instance `this`.
|
||||
if (isStaticMember(role.memberNode)) return null;
|
||||
|
||||
// Find enclosing type declaration. Walking past function-like
|
||||
// boundaries is fine — nested local functions inside a method can
|
||||
// still reference outer `this` through the lexical chain, but we
|
||||
// only synthesize on the immediate function body. The resolver's
|
||||
// scope-chain walk reaches ancestor synthesized bindings for nested
|
||||
// arrow functions.
|
||||
const enclosingType = findEnclosingType(role.memberNode);
|
||||
if (enclosingType === null) return null;
|
||||
|
||||
const typeName = getTypeDeclName(enclosingType);
|
||||
if (typeName === null) return null;
|
||||
|
||||
// Anchor the synthetic capture on the function body so the binding
|
||||
// lands in the method's scope, not its parent type scope. Method
|
||||
// signatures in interfaces / abstract classes have no body — use
|
||||
// the method node itself as the anchor; scope-extractor attaches
|
||||
// it to the function scope created by the `@scope.function`
|
||||
// anchor at the same range.
|
||||
const anchorNode = fnNode.childForFieldName('body') ?? fnNode;
|
||||
|
||||
return buildThisBinding(anchorNode, typeName);
|
||||
}
|
||||
|
||||
interface FunctionRole {
|
||||
/** Node carrying the (possibly `static`) modifier. For method
|
||||
* definitions this is `fnNode` itself; for arrow-bodied fields
|
||||
* this is the `public_field_definition` parent. */
|
||||
readonly memberNode: SyntaxNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether `fnNode` participates as a class member. Returns
|
||||
* `null` when the function is not structurally "a class instance
|
||||
* member" — e.g. a free function, a non-method arrow expression, an
|
||||
* arrow inside a method body (those inherit `this` via scope chain
|
||||
* lookup, no synthesis needed).
|
||||
*/
|
||||
function classifyFunctionRole(fnNode: SyntaxNode): FunctionRole | null {
|
||||
if (CLASS_MEMBER_FUNCTION_TYPES.has(fnNode.type)) {
|
||||
return { memberNode: fnNode };
|
||||
}
|
||||
if (CLASS_FIELD_FUNCTION_TYPES.has(fnNode.type)) {
|
||||
// `public_field_definition` represents a class field. Only when
|
||||
// the arrow/function-expression is a DIRECT value of a field do
|
||||
// we treat it as a class method with synthesized `this`.
|
||||
const parent = fnNode.parent;
|
||||
if (parent !== null && parent.type === 'public_field_definition') {
|
||||
const valueField = parent.childForFieldName('value');
|
||||
if (valueField !== null && valueField.startIndex === fnNode.startIndex) {
|
||||
return { memberNode: parent };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Class-body definitions carry an optional `accessibility_modifier` and
|
||||
* optional `static` keyword as named children. The `static` token is
|
||||
* usually a plain child of the member node — not a named field — so
|
||||
* we scan children for a token whose text is exactly `static`. */
|
||||
function isStaticMember(memberNode: SyntaxNode): boolean {
|
||||
for (let i = 0; i < memberNode.childCount; i++) {
|
||||
const c = memberNode.child(i);
|
||||
if (c === null) continue;
|
||||
// `static` can appear as an unnamed token or as a `readonly` /
|
||||
// `static` keyword node depending on grammar version; check text.
|
||||
if (c.text === 'static') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findEnclosingType(node: SyntaxNode): SyntaxNode | null {
|
||||
let cur: SyntaxNode | null = node.parent;
|
||||
while (cur !== null) {
|
||||
if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Return the declared name of a class / interface / abstract-class.
|
||||
* `class_expression` ( `const X = class { … }` ) may lack a name —
|
||||
* in that case we return `null` and the caller skips synthesis (the
|
||||
* outer variable's name is the usable handle, but wiring it would
|
||||
* require a separate walk; defer to a follow-up). */
|
||||
function getTypeDeclName(typeNode: SyntaxNode): string | null {
|
||||
const nameField = typeNode.childForFieldName('name');
|
||||
if (nameField === null) return null;
|
||||
return nameField.text;
|
||||
}
|
||||
|
||||
function buildThisBinding(anchorNode: SyntaxNode, typeText: string): CaptureMatch {
|
||||
const m: Record<string, Capture> = {
|
||||
'@type-binding.this': nodeToCapture('@type-binding.this', anchorNode),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, 'this'),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
|
||||
};
|
||||
return m;
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* TypeScript `ScopeResolver` registered in `SCOPE_RESOLVERS` and
|
||||
* consumed by the generic `runScopeResolution` orchestrator
|
||||
* (RFC #909 Ring 3).
|
||||
*
|
||||
* Third migration after Python and C#. Follows the same minimal
|
||||
* wiring-only pattern — per-hook logic lives in the sibling modules
|
||||
* (`arity.ts`, `merge-bindings.ts`, `import-target.ts`, etc.).
|
||||
*
|
||||
* See ./index.ts for the per-module rationale and the full list of
|
||||
* known limitations. The canonical capture vocabulary is pinned in
|
||||
* ./query.ts (TYPESCRIPT_SCOPE_QUERY constant).
|
||||
*/
|
||||
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
|
||||
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { typescriptProvider } from '../typescript.js';
|
||||
import { loadTsconfigPaths, type TsconfigPaths } from '../../language-config.js';
|
||||
import {
|
||||
typescriptArityCompatibility,
|
||||
typescriptMergeBindings,
|
||||
resolveTsTarget,
|
||||
type TsResolveContext,
|
||||
} from './index.js';
|
||||
|
||||
/** Shape the orchestrator threads in via `RunScopeResolutionInput.resolutionConfig`. */
|
||||
interface TypescriptResolutionConfig {
|
||||
readonly tsconfigPaths: TsconfigPaths | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `resolveImportTarget` adapter that memoizes the workspace
|
||||
* file list, the lower-cased file list, and the per-pass `resolveCache`
|
||||
* across every import lookup in a single workspace pass. The
|
||||
* orchestrator passes the same `ReadonlySet` reference for every call
|
||||
* within a pass — we use that identity to detect when the workspace
|
||||
* changes and recompute the derived state lazily.
|
||||
*
|
||||
* Without this memoization, `resolveTsTarget` re-derived
|
||||
* `allFileList` and `normalizedFileList` (both O(N_files)) and threw
|
||||
* away the `resolveCache` on every import — O(N_files × N_imports)
|
||||
* total work for what should be O(N_files + N_imports).
|
||||
*/
|
||||
function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] {
|
||||
interface PassCache {
|
||||
readonly key: ReadonlySet<string>;
|
||||
readonly allFilePaths: Set<string>;
|
||||
readonly allFileList: readonly string[];
|
||||
readonly normalizedFileList: readonly string[];
|
||||
readonly resolveCache: Map<string, string | null>;
|
||||
}
|
||||
let cached: PassCache | null = null;
|
||||
|
||||
return (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
|
||||
if (cached === null || cached.key !== allFilePaths) {
|
||||
const allFileList = Array.from(allFilePaths);
|
||||
cached = {
|
||||
key: allFilePaths,
|
||||
allFilePaths: new Set(allFilePaths),
|
||||
allFileList,
|
||||
normalizedFileList: allFileList.map((f) => f.toLowerCase()),
|
||||
resolveCache: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
const cfg = resolutionConfig as TypescriptResolutionConfig | undefined;
|
||||
const ws: TsResolveContext = {
|
||||
fromFile,
|
||||
allFilePaths: cached.allFilePaths,
|
||||
allFileList: cached.allFileList,
|
||||
normalizedFileList: cached.normalizedFileList,
|
||||
resolveCache: cached.resolveCache,
|
||||
tsconfigPaths: cfg?.tsconfigPaths ?? null,
|
||||
};
|
||||
return resolveTsTarget(targetRaw, ws);
|
||||
};
|
||||
}
|
||||
|
||||
const typescriptScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.TypeScript,
|
||||
languageProvider: typescriptProvider,
|
||||
importEdgeReason: 'typescript-scope: import',
|
||||
|
||||
resolveImportTarget: makeTsResolveImportTarget(),
|
||||
|
||||
// Threaded into `resolveImportTarget` so tsconfig path aliases
|
||||
// (`@/services/user`, `~/x`, …) resolve through the same standard
|
||||
// resolver branch the legacy DAG uses. One I/O round-trip per
|
||||
// workspace pass; the orchestrator awaits this once.
|
||||
loadResolutionConfig: async (repoPath: string) => ({
|
||||
tsconfigPaths: await loadTsconfigPaths(repoPath),
|
||||
}),
|
||||
|
||||
// TypeScript declaration merging + LEGB: local > import > wildcard,
|
||||
// separated by declaration space (value / type / namespace). The
|
||||
// per-scope id is unused (shadowing is computed from origin + def.type),
|
||||
// so we don't need to synthesize a Scope here.
|
||||
mergeBindings: (existing, incoming) => [...typescriptMergeBindings([...existing, ...incoming])],
|
||||
|
||||
// Adapter: typescriptArityCompatibility uses (def, callsite); the
|
||||
// ScopeResolver contract is (callsite, def).
|
||||
arityCompatibility: (callsite, def) => typescriptArityCompatibility(def, callsite),
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) =>
|
||||
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
|
||||
|
||||
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
|
||||
|
||||
// TypeScript uses `super` for super-class dispatch as a plain
|
||||
// identifier or as `super()` in constructors. Match both — `super`
|
||||
// on its own (`super.foo`, `super[x]`) and `super(...)` (constructor
|
||||
// chain). This also correctly rejects identifiers that merely
|
||||
// contain the substring `super` (e.g. `superman`).
|
||||
isSuperReceiver: (text) => /^super(\s*\(|\s*\.|\s*\[|\s*$)/.test(text.trim()),
|
||||
|
||||
// TypeScript is statically typed — field-fallback heuristic off
|
||||
// (the type-binding layer produces precise owner types). Return-
|
||||
// type propagation across imports on (matches the legacy DAG's
|
||||
// behavior: explicit return-type annotations flow across `export`
|
||||
// boundaries and resolve chained member calls).
|
||||
fieldFallbackOnMethodLookup: false,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
|
||||
// TypeScript uses `.values()` / `.keys()` method-call syntax for
|
||||
// collection views — no property-style accessors like C#'s
|
||||
// `Dictionary<K,V>.Values`. Leave `unwrapCollectionAccessor`
|
||||
// undefined and let the regular member-call branch handle them.
|
||||
//
|
||||
// `collapseMemberCallsByCallerTarget` left undefined (= false) —
|
||||
// TypeScript legacy DAG emits one edge per call site, so
|
||||
// per-site dedup is the parity target.
|
||||
//
|
||||
// `populateNamespaceSiblings` left undefined — TypeScript requires
|
||||
// an explicit `import` / namespace augmentation for cross-file
|
||||
// visibility; there's no implicit same-namespace sibling rule
|
||||
// like C#'s.
|
||||
//
|
||||
// `hoistTypeBindingsToModule` — `tsBindingScopeFor` DOES hoist
|
||||
// method return-type bindings to the enclosing Module scope
|
||||
// (mirrors C#), so enable the walk-up that lets the compound-
|
||||
// receiver resolver find them.
|
||||
hoistTypeBindingsToModule: true,
|
||||
};
|
||||
|
||||
export { typescriptScopeResolver };
|
||||
162
gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts
Normal file
162
gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* Trivial / no-op-ish hooks for the TypeScript provider. Kept together
|
||||
* because each is a few lines and they share a common theme: making
|
||||
* the provider's choice explicit rather than relying on "absence ==
|
||||
* default" so reviewers don't have to re-derive the analysis.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CaptureMatch,
|
||||
ParsedImport,
|
||||
Scope,
|
||||
ScopeId,
|
||||
ScopeTree,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
|
||||
// ─── bindingScopeFor ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* TypeScript/JavaScript has block-scoped `let`/`const` (the innermost
|
||||
* default covers these) but function-scoped `var` — which hoists to
|
||||
* the enclosing **function or module** scope, bypassing intermediate
|
||||
* blocks. JS also function-hoists `function_declaration` to the same
|
||||
* level.
|
||||
*
|
||||
* We distinguish var from let/const by sniffing the `@declaration.variable`
|
||||
* capture's leading keyword. The capture's text begins with the
|
||||
* source-literal keyword (`var ` / `let ` / `const `) because the
|
||||
* anchor is the outer `lexical_declaration` / `variable_declaration`
|
||||
* node — there's no whitespace before the keyword in any well-formed
|
||||
* TS/JS source.
|
||||
*
|
||||
* Additionally hoists **method return-type bindings**
|
||||
* (`@type-binding.return`) all the way to the Module scope, matching
|
||||
* C#: the compound-receiver walker and `propagateImportedReturnTypes`
|
||||
* both read from module-level typeBindings for cross-file chain
|
||||
* propagation.
|
||||
*/
|
||||
export function tsBindingScopeFor(
|
||||
decl: CaptureMatch,
|
||||
innermost: Scope,
|
||||
tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
// Method return type: hoist to Module (mirrors csharpBindingScopeFor).
|
||||
if (decl['@type-binding.return'] !== undefined) {
|
||||
return walkToScope(innermost, tree, 'Module');
|
||||
}
|
||||
|
||||
// Parameter property (`constructor(public address: Address)`): hoist
|
||||
// to the enclosing Class scope so `user.address` field access
|
||||
// resolves through the class's typeBindings. The regular
|
||||
// @type-binding.parameter binding still fires for the constructor
|
||||
// scope; this one adds a second binding on the class.
|
||||
if (decl['@type-binding.parameter-property'] !== undefined) {
|
||||
return walkToScope(innermost, tree, 'Class');
|
||||
}
|
||||
|
||||
// `var` declarations: hoist to nearest enclosing Function or Module.
|
||||
const variable = decl['@declaration.variable'];
|
||||
if (variable !== undefined && isVarDeclaration(variable.text)) {
|
||||
return walkToScope(innermost, tree, 'Function', 'Module');
|
||||
}
|
||||
|
||||
// Function declarations are already anchored at their definition
|
||||
// site via `@scope.function`; hoisting is a no-op for them (JS
|
||||
// function hoisting is about visibility before the definition, not
|
||||
// about placing the binding in a different scope). The scope tree
|
||||
// already attaches their name to the enclosing scope. No override
|
||||
// needed.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up the scope chain to find the first scope whose `kind` matches
|
||||
* any of `kinds`. Returns the matching scope's id or `null` when no
|
||||
* ancestor matches (e.g., a return type binding emitted outside any
|
||||
* Module scope — shouldn't happen in well-formed input).
|
||||
*/
|
||||
function walkToScope(
|
||||
from: Scope,
|
||||
tree: ScopeTree,
|
||||
...kinds: readonly Scope['kind'][]
|
||||
): ScopeId | null {
|
||||
let cur: Scope | undefined = from;
|
||||
const kindSet = new Set(kinds);
|
||||
while (cur !== undefined) {
|
||||
if (kindSet.has(cur.kind)) return cur.id;
|
||||
const parentId: ScopeId | null = cur.parent ?? null;
|
||||
if (parentId === null) break;
|
||||
cur = tree.getScope(parentId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `var x = 1;` vs `let x = 1;` / `const x = 1;`. The capture's text
|
||||
* starts at the outer declaration's `startIndex` in source, which is
|
||||
* the keyword's first character — no leading whitespace possible. */
|
||||
function isVarDeclaration(captureText: string): boolean {
|
||||
return (
|
||||
captureText.startsWith('var ') ||
|
||||
captureText.startsWith('var\t') ||
|
||||
captureText.startsWith('var\n')
|
||||
);
|
||||
}
|
||||
|
||||
// ─── importOwningScope ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* TypeScript imports are syntactically top-level: `import_statement` is
|
||||
* legal only inside `program` (the module root). `namespace X { … }`
|
||||
* bodies CAN contain imports (`internal_module`), in which case the
|
||||
* import scopes to the namespace. Dynamic `import()` calls appear
|
||||
* inside any scope but their runtime effect is still a module-level
|
||||
* resolution — we attach the `ParsedImport` to the innermost Module /
|
||||
* Namespace scope so the binding is visible through the full subtree.
|
||||
*
|
||||
* Returning `null` delegates to the central default, which walks to
|
||||
* the nearest enclosing `Module`/`Namespace`. That matches our rule,
|
||||
* so we only override when we explicitly need a non-default scope
|
||||
* (we don't).
|
||||
*/
|
||||
export function tsImportOwningScope(
|
||||
_imp: ParsedImport,
|
||||
_innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── receiverBinding ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Look up `this` on the function scope's type bindings.
|
||||
*
|
||||
* `this` is synthesized as a type binding on instance-method function
|
||||
* scopes during capture emission (`receiver-binding.ts`). Arrow
|
||||
* functions and nested functions that reference `this` naturally
|
||||
* resolve it via the scope-chain walk — if the arrow function is a
|
||||
* class method (`m = () => {}`), it gets a synthesized `this`; if it
|
||||
* is nested inside a class method, the scope-chain lookup finds the
|
||||
* outer method's `this`. This mirrors TypeScript's lexical-this
|
||||
* semantics for arrow functions.
|
||||
*
|
||||
* Returns `null` for:
|
||||
* - static methods (no `this` synthesized)
|
||||
* - free functions / module-level code (no enclosing class-like)
|
||||
* - non-Function scopes
|
||||
*
|
||||
* Caveat: a non-arrow `function` declaration nested inside a method
|
||||
* DOES see the outer `this` via our scope-chain lookup, even though at
|
||||
* runtime its `this` is independently bound (strict-mode `undefined`,
|
||||
* sloppy `globalThis`). We accept this false-positive — the real-world
|
||||
* pattern that relies on independent `this` inside a nested regular
|
||||
* function inside a class method is extremely rare, and catching it
|
||||
* would require injecting a `this: undefined` shadow on every non-
|
||||
* arrow function scope. Documented as a known limitation in
|
||||
* `index.ts`.
|
||||
*/
|
||||
export function tsReceiverBinding(functionScope: Scope): TypeRef | null {
|
||||
if (functionScope.kind !== 'Function') return null;
|
||||
return functionScope.typeBindings.get('this') ?? null;
|
||||
}
|
||||
|
|
@ -68,6 +68,8 @@ const vueClassExtractor = createClassExtractor(vueClassConfig);
|
|||
export const vueProvider = defineLanguage({
|
||||
id: SupportedLanguages.Vue,
|
||||
extensions: ['.vue'],
|
||||
entryPointPatterns: [],
|
||||
astFrameworkPatterns: [],
|
||||
treeSitterQueries: TYPESCRIPT_QUERIES,
|
||||
typeConfig: typescriptConfig,
|
||||
exportChecker: tsExportChecker,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// gitnexus/src/core/ingestion/method-extractors/configs/swift.ts
|
||||
// Verified against tree-sitter-swift 0.6.0
|
||||
// Verified against tree-sitter-swift 0.7.x
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type {
|
||||
|
|
@ -110,7 +110,7 @@ function extractSwiftReturnType(node: SyntaxNode): string | undefined {
|
|||
function extractSwiftParameters(node: SyntaxNode): ParameterInfo[] {
|
||||
const params: ParameterInfo[] = [];
|
||||
|
||||
// In tree-sitter-swift 0.6.0, parameters are direct children of function_declaration.
|
||||
// In tree-sitter-swift, parameters are direct children of function_declaration.
|
||||
// Default value tokens ('=', literal) are siblings of the parameter node at the
|
||||
// function_declaration level, not children of the parameter node.
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
|
|
@ -264,8 +264,7 @@ function extractSwiftAnnotations(node: SyntaxNode): string[] {
|
|||
export const swiftMethodConfig: MethodExtractionConfig = {
|
||||
language: SupportedLanguages.Swift,
|
||||
|
||||
// tree-sitter-swift 0.6.0 may use class_declaration for classes, structs, enums, extensions,
|
||||
// and actors — but this cannot be verified until the grammar installs on Node 22+.
|
||||
// Keep this conservative until Swift type-shape coverage is expanded.
|
||||
// TODO: Verify struct_declaration, enum_declaration, extension_declaration, actor_declaration
|
||||
// node types once tree-sitter-swift loads on Node 22, and add them here if they are distinct.
|
||||
// protocol_declaration is a separate, confirmed node type.
|
||||
|
|
|
|||
|
|
@ -62,8 +62,21 @@ export interface ScopeResolutionIndexes {
|
|||
readonly methodDispatch: MethodDispatchIndex;
|
||||
/** Finalized `ImportEdge[]` per module scope. */
|
||||
readonly imports: ReadonlyMap<ScopeId, readonly ImportEdge[]>;
|
||||
/** Merged bindings (local + imports + wildcards) per module scope. */
|
||||
/** Finalize-output bindings (local + imports + wildcards) per module scope.
|
||||
* Inner `BindingRef[]` arrays are frozen by `materializeBindings`;
|
||||
* this channel is permanently immutable post-finalize. Consumers
|
||||
* MUST read via `lookupBindingsAt` so the augmentation channel is
|
||||
* consulted alongside. See I8 in `contract/scope-resolver.ts`. */
|
||||
readonly bindings: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
|
||||
/** Append-only post-finalize augmentation channel. Populated by
|
||||
* language hooks such as `populateNamespaceSiblings` for cross-file
|
||||
* bindings synthesized after finalize (e.g. C# same-namespace
|
||||
* visibility, `using static` member exposure). Inner arrays are
|
||||
* NOT frozen — hooks `push()` directly. Walkers must consult both
|
||||
* this map and `bindings` via `lookupBindingsAt`; finalized refs
|
||||
* are returned first and win duplicate `def.nodeId` metadata, with
|
||||
* unique augmentations appended after. See I8. */
|
||||
readonly bindingAugmentations: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
|
||||
/** Pre-resolution usage facts; consumed by the resolution phase. */
|
||||
readonly referenceSites: readonly ReferenceSite[];
|
||||
/** SCC condensation of the file-level import graph — callers that want
|
||||
|
|
|
|||
|
|
@ -48,7 +48,11 @@ import type {
|
|||
FileScopeBindings,
|
||||
ExtractedORMQuery,
|
||||
} from './workers/parse-worker.js';
|
||||
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';
|
||||
import {
|
||||
getTreeSitterBufferSize,
|
||||
getTreeSitterContentByteLength,
|
||||
TREE_SITTER_MAX_BUFFER,
|
||||
} from './constants.js';
|
||||
|
||||
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
|
||||
|
||||
|
|
@ -352,7 +356,7 @@ const processParsingSequential = async (
|
|||
}
|
||||
|
||||
// Skip files larger than the max tree-sitter buffer (32 MB)
|
||||
if (file.content.length > TREE_SITTER_MAX_BUFFER) continue;
|
||||
if (getTreeSitterContentByteLength(file.content) > TREE_SITTER_MAX_BUFFER) continue;
|
||||
|
||||
// Vue SFC preprocessing: extract <script> block content
|
||||
let parseContent = file.content;
|
||||
|
|
@ -375,7 +379,7 @@ const processParsingSequential = async (
|
|||
let tree: Parser.Tree;
|
||||
try {
|
||||
tree = parser.parse(parseContent, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(parseContent.length),
|
||||
bufferSize: getTreeSitterBufferSize(parseContent),
|
||||
});
|
||||
} catch (parseError) {
|
||||
console.warn(`Skipping unparseable file: ${file.path}`);
|
||||
|
|
@ -538,10 +542,13 @@ const processParsingSequential = async (
|
|||
}
|
||||
}
|
||||
|
||||
// Append #<paramCount> to Method/Constructor IDs to disambiguate overloads.
|
||||
// Functions are not suffixed — they don't overload by name in the same scope.
|
||||
// Append #<paramCount> to owned callable IDs to disambiguate overloads.
|
||||
// Top-level Function IDs stay stable; functions inside an owner may overload.
|
||||
// When same-arity collisions exist, append ~type1,type2 for further disambiguation.
|
||||
const needsAritySuffix = nodeLabel === 'Method' || nodeLabel === 'Constructor';
|
||||
const needsAritySuffix =
|
||||
nodeLabel === 'Method' ||
|
||||
nodeLabel === 'Constructor' ||
|
||||
(nodeLabel === 'Function' && enclosingClassId !== null);
|
||||
let arityTag = needsAritySuffix && arityForId !== undefined ? `#${arityForId}` : '';
|
||||
if (arityTag && seqDefMethods && seqDefMethodInfo && seqClassNodeId !== undefined) {
|
||||
// Use cached method map + collision groups (built once per class, not per method)
|
||||
|
|
@ -721,6 +728,14 @@ export const processParsing = async (
|
|||
onFileProgress?: FileProgressCallback,
|
||||
workerPool?: WorkerPool,
|
||||
): Promise<WorkerExtractedData | null> => {
|
||||
let lastProgress = 0;
|
||||
const reportProgress: FileProgressCallback | undefined = onFileProgress
|
||||
? (current, total, detail) => {
|
||||
lastProgress = Math.max(lastProgress, current);
|
||||
onFileProgress(lastProgress, total, detail);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (workerPool) {
|
||||
if (scopeTreeCache !== undefined && process.env.PROF_SCOPE_RESOLUTION === '1') {
|
||||
// Trees can't cross MessageChannels, so worker-parsed files land
|
||||
|
|
@ -738,12 +753,15 @@ export const processParsing = async (
|
|||
symbolTable,
|
||||
astCache,
|
||||
workerPool,
|
||||
onFileProgress,
|
||||
reportProgress,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'Worker pool parsing failed, falling back to sequential:',
|
||||
err instanceof Error ? err.message : err,
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn('Worker pool parsing stopped; continuing with sequential parser:', message);
|
||||
reportProgress?.(
|
||||
lastProgress,
|
||||
files.length,
|
||||
`Sequential fallback after worker issue: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -755,7 +773,7 @@ export const processParsing = async (
|
|||
symbolTable,
|
||||
astCache,
|
||||
scopeTreeCache,
|
||||
onFileProgress,
|
||||
reportProgress,
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue