Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32e5805882 | ||
|
|
1029a8ddd7 | ||
|
|
fa8ebf672e | ||
|
|
fb40d15a16 | ||
|
|
6ef173fc51 | ||
|
|
63527cf44d | ||
|
|
3d022c6aa9 | ||
|
|
42de00593b | ||
|
|
5aada28da5 | ||
|
|
1fc1a32d9c | ||
|
|
3abb0267d1 | ||
|
|
6a37ee1bdc | ||
|
|
0087ce4fa1 | ||
|
|
4c8aceecc2 | ||
|
|
0f9474cbf7 | ||
|
|
96bcd28d96 | ||
|
|
f9592c49ce | ||
|
|
365de846d1 | ||
|
|
859e4b75a4 | ||
|
|
35ebe37c42 | ||
|
|
400cc6a440 | ||
|
|
316aaed928 | ||
|
|
905b7dfa21 | ||
|
|
f5a2e6a248 | ||
|
|
e148bc089a | ||
|
|
9c5a174303 | ||
|
|
15583fc9e9 | ||
|
|
028bd11053 | ||
|
|
c1a1b2a553 | ||
|
|
8ad4469e96 | ||
|
|
a7df8f861a | ||
|
|
7ca7166b8e | ||
|
|
57e4afa4c8 | ||
|
|
c45d38f27a | ||
|
|
8bef64baa1 | ||
|
|
576e81442e | ||
|
|
d7ff76e6e9 | ||
|
|
269737982e | ||
|
|
5f667c32a3 | ||
|
|
9b8d31a1f2 | ||
|
|
e6f2296d00 | ||
|
|
a05a1659bd | ||
|
|
ba071b5bb3 | ||
|
|
5165686798 | ||
|
|
9aa65ae3f8 | ||
|
|
8886d55008 | ||
|
|
0936553d63 | ||
|
|
ca396e38bc | ||
|
|
47477e5554 | ||
|
|
698f5efc82 | ||
|
|
49ffd8e316 | ||
|
|
d27fd11c4b | ||
|
|
1a03c8527a | ||
|
|
b16ec344f7 | ||
|
|
1c8ad84796 | ||
|
|
d7da752cfb |
+2
-1
@@ -1,4 +1,5 @@
|
||||
# Code owners
|
||||
|
||||
* @Arvuno
|
||||
* @abhigyanpatwari
|
||||
* @magyargergo
|
||||
* @azizur100389
|
||||
|
||||
@@ -61,9 +61,10 @@ def _physical_vendor_grammars() -> set[str]:
|
||||
def _render_report() -> tuple[str, int]:
|
||||
"""Run main() with network mocked to mirror PRODUCTION; return (md, exit_code).
|
||||
|
||||
- npm grammars resolve to a permissive "Ready" peer dep, so the ONLY blocker
|
||||
left is the held vendored tree-sitter-c — letting us assert the hold is
|
||||
load-bearing (exit code stays non-zero because of it).
|
||||
- npm grammars resolve to a permissive "Ready" peer dep, so the only blockers
|
||||
left are the held vendored grammars (tree-sitter-c, tree-sitter-kotlin) plus
|
||||
the intentionally-pinned tree-sitter-cpp — letting us assert holds are
|
||||
load-bearing (exit code stays non-zero because of them).
|
||||
- npm_view_json records its calls so we can prove vendored grammars are never
|
||||
npm-queried.
|
||||
- fetch_text mirrors the real workflow: upstream parser.c resolves to a real
|
||||
@@ -363,13 +364,15 @@ class ReportRendering(TestCase):
|
||||
# Counts are derived from _render_report()'s mock corpus (all npm peer
|
||||
# deps mocked permissive): of the 10 npm-installed grammars, 9 render
|
||||
# Ready and 1 — tree-sitter-cpp — is the intentional pin (#1242), so it is
|
||||
# not counted ready. The 2 blockers are that same pinned tree-sitter-cpp
|
||||
# plus the vendored, ABI-held tree-sitter-c (the only out-of-range
|
||||
# vendored grammar). If a grammar is added/removed or a pin/hold changes,
|
||||
# not counted ready. The 3 blockers are that same pinned tree-sitter-cpp
|
||||
# plus two held vendored grammars: ABI-held tree-sitter-c (#1242/#858) and
|
||||
# tree-sitter-kotlin (pinned to an unreleased fwcd main commit for `fun
|
||||
# interface` support — ABI 14 is in range, but a hold counts as a blocker
|
||||
# until it is lifted). If a grammar is added/removed or a pin/hold changes,
|
||||
# update _render_report()'s mock AND these expected counts together; a
|
||||
# mismatch here means the report prose drifted, not the regex.
|
||||
self.assertEqual(ready.groups(), ("9", "10"))
|
||||
self.assertEqual(blockers.group(1), "2")
|
||||
self.assertEqual(blockers.group(1), "3")
|
||||
|
||||
def _matrix_row(self, name: str) -> str:
|
||||
for line in self.report.splitlines():
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
},
|
||||
"kotlin": {
|
||||
"name": "tree-sitter-kotlin",
|
||||
"upstream": { "npm": "tree-sitter-kotlin" }
|
||||
"upstream": { "npm": "tree-sitter-kotlin" },
|
||||
"hold": "pinned to unreleased fwcd main commit c8ac3d26 for `fun interface` support (fwcd/tree-sitter-kotlin#169, closes #87) — npm latest (0.3.8) lacks the fix, so the monitor must NOT auto-revert (isNewer is strict-inequality: 0.3.8 != 0.4.0). Drop this hold and bump when upstream cuts a release that includes the fix"
|
||||
},
|
||||
"dart": {
|
||||
"name": "tree-sitter-dart",
|
||||
|
||||
@@ -14,8 +14,9 @@ name: Build tree-sitter prebuilds
|
||||
# REQUIRED grammar)
|
||||
# - tree-sitter-dart (vendored source; built from gitnexus/vendor/)
|
||||
# - tree-sitter-proto (vendored source; built from gitnexus/vendor/)
|
||||
# - tree-sitter-kotlin (vendored source; built from the published npm package —
|
||||
# upstream ships source only)
|
||||
# - tree-sitter-kotlin (vendored source; built from gitnexus/vendor/ — pinned to
|
||||
# an unreleased main commit for `fun interface` support
|
||||
# (#169) that no npm release carries yet)
|
||||
# - tree-sitter-swift (vendored source; built from gitnexus/vendor/ — its
|
||||
# prebuilds were originally upstream-shipped, now
|
||||
# GitNexus-cross-built like the rest for uniformity)
|
||||
@@ -28,12 +29,20 @@ name: Build tree-sitter prebuilds
|
||||
# incl. macOS + arm64). It is DELIBERATELY NOT wired into normal PR/push CI. It
|
||||
# runs only:
|
||||
# 1. on manual dispatch (workflow_dispatch); or
|
||||
# 2. when a covered grammar's recorded version actually CHANGES — the `guard`
|
||||
# job is the real gate (it diffs the recorded version vs the PR base); the
|
||||
# `paths:` filter below only makes ordinary code PRs cost ZERO matrix time.
|
||||
# Net effect: an ordinary code PR triggers nothing; bumping one grammar costs
|
||||
# exactly one matrix run for that grammar, which opens a PR committing its rebuilt
|
||||
# binaries.
|
||||
# 2. when a covered grammar's VENDORED SOURCE changes in a PR — a version bump
|
||||
# OR an edit to the grammar's build-affecting source (parser.c / grammar.js /
|
||||
# binding.gyp / scanner / bindings). The `guard` job is the real gate (it
|
||||
# diffs BOTH the recorded version AND the source files vs the PR base); the
|
||||
# `paths:` filter below keeps ordinary code PRs at ZERO matrix time and
|
||||
# excludes the prebuilds the job commits back, so it never retriggers itself.
|
||||
# Net effect: an ordinary code PR triggers nothing; touching one grammar's source
|
||||
# costs exactly one matrix run for that grammar. Delivery of the rebuilt binaries:
|
||||
# - same-repo PR -> committed straight onto the PR's own branch (in the SAME PR);
|
||||
# - manual dispatch (open_pr=true) -> a fresh chore/ PR;
|
||||
# - fork PR -> the trusted commit-fork-prebuilds.yml (workflow_run) pushes them
|
||||
# onto the fork branch when "Allow edits by maintainers" is on, else
|
||||
# comments download-and-commit instructions. That consumer must be
|
||||
# on the DEFAULT branch to run, so it activates once merged to main.
|
||||
#
|
||||
# Concurrency convention: see CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention".
|
||||
#
|
||||
@@ -68,13 +77,16 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
# Vendored grammars: their version lives in the vendor snapshot package.json.
|
||||
- 'gitnexus/vendor/tree-sitter-c/package.json'
|
||||
- 'gitnexus/vendor/tree-sitter-dart/package.json'
|
||||
- 'gitnexus/vendor/tree-sitter-proto/package.json'
|
||||
- 'gitnexus/vendor/tree-sitter-kotlin/package.json'
|
||||
- 'gitnexus/vendor/tree-sitter-swift/package.json'
|
||||
# Transition window: kotlin's pin still lives here until it is vendored.
|
||||
# Any build-affecting change under a vendored grammar triggers a rebuild —
|
||||
# not just a version bump — so editing the vendored source (parser.c,
|
||||
# grammar.js, binding.gyp, scanner, bindings) re-cuts the prebuilds too.
|
||||
# The prebuilds we commit back are EXCLUDED (negated last) so the bot's own
|
||||
# in-PR commit can never retrigger this workflow (no build->commit->build loop).
|
||||
- 'gitnexus/vendor/tree-sitter-*/**'
|
||||
- '!gitnexus/vendor/tree-sitter-*/prebuilds/**'
|
||||
# Self-test: re-run the guard if a future grammar pin is reintroduced in
|
||||
# the main package.json (optionalDependencies fallback). No-op otherwise —
|
||||
# all five grammars are now fully vendored (kotlin included).
|
||||
- 'gitnexus/package.json'
|
||||
# Self-test: re-run the guard (normally a no-op) when the recipe changes.
|
||||
- '.github/workflows/build-tree-sitter-prebuilds.yml'
|
||||
@@ -102,7 +114,7 @@ jobs:
|
||||
matrix: ${{ steps.decide.outputs.matrix }}
|
||||
release_app: ${{ steps.relapp.outputs.configured }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0 # need base history to diff recorded versions
|
||||
persist-credentials: false
|
||||
@@ -135,7 +147,12 @@ jobs:
|
||||
c: { name: 'tree-sitter-c', kind: 'npm' },
|
||||
dart: { name: 'tree-sitter-dart', kind: 'vendored' },
|
||||
proto: { name: 'tree-sitter-proto', kind: 'vendored' },
|
||||
kotlin: { name: 'tree-sitter-kotlin', kind: 'npm' },
|
||||
// kotlin is vendored WITH its source (parser.c/scanner.c/binding.gyp),
|
||||
// so it builds from gitnexus/vendor/ like dart/proto/swift. It was
|
||||
// 'npm' while tracking released versions, but is now pinned to an
|
||||
// unreleased main commit for `fun interface` support (#169) that no
|
||||
// npm release carries yet — so it must build from the vendored source.
|
||||
kotlin: { name: 'tree-sitter-kotlin', kind: 'vendored' },
|
||||
// swift is vendored WITH its source (parser.c/scanner.c/binding.gyp),
|
||||
// so it builds from gitnexus/vendor/ like dart/proto. Its prebuilds
|
||||
// were originally upstream-shipped; rebuilding them here unifies it.
|
||||
@@ -184,8 +201,13 @@ jobs:
|
||||
// Resolve the base-ref recorded versions (pull_request only) so we can
|
||||
// diff. On dispatch, base is irrelevant (manual intent / force wins).
|
||||
const baseRoot = `${process.env.RUNNER_TEMP}/base`;
|
||||
const baseSha = process.env.BASE_SHA;
|
||||
// Defense in depth: baseSha is interpolated into git commands below, so
|
||||
// reject anything that is not a plain commit-ish before we touch a shell.
|
||||
if (event === 'pull_request' && baseSha && !/^[0-9a-fA-F]{7,40}$/.test(baseSha)) {
|
||||
throw new Error(`unexpected base sha '${baseSha}'`);
|
||||
}
|
||||
if (event === 'pull_request') {
|
||||
const baseSha = process.env.BASE_SHA;
|
||||
for (const s of selected) {
|
||||
const name = REGISTRY[s].name;
|
||||
for (const rel of [`gitnexus/vendor/${name}/package.json`, `gitnexus/package.json`]) {
|
||||
@@ -219,9 +241,24 @@ jobs:
|
||||
if (event === 'workflow_dispatch') {
|
||||
build = true; // manual intent (force toggles only the unchanged-guard, which is bypassed here)
|
||||
} else {
|
||||
// pull_request: build when the recorded version changed OR any
|
||||
// build-affecting source file under the vendored grammar changed vs
|
||||
// the PR base. The prebuilds/ subtree is excluded from the diff so
|
||||
// the bot's own in-PR commit (which adds ONLY prebuilds) never reads
|
||||
// as a source change — this is the other half of the no-loop guard.
|
||||
const base = recordedVersion(baseRoot, name);
|
||||
build = !!head && head !== base;
|
||||
console.log(`${short}: head='${head || '<absent>'}' base='${base || '<absent>'}' -> ${build ? 'BUILD' : 'skip'}`);
|
||||
const versionChanged = !!head && head !== base;
|
||||
let sourceChanged = false;
|
||||
try {
|
||||
const diff = execSync(
|
||||
`git diff --name-only ${baseSha} -- gitnexus/vendor/${name} ` +
|
||||
`':(exclude)gitnexus/vendor/${name}/prebuilds/**'`,
|
||||
{ stdio: ['ignore', 'pipe', 'ignore'] },
|
||||
).toString().trim();
|
||||
sourceChanged = diff.length > 0;
|
||||
} catch { /* base unavailable -> fall back to the version gate */ }
|
||||
build = versionChanged || sourceChanged;
|
||||
console.log(`${short}: version ${versionChanged ? 'changed' : 'same'}, source ${sourceChanged ? 'changed' : 'same'} -> ${build ? 'BUILD' : 'skip'}`);
|
||||
}
|
||||
if (force) build = true;
|
||||
if (!build) continue;
|
||||
@@ -255,6 +292,47 @@ jobs:
|
||||
echo "::notice::Release GitHub App secrets (RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY) are not configured — prebuilds will build and upload as artifacts, but the auto-PR is skipped. Provision the App, or run with open_pr=false to suppress this notice."
|
||||
fi
|
||||
|
||||
# ── Fork PRs: emit the PR identity so the trusted `commit-fork-prebuilds`
|
||||
# workflow_run job can push the rebuilt prebuilds back onto the fork's
|
||||
# branch. That job has no PR context of its own (workflow_run.pull_requests
|
||||
# is empty for forks), so it reads this. Same-repo PRs don't need it — the
|
||||
# aggregate job below commits straight onto their branch. This artifact is
|
||||
# untrusted producer output: every field is allowlist-validated again on
|
||||
# the consumer side AND cross-checked against the workflow_run authority.
|
||||
- name: Record fork PR identity
|
||||
id: forkmeta
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true && steps.decide.outputs.any == 'true'
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
BASE_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/pr-meta"
|
||||
# Values flow through env + jq so an exotic head_ref is quoted, never
|
||||
# interpolated into a shell command.
|
||||
jq -n \
|
||||
--arg schema "gitnexus.ts-prebuild/v1" \
|
||||
--argjson pr_number "$PR_NUMBER" \
|
||||
--arg head_sha "$HEAD_SHA" \
|
||||
--arg head_ref "$HEAD_REF" \
|
||||
--arg head_repo "$HEAD_REPO" \
|
||||
--arg base_repo "$BASE_REPO" \
|
||||
'{schema:$schema, pr_number:$pr_number, head_sha:$head_sha, head_ref:$head_ref, head_repo:$head_repo, base_repo:$base_repo}' \
|
||||
> "$RUNNER_TEMP/pr-meta/metadata.json"
|
||||
cat "$RUNNER_TEMP/pr-meta/metadata.json"
|
||||
|
||||
- name: Upload fork PR meta
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true && steps.decide.outputs.any == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: pr-meta
|
||||
path: ${{ runner.temp }}/pr-meta/metadata.json
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# ── Build one native prebuild per (grammar, platform-arch). No cross-compile. ─
|
||||
build:
|
||||
name: ${{ matrix.grammar }} ${{ matrix.platform_arch }}
|
||||
@@ -270,7 +348,7 @@ jobs:
|
||||
# and compiling them under emulation on the arm runners is slow.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false # this job uploads artifacts (artipacked)
|
||||
|
||||
@@ -280,7 +358,7 @@ jobs:
|
||||
|
||||
- name: Ensure Python (arm64 Windows only)
|
||||
if: matrix.platform_arch == 'win32-arm64'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
@@ -402,16 +480,18 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# ── Aggregate every grammar's six prebuilds, assert completeness, open a PR. ─
|
||||
# ── Aggregate every grammar's six prebuilds, assert completeness, deliver them. ─
|
||||
aggregate:
|
||||
name: Vendor prebuilds + open PR
|
||||
name: Vendor prebuilds + deliver
|
||||
needs: [guard, build]
|
||||
# Open the prebuild PR on a non-fork pull_request that bumped a grammar
|
||||
# version (the documented version-change -> prebuild-PR flow), or on a manual
|
||||
# dispatch with open_pr=true. Event-gating is explicit so we never rely on
|
||||
# GHA coercing a null `inputs.open_pr` on pull_request events (Codex F4):
|
||||
# `inputs.open_pr` is null off-dispatch, and `null != false` is direction-
|
||||
# ambiguous, so `open_pr` is only consulted on workflow_dispatch.
|
||||
# Runs on a non-fork pull_request whose vendored grammar source changed — the
|
||||
# rebuilt prebuilds are committed straight onto that PR's own branch (same PR)
|
||||
# — or on a manual dispatch with open_pr=true, which opens a fresh chore/ PR.
|
||||
# Fork PRs are excluded: a bot cannot push into a fork branch, so they get
|
||||
# artifacts only. Event-gating is explicit so we never rely on GHA coercing a
|
||||
# null `inputs.open_pr` on pull_request events (Codex F4): `inputs.open_pr` is
|
||||
# null off-dispatch, and `null != false` is direction-ambiguous, so `open_pr`
|
||||
# is only consulted on workflow_dispatch.
|
||||
if: >-
|
||||
needs.guard.outputs.any == 'true' &&
|
||||
needs.guard.outputs.release_app == 'true' &&
|
||||
@@ -431,9 +511,13 @@ jobs:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
# On a (non-fork) PR, check out the PR's HEAD branch — not the merge ref —
|
||||
# so the rebuilt-prebuilds commit lands on the PR's own branch (same PR).
|
||||
# Empty on manual dispatch -> the workflow's default ref.
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || '' }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download all prebuild artifacts
|
||||
@@ -481,7 +565,7 @@ jobs:
|
||||
with:
|
||||
subject-path: 'gitnexus/vendor/tree-sitter-*/prebuilds/**/*.node'
|
||||
|
||||
- name: Create or update PR
|
||||
- name: Deliver rebuilt prebuilds
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
GRAMMARS: ${{ steps.place.outputs.grammars }}
|
||||
@@ -493,8 +577,8 @@ jobs:
|
||||
const { execSync } = require('node:child_process');
|
||||
const run = (c) => execSync(c, { stdio: ['ignore', 'pipe', 'inherit'] }).toString().trim();
|
||||
const grammars = process.env.GRAMMARS;
|
||||
const slug = grammars.replace(/[^a-z0-9]+/gi, '-');
|
||||
const branch = `chore/vendor-ts-prebuilds-${slug}-${context.runId}`;
|
||||
const { owner, repo } = context.repo;
|
||||
const remote = `https://x-access-token:${process.env.GH_TOKEN}@github.com/${owner}/${repo}.git`;
|
||||
|
||||
run('git add gitnexus/vendor/tree-sitter-*/prebuilds');
|
||||
if (!run('git status --porcelain -- gitnexus/vendor/tree-sitter-*/prebuilds')) {
|
||||
@@ -503,16 +587,35 @@ jobs:
|
||||
}
|
||||
run('git config user.name "gitnexus-release-bot[bot]"');
|
||||
run('git config user.email "gitnexus-release-bot[bot]@users.noreply.github.com"');
|
||||
run(`git commit -m "chore(vendor): rebuild native prebuilds (${grammars})" -m "Built by ${process.env.RUN_URL}"`);
|
||||
|
||||
// ── Same-repo PR: ride the rebuilt prebuilds into the SAME PR by
|
||||
// pushing one commit onto its head branch. The aggregate checkout
|
||||
// used `ref: head.ref`, so HEAD is the PR branch tip (NOT the merge
|
||||
// ref) and this is a clean fast-forward of exactly our new commit.
|
||||
// Plain push (NOT --force): we only ever ADD on top of head, so we
|
||||
// must never clobber the contributor's commits. If the branch
|
||||
// advanced mid-build the push is rejected — and the PR's
|
||||
// cancel-in-progress concurrency will already have started a fresher
|
||||
// run against the new head — so a rejection is a no-op we just note.
|
||||
if (context.eventName === 'pull_request') {
|
||||
const headRef = context.payload.pull_request.head.ref;
|
||||
try {
|
||||
run(`git push "${remote}" "HEAD:${headRef}"`);
|
||||
core.notice(`Pushed rebuilt prebuilds onto PR branch '${headRef}' (included in this PR).`);
|
||||
} catch (e) {
|
||||
core.warning(`Could not fast-forward '${headRef}' (it likely advanced mid-build); a fresher run will rebuild. ${e.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Manual dispatch: there is no PR to attach to, so open a fresh one
|
||||
// off an ephemeral, run-unique branch. Plain --force is safe here:
|
||||
// the branch is keyed by context.runId and written ONLY by this job,
|
||||
// so there is no concurrent writer to protect against.
|
||||
const slug = grammars.replace(/[^a-z0-9]+/gi, '-');
|
||||
const branch = `chore/vendor-ts-prebuilds-${slug}-${context.runId}`;
|
||||
run(`git checkout -b "${branch}"`);
|
||||
run(`git commit -m "chore(vendor): rebuild native prebuilds (${grammars})\n\nBuilt by ${process.env.RUN_URL}"`);
|
||||
const { owner, repo } = context.repo;
|
||||
const remote = `https://x-access-token:${process.env.GH_TOKEN}@github.com/${owner}/${repo}.git`;
|
||||
// Plain --force, not --force-with-lease: the branch is ephemeral and
|
||||
// unique per run (keyed by context.runId), written ONLY by this job, so
|
||||
// there is no concurrent writer to protect against. --force-with-lease
|
||||
// would compare against a remote-tracking ref this fresh checkout never
|
||||
// fetched, so re-running the SAME run (branch already pushed by attempt
|
||||
// 1) fails with "stale info" instead of overwriting.
|
||||
run(`git push --force "${remote}" "HEAD:${branch}"`);
|
||||
const body = [
|
||||
`Rebuilt the vendored native prebuilds for: **${grammars}**.`,
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
# persist-credentials: false — this job only reads (tests and syntax
|
||||
# checks) and never pushes. The setting keeps GITHUB_TOKEN out of
|
||||
# .git/config, which zizmor flags as the "artipacked" issue.
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# persist-credentials: false — this is a read-only build smoke that
|
||||
# never pushes. The setting keeps GITHUB_TOKEN out of .git/config,
|
||||
# which zizmor flags as the "artipacked" issue.
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
||||
@@ -14,7 +14,9 @@ jobs:
|
||||
outputs:
|
||||
web_changed: ${{ steps.filter.outputs.web }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3
|
||||
id: filter
|
||||
with:
|
||||
@@ -29,7 +31,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure e2e GitNexus home
|
||||
run: echo "GITNEXUS_HOME=${RUNNER_TEMP}/gitnexus-home" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -11,7 +11,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -24,7 +26,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -37,7 +41,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- run: npx tsc --noEmit
|
||||
working-directory: gitnexus
|
||||
@@ -46,7 +52,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus-web
|
||||
- run: npx tsc -b --noEmit
|
||||
working-directory: gitnexus-web
|
||||
@@ -67,7 +75,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Validate workflow concurrency convention
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
|
||||
- name: Checkout (for vitest config)
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
sparse-checkout: gitnexus/vitest.config.ts
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
@@ -11,12 +11,16 @@ jobs:
|
||||
name: ubuntu / coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
# Fail loudly (don't silently skip) if the FTS extension is unavailable, so
|
||||
# FTS-dependent lbug integration suites are guaranteed to run in CI.
|
||||
env:
|
||||
GITNEXUS_REQUIRE_FTS: '1'
|
||||
steps:
|
||||
# persist-credentials: false — this job runs tests and uploads a
|
||||
# test-reports artifact (if: always()). The default-persisted token in
|
||||
# .git/config must not be capturable through that upload (zizmor
|
||||
# credential-persistence / artipacked audit). The job never pushes.
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
@@ -77,10 +81,14 @@ jobs:
|
||||
os: [windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
# Same guarantee on the platform-sensitive runners: FTS-dependent suites in
|
||||
# the cross-platform subset must run, not silently skip.
|
||||
env:
|
||||
GITNEXUS_REQUIRE_FTS: '1'
|
||||
steps:
|
||||
# persist-credentials: false — runs tests only, never pushes (zizmor
|
||||
# credential-persistence / artipacked audit).
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
@@ -106,7 +114,9 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
@@ -138,7 +148,7 @@ jobs:
|
||||
# from a tarball and never pushes back; the token in .git/config would
|
||||
# be at risk of leaking through any future artifact-upload step
|
||||
# (zizmor artipacked audit). Disable upfront.
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
@@ -246,7 +256,7 @@ jobs:
|
||||
# and never pushes; the default-persisted token in .git/config would be at
|
||||
# risk of leaking through an artifact upload (zizmor credential-persistence
|
||||
# / artipacked audit). Mirrors the packaged-install-smoke job below.
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
core.setOutput('code_review', isCodeReview ? 'true' : 'false');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }}
|
||||
ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }}
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# Don't leave GITHUB_TOKEN in .git/config for downstream steps to read.
|
||||
persist-credentials: false
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
name: Commit fork prebuilds
|
||||
|
||||
# TRUSTED HALF of the vendored-grammar prebuild pipeline — FORK PRs only.
|
||||
#
|
||||
# `build-tree-sitter-prebuilds.yml` runs in the UNTRUSTED `pull_request`
|
||||
# context. On a fork PR it has a read-only token and no secrets, so it can
|
||||
# build + validate the native prebuilds and upload them as artifacts, but it
|
||||
# cannot commit them back. This workflow is the trusted consumer: triggered by
|
||||
# `workflow_run`, it runs from the DEFAULT BRANCH's copy of this file (the trust
|
||||
# anchor) with a writable token, downloads ONLY the artifacts (data — the
|
||||
# already-built-and-validated `.node` files + a small metadata.json), verifies
|
||||
# the metadata against the GitHub-controlled workflow_run authority, then pushes
|
||||
# the prebuilds onto the fork PR's head branch.
|
||||
#
|
||||
# It NEVER checks out or executes fork-controlled code: the producer already
|
||||
# `require()`-loaded + parsed each `.node` on its target platform in the
|
||||
# untrusted half (the correct place to run untrusted code). Here we only move
|
||||
# bytes and run git. The prebuilds touch ONLY gitnexus/vendor/<g>/prebuilds/**,
|
||||
# never .github/ — so the GITHUB_TOKEN's lack of `workflows` scope is irrelevant.
|
||||
#
|
||||
# Pushing to a fork branch with the GITHUB_TOKEN works only when the contributor
|
||||
# left "Allow edits by maintainers" enabled (the PR default) — the same
|
||||
# constraint as pr-autofix-apply.yml. When it's off we fall back to a comment.
|
||||
#
|
||||
# Same-repo PRs do NOT come here: they have secrets in the producer run, so the
|
||||
# `aggregate` job in build-tree-sitter-prebuilds.yml commits straight onto their
|
||||
# branch. This workflow's `if:` filters to forks.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Build tree-sitter prebuilds']
|
||||
types: [completed]
|
||||
|
||||
concurrency:
|
||||
# Per-PR identity, NOT workflow_run.id (which is per-run unique and would
|
||||
# defeat serialization). Fork PRs have an empty pull_requests[] in the
|
||||
# workflow_run payload, so fall back to head-repo + head-branch.
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
deliver:
|
||||
name: deliver-fork-prebuilds
|
||||
# Only a SUCCESSFUL fork pull_request producer run. Same-repo PRs
|
||||
# (head_repository == base) are handled by the producer's aggregate job.
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
&& github.event.workflow_run.conclusion == 'success'
|
||||
&& github.event.workflow_run.head_repository.full_name != github.repository
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write # push the prebuilds commit to the fork PR head branch
|
||||
pull-requests: write # comment the delivery outcome
|
||||
actions: read # download artifacts produced by the producer run
|
||||
steps:
|
||||
# Pinned to v8.0.1 (same SHA used across this repo's workflows).
|
||||
- name: Download prebuild artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
pattern: ts-prebuild-*
|
||||
path: prebuilds-in
|
||||
|
||||
- name: Download PR meta
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: pr-meta
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path: meta-in
|
||||
|
||||
- name: Read and validate metadata
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# No meta => this producer run had no fork-PR prebuilds to deliver
|
||||
# (nothing changed, or it wasn't a fork). Exit cleanly.
|
||||
if [ ! -f meta-in/metadata.json ]; then
|
||||
echo "No pr-meta artifact — nothing to deliver."
|
||||
echo "deliver=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# No prebuild artifacts => same (defensive; producer uploads both together).
|
||||
if ! ls prebuilds-in/ts-prebuild-* >/dev/null 2>&1; then
|
||||
echo "No ts-prebuild-* artifacts — nothing to deliver."
|
||||
echo "deliver=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
jq . meta-in/metadata.json
|
||||
|
||||
# The artifact comes from the untrusted producer running fork code.
|
||||
# Allowlist EVERY field before it flows into $GITHUB_OUTPUT — a newline
|
||||
# in head_ref would otherwise inject a second output line and redirect
|
||||
# this job's write-scoped push/comment onto a victim PR.
|
||||
assert_field() {
|
||||
local key="$1" pattern="$2" value
|
||||
value=$(jq -r ".${key} // empty" meta-in/metadata.json)
|
||||
if [ -z "$value" ] || ! [[ "$value" =~ $pattern ]]; then
|
||||
echo "::error::metadata.${key} failed allowlist (got: $(printf '%q' "$value"))"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
SCHEMA=$(assert_field schema '^gitnexus\.ts-prebuild/v[0-9]+$')
|
||||
PR_NUMBER=$(assert_field pr_number '^[0-9]+$')
|
||||
HEAD_SHA=$(assert_field head_sha '^[0-9a-f]{40}$')
|
||||
HEAD_REF=$(assert_field head_ref '^[A-Za-z0-9._/-]+$')
|
||||
HEAD_REPO=$(assert_field head_repo '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$')
|
||||
BASE_REPO=$(assert_field base_repo '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$')
|
||||
|
||||
# Defence-in-depth: refuse to act if the artifact claims another repo.
|
||||
if [ "$BASE_REPO" != "${GITHUB_REPOSITORY}" ]; then
|
||||
echo "::error::Artifact base_repo does not match \$GITHUB_REPOSITORY — refusing to deliver."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "deliver=true"
|
||||
echo "schema=${SCHEMA}"
|
||||
echo "pr_number=${PR_NUMBER}"
|
||||
echo "head_sha=${HEAD_SHA}"
|
||||
echo "head_ref=${HEAD_REF}"
|
||||
echo "head_repo=${HEAD_REPO}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Cross-verify the artifact's claimed identity against the GitHub-controlled
|
||||
# workflow_run event. The allowlist above only proves the fields are
|
||||
# well-formed — not that they refer to the PR/SHA that actually triggered
|
||||
# us. A fork-controlled build could mutate metadata.json to reference
|
||||
# another PR/SHA and redirect our write-scoped push. Authority sources are
|
||||
# all server-controlled: workflow_run.head_sha, head_repository.full_name,
|
||||
# and pull_requests[].number (empty on forks -> commits/{sha}/pulls).
|
||||
- name: Verify metadata against workflow_run authority
|
||||
if: steps.meta.outputs.deliver == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
META_PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
|
||||
META_HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
|
||||
META_HEAD_REPO: ${{ steps.meta.outputs.head_repo }}
|
||||
WF_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
WF_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
WF_PR_NUMBERS: ${{ toJSON(github.event.workflow_run.pull_requests.*.number) }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 1) head_sha must match exactly — the commit GitHub ran the producer against.
|
||||
if [ "${META_HEAD_SHA}" != "${WF_HEAD_SHA}" ]; then
|
||||
echo "::error::Artifact head_sha (${META_HEAD_SHA}) != workflow_run.head_sha (${WF_HEAD_SHA}) — refusing."
|
||||
exit 1
|
||||
fi
|
||||
# 2) head_repo must match exactly.
|
||||
if [ "${META_HEAD_REPO}" != "${WF_HEAD_REPO}" ]; then
|
||||
echo "::error::Artifact head_repo (${META_HEAD_REPO}) != workflow_run.head_repository (${WF_HEAD_REPO}) — refusing."
|
||||
exit 1
|
||||
fi
|
||||
# 3) pr_number must reference an open PR with this head SHA. Forks have
|
||||
# an empty pull_requests[] by design — fall back to commits/{sha}/pulls.
|
||||
allowed_numbers=$(jq -c '.' <<< "${WF_PR_NUMBERS}")
|
||||
if [ "${allowed_numbers}" = "[]" ]; then
|
||||
echo "workflow_run.pull_requests empty (fork) — using commits/{sha}/pulls."
|
||||
allowed_numbers=$(gh api "repos/${GH_REPO}/commits/${WF_HEAD_SHA}/pulls" \
|
||||
--jq '[.[] | select(.state == "open") | .number]' 2>/dev/null || echo "[]")
|
||||
if [ "${allowed_numbers}" = "[]" ]; then
|
||||
echo "::error::No open PR for head ${WF_HEAD_SHA} — refusing."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if ! jq -e --argjson n "${META_PR_NUMBER}" 'index($n) != null' <<< "${allowed_numbers}" >/dev/null; then
|
||||
echo "::error::Artifact pr_number (${META_PR_NUMBER}) not in authoritative list (${allowed_numbers}) — refusing."
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified identity: PR=${META_PR_NUMBER} head_sha=${META_HEAD_SHA} head_repo=${META_HEAD_REPO}."
|
||||
|
||||
# Pinned to v6.0.3 (same SHA used by build-tree-sitter-prebuilds.yml).
|
||||
# persist-credentials: false — push auth is provided inline at push time,
|
||||
# never written to .git/config on disk.
|
||||
- name: Checkout fork PR head
|
||||
if: steps.meta.outputs.deliver == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: ${{ steps.meta.outputs.head_repo }}
|
||||
ref: ${{ steps.meta.outputs.head_sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
path: pr-checkout
|
||||
|
||||
- name: Place prebuilds into the fork checkout
|
||||
if: steps.meta.outputs.deliver == 'true'
|
||||
env:
|
||||
DL: prebuilds-in
|
||||
CHECKOUT: pr-checkout
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --input-type=module - <<'NODE'
|
||||
import fs from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
const dl = process.env.DL;
|
||||
const checkout = process.env.CHECKOUT;
|
||||
const PLATFORMS = ['linux-x64', 'linux-arm64', 'darwin-arm64', 'darwin-x64', 'win32-x64', 'win32-arm64'];
|
||||
// Reconstruct {grammar -> archs} from the downloaded artifact dir names
|
||||
// (ts-prebuild-<grammar>-<platform-arch>; grammar shortnames are dash-free).
|
||||
const byGrammar = {};
|
||||
for (const d of (fs.existsSync(dl) ? fs.readdirSync(dl) : [])) {
|
||||
const m = d.match(/^ts-prebuild-([a-z0-9]+)-(.+)$/);
|
||||
if (m) (byGrammar[m[1]] ||= []).push(m[2]);
|
||||
}
|
||||
const grammars = Object.keys(byGrammar);
|
||||
if (grammars.length === 0) throw new Error('no ts-prebuild-* artifacts present');
|
||||
const changed = [];
|
||||
for (const grammar of grammars) {
|
||||
const name = `tree-sitter-${grammar}`;
|
||||
const dest = `${checkout}/gitnexus/vendor/${name}/prebuilds`;
|
||||
// A grammar with 5/6 prebuilds silently breaks node-gyp-build on the
|
||||
// 6th platform — refuse a partial result.
|
||||
for (const pa of PLATFORMS) {
|
||||
const art = `${dl}/ts-prebuild-${grammar}-${pa}/${name}.node`;
|
||||
if (!fs.existsSync(art)) throw new Error(`missing ${grammar} prebuild for ${pa}`);
|
||||
fs.mkdirSync(`${dest}/${pa}`, { recursive: true });
|
||||
fs.copyFileSync(art, `${dest}/${pa}/${name}.node`);
|
||||
}
|
||||
execSync(`cd ${dest} && find . -name "*.node" | sort | xargs sha256sum > SHA256SUMS`);
|
||||
changed.push(name);
|
||||
}
|
||||
console.log('Placed prebuilds for:', changed.join(', '));
|
||||
NODE
|
||||
|
||||
- name: Commit and push to the fork branch
|
||||
id: push
|
||||
if: steps.meta.outputs.deliver == 'true'
|
||||
working-directory: pr-checkout
|
||||
env:
|
||||
HEAD_REF: ${{ steps.meta.outputs.head_ref }}
|
||||
HEAD_REPO: ${{ steps.meta.outputs.head_repo }}
|
||||
HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
|
||||
# Push auth only — supplied via env, never interpolated into the command.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git add gitnexus/vendor/tree-sitter-*/prebuilds
|
||||
if git diff --cached --quiet; then
|
||||
echo "Prebuilds byte-identical to the fork branch — nothing to commit."
|
||||
echo "result=nothing-to-commit" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Loop guard: if HEAD is already our prebuild bot commit, don't stack
|
||||
# another. (The producer's paths filter already excludes prebuilds/**,
|
||||
# so a prebuild-only push cannot retrigger it — this is defence in depth.)
|
||||
head_author=$(git log -1 --format='%ae' HEAD)
|
||||
head_subject=$(git log -1 --format='%s' HEAD)
|
||||
if [ "${head_author}" = "41898282+github-actions[bot]@users.noreply.github.com" ] \
|
||||
&& [[ "${head_subject}" =~ ^chore\(vendor\) ]]; then
|
||||
echo "::warning::HEAD is already a prebuild bot commit — refusing to re-apply."
|
||||
echo "result=loop-prevented" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
grammars=$(git diff --cached --name-only \
|
||||
| sed -n 's#gitnexus/vendor/\(tree-sitter-[a-z0-9]*\)/.*#\1#p' | sort -u | paste -sd, -)
|
||||
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
git commit -q -m "chore(vendor): rebuild native prebuilds (${grammars})" \
|
||||
-m "Built + validated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
|
||||
# Push to the fork head with a lease against the resolved SHA, so a
|
||||
# contributor force-push during the build surfaces as lease-failed (not
|
||||
# push-failed, which would mislead them into the maintainer-edit fix).
|
||||
# Auth via per-invocation http.extraheader (never persisted, never in
|
||||
# the process args / git remote -v). Base64-encoded form is masked too.
|
||||
push_url="${GITHUB_SERVER_URL}/${HEAD_REPO}.git"
|
||||
auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 -w0)"
|
||||
echo "::add-mask::${auth_header}"
|
||||
push_stderr=$(mktemp)
|
||||
if git -c http.extraheader="${auth_header}" \
|
||||
push --force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" \
|
||||
"${push_url}" "HEAD:${HEAD_REF}" 2>"$push_stderr"; then
|
||||
echo "result=applied" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
cat "$push_stderr" >&2
|
||||
if grep -qE "stale info|force-with-lease|rejected.*non-fast-forward|remote rejected|! \[rejected\]" "$push_stderr"; then
|
||||
echo "::error::Push lease failed — fork branch moved during build."
|
||||
echo "result=lease-failed" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::Push failed — likely a fork without 'Allow edits by maintainers'."
|
||||
echo "result=push-failed" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Comment delivery outcome
|
||||
if: always() && steps.meta.outputs.deliver == 'true' && steps.push.outcome != 'skipped'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
PR: ${{ steps.meta.outputs.pr_number }}
|
||||
RESULT: ${{ steps.push.outputs.result }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
marker="<!-- gitnexus:ts-prebuild-fork -->"
|
||||
case "${RESULT}" in
|
||||
applied)
|
||||
body="${marker}
|
||||
✅ **Rebuilt native prebuilds pushed to this PR branch.** A grammar source change re-cut the vendored \`tree-sitter\` prebuilds for all 6 platforms and they're now committed on your branch. ([builder run](${RUN_URL}))" ;;
|
||||
nothing-to-commit)
|
||||
body="${marker}
|
||||
✅ Native prebuilds are already up to date on this branch — nothing to push." ;;
|
||||
loop-prevented)
|
||||
body="${marker}
|
||||
🔁 Skipping prebuild push: the branch HEAD is already an automated prebuild commit." ;;
|
||||
lease-failed)
|
||||
body="${marker}
|
||||
⏳ The PR head moved while the prebuilds were building, so they weren't pushed. Push another commit (or wait for the next build) and they'll be re-cut. ([builder run](${RUN_URL}))" ;;
|
||||
push-failed)
|
||||
body="${marker}
|
||||
⚠️ Rebuilt native prebuilds are ready but **couldn't be pushed to your fork branch**. Tick **Allow edits by maintainers** in the PR sidebar so CI can commit them — or download them from the [builder run](${RUN_URL}) artifacts (\`ts-prebuild-*\`) and commit them under \`gitnexus/vendor/<grammar>/prebuilds/\` yourself." ;;
|
||||
*)
|
||||
body="${marker}
|
||||
❓ Prebuild delivery finished in an unexpected state (\`${RESULT:-unknown}\`). See the [builder run](${RUN_URL})." ;;
|
||||
esac
|
||||
# Strip the YAML block indent so the rendered comment starts at column 0.
|
||||
body="$(printf '%s\n' "$body" | sed 's/^ //')"
|
||||
|
||||
# Upsert a single sticky comment keyed by the marker; only ever edit our
|
||||
# own bot comment (PATCH on someone else's 403s and would abort).
|
||||
existing=$(gh api "repos/${GH_REPO}/issues/${PR}/comments" --paginate \
|
||||
--jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"${marker}\"))) | .id" \
|
||||
| head -n1 || true)
|
||||
if [ -n "${existing}" ]; then
|
||||
gh api -X PATCH "repos/${GH_REPO}/issues/comments/${existing}" -f body="${body}" >/dev/null
|
||||
echo "Updated comment ${existing}."
|
||||
else
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" -f body="${body}" >/dev/null
|
||||
echo "Created delivery comment."
|
||||
fi
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
# When triggered by workflow_call the caller passes the RC tag as an input;
|
||||
# we check out that tag so the Dockerfile and package.json match the built image.
|
||||
# For tag-push events github.ref is already the tag ref — no override needed.
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# Full history needed for the on-push full-history scan; on PRs the
|
||||
# action diffs against the base ref so the cost is bounded by the PR.
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
# artifact and never pushes; the default-persisted token in .git/config
|
||||
# must not be capturable through that upload (zizmor credential-persistence
|
||||
# / artipacked audit). Mirrors ci-tests.yml.
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -336,7 +336,7 @@ jobs:
|
||||
# Push auth is provided inline at push time via the URL.
|
||||
- name: Checkout PR head
|
||||
if: steps.locate.outputs.found == 'true'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5.0.4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v5.0.4
|
||||
with:
|
||||
repository: ${{ steps.locate.outputs.head_repo }}
|
||||
ref: ${{ steps.locate.outputs.head_sha }}
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# PR head commit (not the synthetic merge ref) — we need the
|
||||
# exact tree the contributor pushed so suggestions line up.
|
||||
|
||||
@@ -108,7 +108,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@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
|
||||
- uses: release-drafter/release-drafter@ed4bc48ec97379be2258e7b7ac2624a3e26ab809 # v7.4.0
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
dry-run: true
|
||||
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
should_run: ${{ steps.decide.outputs.should_run }}
|
||||
head_sha: ${{ steps.decide.outputs.head_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@@ -332,7 +332,7 @@ jobs:
|
||||
# on the RC path.
|
||||
- name: Checkout (RC)
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@@ -349,7 +349,7 @@ jobs:
|
||||
|
||||
- name: Checkout (stable)
|
||||
if: needs.route.outputs.mode == 'stable'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# No `token:` — actions/checkout uses GITHUB_TOKEN by default. Stable
|
||||
# path performs no git pushes; the default scope is sufficient.
|
||||
with:
|
||||
@@ -807,7 +807,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v2
|
||||
with:
|
||||
tag_name: ${{ steps.vtag-gate.outputs.vtag }}
|
||||
name: >-
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -52,7 +52,9 @@ jobs:
|
||||
report: ${{ steps.readiness.outputs.report }}
|
||||
exit_code: ${{ steps.readiness.outputs.exit_code }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
|
||||
@@ -59,14 +59,14 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
sparse-checkout: .github/scripts/triage
|
||||
sparse-checkout-cone-mode: false
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
run: pip install -r .github/scripts/triage/requirements.txt
|
||||
|
||||
- name: Cache FastEmbed model weights
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/.fastembed_cache
|
||||
key: fastembed-bge-small-en-v1.5
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -53,12 +53,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
||||
@@ -23,6 +23,18 @@ rules:
|
||||
# comment in the file documents the split.
|
||||
- pr-autofix-publish.yml
|
||||
|
||||
# workflow_run is the trusted half of the vendored-grammar prebuild
|
||||
# pipeline (commit-fork-prebuilds.yml). The untrusted producer
|
||||
# (build-tree-sitter-prebuilds.yml on a fork pull_request) builds +
|
||||
# validates the .node prebuilds and uploads them as artifacts. This
|
||||
# consumer downloads ONLY those artifacts + metadata.json,
|
||||
# allowlist-validates every metadata field, cross-checks identity against
|
||||
# the workflow_run authority (head_sha / head_repo / pr_number), and
|
||||
# checks out the fork head pinned to that HEAD SHA solely to ADD prebuild
|
||||
# files (never executes fork code) before pushing. Header comment in the
|
||||
# file documents the split.
|
||||
- commit-fork-prebuilds.yml
|
||||
|
||||
# pull_request_target needed by claude-code-action to access secrets
|
||||
# and post review comments on fork PRs. Mitigated by: PR checkouts pin
|
||||
# the fork's HEAD SHA (not the branch ref) to prevent TOCTOU races,
|
||||
|
||||
+12
-7
@@ -15,9 +15,9 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
||||
|
||||
## End-to-end flow: index → graph → tools
|
||||
|
||||
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 14 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
|
||||
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 15 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
|
||||
|
||||
2. **Persistence** — `repo-manager.ts` (paths, registry, KuzuDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches).
|
||||
2. **Persistence** — `repo-manager.ts` (paths, registry, LadybugDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches).
|
||||
|
||||
3. **Query layer** — three interfaces to the same backend:
|
||||
- **MCP (stdio):** `mcp.ts` → `LocalBackend` → tools (`tools.ts`) + resources (`resources.ts`)
|
||||
@@ -38,7 +38,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
||||
| `detect_changes` | Map git diffs to affected symbols and processes |
|
||||
| `rename` | Graph-assisted multi-file rename with `dry_run` preview |
|
||||
| `api_impact` | Pre-change impact report for an API route handler |
|
||||
| `trace` | Shortest directed path between two symbols (call + class-member edges) |
|
||||
| `trace` | Shortest directed path between two symbols (call + class-member edges); group-aware (`repo: "@<group>"`) for cross-repo traces |
|
||||
| `route_map` | API route → handler → consumer mappings |
|
||||
| `tool_map` | MCP/RPC tool definitions and handlers |
|
||||
| `shape_check` | Response shape vs consumer property access mismatches |
|
||||
@@ -47,7 +47,9 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
||||
| `group_list` | List repo groups or details for one group |
|
||||
| `group_sync` | Rebuild group Contract Registry (`contracts.json`) and bridge graph |
|
||||
|
||||
`query`, `context`, and `impact` are group-aware: pass `repo: "@<groupName>"` (or `"@<groupName>/<memberPath>"` to scope to one member) plus optional `service: "<monorepo/path>"`. Group-mode `query` merges per-repo results via Reciprocal Rank Fusion; group-mode `impact` runs the local walk in the chosen member and fans out across boundaries via the Contract Bridge (`gitnexus/src/core/group/cross-impact.ts`). The previously-planned `group_query`, `group_context`, `group_impact`, `group_contracts`, `group_status` MCP tools are intentionally not introduced — group-level state is exposed via resources instead:
|
||||
`query`, `context`, and `impact` are group-aware: pass `repo: "@<groupName>"` (or `"@<groupName>/<memberPath>"` to scope to one member) plus optional `service: "<monorepo/path>"`. Group-mode `query` merges per-repo results via Reciprocal Rank Fusion; group-mode `impact` runs the local walk in the chosen member and fans out across boundaries via the Contract Bridge (`gitnexus/src/core/group/cross-impact.ts`). `trace` is also group-aware via `repo: "@<groupName>"` — but, unlike the others, it resolves `from`/`to` across **all** members (a `@<groupName>/<memberPath>` suffix is advisory for trace, not a scope); pass `from_uid`/`to_uid` to disambiguate a symbol name that occurs in more than one member.
|
||||
|
||||
Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path that crosses repositories: it resolves `from`/`to` across all members, and when they live in different repos it joins the home-repo segment to the target-repo segment over a single `ContractLink` boundary (an HTTP consumer→provider link, joined on `Contract.symbolUid`), reported as a `CONTRACT_LINK` hop in `crossings[]`. The crossing is clamped to one boundary (`MAX_SUPPORTED_CROSS_DEPTH`, shared with cross-impact); deeper `crossDepth` is reported via `notes[]`. With `pdg: true` (experimental, opt-in), each boundary-adjacent segment is enriched with its intra-procedural REACHING_DEF data-flow when that repo was indexed with `--pdg` (reusing the same anchored `flows` query as `pdg_query`); data flow never crosses the repo boundary, and a missing PDG layer degrades to call-level hops with a note. Two stores meet only at the `symbolUid` grain — the per-repo PDG/call graph and the group bridge — so this is the documented join; full cross-program (SDG-like) data flow across the boundary remains deferred (see `docs/plans/2026-06-18-002-feat-unified-pdg-impact-evaluation-plan.md`). The previously-planned `group_query`, `group_context`, `group_impact`, `group_contracts`, `group_status` MCP tools are intentionally not introduced — group-level state is exposed via resources instead:
|
||||
|
||||
| Resource URI | Purpose |
|
||||
|--------------|---------|
|
||||
@@ -80,11 +82,11 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
||||
|
||||
## Pipeline Phase DAG
|
||||
|
||||
14 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
|
||||
15 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
|
||||
|
||||
```
|
||||
scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
||||
→ crossFile → scopeResolution → pruneLocalSymbols → mro → communities → processes
|
||||
→ crossFile → scopeResolution → pruneLocalSymbols → mro → di → communities → processes
|
||||
```
|
||||
|
||||
| Phase | File | Deps | Output |
|
||||
@@ -101,6 +103,7 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
||||
| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator |
|
||||
| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution |
|
||||
| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
|
||||
| `di` | `di.ts` | `mro` | INJECTS edges (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) |
|
||||
| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
|
||||
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges |
|
||||
|
||||
@@ -124,7 +127,7 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
||||
- **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output.
|
||||
- **Typed phase access** — `getPhaseOutput<T>(deps, 'name')` for type-safe upstream results.
|
||||
- **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership.
|
||||
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests); `pruneLocalSymbols` still runs (it is graph cleanup, not analysis). `skipWorkers` is no longer a sequential escape hatch — it (like `--workers 0` / `GITNEXUS_WORKER_POOL_SIZE=0`) is rejected with an actionable error, since the worker pool is the sole parse path (§ Chunked parse-and-resolve).
|
||||
- **Skippable phases** — `skipGraphPhases` omits MRO/di/communities/processes (faster tests); `pruneLocalSymbols` still runs (it is graph cleanup, not analysis). `skipWorkers` is no longer a sequential escape hatch — it (like `--workers 0` / `GITNEXUS_WORKER_POOL_SIZE=0`) is rejected with an actionable error, since the worker pool is the sole parse path (§ Chunked parse-and-resolve).
|
||||
- **Local-symbol pruning** — `pruneLocalSymbols` removes inert block-local value symbols after scope resolution has consumed them. Opt out per-call with `PipelineOptions.keepLocalValueSymbols` or globally with the `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` env var.
|
||||
|
||||
### How to add a new phase
|
||||
@@ -216,6 +219,7 @@ On a `--pdg` run the parse worker builds a per-function control-flow graph from
|
||||
- **M3/M4 — TAINTED / SANITIZES / TAINT_PATH** (#2083–#2084): intra- and inter-procedural taint (source→sink) — the `explain` tool's data.
|
||||
- **M5 — CDG** (#2085): Ferrante control dependence over a Cooper–Harvey–Kennedy post-dominator tree (the EXIT-rooted reverse CFG); branch sense (`'T'`/`'F'`) rides `reason`. A CFG whose EXIT is unreachable from some block is skipped for CDG (post-dominance would be unsound) while its CFG/REACHING_DEF layers are kept.
|
||||
- **M6 — read surface** (#2086): the `pdg_query` MCP tool answers "what gates X?" (CDG, `mode: controls`) and "where does Y flow?" (REACHING_DEF, `mode: flows`); `explain` is the taint consumer. Both are always anchored + `LIMIT`-bounded (LadybugDB has no rel-property index) and share one `resolveBlockAnchor` helper. These PDG edge types are deliberately kept out of the default `VALID_RELATION_TYPES` / web schema.
|
||||
- **Cross-repo trace enrichment**: group-mode `trace` (`pdg: true`) reuses the same anchored REACHING_DEF `flows` query to annotate a boundary-adjacent segment with how a value reaches the cross-repo call — strictly intra-procedural (data flow never crosses the repo boundary). See the group-aware tools note above.
|
||||
|
||||
See `core/ingestion/cfg/` (emit + the pure CFG / post-dominator / control-dependence / reaching-defs / taint passes) and `mcp/local/local-backend.ts` (`_pdgQueryImpl`, `_explainImpl`, the shared `resolveBlockAnchor`).
|
||||
|
||||
@@ -300,6 +304,7 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
|
||||
| `exportChecker` | Public/exported symbol detection |
|
||||
| `typeConfig` | Type annotation extraction rules |
|
||||
| `mroStrategy` | `first-wins` / `c3` / `none` |
|
||||
| `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) |
|
||||
|
||||
16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,76 @@
|
||||
# Connect GitNexus to Kilo Code via MCP
|
||||
|
||||
This guide shows how to connect GitNexus to the Kilo Code VS Code extension using Kilo’s MCP support, based on a setup that has been tested successfully.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
GitNexus should already be installed globally and working on the target repository, and the repository should be indexed successfully with `gitnexus analyze` before testing inside Kilo.
|
||||
|
||||
## Tested Versions
|
||||
|
||||
| Component | Version |
|
||||
| --- | --- |
|
||||
| VS Code | 1.125.1 (user setup) |
|
||||
| Node.js | 24.15.0 |
|
||||
| Kilo Code | 7.3.50 |
|
||||
| OS | Windows 11 25H2 / Windows_NT x64 10.0.26200 |
|
||||
| GitNexus | 1.6.7 |
|
||||
|
||||
## Where Kilo Stores MCP Config
|
||||
|
||||
Kilo Code stores MCP server configuration in its main config file. For the VS Code extension, config can be stored at either the global or project level.
|
||||
|
||||
| Scope | Config path |
|
||||
| --- | --- |
|
||||
| Global | `~/.config/kilo/kilo.jsonc` |
|
||||
| Project | `kilo.jsonc` or `.kilo/kilo.jsonc` in the project root |
|
||||
|
||||
Check latest path : https://kilo.ai/docs/automate/mcp/using-in-kilo-code
|
||||
|
||||
## Add GitNexus as an MCP Server
|
||||
|
||||
Kilo supports local MCP servers through STDIO, and GitNexus should be added as a local server under the `mcp` key in `kilo.jsonc`. Use this configuration:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcp": {
|
||||
"gitnexus": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "gitnexus@latest", "mcp"],
|
||||
"enabled": true,
|
||||
"timeout": 10000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Check It Through the Kilo UI
|
||||
|
||||
1. restart kilo code extension or vs code
|
||||
2. open kilo code settings
|
||||
3. select mcp server section
|
||||
|
||||
#### From there, Kilo allows adding, editing, enabling, disabling, and deleting MCP servers, and it writes changes directly to the appropriate config file.
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
## Test the Connection
|
||||
|
||||
After configuration, Kilo automatically detects the tools exposed by the MCP server and can use them from chat once the server is available.
|
||||
|
||||
A practical test flow is:
|
||||
|
||||
1. Open the indexed repository in VS Code.
|
||||
2. Confirm `gitnexus analyze`completed successfully.
|
||||
3. Open Kilo chat and ask: `Use GitNexus and explain What does index.php do?`.
|
||||
4. Approve the MCP tool call if prompted.
|
||||
|
||||
#### Full Support will be added Soon 😎
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. If the server shows `failed`, check the CLI output and confirm the command and paths are correct.
|
||||
2. If no tools appear, confirm the MCP server is enabled and GitNexus is exposing the expected tools.
|
||||
3. If Kilo does not automatically select GitNexus, note the exact settings you changed and mark them as an observed workaround.
|
||||
+1
-1
@@ -61,7 +61,7 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
|
||||
|
||||
- **Trigger:** Errors opening `.gitnexus/lbug` while MCP and analyze both run.
|
||||
- **Do:** Stop overlapping processes (one writer at a time). Retry analyze or restart MCP.
|
||||
- **Why:** Embedded DB expects single-process ownership.
|
||||
- **Why:** Embedded DB expects single-process ownership. `@ladybugdb/core` 0.18.0 also reports this contention as `"Only one write transaction at a time is allowed in the system."` — our busy/lock retry matcher (`isDbBusyError` in `src/core/lbug/lbug-config.ts`) recognizes this exact string too, so it's auto-retried the same as any other lock error. If you see that exact message, it's the same "one writer at a time" issue above, not a new failure mode.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ Built by the community — not officially maintained, but worth checking out.
|
||||
| ----------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) | [@tintinweb](https://github.com/tintinweb) | GitNexus plugin for [pi](https://pi.dev) — `pi install npm:pi-gitnexus` |
|
||||
| [gitnexus-stable-ops](https://github.com/ShunsukeHayashi/gitnexus-stable-ops) | [@ShunsukeHayashi](https://github.com/ShunsukeHayashi) | Stable ops & deployment workflows (Miyabi ecosystem) |
|
||||
| [KiloCode MCP workflow ](Documentation/kilo-code-mcp.md) | [@oktanishq](https://github.com/oktanishq) | Guide to connect GitNexus MCP to Kilo Code and verify tools. |
|
||||
|
||||
> Have a project built on GitNexus? Open a PR to add it here!
|
||||
|
||||
@@ -327,6 +328,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
|
||||
| `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. |
|
||||
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
|
||||
| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
|
||||
|
||||
+3
-1
@@ -152,7 +152,9 @@ Analyze re-execs Node with a **large old-space heap** when needed (`analyze.ts`)
|
||||
|
||||
## LadybugDB / lock errors
|
||||
|
||||
Only one process should open a repo’s `.gitnexus/lbug` store at a time. If MCP and a second `analyze` run conflict, stop one process, then retry `analyze` or restart MCP.
|
||||
Only one process should open a repo's `.gitnexus/lbug` store at a time. If MCP and a second `analyze` run conflict, stop one process, then retry `analyze` or restart MCP.
|
||||
|
||||
If the error text is `"Only one write transaction at a time is allowed in the system."` instead of a lock/busy message, it's the same underlying conflict — our retry matcher (`isDbBusyError` in `src/core/lbug/lbug-config.ts`) recognizes this exact string and auto-retries it. The fix if it still surfaces after retries is the same: stop the overlapping process.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -78,6 +78,9 @@ export type NodeProperties = {
|
||||
level?: number;
|
||||
returnType?: string;
|
||||
declaredType?: string;
|
||||
/** Verbatim declared-type source text with generics preserved
|
||||
* (e.g. `List<Shape>` where `declaredType` is the stripped `List`). */
|
||||
rawDeclaredType?: string;
|
||||
visibility?: string;
|
||||
isStatic?: boolean;
|
||||
isReadonly?: boolean;
|
||||
@@ -124,6 +127,19 @@ export type RelationshipType =
|
||||
| 'ENTRY_POINT_OF'
|
||||
| 'WRAPS'
|
||||
| 'QUERIES'
|
||||
/** Dependency-injection edge: a consumer class receives every implementer
|
||||
* of interface `T` via a container-injected collection-typed field
|
||||
* (`List<T>`, `Set<T>`, `Collection<T>`, or `Map<K,T>`). Precondition: the
|
||||
* field carries an injection annotation recognized by a per-language
|
||||
* matcher registered in `di-extractors/` (Java/Spring today: `@Autowired`
|
||||
* or `@Inject`; `@Resource` is excluded — by-name-first semantics).
|
||||
* Source = the consumer Class node (the one owning the field).
|
||||
* Target = an implementing Class node.
|
||||
* Framework specifics live in the `reason` payload (e.g.
|
||||
* `Spring DI: @Autowired List<T>`), not in this type contract.
|
||||
* Lets Cypher queries trace which beans the container injects into a given
|
||||
* consumer, complementing the structural `IMPLEMENTS` heritage edges. */
|
||||
| 'INJECTS'
|
||||
/** Vue component event system: a handler function in a parent component is
|
||||
* bound to an event emitted by a child component (`@event="handlerFn"`).
|
||||
* Source = handler Function/Method node in the parent.
|
||||
|
||||
@@ -69,6 +69,7 @@ export const REL_TYPES = [
|
||||
'ENTRY_POINT_OF',
|
||||
'WRAPS',
|
||||
'QUERIES',
|
||||
'INJECTS',
|
||||
// Taint/PDG substrate (issue #2080) — reserved edge types, emitted by no
|
||||
// phase yet (CFG → M1, REACHING_DEF → M2, TAINTED/SANITIZES/TAINT_PATH →
|
||||
// M3/M4). REACHING_DEF's variable name rides the relation's `reason` column.
|
||||
|
||||
@@ -105,6 +105,13 @@ export type ParsedImport =
|
||||
readonly localName: string;
|
||||
readonly importedName: string;
|
||||
readonly targetRaw: string;
|
||||
/**
|
||||
* Set by providers when `targetRaw` already names the imported symbol
|
||||
* rather than only its containing module. Consumers that compose
|
||||
* `<local>.<member>` paths can then use `targetRaw.<member>` instead of
|
||||
* duplicating `importedName`.
|
||||
*/
|
||||
readonly targetIncludesImportedName?: boolean;
|
||||
}
|
||||
/**
|
||||
* Per-name import with rename.
|
||||
@@ -119,6 +126,8 @@ export type ParsedImport =
|
||||
readonly importedName: string;
|
||||
readonly alias: string;
|
||||
readonly targetRaw: string;
|
||||
/** See the same field on the `named` variant. */
|
||||
readonly targetIncludesImportedName?: boolean;
|
||||
}
|
||||
/**
|
||||
* Qualified module handle, with or without rename. `importedName` is the
|
||||
|
||||
Generated
+130
-128
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"name": "gitnexus-web",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"name": "gitnexus-web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.3.29",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.30",
|
||||
"@langchain/langgraph": "^1.4.1",
|
||||
"@langchain/core": "^1.1.49",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.7",
|
||||
"@langchain/ollama": "^1.2.7",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
"@langchain/openai": "^1.5.3",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.16.1",
|
||||
@@ -28,9 +28,9 @@
|
||||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.4.4",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.17.0",
|
||||
"langchain": "^1.4.6",
|
||||
"lru-cache": "^11.5.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.40.4",
|
||||
"pandemonium": "^2.4.0",
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.0",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -59,7 +59,7 @@
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.8.12",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
@@ -1357,9 +1357,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "1.1.48",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.48.tgz",
|
||||
"integrity": "sha512-fQU6Guyb1pwc2fEplmA8FPbKfOMAofjnyJzExevro0FxEiuGHE18Ov/ZHmT9trWCDTZRI9eW1VIc6aChxV8pAQ==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.1.tgz",
|
||||
"integrity": "sha512-NNG/cC5FGuHDOAP56h0ddp8Rfk8p+othWzEK5RV9JIG6RvnF5vGa5r0AEGtKfQieed7s1kC42GuIzVOBvMBL/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.0.2",
|
||||
@@ -1375,50 +1375,43 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/google-genai": {
|
||||
"version": "2.1.30",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.1.30.tgz",
|
||||
"integrity": "sha512-0wKgy1NvV89fw5MwYiOOhh18SnUEH20z6MZrPV6Tj2hMAA3jAHVSLlIcCQ2mDRJo2r1aHLV8MDXhzkvD1tEHoQ==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.2.0.tgz",
|
||||
"integrity": "sha512-1mDqbmB6+iC6ZBQY15r5xJg9wPErnQ774inpKh6qi6BrrjadDwaPHoklJW5IXU94edKiDpm1akIzJCrQDWe6yA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.24.0"
|
||||
"@google/generative-ai": "^0.24.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.43"
|
||||
"@langchain/core": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.1.tgz",
|
||||
"integrity": "sha512-rrDIeSYUqKKNASZgB5BqAFZ5y1zjrh/qc/pP/W0J7zV5+2HxrqgdMdTV6zy3RtNgDnrWShaI4EZnqObw1blWqQ==",
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.7.tgz",
|
||||
"integrity": "sha512-2tcyf3QGC7v89kqSxMCtRvzg/3L/4yHtOaWC49A8KieCciWJs7LGaxHoPB6QRxXyUgyR+Zg9Q1ss/XJIE+JuSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph-checkpoint": "^1.1.0",
|
||||
"@langchain/langgraph-sdk": "~1.9.21",
|
||||
"@langchain/protocol": "^0.0.16",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"uuid": "^14.0.0"
|
||||
"@langchain/langgraph-checkpoint": "^1.1.3",
|
||||
"@langchain/langgraph-sdk": "~1.9.25",
|
||||
"@langchain/protocol": "^0.0.18",
|
||||
"@standard-schema/spec": "1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.48",
|
||||
"zod": "^3.25.32 || ^4.2.0",
|
||||
"zod-to-json-schema": "^3.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod-to-json-schema": {
|
||||
"optional": true
|
||||
}
|
||||
"zod": "^3.25.32 || ^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-checkpoint": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.1.2.tgz",
|
||||
"integrity": "sha512-m5Xd7W3G9JrlEhFZ5WAcqZPgE46R9gr1gFDFaVqEKeuwin3tgEp0jlPbru+iFXCug338DcQjFS/Kuuci21ydvw==",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.1.3.tgz",
|
||||
"integrity": "sha512-wgzdQNeEsdw1e+4lvlj0tdq/RYR/k1vPin10g0ymGoehZDDgd9nvIllGXSXN4TFgF9sf5qQP/KTkOcLfeseIhA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -1428,12 +1421,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk": {
|
||||
"version": "1.9.23",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.23.tgz",
|
||||
"integrity": "sha512-JF5TWOrrKaMn9D7O0xT/9e9t3CpDRd8DUyKQdcbGswDsWdlI+04E9E1Lxv361tMu5pNYhval3iJPAwGxUuqi4w==",
|
||||
"version": "1.9.25",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.25.tgz",
|
||||
"integrity": "sha512-mRKW8zyQUaHox+HirRFMRrPqOvNbQI3xeXDt6kkk4PbBg77V92bsO1WzUVNrmJ81zCkvxyOrWSK8D6ioCj0a8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/protocol": "^0.0.16",
|
||||
"@langchain/protocol": "^0.0.18",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"p-queue": "^9.0.1",
|
||||
"p-retry": "^7.1.1"
|
||||
@@ -1510,26 +1503,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/openai": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.4.5.tgz",
|
||||
"integrity": "sha512-bQ2WMIZfSh02trJLYSAtiIcD3j6EBCiAm9nw0dZWQsVaUxmWc3JJqs8uUte6AkMazmLHzcUIw+14UkXO5fRJvQ==",
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.3.tgz",
|
||||
"integrity": "sha512-OStS2AUvy9oe/hEf/3ndBOFztUDOfuJYLNXh89m3iiJAI2Cp5Dp0n/pvpO27MO0b+VgENd+xSHVyQZ7fe+ulxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"openai": "^6.34.0",
|
||||
"openai": "^6.41.0",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.42"
|
||||
"@langchain/core": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/protocol": {
|
||||
"version": "0.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.16.tgz",
|
||||
"integrity": "sha512-ws+J7MaHyhO5dG7f0vdyHQiUn9hoCnki0f3crJPa4MCTGzcRC39jYSCghyrGtBPYQnZbUQiGyRVpW3z3M8IpJg==",
|
||||
"version": "0.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.18.tgz",
|
||||
"integrity": "sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
@@ -1629,13 +1622,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
"playwright": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3044,14 +3037,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz",
|
||||
"integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
|
||||
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -3065,8 +3058,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.8",
|
||||
"vitest": "4.1.8"
|
||||
"@vitest/browser": "4.1.9",
|
||||
"vitest": "4.1.9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -3075,16 +3068,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
|
||||
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -3093,13 +3086,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
|
||||
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -3130,9 +3123,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
|
||||
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3143,13 +3136,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
|
||||
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -3157,14 +3150,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
|
||||
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -3173,9 +3166,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
|
||||
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -3183,13 +3176,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
|
||||
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -5707,13 +5700,13 @@
|
||||
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
|
||||
},
|
||||
"node_modules/langchain": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.4.4.tgz",
|
||||
"integrity": "sha512-tepOCwUDaIZOYJ9Eo0O6o5dXEN/0KJheiFDnHHFL8Tx8rfkDLL4cOTSTln4Vpn9LpWzXYkjQ8lkHnnNDQWZPeg==",
|
||||
"version": "1.4.6",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.4.6.tgz",
|
||||
"integrity": "sha512-pwuFmGOyiMezptLVLrpb5jILirvYPGHI5uJCFHL5K5WPxMy2XuPLI5QNMKtoHkdiL6a2dLebqugKw87cneaESw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph": "^1.3.2",
|
||||
"@langchain/langgraph-checkpoint": "^1.0.1",
|
||||
"@langchain/langgraph": "^1.3.4",
|
||||
"@langchain/langgraph-checkpoint": "^1.0.4",
|
||||
"langsmith": ">=0.5.0 <1.0.0",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
},
|
||||
@@ -5721,7 +5714,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.48"
|
||||
"@langchain/core": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/langsmith": {
|
||||
@@ -6050,18 +6043,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz",
|
||||
"integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==",
|
||||
"version": "1.21.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz",
|
||||
"integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -7307,18 +7300,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.34.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.34.0.tgz",
|
||||
"integrity": "sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw==",
|
||||
"version": "6.45.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz",
|
||||
"integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@aws-sdk/credential-provider-node": ">=3.972.0 <4",
|
||||
"@smithy/hash-node": ">=4.3.0 <5",
|
||||
"@smithy/signature-v4": ">=5.4.0 <6",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@aws-sdk/credential-provider-node": {
|
||||
"optional": true
|
||||
},
|
||||
"@smithy/hash-node": {
|
||||
"optional": true
|
||||
},
|
||||
"@smithy/signature-v4": {
|
||||
"optional": true
|
||||
},
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -7532,13 +7534,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -7551,9 +7553,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -8789,19 +8791,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
|
||||
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.8",
|
||||
"@vitest/mocker": "4.1.8",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/runner": "4.1.8",
|
||||
"@vitest/snapshot": "4.1.8",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/expect": "4.1.9",
|
||||
"@vitest/mocker": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/runner": "4.1.9",
|
||||
"@vitest/snapshot": "4.1.9",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -8829,12 +8831,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.8",
|
||||
"@vitest/browser-preview": "4.1.8",
|
||||
"@vitest/browser-webdriverio": "4.1.8",
|
||||
"@vitest/coverage-istanbul": "4.1.8",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"@vitest/ui": "4.1.8",
|
||||
"@vitest/browser-playwright": "4.1.9",
|
||||
"@vitest/browser-preview": "4.1.9",
|
||||
"@vitest/browser-webdriverio": "4.1.9",
|
||||
"@vitest/coverage-istanbul": "4.1.9",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vitest/ui": "4.1.9",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
+10
-10
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"name": "gitnexus-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"engines": {
|
||||
@@ -19,11 +19,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.3.29",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.30",
|
||||
"@langchain/langgraph": "^1.4.1",
|
||||
"@langchain/core": "^1.1.49",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.7",
|
||||
"@langchain/ollama": "^1.2.7",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
"@langchain/openai": "^1.5.3",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.16.1",
|
||||
@@ -38,9 +38,9 @@
|
||||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.4.4",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.17.0",
|
||||
"langchain": "^1.4.6",
|
||||
"lru-cache": "^11.5.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.40.4",
|
||||
"pandemonium": "^2.4.0",
|
||||
@@ -58,7 +58,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.0",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -69,7 +69,7 @@
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.8.12",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, GitBranch, Search, Filter, Zap, Keyboard, BarChart2, HelpCircle } from 'lucide-react';
|
||||
import {
|
||||
X,
|
||||
GitBranch,
|
||||
Search,
|
||||
Filter,
|
||||
Zap,
|
||||
Keyboard,
|
||||
BarChart2,
|
||||
HelpCircle,
|
||||
} from '@/lib/lucide-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface HelpPanelProps {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Copy, Focus, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { Copy, Focus, ZoomIn, ZoomOut } from '@/lib/lucide-icons';
|
||||
import mermaid from 'mermaid';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { ProcessData, generateProcessMermaid } from '../lib/mermaid-generator';
|
||||
@@ -18,7 +18,6 @@ interface ProcessFlowModalProps {
|
||||
isFullScreen?: boolean;
|
||||
}
|
||||
|
||||
// Initialize mermaid with cyan/purple theme matching GitNexus
|
||||
// Initialize mermaid with cyan/purple theme matching GitNexus
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Sparkles,
|
||||
Lightbulb,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
} from '@/lib/lucide-icons';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { ProcessFlowModal } from './ProcessFlowModal';
|
||||
import type { ProcessData, ProcessStep } from '../lib/mermaid-generator';
|
||||
|
||||
@@ -47,6 +47,7 @@ export {
|
||||
ArrowDown,
|
||||
ArrowRight,
|
||||
AtSign,
|
||||
BarChart2,
|
||||
Brain,
|
||||
Box,
|
||||
Braces,
|
||||
@@ -71,6 +72,7 @@ export {
|
||||
Heart,
|
||||
HelpCircle,
|
||||
Home,
|
||||
Keyboard,
|
||||
Key,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
|
||||
+11
-1
@@ -357,12 +357,14 @@ npm install -g gitnexus
|
||||
|
||||
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:
|
||||
Configure the behavior with these 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. |
|
||||
| `GITNEXUS_FTS_STEMMER` | supported LadybugDB stemmer | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. |
|
||||
| `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
|
||||
|
||||
```bash
|
||||
@@ -371,6 +373,14 @@ 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
|
||||
|
||||
# CJK-heavy codebase: rebuild keyword indexes without English stemming
|
||||
GITNEXUS_FTS_STEMMER=none npx gitnexus analyze --repair-fts
|
||||
|
||||
# CJK-heavy codebase: enable sub-phrase search over Chinese/Japanese Han text.
|
||||
# On an already-indexed repo, the first run after enabling this MUST be --force —
|
||||
# --repair-fts and plain incremental `analyze` both leave old files un-segmented.
|
||||
GITNEXUS_FTS_CJK_SEGMENTATION=bigram npx gitnexus analyze --force
|
||||
```
|
||||
|
||||
### Analysis runs out of memory
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Cross-repo trace — end-to-end verification
|
||||
|
||||
Verifies the cross-repo `trace` MCP tool against the **real pipeline** (not
|
||||
hand-persisted graphs): `runFullAnalysis(--pdg)` on two repos → real `syncGroup`
|
||||
HTTP contract extraction + bridge build → `callTool('trace', { repo: '@group' })`.
|
||||
|
||||
Run from `gitnexus/` (needs a current build for the parse worker):
|
||||
|
||||
```bash
|
||||
node scripts/build.js
|
||||
node bench/cross-repo-trace/verify.mjs
|
||||
```
|
||||
|
||||
`verify.mjs` is self-contained — it generates each fixture inline, runs the real
|
||||
analyze → sync → trace/impact pipeline, and prints PASS/FAIL per assertion
|
||||
(exit non-zero on any failure). Expected verdict: **16/16 checks passed**.
|
||||
|
||||
## Cases covered (one scenario each)
|
||||
|
||||
1. **Named handlers, same file** — a frontend with named `fetch` wrappers
|
||||
(`fetchUsers`, `createUserReq`) and a backend with named express handlers
|
||||
(`listUsers`, `createUser`) on `/api/users` GET/POST. Asserts: all four
|
||||
contracts resolve a `symbolUid`; `trace` is **symbol-precise** (the GET pair
|
||||
selects `http::GET`, the POST pair `http::POST`, no file-fallback note); the
|
||||
destination trace lands at `listUsers`.
|
||||
2. **Anonymous handler** — `router.get('/api/ping', (req,res) => …)`. Asserts the
|
||||
provider contract has an empty `symbolUid`, and the **destination trace**
|
||||
(omit `to`) reaches it, reported as `<http::GET::/api/ping handler>` with an
|
||||
anonymous note.
|
||||
3. **Cross-repo `impact` fan-out** — `impact @group` on `fetchUsers` crosses the
|
||||
boundary (`cross_repo_hits >= 1`); the same `symbolUid` join was 0 before.
|
||||
4. **Multi-language (Python)** — a Flask provider + `requests` consumer; asserts
|
||||
the Python line wiring resolves the consumer and the cross-repo `trace`
|
||||
stitches `fetch_items -> list_items`.
|
||||
5. **Cross-file named handler** (#2275) — a route whose handler (`listUsers`) is
|
||||
imported from another file than its registration. Asserts the provider
|
||||
resolves to the handler via the import-pinned module lookup, and the trace is
|
||||
symbol-precise (no file-level fallback).
|
||||
6. **Aliased cross-file import** (#2275) — `import { listUsers as handleUsers }`
|
||||
with an unrelated decoy `handleUsers` elsewhere. Asserts the route resolves
|
||||
through the import to the declared `listUsers` (not the alias or the decoy),
|
||||
proving import-pinned resolution.
|
||||
7. **Python aliased import** (#2275) — a Flask `add_url_rule('/api/users',
|
||||
view_func=handle_users)` whose view is `from .handlers.users import list_users
|
||||
as handle_users`. Asserts the handler resolves through Python's dotted
|
||||
relative module to `list_users`, symbol-precise.
|
||||
|
||||
The **ambiguous-destination** (a file making several HTTP calls whose consumer
|
||||
contracts have no resolved uid) and **degraded-member** (a member DB that throws
|
||||
mid-resolution) paths need synthetic inputs the real analyzer cannot produce, so
|
||||
they live in the unit suite (`test/unit/group/cross-trace.test.ts`).
|
||||
|
||||
## What it proves
|
||||
|
||||
- `analyze` + `syncGroup` build the correct `ContractLink`s (exact HTTP match).
|
||||
- HTTP contracts carry a **real `symbolUid` whenever the endpoint resolves** —
|
||||
the extractor binds each detection to the function it lives in (the function
|
||||
CONTAINING the `fetch`; the named handler, or the inline handler by line-span
|
||||
containment, for a route). A handler/consumer that resolves to no named symbol
|
||||
(a fully anonymous handler, or a language plugin that does not yet set the
|
||||
call-site line) keeps an empty uid and degrades to the file/destination
|
||||
fallback. When resolved, contracts report
|
||||
`extractionStrategy: 'source_scan_resolved'` / `'graph_assisted'` with a uid.
|
||||
- `trace @group from=<calling fn> to=<handler fn>` **stitches the cross-repo
|
||||
path** (`fetchUsers → listUsers`), reporting the `CONTRACT_LINK` hop and
|
||||
(with `pdg:true`) the data-flow enrichment, **symbol-precise** (GET pair →
|
||||
`http::GET` contract, POST → `http::POST`), with no file-fallback note.
|
||||
- The same `symbolUid` fix makes `impact @group` fan out across the boundary
|
||||
(it was 0 cross-repo hits before — both tools join crossings on `symbolUid`).
|
||||
|
||||
## Resolution precedence & residual limits
|
||||
|
||||
The extractor resolves `symbolUid` in this order, falling through on a miss:
|
||||
|
||||
1. **Named handler** — `router.get('/x', listUsers)` resolves `listUsers` by name.
|
||||
2. **Containment** — the innermost `Function`/`Method` whose line span encloses
|
||||
the call/registration line (consumers; inline-arrow providers).
|
||||
3. **File-level boundary fallback** (in `cross-trace`) — only when 1–2 leave the
|
||||
uid empty: if the user's `from`/`to` resolves into the contract's file, that
|
||||
endpoint anchors the boundary. A `notes[]` entry flags it as file-level, not
|
||||
symbol-precise.
|
||||
|
||||
The call-site line is set by all bundled language plugins (Node/TS, Python, Go,
|
||||
PHP, Kotlin, Java), and containment matches symbols by `filePath` across
|
||||
`Function`/`Method`/`CodeElement`, so it also resolves methods nested in classes
|
||||
(Java/Kotlin), not just top-level functions.
|
||||
|
||||
### Anonymous handlers — the destination trace
|
||||
|
||||
A **fully anonymous handler** (`router.get('/x', (req,res) => res.json(...))`)
|
||||
has no symbol node at all, so it cannot be named as a `to` target. This is
|
||||
handled by the **destination trace**: omit `to`/`to_uid`/`to_file` on an
|
||||
`@group` trace and `trace from=<consumer>` follows the consumer's outgoing HTTP
|
||||
call across the bridge and reports where it lands — by route + file:line, with a
|
||||
`notes[]` entry flagging the handler as anonymous:
|
||||
|
||||
```
|
||||
app/frontend:fetchUsers → app/backend:<http::GET::/api/users handler> [CONTRACT_LINK]
|
||||
```
|
||||
|
||||
To go deeper into an anonymous handler, trace to a named function it calls (the
|
||||
provider segment then resolves normally).
|
||||
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* Cross-repo trace — comprehensive end-to-end verification.
|
||||
*
|
||||
* Drives the REAL pipeline (runFullAnalysis --pdg -> real syncGroup -> trace /
|
||||
* impact via a LocalBackend) over inline fixtures, one scenario per implemented
|
||||
* case, and reports PASS/FAIL per assertion. Run from gitnexus/ (needs a current
|
||||
* build for the parse worker):
|
||||
*
|
||||
* node scripts/build.js
|
||||
* node bench/cross-repo-trace/verify.mjs
|
||||
*
|
||||
* Cases covered: symbolUid containment resolution (named, same-file + nested),
|
||||
* symbol-precise crossing selection, the destination trace (named + anonymous
|
||||
* endpoint), cross-repo impact fan-out, and multi-language (Python) resolution.
|
||||
* (Ambiguous-destination and degraded-member paths need synthetic inputs the
|
||||
* real analyzer can't produce; those are covered in the unit suite.)
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const REPO = path.resolve('.');
|
||||
const { runFullAnalysis } = await import(path.join(REPO, 'dist/core/run-analyze.js'));
|
||||
const { getGroupDir } = await import(path.join(REPO, 'dist/core/group/storage.js'));
|
||||
const { loadGroupConfig } = await import(path.join(REPO, 'dist/core/group/config-parser.js'));
|
||||
const { syncGroup } = await import(path.join(REPO, 'dist/core/group/sync.js'));
|
||||
const { LocalBackend } = await import(path.join(REPO, 'dist/mcp/local/local-backend.js'));
|
||||
|
||||
const cb = { onProgress: () => {}, onLog: () => {} };
|
||||
const ANALYZE = { pdg: true, skipSkills: true, embeddings: false, force: true };
|
||||
const line = (s = '') => console.log(s);
|
||||
|
||||
const results = [];
|
||||
const check = (pass, label, detail = '') => {
|
||||
results.push({ pass, label });
|
||||
line(` [${pass ? 'PASS' : 'FAIL'}] ${label}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
|
||||
function writeFiles(dir, files) {
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const p = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, content);
|
||||
}
|
||||
}
|
||||
|
||||
function groupYaml(name, repos) {
|
||||
const lines = Object.entries(repos)
|
||||
.map(([k, v]) => ` ${k}: ${v}`)
|
||||
.join('\n');
|
||||
return `version: 1
|
||||
name: ${name}
|
||||
description: ""
|
||||
repos:
|
||||
${lines}
|
||||
links: []
|
||||
packages: {}
|
||||
detect:
|
||||
http: true
|
||||
matching:
|
||||
bm25_threshold: 0.7
|
||||
embedding_threshold: 0.65
|
||||
max_candidates_per_step: 3
|
||||
`;
|
||||
}
|
||||
|
||||
/** Analyze each repo, sync the group, return a ready LocalBackend + sync result. */
|
||||
async function setup(tag, repos, groupName, groupRepos) {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), `gn-bench-${tag}-`));
|
||||
process.env.GITNEXUS_HOME = home;
|
||||
for (const [reg, files] of Object.entries(repos)) {
|
||||
const dir = path.join(home, reg);
|
||||
writeFiles(dir, files);
|
||||
await runFullAnalysis(dir, ANALYZE, cb);
|
||||
}
|
||||
const gd = getGroupDir(home, groupName);
|
||||
fs.mkdirSync(gd, { recursive: true });
|
||||
fs.writeFileSync(path.join(gd, 'group.yaml'), groupYaml(groupName, groupRepos));
|
||||
const sync = await syncGroup(await loadGroupConfig(gd), { groupDir: gd });
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
return { home, sync, backend };
|
||||
}
|
||||
|
||||
const hasNote = (r, frag) => (r.notes ?? []).some((n) => n.includes(frag));
|
||||
const crossingId = (r) => r.crossings?.[0]?.contractId;
|
||||
|
||||
// ── Scenario 1+3: named handlers (precise trace, destination, impact fan-out) ──
|
||||
line('## Scenario: named handlers (same-file) — symbolUid precise');
|
||||
{
|
||||
const { sync, backend, home } = await setup(
|
||||
'named',
|
||||
{
|
||||
'named-backend': {
|
||||
'src/routes.ts': `import { Router } from 'express';
|
||||
const router = Router();
|
||||
export function listUsers(req: { body: unknown }, res: { json: (v: unknown) => void }) { res.json([]); }
|
||||
export function createUser(req: { body: unknown }, res: { json: (v: unknown) => void }) { res.json({}); }
|
||||
router.get('/api/users', listUsers);
|
||||
router.post('/api/users', createUser);
|
||||
export default router;
|
||||
`,
|
||||
'package.json': '{ "name": "named-backend", "version": "1.0.0" }',
|
||||
},
|
||||
'named-frontend': {
|
||||
'src/api.ts': `export async function fetchUsers() {
|
||||
const r = await fetch('/api/users');
|
||||
return r.json();
|
||||
}
|
||||
export async function createUserReq(data: { name: string }) {
|
||||
const r = await fetch('/api/users', { method: 'POST', body: JSON.stringify(data) });
|
||||
return r.json();
|
||||
}
|
||||
`,
|
||||
'package.json': '{ "name": "named-frontend", "version": "1.0.0" }',
|
||||
},
|
||||
},
|
||||
'named-group',
|
||||
{ 'app/backend': 'named-backend', 'app/frontend': 'named-frontend' },
|
||||
);
|
||||
|
||||
const resolved = sync.contracts.filter((c) => c.symbolUid).length;
|
||||
check(resolved >= 4, `all 4 contracts resolve a symbolUid (got ${resolved}/4)`);
|
||||
|
||||
const get = await backend.callTool('trace', {
|
||||
repo: '@named-group',
|
||||
from: 'fetchUsers',
|
||||
to: 'listUsers',
|
||||
pdg: true,
|
||||
});
|
||||
check(
|
||||
get.status === 'ok' && crossingId(get) === 'http::GET::/api/users' && !hasNote(get, 'file'),
|
||||
'GET trace is symbol-precise (fetchUsers -> listUsers over http::GET::/api/users, no file fallback)',
|
||||
`status=${get.status} crossing=${crossingId(get)}`,
|
||||
);
|
||||
|
||||
const post = await backend.callTool('trace', {
|
||||
repo: '@named-group',
|
||||
from: 'createUserReq',
|
||||
to: 'createUser',
|
||||
pdg: true,
|
||||
});
|
||||
check(
|
||||
post.status === 'ok' && crossingId(post) === 'http::POST::/api/users',
|
||||
'POST trace selects the POST crossing (no GET/POST confusion)',
|
||||
`crossing=${crossingId(post)}`,
|
||||
);
|
||||
|
||||
const dest = await backend.callTool('trace', { repo: '@named-group', from: 'fetchUsers' });
|
||||
check(
|
||||
dest.status === 'ok' &&
|
||||
dest.to?.name === 'listUsers' &&
|
||||
crossingId(dest) === 'http::GET::/api/users',
|
||||
'destination trace (no `to`) lands at the named handler listUsers',
|
||||
`to=${dest.to?.name}`,
|
||||
);
|
||||
|
||||
const imp = await backend.callTool('impact', {
|
||||
repo: '@named-group/app/frontend',
|
||||
target: 'fetchUsers',
|
||||
direction: 'downstream',
|
||||
});
|
||||
const hits = imp.summary?.cross_repo_hits ?? (Array.isArray(imp.cross) ? imp.cross.length : 0);
|
||||
check(hits >= 1, `impact @group fans out across the boundary (cross_repo_hits=${hits})`);
|
||||
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ── Scenario 2: anonymous handler — destination reports endpoint by route ──
|
||||
line('\n## Scenario: anonymous handler — destination trace');
|
||||
{
|
||||
const { sync, backend, home } = await setup(
|
||||
'anon',
|
||||
{
|
||||
'anon-backend': {
|
||||
'src/routes.ts': `import { Router } from 'express';
|
||||
const router = Router();
|
||||
router.get('/api/ping', (req: unknown, res: { json: (v: unknown) => void }) => { res.json({ ok: true }); });
|
||||
export default router;
|
||||
`,
|
||||
'package.json': '{ "name": "anon-backend", "version": "1.0.0" }',
|
||||
},
|
||||
'anon-frontend': {
|
||||
'src/ping.ts': `export async function ping() {
|
||||
const r = await fetch('/api/ping');
|
||||
return r.json();
|
||||
}
|
||||
`,
|
||||
'package.json': '{ "name": "anon-frontend", "version": "1.0.0" }',
|
||||
},
|
||||
},
|
||||
'anon-group',
|
||||
{ 'app/backend': 'anon-backend', 'app/frontend': 'anon-frontend' },
|
||||
);
|
||||
|
||||
const provider = sync.contracts.find((c) => c.role === 'provider');
|
||||
check(
|
||||
provider !== undefined && !provider.symbolUid,
|
||||
'anonymous provider has an empty symbolUid (no named symbol to resolve)',
|
||||
`uid=${provider?.symbolUid || 'empty'}`,
|
||||
);
|
||||
|
||||
const dest = await backend.callTool('trace', { repo: '@anon-group', from: 'ping' });
|
||||
check(
|
||||
dest.status === 'ok' &&
|
||||
dest.to?.name === '<http::GET::/api/ping handler>' &&
|
||||
hasNote(dest, 'anonymous'),
|
||||
'destination trace reaches the anonymous handler, reported by route + anonymous note',
|
||||
`to=${dest.to?.name}`,
|
||||
);
|
||||
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ── Scenario 4: multi-language (Python) — symbolUid resolution beyond TS ──
|
||||
line('\n## Scenario: multi-language (Python) — line wiring + resolution');
|
||||
{
|
||||
const { sync, backend, home } = await setup(
|
||||
'py',
|
||||
{
|
||||
'py-backend': {
|
||||
'app.py': `from flask import Flask
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route('/api/items')
|
||||
def list_items():
|
||||
return []
|
||||
`,
|
||||
},
|
||||
'py-frontend': {
|
||||
'client.py': `import requests
|
||||
|
||||
def fetch_items():
|
||||
return requests.get('/api/items').json()
|
||||
`,
|
||||
},
|
||||
},
|
||||
'py-group',
|
||||
{ 'app/backend': 'py-backend', 'app/frontend': 'py-frontend' },
|
||||
);
|
||||
|
||||
line(
|
||||
` (py contracts: ${sync.contracts
|
||||
.map((c) => `${c.role}:${c.symbolName}:${c.symbolUid ? 'uid' : 'empty'}`)
|
||||
.join(' ')} | crossLinks=${sync.crossLinks.length})`,
|
||||
);
|
||||
check(
|
||||
sync.crossLinks.length >= 1,
|
||||
`Python HTTP link built (crossLinks=${sync.crossLinks.length})`,
|
||||
);
|
||||
|
||||
const tr = await backend.callTool('trace', {
|
||||
repo: '@py-group',
|
||||
from: 'fetch_items',
|
||||
to: 'list_items',
|
||||
});
|
||||
check(
|
||||
tr.status === 'ok' && crossingId(tr) === 'http::GET::/api/items',
|
||||
'Python cross-repo trace stitches fetch_items -> list_items',
|
||||
`status=${tr.status} crossing=${crossingId(tr) ?? tr.role}`,
|
||||
);
|
||||
// The Flask provider resolves no symbol here, so the provider boundary is
|
||||
// anchored by the contract FILE (to=list_items lives in the provider file).
|
||||
// This exercises the file-level fallback path end-to-end.
|
||||
check(
|
||||
hasNote(tr, 'FILE'),
|
||||
'provider boundary uses the file-level fallback when the provider has no uid',
|
||||
`notes=${(tr.notes ?? []).length}`,
|
||||
);
|
||||
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ── Scenario: Python Flask add_url_rule with an ALIASED relative import —
|
||||
// import-pinned resolution across Python's dotted module syntax. ───────────
|
||||
line('\n## Scenario: Python aliased import (Flask add_url_rule) — import-pinned');
|
||||
{
|
||||
const { sync, backend, home } = await setup(
|
||||
'pyalias',
|
||||
{
|
||||
'pyalias-backend': {
|
||||
'app/handlers/users.py': `def list_users():
|
||||
return []
|
||||
`,
|
||||
'app/routes.py': `from flask import Flask
|
||||
from .handlers.users import list_users as handle_users
|
||||
app = Flask(__name__)
|
||||
app.add_url_rule('/api/users', view_func=handle_users)
|
||||
`,
|
||||
},
|
||||
'pyalias-frontend': {
|
||||
'client.py': `import requests
|
||||
|
||||
def fetch_users():
|
||||
return requests.get('/api/users').json()
|
||||
`,
|
||||
},
|
||||
},
|
||||
'pyalias-group',
|
||||
{ 'app/backend': 'pyalias-backend', 'app/frontend': 'pyalias-frontend' },
|
||||
);
|
||||
|
||||
const provider = sync.contracts.find(
|
||||
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/users',
|
||||
);
|
||||
check(
|
||||
provider?.symbolName === 'list_users',
|
||||
'Python Flask aliased view resolves through the relative import to list_users',
|
||||
`sym=${provider?.symbolName} uid=${provider?.symbolUid ? 'set' : 'empty'}`,
|
||||
);
|
||||
|
||||
const tr = await backend.callTool('trace', {
|
||||
repo: '@pyalias-group',
|
||||
from: 'fetch_users',
|
||||
to: 'list_users',
|
||||
});
|
||||
check(
|
||||
tr.status === 'ok' && crossingId(tr) === 'http::GET::/api/users' && !hasNote(tr, 'FILE'),
|
||||
'Python aliased-import trace is symbol-precise (no file-level fallback)',
|
||||
`status=${tr.status} crossing=${crossingId(tr)}`,
|
||||
);
|
||||
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ── Scenario: cross-file named handler (#2275) — repo-wide unique resolution ──
|
||||
line('\n## Scenario: cross-file named handler — repo-wide unique resolution');
|
||||
{
|
||||
const { sync, backend, home } = await setup(
|
||||
'xfile',
|
||||
{
|
||||
'xfile-backend': {
|
||||
'src/handlers/users.ts': `export function listUsers(req: { body: unknown }, res: { json: (v: unknown) => void }) {
|
||||
res.json([]);
|
||||
}
|
||||
`,
|
||||
'src/routes.ts': `import { Router } from 'express';
|
||||
import { listUsers } from './handlers/users';
|
||||
const router = Router();
|
||||
router.get('/api/users', listUsers);
|
||||
export default router;
|
||||
`,
|
||||
'package.json': '{ "name": "xfile-backend", "version": "1.0.0" }',
|
||||
},
|
||||
'xfile-frontend': {
|
||||
'src/api.ts': `export async function fetchUsers() {
|
||||
const r = await fetch('/api/users');
|
||||
return r.json();
|
||||
}
|
||||
`,
|
||||
'package.json': '{ "name": "xfile-frontend", "version": "1.0.0" }',
|
||||
},
|
||||
},
|
||||
'xfile-group',
|
||||
{ 'app/backend': 'xfile-backend', 'app/frontend': 'xfile-frontend' },
|
||||
);
|
||||
|
||||
const provider = sync.contracts.find(
|
||||
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/users',
|
||||
);
|
||||
check(
|
||||
Boolean(provider?.symbolUid) && provider?.symbolName === 'listUsers',
|
||||
'cross-file provider resolves to the handler defined in another file (repo-wide unique)',
|
||||
`sym=${provider?.symbolName} uid=${provider?.symbolUid ? 'set' : 'empty'}`,
|
||||
);
|
||||
|
||||
const tr = await backend.callTool('trace', {
|
||||
repo: '@xfile-group',
|
||||
from: 'fetchUsers',
|
||||
to: 'listUsers',
|
||||
pdg: true,
|
||||
});
|
||||
check(
|
||||
tr.status === 'ok' && crossingId(tr) === 'http::GET::/api/users' && !hasNote(tr, 'FILE'),
|
||||
'cross-file trace is symbol-precise (no file-level fallback)',
|
||||
`status=${tr.status} crossing=${crossingId(tr)}`,
|
||||
);
|
||||
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ── Scenario: ALIASED cross-file import — resolved through the import to the
|
||||
// declared symbol, not the local alias (and not a same-named decoy). ───────
|
||||
line('\n## Scenario: aliased cross-file import — import-pinned resolution');
|
||||
{
|
||||
const { sync, backend, home } = await setup(
|
||||
'alias',
|
||||
{
|
||||
'alias-backend': {
|
||||
'src/handlers/users.ts': `export function listUsers(req: { body: unknown }, res: { json: (v: unknown) => void }) {
|
||||
res.json([]);
|
||||
}
|
||||
`,
|
||||
// Decoy: a DIFFERENT, unrelated symbol named handleUsers. Name-only
|
||||
// resolution of the local alias would wrongly pick this one.
|
||||
'src/util.ts': `export function handleUsers() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
'src/routes.ts': `import { Router } from 'express';
|
||||
import { listUsers as handleUsers } from './handlers/users';
|
||||
const router = Router();
|
||||
router.get('/api/users', handleUsers);
|
||||
export default router;
|
||||
`,
|
||||
'package.json': '{ "name": "alias-backend", "version": "1.0.0" }',
|
||||
},
|
||||
'alias-frontend': {
|
||||
'src/api.ts': `export async function fetchUsers() {
|
||||
const r = await fetch('/api/users');
|
||||
return r.json();
|
||||
}
|
||||
`,
|
||||
'package.json': '{ "name": "alias-frontend", "version": "1.0.0" }',
|
||||
},
|
||||
},
|
||||
'alias-group',
|
||||
{ 'app/backend': 'alias-backend', 'app/frontend': 'alias-frontend' },
|
||||
);
|
||||
|
||||
const provider = sync.contracts.find(
|
||||
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/users',
|
||||
);
|
||||
check(
|
||||
provider?.symbolName === 'listUsers',
|
||||
'aliased handler resolves through the import to the declared symbol (not the alias/decoy)',
|
||||
`sym=${provider?.symbolName} uid=${provider?.symbolUid ? 'set' : 'empty'}`,
|
||||
);
|
||||
|
||||
const tr = await backend.callTool('trace', {
|
||||
repo: '@alias-group',
|
||||
from: 'fetchUsers',
|
||||
to: 'listUsers',
|
||||
pdg: true,
|
||||
});
|
||||
check(
|
||||
tr.status === 'ok' && crossingId(tr) === 'http::GET::/api/users' && !hasNote(tr, 'FILE'),
|
||||
'aliased-import trace is symbol-precise (no file-level fallback)',
|
||||
`status=${tr.status} crossing=${crossingId(tr)}`,
|
||||
);
|
||||
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────────
|
||||
const passed = results.filter((r) => r.pass).length;
|
||||
line(`\n## Verdict: ${passed}/${results.length} checks passed`);
|
||||
if (passed !== results.length) {
|
||||
line(' FAILED:');
|
||||
for (const r of results.filter((x) => !x.pass)) line(` - ${r.label}`);
|
||||
}
|
||||
process.exit(passed === results.length ? 0 : 1);
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"fingerprint": "4cc418ea87b6d20a68b5c1139f35d81820b715c63de0ec812e73e2135f5b00b1",
|
||||
"fingerprint": "b169463b7d02185d757b6d8601db6215ac6e7b2a20e52fb0f1276cc153836bd4",
|
||||
"scaling_budget": 1.8,
|
||||
"max_ms_large": 1000,
|
||||
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"c": {
|
||||
"fingerprint": "12a196b2d6249c8d86a931b12ecebc2a0cdf8d6f47683acdd0d8e9d8bc7657f5",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance — flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.",
|
||||
"_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.",
|
||||
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.",
|
||||
"_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c \u2014 worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.",
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
|
||||
},
|
||||
"cpp": {
|
||||
@@ -24,7 +24,7 @@
|
||||
"_note_1899_followup": "#1899 follow-up: braced-init metadata now carries element count, intentionally changing C++ capture output; CI benchmark scaling remains linear (1.129 < 1.5).",
|
||||
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).",
|
||||
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5)."
|
||||
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift \u2014 no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures \u2014 pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture \u2014 pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5)."
|
||||
},
|
||||
"csharp": {
|
||||
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
|
||||
@@ -35,20 +35,20 @@
|
||||
"rust": {
|
||||
"fingerprint": "ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) — legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED — @declaration.macro/@reference.macro + MacroRegistry → USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures — pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
|
||||
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
|
||||
},
|
||||
"php": {
|
||||
"fingerprint": "bc2c27c5ba26d5aea61142a2a99fb772222f5b969205260eb7a71b4c0bd73cdb",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04).",
|
||||
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class — fixture count 138→140, fingerprint drift expected."
|
||||
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected."
|
||||
},
|
||||
"ruby": {
|
||||
"fingerprint": "b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1991: + ruby-nested-mixin-tail-collision fixture (85→86). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb."
|
||||
"_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb."
|
||||
},
|
||||
"swift": {
|
||||
"fingerprint": "180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998",
|
||||
@@ -62,16 +62,16 @@
|
||||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "9b29cafe32873b4902bda311bd089ffc04efe08f13557b966d29544be514080a",
|
||||
"fingerprint": "062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically \u2014 no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box<T>()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06)."
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures — fingerprint drift expected.",
|
||||
"_note": "#1968: F44, F85, F87 — fingerprint drift expected."
|
||||
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures \u2014 fingerprint drift expected.",
|
||||
"_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected."
|
||||
},
|
||||
"javascript": {
|
||||
"fingerprint": "d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8",
|
||||
@@ -80,9 +80,10 @@
|
||||
"_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged."
|
||||
},
|
||||
"kotlin": {
|
||||
"fingerprint": "90aa832978d9744e50058e77a04748390a7e34e36b309f6c1d178eb07280b7ea",
|
||||
"fingerprint": "4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
|
||||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.",
|
||||
"_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear)."
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+50
-44
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.8",
|
||||
"version": "1.6.9-rc.44",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.8",
|
||||
"version": "1.6.9-rc.44",
|
||||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^4.1.0",
|
||||
"@ladybugdb/core": "^0.17.0",
|
||||
"@ladybugdb/core": "^0.18.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"busboy": "^1.6.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"commander": "^14.0.3",
|
||||
"commander": "^15.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
@@ -1255,9 +1255,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ladybugdb/core": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.17.1.tgz",
|
||||
"integrity": "sha512-K1bHnQrRy3bxkyrFHlxGqKUyIUS1LsRXKOSt14XGY/msBZHaDat/uBrlHiWpM4/24OtfOq/qwTqcTCXannnEjw==",
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.18.0.tgz",
|
||||
"integrity": "sha512-3X1NCsZZn2oPF/KtvrbQtRu9NvRw9z6BvNSW3lO9xe/I89HSnAsXXFaMDIirLnm3hgGb8ocnVhuMAyfS4DXnhA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1266,17 +1266,17 @@
|
||||
"node-addon-api": "^6.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ladybugdb/core-darwin-arm64": "0.17.1",
|
||||
"@ladybugdb/core-darwin-x64": "0.17.1",
|
||||
"@ladybugdb/core-linux-arm64": "0.17.1",
|
||||
"@ladybugdb/core-linux-x64": "0.17.1",
|
||||
"@ladybugdb/core-win32-x64": "0.17.1"
|
||||
"@ladybugdb/core-darwin-arm64": "0.18.0",
|
||||
"@ladybugdb/core-darwin-x64": "0.18.0",
|
||||
"@ladybugdb/core-linux-arm64": "0.18.0",
|
||||
"@ladybugdb/core-linux-x64": "0.18.0",
|
||||
"@ladybugdb/core-win32-x64": "0.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ladybugdb/core-darwin-arm64": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.17.1.tgz",
|
||||
"integrity": "sha512-JG/uzmolEh3wXJ/ME1EaTH5LTDQ9Cs+Q3Czul8pW2eWbWQZghQU3jjM++7ST7Bla5BX/WITqwPqPoC+sL+slfA==",
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.18.0.tgz",
|
||||
"integrity": "sha512-HlJswkjSdPyXDp+krZBnU5jHQYK/1G4jTBj9Y5cUkm7ze+qR7mj9bkstUldEC3t2N7W6Os7wzTIUUORpHDRGvA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1287,9 +1287,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-darwin-x64": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.17.1.tgz",
|
||||
"integrity": "sha512-Enjm+/V9/jpKmtzF2PB0muVkgpFUGHEvA7r16eJWxVRA/BeO8VPmngTKy9rf/4Yc6TWexjoHRug04BbTXEmerg==",
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.18.0.tgz",
|
||||
"integrity": "sha512-NUqnxnnGPs3XR86/4fLWsn+Cz9/sykVIaNOw3BsYccpiGWKvnGnDcpKID1HVHRcSa84N+CrXPkuzUvkRD36s3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1300,9 +1300,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-linux-arm64": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.17.1.tgz",
|
||||
"integrity": "sha512-P+xM9o4I3JAQtXpX19ZuLj9EeO2gppa+IdmAqhpI8tuhyA3/a85Eaxby1fXOjsbrnOAEyFJczUdyoDkhCPSyiw==",
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.18.0.tgz",
|
||||
"integrity": "sha512-/wHoqsPOna+lZZbeAOFRiTHHpaiuA+07H5FUQqZI/qrEfjjN38xznmnLVCE5YZcFCzNxAS4aK40rXaUFZLj+Ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1313,9 +1313,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-linux-x64": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.17.1.tgz",
|
||||
"integrity": "sha512-N2ujE0CrsToBpVBpou1iWwEkK7CgVxucnUNxteySrnDccZwICXFP5BlcFpKE0qq3Eqmqszh4ptR4GuSi6rKPGw==",
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.18.0.tgz",
|
||||
"integrity": "sha512-ge9pGnU94jVBOYxAuUq3d9BYSS3ProtwIKse9ZKXxeL9Hh8Qmwcz9Ey++BJMm+lSN8dE0lUBQTGXCTd1jrMnpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1326,9 +1326,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-win32-x64": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.17.1.tgz",
|
||||
"integrity": "sha512-9i3xNfFAMqFRuQG3F1hOCWYGna6eTg8HJ/XYhWVDGkeFJNUV3IdneEiYttF5B2qAtQYUd4sAikScsImrMRw+6g==",
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.18.0.tgz",
|
||||
"integrity": "sha512-RLW9m9BJ4LdFjKsTOZdg43FjmdboZ1gvhmnP494JPfVsmNt+7lMJOmhtvuePj3uAhOEd9qOpGN8hqGiPDywbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1913,9 +1913,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
|
||||
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
|
||||
"version": "25.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz",
|
||||
"integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
@@ -2527,12 +2527,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
|
||||
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
@@ -4200,15 +4200,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-common": {
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.26.0.tgz",
|
||||
"integrity": "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==",
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.27.0.tgz",
|
||||
"integrity": "sha512-3KxL5wIVqa8Ex08jxSzncm9CMgw8CjOFyOQ7SxvG9o0cVLlhTNKXyIQuTbtX4tGPJEf73OER2xrjt4HJSBL4ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/onnxruntime-node": {
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.26.0.tgz",
|
||||
"integrity": "sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ==",
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.27.0.tgz",
|
||||
"integrity": "sha512-QEzGwrvNBgv4uPVdnbHsOGG4G6T96mdlcFI8aAKPjMU8wOPpVocPXb6k3QGkaZagVTv2G9Bnnbo6Z3JdXr1fQw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
@@ -4219,9 +4219,15 @@
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"global-agent": "^4.1.3",
|
||||
"onnxruntime-common": "1.26.0"
|
||||
"onnxruntime-common": "1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-node/node_modules/onnxruntime-common": {
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.26.0.tgz",
|
||||
"integrity": "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/onnxruntime-web": {
|
||||
"version": "1.26.0-dev.20260416-b7804b056c",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz",
|
||||
@@ -5424,9 +5430,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
|
||||
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
|
||||
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.8",
|
||||
"version": "1.6.9-rc.44",
|
||||
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
|
||||
"author": "Abhigyan Patwari",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
@@ -56,12 +56,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^4.1.0",
|
||||
"@ladybugdb/core": "^0.17.0",
|
||||
"@ladybugdb/core": "^0.18.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"busboy": "^1.6.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"commander": "^14.0.3",
|
||||
"commander": "^15.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
|
||||
@@ -36,6 +36,7 @@ const PLATFORM_LOGIC = [
|
||||
'test/unit/lbug-pool-fts-load.test.ts',
|
||||
'test/unit/repo-manager.test.ts',
|
||||
'test/unit/repo-manager-finalize-invariant.test.ts',
|
||||
'test/unit/git-utils.test.ts',
|
||||
'test/unit/hooks.test.ts',
|
||||
'test/unit/hook-db-lock-probe.test.ts',
|
||||
'test/unit/cursor-hook.test.ts',
|
||||
@@ -46,6 +47,7 @@ const PLATFORM_LOGIC = [
|
||||
'test/unit/ignore-service.test.ts',
|
||||
'test/unit/group/bridge-db.test.ts',
|
||||
'test/unit/group/bridge-db-edge.test.ts',
|
||||
'test/unit/onnxruntime-node-resolver.test.ts',
|
||||
];
|
||||
|
||||
// Native LadybugDB integration tests — exercise the @ladybugdb/core
|
||||
@@ -62,12 +64,19 @@ const LBUG_NATIVE = [
|
||||
'test/integration/lbug-orphan-sidecar-recovery.test.ts',
|
||||
'test/integration/lbug-readonly-init.test.ts',
|
||||
'test/integration/lbug-non-ascii-path.test.ts',
|
||||
// Cross-repo trace e2e: builds two real lbug indexes + a real bridge and
|
||||
// opens them through the pool adapter (native addon + bridge file locking).
|
||||
// Windows is skipped in-file (describeReopen) due to the bridge reopen lock.
|
||||
'test/integration/group/cross-trace-e2e.test.ts',
|
||||
'test/integration/local-backend.test.ts',
|
||||
'test/integration/local-backend-calltool.test.ts',
|
||||
'test/integration/search-core.test.ts',
|
||||
'test/integration/search-pool.test.ts',
|
||||
'test/integration/fts-description-search.test.ts',
|
||||
'test/integration/staleness-and-stability.test.ts',
|
||||
'test/integration/analyze-wal-checkpoint-failure.test.ts',
|
||||
'test/integration/fts-stemmer-sweep.test.ts',
|
||||
'test/integration/lbug-multiwriter-deadlock.test.ts',
|
||||
];
|
||||
|
||||
// Process spawning and CLI tests — exercise child_process with real
|
||||
@@ -75,6 +84,7 @@ const LBUG_NATIVE = [
|
||||
// quoting, path resolution, signal handling)
|
||||
const SPAWN_CLI = [
|
||||
'test/integration/cli-e2e.test.ts',
|
||||
'test/integration/cli-limit-e2e.test.ts',
|
||||
'test/integration/hooks-e2e.test.ts',
|
||||
'test/integration/skills-e2e.test.ts',
|
||||
'test/integration/server-http-startup.test.ts',
|
||||
|
||||
@@ -57,11 +57,16 @@ export function formatDetectChangesResult(result: unknown): string {
|
||||
const changed = Array.isArray(payload.changed_symbols) ? payload.changed_symbols : [];
|
||||
if (changed.length > 0) {
|
||||
lines.push(t('tool.detectChanges.changedSymbols'));
|
||||
for (const symbol of changed.slice(0, 15)) {
|
||||
const shown = changed.slice(0, 15);
|
||||
for (const symbol of shown) {
|
||||
lines.push(` ${symbol.type ?? 'Symbol'} ${symbol.name ?? '?'} → ${symbol.filePath ?? '?'}`);
|
||||
}
|
||||
if (changed.length > 15) {
|
||||
lines.push(t('tool.detectChanges.overflowMore', { count: changed.length - 15 }));
|
||||
// Overflow is measured against the TRUE total (summary.changed_count), not
|
||||
// the array length — the array may already be `--limit`-sliced, so using its
|
||||
// length would under-report (or hide) how many symbols are not shown.
|
||||
const totalChanged = summary.changed_count ?? changed.length;
|
||||
if (totalChanged > shown.length) {
|
||||
lines.push(t('tool.detectChanges.overflowMore', { count: totalChanged - shown.length }));
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
@@ -69,7 +74,8 @@ export function formatDetectChangesResult(result: unknown): string {
|
||||
const affected = Array.isArray(payload.affected_processes) ? payload.affected_processes : [];
|
||||
if (affected.length > 0) {
|
||||
lines.push(t('tool.detectChanges.affectedExecutionFlows'));
|
||||
for (const processInfo of affected.slice(0, 10)) {
|
||||
const shownAffected = affected.slice(0, 10);
|
||||
for (const processInfo of shownAffected) {
|
||||
const changedSteps = Array.isArray(processInfo.changed_steps)
|
||||
? processInfo.changed_steps
|
||||
: [];
|
||||
@@ -80,6 +86,12 @@ export function formatDetectChangesResult(result: unknown): string {
|
||||
})}) — ${t('tool.detectChanges.changedSteps', { steps })}`,
|
||||
);
|
||||
}
|
||||
const totalAffected = summary.affected_count ?? affected.length;
|
||||
if (totalAffected > shownAffected.length) {
|
||||
lines.push(
|
||||
t('tool.detectChanges.overflowMore', { count: totalAffected - shownAffected.length }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n').trim();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/
|
||||
import { resolveEmbeddingConfig } from '../core/embeddings/config.js';
|
||||
import { isHttpMode } from '../core/embeddings/http-client.js';
|
||||
import { getLocalEmbeddingRuntimeBlocker } from '../core/embeddings/runtime-support.js';
|
||||
import { cudaRedirectDoctorStatus } from '../core/embeddings/onnxruntime-node-resolver.js';
|
||||
import { checkLbugNative } from '../core/lbug/native-check.js';
|
||||
import { getExtensionInstallPolicy } from '../core/lbug/extension-loader.js';
|
||||
import { t } from './i18n/index.js';
|
||||
@@ -139,4 +140,14 @@ export const doctorCommand = async () => {
|
||||
if (support.detail) {
|
||||
process.stderr.write(`\n${support.detail.replace(/^/gm, ' ')}\n\n`);
|
||||
}
|
||||
// Surface the CUDA-build-redirect decision so "why is my CUDA-13 host
|
||||
// still on CPU" is visible without digging through debug logs (#2341
|
||||
// follow-up). Only meaningful on the local runtime path.
|
||||
if (!isHttpMode()) {
|
||||
const cudaRedirect = cudaRedirectDoctorStatus();
|
||||
console.log(` ${padDisplayEnd('CUDA:', 12)}${cudaRedirect.status}`);
|
||||
if (cudaRedirect.detail) {
|
||||
console.log(` ${padDisplayEnd('', 12)}${cudaRedirect.detail}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,6 +100,7 @@ const OPTION_DESCRIPTION_KEYS = {
|
||||
'wiki|--lang <lang>': 'help.option.wiki.lang',
|
||||
'publish|--id <owner/repo>': 'help.option.publish.id',
|
||||
'publish|--skip-git': 'help.option.skipGit',
|
||||
'query|-q, --query <text>': 'help.option.query.flag',
|
||||
'query|-r, --repo <name>': 'help.option.repo.targetOmitOne',
|
||||
'query|--branch <name>': 'help.option.branch',
|
||||
'query|-c, --context <text>': 'help.option.query.context',
|
||||
@@ -110,6 +111,7 @@ const OPTION_DESCRIPTION_KEYS = {
|
||||
'context|--branch <name>': 'help.option.branch',
|
||||
'context|-u, --uid <uid>': 'help.option.context.uid',
|
||||
'context|-f, --file <path>': 'help.option.context.file',
|
||||
'context|-l, --limit <n>': 'help.option.context.limit',
|
||||
'context|--content': 'help.option.content',
|
||||
'impact|-d, --direction <dir>': 'help.option.impact.direction',
|
||||
'impact|-r, --repo <name>': 'help.option.repo.target',
|
||||
@@ -119,13 +121,15 @@ const OPTION_DESCRIPTION_KEYS = {
|
||||
'impact|--kind <kind>': 'help.option.impact.kind',
|
||||
'impact|--depth <n>': 'help.option.impact.depth',
|
||||
'impact|--include-tests': 'help.option.impact.includeTests',
|
||||
'impact|--limit <n>': 'help.option.impact.limit',
|
||||
'impact|-l, --limit <n>': 'help.option.impact.limit',
|
||||
'impact|--offset <n>': 'help.option.impact.offset',
|
||||
'impact|--summary-only': 'help.option.impact.summaryOnly',
|
||||
'cypher|-r, --repo <name>': 'help.option.repo.target',
|
||||
'cypher|--branch <name>': 'help.option.branch',
|
||||
'cypher|-l, --limit <n>': 'help.option.cypher.limit',
|
||||
'detect-changes|-s, --scope <scope>': 'help.option.detectChanges.scope',
|
||||
'detect-changes|-b, --base-ref <ref>': 'help.option.detectChanges.baseRef',
|
||||
'detect-changes|-l, --limit <n>': 'help.option.detectChanges.limit',
|
||||
'detect-changes|-r, --repo <name>': 'help.option.repo.target',
|
||||
'detect-changes|--branch <name>': 'help.option.branch',
|
||||
'check|--cycles': 'help.option.check.cycles',
|
||||
|
||||
@@ -51,7 +51,7 @@ export const en = {
|
||||
'remove.removed': 'Removed: {{name}}',
|
||||
'remove.failed': 'Failed to remove {{name}}: {{message}}',
|
||||
'tool.noIndexed': 'GitNexus: No indexed repositories found. Run: gitnexus analyze',
|
||||
'tool.usage.query': 'Usage: gitnexus query <search_query>',
|
||||
'tool.usage.query': 'Usage: gitnexus query [search_query] or gitnexus query --query <text>',
|
||||
'tool.usage.context': 'Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]',
|
||||
'tool.usage.impact':
|
||||
'Usage: gitnexus impact <symbol_name> [--uid <uid>] [--file <path>] [--kind <kind>] [--direction upstream|downstream]',
|
||||
@@ -244,12 +244,15 @@ export const en = {
|
||||
'help.option.branch': 'Scope to a specific branch index (multi-branch repos)',
|
||||
'help.option.context.uid': 'Direct symbol UID (zero-ambiguity lookup)',
|
||||
'help.option.context.file': 'File path to disambiguate common names',
|
||||
'help.option.context.limit': 'Max callers/callees/processes to return',
|
||||
'help.option.query.flag': 'Search query (alias for positional argument)',
|
||||
'help.option.impact.kind':
|
||||
'Kind filter to disambiguate common names (e.g. Function, Class, Method)',
|
||||
'help.option.impact.direction': 'upstream (dependants) or downstream (dependencies)',
|
||||
'help.option.impact.depth': 'Max relationship depth (default: 3)',
|
||||
'help.option.impact.includeTests': 'Include test files in results',
|
||||
'help.option.impact.limit': 'Max symbols per depth level (default: 100)',
|
||||
'help.option.impact.limit':
|
||||
'Max symbols per depth level and affected processes/modules to return (default: 100)',
|
||||
'help.option.impact.offset': 'Skip N symbols per depth level for pagination',
|
||||
'help.option.impact.summaryOnly': 'Return counts and risk only, omit symbol list',
|
||||
'help.option.trace.fromUid': 'Source symbol UID (zero-ambiguity lookup)',
|
||||
@@ -260,6 +263,8 @@ export const en = {
|
||||
'help.option.trace.includeTests': 'Traverse through test-file symbols (default: false)',
|
||||
'help.option.detectChanges.scope': 'What to analyze: unstaged, staged, all, or compare',
|
||||
'help.option.detectChanges.baseRef': 'Branch/commit for compare scope (e.g. main)',
|
||||
'help.option.detectChanges.limit': 'Max changed symbols to return',
|
||||
'help.option.cypher.limit': 'Max result rows to return',
|
||||
'help.option.check.cycles': 'Detect circular imports and fail when any are found',
|
||||
'help.option.evalServer.host':
|
||||
'Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)',
|
||||
@@ -285,5 +290,5 @@ export const en = {
|
||||
'help.option.group.contracts.repo': 'Filter by repo',
|
||||
'help.option.group.contracts.unmatched': 'Show only unmatched contracts',
|
||||
'help.analyze.environment':
|
||||
'\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_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\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\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
|
||||
'\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_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\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 GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
|
||||
} as const;
|
||||
|
||||
@@ -55,7 +55,7 @@ export const zhCN = {
|
||||
'remove.removed': '已移除:{{name}}',
|
||||
'remove.failed': '移除 {{name}} 失败:{{message}}',
|
||||
'tool.noIndexed': 'GitNexus:未找到已索引仓库。请运行:gitnexus analyze',
|
||||
'tool.usage.query': '用法:gitnexus query <搜索词>',
|
||||
'tool.usage.query': '用法:gitnexus query [搜索词] 或 gitnexus query --query <文本>',
|
||||
'tool.usage.context': '用法:gitnexus context <符号名> [--uid <uid>] [--file <路径>]',
|
||||
'tool.usage.impact':
|
||||
'用法:gitnexus impact <符号名> [--uid <uid>] [--file <路径>] [--kind <类型>] [--direction upstream|downstream]',
|
||||
@@ -228,11 +228,13 @@ export const zhCN = {
|
||||
'help.option.branch': '将查询限定到指定分支的索引(多分支仓库)',
|
||||
'help.option.context.uid': '直接符号 UID(零歧义查找)',
|
||||
'help.option.context.file': '用于消除常见名称歧义的文件路径',
|
||||
'help.option.context.limit': '最多返回的调用者/被调用者/流程数',
|
||||
'help.option.query.flag': '搜索词(位置参数的别名)',
|
||||
'help.option.impact.kind': '用于消除常见名称歧义的类型过滤(如 Function、Class、Method)',
|
||||
'help.option.impact.direction': 'upstream(依赖它的项)或 downstream(它依赖的项)',
|
||||
'help.option.impact.depth': '最大关系遍历深度(默认:3)',
|
||||
'help.option.impact.includeTests': '在结果中包含测试文件',
|
||||
'help.option.impact.limit': '每层深度最大符号数(默认:100)',
|
||||
'help.option.impact.limit': '每层深度最大符号数及最多返回的受影响流程/模块数(默认:100)',
|
||||
'help.option.impact.offset': '每层深度跳过 N 个符号(分页用)',
|
||||
'help.option.impact.summaryOnly': '仅返回计数和风险等级,省略符号列表',
|
||||
'help.option.trace.fromUid': '源符号 UID(零歧义查找)',
|
||||
@@ -243,6 +245,8 @@ export const zhCN = {
|
||||
'help.option.trace.includeTests': '遍历时包含测试文件中的符号(默认:false)',
|
||||
'help.option.detectChanges.scope': '分析范围:unstaged、staged、all 或 compare',
|
||||
'help.option.detectChanges.baseRef': 'compare 范围的分支/提交(例如 main)',
|
||||
'help.option.detectChanges.limit': '最多返回的已变更符号数',
|
||||
'help.option.cypher.limit': '最多返回的结果行数',
|
||||
'help.option.check.cycles': '检测循环导入,并在发现循环时失败',
|
||||
'help.option.evalServer.host': '绑定地址(默认:127.0.0.1;用 0.0.0.0 暴露到所有网卡)',
|
||||
'help.option.evalServer.idleTimeout': '空闲 N 秒后自动关闭(0 = 禁用)',
|
||||
@@ -265,5 +269,5 @@ export const zhCN = {
|
||||
'help.option.group.contracts.repo': '按仓库过滤',
|
||||
'help.option.group.contracts.unmatched': '仅显示未匹配契约',
|
||||
'help.analyze.environment':
|
||||
'\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。',
|
||||
'\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。',
|
||||
} satisfies EnglishMessages;
|
||||
|
||||
@@ -314,8 +314,9 @@ program
|
||||
// These invoke LocalBackend directly for use in eval, scripts, and CI.
|
||||
|
||||
program
|
||||
.command('query <search_query>')
|
||||
.command('query [search_query]')
|
||||
.description('Search the knowledge graph for execution flows related to a concept')
|
||||
.option('-q, --query <text>', 'Search query (alias for positional argument)')
|
||||
.option('-r, --repo <name>', 'Target repository (omit if only one indexed)')
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.option('-c, --context <text>', 'Task context to improve ranking')
|
||||
@@ -331,6 +332,7 @@ program
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
|
||||
.option('-f, --file <path>', 'File path to disambiguate common names')
|
||||
.option('-l, --limit <n>', 'Max callers/callees/processes to return')
|
||||
.option('--content', 'Include full symbol source code')
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'contextCommand'));
|
||||
|
||||
@@ -357,7 +359,10 @@ program
|
||||
)
|
||||
.option('--depth <n>', 'Max relationship depth (default: 3)')
|
||||
.option('--include-tests', 'Include test files in results')
|
||||
.option('--limit <n>', 'Max symbols per depth level (default: 100)')
|
||||
.option(
|
||||
'-l, --limit <n>',
|
||||
'Max symbols per depth level and affected processes/modules to return (default: 100)',
|
||||
)
|
||||
.option('--offset <n>', 'Skip N symbols per depth level for pagination')
|
||||
.option('--summary-only', 'Return counts and risk only, omit symbol list')
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'impactCommand'));
|
||||
@@ -380,6 +385,7 @@ program
|
||||
.description('Execute raw Cypher query against the knowledge graph')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.option('-l, --limit <n>', 'Max result rows to return')
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'cypherCommand'));
|
||||
|
||||
program
|
||||
@@ -390,6 +396,7 @@ program
|
||||
.option('-b, --base-ref <ref>', 'Branch/commit for compare scope (e.g. main)')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.option('-l, --limit <n>', 'Max changed symbols to return')
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'detectChangesCommand'));
|
||||
|
||||
program
|
||||
|
||||
@@ -58,9 +58,37 @@ function output(data: any): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `--limit` CLI option into a positive row cap, or `undefined` when the
|
||||
* flag is absent, non-numeric, zero, or negative.
|
||||
*
|
||||
* Treating invalid / 0 / negative input as "no limit" — rather than the old
|
||||
* `options.limit ? Math.max(0, parseInt(...)) : undefined` path, where a string
|
||||
* like `"abc"` is truthy and yields `NaN`, then `slice(0, NaN)` silently EMPTIES
|
||||
* the result with exit 0 — keeps the guardrail commands (impact / context /
|
||||
* detect-changes) honest: a bad `--limit` shows everything, never nothing.
|
||||
*/
|
||||
function parseLimit(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const n = Number(raw);
|
||||
return Number.isInteger(n) && n > 0 ? n : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an `--offset` CLI option into a non-negative pagination start, or
|
||||
* `undefined` when the flag is absent or invalid. Mirrors {@link parseLimit};
|
||||
* offset `0` is valid ("start at the beginning"), so the guard is `>= 0`.
|
||||
*/
|
||||
function parseOffset(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const n = Number(raw);
|
||||
return Number.isInteger(n) && n >= 0 ? n : undefined;
|
||||
}
|
||||
|
||||
export async function queryCommand(
|
||||
queryText: string,
|
||||
queryText: string | undefined,
|
||||
options?: {
|
||||
query?: string;
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
context?: string;
|
||||
@@ -69,7 +97,8 @@ export async function queryCommand(
|
||||
content?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!queryText?.trim()) {
|
||||
const resolvedQuery = queryText?.trim() || options?.query?.trim();
|
||||
if (!resolvedQuery) {
|
||||
cliErrorKey('tool.usage.query');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -77,10 +106,10 @@ export async function queryCommand(
|
||||
const backend = await getBackend();
|
||||
const result = await backend.callTool('query', {
|
||||
// #2175: canonical param is search_query; the backend still accepts legacy "query".
|
||||
search_query: queryText,
|
||||
search_query: resolvedQuery,
|
||||
task_context: options?.context,
|
||||
goal: options?.goal,
|
||||
limit: options?.limit ? parseInt(options.limit) : undefined,
|
||||
limit: parseLimit(options?.limit),
|
||||
include_content: options?.content ?? false,
|
||||
repo: options?.repo,
|
||||
branch: options?.branch,
|
||||
@@ -95,6 +124,7 @@ export async function contextCommand(
|
||||
branch?: string;
|
||||
file?: string;
|
||||
uid?: string;
|
||||
limit?: string;
|
||||
content?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
@@ -108,6 +138,7 @@ export async function contextCommand(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const limit = parseLimit(options?.limit);
|
||||
const backend = await getBackend();
|
||||
const result = await backend.callTool('context', {
|
||||
name: name || undefined,
|
||||
@@ -117,6 +148,24 @@ export async function contextCommand(
|
||||
repo: options?.repo,
|
||||
branch: options?.branch,
|
||||
});
|
||||
if (limit !== undefined) {
|
||||
// Bound every array-valued category under incoming/outgoing (calls, accesses,
|
||||
// imports, extends, uses, …) — categorize() buckets by relType, so the prior
|
||||
// hardcoded calls/accesses missed the rest (e.g. incoming.accesses) — plus
|
||||
// typed_properties and processes, so --limit caps the whole context payload.
|
||||
for (const dir of [result.incoming, result.outgoing] as Array<
|
||||
Record<string, unknown> | undefined
|
||||
>) {
|
||||
if (!dir) continue;
|
||||
for (const key of Object.keys(dir)) {
|
||||
const bucket = dir[key];
|
||||
if (Array.isArray(bucket)) dir[key] = bucket.slice(0, limit);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(result.typed_properties))
|
||||
result.typed_properties = result.typed_properties.slice(0, limit);
|
||||
if (Array.isArray(result.processes)) result.processes = result.processes.slice(0, limit);
|
||||
}
|
||||
output(result);
|
||||
}
|
||||
|
||||
@@ -160,10 +209,8 @@ export async function impactCommand(
|
||||
|
||||
try {
|
||||
const backend = await getBackend();
|
||||
const rawLimit = parseInt(options?.limit ?? '', 10);
|
||||
const rawOffset = parseInt(options?.offset ?? '', 10);
|
||||
const parsedLimit = Number.isFinite(rawLimit) ? rawLimit : undefined;
|
||||
const parsedOffset = Number.isFinite(rawOffset) ? rawOffset : undefined;
|
||||
const parsedLimit = parseLimit(options?.limit);
|
||||
const parsedOffset = parseOffset(options?.offset);
|
||||
// `--line` is a PDG-only statement anchor (1-based source line). Parse it to
|
||||
// an integer when provided and thread it ONLY when present, so the backend's
|
||||
// line-without-pdg / non-positive-integer validation fires on the real value
|
||||
@@ -189,6 +236,15 @@ export async function impactCommand(
|
||||
offset: parsedOffset,
|
||||
summaryOnly: options?.summaryOnly ?? undefined,
|
||||
});
|
||||
// Client-side cap of the affected-list payload to --limit (parity with the
|
||||
// other tool commands). The backend already paginates byDepth per level to
|
||||
// the same limit, so byDepth needs no client-side re-slice.
|
||||
if (parsedLimit !== undefined) {
|
||||
if (Array.isArray(result.affected_processes))
|
||||
result.affected_processes = result.affected_processes.slice(0, parsedLimit);
|
||||
if (Array.isArray(result.affected_modules))
|
||||
result.affected_modules = result.affected_modules.slice(0, parsedLimit);
|
||||
}
|
||||
output(result);
|
||||
} catch (err: unknown) {
|
||||
// Belt-and-suspenders: catch infrastructure failures (getBackend, callTool transport)
|
||||
@@ -209,6 +265,7 @@ export async function cypherCommand(
|
||||
options?: {
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
limit?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!query?.trim()) {
|
||||
@@ -216,6 +273,7 @@ export async function cypherCommand(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const limit = parseLimit(options?.limit);
|
||||
const backend = await getBackend();
|
||||
const result = await backend.callTool('cypher', {
|
||||
// #2175: canonical param is statement; the backend still accepts legacy "query".
|
||||
@@ -223,6 +281,25 @@ export async function cypherCommand(
|
||||
repo: options?.repo,
|
||||
branch: options?.branch,
|
||||
});
|
||||
if (limit !== undefined) {
|
||||
if (Array.isArray(result)) {
|
||||
// Non-tabular result: a raw row array.
|
||||
result.splice(limit);
|
||||
} else if (result && typeof result === 'object' && typeof result.row_count === 'number') {
|
||||
// Tabular result: { markdown, row_count }. The markdown is a table built as
|
||||
// [header, separator, ...dataRows].join('\n'), so slice it to `limit` data
|
||||
// rows (keeping the 2 header lines) and report a row_count that matches what
|
||||
// is actually printed — otherwise `--limit 2` over 50 rows prints all 50 but
|
||||
// claims row_count: 2.
|
||||
if (typeof result.markdown === 'string' && result.row_count > limit) {
|
||||
result.markdown = result.markdown
|
||||
.split('\n')
|
||||
.slice(0, 2 + limit)
|
||||
.join('\n');
|
||||
}
|
||||
result.row_count = Math.min(result.row_count, limit);
|
||||
}
|
||||
}
|
||||
output(result);
|
||||
}
|
||||
|
||||
@@ -231,7 +308,9 @@ export async function detectChangesCommand(options?: {
|
||||
baseRef?: string;
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
limit?: string;
|
||||
}): Promise<void> {
|
||||
const limit = parseLimit(options?.limit);
|
||||
const backend = await getBackend();
|
||||
const result = await backend.callTool('detect_changes', {
|
||||
scope: options?.scope || 'unstaged',
|
||||
@@ -239,6 +318,12 @@ export async function detectChangesCommand(options?: {
|
||||
repo: options?.repo,
|
||||
branch: options?.branch,
|
||||
});
|
||||
if (limit !== undefined) {
|
||||
if (Array.isArray(result.changed_symbols))
|
||||
result.changed_symbols = result.changed_symbols.slice(0, limit);
|
||||
if (Array.isArray(result.affected_processes))
|
||||
result.affected_processes = result.affected_processes.slice(0, limit);
|
||||
}
|
||||
output(formatDetectChangesResult(result));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,53 @@
|
||||
import { defaultEmbeddingThreads } from '../platform/capabilities.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig } from './types.js';
|
||||
|
||||
export const DEFAULT_VECTOR_MAX_DISTANCE = 0.5;
|
||||
export const DEFAULT_MCP_VECTOR_MAX_DISTANCE = 0.6;
|
||||
|
||||
/**
|
||||
* Cosine distance over normalized embeddings is bounded to [0, 2], so any threshold
|
||||
* above this accepts every row and silently disables the relevance filter. Values
|
||||
* over the ceiling are clamped to it rather than passed through.
|
||||
*/
|
||||
export const VECTOR_MAX_DISTANCE_CEILING = 2;
|
||||
|
||||
const warned = new Set<string>();
|
||||
|
||||
const warnOnce = (key: string, message: string): void => {
|
||||
if (warned.has(key)) return;
|
||||
warned.add(key);
|
||||
logger.warn(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the effective max accepted vector/semantic cosine distance.
|
||||
* Reads `GITNEXUS_VECTOR_MAX_DISTANCE`. Unset/empty/whitespace → silent fallback.
|
||||
* Invalid (non-numeric, <= 0, non-finite) → fallback plus a one-time warning.
|
||||
* Values above the cosine ceiling (2) are clamped to it with a one-time warning.
|
||||
*/
|
||||
export const getVectorMaxDistance = (fallback: number = DEFAULT_VECTOR_MAX_DISTANCE): number => {
|
||||
const raw = process.env.GITNEXUS_VECTOR_MAX_DISTANCE;
|
||||
if (raw === undefined || raw.trim() === '') return fallback;
|
||||
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
warnOnce(
|
||||
`invalid:${raw}`,
|
||||
` GITNEXUS_VECTOR_MAX_DISTANCE must be a positive number in (0, ${VECTOR_MAX_DISTANCE_CEILING}], got "${raw}" — using default ${fallback}`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
if (parsed > VECTOR_MAX_DISTANCE_CEILING) {
|
||||
warnOnce(
|
||||
`clamp:${raw}`,
|
||||
` GITNEXUS_VECTOR_MAX_DISTANCE=${parsed} exceeds the cosine-distance ceiling (${VECTOR_MAX_DISTANCE_CEILING}) — clamping`,
|
||||
);
|
||||
return VECTOR_MAX_DISTANCE_CEILING;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const parsePositiveInt = (name: string, value: string | undefined, fallback: number): number => {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number(value);
|
||||
|
||||
@@ -19,94 +19,18 @@ if (!process.env.ORT_LOG_LEVEL) {
|
||||
// runtime. The runtime values (pipeline, env) are dynamically imported inside
|
||||
// initEmbedder, after the platform guard has passed (#1515).
|
||||
import type { FeatureExtractionPipeline, ProgressInfo } from '@huggingface/transformers';
|
||||
import { existsSync } from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { createRequire } from 'module';
|
||||
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js';
|
||||
import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js';
|
||||
import { resolveEmbeddingConfig } from './config.js';
|
||||
import { applyHfEnvOverrides, isHfDownloadFailure, withHfDownloadRetry } from './hf-env.js';
|
||||
import { getLocalEmbeddingRuntimeBlocker } from './runtime-support.js';
|
||||
import { ensureOnnxRuntimeCommonResolvable } from './onnxruntime-common-resolver.js';
|
||||
import {
|
||||
ensureOnnxRuntimeNodeMatchesSystem,
|
||||
isEffectiveCudaAvailable,
|
||||
} from './onnxruntime-node-resolver.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Check whether the onnxruntime-node package that @huggingface/transformers
|
||||
* will actually load at runtime ships the CUDA execution provider.
|
||||
*
|
||||
* Critical: we resolve from transformers' own module scope, NOT from ours.
|
||||
* npm may install two copies — a top-level 1.24.x (our dep) and a nested
|
||||
* 1.21.0 (transformers' pinned dep). The guard must inspect whichever copy
|
||||
* transformers.js will dlopen, otherwise the check is meaningless.
|
||||
*/
|
||||
function hasOrtCudaProvider(): boolean {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
// Resolve from @huggingface/transformers' scope so we find the same
|
||||
// onnxruntime-node binary that transformers.js will use at runtime
|
||||
const transformersDir = dirname(require.resolve('@huggingface/transformers/package.json'));
|
||||
const ortRequire = createRequire(join(transformersDir, 'package.json'));
|
||||
const ortPath = dirname(ortRequire.resolve('onnxruntime-node/package.json'));
|
||||
// ORT 1.24.x only ships CUDA binaries for linux/x64 (downloaded from NuGet
|
||||
// at postinstall). arm64 will correctly return false here until ORT adds support.
|
||||
const arch = process.arch;
|
||||
return existsSync(
|
||||
join(ortPath, 'bin', 'napi-v6', 'linux', arch, 'libonnxruntime_providers_cuda.so'),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether CUDA libraries are actually available on this system.
|
||||
* ONNX Runtime's native layer crashes (uncatchable) if we attempt CUDA
|
||||
* without the required shared libraries, so we probe first.
|
||||
*
|
||||
* Checks both:
|
||||
* 1. That system CUDA libraries (libcublasLt) are present
|
||||
* 2. That onnxruntime-node ships the CUDA execution provider binary
|
||||
*
|
||||
* Both conditions must be true — system CUDA libs alone are not enough
|
||||
* if onnxruntime-node is a CPU-only build (versions < 1.24.0).
|
||||
*/
|
||||
function isCudaAvailable(): boolean {
|
||||
// First, verify onnxruntime-node has the CUDA provider binary.
|
||||
// Without this, requesting CUDA causes an uncatchable native crash.
|
||||
if (!hasOrtCudaProvider()) return false;
|
||||
|
||||
// Primary: query the dynamic linker cache — covers all architectures,
|
||||
// distro layouts, and custom install paths registered with ldconfig
|
||||
try {
|
||||
const out = execFileSync('ldconfig', ['-p'], {
|
||||
timeout: 3000,
|
||||
encoding: 'utf-8',
|
||||
windowsHide: true,
|
||||
});
|
||||
if (out.includes('libcublasLt.so.12')) return true;
|
||||
} catch {
|
||||
// ldconfig not available (e.g. non-standard container)
|
||||
}
|
||||
|
||||
// Fallback: check CUDA_PATH and LD_LIBRARY_PATH for environments where
|
||||
// ldconfig doesn't know about the CUDA install (conda, manual /opt/cuda, etc.)
|
||||
for (const envVar of ['CUDA_PATH', 'LD_LIBRARY_PATH']) {
|
||||
const val = process.env[envVar];
|
||||
if (!val) continue;
|
||||
for (const dir of val.split(':').filter(Boolean)) {
|
||||
if (
|
||||
existsSync(join(dir, 'lib64', 'libcublasLt.so.12')) ||
|
||||
existsSync(join(dir, 'lib', 'libcublasLt.so.12')) ||
|
||||
existsSync(join(dir, 'libcublasLt.so.12'))
|
||||
)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Module-level state for singleton pattern
|
||||
let embedderInstance: FeatureExtractionPipeline | null = null;
|
||||
let isInitializing = false;
|
||||
@@ -172,7 +96,7 @@ export const initEmbedder = async (
|
||||
// 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 gpuDevice = isCudaAvailable() ? 'cuda' : 'cpu';
|
||||
const gpuDevice = isEffectiveCudaAvailable() ? 'cuda' : 'cpu';
|
||||
const requestedDevice =
|
||||
forceDevice || (finalConfig.device === 'auto' ? gpuDevice : finalConfig.device);
|
||||
|
||||
@@ -183,6 +107,12 @@ export const initEmbedder = async (
|
||||
// Under pnpm-strict / `pnpm dlx`, transformers' phantom `onnxruntime-common`
|
||||
// import is unresolvable; register the fallback resolver first (#307).
|
||||
ensureOnnxRuntimeCommonResolvable();
|
||||
// Registered AFTER the common fallback so this hook resolves FIRST (Node
|
||||
// runs the most-recently-registered hook first): on CUDA-13 hosts it
|
||||
// redirects onnxruntime-node (and its version-matched onnxruntime-common)
|
||||
// to the CUDA-13 build before transformers imports them. No-op on matching
|
||||
// layouts, non-CUDA, Windows/DirectML, and macOS.
|
||||
ensureOnnxRuntimeNodeMatchesSystem();
|
||||
const { pipeline, env } = await import('@huggingface/transformers');
|
||||
|
||||
// Configure transformers.js environment
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
type EmbeddableNode,
|
||||
type SemanticSearchResult,
|
||||
type ModelProgress,
|
||||
type EmbeddingContext,
|
||||
EMBEDDABLE_LABELS,
|
||||
isShortLabel,
|
||||
LABEL_METHOD,
|
||||
@@ -34,7 +33,11 @@ import {
|
||||
STRUCTURAL_LABELS,
|
||||
collectBestChunks,
|
||||
} from './types.js';
|
||||
import { resolveEmbeddingConfig } from './config.js';
|
||||
import {
|
||||
DEFAULT_VECTOR_MAX_DISTANCE,
|
||||
getVectorMaxDistance,
|
||||
resolveEmbeddingConfig,
|
||||
} from './config.js';
|
||||
import { rankExactEmbeddingRows, type ExactEmbeddingRow } from './exact-search.js';
|
||||
import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME, STALE_HASH_SENTINEL } from '../lbug/schema.js';
|
||||
import { loadVectorExtension, createVectorIndex } from '../lbug/lbug-adapter.js';
|
||||
@@ -76,7 +79,7 @@ const ensureVectorExtensionAvailable = async (): Promise<boolean> => {
|
||||
* invalidate existing vectors, such as metadata/header shape changes,
|
||||
* structural container context changes, or preceding-context formatting rules.
|
||||
*/
|
||||
export const EMBEDDING_TEXT_VERSION = 'v2';
|
||||
export const EMBEDDING_TEXT_VERSION = 'v4';
|
||||
|
||||
/**
|
||||
* Compute a stable content fingerprint for an embeddable node.
|
||||
@@ -251,6 +254,42 @@ export interface EmbeddingPipelineResult {
|
||||
semanticMode: 'vector-index' | 'exact-scan';
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE stale embedding rows for the given nodeIds so they can be re-inserted.
|
||||
*
|
||||
* Kuzu forbids SET on vector-indexed properties; DELETE-then-INSERT is the
|
||||
* sanctioned pattern. A `"does not exist"` error means the rows are already gone
|
||||
* (safe to proceed); any other error risks vector-index corruption, so it
|
||||
* propagates and aborts the pipeline.
|
||||
*
|
||||
* Called per-batch (just before each batch's INSERT), not once up front — see
|
||||
* the caller comment / KTD7: an up-front bulk delete of every stale row leaves
|
||||
* the whole index deleted-not-reinserted if the re-embed is interrupted. Per-batch
|
||||
* interleaving bounds that window to a single batch.
|
||||
*/
|
||||
const deleteStaleEmbeddingRows = async (
|
||||
executeWithReusedStatement: (
|
||||
cypher: string,
|
||||
paramsList: Array<Record<string, any>>,
|
||||
) => Promise<void>,
|
||||
nodeIds: string[],
|
||||
): Promise<void> => {
|
||||
if (nodeIds.length === 0) return;
|
||||
try {
|
||||
await executeWithReusedStatement(
|
||||
`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) DELETE e`,
|
||||
nodeIds.map((nodeId) => ({ nodeId })),
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes('does not exist')) {
|
||||
throw new Error(
|
||||
`[embed] Failed to delete stale embedding rows — aborting to prevent vector-index corruption: ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run the embedding pipeline
|
||||
*
|
||||
@@ -259,11 +298,9 @@ export interface EmbeddingPipelineResult {
|
||||
* @param onProgress - Callback for progress updates
|
||||
* @param config - Optional configuration override
|
||||
* @param skipNodeIds - Optional set of node IDs that already have embeddings (incremental mode)
|
||||
* @param context - Optional repo/server context for metadata enrichment
|
||||
* @param existingEmbeddings - Optional map of nodeId → contentHash for incremental mode.
|
||||
* Nodes whose hash matches are skipped; nodes with a changed hash are DELETE'd
|
||||
* and re-embedded; nodes not in the map are embedded fresh.
|
||||
|
||||
*/
|
||||
export const runEmbeddingPipeline = async (
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
@@ -274,7 +311,6 @@ export const runEmbeddingPipeline = async (
|
||||
onProgress: EmbeddingProgressCallback,
|
||||
config: Partial<EmbeddingConfig> = {},
|
||||
skipNodeIds?: Set<string>,
|
||||
context?: EmbeddingContext,
|
||||
existingEmbeddings?: Map<string, string>,
|
||||
): Promise<EmbeddingPipelineResult> => {
|
||||
const finalConfig = resolveEmbeddingConfig(config);
|
||||
@@ -317,21 +353,16 @@ export const runEmbeddingPipeline = async (
|
||||
// Phase 2: Query embeddable nodes
|
||||
let nodes = await queryEmbeddableNodes(executeQuery);
|
||||
|
||||
// Apply context metadata
|
||||
if (context?.repoName) {
|
||||
for (const node of nodes) {
|
||||
node.repoName = context.repoName;
|
||||
node.serverName = context.serverName;
|
||||
}
|
||||
}
|
||||
|
||||
// Incremental mode: compare content hashes, delete stale rows, skip fresh ones.
|
||||
// Computed hashes for stale nodes are cached so batchInsertEmbeddings can reuse them
|
||||
// (avoids double computation).
|
||||
const computedStaleHashes = new Map<string, string>();
|
||||
// Stale rows are DELETE'd per-batch (just before each batch's INSERT) rather
|
||||
// than all up front — see U6 / KTD7. `staleNodeIds` is consulted inside the
|
||||
// batch loop; it stays empty in full (non-incremental) mode so no deletes fire.
|
||||
const staleNodeIds = new Set<string>();
|
||||
if (existingEmbeddings && existingEmbeddings.size > 0) {
|
||||
const beforeCount = nodes.length;
|
||||
const staleNodeIds: string[] = [];
|
||||
nodes = nodes.filter((n) => {
|
||||
const existingHash = existingEmbeddings.get(n.id);
|
||||
if (existingHash === undefined) {
|
||||
@@ -342,40 +373,16 @@ export const runEmbeddingPipeline = async (
|
||||
if (currentHash !== existingHash) {
|
||||
// Content changed — cache hash for reuse during insert, mark for DELETE + re-embed
|
||||
computedStaleHashes.set(n.id, currentHash);
|
||||
staleNodeIds.push(n.id);
|
||||
staleNodeIds.add(n.id);
|
||||
return true;
|
||||
}
|
||||
// Hash matches — skip (fresh); no need to cache hash for skipped nodes
|
||||
return false;
|
||||
});
|
||||
|
||||
// DELETE stale embedding rows so they can be re-inserted
|
||||
// (Kuzu forbids SET on vector-indexed properties; DELETE-then-INSERT is the sanctioned pattern)
|
||||
if (staleNodeIds.length > 0) {
|
||||
if (isDev) {
|
||||
logger.info(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`);
|
||||
}
|
||||
try {
|
||||
await executeWithReusedStatement(
|
||||
`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) DELETE e`,
|
||||
staleNodeIds.map((nodeId) => ({ nodeId })),
|
||||
);
|
||||
} catch (err) {
|
||||
// "does not exist" = rows already gone — safe to proceed.
|
||||
// All other errors risk vector-index corruption (Kuzu requires DELETE-before-INSERT
|
||||
// for vector-indexed properties) — propagate so the pipeline aborts cleanly.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes('does not exist')) {
|
||||
throw new Error(
|
||||
`[embed] Failed to delete stale embedding rows — aborting to prevent vector-index corruption: ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
`📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.length} stale, ${nodes.length} to embed`,
|
||||
`📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.size} stale, ${nodes.length} to embed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -500,6 +507,12 @@ export const runEmbeddingPipeline = async (
|
||||
}
|
||||
}
|
||||
|
||||
// U6 / KTD7: delete this batch's stale rows immediately before its inserts,
|
||||
// so an interrupted re-embed loses at most one batch (not the whole index).
|
||||
// Preserves Kuzu's required DELETE-before-INSERT for vector-indexed rows.
|
||||
const batchStaleIds = batch.filter((n) => staleNodeIds.has(n.id)).map((n) => n.id);
|
||||
await deleteStaleEmbeddingRows(executeWithReusedStatement, batchStaleIds);
|
||||
|
||||
// Embed chunk texts in sub-batches to control memory
|
||||
const EMBED_SUB_BATCH = finalConfig.subBatchSize;
|
||||
for (let si = 0; si < allTexts.length; si += EMBED_SUB_BATCH) {
|
||||
@@ -595,7 +608,7 @@ export const semanticSearch = async (
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
query: string,
|
||||
k: number = 10,
|
||||
maxDistance: number = 0.5,
|
||||
maxDistance: number = getVectorMaxDistance(DEFAULT_VECTOR_MAX_DISTANCE),
|
||||
): Promise<SemanticSearchResult[]> => {
|
||||
if (!isEmbedderReady()) {
|
||||
throw new Error('Embedding model not initialized. Run embedding pipeline first.');
|
||||
@@ -741,7 +754,7 @@ export const semanticSearchWithContext = async (
|
||||
k: number = 5,
|
||||
_hops: number = 1,
|
||||
): Promise<any[]> => {
|
||||
const results = await semanticSearch(executeQuery, query, k, 0.5);
|
||||
const results = await semanticSearch(executeQuery, query, k);
|
||||
|
||||
return results.map((r) => ({
|
||||
matchId: r.nodeId,
|
||||
|
||||
@@ -21,14 +21,18 @@
|
||||
* Install a synchronous, in-thread ESM resolution hook (`module.registerHooks`,
|
||||
* Node >= 22.15) that redirects `onnxruntime-common` to a copy gitnexus can
|
||||
* resolve — but only when the default resolver fails. The redirect target is
|
||||
* preferentially the `onnxruntime-common` that `onnxruntime-node` (the native
|
||||
* binding transformers actually loads) itself depends on, so the redirected copy
|
||||
* is version-matched to that binding even under `pnpm dlx` — where gitnexus'
|
||||
* npm-style `overrides` block does NOT apply, because it is honoured only from a
|
||||
* root manifest and gitnexus is a transitive dependency there. It falls back to
|
||||
* gitnexus' own direct `onnxruntime-common` dependency when that chain can't be
|
||||
* walked. onnxruntime-common is a stable, pure-JS package whose `Tensor` surface
|
||||
* is unchanged across 1.24–1.26, so either target is API-compatible. On working
|
||||
* preferentially the `onnxruntime-common` that `onnxruntime-node` depends on —
|
||||
* specifically {@link getEffectiveOnnxRuntimeNodeDir}, the SAME onnxruntime-node
|
||||
* copy the sibling {@link ./onnxruntime-node-resolver.ts} CUDA-major redirect
|
||||
* will actually load (transformers' own default when no redirect is active,
|
||||
* or the CUDA-build-matched copy when one is) — so this hook and that one can
|
||||
* never disagree about which onnxruntime-node's own onnxruntime-common
|
||||
* dependency to pair with, even under `pnpm dlx` where gitnexus' npm-style
|
||||
* `overrides` block does NOT apply (honoured only from a root manifest, and
|
||||
* gitnexus is a transitive dependency there). Falls back to gitnexus' own
|
||||
* direct `onnxruntime-common` dependency when that chain can't be walked.
|
||||
* onnxruntime-common is a stable, pure-JS package whose `Tensor` surface is
|
||||
* unchanged across 1.24–1.26, so either target is API-compatible. On working
|
||||
* layouts the default resolver succeeds first and the hook never fires, so
|
||||
* behaviour is unchanged.
|
||||
*
|
||||
@@ -55,6 +59,8 @@
|
||||
*/
|
||||
import { registerHooks, createRequire } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
import { getEffectiveOnnxRuntimeNodeDir } from './onnxruntime-node-resolver.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
let attempted = false;
|
||||
@@ -62,21 +68,19 @@ let attempted = false;
|
||||
/**
|
||||
* Compute the file: URL the hook redirects `onnxruntime-common` to.
|
||||
*
|
||||
* Prefer the copy `onnxruntime-node` (the native binding transformers loads)
|
||||
* depends on, so the redirected module is version-matched to the binding even
|
||||
* under `pnpm dlx`, where transformers keeps its own pinned onnxruntime-node.
|
||||
* The walk resolves transformers' MAIN entry — NOT `@huggingface/transformers/
|
||||
* package.json`, which transformers' `exports` map blocks
|
||||
* (`ERR_PACKAGE_PATH_NOT_EXPORTED`) — then onnxruntime-node, then its
|
||||
* onnxruntime-common. Falls back to gitnexus' own direct dependency (always
|
||||
* resolvable from our scope) when any step fails.
|
||||
* Pair with {@link getEffectiveOnnxRuntimeNodeDir}'s onnxruntime-node copy —
|
||||
* NOT independently re-derived — so the redirected module is version-matched
|
||||
* to whichever onnxruntime-node will actually load, even under `pnpm dlx`
|
||||
* (where transformers keeps its own pinned onnxruntime-node) and even when
|
||||
* the sibling CUDA-major redirect is active. Falls back to gitnexus' own
|
||||
* direct dependency (always resolvable from our scope) when that fails.
|
||||
*/
|
||||
const resolveOnnxRuntimeCommonUrl = (): string => {
|
||||
const require = createRequire(import.meta.url);
|
||||
try {
|
||||
const transformersMain = require.resolve('@huggingface/transformers');
|
||||
const ortNodePkg = createRequire(transformersMain).resolve('onnxruntime-node/package.json');
|
||||
const common = createRequire(ortNodePkg).resolve('onnxruntime-common');
|
||||
const effectiveDir = getEffectiveOnnxRuntimeNodeDir();
|
||||
if (!effectiveDir) throw new Error('no effective onnxruntime-node dir resolved');
|
||||
const common = createRequire(join(effectiveDir, 'package.json')).resolve('onnxruntime-common');
|
||||
return pathToFileURL(common).href;
|
||||
} catch {
|
||||
return pathToFileURL(require.resolve('onnxruntime-common')).href;
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Redirect `@huggingface/transformers`' `onnxruntime-node` import to whichever
|
||||
* bundled copy's CUDA build matches this host's CUDA runtime (CUDA 12 vs 13).
|
||||
*
|
||||
* ## Why
|
||||
* transformers exact-pins `onnxruntime-node` (e.g. `1.24.3`, a CUDA **12**
|
||||
* build), while gitnexus' own `onnxruntime-node: ^1.24.0` floats to the latest
|
||||
* 1.x (a CUDA **13** build). npm/pnpm cannot dedupe an exact pin against a
|
||||
* range, so a `npm i -g` install ends up with TWO copies: gitnexus' top-level
|
||||
* CUDA-13 build (unused) and transformers' nested CUDA-12 build (the one that
|
||||
* actually loads). gitnexus' `overrides` block that would collapse them is
|
||||
* honoured only from a *root* manifest, so it is inert once gitnexus is a
|
||||
* dependency — the same transitive-override limitation documented in
|
||||
* {@link ./onnxruntime-common-resolver.ts} (#307).
|
||||
*
|
||||
* The consequence on a CUDA-13-only host: the nested CUDA-12 provider cannot
|
||||
* find `libcublasLt.so.12`, the CUDA execution provider fails to load, and
|
||||
* embeddings silently fall back to CPU (~5-6x slower) even with
|
||||
* `--embedding-device cuda`.
|
||||
*
|
||||
* ## What this does
|
||||
* Best-effort, before transformers is imported: if the system's cuBLASLt major
|
||||
* (12 or 13) does NOT match the CUDA build transformers would load by default,
|
||||
* but gitnexus' own top-level `onnxruntime-node` copy DOES match, install a
|
||||
* synchronous ESM resolution hook (`module.registerHooks`, Node >= 22.15) that
|
||||
* redirects both `onnxruntime-node` and `onnxruntime-common` to that matching
|
||||
* copy. onnxruntime-common is redirected alongside so the `Tensor` surface
|
||||
* stays a single identity, version-matched to the redirected binding.
|
||||
*
|
||||
* ## Safety
|
||||
* Detection-based and conservative — it acts ONLY when it is a net improvement:
|
||||
* - system CUDA major == default build major -> NO-OP (already correct)
|
||||
* - no system CUDA libs / non-linux -> NO-OP (CPU path)
|
||||
* - only one copy present -> NO-OP
|
||||
* - neither copy matches the system -> NO-OP (never makes it worse)
|
||||
* So CUDA-12 hosts, Windows (DirectML), macOS, and CPU-only hosts are
|
||||
* untouched. Idempotent; any failure is swallowed and leaves the default
|
||||
* resolution exactly as before. `module.registerHooks` requires Node >= 22.15
|
||||
* (the gitnexus engines floor is >= 22.0.0); on older runtimes the redirect is
|
||||
* a no-op, but the default copy's CUDA major is still probed so an
|
||||
* already-matching host (e.g. CUDA 12 + transformers' CUDA-12 build) keeps
|
||||
* auto-selecting the GPU.
|
||||
* `npm link` / symlinked local-dev checkouts are a known caveat: `resolveOurOrtNodeDir`/
|
||||
* `resolveDefaultOrtNodeDir` are anchored to this module's own real (post-symlink)
|
||||
* location via `import.meta.url`, so a linked dev checkout may resolve against
|
||||
* its own `node_modules` rather than the consuming app's — narrow, dev-only
|
||||
* blast radius; regular npm/pnpm installs are unaffected.
|
||||
*
|
||||
* The CUDA-major decision is exposed via {@link getEffectiveOnnxRuntimeNodeDir}
|
||||
* so the embedder's CUDA probe can inspect the SAME copy that will actually be
|
||||
* loaded (the probe uses CJS `require.resolve`, which an ESM hook does not
|
||||
* affect) — keeping probe and runtime consistent.
|
||||
*/
|
||||
import { registerHooks, createRequire } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
export type CudaMajor = 12 | 13;
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Read a shared object's NEEDED entries, tolerating ldd's non-zero exit when a
|
||||
* lib is unresolved (that case still yields a usable "=> not found" stdout).
|
||||
* `failed: true` means ldd produced no usable output at all (missing `ldd`
|
||||
* binary, permission-denied `.so`, sandboxed exec) — distinct from "ldd ran
|
||||
* fine and simply found no matching NEEDED entry" (`failed: false`, `needed: ''`),
|
||||
* so callers don't have to treat "detection failed" identically to "definitely
|
||||
* no CUDA provider".
|
||||
*/
|
||||
const readSoNeeded = (soPath: string): { needed: string; failed: boolean } => {
|
||||
try {
|
||||
return {
|
||||
needed: execFileSync('ldd', [soPath], {
|
||||
timeout: 5000,
|
||||
encoding: 'utf-8',
|
||||
windowsHide: true,
|
||||
}),
|
||||
failed: false,
|
||||
};
|
||||
} catch (err) {
|
||||
const out = (err as { stdout?: string } | null | undefined)?.stdout;
|
||||
if (typeof out === 'string' && out.length > 0) return { needed: out, failed: false };
|
||||
return { needed: '', failed: true };
|
||||
}
|
||||
};
|
||||
|
||||
/** The CUDA major an onnxruntime-node copy's CUDA provider links against, or null (Linux/x64 only ships one). */
|
||||
export const ortCudaMajor = (ortNodeDir: string): CudaMajor | null => {
|
||||
const so = join(
|
||||
ortNodeDir,
|
||||
'bin',
|
||||
'napi-v6',
|
||||
'linux',
|
||||
process.arch,
|
||||
'libonnxruntime_providers_cuda.so',
|
||||
);
|
||||
// A pre-PR CUDA-12 host relied only on this existence check (no `ldd`
|
||||
// dependency) — retained here as the first, unconditional signal so a host
|
||||
// whose CUDA provider `.so` is genuinely present but merely un-inspectable
|
||||
// (see the `failed` case below) is never treated identically to a host that
|
||||
// never shipped a CUDA provider at all.
|
||||
if (!existsSync(so)) return null;
|
||||
const { needed, failed } = readSoNeeded(so);
|
||||
if (failed) {
|
||||
logger.warn(
|
||||
{ so },
|
||||
'Could not read CUDA provider dependencies (ldd failed to run) — CUDA-major detection ' +
|
||||
'is unknown, not necessarily absent; embeddings will fall back to CPU either way',
|
||||
);
|
||||
}
|
||||
if (/libcublasLt\.so\.13/.test(needed)) return 13;
|
||||
if (/libcublasLt\.so\.12/.test(needed)) return 12;
|
||||
return null;
|
||||
};
|
||||
|
||||
/** The cuBLASLt major installed on this system, or null. Linux only. */
|
||||
export const detectSystemCudaMajor = (): CudaMajor | null => {
|
||||
if (process.platform !== 'linux') return null;
|
||||
try {
|
||||
const out = execFileSync('ldconfig', ['-p'], {
|
||||
timeout: 3000,
|
||||
encoding: 'utf-8',
|
||||
windowsHide: true,
|
||||
});
|
||||
if (out.includes('libcublasLt.so.13')) return 13;
|
||||
if (out.includes('libcublasLt.so.12')) return 12;
|
||||
} catch {
|
||||
// ldconfig not available (e.g. non-standard container) — fall through to path scan.
|
||||
}
|
||||
// Prefer CUDA 13 across the ENTIRE search space, not just within one
|
||||
// dir/sub pair — a `.so.12` found early (e.g. a stale CUDA_PATH entry from
|
||||
// a prior install) must not shadow a genuine `.so.13` found later in
|
||||
// LD_LIBRARY_PATH. Return immediately on a 13 (the best possible answer);
|
||||
// remember a 12 and keep scanning in case a later entry still has a 13.
|
||||
let found: CudaMajor | null = null;
|
||||
for (const envVar of ['CUDA_PATH', 'LD_LIBRARY_PATH']) {
|
||||
const val = process.env[envVar];
|
||||
if (!val) continue;
|
||||
for (const dir of val.split(':').filter(Boolean))
|
||||
for (const sub of ['lib64', 'lib', ''])
|
||||
for (const maj of [13, 12] as const)
|
||||
if (existsSync(join(dir, sub, `libcublasLt.so.${maj}`))) {
|
||||
if (maj === 13) return 13;
|
||||
found = maj;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
/** onnxruntime-node dir transformers loads by default (its own nested/pinned copy). */
|
||||
const resolveDefaultOrtNodeDir = (): string | null => {
|
||||
try {
|
||||
const transformersMain = require.resolve('@huggingface/transformers');
|
||||
return dirname(createRequire(transformersMain).resolve('onnxruntime-node/package.json'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** gitnexus' own direct top-level onnxruntime-node dir. */
|
||||
const resolveOurOrtNodeDir = (): string | null => {
|
||||
try {
|
||||
return dirname(require.resolve('onnxruntime-node/package.json'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface Decision {
|
||||
redirect: boolean;
|
||||
effectiveDir: string | null; // the onnxruntime-node dir that WILL be used (default, or ours)
|
||||
effectiveMajor: CudaMajor | null; // effectiveDir's own CUDA major, already probed — never re-probe it
|
||||
systemMajor: CudaMajor | null;
|
||||
}
|
||||
|
||||
let cached: Decision | null = null;
|
||||
|
||||
const decide = (): Decision => {
|
||||
if (cached) return cached;
|
||||
const defaultDir = resolveDefaultOrtNodeDir();
|
||||
|
||||
// Node < 22.15 has no `registerHooks` API, so a redirect can never actually
|
||||
// install (see ensureOnnxRuntimeNodeMatchesSystem below) — the probe must
|
||||
// agree with that up front, never reporting a redirect target that won't be
|
||||
// loaded. But the DEFAULT copy still loads and needs no hook, so its CUDA
|
||||
// major is still probed: a CUDA-12 host on Node 22.0–22.14 whose default
|
||||
// build already matches must keep auto-selecting the GPU exactly as it did
|
||||
// before this redirect existed.
|
||||
const canRedirect = typeof registerHooks === 'function';
|
||||
|
||||
const systemMajor = detectSystemCudaMajor();
|
||||
// `defaultDir` resolving is NOT a precondition for checking `ourDir` below —
|
||||
// if transformers' own resolution fails outright (defaultMajor stays null),
|
||||
// that still counts as "the default doesn't match", so a working `ourDir`
|
||||
// should still be picked up as the effective target instead of leaving
|
||||
// `effectiveDir` stuck at `null`. Gated behind `systemMajor != null` (as
|
||||
// before) so a non-CUDA host never pays for a provider-.so probe at all.
|
||||
const defaultMajor = systemMajor != null && defaultDir ? ortCudaMajor(defaultDir) : null;
|
||||
let decision: Decision = {
|
||||
redirect: false,
|
||||
effectiveDir: defaultDir,
|
||||
effectiveMajor: defaultMajor,
|
||||
systemMajor,
|
||||
};
|
||||
|
||||
if (canRedirect && systemMajor != null && defaultMajor !== systemMajor) {
|
||||
const ourDir = resolveOurOrtNodeDir();
|
||||
if (ourDir && ourDir !== defaultDir) {
|
||||
const ourMajor = ortCudaMajor(ourDir);
|
||||
if (ourMajor === systemMajor) {
|
||||
decision = { redirect: true, effectiveDir: ourDir, effectiveMajor: ourMajor, systemMajor };
|
||||
}
|
||||
}
|
||||
}
|
||||
cached = decision;
|
||||
return decision;
|
||||
};
|
||||
|
||||
/**
|
||||
* The onnxruntime-node dir that will actually back transformers at runtime once
|
||||
* {@link ensureOnnxRuntimeNodeMatchesSystem} has run — i.e. the redirected copy
|
||||
* when a redirect applies, otherwise transformers' default. The CUDA probe must
|
||||
* inspect THIS dir (not transformers' CJS-resolved default) so probe and
|
||||
* runtime agree. Returns null only when neither copy resolves.
|
||||
*/
|
||||
export const getEffectiveOnnxRuntimeNodeDir = (): string | null => decide().effectiveDir;
|
||||
|
||||
/**
|
||||
* Whether the onnxruntime-node copy that will actually load ships a CUDA
|
||||
* provider matching this host's CUDA major — reads straight from the cached
|
||||
* `decide()` result rather than re-probing `ortCudaMajor`/`detectSystemCudaMajor`
|
||||
* a second time (both are already computed above). `systemMajor` is checked
|
||||
* for non-null explicitly so two absent majors (null === null) never count
|
||||
* as a match.
|
||||
*/
|
||||
export const isEffectiveCudaAvailable = (): boolean => {
|
||||
const d = decide();
|
||||
return d.systemMajor !== null && d.systemMajor === d.effectiveMajor;
|
||||
};
|
||||
|
||||
/**
|
||||
* CUDA-build-redirect status for the `doctor` Embeddings section — pure
|
||||
* summary of decide()'s already-computed decision, matching
|
||||
* doctor.ts's `localEmbeddingDoctorStatus`'s `{status, detail}` shape so an
|
||||
* operator can tell "why is my CUDA-13 host still on CPU" apart from
|
||||
* "there's no system CUDA to redirect for" at a glance.
|
||||
*/
|
||||
export const cudaRedirectDoctorStatus = (): { status: string; detail: string | null } => {
|
||||
const d = decide();
|
||||
if (d.systemMajor === null) {
|
||||
return { status: 'n/a (no system CUDA detected)', detail: null };
|
||||
}
|
||||
if (d.redirect) {
|
||||
return {
|
||||
status: `✓ redirected onnxruntime-node to the CUDA ${d.systemMajor} build`,
|
||||
detail: d.effectiveDir,
|
||||
};
|
||||
}
|
||||
if (d.systemMajor === d.effectiveMajor) {
|
||||
return {
|
||||
status: `✓ default onnxruntime-node build already matches CUDA ${d.systemMajor}`,
|
||||
detail: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: `✗ no CUDA ${d.systemMajor}-matched onnxruntime-node build found (falling back to CPU)`,
|
||||
detail: d.effectiveDir,
|
||||
};
|
||||
};
|
||||
|
||||
let attempted = false;
|
||||
|
||||
/**
|
||||
* Idempotently install the CUDA-build-matching redirect. Call once immediately
|
||||
* before the dynamic `import('@huggingface/transformers')` on the local
|
||||
* embedding path (after the runtime guard, alongside the onnxruntime-common
|
||||
* fallback). No-op unless a strictly-better matching copy exists.
|
||||
*/
|
||||
export const ensureOnnxRuntimeNodeMatchesSystem = (): void => {
|
||||
if (attempted) return;
|
||||
attempted = true;
|
||||
try {
|
||||
if (typeof registerHooks !== 'function') return; // Node < 22.15: graceful no-op
|
||||
const d = decide();
|
||||
if (!d.redirect || !d.effectiveDir) return;
|
||||
|
||||
const nodeUrl = pathToFileURL(
|
||||
createRequire(join(d.effectiveDir, 'package.json')).resolve('onnxruntime-node'),
|
||||
).href;
|
||||
let commonUrl: string | null = null;
|
||||
try {
|
||||
commonUrl = pathToFileURL(
|
||||
createRequire(join(d.effectiveDir, 'package.json')).resolve('onnxruntime-common'),
|
||||
).href;
|
||||
} catch {
|
||||
commonUrl = null; // fall back to the onnxruntime-common-resolver for common
|
||||
}
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier === 'onnxruntime-node') return { url: nodeUrl, shortCircuit: true };
|
||||
if (commonUrl && specifier === 'onnxruntime-common')
|
||||
return { url: commonUrl, shortCircuit: true };
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
// info (not debug): this is the one signal an operator has that CUDA
|
||||
// embeddings are actually using the GPU on this host — the common/no-op
|
||||
// paths below stay at debug since they're the expected default.
|
||||
logger.info(
|
||||
{ systemMajor: d.systemMajor, effectiveDir: d.effectiveDir },
|
||||
'Redirected onnxruntime-node to system-matched CUDA build',
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
'onnxruntime-node CUDA-build redirect not installed',
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Text Generator Module
|
||||
*
|
||||
* Generates enriched embedding text from code nodes with metadata.
|
||||
* Generates compact, description-forward embedding text from code nodes.
|
||||
* Supports chunkable labels (Function/Method with AST chunking),
|
||||
* Class-specific structural text, and short-node direct embed.
|
||||
*
|
||||
@@ -58,33 +58,51 @@ const cleanContent = (content: string): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Build metadata header for a node
|
||||
* Compact location signal for the embedding header: the last 1-2 path segments
|
||||
* (immediate parent dir + basename), never the full deep path.
|
||||
*
|
||||
* #2333 / PR #2334 tri-review: U1 dropped the location entirely, which regressed
|
||||
* path/service-qualified semantic search (e.g. `billing/handler` vs
|
||||
* `identity/handler` in a monorepo) — and FTS indexes only name/content/description,
|
||||
* never `filePath`, so there is no keyword backfill. The bounded form restores the
|
||||
* discriminating tokens (service dir + filename-concept) at a fraction of the
|
||||
* dilution the full path caused.
|
||||
*/
|
||||
const buildMetadataHeader = (node: EmbeddableNode, config: Partial<EmbeddingConfig>): string => {
|
||||
const boundedLocation = (filePath: string): string => {
|
||||
const segments = filePath.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
return segments.slice(-2).join('/');
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a compact, description-forward header for embedding text.
|
||||
*
|
||||
* Issue #2333 (sub-issue of #2326), Option A: lead the embedding text with the
|
||||
* symbol name + doc-comment description and drop the low-signal metadata lines
|
||||
* (`Repo`/`Server`/`Export` and the verbose full `Path`). For short doc comments
|
||||
* those lines used to be ~25-30% of the embedding text, diluting the description's
|
||||
* semantic weight in the vector and weakening description-shaped search — worst
|
||||
* for CJK, where a complete concept is often 4-20 characters.
|
||||
*
|
||||
* A *bounded* location signal (last 1-2 path segments) is kept after the
|
||||
* description — see `boundedLocation` for why the full path drop was reversed.
|
||||
*
|
||||
* Full metadata is unaffected: it lives on the graph node properties, which is
|
||||
* what display/context tools read. Only the embedding text changes here.
|
||||
*
|
||||
* Option B (reorder only, keep metadata) was rejected — mean-pooled embeddings
|
||||
* weight by token proportion, not position, so reordering alone barely moves the
|
||||
* signal. Option C (a separate description-only embedding + hybrid merge) is
|
||||
* deferred to follow-up; build it only if Option A proves insufficient against
|
||||
* real measurement. Any change to this template MUST bump EMBEDDING_TEXT_VERSION.
|
||||
*/
|
||||
const buildEmbeddingHeader = (node: EmbeddableNode, config: Partial<EmbeddingConfig>): string => {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Label + name
|
||||
parts.push(`${node.label}: ${node.name}`);
|
||||
|
||||
// Repo name
|
||||
if (node.repoName) {
|
||||
parts.push(`Repo: ${node.repoName}`);
|
||||
}
|
||||
|
||||
// Server name (optional)
|
||||
if (node.serverName) {
|
||||
parts.push(`Server: ${node.serverName}`);
|
||||
}
|
||||
|
||||
// Full file path
|
||||
parts.push(`Path: ${node.filePath}`);
|
||||
|
||||
// Export status
|
||||
if (node.isExported !== undefined) {
|
||||
parts.push(`Export: ${node.isExported}`);
|
||||
}
|
||||
|
||||
// Description (truncated)
|
||||
// Description hoisted above everything else so its semantic signal dominates
|
||||
// the embedding vector and is never the part lost to token-limit truncation.
|
||||
if (node.description) {
|
||||
const maxLen = config.maxDescriptionLength ?? DEFAULT_EMBEDDING_CONFIG.maxDescriptionLength;
|
||||
const truncated = truncateDescription(node.description, maxLen);
|
||||
@@ -93,6 +111,16 @@ const buildMetadataHeader = (node: EmbeddableNode, config: Partial<EmbeddingConf
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded location signal — placed after the description so the description
|
||||
// still leads the vector. Restores path/service disambiguation lost when the
|
||||
// full Path line was dropped (FTS does not index filePath to backfill it).
|
||||
if (node.filePath) {
|
||||
const loc = boundedLocation(node.filePath);
|
||||
if (loc) {
|
||||
parts.push(`Loc: ${loc}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
};
|
||||
|
||||
@@ -102,7 +130,7 @@ const generateCodeBodyText = (
|
||||
config: Partial<EmbeddingConfig>,
|
||||
prevTail?: string,
|
||||
): string => {
|
||||
const header = buildMetadataHeader(node, config);
|
||||
const header = buildEmbeddingHeader(node, config);
|
||||
const parts = [header];
|
||||
if (prevTail) {
|
||||
parts.push(`[preceding context]: ...${cleanContent(prevTail)}`);
|
||||
@@ -128,7 +156,7 @@ const generateStructuralTypeText = (
|
||||
chunkIndex?: number,
|
||||
prevTail?: string,
|
||||
): string => {
|
||||
const header = buildMetadataHeader(node, config);
|
||||
const header = buildEmbeddingHeader(node, config);
|
||||
const parts: string[] = [header];
|
||||
const isFirstChunk = chunkIndex === undefined || chunkIndex === 0;
|
||||
const cleanedContent = cleanContent(node.content);
|
||||
@@ -253,7 +281,7 @@ export const generateEmbeddingText = (
|
||||
prevTail?: string,
|
||||
): string => {
|
||||
if (isShortLabel(node.label)) {
|
||||
const header = buildMetadataHeader(node, config);
|
||||
const header = buildEmbeddingHeader(node, config);
|
||||
const cleaned = cleanContent(node.content);
|
||||
return `${header}\n\n${cleaned}`;
|
||||
}
|
||||
|
||||
@@ -289,14 +289,6 @@ export interface CachedEmbedding {
|
||||
contentHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context info for embedding pipeline (repo/server metadata enrichment)
|
||||
*/
|
||||
export interface EmbeddingContext {
|
||||
repoName?: string;
|
||||
serverName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model download progress from transformers.js
|
||||
*/
|
||||
|
||||
@@ -112,11 +112,14 @@ flowchart TD
|
||||
EMIT --> BRIDGE[(bridge.lbug<br/>#795)]
|
||||
```
|
||||
|
||||
Label-scoped queries in `resolveSymbol` keep accidental cross-matches
|
||||
out:
|
||||
- `topic` → `(n:Function|Method|Class|Interface)`
|
||||
- `grpc` method → `(n:Function|Method)`, service → `(n:Class|Interface)`
|
||||
- `lib` → `(n:Package|Module)`
|
||||
Label-scoped queries in `resolveSymbol` keep accidental cross-matches out.
|
||||
They use the `MATCH (n) WHERE labels(n) IN [...]` allowlist form, NOT the
|
||||
`MATCH (n:A|B)` disjunction — LadybugDB's parser rejects a disjunction that
|
||||
names a reserved keyword (e.g. `Macro`, `Union`), which is what broke the
|
||||
`custom` branch in #2325:
|
||||
- `topic` → `labels(n) IN ['Function','Method','Class','Interface']`
|
||||
- `grpc`/`thrift` method → `labels(n) IN ['Function','Method']`, service → `labels(n) IN ['Class','Interface']`
|
||||
- `lib` → `labels(n) IN ['Module']`
|
||||
|
||||
## Cross-impact query (PR #606)
|
||||
|
||||
@@ -137,3 +140,28 @@ The bridge stores every extracted contract keyed by `symbolUid`.
|
||||
Manifest-sourced contracts use the synthetic uid form so both sides
|
||||
of the `(local impact) ↔ (bridge query)` join derive the same uid
|
||||
without coordinating through any shared state.
|
||||
|
||||
## Cross-repo trace (`cross-trace.ts`)
|
||||
|
||||
A second consumer of the bridge. Where cross-impact fans a blast radius
|
||||
*outward* from one symbol, cross-trace stitches a directed **path** between
|
||||
two symbols that live in different repos:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
FT[from / to resolved<br/>across all members] --> SR{same repo?}
|
||||
SR -- yes --> LT[single-repo trace<br/>no crossing]
|
||||
SR -- no --> SEGA[trace: from → consumer symbol<br/>in home repo]
|
||||
SEGA --> XB[Bridge pair query<br/>consumer.symbolUid → provider.symbolUid<br/>one ContractLink boundary]
|
||||
XB --> SEGB[trace: provider symbol → to<br/>in target repo]
|
||||
SEGB --> STITCH[stitched hops + CONTRACT_LINK edge<br/>+ optional REACHING_DEF data-flow]
|
||||
```
|
||||
|
||||
It reuses the same `symbolUid` join as cross-impact, but issues its own
|
||||
*pair* query (`listCrossingsBetween`) because a path needs BOTH endpoints of
|
||||
a crossing — the uid-filtered neighbor join (`resolveBridgeNeighbors`, shared
|
||||
with impact) returns only the far side. The crossing is clamped to one
|
||||
boundary (`MAX_SUPPORTED_CROSS_DEPTH`). With `pdg: true` the boundary-adjacent
|
||||
segments are enriched with intra-procedural REACHING_DEF data-flow (never
|
||||
across the boundary). Full cross-program data flow across the boundary is a
|
||||
deferred follow-up.
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
|
||||
const bridgeLogger = createLogger('bridge-db', { debugEnvVar: 'GITNEXUS_DEBUG_BRIDGE' });
|
||||
const bridgeLogger = createLogger('bridge-db', {
|
||||
debugEnvVar: 'GITNEXUS_DEBUG_BRIDGE',
|
||||
});
|
||||
|
||||
/**
|
||||
* Sidecar files that LadybugDB creates next to a `bridge.lbug` file.
|
||||
@@ -33,6 +35,358 @@ const bridgeLogger = createLogger('bridge-db', { debugEnvVar: 'GITNEXUS_DEBUG_BR
|
||||
*/
|
||||
const LBUG_SIDECAR_SUFFIXES = ['.wal', '.shadow'] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Read-only bridge handle cache */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Cache of read-only bridge handles keyed by groupDir. Keeps one RO handle
|
||||
* per groupDir alive across @group tool calls so a long-lived MCP server
|
||||
* never reopens the same bridge.lbug in-process — reopening fails on Windows
|
||||
* because the OS file handle isn't fully released before the next open races
|
||||
* in (see PR #2269, #2274).
|
||||
*
|
||||
* deliberation: mtime-based invalidation was chosen over a simpler
|
||||
* time-to-live or explicit-close model because:
|
||||
* 1. TTL would force a reopen on a timer even when nothing changed.
|
||||
* 2. Explicit-close requires every caller to know about the cache.
|
||||
* 3. A cheap `fsp.stat` (uncached, but typically a single inode lookup on
|
||||
* modern kernels) before each `ensureBridgeReady` call detects external
|
||||
* writers (e.g. another process ran group sync) with zero false
|
||||
* positives and no timer complication.
|
||||
* 4. Same-process writes invalidate explicitly via `invalidateBridgeCache`
|
||||
* before the atomic rename so the cached RO handle does not block it.
|
||||
*/
|
||||
interface CachedBridgeEntry {
|
||||
handle: BridgeHandle;
|
||||
mtime: number;
|
||||
/**
|
||||
* Active leases: callers between `getCachedBridgeReadOnly` (acquire, `refs++`)
|
||||
* and `closeBridgeDb` (release, `refs--`). The native handle is never closed
|
||||
* while `refs > 0` — a concurrent `@group` reader may still be querying it,
|
||||
* and closing under a live query is a native use-after-free.
|
||||
*/
|
||||
refs: number;
|
||||
/** Set once the entry leaves the cache; the native close is deferred to the last release. */
|
||||
evicted: boolean;
|
||||
/** Guards `finalizeBridgeClose` so the native close runs exactly once. */
|
||||
closeStarted: boolean;
|
||||
/**
|
||||
* Per-handle FIFO serialization tail. The cached RO handle is shared across
|
||||
* concurrent `@group` callers, but a LadybugDB `Connection` is NOT safe for
|
||||
* concurrent query execution (see `lbug/conn-lock.ts` — two queries on one
|
||||
* connection corrupt the native heap). `queryBridge` runs each op on this
|
||||
* chain so no two ever overlap on one handle. Per-handle (not a single global
|
||||
* lock) so different groups — separate connections — stay parallel.
|
||||
*/
|
||||
lockTail: Promise<void>;
|
||||
/**
|
||||
* Resolves when the native handle has actually been closed. `writeBridge` on
|
||||
* Windows awaits this (bounded — see `WINDOWS_DRAIN_TIMEOUT_MS`) before its
|
||||
* atomic rename, because Windows cannot rename over an open handle. On POSIX
|
||||
* the rename succeeds over an open RO handle (the old inode survives for the
|
||||
* in-flight reader), so the close stays fully non-blocking there.
|
||||
*/
|
||||
drained: Promise<void>;
|
||||
/** Resolver for {@link CachedBridgeEntry.drained}; called once by `finalizeBridgeClose`. */
|
||||
resolveDrained: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows-only bound on how long `invalidateBridgeCache` waits for in-flight
|
||||
* readers to release before letting `writeBridge` rename. Past this, it falls
|
||||
* through and `retryRename` (EBUSY ×3) copes — so a pathologically long reader
|
||||
* can never wedge `group_sync`. ponytail: fixed 5s ceiling; make it
|
||||
* configurable if a real workload shows reads routinely outlasting it.
|
||||
*/
|
||||
const WINDOWS_DRAIN_TIMEOUT_MS = 5000;
|
||||
|
||||
const cachedBridgeHandles = new Map<string, CachedBridgeEntry>();
|
||||
|
||||
/**
|
||||
* Reverse lookup: cache entry by its `BridgeHandle`. Lets `queryBridge` and
|
||||
* `closeBridgeDb` find an entry from just the handle — including an *evicted*
|
||||
* entry that is no longer in `cachedBridgeHandles` but whose native handle a
|
||||
* lease still holds open. Uncached/writable handles (the `writeBridge` temp DB)
|
||||
* are absent here, which is how those paths opt out of the lock and refcount.
|
||||
*/
|
||||
const bridgeEntryByHandle = new WeakMap<BridgeHandle, CachedBridgeEntry>();
|
||||
|
||||
/**
|
||||
* In-flight opens keyed by groupDir. Prevents the TOCTOU race where two
|
||||
* concurrent cache-miss calls both open a fresh handle and the second
|
||||
* overwrites the first in `cachedBridgeHandles` — leaking the first
|
||||
* handle. Mirrors the `local-backend.ts:1293` reinitPromises pattern.
|
||||
*/
|
||||
const inFlightOpens = new Map<string, Promise<BridgeHandle | null>>();
|
||||
|
||||
function bridgeCacheKey(groupDir: string): string {
|
||||
return path.resolve(groupDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize an operation on a cached handle's per-handle FIFO chain. Mirrors the
|
||||
* promise-chain mechanic of `lbug/conn-lock.ts` (install a fresh unresolved
|
||||
* tail, await the prior holder, release in `finally` so a throw never wedges the
|
||||
* chain) — but keyed per handle, not a single global lock. No re-entry guard:
|
||||
* `queryBridge` is a leaf (it never calls another locked bridge helper), and the
|
||||
* native close runs outside the lock gated on `refs === 0`.
|
||||
*/
|
||||
export async function withHandleLock<T>(
|
||||
lock: { lockTail: Promise<void> },
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const prior = lock.lockTail;
|
||||
let release!: () => void;
|
||||
lock.lockTail = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await prior;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a cached entry's native handle exactly once. Guarded by `closeStarted`
|
||||
* so the mtime-evict path, `invalidateBridgeCache`, the last lease release, and
|
||||
* `closeAllCachedBridges` can all reach here and only one native close runs.
|
||||
*/
|
||||
async function finalizeBridgeClose(entry: CachedBridgeEntry): Promise<void> {
|
||||
if (entry.closeStarted) return;
|
||||
entry.closeStarted = true;
|
||||
bridgeEntryByHandle.delete(entry.handle);
|
||||
try {
|
||||
await closeBridgeHandle(entry.handle);
|
||||
} finally {
|
||||
entry.resolveDrained();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an entry from the cache and release its native handle. The native
|
||||
* close is DEFERRED until in-flight leases drain (`refs === 0`): closing a
|
||||
* handle a concurrent `@group` reader is still querying is a native
|
||||
* use-after-free (the `conn-lock.ts` hazard). When `refs === 0` (the common
|
||||
* single-threaded case — e.g. `group_sync` with no concurrent read) the close
|
||||
* runs now and the returned promise resolves when it completes, so
|
||||
* `writeBridge`'s atomic rename never races a live RO handle on Windows.
|
||||
*
|
||||
* When `refs > 0` (a concurrent reader holds a lease), the native close is
|
||||
* deferred to the last `closeBridgeDb` release — closing now would be a
|
||||
* use-after-free. Platform split for the rename that follows:
|
||||
* - POSIX: return immediately. The rename succeeds over the still-open RO
|
||||
* handle (old inode survives for the reader); no wait, no starvation.
|
||||
* - Windows: a rename over an open handle fails (EBUSY), so wait — bounded by
|
||||
* `WINDOWS_DRAIN_TIMEOUT_MS` — for the reader to release and the deferred
|
||||
* close to complete, then the rename is clean. On timeout, fall through and
|
||||
* let `retryRename` cope, so a slow reader can never wedge `group_sync`.
|
||||
*
|
||||
* This is the single eviction path for BOTH the mtime-change branch and
|
||||
* `invalidateBridgeCache`.
|
||||
*/
|
||||
async function evictBridgeEntry(key: string, entry: CachedBridgeEntry): Promise<void> {
|
||||
if (!entry.evicted) {
|
||||
entry.evicted = true;
|
||||
if (cachedBridgeHandles.get(key) === entry) cachedBridgeHandles.delete(key);
|
||||
}
|
||||
if (entry.refs <= 0) {
|
||||
await finalizeBridgeClose(entry);
|
||||
return;
|
||||
}
|
||||
// refs > 0: close deferred to the last closeBridgeDb release.
|
||||
if (process.platform === 'win32') {
|
||||
// Windows needs the handle closed before writeBridge renames. Wait (bounded)
|
||||
// for readers to drain; on timeout, retryRename handles the residual EBUSY.
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, WINDOWS_DRAIN_TIMEOUT_MS);
|
||||
});
|
||||
await Promise.race([entry.drained, timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a BridgeHandle's native resources without touching the cache.
|
||||
* Shared by `closeBridgeDb` (uncached handles) and the cache invalidation
|
||||
* / shutdown paths so neither duplicates the close logic.
|
||||
*/
|
||||
async function closeBridgeHandle(handle: BridgeHandle): Promise<void> {
|
||||
if (!handle._readOnly) {
|
||||
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 {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await (handle._db as lbug.Database).close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a cached read-only bridge handle for `groupDir`.
|
||||
*
|
||||
* - First call: delegates to `openBridgeDbReadOnly`, records the file's
|
||||
* `mtimeMs`, and caches the handle.
|
||||
* - Subsequent calls (mtime unchanged): returns the cached handle — no
|
||||
* reopen, no OS file-handle churn.
|
||||
* - After the file's mtime changes (external writer, e.g. another process
|
||||
* ran `gitnexus group sync`): closes the stale handle, opens a fresh
|
||||
* one, and updates the cache.
|
||||
* - After the file disappears (ENOENT): invalidates cache, returns null.
|
||||
*
|
||||
* Returns `null` when the bridge file is missing, has an incompatible
|
||||
* schema version, or cannot be opened even after the retry loop in
|
||||
* `openBridgeDbReadOnly`.
|
||||
*/
|
||||
export async function getCachedBridgeReadOnly(groupDir: string): Promise<BridgeHandle | null> {
|
||||
const key = bridgeCacheKey(groupDir);
|
||||
const dbPath = path.join(groupDir, 'bridge.lbug');
|
||||
|
||||
// Fast path: cache hit, unchanged mtime → lease the cached handle.
|
||||
const entry = cachedBridgeHandles.get(key);
|
||||
if (entry) {
|
||||
try {
|
||||
const stat = await fsp.stat(dbPath);
|
||||
// Re-check `evicted` AFTER the await: a concurrent writeBridge/invalidate
|
||||
// may have evicted this entry while we awaited `stat`. Leasing an evicted
|
||||
// (closing) handle would be a use-after-close. The `refs++` is the first
|
||||
// synchronous statement after the check, so no evictor can slip between.
|
||||
if (!entry.evicted && stat.mtimeMs === entry.mtime) {
|
||||
entry.refs++;
|
||||
return entry.handle;
|
||||
}
|
||||
} catch {
|
||||
// File disappeared (ENOENT) — fall through to evict + reopen.
|
||||
}
|
||||
// mtime changed or file gone — evict (defers the native close if a
|
||||
// concurrent reader still holds a lease; closes now otherwise).
|
||||
if (!entry.evicted) await evictBridgeEntry(key, entry);
|
||||
}
|
||||
|
||||
// TOCTOU guard: if another caller is already opening for this key, await
|
||||
// their in-flight promise and take a lease on the result instead of opening
|
||||
// a second handle.
|
||||
const inFlight = inFlightOpens.get(key);
|
||||
if (inFlight) {
|
||||
const handle = await inFlight;
|
||||
if (!handle) return null;
|
||||
// Same post-await guard as the fast path: the opener's entry may have been
|
||||
// evicted between caching and this awaiter resuming. Only lease a live,
|
||||
// identity-matched entry; otherwise retry from the top for a fresh handle.
|
||||
const opened = cachedBridgeHandles.get(key);
|
||||
if (opened && !opened.evicted && opened.handle === handle) {
|
||||
opened.refs++;
|
||||
return handle;
|
||||
}
|
||||
return getCachedBridgeReadOnly(groupDir);
|
||||
}
|
||||
|
||||
const openPromise: Promise<BridgeHandle | null> = (async () => {
|
||||
try {
|
||||
const handle = await openBridgeDbReadOnly(groupDir);
|
||||
if (!handle) return null;
|
||||
|
||||
let mtime = 0;
|
||||
try {
|
||||
const stat = await fsp.stat(dbPath);
|
||||
mtime = stat.mtimeMs;
|
||||
} catch {
|
||||
// bridge.lbug not stat-able right after open (rare race). Leaving
|
||||
// mtime at 0 means the next call's fast-path comparison won't match
|
||||
// (a real file's mtime is never 0), so it re-opens. Benign: the handle
|
||||
// still works for this caller; we just don't cache-reuse it until a
|
||||
// later open records a real mtime.
|
||||
}
|
||||
|
||||
let resolveDrained!: () => void;
|
||||
const drained = new Promise<void>((resolve) => {
|
||||
resolveDrained = resolve;
|
||||
});
|
||||
const newEntry: CachedBridgeEntry = {
|
||||
handle,
|
||||
mtime,
|
||||
refs: 0,
|
||||
evicted: false,
|
||||
closeStarted: false,
|
||||
lockTail: Promise.resolve(),
|
||||
drained,
|
||||
resolveDrained,
|
||||
};
|
||||
cachedBridgeHandles.set(key, newEntry);
|
||||
bridgeEntryByHandle.set(handle, newEntry);
|
||||
return handle;
|
||||
} finally {
|
||||
inFlightOpens.delete(key);
|
||||
}
|
||||
})();
|
||||
inFlightOpens.set(key, openPromise);
|
||||
|
||||
// Each caller (the opener and every awaiter) takes exactly one lease here, so
|
||||
// refs counts callers correctly even under inFlightOpens coalescing.
|
||||
const handle = await openPromise;
|
||||
if (!handle) return null;
|
||||
const opened = cachedBridgeHandles.get(key);
|
||||
if (opened && !opened.evicted && opened.handle === handle) {
|
||||
opened.refs++;
|
||||
return handle;
|
||||
}
|
||||
return getCachedBridgeReadOnly(groupDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cached read-only handle for `groupDir`. Drops it from the
|
||||
* cache immediately; the native close is deferred until any in-flight reader
|
||||
* leases drain (see {@link evictBridgeEntry}). With no concurrent reader this
|
||||
* resolves only after the handle is actually closed — which is why
|
||||
* `writeBridge` awaits it before its atomic rename (Windows: a still-open RO
|
||||
* handle would block the rename with EBUSY).
|
||||
*/
|
||||
export async function invalidateBridgeCache(groupDir: string): Promise<void> {
|
||||
const key = bridgeCacheKey(groupDir);
|
||||
const entry = cachedBridgeHandles.get(key);
|
||||
if (entry) await evictBridgeEntry(key, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close ALL cached bridge handles. Call on process shutdown only — it force-
|
||||
* closes regardless of refs (safe at `beforeExit`, which fires only at
|
||||
* event-loop quiescence, so no query is in flight). Do NOT wire this to a
|
||||
* SIGTERM/SIGINT handler that can fire mid-request: that would close a handle
|
||||
* under a live query. Routes through `finalizeBridgeClose` for the close-once
|
||||
* guarantee.
|
||||
*/
|
||||
export async function closeAllCachedBridges(): Promise<void> {
|
||||
const entries = [...cachedBridgeHandles.values()];
|
||||
cachedBridgeHandles.clear();
|
||||
await Promise.all(entries.map((e) => finalizeBridgeClose(e)));
|
||||
}
|
||||
|
||||
// Best-effort process-exit cleanup. 'beforeExit' fires before 'exit' and
|
||||
// lets async work drain (unlike 'exit' which is synchronous-only). It does
|
||||
// NOT fire on process.exit()/SIGTERM/SIGINT — but that is fine here: the OS
|
||||
// reclaims all handles on any exit path, and for read-only handles there is
|
||||
// no WAL to flush, so the only thing lost on signal death is a tidy close
|
||||
// (cosmetic). We deliberately do NOT register a SIGTERM/SIGINT handler: a
|
||||
// signal can fire mid-request, and closeAllCachedBridges force-closes
|
||||
// regardless of refs, which would close a handle under a live query. Shutdown
|
||||
// sequencing is the MCP server's responsibility — it should call
|
||||
// closeAllCachedBridges() at a quiescent point (also how tests get a
|
||||
// deterministic teardown).
|
||||
process.once('beforeExit', () => {
|
||||
void closeAllCachedBridges();
|
||||
});
|
||||
|
||||
async function removeLbugFile(basePath: string): Promise<void> {
|
||||
const candidates = [basePath, ...LBUG_SIDECAR_SUFFIXES.map((s) => `${basePath}${s}`)];
|
||||
for (const f of candidates) {
|
||||
@@ -195,20 +549,29 @@ export async function queryBridge<T>(
|
||||
cypher: string,
|
||||
params?: Record<string, LbugValue>,
|
||||
): Promise<T[]> {
|
||||
const conn = handle._conn as lbug.Connection;
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
const stmt = await conn.prepare(cypher);
|
||||
if (!stmt.isSuccess()) {
|
||||
const errMsg = await stmt.getErrorMessage();
|
||||
throw new Error(`Bridge query prepare failed: ${errMsg}`);
|
||||
const run = async (): Promise<T[]> => {
|
||||
const conn = handle._conn as lbug.Connection;
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
const stmt = await conn.prepare(cypher);
|
||||
if (!stmt.isSuccess()) {
|
||||
const errMsg = await stmt.getErrorMessage();
|
||||
throw new Error(`Bridge query prepare failed: ${errMsg}`);
|
||||
}
|
||||
const queryResult = await conn.execute(stmt, params);
|
||||
const result = unwrapQueryResult(queryResult);
|
||||
return (await result.getAll()) as T[];
|
||||
}
|
||||
const queryResult = await conn.execute(stmt, params);
|
||||
const queryResult = await conn.query(cypher);
|
||||
const result = unwrapQueryResult(queryResult);
|
||||
return (await result.getAll()) as T[];
|
||||
}
|
||||
const queryResult = await conn.query(cypher);
|
||||
const result = unwrapQueryResult(queryResult);
|
||||
return (await result.getAll()) as T[];
|
||||
};
|
||||
// Cached RO handles are shared across concurrent @group callers, so serialize
|
||||
// conn ops per handle (a LadybugDB Connection is not safe for concurrent
|
||||
// queries — conn-lock.ts). Uncached/writable handles (the writeBridge temp DB)
|
||||
// are single-threaded — they're absent from bridgeEntryByHandle and skip the
|
||||
// lock at zero cost.
|
||||
const entry = bridgeEntryByHandle.get(handle);
|
||||
return entry ? withHandleLock(entry, run) : run();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,30 +593,54 @@ function unwrapQueryResult(queryResult: lbug.QueryResult | lbug.QueryResult[]):
|
||||
return queryResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a caller's reference to a bridge handle.
|
||||
*
|
||||
* - **Cache-owned handle** (returned by `getCachedBridgeReadOnly`): this is the
|
||||
* matching *release* for that acquire — it decrements the lease refcount, it
|
||||
* does NOT close the native handle. The cache owns the lifetime; the handle
|
||||
* closes on explicit `invalidateBridgeCache`, mtime-eviction, or process
|
||||
* shutdown. If the entry was already evicted and this is the last lease, the
|
||||
* deferred native close fires here (exactly once).
|
||||
* - **Uncached/writable handle** (e.g. the `writeBridge` temp DB): closes the
|
||||
* native handle for real (CHECKPOINT-flush for writable handles).
|
||||
*
|
||||
* Contract: before renaming or deleting `bridge.lbug`, call
|
||||
* `invalidateBridgeCache` (not this) — `closeBridgeDb` on a cache-owned handle
|
||||
* is a lease release, so the file may stay open under other readers.
|
||||
*/
|
||||
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 {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await (handle._db as lbug.Database).close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
const entry = bridgeEntryByHandle.get(handle);
|
||||
if (!entry) {
|
||||
// Uncached or writable handle — close for real.
|
||||
await closeBridgeHandle(handle);
|
||||
return;
|
||||
}
|
||||
// Cache-owned handle: release this lease. Close only the evicted handle whose
|
||||
// last lease just dropped (deferred-close completion); the live cached handle
|
||||
// stays open for reuse.
|
||||
if (entry.refs > 0) entry.refs--;
|
||||
if (entry.evicted && entry.refs <= 0) await finalizeBridgeClose(entry);
|
||||
}
|
||||
|
||||
// NOTE: Windows in-process write→read reopen of the SAME bridge.lbug is still a
|
||||
// known limitation (the writable close's OS file handle is not released before
|
||||
// the read open races; the existing open-side LBUG_OPEN_RETRY only retries
|
||||
// lock-pattern errors, not the post-rename sidecar database-id mismatch). The
|
||||
// bridge's close-then-reopen tests stay Windows-skipped. A close-side
|
||||
// waitForWindowsHandleRelease + finalizeLbugSidecarsAfterClose probe (mirroring
|
||||
// safeClose) was tried and did NOT close that gap on Windows CI, so it was
|
||||
// removed rather than carry latency/duplication for no Windows benefit.
|
||||
//
|
||||
// Scope of the RO bridge-handle cache (getCachedBridgeReadOnly): it removes the
|
||||
// PRODUCTION symptom — a long-lived MCP serve process reopening bridge.lbug on
|
||||
// every @group call — by keeping one RO handle alive for read→READ reuse.
|
||||
// It does NOT fix the write→READ reopen: the first @group read right after an
|
||||
// in-process group_sync is a cache miss → openBridgeDbReadOnly, i.e. the same
|
||||
// unfixed reopen, so on Windows that first post-sync read still returns null.
|
||||
// The read-only CHECKPOINT skip above remains the load-bearing fix on
|
||||
// Linux/macOS.
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* retryRename — handles transient EBUSY/EPERM/EACCES on Windows */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -361,6 +748,13 @@ export async function writeBridge(
|
||||
input: WriteBridgeInput,
|
||||
): Promise<WriteBridgeReport> {
|
||||
await fsp.mkdir(groupDir, { recursive: true });
|
||||
|
||||
// Invalidate the RO cache before writing. On Windows the cached handle
|
||||
// would block the atomic rename (tmp → bridge.lbug) because the OS keeps
|
||||
// a shared-mode lock on the open file. Closing it first guarantees the
|
||||
// rename succeeds without EBUSY.
|
||||
await invalidateBridgeCache(groupDir);
|
||||
|
||||
const contracts = dedupeContracts(input.contracts);
|
||||
const crossLinks = dedupeCrossLinks(input.crossLinks);
|
||||
|
||||
@@ -642,6 +1036,11 @@ export async function writeBridge(
|
||||
* 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.
|
||||
*
|
||||
* As of v0.18.0 the "Could not set lock" file-lock error text gained an
|
||||
* appended detail suffix upstream (see `lbug-config.ts`'s
|
||||
* `OPEN_LOCK_RETRY_ATTEMPTS` comment), but the substrings matched here are
|
||||
* unaffected by that change.
|
||||
*/
|
||||
const LBUG_OPEN_RETRY_PATTERNS = [
|
||||
'process cannot access the file',
|
||||
@@ -713,7 +1112,12 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
|
||||
// (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;
|
||||
return {
|
||||
_db: handle.db,
|
||||
_conn: handle.conn,
|
||||
groupDir,
|
||||
_readOnly: true,
|
||||
} as BridgeHandle;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (handle) await closeLbugConnection(handle);
|
||||
@@ -730,7 +1134,11 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
|
||||
const safeErrMsg =
|
||||
lastErr instanceof Error ? String(lastErr.message).replace(/[\r\n]/g, ' ') : undefined;
|
||||
bridgeLogger.debug(
|
||||
{ groupDir: safeGroupDir, errMsg: safeErrMsg, attempts: LBUG_OPEN_RETRY_ATTEMPTS },
|
||||
{
|
||||
groupDir: safeGroupDir,
|
||||
errMsg: safeErrMsg,
|
||||
attempts: LBUG_OPEN_RETRY_ATTEMPTS,
|
||||
},
|
||||
'openBridgeDbReadOnly gave up',
|
||||
);
|
||||
return null;
|
||||
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
repoInSubgroup,
|
||||
} from './group-path-utils.js';
|
||||
import { getGroupDir } from './storage.js';
|
||||
import { closeBridgeDb, openBridgeDbReadOnly, queryBridge, readBridgeMeta } from './bridge-db.js';
|
||||
import {
|
||||
closeBridgeDb,
|
||||
getCachedBridgeReadOnly,
|
||||
queryBridge,
|
||||
readBridgeMeta,
|
||||
} from './bridge-db.js';
|
||||
import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
|
||||
|
||||
// High limit for the local phase of group impact so collectImpactSymbolUids
|
||||
@@ -63,7 +68,7 @@ RETURN provider.repo AS neighborRepo,
|
||||
provider.type AS contractType
|
||||
`;
|
||||
|
||||
type BridgeNeighborRow = {
|
||||
export type BridgeNeighborRow = {
|
||||
neighborRepo: string;
|
||||
neighborUid: string;
|
||||
neighborFilePath?: string;
|
||||
@@ -352,7 +357,7 @@ export function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string {
|
||||
return localRisk;
|
||||
}
|
||||
|
||||
async function ensureBridgeReady(
|
||||
export async function ensureBridgeReady(
|
||||
groupDir: string,
|
||||
): Promise<{ handle: BridgeHandle } | { error: string }> {
|
||||
const meta = await readBridgeMeta(groupDir);
|
||||
@@ -369,7 +374,10 @@ async function ensureBridgeReady(
|
||||
error: `No bridge.lbug in this group directory. Run gitnexus group sync (schema ${BRIDGE_SCHEMA_VERSION}).`,
|
||||
};
|
||||
}
|
||||
const handle = await openBridgeDbReadOnly(groupDir);
|
||||
// Use the cached read-only handle if available — avoids reopening the same
|
||||
// bridge.lbug in a long-lived MCP server, which fails on Windows because
|
||||
// the OS handle isn't fully released before the next open races in.
|
||||
const handle = await getCachedBridgeReadOnly(groupDir);
|
||||
if (!handle) {
|
||||
return {
|
||||
error: `Could not open bridge.lbug read-only (schema ${BRIDGE_SCHEMA_VERSION}). Run gitnexus group sync.`,
|
||||
@@ -394,6 +402,39 @@ function rowToNeighbor(r: Record<string, unknown>): BridgeNeighborRow | null {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve cross-repo neighbors over `ContractLink` for a set of local symbol
|
||||
* UIDs, in a single direction, sorted by descending confidence.
|
||||
*
|
||||
* This is the one shared consumer↔provider bridge join. `runGroupImpact`'s
|
||||
* Phase-2 fan-out uses it directly; the cross-repo trace path (`cross-trace.ts`)
|
||||
* reuses the same `queryBridge` + row-normalization primitives but issues a
|
||||
* distinct *pair* query, because a trace must keep BOTH endpoints of a crossing
|
||||
* (this neighbor join intentionally returns only the far side, which is lossy
|
||||
* for stitching a path). Keeping this helper as the single uid-filtered join
|
||||
* means impact never forks its own copy of the neighbor Cypher.
|
||||
*
|
||||
* Returns `[]` for an empty `uids` set without touching the DB.
|
||||
*/
|
||||
export async function resolveBridgeNeighbors(
|
||||
handle: BridgeHandle,
|
||||
opts: { localRepo: string; uids: string[]; direction: 'upstream' | 'downstream' },
|
||||
): Promise<BridgeNeighborRow[]> {
|
||||
if (opts.uids.length === 0) return [];
|
||||
const cypher = opts.direction === 'upstream' ? CY_NEIGHBORS_UPSTREAM : CY_NEIGHBORS_DOWNSTREAM;
|
||||
const rows = await queryBridge<Record<string, unknown>>(handle, cypher, {
|
||||
localRepo: opts.localRepo,
|
||||
uids: opts.uids,
|
||||
});
|
||||
const neighbors: BridgeNeighborRow[] = [];
|
||||
for (const raw of rows) {
|
||||
const n = rowToNeighbor(raw);
|
||||
if (n) neighbors.push(n);
|
||||
}
|
||||
neighbors.sort((a, b) => b.confidence - a.confidence);
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
export async function runGroupImpact(
|
||||
deps: RunGroupImpactDeps,
|
||||
params: Record<string, unknown>,
|
||||
@@ -537,19 +578,12 @@ export async function runGroupImpact(
|
||||
const truncatedRepos: string[] = [];
|
||||
|
||||
try {
|
||||
const cypher = direction === 'upstream' ? CY_NEIGHBORS_UPSTREAM : CY_NEIGHBORS_DOWNSTREAM;
|
||||
const rows = await queryBridge<Record<string, unknown>>(handle, cypher, {
|
||||
const neighbors = await resolveBridgeNeighbors(handle, {
|
||||
localRepo: repoPath,
|
||||
uids,
|
||||
direction,
|
||||
});
|
||||
|
||||
const neighbors: BridgeNeighborRow[] = [];
|
||||
for (const raw of rows) {
|
||||
const n = rowToNeighbor(raw);
|
||||
if (n) neighbors.push(n);
|
||||
}
|
||||
neighbors.sort((a, b) => b.confidence - a.confidence);
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const n of neighbors) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,11 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
|
||||
|
||||
// ─── Provider: framework routing ──────────────────────────────────────
|
||||
// Matches `\w+\.GET(...)` etc. (gin, echo, chi all share this shape).
|
||||
// Captures the HTTP method (field name), path literal, and handler
|
||||
// identifier passed as the second argument.
|
||||
// Captures the HTTP method (field name), path literal, and the handler —
|
||||
// anchored to the LAST argument (`@handler .`) so a variadic middleware
|
||||
// chain (`r.GET("/x", mw, handler)`, gin/echo/chi style) binds the real
|
||||
// handler, not a middleware identifier (which would otherwise over-match
|
||||
// and attach the route to the wrong symbol — see #2276 review).
|
||||
const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({
|
||||
name: 'go-framework-route',
|
||||
language: Go,
|
||||
@@ -31,7 +34,8 @@ const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({
|
||||
field: (field_identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE|PATCH)$"))
|
||||
arguments: (argument_list
|
||||
(interpreted_string_literal) @path
|
||||
(identifier) @handler))
|
||||
[(identifier) (func_literal)] @handler
|
||||
.))
|
||||
`,
|
||||
},
|
||||
],
|
||||
@@ -51,7 +55,8 @@ const HANDLE_FUNC_PATTERNS = compilePatterns({
|
||||
field: (field_identifier) @fn (#eq? @fn "HandleFunc"))
|
||||
arguments: (argument_list
|
||||
(interpreted_string_literal) @path
|
||||
(identifier) @handler))
|
||||
[(identifier) (func_literal)] @handler
|
||||
.))
|
||||
`,
|
||||
},
|
||||
],
|
||||
@@ -138,12 +143,18 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
if (!methodNode || !pathNode) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
// An inline `func(){…}` handler has no name → emit `name: null` and a
|
||||
// `line` so it resolves to its containing/closure symbol by line-span
|
||||
// containment (like a consumer). A named identifier handler keeps its
|
||||
// name and resolves by name; `line` is harmless there.
|
||||
const isInlineHandler = handlerNode?.type === 'func_literal';
|
||||
out.push({
|
||||
role: 'provider',
|
||||
framework: 'go-framework',
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: handlerNode?.text ?? null,
|
||||
name: isInlineHandler ? null : (handlerNode?.text ?? null),
|
||||
line: (handlerNode ?? pathNode).startPosition.row + 1,
|
||||
confidence: 0.8,
|
||||
});
|
||||
}
|
||||
@@ -155,12 +166,16 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
if (!pathNode) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
// Inline `func(){…}` handler → resolve by containment (see go-framework
|
||||
// note above); a named handler resolves by name.
|
||||
const isInlineHandler = handlerNode?.type === 'func_literal';
|
||||
out.push({
|
||||
role: 'provider',
|
||||
framework: 'go-stdlib',
|
||||
method: 'GET',
|
||||
path,
|
||||
name: handlerNode?.text ?? null,
|
||||
name: isInlineHandler ? null : (handlerNode?.text ?? null),
|
||||
line: (handlerNode ?? pathNode).startPosition.row + 1,
|
||||
confidence: 0.8,
|
||||
});
|
||||
}
|
||||
@@ -180,6 +195,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: httpMethod,
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -198,6 +214,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -215,6 +232,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
METHOD_ANNOTATION_TO_HTTP,
|
||||
isRouteMemberKey,
|
||||
findEnclosingClass,
|
||||
joinPath,
|
||||
type SharedSpringType,
|
||||
} from '../../../ingestion/route-extractors/spring-shared.js';
|
||||
import {
|
||||
REST_TEMPLATE_TO_HTTP,
|
||||
@@ -18,9 +20,7 @@ import {
|
||||
EXCHANGE_ANNOTATION_TO_HTTP,
|
||||
parseRequestLine,
|
||||
pushPrefix,
|
||||
joinPath,
|
||||
scanSpringInheritanceProject,
|
||||
type SharedSpringType,
|
||||
OPENFEIGN_FRAMEWORK,
|
||||
HTTP_INTERFACE_FRAMEWORK,
|
||||
FEIGN_CONFIDENCE,
|
||||
@@ -676,6 +676,30 @@ function scanSpringProject(files: readonly HttpScanInput[]): HttpFileDetections[
|
||||
export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
name: 'java-http',
|
||||
language: Java,
|
||||
// routeCoverage intentionally LEFT at the default 'partial' (#2138 Part 2).
|
||||
// The graph provider set is a strict *subset* of this scan()'s provider set —
|
||||
// ingestion does NOT emit a Route node for a method-level array route nested
|
||||
// under a class-level array-form `@RequestMapping` (ingestion suppresses it
|
||||
// rather than drop the prefix; bare/scalar-prefixed array methods ARE now
|
||||
// emitted — see #2280). Interface-inherited Spring routes ARE now emitted by
|
||||
// ingestion (#2288), and same-URL multi-verb routes are now per-`(method,url)`
|
||||
// Route nodes (#2289), so they are no longer coverage gaps. Declaring
|
||||
// 'complete' here would let the parse-skip drop the remaining group-only
|
||||
// providers (the array-prefix gap above). Java flips to 'complete' only once
|
||||
// ingestion provider extraction matches this scan — class-level array-form
|
||||
// prefix support is the final follow-up tracked in #2280.
|
||||
// `hasConsumerSignals` below is kept ready for that flip.
|
||||
// Consumer signals this plugin's scan() can detect: RestTemplate / WebClient /
|
||||
// OkHttp / Java-HttpClient / Apache-HttpClient call sites, OpenFeign
|
||||
// (`@FeignClient` + `@RequestLine`) interfaces, and Spring 6 HTTP Interface
|
||||
// `@(Get|...)Exchange` / `@HttpExchange`. A provider-covered file containing
|
||||
// any of these must still be parsed so its consumer contracts are not dropped
|
||||
// (ingestion emits no FETCHES for Java). Conservative by design.
|
||||
hasConsumerSignals(content) {
|
||||
return /\brestTemplate\b|\bwebClient\b|Request\.Builder|HttpRequest|HttpMethod\.|new\s+Http(Get|Post|Put|Delete|Patch)\b|@RequestLine|@FeignClient|Exchange/.test(
|
||||
content,
|
||||
);
|
||||
},
|
||||
scan(tree) {
|
||||
const out: HttpDetection[] = [];
|
||||
|
||||
@@ -708,6 +732,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: route.httpMethod,
|
||||
path: joinPath(prefix, route.rawPath),
|
||||
name: route.methodName,
|
||||
line: route.methodNode.startPosition.row + 1,
|
||||
confidence: FEIGN_CONFIDENCE,
|
||||
});
|
||||
}
|
||||
@@ -725,6 +750,13 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: route.httpMethod,
|
||||
path: joinPath(prefix, route.rawPath),
|
||||
name: route.methodName,
|
||||
// Spring providers are named controller methods resolved BY NAME, so
|
||||
// `line` is inert — a named provider never falls through to line-span
|
||||
// containment. Gate it on a present name so a (grammar-impossible)
|
||||
// nameless provider degrades to file-level rather than resolving by
|
||||
// containment to the enclosing class. Wired for consumer-emit parity
|
||||
// and a future inline DSL.
|
||||
line: route.methodName ? route.methodNode.startPosition.row + 1 : undefined,
|
||||
confidence: 0.8,
|
||||
});
|
||||
}
|
||||
@@ -751,6 +783,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: requestLine.parsed.method,
|
||||
path: joinPath(prefix, requestLine.parsed.path),
|
||||
name: requestLine.methodName,
|
||||
line: requestLine.methodNode.startPosition.row + 1,
|
||||
confidence: REQUEST_LINE_CONFIDENCE,
|
||||
});
|
||||
}
|
||||
@@ -772,6 +805,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: route.httpMethod,
|
||||
path: joinPath(prefix, route.rawPath),
|
||||
name: route.methodName,
|
||||
line: route.methodNode.startPosition.row + 1,
|
||||
confidence: EXCHANGE_CONFIDENCE,
|
||||
});
|
||||
}
|
||||
@@ -792,6 +826,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: httpMethod,
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -808,6 +843,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: httpMethodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -830,6 +866,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: httpMethod,
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -853,6 +890,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: verbText,
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -879,6 +917,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method,
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
import {
|
||||
METHOD_ANNOTATION_TO_HTTP,
|
||||
findEnclosingClass,
|
||||
joinPath,
|
||||
type SharedSpringType,
|
||||
} from '../../../ingestion/route-extractors/spring-shared.js';
|
||||
import {
|
||||
REST_TEMPLATE_TO_HTTP,
|
||||
@@ -23,9 +25,7 @@ import {
|
||||
EXCHANGE_ANNOTATION_TO_HTTP,
|
||||
parseRequestLine,
|
||||
pushPrefix,
|
||||
joinPath,
|
||||
scanSpringInheritanceProject,
|
||||
type SharedSpringType,
|
||||
OPENFEIGN_FRAMEWORK,
|
||||
HTTP_INTERFACE_FRAMEWORK,
|
||||
FEIGN_CONFIDENCE,
|
||||
@@ -996,6 +996,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
||||
method: httpMethod,
|
||||
path: joinPath(prefix, rawPath),
|
||||
name: nameNode?.text ?? null,
|
||||
line: methodNode.startPosition.row + 1,
|
||||
confidence: FEIGN_CONFIDENCE,
|
||||
});
|
||||
}
|
||||
@@ -1018,6 +1019,13 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
||||
method: httpMethod,
|
||||
path: joinPath(prefix, rawPath),
|
||||
name: nameNode?.text ?? null,
|
||||
// Spring providers are named controller methods resolved BY NAME, so
|
||||
// `line` is inert — a named provider never falls through to line-span
|
||||
// containment. Gate it on a present name so a (grammar-impossible)
|
||||
// nameless provider degrades to file-level rather than resolving by
|
||||
// containment to the enclosing class. Wired for consumer-emit parity
|
||||
// and a future inline DSL.
|
||||
line: nameNode?.text ? methodNode.startPosition.row + 1 : undefined,
|
||||
confidence: 0.8,
|
||||
});
|
||||
}
|
||||
@@ -1038,6 +1046,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
||||
method: httpMethod,
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -1057,6 +1066,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
||||
method: httpMethod,
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -1084,6 +1094,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
||||
method: verbText,
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -1108,6 +1119,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
||||
method,
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -1134,6 +1146,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
||||
method: httpMethod,
|
||||
path: joinPath(prefix, rawPath),
|
||||
name: nameNode?.text ?? null,
|
||||
line: methodNode.startPosition.row + 1,
|
||||
confidence: EXCHANGE_CONFIDENCE,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ const EXPRESS_SPEC: PatternSpec<Record<string, never>> = {
|
||||
function: (member_expression
|
||||
object: (identifier) @obj (#match? @obj "^(router|app)$")
|
||||
property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$"))
|
||||
arguments: (arguments . [(string) (template_string)] @path))
|
||||
arguments: (arguments . [(string) (template_string)] @path . (_)? @handler))
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -295,8 +295,53 @@ function findDecoratedMethod(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNod
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each named import's LOCAL binding to its DECLARED export name and source
|
||||
* module, by walking the file's `import { x as y } from 'm'` statements. Lets
|
||||
* the express handler resolve through an alias (the local `y`) to the real
|
||||
* symbol (`x` in `m`) instead of looking up the alias text. Only named imports
|
||||
* are mapped — default and namespace imports are left to fall through as
|
||||
* locally-scoped identifiers.
|
||||
*/
|
||||
function buildImportMap(tree: Parser.Tree): Map<string, { name: string; module: string }> {
|
||||
const map = new Map<string, { name: string; module: string }>();
|
||||
const walk = (node: Parser.SyntaxNode): void => {
|
||||
if (node.type === 'import_statement') {
|
||||
const sourceNode = node.childForFieldName('source');
|
||||
const module = sourceNode ? unquoteLiteral(sourceNode.text) : null;
|
||||
if (module !== null) {
|
||||
const collect = (n: Parser.SyntaxNode): void => {
|
||||
if (n.type === 'import_specifier') {
|
||||
const nameNode = n.childForFieldName('name');
|
||||
const aliasNode = n.childForFieldName('alias');
|
||||
const local = aliasNode ?? nameNode;
|
||||
if (nameNode && local && local.type === 'identifier') {
|
||||
map.set(local.text, { name: nameNode.text, module });
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < n.namedChildCount; i++) {
|
||||
const c = n.namedChild(i);
|
||||
if (c) collect(c);
|
||||
}
|
||||
};
|
||||
collect(node);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c) walk(c);
|
||||
}
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
return map;
|
||||
}
|
||||
|
||||
function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection[] {
|
||||
const out: HttpDetection[] = [];
|
||||
// Local-binding → { declared export name, module } for the file's named
|
||||
// imports, so an express handler that is an imported (possibly aliased)
|
||||
// symbol resolves to the real definition rather than its local alias text.
|
||||
const importMap = buildImportMap(tree);
|
||||
|
||||
// NestJS: collect `@Controller('prefix')` class decorators, keyed by
|
||||
// the `class_declaration` they decorate.
|
||||
@@ -348,6 +393,7 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
|
||||
method: httpMethod,
|
||||
path: joinPath(prefix, rawPath),
|
||||
name,
|
||||
line: methodNode.startPosition.row + 1,
|
||||
confidence: 0.8,
|
||||
});
|
||||
}
|
||||
@@ -359,12 +405,24 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
|
||||
if (!methodNode || !pathNode) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
// Capture the handler argument identifier (`router.get('/x', listUsers)`
|
||||
// → `listUsers`) so a named handler resolves by name. For an inline/anonymous
|
||||
// handler emit `name: null` (NOT the sentinel `'handler'`) so the resolver
|
||||
// does NOT match an unrelated function that happens to be named `handler` —
|
||||
// it uses the registration line for containment instead. When the handler is
|
||||
// an imported (possibly aliased) symbol, carry the resolved import so the
|
||||
// extractor can pin it to the source module rather than the local alias text.
|
||||
const handlerNode = match.captures.handler;
|
||||
const localHandler = handlerNode?.type === 'identifier' ? handlerNode.text : null;
|
||||
const imported = localHandler !== null ? importMap.get(localHandler) : undefined;
|
||||
out.push({
|
||||
role: 'provider',
|
||||
framework: 'express',
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: 'handler',
|
||||
name: imported ? imported.name : localHandler,
|
||||
handlerImport: imported,
|
||||
line: (handlerNode ?? pathNode).startPosition.row + 1,
|
||||
confidence: 0.8,
|
||||
});
|
||||
}
|
||||
@@ -385,6 +443,7 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -403,6 +462,7 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
|
||||
method: 'GET',
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -420,6 +480,7 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -437,6 +498,7 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -456,6 +518,7 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
|
||||
method,
|
||||
path,
|
||||
name: null,
|
||||
line: optionsNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -476,6 +539,7 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
|
||||
method,
|
||||
path,
|
||||
name: null,
|
||||
line: optionsNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,7 +37,9 @@ const LARAVEL_ROUTE_SPEC: PatternSpec<Record<string, never>> = {
|
||||
(scoped_call_expression
|
||||
scope: (name) @scope (#eq? @scope "Route")
|
||||
name: (name) @method (#match? @method "^(get|post|put|delete|patch)$")
|
||||
arguments: (arguments . (argument (string) @path)))
|
||||
arguments: (arguments
|
||||
. (argument (string) @path)
|
||||
(argument [(anonymous_function) (arrow_function)] @closure)?))
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -130,6 +132,17 @@ function isHttpUrlLiteral(path: string): boolean {
|
||||
export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
name: 'php-http',
|
||||
language: PHP.php_only,
|
||||
// Laravel `Route::<verb>(...)` definitions are emitted as Route nodes by
|
||||
// ingestion, so the graph is authoritative for PHP providers (#2138 Part 2).
|
||||
routeCoverage: 'complete',
|
||||
// Consumer signals scan() can detect: Laravel `Http::<verb>`, Guzzle client
|
||||
// `->get/post/.../request(...)`, and `file_get_contents` of an HTTP URL. A
|
||||
// provider-covered file with any of these must still be parsed (ingestion
|
||||
// emits no FETCHES for PHP). Conservative — the `->verb(` shape over-matches
|
||||
// ordinary method calls, which only costs a parse, never data.
|
||||
hasConsumerSignals(content) {
|
||||
return /Http::|file_get_contents|->\s*(get|post|put|delete|patch|request)\s*\(/i.test(content);
|
||||
},
|
||||
scan(tree) {
|
||||
const out: HttpDetection[] = [];
|
||||
|
||||
@@ -139,12 +152,22 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
if (!methodNode || !pathNode) continue;
|
||||
const path = phpStringText(pathNode);
|
||||
if (path === null) continue;
|
||||
// A closure handler (`Route::get('/x', function(){…})` / `fn() => …`) has
|
||||
// no name → emit `name: null` + the registration line so it resolves to
|
||||
// its containing symbol (e.g. a service-provider `boot()` or controller
|
||||
// method) by line-span containment. A named-controller route keeps the
|
||||
// `'route'` label — resolving its array/string handler to a real method is
|
||||
// a separate, graph-backed concern. NOTE: a closure at FILE scope
|
||||
// (routes/web.php) has no enclosing function and PHP closures are not yet
|
||||
// indexed as symbols, so it still degrades to file-level (see #2276).
|
||||
const closureNode = match.captures.closure;
|
||||
out.push({
|
||||
role: 'provider',
|
||||
framework: 'laravel',
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: 'route',
|
||||
name: closureNode ? null : 'route',
|
||||
line: (closureNode ?? pathNode).startPosition.row + 1,
|
||||
confidence: 0.8,
|
||||
});
|
||||
}
|
||||
@@ -161,6 +184,7 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -177,6 +201,7 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -192,6 +217,7 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: 'GET',
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
unquoteLiteral,
|
||||
type LanguagePatterns,
|
||||
} from '../tree-sitter-scanner.js';
|
||||
import { normalizeExtractedRoutePath } from '../../../ingestion/route-extractors/route-path.js';
|
||||
import type { HttpDetection, HttpLanguagePlugin, RepoContext } from './types.js';
|
||||
|
||||
/**
|
||||
@@ -79,6 +80,33 @@ const FASTAPI_ROUTER_PATTERNS = compilePatterns({
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// ─── Provider: Flask `app.add_url_rule('/path', view_func=handler)` ───
|
||||
// The imperative Flask route registration: unlike `@app.route` (whose handler
|
||||
// is the decorated function, same-file), `view_func` is frequently an IMPORTED
|
||||
// (and sometimes aliased) view, so the handler resolves through the file's
|
||||
// imports. `add_url_rule` + a `view_func=` keyword is highly Flask-specific, so
|
||||
// the false-positive risk is low. Method(s) come from a `methods=[...]` keyword
|
||||
// (default GET), extracted in code from the captured call.
|
||||
const FLASK_ADD_URL_RULE_PATTERNS = compilePatterns({
|
||||
name: 'python-flask-add-url-rule',
|
||||
language: Python,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(call
|
||||
function: (attribute
|
||||
attribute: (identifier) @fn (#eq? @fn "add_url_rule"))
|
||||
arguments: (argument_list
|
||||
. (string) @path
|
||||
(keyword_argument
|
||||
name: (identifier) @kw (#eq? @kw "view_func")
|
||||
value: (identifier) @handler))) @call
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// ─── include_router(<router_obj>, prefix='/x') across the repo ────────
|
||||
// Two shapes are common:
|
||||
// app.include_router(assistant.router, prefix='/ai')
|
||||
@@ -135,6 +163,26 @@ const INCLUDE_ROUTER_NAME_PATTERNS = compilePatterns({
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
const API_ROUTER_PREFIX_PATTERNS = compilePatterns({
|
||||
name: 'python-fastapi-apirouter-prefix',
|
||||
language: Python,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(assignment
|
||||
left: (identifier) @router_name (#eq? @router_name "router")
|
||||
right: (call
|
||||
function: (identifier) @factory (#eq? @factory "APIRouter")
|
||||
arguments: (argument_list
|
||||
(keyword_argument
|
||||
name: (identifier) @kw (#eq? @kw "prefix")
|
||||
value: (string) @prefix))))
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// `from .api.assistant import router` style — used together with
|
||||
// INCLUDE_ROUTER_NAME so we can map a local name back to its module
|
||||
// path, then back to the file the router was declared in.
|
||||
@@ -331,6 +379,73 @@ const WRAPPER_URI_VAR_PATTERNS = compilePatterns({
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
/**
|
||||
* Map each `from <module> import <name> [as <alias>]` binding to its declared
|
||||
* name + raw module specifier (the spec keeps the leading dots for relative
|
||||
* imports — `.users`, `..pkg.users` — which the extractor resolves to a target
|
||||
* file). Lets a Flask `view_func` handler resolve through an alias to the real
|
||||
* symbol in its module rather than the local alias text. `import x` / `import x
|
||||
* as y` (module imports, not symbol imports) are left out — a route handler is a
|
||||
* symbol, addressed via `from … import …`.
|
||||
*/
|
||||
function buildPythonImportMap(tree: Parser.Tree): Map<string, { name: string; module: string }> {
|
||||
const map = new Map<string, { name: string; module: string }>();
|
||||
const walk = (node: Parser.SyntaxNode): void => {
|
||||
if (node.type === 'import_from_statement') {
|
||||
const moduleNode = node.childForFieldName('module_name');
|
||||
const module = moduleNode?.text ?? null;
|
||||
if (module !== null) {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (!c || c.id === moduleNode?.id) continue;
|
||||
if (c.type === 'dotted_name') {
|
||||
map.set(c.text, { name: c.text, module });
|
||||
} else if (c.type === 'aliased_import') {
|
||||
const nameNode = c.childForFieldName('name');
|
||||
const aliasNode = c.childForFieldName('alias');
|
||||
if (nameNode && aliasNode) {
|
||||
map.set(aliasNode.text, { name: nameNode.text, module });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c) walk(c);
|
||||
}
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP verbs declared on a Flask `add_url_rule(..., methods=[...])` call, upper-
|
||||
* cased. Defaults to `['GET']` when no `methods` keyword is present (Flask's own
|
||||
* default). Reads the captured call node directly since the list value is awkward
|
||||
* to capture in a tree-sitter query.
|
||||
*/
|
||||
function extractFlaskMethods(callNode: Parser.SyntaxNode): string[] {
|
||||
const args = callNode.childForFieldName('arguments');
|
||||
if (args) {
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const kw = args.namedChild(i);
|
||||
if (!kw || kw.type !== 'keyword_argument') continue;
|
||||
if (kw.childForFieldName('name')?.text !== 'methods') continue;
|
||||
const list = kw.childForFieldName('value');
|
||||
if (!list) continue;
|
||||
const methods: string[] = [];
|
||||
for (let j = 0; j < list.namedChildCount; j++) {
|
||||
const el = list.namedChild(j);
|
||||
const v = el && el.type === 'string' ? unquoteLiteral(el.text) : null;
|
||||
if (v) methods.push(v.toUpperCase());
|
||||
}
|
||||
if (methods.length > 0) return methods;
|
||||
}
|
||||
}
|
||||
return ['GET'];
|
||||
}
|
||||
|
||||
// Pre-scan: collect local string assignments (uri = "api/v1/endpoint/")
|
||||
function buildLocalStringMap(tree: Parser.Tree): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
@@ -811,10 +926,10 @@ function buildPythonRepoContext(
|
||||
const prefixesByLongKey = new Map<string, Set<string>>();
|
||||
const prefixesByShortKey = new Map<string, Set<string>>();
|
||||
|
||||
// Pre-pass over .py files. We deliberately run this even on files
|
||||
// that don't contain `include_router` — the cost of an extra parse
|
||||
// is bounded by the file count, and detecting `include_router`
|
||||
// beforehand would require its own grep/scan.
|
||||
// Cross-file pre-pass: only `include_router` sites need it — they bind a
|
||||
// prefix declared in one file to a router defined in another. Same-file
|
||||
// `APIRouter(prefix=...)` is resolved in scan() from the file's own tree, so
|
||||
// APIRouter-only files are left out here and never parsed twice.
|
||||
for (const rel of files) {
|
||||
if (!rel.endsWith('.py')) continue;
|
||||
const src = readFile(rel);
|
||||
@@ -907,19 +1022,37 @@ function buildPythonRepoContext(
|
||||
}
|
||||
}
|
||||
|
||||
return { prefixesByLongKey, prefixesByShortKey };
|
||||
return {
|
||||
prefixesByLongKey,
|
||||
prefixesByShortKey,
|
||||
};
|
||||
}
|
||||
|
||||
function joinPrefix(prefix: string, route: string): string {
|
||||
// Mirror FastAPI's path joining: trim trailing slash off prefix,
|
||||
// ensure exactly one leading slash on the result.
|
||||
const p = prefix.replace(/\/+$/, '');
|
||||
const r = route.startsWith('/') ? route : `/${route}`;
|
||||
return `${p}${r}`;
|
||||
// Delegate to the shared route-path normalizer so the group contract and the
|
||||
// ingestion Route node join prefixes identically — one helper, no
|
||||
// trailing-slash drift on empty routes (`APIRouter(prefix="/x")` + `@get("")`).
|
||||
return normalizeExtractedRoutePath(route, prefix);
|
||||
}
|
||||
export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
name: 'python-http',
|
||||
language: Python,
|
||||
// routeCoverage intentionally LEFT at the default 'partial' (#2138 Part 2).
|
||||
// It would be a no-op even if set to 'complete': FastAPI decorator routes set
|
||||
// no handlerName (generic worker path) and Django sets methodName: null, so no
|
||||
// Python file ever resolves a handlerSymbolId and none would be parse-skipped.
|
||||
// Declaring 'complete' now is only a latent trap for the moment a follow-up
|
||||
// gives FastAPI routes a handlerName. `hasConsumerSignals` is kept (and is a
|
||||
// true superset of scan()'s consumer shapes) so the precondition already holds
|
||||
// when Python is later flipped to 'complete'.
|
||||
// Consumer signals scan() can detect: `requests.<verb>`/`requests.request`,
|
||||
// `httpx` (sync/async client), the `uri=`/`url=` keyword/variable wrapper
|
||||
// calls, plus aiohttp/urllib. Conservative — over-matching only costs a parse.
|
||||
hasConsumerSignals(content) {
|
||||
return /\brequests\s*\.|\bhttpx\b|\baiohttp\b|\burllib\b|\burlopen\b|\buri\s*=|\burl\s*=/.test(
|
||||
content,
|
||||
);
|
||||
},
|
||||
prepareRepo({ files, parser, readFile, parseSource }): RepoContext {
|
||||
return buildPythonRepoContext(files, parser, readFile, parseSource);
|
||||
},
|
||||
@@ -927,6 +1060,10 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
const out: HttpDetection[] = [];
|
||||
const httpxAsyncClients = collectHttpxAsyncClients(tree);
|
||||
const ctx = repoContext as PythonRepoContext | undefined;
|
||||
// Local-binding → { declared name, module } for the file's `from … import …`
|
||||
// statements, so an imperatively-registered handler (Flask `view_func`) that
|
||||
// is an imported (possibly aliased) symbol resolves to its real definition.
|
||||
const importMap = buildPythonImportMap(tree);
|
||||
|
||||
// Providers: FastAPI @app.<verb>("/path") — already absolute path.
|
||||
for (const match of runCompiledPatterns(FASTAPI_APP_PATTERNS, tree)) {
|
||||
@@ -943,6 +1080,12 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: httpMethod,
|
||||
path,
|
||||
name: null,
|
||||
// The decorated handler has no captured name → resolve by line-span
|
||||
// containment. Best-effort fallback: FastAPI routes are graph-backed
|
||||
// (ingestion decorator routes) and the function span starts at `def`
|
||||
// (decorators excluded), so this lands the single-decorator case and
|
||||
// degrades to file-level for multi-decorator stacks.
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.8,
|
||||
});
|
||||
}
|
||||
@@ -951,6 +1094,18 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
// the ingestion route extractor), not a per-file source scan — see the note
|
||||
// at the top of this file.
|
||||
|
||||
// Same-file `router = APIRouter(prefix="/x")` (router-only). Read from this
|
||||
// file's own tree, so there is no cross-file map and no prefix bleed across
|
||||
// same-stem files; it stacks under any include_router(prefix=...) below.
|
||||
let constructorPrefix: string | undefined;
|
||||
for (const m of runCompiledPatterns(API_ROUTER_PREFIX_PATTERNS, tree)) {
|
||||
const prefixNode = m.captures.prefix;
|
||||
if (!prefixNode) continue;
|
||||
const p = unquoteLiteral(prefixNode.text);
|
||||
if (p === null) continue;
|
||||
constructorPrefix = p;
|
||||
}
|
||||
|
||||
// Providers: FastAPI @router.<verb>("/path") — must be joined
|
||||
// with the prefix(es) declared at the include_router site. When
|
||||
// no prefix is found we still emit the unprefixed path so this
|
||||
@@ -975,10 +1130,13 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
const shortPrefixes =
|
||||
longPrefixes || !shortKey ? undefined : ctx?.prefixesByShortKey.get(shortKey);
|
||||
const prefixSet = longPrefixes ?? shortPrefixes;
|
||||
// Stack the same-file APIRouter(prefix=...) under any cross-file
|
||||
// include_router prefix.
|
||||
const localPath = constructorPrefix ? joinPrefix(constructorPrefix, rawPath) : rawPath;
|
||||
const paths =
|
||||
prefixSet && prefixSet.size > 0
|
||||
? [...prefixSet].map((p) => joinPrefix(p, rawPath))
|
||||
: [rawPath];
|
||||
? [...prefixSet].map((p) => joinPrefix(p, localPath))
|
||||
: [localPath];
|
||||
|
||||
for (const p of paths) {
|
||||
out.push({
|
||||
@@ -987,6 +1145,34 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: httpMethod,
|
||||
path: p,
|
||||
name: null,
|
||||
// Best-effort containment fallback — see the @app provider note above.
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.8,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Providers: Flask `app.add_url_rule('/path', view_func=handler, methods=[…])`.
|
||||
// The handler is a `view_func` identifier, frequently an imported (possibly
|
||||
// aliased) view, so resolve it through the file's imports to the declared
|
||||
// symbol + its module for import-pinned resolution downstream.
|
||||
for (const match of runCompiledPatterns(FLASK_ADD_URL_RULE_PATTERNS, tree)) {
|
||||
const pathNode = match.captures.path;
|
||||
const handlerNode = match.captures.handler;
|
||||
const callNode = match.captures.call;
|
||||
if (!pathNode || !handlerNode || !callNode) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
const imported = importMap.get(handlerNode.text);
|
||||
for (const method of extractFlaskMethods(callNode)) {
|
||||
out.push({
|
||||
role: 'provider',
|
||||
framework: 'flask',
|
||||
method,
|
||||
path,
|
||||
name: imported ? imported.name : handlerNode.text,
|
||||
handlerImport: imported,
|
||||
line: (imported ? pathNode : handlerNode).startPosition.row + 1,
|
||||
confidence: 0.8,
|
||||
});
|
||||
}
|
||||
@@ -1005,6 +1191,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -1022,6 +1209,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -1040,6 +1228,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: methodRaw.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -1059,6 +1248,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -1079,6 +1269,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: methodRaw.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -1111,6 +1302,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: httpMethod,
|
||||
path,
|
||||
name: null,
|
||||
line: methodNode.startPosition.row + 1,
|
||||
confidence: 0.65,
|
||||
});
|
||||
}
|
||||
@@ -1137,6 +1329,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
method: httpMethod,
|
||||
path: normalized,
|
||||
name: null,
|
||||
line: methodNode.startPosition.row + 1,
|
||||
confidence: 0.6,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
*/
|
||||
|
||||
import type { HttpDetection, HttpFileDetections } from './types.js';
|
||||
import {
|
||||
resolveInheritedSpringRoutes,
|
||||
type SharedSpringType,
|
||||
} from '../../../ingestion/route-extractors/spring-shared.js';
|
||||
|
||||
/**
|
||||
* RestTemplate method-name → HTTP verb. Source-scan only: the receiver must be
|
||||
@@ -125,141 +129,28 @@ export function parseRequestLine(raw: string): { method: string; path: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a class/interface-level prefix and a method-level path into a single
|
||||
* URL path: strip leading/trailing slashes on the prefix and leading slashes
|
||||
* on the method path, then ensure exactly one slash between them.
|
||||
*/
|
||||
export function joinPath(prefix: string, methodPath: string): string {
|
||||
const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const cleanSub = methodPath.replace(/^\/+/, '');
|
||||
if (!cleanPrefix) return `/${cleanSub}`;
|
||||
return `/${cleanPrefix}/${cleanSub}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a controller's own class prefix with a route inherited from an interface
|
||||
* (interface-based controllers, #1743). The inherited path already has the
|
||||
* interface's own class prefix (`inheritedOwnerPrefix`) baked in; when the
|
||||
* controller repeats that same prefix we must NOT prepend it twice (#2057).
|
||||
* Shared by both plugins' `scanProject` so Java and Kotlin agree.
|
||||
*/
|
||||
export function joinInheritedSpringPath(
|
||||
controllerPrefix: string,
|
||||
inheritedPath: string,
|
||||
inheritedOwnerPrefix = '',
|
||||
): string {
|
||||
const joined = joinPath(controllerPrefix, inheritedPath);
|
||||
const cleanPrefix = controllerPrefix.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const cleanOwnerPrefix = inheritedOwnerPrefix.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const cleanInherited = inheritedPath.replace(/^\/+/, '');
|
||||
if (!cleanPrefix) return joined;
|
||||
if (
|
||||
cleanPrefix === cleanOwnerPrefix &&
|
||||
(cleanInherited === cleanPrefix || cleanInherited.startsWith(`${cleanPrefix}/`))
|
||||
) {
|
||||
return `/${cleanInherited}`;
|
||||
}
|
||||
return joined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Language-agnostic view of a Spring class/interface that each plugin's
|
||||
* grammar-specific collector produces. The interface-based-controller
|
||||
* inheritance algorithm (`scanSpringInheritanceProject`) operates only on this
|
||||
* shape, so the Java and Kotlin plugins share one algorithm and cannot drift.
|
||||
*
|
||||
* `methods[].routes` carry only `{ method, path }` — the interface's own class
|
||||
* prefix is applied *inside* `scanSpringInheritanceProject` (it is not part of
|
||||
* the collector's output).
|
||||
*/
|
||||
export interface SharedSpringType {
|
||||
filePath: string;
|
||||
kind: 'class' | 'interface';
|
||||
name: string;
|
||||
/** Class-level `@RequestMapping` prefixes — one per array element. */
|
||||
classPrefixes: string[];
|
||||
implementedInterfaces: string[];
|
||||
isController: boolean;
|
||||
methods: Array<{ name: string; routes: Array<{ method: string; path: string }> }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve interface-based-controller provider routes (#1743): a concrete
|
||||
* Resolve interface-based-controller provider *detections* (#1743): a concrete
|
||||
* `@RestController`/`@Controller` class inherits the `@(Get|...)Mapping` routes
|
||||
* declared on the interface it implements. Shared by the Java and Kotlin plugins
|
||||
* so both emit byte-identical provider contracts.
|
||||
*
|
||||
* An interface name that resolves to two distinct interfaces is ambiguous and
|
||||
* its routes are dropped (the `null` marker). The controller's own class
|
||||
* prefix(es) cross-product the inherited routes; `joinInheritedSpringPath`
|
||||
* avoids doubling a prefix the interface already baked in (#2057).
|
||||
* declared on the interface it implements. Thin group-layer adapter over the
|
||||
* shared, language-agnostic `resolveInheritedSpringRoutes` (in
|
||||
* `ingestion/route-extractors/spring-shared.ts`) — it maps each inherited route
|
||||
* to a provider `HttpDetection`. Shared by the Java and Kotlin plugins so both
|
||||
* emit byte-identical provider contracts; the ingestion route extractor calls
|
||||
* the same underlying algorithm so all three stay in parity.
|
||||
*/
|
||||
export function scanSpringInheritanceProject(types: SharedSpringType[]): HttpFileDetections[] {
|
||||
// interface name → (method name → routes). `ownerPrefix` records the
|
||||
// interface's own class prefix so the controller side avoids doubling it
|
||||
// (#2057). `null` marks an ambiguous (duplicated) interface name.
|
||||
type InheritedRoute = { method: string; path: string; ownerPrefix: string };
|
||||
const interfaceRoutes = new Map<string, Map<string, InheritedRoute[]> | null>();
|
||||
for (const type of types) {
|
||||
if (type.kind !== 'interface') continue;
|
||||
if (interfaceRoutes.has(type.name)) {
|
||||
interfaceRoutes.set(type.name, null);
|
||||
continue;
|
||||
}
|
||||
const prefixes = type.classPrefixes.length ? type.classPrefixes : [''];
|
||||
const methodMap = new Map<string, InheritedRoute[]>();
|
||||
for (const method of type.methods) {
|
||||
// Cross-product the interface's class prefixes with each method route, so a
|
||||
// multi-element `@RequestMapping(["/a","/b"])` interface yields N bindings.
|
||||
const routes = method.routes.flatMap((route) =>
|
||||
prefixes.map((prefix) => ({
|
||||
method: route.method,
|
||||
path: prefix ? joinPath(prefix, route.path) : route.path,
|
||||
ownerPrefix: prefix,
|
||||
})),
|
||||
);
|
||||
if (routes.length > 0) methodMap.set(method.name, routes);
|
||||
}
|
||||
interfaceRoutes.set(type.name, methodMap);
|
||||
}
|
||||
|
||||
const detectionsByFile = new Map<string, HttpDetection[]>();
|
||||
for (const type of types) {
|
||||
if (type.kind !== 'class' || !type.isController) continue;
|
||||
// Cross-product the controller's own class prefixes with each inherited
|
||||
// route; `['']` keeps the common no-prefix controller emitting the
|
||||
// interface path unchanged.
|
||||
const controllerPrefixes = type.classPrefixes.length ? type.classPrefixes : [''];
|
||||
for (const method of type.methods) {
|
||||
if (method.routes.length > 0) continue; // own @*Mapping → already a provider via scan()
|
||||
const inherited = type.implementedInterfaces.flatMap((iface) => {
|
||||
const routeMap = interfaceRoutes.get(iface);
|
||||
if (!routeMap) return [];
|
||||
const routes = routeMap.get(method.name) ?? [];
|
||||
return routes.flatMap((route) =>
|
||||
controllerPrefixes.map((controllerPrefix) => ({
|
||||
method: route.method,
|
||||
path: joinInheritedSpringPath(controllerPrefix, route.path, route.ownerPrefix),
|
||||
})),
|
||||
);
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
for (const route of inherited) {
|
||||
const key = `${route.method} ${route.path}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const detections = detectionsByFile.get(type.filePath) ?? [];
|
||||
detections.push({
|
||||
role: 'provider',
|
||||
framework: 'spring',
|
||||
method: route.method,
|
||||
path: route.path,
|
||||
name: method.name,
|
||||
confidence: 0.8,
|
||||
});
|
||||
detectionsByFile.set(type.filePath, detections);
|
||||
}
|
||||
}
|
||||
for (const route of resolveInheritedSpringRoutes(types)) {
|
||||
const detections = detectionsByFile.get(route.filePath) ?? [];
|
||||
detections.push({
|
||||
role: 'provider',
|
||||
framework: 'spring',
|
||||
method: route.method,
|
||||
path: route.path,
|
||||
name: route.methodName,
|
||||
confidence: 0.8,
|
||||
});
|
||||
detectionsByFile.set(route.filePath, detections);
|
||||
}
|
||||
|
||||
return [...detectionsByFile.entries()].map(([filePath, detections]) => ({
|
||||
|
||||
@@ -36,6 +36,26 @@ export interface HttpDetection {
|
||||
* Null when no good candidate is available.
|
||||
*/
|
||||
name: string | null;
|
||||
/**
|
||||
* 1-based source line of the call/registration site (the `fetch(...)` for
|
||||
* consumers, the `router.get(...)` / decorator for providers). Lets the
|
||||
* extractor resolve the contract to the *containing* symbol (the function
|
||||
* the call lives in) via line-span containment, so HTTP contracts carry a
|
||||
* real `symbolUid` instead of an empty one. Optional — a plugin that does
|
||||
* not set it falls back to file-level boundary resolution downstream.
|
||||
*/
|
||||
line?: number;
|
||||
/**
|
||||
* When the handler is an IMPORTED symbol, the import resolved to its declared
|
||||
* (exported) `name` and the `module` specifier it came from. The extractor
|
||||
* pins resolution to the import's target file, so an aliased import
|
||||
* (`import { listUsers as handleUsers }`) or a name that collides with a local
|
||||
* symbol resolves to the right handler instead of a same-named decoy. `name`
|
||||
* here is the DECLARED export name (not the local alias); `module` is the raw
|
||||
* specifier (e.g. `./handlers/users`). Set only for named imports; omitted for
|
||||
* locally-defined or anonymous handlers.
|
||||
*/
|
||||
handlerImport?: { name: string; module: string };
|
||||
/** Confidence in (0, 1]. Source-scan plugins typically use 0.7–0.8. */
|
||||
confidence: number;
|
||||
}
|
||||
@@ -78,6 +98,43 @@ export interface HttpLanguagePlugin {
|
||||
name: string;
|
||||
/** tree-sitter grammar object (passed to the shared parser). */
|
||||
language: unknown;
|
||||
/**
|
||||
* Whether ingestion is known to emit a `Route` graph node for EVERY
|
||||
* provider route in this language (Spring/FastAPI/Laravel annotations are
|
||||
* extracted into Route nodes during parse). When `'complete'`, the
|
||||
* orchestrator may skip the source-scan + tree-sitter parse for a file whose
|
||||
* graph provider routes all resolved a handler symbol (#2138 Part 2) — the
|
||||
* graph is authoritative, the scan would only re-discover the same routes.
|
||||
*
|
||||
* Defaults to `'partial'` (the safe assumption): the source scan always runs,
|
||||
* so a language whose ingestion coverage is incomplete never loses routes.
|
||||
* This is a deliberate, per-language trust assertion — set it only for
|
||||
* languages whose route ingestion is provably complete.
|
||||
*/
|
||||
routeCoverage?: 'complete' | 'partial';
|
||||
/**
|
||||
* Cheap, parse-free pre-check used by the parse-skip optimization (#2138
|
||||
* Part 2). Given a file's raw source text, return `false` ONLY when the file
|
||||
* provably contains no outbound-HTTP (consumer) call that this plugin's
|
||||
* `scan()` would detect; return `true` on any doubt.
|
||||
*
|
||||
* Why it exists: `routeCoverage: 'complete'` asserts *provider* Route-node
|
||||
* completeness only. A provider-covered file may ALSO be a consumer (e.g. a
|
||||
* Spring `@RestController` that calls `restTemplate`/`webClient`, a Laravel
|
||||
* controller using Guzzle, a FastAPI handler calling `requests`/`httpx`).
|
||||
* Ingestion's `FETCHES` edges are JS/TS-only, so the graph cannot back up
|
||||
* those server-side consumers — they come solely from the source scan. The
|
||||
* orchestrator may therefore skip a provider-covered file's parse only when
|
||||
* this returns `false`; otherwise the file is still scanned so its consumer
|
||||
* contracts are not dropped.
|
||||
*
|
||||
* MUST be implemented by any plugin whose `scan()` can emit `'consumer'`
|
||||
* detections AND that declares `routeCoverage: 'complete'`; otherwise that
|
||||
* language's provider-covered files are never parse-skipped (safe, no win).
|
||||
* The check is intentionally conservative — over-matching only costs a parse
|
||||
* that could have been skipped; it never drops data.
|
||||
*/
|
||||
hasConsumerSignals?(content: string): boolean;
|
||||
/**
|
||||
* Optional pre-pass: walk the relevant files in the repo and produce
|
||||
* an opaque context that `scan` can use to resolve cross-file facts.
|
||||
|
||||
@@ -49,6 +49,7 @@ MATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
|
||||
RETURN handlerFile.id AS fileId, handlerFile.filePath AS filePath,
|
||||
route.name AS routePath, route.id AS routeId,
|
||||
route.method AS routeMethod,
|
||||
route.handlerSymbolId AS handlerSymbolId,
|
||||
route.responseKeys AS responseKeys,
|
||||
r.reason AS routeSource`;
|
||||
const FETCHES_QUERY = `
|
||||
@@ -57,11 +58,163 @@ RETURN callerFile.id AS fileId, callerFile.filePath AS filePath,
|
||||
route.name AS routePath, route.id AS routeId,
|
||||
r.reason AS fetchReason`;
|
||||
|
||||
const CONTAINS_QUERY = `
|
||||
MATCH (file:File {id: $fileId})<-[:CodeRelation {type: 'CONTAINS'}]-(sym)
|
||||
WHERE sym.startLine IS NOT NULL
|
||||
RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath, labels(sym) AS labels
|
||||
ORDER BY sym.startLine`;
|
||||
// Function/Method/CodeElement symbols (with line spans) in a file, addressed by
|
||||
// repo-relative path so the source-scan paths — which have a path but no graph
|
||||
// `fileId` — can resolve the symbol CONTAINING an HTTP call by line-span
|
||||
// containment. Matched by `filePath` rather than a File-[DEFINES]->sym edge so
|
||||
// it also reaches methods nested in classes (Java/Kotlin), where the File
|
||||
// defines the class and the class defines the method.
|
||||
const CONTAINING_QUERY = `
|
||||
MATCH (sym:Function)
|
||||
WHERE sym.filePath = $filePath AND sym.startLine IS NOT NULL AND sym.endLine IS NOT NULL
|
||||
RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath,
|
||||
sym.startLine AS startLine, sym.endLine AS endLine, labels(sym) AS labels
|
||||
UNION ALL
|
||||
MATCH (sym:Method)
|
||||
WHERE sym.filePath = $filePath AND sym.startLine IS NOT NULL AND sym.endLine IS NOT NULL
|
||||
RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath,
|
||||
sym.startLine AS startLine, sym.endLine AS endLine, labels(sym) AS labels
|
||||
UNION ALL
|
||||
MATCH (sym:CodeElement)
|
||||
WHERE sym.filePath = $filePath AND sym.startLine IS NOT NULL AND sym.endLine IS NOT NULL
|
||||
RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath,
|
||||
sym.startLine AS startLine, sym.endLine AS endLine, labels(sym) AS labels`;
|
||||
|
||||
// Repo-wide lookup of a symbol by exact name. Used to resolve a provider's
|
||||
// named handler when it is defined in a file OTHER than its route registration —
|
||||
// and only honored when the result is unique (see resolveSymbolByNameUnique).
|
||||
//
|
||||
// Label filtering uses `labels(n) IN [...]` rather than the openCypher
|
||||
// disjunction `MATCH (n:A|B|C)`. NOTE: this 3-label set (Function/Method/
|
||||
// CodeElement) actually PARSES — LadybugDB only rejects a disjunction that
|
||||
// names a reserved keyword (e.g. `Macro`, `Union`) or a missing node table,
|
||||
// neither of which applies here. So this query was NOT broken by #2325; it
|
||||
// uses the `labels(n) IN` form for consistency with the manifest custom-branch
|
||||
// fix (which WAS broken) and to stay immune if a reserved-keyword label is
|
||||
// added later. `labels(n)` returns the node's single label as a string here, so
|
||||
// `IN [...]` is an exact allowlist. (Exported so integration tests can run the
|
||||
// exact production query against a real LadybugDB — the bug shipped because no
|
||||
// test ran these strings against the real parser.)
|
||||
//
|
||||
// `n.filePath <> ''` excludes synthetic non-source `CodeElement` nodes that
|
||||
// carry no real file — ORM model/table nodes (orm.ts emits `filePath: ''`) and
|
||||
// similar — so a handler name colliding with an ORM model neither resolves to a
|
||||
// degenerate edge-less node NOR inflates the uniqueness count and masks the real
|
||||
// handler. `LIMIT 2` bounds materialization: distinguishing unique (1) from
|
||||
// ambiguous (>=2) never needs more than two rows (the count guard stays exact).
|
||||
export const RESOLVE_BY_NAME_QUERY = `
|
||||
MATCH (n) WHERE labels(n) IN ['Function','Method','CodeElement']
|
||||
AND n.name = $name AND n.filePath <> ''
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
LIMIT 2`;
|
||||
|
||||
// Resolve an IMPORTED handler by pinning it to the import's target module: the
|
||||
// declared export `$name` whose file is the module the handler was imported from
|
||||
// (`$fileDot` matches `mod.ext`, `$fileSlash` matches `mod/index.ext`). This is
|
||||
// the precise rung — it survives aliases and local same-name collisions that a
|
||||
// repo-wide name lookup cannot, and only resolves on a unique match within that
|
||||
// module. `LIMIT 2` keeps the uniqueness count exact (see RESOLVE_BY_NAME_QUERY).
|
||||
export const RESOLVE_IN_MODULE_QUERY = `
|
||||
MATCH (n) WHERE labels(n) IN ['Function','Method','CodeElement']
|
||||
AND n.name = $name AND (n.filePath STARTS WITH $fileDot OR n.filePath STARTS WITH $fileSlash)
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
LIMIT 2`;
|
||||
|
||||
// Source-file extensions an import specifier may resolve to (stripped before
|
||||
// building the module file-prefix so `./h/users` and `./h/users.ts` agree).
|
||||
const SOURCE_EXT_RE = /\.(?:m|c)?[jt]sx?$/;
|
||||
|
||||
/**
|
||||
* Resolve an import specifier to a repo-relative FILE BASE (path without
|
||||
* extension) so the target module can be matched by `filePath STARTS WITH`.
|
||||
* Handles two relative-import dialects and returns null for bare/absolute
|
||||
* imports (which fall back to a repo-wide name lookup):
|
||||
* - path-style (JS/TS): `./handlers/users`, `../x` → joined against the
|
||||
* importing file's directory.
|
||||
* - dotted-relative (Python): `.users`, `..pkg.users` → leading dots are
|
||||
* package levels (one dot = the file's own package), the rest dot→slash.
|
||||
*/
|
||||
function resolveModuleBase(fromFile: string, module: string): string | null {
|
||||
const dir = path.posix.dirname(fromFile.replace(/\\/g, '/'));
|
||||
if (module.includes('/')) {
|
||||
// path-style relative import
|
||||
if (!module.startsWith('.')) return null;
|
||||
return path.posix.normalize(path.posix.join(dir, module)).replace(SOURCE_EXT_RE, '');
|
||||
}
|
||||
if (module.startsWith('.')) {
|
||||
// Python dotted-relative import
|
||||
const dots = module.length - module.replace(/^\.+/, '').length;
|
||||
const rest = module.slice(dots).replace(/\./g, '/');
|
||||
let base = dir;
|
||||
for (let i = 1; i < dots; i++) base = path.posix.dirname(base);
|
||||
return rest ? path.posix.normalize(path.posix.join(base, rest)) : base;
|
||||
}
|
||||
return null; // bare / absolute import — repo-wide fallback
|
||||
}
|
||||
|
||||
interface ResolvedSymbol {
|
||||
uid: string;
|
||||
name: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The innermost Function/Method whose `[startLine, endLine]` span contains
|
||||
* `line` — i.e. the symbol the HTTP call lives inside. For a consumer this is
|
||||
* the function making the `fetch`; for an inline-arrow provider it is the
|
||||
* handler arrow itself. Returns null when nothing encloses the line (e.g. a
|
||||
* route registered at module scope referencing a named handler defined
|
||||
* elsewhere — that case resolves by name instead).
|
||||
*/
|
||||
function resolveContainingSymbol(
|
||||
rows: Record<string, unknown>[],
|
||||
line: number,
|
||||
): ResolvedSymbol | null {
|
||||
const norm = (x: unknown): string => String(x ?? '');
|
||||
// Detection lines are 1-based; symbol spans are stored 0-based for the
|
||||
// languages indexed today (parse-worker records `startPosition.row`). So the
|
||||
// base-correct probe is `line - 1`. Pick the INNERMOST (smallest-span) symbol
|
||||
// whose span contains the probe. Only if nothing contains `line - 1` do we
|
||||
// retry with the raw `line` — a defensive fallback for any future language
|
||||
// that stores 1-based spans. Probing `line - 1` first (rather than OR-ing both)
|
||||
// avoids the +1 slack mis-picking a one-line sibling that sits on `line`.
|
||||
const pick = (probe: number): ResolvedSymbol | null => {
|
||||
let best: ResolvedSymbol | null = null;
|
||||
let bestSpan = Number.POSITIVE_INFINITY;
|
||||
for (const r of rows) {
|
||||
const labels = JSON.stringify(r.labels ?? r[5] ?? '');
|
||||
if (!['Function', 'Method', 'CodeElement'].some((l) => labels.includes(l))) continue;
|
||||
const start = Number(r.startLine ?? r[3]);
|
||||
const end = Number(r.endLine ?? r[4]);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
|
||||
if (probe < start || probe > end) continue;
|
||||
const span = end - start;
|
||||
if (span < bestSpan) {
|
||||
bestSpan = span;
|
||||
best = {
|
||||
uid: norm(r.uid ?? r[0]),
|
||||
name: norm(r.name ?? r[1]),
|
||||
filePath: norm(r.filePath ?? r[2]),
|
||||
};
|
||||
}
|
||||
}
|
||||
return best && best.uid ? best : null;
|
||||
};
|
||||
return pick(line - 1) ?? pick(line);
|
||||
}
|
||||
|
||||
/** A Function/Method in the file matching `name` exactly (for named handlers). */
|
||||
function resolveSymbolByName(rows: Record<string, unknown>[], name: string): ResolvedSymbol | null {
|
||||
const norm = (x: unknown): string => String(x ?? '');
|
||||
for (const r of rows) {
|
||||
const labels = JSON.stringify(r.labels ?? r[5] ?? '');
|
||||
if (!['Function', 'Method', 'CodeElement'].some((l) => labels.includes(l))) continue;
|
||||
if (norm(r.name ?? r[1]) !== name) continue;
|
||||
const uid = norm(r.uid ?? r[0]);
|
||||
if (uid) return { uid, name, filePath: norm(r.filePath ?? r[2]) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Path normalization (shared between provider / consumer paths) ──
|
||||
|
||||
@@ -111,6 +264,10 @@ function contractIdFor(method: string, pathNorm: string): string {
|
||||
return `http::${method.toUpperCase()}::${pathNorm}`;
|
||||
}
|
||||
|
||||
export function normalizeRepoRelPath(filePath: string): string {
|
||||
return filePath.replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
// ─── Graph row helpers ───────────────────────────────────────────────
|
||||
|
||||
function methodFromRouteReason(reason: string): string | null {
|
||||
@@ -123,35 +280,6 @@ function methodFromRouteReason(reason: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickSymbolUid(
|
||||
rows: Record<string, unknown>[],
|
||||
preferredName: string | null,
|
||||
): { uid: string; name: string; filePath: string } {
|
||||
const norm = (x: unknown) => String(x ?? '');
|
||||
const labeled = rows.filter((r) => {
|
||||
const labels = r.labels ?? r[3];
|
||||
const s = JSON.stringify(labels);
|
||||
return s.includes('Method') || s.includes('Function');
|
||||
});
|
||||
const pool = labeled.length > 0 ? labeled : rows;
|
||||
if (preferredName) {
|
||||
const hit = pool.find((r) => norm(r.name ?? r[1]) === preferredName);
|
||||
if (hit) {
|
||||
return {
|
||||
uid: norm(hit.uid ?? hit[0]),
|
||||
name: norm(hit.name ?? hit[1]),
|
||||
filePath: norm(hit.filePath ?? hit[2]),
|
||||
};
|
||||
}
|
||||
}
|
||||
const first = pool[0] || rows[0];
|
||||
return {
|
||||
uid: norm(first?.uid ?? first?.[0]),
|
||||
name: norm(first?.name ?? first?.[1]),
|
||||
filePath: norm(first?.filePath ?? first?.[2]),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Orchestrator ────────────────────────────────────────────────────
|
||||
|
||||
export class HttpRouteExtractor implements ContractExtractor {
|
||||
@@ -282,22 +410,197 @@ export class HttpRouteExtractor implements ContractExtractor {
|
||||
};
|
||||
|
||||
const files = await getScannedFiles();
|
||||
await collectProjectDetections(files);
|
||||
|
||||
// Resolve an HTTP detection to the symbol it lives in — the containing
|
||||
// function for a consumer / inline-arrow provider, or a named handler for
|
||||
// a provider — addressed by repo-relative file path so the source-scan
|
||||
// paths (which have no graph `fileId`) can resolve too. Per-file symbol
|
||||
// lists are cached. Returns null without a DB or when nothing resolves (a
|
||||
// named provider resolves by name even with no `line`; containment needs
|
||||
// one); the contract then keeps an empty symbolUid and downstream falls
|
||||
// back to file-level boundary matching.
|
||||
const fileSymbolCache = new Map<string, Record<string, unknown>[]>();
|
||||
const loadFileSymbols = async (filePath: string): Promise<Record<string, unknown>[]> => {
|
||||
if (!dbExecutor) return [];
|
||||
const cached = fileSymbolCache.get(filePath);
|
||||
if (cached) return cached;
|
||||
let rows: Record<string, unknown>[] = [];
|
||||
try {
|
||||
rows = await dbExecutor(CONTAINING_QUERY, { filePath });
|
||||
} catch {
|
||||
rows = [];
|
||||
}
|
||||
fileSymbolCache.set(filePath, rows);
|
||||
return rows;
|
||||
};
|
||||
// Repo-wide UNAMBIGUOUS resolution for a provider handler defined in a file
|
||||
// other than its route registration (e.g. `router.get('/x', listUsers)` with
|
||||
// `listUsers` imported from another module). Returns the symbol ONLY when
|
||||
// exactly one Function/Method/CodeElement carries that name across the repo.
|
||||
// The strict uniqueness guard is intentionally conservative: when a name is
|
||||
// shared across files (homonyms like `handler`/`index`), we prefer a
|
||||
// false-negative (no attribution → file-level fallback) over a false-positive
|
||||
// (wrong symbol).
|
||||
//
|
||||
// An IMPORTED handler (the common cross-file case) is pinned to its source
|
||||
// module first by resolveImportedSymbol, so an alias or a name colliding with
|
||||
// a local symbol resolves correctly; this repo-wide-by-name rung is the
|
||||
// fallback for non-relative/bare imports and for plugins that supply only a
|
||||
// name. Cached by name for the lifetime of this extract().
|
||||
const globalNameCache = new Map<string, ResolvedSymbol | null>();
|
||||
const toResolvedSymbol = (rows: Record<string, unknown>[]): ResolvedSymbol | null => {
|
||||
const norm = (x: unknown): string => String(x ?? '');
|
||||
const uid = rows.length === 1 ? norm(rows[0]!.uid ?? rows[0]![0]) : '';
|
||||
const filePath = uid ? norm(rows[0]!.filePath ?? rows[0]![2]) : '';
|
||||
// Reject a unique match that carries no real file (a synthetic ORM /
|
||||
// non-source node) so it can never anchor a cross-trace on an edge-less
|
||||
// node — defence in depth alongside the queries' filePath predicates.
|
||||
return uid && filePath ? { uid, name: norm(rows[0]!.name ?? rows[0]![1]), filePath } : null;
|
||||
};
|
||||
const resolveSymbolByNameUnique = async (name: string): Promise<ResolvedSymbol | null> => {
|
||||
if (!dbExecutor) return null;
|
||||
const cached = globalNameCache.get(name);
|
||||
if (cached !== undefined) return cached;
|
||||
let rows: Record<string, unknown>[] = [];
|
||||
try {
|
||||
rows = await dbExecutor(RESOLVE_BY_NAME_QUERY, { name });
|
||||
} catch {
|
||||
rows = [];
|
||||
}
|
||||
const result = toResolvedSymbol(rows);
|
||||
globalNameCache.set(name, result);
|
||||
return result;
|
||||
};
|
||||
// Resolve a handler imported from a RELATIVE module to the unique declared
|
||||
// symbol of that name inside the import's target file. Returns null for
|
||||
// non-relative (bare/aliased-path) imports — those fall back to the repo-wide
|
||||
// name lookup. Cached by (target-file-prefix, declared name).
|
||||
const importedSymbolCache = new Map<string, ResolvedSymbol | null>();
|
||||
const resolveImportedSymbol = async (
|
||||
fromFile: string,
|
||||
imp: { name: string; module: string },
|
||||
): Promise<ResolvedSymbol | null> => {
|
||||
if (!dbExecutor) return null;
|
||||
const base = resolveModuleBase(fromFile, imp.module);
|
||||
if (base === null) return null; // bare/absolute import → repo-wide fallback
|
||||
const cacheKey = JSON.stringify([base, imp.name]);
|
||||
const cached = importedSymbolCache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
let rows: Record<string, unknown>[] = [];
|
||||
try {
|
||||
rows = await dbExecutor(RESOLVE_IN_MODULE_QUERY, {
|
||||
name: imp.name,
|
||||
fileDot: `${base}.`,
|
||||
fileSlash: `${base}/`,
|
||||
});
|
||||
} catch {
|
||||
rows = [];
|
||||
}
|
||||
const result = toResolvedSymbol(rows);
|
||||
importedSymbolCache.set(cacheKey, result);
|
||||
return result;
|
||||
};
|
||||
const resolveDetectionSymbol = async (
|
||||
filePath: string,
|
||||
d: HttpDetection,
|
||||
): Promise<ResolvedSymbol | null> => {
|
||||
if (!dbExecutor) return null;
|
||||
const syms = await loadFileSymbols(filePath);
|
||||
// Name resolution does NOT need a detection line — a named provider
|
||||
// handler (Spring/Go/etc. method name) resolves by name even when the
|
||||
// plugin didn't set `line`. Try the registration file FIRST; then, for a
|
||||
// handler defined in another file, the unique repo-wide match. Only the
|
||||
// containment fallback requires a line.
|
||||
if (d.role === 'provider' && d.name) {
|
||||
// IMPORTED handler: pin to the import's target module first. This is the
|
||||
// precise rung — it survives aliases and names that collide with a local
|
||||
// symbol. The handler is defined ELSEWHERE, so a file-scoped lookup of
|
||||
// its (declared) name would be wrong; on a miss go straight to a unique
|
||||
// repo-wide match on the declared name, never file-scoped.
|
||||
if (d.handlerImport) {
|
||||
const byImport = await resolveImportedSymbol(filePath, d.handlerImport);
|
||||
if (byImport) return byImport;
|
||||
const byGlobal = await resolveSymbolByNameUnique(d.handlerImport.name);
|
||||
if (byGlobal) return byGlobal;
|
||||
return null;
|
||||
}
|
||||
const byName = resolveSymbolByName(syms, d.name);
|
||||
if (byName) return byName;
|
||||
const byGlobal = await resolveSymbolByNameUnique(d.name);
|
||||
if (byGlobal) return byGlobal;
|
||||
// A NAMED handler we could not resolve by name (neither file-scoped nor
|
||||
// the unique repo-wide match) must NOT fall through to line-span
|
||||
// containment: `d.line` is the route REGISTRATION site, so containment
|
||||
// would attach the route to the enclosing registrar (e.g. a
|
||||
// `setupRoutes()` wrapper) rather than the handler. Leave it empty →
|
||||
// file-level boundary fallback, upholding the invariant that a
|
||||
// zero/ambiguous name match never yields a wrong-symbol attribution.
|
||||
return null;
|
||||
}
|
||||
// Consumers (the function making the fetch) and inline-arrow providers
|
||||
// (d.name === null) DO resolve by containment — there the enclosing symbol
|
||||
// is the right one.
|
||||
if (syms.length === 0 || d.line == null) return null;
|
||||
return resolveContainingSymbol(syms, d.line);
|
||||
};
|
||||
|
||||
// Run the graph provider pass FIRST. After #2138 Part 2 it reads handler
|
||||
// symbols from the graph (no source parse for resolved routes), so it can
|
||||
// report which files are fully graph-covered BEFORE we decide what to
|
||||
// parse. Files fully covered by a `routeCoverage: 'complete'` language are
|
||||
// candidates to skip the source scan + tree-sitter parse — but only their
|
||||
// *providers* are graph-authoritative; the consumer-safety gate below
|
||||
// removes any candidate that still needs scanning for outbound calls.
|
||||
const coveredFiles = new Set<string>();
|
||||
const graphProviders =
|
||||
dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, getDetections) : [];
|
||||
// Source scan always runs to capture routes in languages/files not covered
|
||||
// by graph edges; the glob and per-file parse results are cached above.
|
||||
dbExecutor != null
|
||||
? await this.extractProvidersGraph(
|
||||
dbExecutor,
|
||||
getDetections,
|
||||
resolveDetectionSymbol,
|
||||
coveredFiles,
|
||||
)
|
||||
: [];
|
||||
|
||||
// Consumer-safety gate (#2138 Part 2): `extractProvidersGraph` marks a file
|
||||
// covered on *provider* grounds (all HANDLES_ROUTE rows resolved + a
|
||||
// `routeCoverage: 'complete'` language). But a provider-covered file may also
|
||||
// be a *consumer* (a controller that calls RestTemplate/WebClient/Guzzle/
|
||||
// requests/...), and ingestion emits no FETCHES edges for those server-side
|
||||
// languages — the graph can't back them up. So a covered file is only truly
|
||||
// safe to skip (parse) when its plugin can PROVE, from a cheap parse-free
|
||||
// text scan, that it holds no such consumer call. Anything else (a positive
|
||||
// signal, no `hasConsumerSignals` hook, or an unreadable file) stays in the
|
||||
// scan set so its consumer contracts are preserved.
|
||||
for (const f of [...coveredFiles]) {
|
||||
const plugin = getPluginForFile(f);
|
||||
const content = readSafe(repoPath, f);
|
||||
const provenNoConsumer =
|
||||
content != null && typeof plugin?.hasConsumerSignals === 'function'
|
||||
? plugin.hasConsumerSignals(content) === false
|
||||
: false;
|
||||
if (!provenNoConsumer) coveredFiles.delete(f);
|
||||
}
|
||||
|
||||
// Everything the graph did not fully cover still gets a full source scan
|
||||
// (fail-open: partial-coverage languages, unresolved routes, and graph-less
|
||||
// runs all land here).
|
||||
const scanFiles = files.filter((f) => !coveredFiles.has(f));
|
||||
|
||||
await collectProjectDetections(scanFiles);
|
||||
|
||||
const providers = this.mergeGraphAndSourceContracts(
|
||||
graphProviders,
|
||||
await this.extractProvidersSourceScan(files, getDetections),
|
||||
await this.extractProvidersSourceScan(scanFiles, getDetections, resolveDetectionSymbol),
|
||||
);
|
||||
|
||||
const graphConsumers =
|
||||
dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : [];
|
||||
dbExecutor != null
|
||||
? await this.extractConsumersGraph(dbExecutor, getDetections, resolveDetectionSymbol)
|
||||
: [];
|
||||
const consumers = this.mergeGraphAndSourceContracts(
|
||||
graphConsumers,
|
||||
await this.extractConsumersSourceScan(files, getDetections),
|
||||
await this.extractConsumersSourceScan(scanFiles, getDetections, resolveDetectionSymbol),
|
||||
);
|
||||
|
||||
return [...providers, ...consumers];
|
||||
@@ -323,8 +626,15 @@ export class HttpRouteExtractor implements ContractExtractor {
|
||||
private async extractProvidersGraph(
|
||||
db: CypherExecutor,
|
||||
getDetections: (rel: string) => Promise<HttpDetection[]>,
|
||||
resolveSymbol: (filePath: string, d: HttpDetection) => Promise<ResolvedSymbol | null>,
|
||||
coveredFiles?: Set<string>,
|
||||
): Promise<ExtractedContract[]> {
|
||||
const out: ExtractedContract[] = [];
|
||||
// Per-file coverage tracking (#2138 Part 2): a file is "fully graph-covered"
|
||||
// when every one of its HANDLES_ROUTE rows resolved a handlerSymbolId AND its
|
||||
// language plugin declares `routeCoverage: 'complete'`. Such files can skip
|
||||
// the source scan + parse entirely — the graph is authoritative for them.
|
||||
const fileAllResolved = new Map<string, boolean>();
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
rows = await db(HANDLES_ROUTE_QUERY);
|
||||
@@ -354,67 +664,79 @@ export class HttpRouteExtractor implements ContractExtractor {
|
||||
.toUpperCase();
|
||||
let method = (graphMethod || null) ?? methodFromRouteReason(routeSource);
|
||||
|
||||
// Look up handler name (and backfill method if missing) from the
|
||||
// plugin's scan of the handler file. This replaces the old
|
||||
// regex-based `inferMethodFromFileScan` and `pickJavaHandlerName`
|
||||
// helpers — tree-sitter gives both pieces of information
|
||||
// structurally. Always run the lookup: even when method is set by
|
||||
// `methodFromRouteReason`, we still need the handler name.
|
||||
const detections = filePath ? await getDetections(filePath) : [];
|
||||
const providerDetections = detections.filter((d) => d.role === 'provider');
|
||||
let handlerName: string | null = null;
|
||||
const normalizedRoute = normalizeHttpPath(routePath);
|
||||
// Candidates share the same normalized path. When multiple
|
||||
// detections at the same path exist (e.g. GET + POST /api/orders
|
||||
// in one router), a blind `.find()` silently returned the first
|
||||
// verb — attaching the wrong handler and, when method was not
|
||||
// already pinned by the route reason, the wrong method too.
|
||||
// Disambiguate by method when we know it; refuse to guess when
|
||||
// we don't.
|
||||
const candidates = providerDetections.filter(
|
||||
(d) => normalizeHttpPath(d.path) === normalizedRoute,
|
||||
);
|
||||
let match: (typeof candidates)[number] | undefined;
|
||||
const ambiguousCandidates = !method && candidates.length > 1;
|
||||
if (method) {
|
||||
match = candidates.find((d) => d.method === method);
|
||||
} else if (candidates.length === 1) {
|
||||
match = candidates[0];
|
||||
const handlerSymbolId = String(row.handlerSymbolId ?? '').trim();
|
||||
const fileId = row.fileId ?? row[0];
|
||||
// Track per-file resolution for the parse-skip coverage set: a file stays
|
||||
// "all resolved" only while every one of its rows carries a handlerSymbolId.
|
||||
if (filePath) {
|
||||
const prev = fileAllResolved.get(filePath);
|
||||
fileAllResolved.set(filePath, (prev ?? true) && handlerSymbolId.length > 0);
|
||||
}
|
||||
// else: multiple candidates + unknown method → leave match
|
||||
// undefined so handlerName stays null and skip symbol
|
||||
// enrichment below, keeping the file-basename fallback instead
|
||||
// of letting pickSymbolUid silently pick the first Function /
|
||||
// Method in the file (which reintroduces the mis-attribution
|
||||
// we were trying to avoid). Method stays at the conservative
|
||||
// 'GET' default set below.
|
||||
if (match) {
|
||||
if (!method) method = match.method;
|
||||
handlerName = match.name;
|
||||
}
|
||||
if (!method) method = 'GET';
|
||||
|
||||
const pathNorm = normalizeHttpPath(routePath);
|
||||
const cid = contractIdFor(method, pathNorm);
|
||||
const pathNormEarly = normalizeHttpPath(routePath);
|
||||
|
||||
let symbolUid = '';
|
||||
let symbolName = path.basename(filePath) || 'handler';
|
||||
let symPath = filePath;
|
||||
const fileId = row.fileId ?? row[0];
|
||||
if (fileId && !ambiguousCandidates) {
|
||||
try {
|
||||
const syms = await db(CONTAINS_QUERY, { fileId });
|
||||
if (syms.length > 0) {
|
||||
const picked = pickSymbolUid(syms, handlerName);
|
||||
symbolUid = picked.uid;
|
||||
symbolName = picked.name;
|
||||
symPath = picked.filePath || filePath;
|
||||
if (handlerSymbolId) {
|
||||
// Fast path (Part 2, #2138): the handler symbol was resolved during
|
||||
// ingestion and persisted on the Route node, so the uid is authoritative
|
||||
// and we SKIP the source-scan/parse the legacy path needed. Recover the
|
||||
// display name from the file's symbols via CONTAINING_QUERY (the correct
|
||||
// File-[DEFINES]->symbol edge — NOT CONTAINS, which is File->Folder).
|
||||
if (!method) method = 'GET';
|
||||
symbolUid = handlerSymbolId;
|
||||
if (filePath) {
|
||||
try {
|
||||
const syms = await db(CONTAINING_QUERY, { filePath });
|
||||
const hit = syms.find((s) => String(s.uid ?? s[0]) === handlerSymbolId);
|
||||
if (hit) {
|
||||
symbolName = String(hit.name ?? hit[1]) || symbolName;
|
||||
symPath = String(hit.filePath ?? hit[2]) || filePath;
|
||||
}
|
||||
} catch {
|
||||
/* keep the authoritative uid + basename fallback */
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
// Legacy fallback (old index / unresolved handler): recover the handler
|
||||
// from the plugin's scan and resolve it to a real symbol by name (the
|
||||
// handler/method name) or, for an inline handler, by line-span containment
|
||||
// — both over File-[DEFINES]->symbol via resolveSymbol. No CONTAINS /
|
||||
// pickSymbolUid: CONTAINS is File->Folder and the old first-symbol guess
|
||||
// could win the contractId merge with a wrong uid.
|
||||
const detections = filePath ? await getDetections(filePath) : [];
|
||||
const providerDetections = detections.filter((d) => d.role === 'provider');
|
||||
// Candidates share the same normalized path. When multiple detections at
|
||||
// the same path exist (GET + POST /api/orders in one router), a blind
|
||||
// `.find()` silently returned the first verb — attaching the wrong
|
||||
// handler/method. Disambiguate by method when known; refuse to guess.
|
||||
const candidates = providerDetections.filter(
|
||||
(d) => normalizeHttpPath(d.path) === pathNormEarly,
|
||||
);
|
||||
let match: (typeof candidates)[number] | undefined;
|
||||
const ambiguousCandidates = !method && candidates.length > 1;
|
||||
if (method) {
|
||||
match = candidates.find((d) => d.method === method);
|
||||
} else if (candidates.length === 1) {
|
||||
match = candidates[0];
|
||||
}
|
||||
// else: multiple candidates + unknown method → leave match undefined and
|
||||
// skip symbol enrichment, keeping the file-basename fallback rather than
|
||||
// guessing the wrong handler.
|
||||
if (match && !method) method = match.method;
|
||||
if (!method) method = 'GET';
|
||||
const resolved =
|
||||
match && !ambiguousCandidates ? await resolveSymbol(filePath, match) : null;
|
||||
if (resolved) {
|
||||
symbolUid = resolved.uid;
|
||||
symbolName = resolved.name;
|
||||
symPath = resolved.filePath || filePath;
|
||||
}
|
||||
}
|
||||
|
||||
const pathNorm = pathNormEarly;
|
||||
const cid = contractIdFor(method, pathNorm);
|
||||
|
||||
out.push({
|
||||
contractId: cid,
|
||||
type: 'http',
|
||||
@@ -432,6 +754,18 @@ export class HttpRouteExtractor implements ContractExtractor {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Populate the parse-skip coverage set: files whose every provider route
|
||||
// resolved a handler symbol AND whose language declares complete ingestion
|
||||
// route coverage. Fail-open — any unresolved row or a 'partial' language
|
||||
// leaves the file out, so it still gets a full source scan.
|
||||
if (coveredFiles) {
|
||||
for (const [fp, allResolved] of fileAllResolved) {
|
||||
if (allResolved && getPluginForFile(fp)?.routeCoverage === 'complete') {
|
||||
coveredFiles.add(fp);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -440,26 +774,35 @@ export class HttpRouteExtractor implements ContractExtractor {
|
||||
private async extractProvidersSourceScan(
|
||||
files: string[],
|
||||
getDetections: (rel: string) => Promise<HttpDetection[]>,
|
||||
resolveSymbol: (filePath: string, d: HttpDetection) => Promise<ResolvedSymbol | null>,
|
||||
): Promise<ExtractedContract[]> {
|
||||
const out: ExtractedContract[] = [];
|
||||
for (const rel of files) {
|
||||
const detections = await getDetections(rel);
|
||||
const filePath = normalizeRepoRelPath(rel);
|
||||
for (const d of detections) {
|
||||
if (d.role !== 'provider') continue;
|
||||
const pathNorm = normalizeHttpPath(d.path);
|
||||
// Resolve the handler to a real symbol (named handler, or the inline
|
||||
// arrow that encloses the registration line) so the contract carries a
|
||||
// real symbolUid; fall back to the file + detection name otherwise.
|
||||
const resolved = await resolveSymbol(filePath, d);
|
||||
out.push({
|
||||
contractId: contractIdFor(d.method, pathNorm),
|
||||
type: 'http',
|
||||
role: 'provider',
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: rel, name: d.name ?? 'handler' },
|
||||
symbolName: d.name ?? 'handler',
|
||||
symbolUid: resolved?.uid ?? '',
|
||||
symbolRef: {
|
||||
filePath: resolved?.filePath || filePath,
|
||||
name: resolved?.name ?? d.name ?? 'handler',
|
||||
},
|
||||
symbolName: resolved?.name ?? d.name ?? 'handler',
|
||||
confidence: d.confidence,
|
||||
meta: {
|
||||
method: d.method,
|
||||
path: pathNorm,
|
||||
pathSegments: pathNorm.split('/').filter(Boolean),
|
||||
extractionStrategy: 'source_scan',
|
||||
extractionStrategy: resolved ? 'source_scan_resolved' : 'source_scan',
|
||||
framework: d.framework,
|
||||
},
|
||||
});
|
||||
@@ -473,6 +816,7 @@ export class HttpRouteExtractor implements ContractExtractor {
|
||||
private async extractConsumersGraph(
|
||||
db: CypherExecutor,
|
||||
getDetections: (rel: string) => Promise<HttpDetection[]>,
|
||||
resolveSymbol: (filePath: string, d: HttpDetection) => Promise<ResolvedSymbol | null>,
|
||||
): Promise<ExtractedContract[]> {
|
||||
const out: ExtractedContract[] = [];
|
||||
let rows: Record<string, unknown>[];
|
||||
@@ -512,19 +856,19 @@ export class HttpRouteExtractor implements ContractExtractor {
|
||||
let symbolUid = '';
|
||||
let symbolName = 'fetch';
|
||||
let symPath = filePath;
|
||||
const fileId = row.fileId ?? row[0];
|
||||
if (fileId) {
|
||||
try {
|
||||
const syms = await db(CONTAINS_QUERY, { fileId });
|
||||
if (syms.length > 0) {
|
||||
const picked = pickSymbolUid(syms, null);
|
||||
symbolUid = picked.uid;
|
||||
symbolName = picked.name;
|
||||
symPath = picked.filePath || filePath;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Resolve the function CONTAINING the fetch by line-span. Do NOT fall back
|
||||
// to the old `pickSymbolUid(syms, null)` first-symbol-in-file guess: an
|
||||
// arbitrary wrong uid is worse than an empty one because it would win the
|
||||
// contractId merge over a correctly-resolved source-scan contract (and the
|
||||
// empty case degrades to the file-level boundary fallback downstream).
|
||||
const resolved =
|
||||
consumerCandidates.length === 1
|
||||
? await resolveSymbol(filePath, consumerCandidates[0])
|
||||
: null;
|
||||
if (resolved) {
|
||||
symbolUid = resolved.uid;
|
||||
symbolName = resolved.name;
|
||||
symPath = resolved.filePath || filePath;
|
||||
}
|
||||
out.push({
|
||||
contractId: cid,
|
||||
@@ -550,25 +894,31 @@ export class HttpRouteExtractor implements ContractExtractor {
|
||||
private async extractConsumersSourceScan(
|
||||
files: string[],
|
||||
getDetections: (rel: string) => Promise<HttpDetection[]>,
|
||||
resolveSymbol: (filePath: string, d: HttpDetection) => Promise<ResolvedSymbol | null>,
|
||||
): Promise<ExtractedContract[]> {
|
||||
const out: ExtractedContract[] = [];
|
||||
for (const rel of files) {
|
||||
const detections = await getDetections(rel);
|
||||
const filePath = normalizeRepoRelPath(rel);
|
||||
for (const d of detections) {
|
||||
if (d.role !== 'consumer') continue;
|
||||
const pathNorm = normalizeConsumerPath(d.path);
|
||||
// Resolve the function CONTAINING the fetch/axios call so the consumer
|
||||
// contract carries a real symbolUid (was always '' — the gap that left
|
||||
// cross-repo trace/impact unable to traverse HTTP links).
|
||||
const resolved = await resolveSymbol(filePath, d);
|
||||
out.push({
|
||||
contractId: contractIdFor(d.method, pathNorm),
|
||||
type: 'http',
|
||||
role: 'consumer',
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: rel, name: 'fetch' },
|
||||
symbolName: 'fetch',
|
||||
symbolUid: resolved?.uid ?? '',
|
||||
symbolRef: { filePath: resolved?.filePath || filePath, name: resolved?.name ?? 'fetch' },
|
||||
symbolName: resolved?.name ?? 'fetch',
|
||||
confidence: d.confidence,
|
||||
meta: {
|
||||
method: d.method,
|
||||
path: pathNorm,
|
||||
extractionStrategy: 'source_scan',
|
||||
extractionStrategy: resolved ? 'source_scan_resolved' : 'source_scan',
|
||||
framework: d.framework,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,6 +7,21 @@ export interface ManifestExtractResult {
|
||||
crossLinks: CrossLink[];
|
||||
}
|
||||
|
||||
// Repo-wide symbol lookup for `custom` workspace contracts. Exported so the
|
||||
// #2325 integration test can run the EXACT production query against a real
|
||||
// LadybugDB — a hand-copied query string in the test would silently drift
|
||||
// from this allowlist. Uses the `labels(n) IN [...]` allowlist form rather
|
||||
// than a `MATCH (n:A|B)` disjunction: this 21-label list contains the
|
||||
// reserved-keyword labels `Macro` and `Union`, and LadybugDB's parser rejects
|
||||
// a disjunction that names a reserved keyword (#2325) — which the resolver's
|
||||
// try/catch then swallowed. `labels(n) IN` has no such collision.
|
||||
export const CUSTOM_CONTRACT_RESOLVE_QUERY = `MATCH (n)
|
||||
WHERE labels(n) IN ['Function','Method','Class','Interface','Struct','Enum','Trait','Constructor','TypeAlias','Impl','Macro','Union','Typedef','Property','Record','Delegate','Annotation','Template','Const','Static','CodeElement']
|
||||
AND n.name = $symbolName
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
ORDER BY n.filePath ASC
|
||||
LIMIT 1`;
|
||||
|
||||
/**
|
||||
* Canonicalize an HTTP path for matching against Route.name in the graph.
|
||||
* Mirrors core/ingestion/pipeline.ts ensureSlash semantics:
|
||||
@@ -189,13 +204,27 @@ export class ManifestExtractor {
|
||||
// Cross-impact still works: the bridge query joins on the synthetic
|
||||
// uid, and the local impact engine derives the same uid for the
|
||||
// unresolved symbol — name-based hints are the additional safety net.
|
||||
//
|
||||
// Label filtering uses `MATCH (n) WHERE labels(n) IN [...]`, NOT the
|
||||
// openCypher disjunction `MATCH (n:A|B|C)`. LadybugDB's parser rejects a
|
||||
// disjunction that names a reserved keyword (`Macro` and `Union` both are)
|
||||
// OR a label with no node table (e.g. the old `lib` branch's `Package`).
|
||||
// The `custom` branch (reserved keywords in its list) and `lib` branch
|
||||
// (missing `Package` table) genuinely threw (#2325) and the whole try/catch
|
||||
// below swallowed it; the other branches parsed but use the same form for
|
||||
// consistency and future-proofing. `labels(n)` returns the node's single
|
||||
// label as a string here, so `IN [...]` is an exact allowlist that includes
|
||||
// listed labels and excludes everything else — and is immune to both
|
||||
// failure modes (no keyword collision; an unknown label is just a non-match).
|
||||
try {
|
||||
let rows: Record<string, unknown>[];
|
||||
if (link.type === 'http') {
|
||||
// Route.name is the canonicalized URL path (see
|
||||
// core/ingestion/pipeline.ts ensureSlash + generateId('Route', ...)).
|
||||
// Normalize the manifest contract the same way so a user-written
|
||||
// "/api/orders" matches "api/orders" in the graph.
|
||||
// Route.name is the canonicalized URL path. Since #2289 a Route node's
|
||||
// *id* is `(method, url)`-composite (`routeNodeKey`), but `route.name`
|
||||
// continues to carry the bare URL so URL-keyed group queries like this
|
||||
// one keep working without a schema change. Normalize the manifest
|
||||
// contract the same way so a user-written "/api/orders" matches
|
||||
// "api/orders" in the graph.
|
||||
//
|
||||
// The contract may also use the explicit-method form "GET::/api/orders"
|
||||
// recommended by buildContractId. Strip the METHOD:: prefix before
|
||||
@@ -220,7 +249,7 @@ export class ManifestExtractor {
|
||||
// avoid cross-matching Files/Variables/Imports that happen to
|
||||
// share the topic name.
|
||||
rows = await executor(
|
||||
`MATCH (n:Function|Method|Class|Interface) WHERE n.name = $contract
|
||||
`MATCH (n) WHERE labels(n) IN ['Function','Method','Class','Interface'] AND n.name = $contract
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
ORDER BY n.filePath ASC
|
||||
LIMIT 1`,
|
||||
@@ -244,7 +273,7 @@ export class ManifestExtractor {
|
||||
const methodName = parts[1]?.trim() ?? '';
|
||||
if (methodName) {
|
||||
rows = await executor(
|
||||
`MATCH (n:Function|Method) WHERE n.name = $methodName
|
||||
`MATCH (n) WHERE labels(n) IN ['Function','Method'] AND n.name = $methodName
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
ORDER BY n.filePath ASC
|
||||
LIMIT 1`,
|
||||
@@ -252,7 +281,7 @@ export class ManifestExtractor {
|
||||
);
|
||||
} else if (serviceName) {
|
||||
rows = await executor(
|
||||
`MATCH (n:Class|Interface) WHERE n.name = $serviceName
|
||||
`MATCH (n) WHERE labels(n) IN ['Class','Interface'] AND n.name = $serviceName
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
ORDER BY n.filePath ASC
|
||||
LIMIT 1`,
|
||||
@@ -264,11 +293,12 @@ export class ManifestExtractor {
|
||||
} else if (link.type === 'lib') {
|
||||
// Only exact match on the symbol's name. Previous fallback to
|
||||
// CONTAINS on n.filePath would promote "react" to "react-native"
|
||||
// or "@types/react" — silent wrong attribution. Restrict to
|
||||
// package-level labels so we don't return arbitrary symbols
|
||||
// named after a library.
|
||||
// or "@types/react" — silent wrong attribution. Restrict to the
|
||||
// package-level `Module` label so we don't return arbitrary symbols
|
||||
// named after a library. (There is no `Package` node table — see
|
||||
// NODE_TABLES — so a `Package` entry only ever matched nothing.)
|
||||
rows = await executor(
|
||||
`MATCH (n:Package|Module) WHERE n.name = $contract
|
||||
`MATCH (n) WHERE labels(n) IN ['Module'] AND n.name = $contract
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
ORDER BY n.filePath ASC
|
||||
LIMIT 1`,
|
||||
@@ -289,14 +319,7 @@ export class ManifestExtractor {
|
||||
const symbolName = link.contract.includes('::')
|
||||
? link.contract.split('::').pop()!
|
||||
: link.contract;
|
||||
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 = $symbolName
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
ORDER BY n.filePath ASC
|
||||
LIMIT 1`,
|
||||
{ symbolName },
|
||||
);
|
||||
rows = await executor(CUSTOM_CONTRACT_RESOLVE_QUERY, { symbolName });
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,79 @@ export interface GroupToolPort {
|
||||
include_content?: boolean;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
// ── Cross-repo trace support (optional on the port) ────────────────
|
||||
// These are optional so existing GroupToolPort test mocks (which predate
|
||||
// the trace path and only stub impact/query/context/impactByUid) keep
|
||||
// type-checking. The real LocalBackend port supplies all three; runGroupTrace
|
||||
// guards on their presence and degrades to a clear error/note when absent.
|
||||
//
|
||||
// Single-repo directed-path trace over CALLS + HAS_METHOD. Returns the same
|
||||
// shape as the `trace` MCP tool (`{ status, from, to, hopCount, hops, edges }`).
|
||||
trace?(
|
||||
repo: GroupRepoHandle,
|
||||
params: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
from_uid?: string;
|
||||
to_uid?: string;
|
||||
from_file?: string;
|
||||
to_file?: string;
|
||||
maxDepth?: number;
|
||||
includeTests?: boolean;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
// Resolve a symbol within one repo to its node id (== bridge symbolUid) and
|
||||
// location, or report ambiguity / absence. Wraps the same resolver the
|
||||
// context()/trace() tools use.
|
||||
resolveSymbol?(
|
||||
repo: GroupRepoHandle,
|
||||
query: { name?: string; uid?: string; file_path?: string },
|
||||
): Promise<GroupSymbolResolution>;
|
||||
// Intra-procedural REACHING_DEF data-flow from an anchor symbol, used to
|
||||
// enrich a boundary-adjacent trace segment. `available:false` signals the
|
||||
// repo has no PDG `flows` layer (degraded, not an error).
|
||||
pdgFlows?(
|
||||
repo: GroupRepoHandle,
|
||||
anchor: { name?: string; uid?: string; file_path?: string },
|
||||
opts: { limit?: number },
|
||||
): Promise<GroupPdgFlowResult>;
|
||||
}
|
||||
|
||||
export type GroupSymbolResolution =
|
||||
| {
|
||||
kind: 'ok';
|
||||
symbol: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
filePath: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
kind: 'ambiguous';
|
||||
candidates: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
filePath: string;
|
||||
startLine: number;
|
||||
}>;
|
||||
}
|
||||
| { kind: 'not_found' };
|
||||
|
||||
export interface GroupPdgFlowHop {
|
||||
line: number;
|
||||
text: string;
|
||||
variable?: string;
|
||||
}
|
||||
|
||||
export interface GroupPdgFlowResult {
|
||||
available: boolean;
|
||||
variable?: string;
|
||||
hops: GroupPdgFlowHop[];
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
function isStoredContract(raw: unknown): raw is StoredContract {
|
||||
@@ -313,6 +386,11 @@ export class GroupService {
|
||||
return runGroupImpact({ port: this.port, gitnexusDir: getDefaultGitnexusDir() }, params);
|
||||
}
|
||||
|
||||
async groupTrace(params: Record<string, unknown>): Promise<unknown> {
|
||||
const { runGroupTrace } = await import('./cross-trace.js');
|
||||
return runGroupTrace({ port: this.port, gitnexusDir: getDefaultGitnexusDir() }, params);
|
||||
}
|
||||
|
||||
async groupContext(params: Record<string, unknown>): Promise<GroupContextResult> {
|
||||
const name = String(params.name ?? '').trim();
|
||||
const target = typeof params.target === 'string' ? params.target.trim() : '';
|
||||
|
||||
@@ -192,6 +192,13 @@ export interface BridgeHandle {
|
||||
readonly _db: unknown;
|
||||
readonly _conn: unknown;
|
||||
readonly groupDir: string;
|
||||
/**
|
||||
* True when the handle was opened read-only. `closeBridgeDb` must NOT issue a
|
||||
* CHECKPOINT on a read-only connection — doing so leaves a WAL/shadow lock
|
||||
* artifact that makes the next read-only open of the same file fail in-process
|
||||
* (repeated `@group` impact/trace calls in a long-lived server).
|
||||
*/
|
||||
readonly _readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface BridgeMeta {
|
||||
|
||||
@@ -68,8 +68,21 @@ const isGraphWide = (label: string): boolean => label === 'Community' || label =
|
||||
// re-included from the FULL fresh graph (which the emit phase recomputes every
|
||||
// run) or an unchanged function's summary would be lost. Cheap: one self-loop
|
||||
// edge per return-flowing function.
|
||||
//
|
||||
// `INJECTS` (DI collection injection, #2200) is the same class as TAINT_PATH
|
||||
// (the #2084 M4 U6 pattern above): its validity is a whole-program property —
|
||||
// a change to a THIRD file (the interface itself, or a new/removed
|
||||
// implementer) creates or invalidates edges between two files that were never
|
||||
// touched, so the endpoint-writability rule would strand a stale
|
||||
// consumer→implementer edge (or miss a new one). Always re-extracted from the
|
||||
// fresh graph; the orchestrator unconditionally delete-alls the old rows
|
||||
// first (`deleteAllInjects`). Crash-recovery: delete-then-COPY is not atomic
|
||||
// by design — a crash between them loses INJECTS edges until the next
|
||||
// analyze, and the `incrementalInProgress` dirty flag (saved before any
|
||||
// delete) forces a full rebuild on the next run. Temporary absence is
|
||||
// possible; duplicates are not.
|
||||
const isGraphWideRelType = (type: string): boolean =>
|
||||
type === 'TAINT_PATH' || type === 'CALL_SUMMARY';
|
||||
type === 'TAINT_PATH' || type === 'CALL_SUMMARY' || type === 'INJECTS';
|
||||
|
||||
/**
|
||||
* Build a Map<nodeId, filePath> for every File-bound node in the graph.
|
||||
|
||||
@@ -21,7 +21,13 @@ import { generateId } from '../../lib/utils.js';
|
||||
import type { SymbolDefinition } from 'gitnexus-shared';
|
||||
import { yieldToEventLoop } from './utils/event-loop.js';
|
||||
import type { ExtractedRoute, ExtractedFetchCall } from './workers/parse-worker.js';
|
||||
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
||||
import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js';
|
||||
import {
|
||||
normalizeExtractedRoutePath,
|
||||
normalizeRouteMethod,
|
||||
routeNodeKey,
|
||||
} from './route-extractors/route-path.js';
|
||||
import { extractReturnTypeName } from './type-extractors/shared.js';
|
||||
|
||||
const MAX_EXPORTS_PER_FILE = 500;
|
||||
@@ -243,6 +249,93 @@ export const processRoutesFromExtracted = async (
|
||||
onProgress?.(extractedRoutes.length, extractedRoutes.length);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve each route's handler to a real symbol UID, keyed by the route's
|
||||
* `(method, url)` identity (`routeNodeKey` — the same key the routes phase uses
|
||||
* for the `Route` node). This is the Part 2 (#2138) groundwork that lets
|
||||
* `HttpRouteExtractor.extractProvidersGraph` read the handler symbol from the
|
||||
* graph instead of re-parsing source via `getDetections()`.
|
||||
*
|
||||
* Two route shapes, one resolution target — `(filePath, name) → nodeId`:
|
||||
* - Laravel framework routes (`ExtractedRoute`) carry `controllerName` +
|
||||
* `methodName`; resolve the controller (qualified-first) then the method in
|
||||
* the controller's own file (mirrors `processRoutesFromExtracted`).
|
||||
* - Decorator routes (`ExtractedDecoratorRoute`, e.g. Spring/FastAPI) carry
|
||||
* `handlerName` (the decorated method, captured at extraction); resolve it
|
||||
* directly in the route's own file.
|
||||
*
|
||||
* First-writer-wins per route identity, matching the routes phase's dedup (it
|
||||
* keeps the first route registered for a `(method, url)` key and counts the rest
|
||||
* as duplicates). The first route to claim a key reserves it **even when its
|
||||
* handler is unresolvable**, so a later same-key route can never stamp its
|
||||
* handler onto the first route's Route node (the routes phase made that first
|
||||
* route the node-winner). Keying is `routeNodeKey(method, url)` (#2289): a
|
||||
* same-URL multi-verb pair (`GET /x` + `POST /x`) resolves two handlers, one per
|
||||
* node; method-less / wildcard routes key by URL alone, byte-identical to the
|
||||
* pre-#2289 behavior. Routes whose handler cannot be *uniquely* resolved (no
|
||||
* name, zero matches, or an ambiguous same-name match) carry no
|
||||
* `handlerSymbolId`; the extractor then falls back to source scan for that route
|
||||
* (fail-open, no regression, never a wrong handler).
|
||||
*/
|
||||
export function resolveRouteHandlerSymbols(
|
||||
model: SemanticModel,
|
||||
extractedRoutes: readonly ExtractedRoute[],
|
||||
decoratorRoutes: readonly ExtractedDecoratorRoute[],
|
||||
): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
// Route identities already claimed by an earlier route (resolved or not).
|
||||
// Mirrors the routes phase `addRoute` first-writer-wins so the handler we
|
||||
// stamp always belongs to the route that actually won the Route node.
|
||||
const claimed = new Set<string>();
|
||||
|
||||
// Resolve a single same-file symbol by name, refusing to guess on ambiguity:
|
||||
// exactly one match → its nodeId; zero or many → undefined (fail-open).
|
||||
const uniqueSymbolId = (filePath: string, name: string): string | undefined => {
|
||||
const defs = model.symbols.lookupExactAll(filePath, name);
|
||||
return defs.length === 1 ? defs[0]?.nodeId : undefined;
|
||||
};
|
||||
|
||||
const claim = (
|
||||
routePath: string | null,
|
||||
prefix: string | null,
|
||||
httpMethod: string | null | undefined,
|
||||
symbolId: string | undefined,
|
||||
) => {
|
||||
if (!routePath) return;
|
||||
const url = normalizeExtractedRoutePath(routePath, prefix);
|
||||
const key = routeNodeKey(normalizeRouteMethod(httpMethod), url);
|
||||
if (claimed.has(key)) return; // first-writer-wins: later same-key routes can't override
|
||||
claimed.add(key);
|
||||
if (symbolId) out.set(key, symbolId);
|
||||
};
|
||||
|
||||
// Laravel framework routes — controller class + method name.
|
||||
for (const route of extractedRoutes) {
|
||||
let methodId: string | undefined;
|
||||
if (route.controllerName && route.methodName) {
|
||||
let controllerDef: SymbolDefinition | undefined;
|
||||
if (route.controllerQualifiedName) {
|
||||
controllerDef = resolveControllerByQualifiedName(model, route.controllerQualifiedName);
|
||||
}
|
||||
if (!controllerDef) {
|
||||
const controllerDefs = model.types.lookupClassByName(route.controllerName);
|
||||
if (controllerDefs.length === 1) controllerDef = controllerDefs[0];
|
||||
}
|
||||
if (controllerDef) methodId = uniqueSymbolId(controllerDef.filePath, route.methodName);
|
||||
}
|
||||
claim(route.routePath, route.prefix ?? null, route.httpMethod, methodId);
|
||||
}
|
||||
|
||||
// Decorator routes (Spring / FastAPI / generic) — the decorated handler in
|
||||
// the route's own file.
|
||||
for (const dr of decoratorRoutes) {
|
||||
const handlerId = dr.handlerName ? uniqueSymbolId(dr.filePath, dr.handlerName) : undefined;
|
||||
claim(dr.routePath, dr.prefix ?? null, dr.httpMethod, handlerId);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Common method names on response/data objects that are NOT property accesses */
|
||||
// Properties/methods to ignore when extracting consumer accessed keys from `data.X` patterns.
|
||||
// Avoids false positives from Fetch API, Array, Object, Promise, and DOM access on variables
|
||||
@@ -386,19 +479,29 @@ export const extractConsumerAccessedKeys = (content: string): string[] => {
|
||||
* Create FETCHES edges from extracted fetch() calls to matching Route nodes.
|
||||
* When consumerContents is provided, extracts property access patterns from
|
||||
* consumer files and encodes them in the edge reason field.
|
||||
*
|
||||
* Matching stays URL-only (#2289): a verb-less consumer (a `fetch()` call has
|
||||
* no statically-known HTTP method) matches a route by URL and connects to
|
||||
* **every** Route node sharing that URL — i.e. both the `GET /x` and `POST /x`
|
||||
* nodes when a URL carries multiple verbs. `routeUrlToKeys` therefore maps each
|
||||
* route URL to the list of `routeNodeKey` identities at that URL; a single-verb
|
||||
* (or method-less) URL has a one-element list, keeping edges byte-identical to
|
||||
* the pre-#2289 behavior.
|
||||
*/
|
||||
export const processNextjsFetchRoutes = (
|
||||
graph: KnowledgeGraph,
|
||||
fetchCalls: ExtractedFetchCall[],
|
||||
routeRegistry: Map<string, string>, // routeURL → handlerFilePath
|
||||
routeUrlToKeys: Map<string, string[]>, // routeURL → route node keys at that URL
|
||||
consumerContents?: Map<string, string>, // filePath → file content
|
||||
) => {
|
||||
// Pre-count how many routes each consumer file matches (for confidence attribution)
|
||||
// Pre-count how many route URLs each consumer file matches (for confidence
|
||||
// attribution). Counts once per call that matches any URL — independent of how
|
||||
// many verbs share that URL — so the multi-fetch heuristic is unchanged.
|
||||
const routeCountByFile = new Map<string, number>();
|
||||
for (const call of fetchCalls) {
|
||||
const normalized = normalizeFetchURL(call.fetchURL);
|
||||
if (!normalized) continue;
|
||||
for (const [routeURL] of routeRegistry) {
|
||||
for (const routeURL of routeUrlToKeys.keys()) {
|
||||
if (routeMatches(normalized, routeURL)) {
|
||||
routeCountByFile.set(call.filePath, (routeCountByFile.get(call.filePath) ?? 0) + 1);
|
||||
break;
|
||||
@@ -410,10 +513,9 @@ export const processNextjsFetchRoutes = (
|
||||
const normalized = normalizeFetchURL(call.fetchURL);
|
||||
if (!normalized) continue;
|
||||
|
||||
for (const [routeURL] of routeRegistry) {
|
||||
for (const [routeURL, routeKeys] of routeUrlToKeys) {
|
||||
if (routeMatches(normalized, routeURL)) {
|
||||
const sourceId = generateId('File', call.filePath);
|
||||
const routeNodeId = generateId('Route', routeURL);
|
||||
|
||||
// Extract consumer accessed keys if file content is available
|
||||
let reason = 'fetch-url-match';
|
||||
@@ -433,14 +535,18 @@ export const processNextjsFetchRoutes = (
|
||||
reason = `${reason}|fetches:${fetchCount}`;
|
||||
}
|
||||
|
||||
graph.addRelationship({
|
||||
id: generateId('FETCHES', `${sourceId}->${routeNodeId}`),
|
||||
sourceId,
|
||||
targetId: routeNodeId,
|
||||
type: 'FETCHES',
|
||||
confidence: 0.9,
|
||||
reason,
|
||||
});
|
||||
// Connect to every Route node at this URL (one per verb).
|
||||
for (const routeKey of routeKeys) {
|
||||
const routeNodeId = generateId('Route', routeKey);
|
||||
graph.addRelationship({
|
||||
id: generateId('FETCHES', `${sourceId}->${routeNodeId}`),
|
||||
sourceId,
|
||||
targetId: routeNodeId,
|
||||
type: 'FETCHES',
|
||||
confidence: 0.9,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Per-language DI field-matcher registry — the lookup the generic `di`
|
||||
* pipeline phase uses to decide whether a `Property` node is a
|
||||
* dependency-injection fan-out candidate.
|
||||
*
|
||||
* Mirrors `scope-resolution/pipeline/registry.ts` (`SCOPE_RESOLVERS`): a
|
||||
* single-valued `ReadonlyMap<SupportedLanguages, DiFieldMatcher>` consumed by
|
||||
* a framework-neutral phase, so no language or framework names leak into
|
||||
* shared pipeline code. Adding a framework is two lines: implement a
|
||||
* `DiFieldMatcher` in `di-extractors/<framework>.ts` and register it here.
|
||||
*
|
||||
* Scope honesty: matchers are per-language *field-injection* matchers.
|
||||
* Constructor injection (the dominant modern Spring idiom) lives on
|
||||
* Method/parameter nodes and would require widening the phase's routing —
|
||||
* deliberately out of scope (see the plan's Deferred work). The registry is
|
||||
* single-valued per language, matching the `SCOPE_RESOLVERS` shape; widen the
|
||||
* value type to arrays only when a second same-language framework actually
|
||||
* lands (a one-line type change then).
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import { springDiFieldMatcher } from './spring.js';
|
||||
|
||||
/** A successful DI field match, produced by a per-language matcher. */
|
||||
export interface DiFieldMatch {
|
||||
/** The element type name `T` — the injected bean interface. */
|
||||
elementTypeName: string;
|
||||
/** Human-readable edge reason. Framework specifics (names, idioms,
|
||||
* collection wrapper, gating annotation) live in this payload so the
|
||||
* shared `di` phase stays framework-neutral. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-language field-injection matcher: given a `Property` node, return the
|
||||
* parsed DI match or `null` when the field is not container-injected. The
|
||||
* matcher receives the whole node (not pre-plucked fields) so the shared
|
||||
* phase stays ignorant of which properties matter.
|
||||
*/
|
||||
export type DiFieldMatcher = (node: GraphNode) => DiFieldMatch | null;
|
||||
|
||||
/** All `SupportedLanguages` string values, for narrowing raw graph strings. */
|
||||
const SUPPORTED_LANGUAGE_VALUES: ReadonlySet<string> = new Set(Object.values(SupportedLanguages));
|
||||
|
||||
/**
|
||||
* Type guard narrowing an arbitrary graph `language` string to
|
||||
* `SupportedLanguages`, so `DI_MATCHERS.get()` needs no cast.
|
||||
*/
|
||||
export function isSupportedLanguage(value: string): value is SupportedLanguages {
|
||||
return SUPPORTED_LANGUAGE_VALUES.has(value);
|
||||
}
|
||||
|
||||
/** Map of `SupportedLanguages` → `DiFieldMatcher`. The `di` phase routes each
|
||||
* `Property` node here by `node.properties.language`; no entry ⇒ the node is
|
||||
* skipped. This is the single source of truth for which languages (and,
|
||||
* transitively, frameworks) produce INJECTS edges. */
|
||||
export const DI_MATCHERS: ReadonlyMap<SupportedLanguages, DiFieldMatcher> = new Map<
|
||||
SupportedLanguages,
|
||||
DiFieldMatcher
|
||||
>([[SupportedLanguages.Java, springDiFieldMatcher]]);
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Spring dependency-injection field matcher for the generic `di` phase.
|
||||
*
|
||||
* Recognizes the fields Spring's container fills via collect-all-implementers
|
||||
* collection injection: when a Java class declares a field carrying an
|
||||
* injection annotation (`@Autowired` or `@Inject`) typed as `List<T>`,
|
||||
* `Set<T>`, `Collection<T>`, or `Map<K,T>`, the container injects EVERY bean
|
||||
* implementing interface `T`. The matcher reports the element type name `T`
|
||||
* plus a human-readable reason naming the collection wrapper and the
|
||||
* annotation that gated the match; the shared `di` phase turns that into
|
||||
* `INJECTS` edges.
|
||||
*
|
||||
* The injection annotation is a hard precondition: a plain (non-annotated)
|
||||
* collection field is never injected by the container and produces no match.
|
||||
* `@Resource` (JSR-250) is DELIBERATELY excluded: it resolves by bean NAME
|
||||
* first (defaulting to the field name), which injects a single named
|
||||
* collection bean — the opposite of the collect-all-implementers fan-out
|
||||
* INJECTS models. Including it would emit false edges.
|
||||
*
|
||||
* Matching happens on `rawDeclaredType` (the verbatim type text, generics
|
||||
* preserved) — NOT `declaredType`, which is generics-stripped by design
|
||||
* (`List<Shape>` → `List`) and can never match the collection patterns.
|
||||
*
|
||||
* Accepted type shapes (after whitespace normalization — internal runs of
|
||||
* whitespace, including newlines from multi-line declarations, collapse to a
|
||||
* single space):
|
||||
* - `List<T>` / `Set<T>` / `Collection<T>` — element `T`.
|
||||
* - `Map<K, T>` — element is the VALUE type `T`; the key `K` is irrelevant
|
||||
* for DI resolution and may itself be generic (`Map<Pair<A,B>, T>` — the
|
||||
* top-level-comma split is bracket-depth-aware, so nested commas in the
|
||||
* key never bleed into the element).
|
||||
* - Bounded wildcards `List<? extends T>` / `List<? super T>` — element `T`
|
||||
* (both are idiomatic Spring collection injection; the container still
|
||||
* collects every implementer of `T`).
|
||||
* - Package-qualified wrappers `java.util.List<T>` — the wrapper is
|
||||
* recognized by its LAST dotted segment. The ELEMENT keeps its dots
|
||||
* (`List<com.a.Shape>` → `com.a.Shape`): dotted element names resolve via
|
||||
* `qualifiedName` downstream in the `di` phase.
|
||||
*
|
||||
* Documented REJECTIONS (parse returns `null` — no INJECTS edges):
|
||||
* - `Map<String, List<IFoo>>` — the element itself is generic; a nested
|
||||
* generic is not resolvable as a single interface.
|
||||
* - `List<?>` — unbounded wildcard; there is no element type to fan out to.
|
||||
* - Arrays: `IFoo[]`, `List<IFoo>[]`, `List<IFoo[]>` — array injection is
|
||||
* not the collect-all-implementers shape INJECTS models.
|
||||
* - Non-collection types (`IFoo`, `Optional<IFoo>`, …) and wrong generic
|
||||
* arity (`Map<String>`, `List<A, B>`).
|
||||
* - Anything whose element is not a plain (possibly dotted) Java type name —
|
||||
* this makes the parser fail closed on unanticipated syntax. In particular
|
||||
* Java block comments inside the generic arguments (a `/* ... ` comment
|
||||
* between `<` and the element) are NOT stripped and fail closed —
|
||||
* acceptable.
|
||||
*
|
||||
* Registered under `SupportedLanguages.Java` in `./index.ts` (`DI_MATCHERS`);
|
||||
* language routing is the registry's job, so the matcher itself never reads
|
||||
* `node.properties.language`.
|
||||
*/
|
||||
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import type { DiFieldMatch, DiFieldMatcher } from './index.js';
|
||||
import { isDev } from '../utils/env.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
/**
|
||||
* Annotations that trigger Spring's collect-all-implementers collection
|
||||
* injection. `@Resource` is deliberately absent — JSR-250 resolves by bean
|
||||
* NAME first (defaulting to the field name), injecting a single named
|
||||
* collection bean rather than fanning out to every implementer, so an
|
||||
* INJECTS fan-out for it would be a false edge.
|
||||
*/
|
||||
const INJECTION_ANNOTATIONS: ReadonlySet<string> = new Set(['@Autowired', '@Inject']);
|
||||
|
||||
/** Collection wrappers whose generic element Spring fans out to every
|
||||
* implementer. `Map` is special-cased for arity (2 args, element = value). */
|
||||
const COLLECTION_WRAPPERS: ReadonlySet<string> = new Set(['List', 'Set', 'Collection', 'Map']);
|
||||
|
||||
/** Bounded-wildcard prefixes stripped from the element position (single-spaced
|
||||
* — the input is whitespace-normalized before these are checked). */
|
||||
const WILDCARD_EXTENDS_PREFIX = '? extends ';
|
||||
const WILDCARD_SUPER_PREFIX = '? super ';
|
||||
|
||||
/** A plain (possibly dotted) Java type name — the only element shape the
|
||||
* parser accepts. Everything else (wildcards, arrays, comments, stray
|
||||
* punctuation) fails closed. */
|
||||
const JAVA_TYPE_NAME_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/;
|
||||
|
||||
/**
|
||||
* Split a generic-argument list on TOP-LEVEL commas only, tracking `<`/`>`
|
||||
* bracket depth so nested generics (e.g. the `Pair<A,B>` key in
|
||||
* `Map<Pair<A,B>, IFoo>`) never split mid-argument.
|
||||
*
|
||||
* @returns the top-level argument segments (untrimmed), or `null` when the
|
||||
* brackets are unbalanced (fail closed on malformed input).
|
||||
*/
|
||||
function splitTopLevelGenericArgs(inner: string): string[] | null {
|
||||
const args: string[] = [];
|
||||
let depth = 0;
|
||||
let segmentStart = 0;
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
const ch = inner[i];
|
||||
if (ch === '<') {
|
||||
depth++;
|
||||
} else if (ch === '>') {
|
||||
depth--;
|
||||
if (depth < 0) return null;
|
||||
} else if (ch === ',' && depth === 0) {
|
||||
args.push(inner.slice(segmentStart, i));
|
||||
segmentStart = i + 1;
|
||||
}
|
||||
}
|
||||
if (depth !== 0) return null;
|
||||
args.push(inner.slice(segmentStart));
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the injected bean type name from one (whitespace-normalized)
|
||||
* generic-argument segment: strip a bounded-wildcard prefix, then require a
|
||||
* plain dotted Java type name.
|
||||
*
|
||||
* @returns the element type name, or `null` for unbounded wildcards, nested
|
||||
* generics, arrays, and any other non-type-name shape (fail closed).
|
||||
*/
|
||||
function parseElementTypeName(segment: string): string | null {
|
||||
let element = segment.trim();
|
||||
// Bounded wildcards are idiomatic collection injection: the container
|
||||
// still collects every implementer of the bound.
|
||||
if (element.startsWith(WILDCARD_EXTENDS_PREFIX)) {
|
||||
element = element.slice(WILDCARD_EXTENDS_PREFIX.length);
|
||||
} else if (element.startsWith(WILDCARD_SUPER_PREFIX)) {
|
||||
element = element.slice(WILDCARD_SUPER_PREFIX.length);
|
||||
}
|
||||
// Final gate: a plain (possibly dotted) type name. Rejects nested generics
|
||||
// (`Map<String, List<IFoo>>` — not resolvable as a single interface),
|
||||
// arrays (`List<IFoo[]>` — not the fan-out shape INJECTS models), the
|
||||
// unbounded wildcard `?`, un-stripped comments, and any other residue —
|
||||
// all documented rejections; fail closed.
|
||||
if (!JAVA_TYPE_NAME_PATTERN.test(element)) return null;
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Spring DI collection field's raw declared type (verbatim source
|
||||
* text, generics preserved) and return the injected bean type name.
|
||||
*
|
||||
* Whitespace-normalizes first (raw tree-sitter `.text` can span lines), then
|
||||
* recognizes the wrapper by the LAST dotted segment before the first `<`
|
||||
* (so `java.util.List<IFoo>` works), depth-aware-splits the generic argument
|
||||
* list, and validates the element position. See the module docstring for the
|
||||
* full accepted/rejected shape inventory.
|
||||
*
|
||||
* @returns the collection wrapper name + element type name, or `null` when
|
||||
* the raw declared type is not a recognized Spring collection shape.
|
||||
*/
|
||||
export function parseSpringCollectionType(
|
||||
rawDeclaredType: string,
|
||||
): { collectionType: string; elementTypeName: string } | null {
|
||||
// Collapse ALL internal whitespace runs (spaces, tabs, newlines from
|
||||
// multi-line declarations) to single spaces, then trim the ends.
|
||||
const normalized = rawDeclaredType.replace(/\s+/g, ' ').trim();
|
||||
const openIndex = normalized.indexOf('<');
|
||||
// No generic argument list, or trailing residue after the closing `>`
|
||||
// (e.g. the array suffix in `List<IFoo>[]`) — not a collection injection.
|
||||
if (openIndex === -1 || !normalized.endsWith('>')) return null;
|
||||
// Wrapper = last dotted segment of the pre-`<` text: strips a package
|
||||
// qualifier from the WRAPPER only (`java.util.List` → `List`).
|
||||
const wrapperPath = normalized.slice(0, openIndex).trim();
|
||||
const wrapperSegments = wrapperPath.split('.');
|
||||
const wrapper = wrapperSegments[wrapperSegments.length - 1];
|
||||
if (!COLLECTION_WRAPPERS.has(wrapper)) return null;
|
||||
const inner = normalized.slice(openIndex + 1, normalized.length - 1);
|
||||
const args = splitTopLevelGenericArgs(inner);
|
||||
if (args === null) return null;
|
||||
// List/Set/Collection take exactly one type argument; Map exactly two,
|
||||
// and the injected bean type is the VALUE (2nd argument) — the key is
|
||||
// irrelevant for DI resolution.
|
||||
const expectedArity = wrapper === 'Map' ? 2 : 1;
|
||||
if (args.length !== expectedArity) return null;
|
||||
const elementTypeName = parseElementTypeName(args[expectedArity - 1]);
|
||||
if (elementTypeName === null) return null;
|
||||
return { collectionType: wrapper, elementTypeName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a `Property` node against Spring's collection-injection shape.
|
||||
*
|
||||
* Returns the parsed match (with a Spring-specific human-readable `reason`
|
||||
* payload) or `null` when the field is not container-injected.
|
||||
*/
|
||||
export const springDiFieldMatcher: DiFieldMatcher = (node: GraphNode): DiFieldMatch | null => {
|
||||
// Injection-annotation gate: only fields the container actually
|
||||
// injects (@Autowired / @Inject) are candidates. Plain collection
|
||||
// fields are never injected; @Resource is deliberately excluded
|
||||
// (by-name-first semantics — see INJECTION_ANNOTATIONS).
|
||||
const matchedAnnotation = node.properties.annotations?.find((a) => INJECTION_ANNOTATIONS.has(a));
|
||||
if (matchedAnnotation === undefined) return null;
|
||||
// Match on rawDeclaredType ONLY — no `?? declaredType` fallback:
|
||||
// production `declaredType` is generics-stripped by design, so a
|
||||
// fallback can never match real data and would only mask plumbing
|
||||
// regressions as quiet no-ops.
|
||||
const rawDeclaredType = node.properties.rawDeclaredType;
|
||||
if (!rawDeclaredType) {
|
||||
// An injection-annotated field with NO rawDeclaredType means the
|
||||
// extraction plumbing broke its contract (U1 threads the raw type
|
||||
// wherever annotations are threaded) — surface it, don't silently drop.
|
||||
if (isDev) {
|
||||
logger.warn(
|
||||
`Spring DI: annotated field '${node.properties.name}' (${node.properties.filePath}) has no rawDeclaredType — extraction plumbing contract breach; skipping`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const parsed = parseSpringCollectionType(rawDeclaredType);
|
||||
if (!parsed) return null;
|
||||
return {
|
||||
elementTypeName: parsed.elementTypeName,
|
||||
// Honest reason: states the annotation actually found on the field and
|
||||
// the collection wrapper it gated. Framework specifics live HERE, in the
|
||||
// payload — never in the phase.
|
||||
reason: `Spring DI: ${matchedAnnotation} ${parsed.collectionType}<${parsed.elementTypeName}>`,
|
||||
};
|
||||
};
|
||||
@@ -48,6 +48,34 @@ export function hasModifier(node: SyntaxNode, modifierType: string, keyword: str
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect `'@Name'`-prefixed annotation names from a declaration node's
|
||||
* modifier-wrapper children (e.g. Java `modifiers`). Handles both
|
||||
* `marker_annotation` (`@Autowired`) and `annotation`
|
||||
* (`@Autowired(required=false)`) node types. Node-type-agnostic: works for
|
||||
* any declaration (method, field, ...) that groups annotations under a
|
||||
* wrapper child of type `modifierType`.
|
||||
*
|
||||
* Shared by the JVM method- and field-extractor configs (moved verbatim from
|
||||
* `method-extractors/configs/jvm.ts` in PR #2200 U2).
|
||||
*/
|
||||
export function extractAnnotations(node: SyntaxNode, modifierType: string): string[] {
|
||||
const annotations: string[] = [];
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child && child.type === modifierType) {
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const mod = child.namedChild(j);
|
||||
if (mod && (mod.type === 'marker_annotation' || mod.type === 'annotation')) {
|
||||
const nameNode = mod.childForFieldName('name') ?? mod.firstNamedChild;
|
||||
if (nameNode) annotations.push('@' + nameNode.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return annotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first matching visibility keyword found either as a direct keyword
|
||||
* child or inside a modifier wrapper node.
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { FieldExtractionConfig } from '../generic.js';
|
||||
import { findVisibility, hasKeyword, hasModifier, typeFromField } from './helpers.js';
|
||||
import {
|
||||
extractAnnotations,
|
||||
findVisibility,
|
||||
hasKeyword,
|
||||
hasModifier,
|
||||
typeFromField,
|
||||
} from './helpers.js';
|
||||
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
|
||||
import type { FieldVisibility } from '../../field-types.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
@@ -55,6 +61,20 @@ export const javaConfig: FieldExtractionConfig = {
|
||||
return undefined;
|
||||
},
|
||||
|
||||
extractRawType(node) {
|
||||
// Verbatim type-node text — preserves generic arguments (`List<Shape>`)
|
||||
// and qualifiers (`java.util.List<Shape>`) that extractType strips.
|
||||
// Precedent: the JVM method extractor keeps raw `.text` for the same
|
||||
// reason (method-extractors/configs/jvm.ts).
|
||||
return node.childForFieldName('type')?.text?.trim();
|
||||
},
|
||||
|
||||
extractAnnotations(node) {
|
||||
// Same walk the JVM method extractor uses — field annotations live under
|
||||
// the `modifiers` child of a `field_declaration` (e.g. `@Autowired`).
|
||||
return extractAnnotations(node, 'modifiers');
|
||||
},
|
||||
|
||||
extractVisibility(node) {
|
||||
return findVisibility(node, JAVA_VIS, 'package', 'modifiers');
|
||||
},
|
||||
|
||||
@@ -51,6 +51,19 @@ export interface FieldExtractionConfig {
|
||||
extractNames?: (node: SyntaxNode) => string[];
|
||||
/** Extract type annotation from a field declaration node */
|
||||
extractType: (node: SyntaxNode) => string | undefined;
|
||||
/**
|
||||
* Extract the verbatim declared-type source text (trimmed) from a field
|
||||
* declaration node, preserving generic arguments (`List<Shape>` stays
|
||||
* `List<Shape>`). Unlike `extractType`, the result bypasses
|
||||
* `normalizeType`/`resolveType` entirely — it is the untouched source text.
|
||||
*/
|
||||
extractRawType?: (node: SyntaxNode) => string | undefined;
|
||||
/**
|
||||
* Extract `'@Name'`-prefixed annotation names from a field declaration
|
||||
* node (e.g. `['@Autowired']`). Optional — only languages with
|
||||
* field-level annotations implement it.
|
||||
*/
|
||||
extractAnnotations?: (node: SyntaxNode) => string[];
|
||||
/** Extract visibility from a field declaration node */
|
||||
extractVisibility: (node: SyntaxNode) => FieldVisibility;
|
||||
/** Extract visibility for one field name from a multi-name declaration. */
|
||||
@@ -183,9 +196,35 @@ export function createFieldExtractor(config: FieldExtractionConfig): FieldExtrac
|
||||
if (resolved) type = resolved;
|
||||
}
|
||||
|
||||
// Raw declared type deliberately bypasses normalizeType/resolveType —
|
||||
// it is the verbatim source text (generics preserved).
|
||||
let rawDeclaredType: string | undefined;
|
||||
try {
|
||||
rawDeclaredType = config.extractRawType?.(node);
|
||||
} catch {
|
||||
// A throw here (an unexpected tree-sitter node shape, a config bug)
|
||||
// must NOT propagate — it would escape processFileGroup to the
|
||||
// language-group catch, which treats any throw as "parser unavailable"
|
||||
// and silently drops every remaining file in the group. Degrade to a
|
||||
// field without the raw type instead. Mirrors the descriptionExtractor
|
||||
// / extractTemplateConstraints guards in parse-worker.ts (#2286 review).
|
||||
rawDeclaredType = undefined;
|
||||
}
|
||||
|
||||
let annotations: string[] | undefined;
|
||||
try {
|
||||
annotations = config.extractAnnotations?.(node);
|
||||
} catch {
|
||||
// Same group-drop rationale as the extractRawType guard above —
|
||||
// degrade to a field without annotations (#2286 review).
|
||||
annotations = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
...(rawDeclaredType !== undefined ? { rawDeclaredType } : {}),
|
||||
...(annotations !== undefined && annotations.length > 0 ? { annotations } : {}),
|
||||
visibility: config.extractVisibilityForName?.(node, name) ?? config.extractVisibility(node),
|
||||
isStatic: config.isStatic(node),
|
||||
isReadonly: config.isReadonly(node),
|
||||
|
||||
@@ -33,6 +33,19 @@ export interface FieldInfo {
|
||||
name: string;
|
||||
/** Resolved type (may be primitive, FQN, or generic) */
|
||||
type: string | null;
|
||||
/**
|
||||
* Verbatim declared-type source text (trimmed), preserving generic
|
||||
* arguments and qualifiers — e.g. `List<Shape>` where `type` is `List`.
|
||||
* Never passes through simple-name extraction or type resolution.
|
||||
*/
|
||||
rawDeclaredType?: string;
|
||||
/**
|
||||
* Annotation names found on the field declaration, `'@Name'`-prefixed
|
||||
* (e.g. `['@Autowired']`), matching the method-extractor convention.
|
||||
* Omitted when the language config does not extract annotations or the
|
||||
* field has none.
|
||||
*/
|
||||
annotations?: string[];
|
||||
/** Visibility modifier */
|
||||
visibility: FieldVisibility;
|
||||
/** Is this a static member? */
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { SyntaxNode } from './utils/ast-helpers.js';
|
||||
import type { CfgVisitor } from './cfg/types.js';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { ExtractedRoute } from './route-extractors/laravel.js';
|
||||
import type { SharedSpringType } from './route-extractors/spring-shared.js';
|
||||
import type Parser from 'tree-sitter';
|
||||
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
||||
|
||||
@@ -288,6 +289,23 @@ interface LanguageProviderConfig {
|
||||
lineOffset: number,
|
||||
) => ExtractedDecoratorRoute[];
|
||||
|
||||
/**
|
||||
* Collect a project-wide, language-agnostic view of route-defining
|
||||
* class/interface declarations (`SharedSpringType`) from a parsed file.
|
||||
*
|
||||
* When defined, the parse worker calls this per file and the parse phase
|
||||
* aggregates the results, then runs a cross-file pass that resolves
|
||||
* interface-inherited routes (a concrete controller inherits the `@*Mapping`s
|
||||
* its interfaces declare) and appends them to `decoratorRoutes`. Separate from
|
||||
* `extractDecoratorRoutes` because inheritance needs all files, not one.
|
||||
*
|
||||
* Default: undefined (no interface-inheritance route resolution).
|
||||
*/
|
||||
readonly extractRouteInheritanceTypes?: (
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
) => SharedSpringType[];
|
||||
|
||||
// ── Noise filtering ────────────────────────────────────────────────
|
||||
/** Built-in/stdlib names that should be filtered from the call graph for this language.
|
||||
* Default: undefined (no language-specific filtering). */
|
||||
|
||||
@@ -31,6 +31,7 @@ const FUNCTION_DECLARATION_TYPES = new Set([
|
||||
'function_item',
|
||||
]);
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { createLeadingDocDescriptionExtractor } from '../utils/ast-helpers.js';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { LanguageProvider } from '../language-provider.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
@@ -397,6 +398,8 @@ export const cProvider = defineLanguage({
|
||||
}),
|
||||
variableExtractor: createVariableExtractor(cVariableConfig),
|
||||
classExtractor: cClassExtractor,
|
||||
// ── Doxygen doc comment → description (issue #2270) ──
|
||||
descriptionExtractor: createLeadingDocDescriptionExtractor(),
|
||||
labelOverride: cppLabelOverride,
|
||||
builtInNames: C_BUILT_INS,
|
||||
|
||||
@@ -482,6 +485,8 @@ export const cppProvider = defineLanguage({
|
||||
}),
|
||||
variableExtractor: createVariableExtractor(cppVariableConfig),
|
||||
classExtractor: cppClassExtractor,
|
||||
// ── Doxygen doc comment → description (issue #2270) ──
|
||||
descriptionExtractor: createLeadingDocDescriptionExtractor(),
|
||||
labelOverride: cppLabelOverride,
|
||||
builtInNames: C_BUILT_INS,
|
||||
extractTemplateConstraints: extractCppTemplateConstraintsForProvider,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { csharpExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
import { csharpImportConfig } from '../import-resolvers/configs/csharp.js';
|
||||
import { CSHARP_QUERIES } from '../tree-sitter-queries.js';
|
||||
import { createLeadingDocDescriptionExtractor } from '../utils/ast-helpers.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { csharpCallConfig } from '../call-extractors/configs/csharp.js';
|
||||
@@ -194,6 +195,8 @@ export const csharpProvider = defineLanguage({
|
||||
methodExtractor: createMethodExtractor(csharpMethodConfig),
|
||||
variableExtractor: createVariableExtractor(csharpVariableConfig),
|
||||
classExtractor: createClassExtractor(csharpClassConfig),
|
||||
// ── XML doc comments (`///`) → description (issue #2270) ──
|
||||
descriptionExtractor: createLeadingDocDescriptionExtractor(),
|
||||
builtInNames: BUILT_INS,
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
|
||||
|
||||
@@ -9,9 +9,12 @@
|
||||
* The hook resolves the enclosing function by inspecting the previous sibling.
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import {
|
||||
createLeadingDocDescriptionExtractor,
|
||||
FUNCTION_NODE_TYPES,
|
||||
type SyntaxNode,
|
||||
} from '../utils/ast-helpers.js';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { FUNCTION_NODE_TYPES } from '../utils/ast-helpers.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { dartClassConfig } from '../class-extractors/configs/dart.js';
|
||||
@@ -124,6 +127,8 @@ export const dartProvider = defineLanguage({
|
||||
methodExtractor: createMethodExtractor(dartMethodConfig),
|
||||
variableExtractor: createVariableExtractor(dartVariableConfig),
|
||||
classExtractor: createClassExtractor(dartClassConfig),
|
||||
// ── Dartdoc (`///`) → description (issue #2270) ──
|
||||
descriptionExtractor: createLeadingDocDescriptionExtractor(),
|
||||
enclosingFunctionFinder: dartEnclosingFunctionFinder,
|
||||
builtInNames: DART_BUILT_INS,
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { goClassConfig } from '../class-extractors/configs/go.js';
|
||||
import { createLeadingDocDescriptionExtractor } from '../utils/ast-helpers.js';
|
||||
import { createGoCfgVisitor } from '../cfg/visitors/go.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import { typeConfig as goConfig } from '../type-extractors/go.js';
|
||||
@@ -138,6 +139,12 @@ export const goProvider = defineLanguage({
|
||||
methodExtractor: createMethodExtractor(goMethodConfig),
|
||||
variableExtractor: createVariableExtractor(goVariableConfig),
|
||||
classExtractor: createClassExtractor(goClassConfig),
|
||||
// ── godoc (`//` leading comments) → description (issue #2270). Build/tool
|
||||
// directives (//go:…, // +build, //nolint, //line) are not documentation. ──
|
||||
descriptionExtractor: createLeadingDocDescriptionExtractor({
|
||||
lineCommentPrefixes: ['//'],
|
||||
lineDirectivePrefixes: ['//go:', '// +build', '//nolint', '//line'],
|
||||
}),
|
||||
builtInNames: GO_BUILT_INS,
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
|
||||
|
||||
@@ -12,8 +12,9 @@ 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 { createLeadingDocDescriptionExtractor } from '../utils/ast-helpers.js';
|
||||
import { javaTypeConfig } from '../type-extractors/jvm.js';
|
||||
import { extractSpringRoutes } from '../route-extractors/spring.js';
|
||||
import { extractSpringRoutes, extractSpringTypes } from '../route-extractors/spring.js';
|
||||
import { javaExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
import { javaImportConfig } from '../import-resolvers/configs/jvm.js';
|
||||
@@ -117,6 +118,9 @@ export const javaProvider = defineLanguage({
|
||||
variableExtractor: createVariableExtractor(javaVariableConfig),
|
||||
classExtractor: createClassExtractor(javaClassConfig),
|
||||
|
||||
// ── Javadoc → description (issue #2270) ──
|
||||
descriptionExtractor: createLeadingDocDescriptionExtractor(),
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks ──
|
||||
emitScopeCaptures: emitJavaScopeCaptures,
|
||||
|
||||
@@ -134,4 +138,5 @@ export const javaProvider = defineLanguage({
|
||||
|
||||
// ── Route extraction ──
|
||||
extractDecoratorRoutes: extractSpringRoutes,
|
||||
extractRouteInheritanceTypes: extractSpringTypes,
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null
|
||||
localName: nameCap?.text ?? simpleName,
|
||||
importedName: simpleName,
|
||||
targetRaw: sourceCap.text,
|
||||
targetIncludesImportedName: true,
|
||||
};
|
||||
}
|
||||
case 'wildcard': {
|
||||
|
||||
@@ -57,6 +57,7 @@ const javaScopeResolver: ScopeResolver = {
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
collapseMemberCallsByCallerTarget: true,
|
||||
hoistTypeBindingsToModule: true,
|
||||
stripReceiverCastExpressions: true,
|
||||
|
||||
populateNamespaceSiblings: populateJavaPackageSiblings,
|
||||
populateRangeBindings: populateJavaCrossFileReturnTypes,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { kotlinClassConfig } from '../class-extractors/configs/jvm.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import { createLeadingDocDescriptionExtractor } from '../utils/ast-helpers.js';
|
||||
import { assertCloneable } from '../workers/clone-safety.js';
|
||||
import { kotlinTypeConfig } from '../type-extractors/jvm.js';
|
||||
import { kotlinExportChecker } from '../export-detection.js';
|
||||
@@ -170,6 +171,10 @@ export const kotlinProvider = defineLanguage({
|
||||
variableExtractor: createVariableExtractor(kotlinVariableConfig),
|
||||
classExtractor: createClassExtractor(kotlinClassConfig),
|
||||
builtInNames: BUILT_INS,
|
||||
|
||||
// ── KDoc → description (issue #2270) ──
|
||||
descriptionExtractor: createLeadingDocDescriptionExtractor(),
|
||||
|
||||
labelOverride: (functionNode, defaultLabel) => {
|
||||
if (defaultLabel !== 'Function') return defaultLabel;
|
||||
if (isKotlinClassMethod(functionNode)) return 'Method';
|
||||
|
||||
@@ -21,13 +21,22 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { phpClassConfig } from '../class-extractors/configs/php.js';
|
||||
import { createPhpCfgVisitor } from '../cfg/visitors/php.js';
|
||||
import { defineLanguage, type AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import {
|
||||
defineLanguage,
|
||||
type AstFrameworkPatternConfig,
|
||||
type CaptureMap,
|
||||
} 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';
|
||||
import { phpImportConfig } from '../import-resolvers/configs/php.js';
|
||||
import { PHP_QUERIES } from '../tree-sitter-queries.js';
|
||||
import { findDescendant, extractStringContent, type SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import {
|
||||
findDescendant,
|
||||
extractStringContent,
|
||||
createLeadingDocDescriptionExtractor,
|
||||
type SyntaxNode,
|
||||
} from '../utils/ast-helpers.js';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { phpConfig as phpFieldConfig } from '../field-extractors/configs/php.js';
|
||||
@@ -221,22 +230,32 @@ function extractEloquentRelationDescription(methodNode: SyntaxNode): string | nu
|
||||
return null;
|
||||
}
|
||||
|
||||
/** PHPDoc-docblock fallback, shared with the other leading-comment languages. */
|
||||
const phpLeadingDocFallback = createLeadingDocDescriptionExtractor();
|
||||
|
||||
/**
|
||||
* LanguageProvider.descriptionExtractor implementation for PHP.
|
||||
* Extracts Eloquent model property metadata and relationship descriptions.
|
||||
* Eloquent model property metadata and relationship descriptions take
|
||||
* precedence (they are richer than prose); otherwise documentable symbols fall
|
||||
* back to their leading PHPDoc docblock (issue #2270), mirroring the other
|
||||
* leading-comment languages.
|
||||
*/
|
||||
function phpDescriptionExtractor(
|
||||
nodeLabel: NodeLabel,
|
||||
nodeName: string,
|
||||
captureMap: Record<string, SyntaxNode>,
|
||||
captureMap: CaptureMap,
|
||||
): string | undefined {
|
||||
if (nodeLabel === 'Property' && captureMap['definition.property']) {
|
||||
return extractPhpPropertyDescription(nodeName, captureMap['definition.property']) ?? undefined;
|
||||
const propertyNode = captureMap['definition.property'];
|
||||
if (nodeLabel === 'Property' && propertyNode) {
|
||||
const eloquentProperty = extractPhpPropertyDescription(nodeName, propertyNode);
|
||||
if (eloquentProperty) return eloquentProperty;
|
||||
}
|
||||
if (nodeLabel === 'Method' && captureMap['definition.method']) {
|
||||
return extractEloquentRelationDescription(captureMap['definition.method']) ?? undefined;
|
||||
const methodNode = captureMap['definition.method'];
|
||||
if (nodeLabel === 'Method' && methodNode) {
|
||||
const eloquentRelation = extractEloquentRelationDescription(methodNode);
|
||||
if (eloquentRelation) return eloquentRelation;
|
||||
}
|
||||
return undefined;
|
||||
return phpLeadingDocFallback(nodeLabel, nodeName, captureMap);
|
||||
}
|
||||
|
||||
/** Detect Laravel route files by path convention. */
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 { createLeadingDocDescriptionExtractor, type SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { typeConfig as rubyConfig } from '../type-extractors/ruby.js';
|
||||
import { routeRubyCall } from '../call-routing.js';
|
||||
import { rubyExportChecker } from '../export-detection.js';
|
||||
@@ -197,6 +197,20 @@ export const rubyProvider = defineLanguage({
|
||||
}),
|
||||
variableExtractor: createVariableExtractor(rubyVariableConfig),
|
||||
classExtractor: createClassExtractor(rubyClassConfig),
|
||||
// ── Leading `#` comments (RDoc/YARD) → description (issue #2270). Magic
|
||||
// comments and the shebang are not documentation. ──
|
||||
descriptionExtractor: createLeadingDocDescriptionExtractor({
|
||||
lineCommentPrefixes: ['#'],
|
||||
lineDirectivePrefixes: [
|
||||
'# frozen_string_literal:',
|
||||
'# encoding:',
|
||||
'# coding:',
|
||||
'# -*-',
|
||||
'#!',
|
||||
'# rubocop:',
|
||||
'# typed:',
|
||||
],
|
||||
}),
|
||||
labelOverride: rubyLabelOverride,
|
||||
// Ruby MRO is kind-aware: prepend providers beat the class's own method,
|
||||
// which in turn beats include providers. The graph-level MRO phase
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { rustClassConfig } from '../class-extractors/configs/rust.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { createLeadingDocDescriptionExtractor, type SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { typeConfig as rustConfig } from '../type-extractors/rust.js';
|
||||
import { rustExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
@@ -176,6 +176,13 @@ export const rustProvider = defineLanguage({
|
||||
}),
|
||||
variableExtractor: createVariableExtractor(rustVariableConfig),
|
||||
classExtractor: createClassExtractor(rustClassConfig),
|
||||
// ── Rust outer doc comments (`///`, `/** */`) → description (issue #2270).
|
||||
// `//!` / `/*!` are INNER docs (document the enclosing item), so they must
|
||||
// not attach to the following item — opt out of both. ──
|
||||
descriptionExtractor: createLeadingDocDescriptionExtractor({
|
||||
lineCommentPrefixes: ['///'],
|
||||
blockDocPrefixes: ['/**'],
|
||||
}),
|
||||
builtInNames: BUILT_INS,
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
|
||||
emitScopeCaptures: emitRustScopeCaptures,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user