Merge branch 'main' into feat/nuxt-auto-imports

This commit is contained in:
Gergő Magyar 2026-06-24 07:04:34 +01:00 • committed by GitHub
commit e8f6d06d72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 896341 additions and 663101 deletions

View file

@ -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():

View file

@ -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",

View file

@ -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'
@ -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 }}
@ -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' &&
@ -434,6 +514,10 @@ jobs:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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}**.`,

View file

@ -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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

12
.github/zizmor.yml vendored
View file

@ -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,

View file

@ -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 |
|--------------|---------|
@ -216,6 +218,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`).

View file

@ -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

View file

@ -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).

View file

@ -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);

View file

@ -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`."

View file

@ -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 — 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)."
}
}

View file

@ -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"
@ -5424,9 +5424,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"

View file

@ -62,6 +62,10 @@ 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',

View file

@ -137,3 +137,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.

View file

@ -237,10 +237,18 @@ export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
// 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 */
//
// ONLY on a writable handle. A read-only connection has nothing to flush,
// and issuing CHECKPOINT on it leaves a WAL/shadow lock artifact that makes
// the very next read-only open of the same path fail in-process — which broke
// repeated `@group` impact/trace calls in a long-lived MCP server (the read
// path opens read-only, queries, and closes per call).
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();
@ -252,6 +260,16 @@ export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
} catch {
/* ignore */
}
// 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. The
// read-only CHECKPOINT skip above is the load-bearing fix and works on
// Linux/macOS (the platforms where in-process reopen is supported).
}
/* ------------------------------------------------------------------ */
@ -713,7 +731,7 @@ 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);

View file

@ -63,7 +63,7 @@ RETURN provider.repo AS neighborRepo,
provider.type AS contractType
`;
type BridgeNeighborRow = {
export type BridgeNeighborRow = {
neighborRepo: string;
neighborUid: string;
neighborFilePath?: string;
@ -352,7 +352,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);
@ -394,6 +394,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 +570,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

View file

@ -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,
});
}

View file

@ -676,6 +676,26 @@ 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 (1) array-form `@GetMapping({...})`,
// (2) interface-inherited Spring routes, or (3) the 2nd verb of a same-URL
// GET+POST pair (Route nodes are URL-keyed). Declaring 'complete' here would
// let the parse-skip drop those group-only providers. Java flips to 'complete'
// only once ingestion provider extraction matches this scan (a follow-up:
// array-form query branch + interface-inheritance emission + per-verb Route
// identity). `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 +728,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 +746,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 +779,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 +801,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 +822,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
method: httpMethod,
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -808,6 +839,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
method: httpMethodNode.text.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -830,6 +862,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
method: httpMethod,
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -853,6 +886,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
method: verbText,
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -879,6 +913,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
method,
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}

View file

@ -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,
});
}

View file

@ -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,
});
}

View file

@ -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,
});
}

View file

@ -79,6 +79,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')
@ -331,6 +358,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>();
@ -920,6 +1014,22 @@ function joinPrefix(prefix: string, route: string): string {
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 +1037,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 +1057,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,
});
}
@ -987,6 +1107,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 +1153,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: methodNode.text.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -1022,6 +1171,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: methodNode.text.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -1040,6 +1190,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: methodRaw.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -1059,6 +1210,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: methodNode.text.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -1079,6 +1231,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: methodRaw.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -1111,6 +1264,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: httpMethod,
path,
name: null,
line: methodNode.startPosition.row + 1,
confidence: 0.65,
});
}
@ -1137,6 +1291,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: httpMethod,
path: normalized,
name: null,
line: methodNode.startPosition.row + 1,
confidence: 0.6,
});
}

View file

@ -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.

View file

@ -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,152 @@ 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 (label-union, as in
// manifest-extractor.ts). 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).
//
// `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).
const RESOLVE_BY_NAME_QUERY = `
MATCH (n:Function|Method|CodeElement)
WHERE 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).
const RESOLVE_IN_MODULE_QUERY = `
MATCH (n:Function|Method|CodeElement)
WHERE 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) ──
@ -123,35 +265,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 +395,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 +611,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 +649,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 +739,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,6 +759,7 @@ 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) {
@ -447,19 +767,26 @@ export class HttpRouteExtractor implements ContractExtractor {
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(rel, 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 || rel,
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 +800,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 +840,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,6 +878,7 @@ 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) {
@ -557,18 +886,22 @@ export class HttpRouteExtractor implements ContractExtractor {
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(rel, 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 || rel, 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,
},
});

View file

@ -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() : '';

View file

@ -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 {

View file

@ -21,7 +21,9 @@ 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 } from './route-extractors/route-path.js';
import { extractReturnTypeName } from './type-extractors/shared.js';
const MAX_EXPORTS_PER_FILE = 500;
@ -243,6 +245,83 @@ export const processRoutesFromExtracted = async (
onProgress?.(extractedRoutes.length, extractedRoutes.length);
};
/**
* Resolve each route's handler to a real symbol UID, keyed by the normalized
* route URL (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 URL, matching the routes phase's dedup (it keeps the
* first route registered for a URL and counts the rest as duplicates). The first
* route to claim a URL reserves it **even when its handler is unresolvable**, so
* a later same-URL route can never stamp its handler onto the first route's Route
* node (the routes phase made that first route the node-winner). 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>();
// URLs 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, symbolId: string | undefined) => {
if (!routePath) return;
const url = normalizeExtractedRoutePath(routePath, prefix);
if (claimed.has(url)) return; // first-writer-wins: later same-URL routes can't override
claimed.add(url);
if (symbolId) out.set(url, 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, 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, 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

View file

@ -30,6 +30,7 @@ export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null
localName: nameCap?.text ?? simpleName,
importedName: simpleName,
targetRaw: sourceCap.text,
targetIncludesImportedName: true,
};
}
case 'wildcard': {

View file

@ -362,13 +362,24 @@ export const kotlinMethodConfig: MethodExtractionConfig = {
},
extractReceiverType(node) {
// Extension function: user_type appears before the simple_identifier (name)
// e.g., fun String.format(template: String) → receiver is "String"
// Extension function receiver. Newer tree-sitter-kotlin exposes it as a
// `receiver` field wrapping a `receiver_type` (which wraps the user_type);
// older grammars emitted a bare user_type/nullable_type child before the
// name (e.g. fun String.format(...) → receiver is "String").
const receiverField = node.childForFieldName('receiver');
if (receiverField) {
const inner = receiverField.namedChild(0) ?? receiverField;
return extractSimpleTypeName(inner) ?? inner.text?.trim();
}
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (child.type === 'simple_identifier') break; // past the name — no receiver
if (child.type === 'user_type' || child.type === 'nullable_type') {
if (
child.type === 'receiver_type' ||
child.type === 'user_type' ||
child.type === 'nullable_type'
) {
return extractSimpleTypeName(child) ?? child.text?.trim();
}
}

View file

@ -40,6 +40,7 @@ import { DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js';
import type { WorkerExtractedData } from '../parsing-processor.js';
import {
processRoutesFromExtracted,
resolveRouteHandlerSymbols,
buildExportedTypeMapFromGraph,
type ExportedTypeMap,
} from '../call-processor.js';
@ -370,6 +371,10 @@ export async function runChunkedParseAndResolve(
allToolDefs: ExtractedToolDef[];
allORMQueries: ExtractedORMQuery[];
bindingAccumulator: BindingAccumulator;
/** Route URL → resolved handler symbol UID (Part 2, #2138). Lets the routes
* phase stamp `handlerSymbolId` on Route nodes so contract extraction can
* read the handler from the graph instead of re-parsing source. */
routeHandlerSymbols: ReadonlyMap<string, string>;
/** SemanticModel populated during parse — scope-resolution reads its
* TypeRegistry / MethodRegistry / SymbolTable indexes. */
model: MutableSemanticModel;
@ -1282,6 +1287,13 @@ export async function runChunkedParseAndResolve(
'parse-impl-return',
`exportedTypeMap=${exportedTypeMap.size} parsedFiles=${allParsedFiles.length} nodes=${graph.nodeCount}`,
);
// Part 2 (#2138): resolve each route's handler to a real symbol UID now that
// the model is fully populated and decorator-route prefixes are finalized.
const routeHandlerSymbols = resolveRouteHandlerSymbols(
model,
allExtractedRoutes,
allDecoratorRoutes,
);
return {
exportedTypeMap,
allFetchCalls,
@ -1291,6 +1303,7 @@ export async function runChunkedParseAndResolve(
allToolDefs,
allORMQueries,
bindingAccumulator,
routeHandlerSymbols,
model,
// Whether a worker pool was actually constructed for this run. False means
// no pool was needed: a warm all-cache-hit run replays cached worker output

View file

@ -51,6 +51,9 @@ export interface ParseOutput {
readonly allDecoratorRoutes: readonly ExtractedDecoratorRoute[];
readonly allToolDefs: readonly ExtractedToolDef[];
readonly allORMQueries: readonly ExtractedORMQuery[];
/** Route URL → resolved handler symbol UID (Part 2, #2138). Consumed by the
* routes phase to stamp `handlerSymbolId` on Route nodes. */
readonly routeHandlerSymbols: ReadonlyMap<string, string>;
bindingAccumulator: BindingAccumulator;
/** SemanticModel populated during parse — scope-resolution reads its
* TypeRegistry / MethodRegistry / SymbolTable indexes. */

View file

@ -29,6 +29,7 @@ import {
compiledMatcherMatchesRoute,
} from '../route-extractors/middleware.js';
import { processNextjsFetchRoutes } from '../call-processor.js';
import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
import { generateId } from '../../../lib/utils.js';
import { readFileContents } from '../filesystem-walker.js';
import { isDev } from '../utils/env.js';
@ -133,17 +134,13 @@ export function extractTemplateStaticFetchCalls(
return calls;
}
export function normalizeExtractedRoutePath(routePath: string, prefix: string | null): string {
const pathPart = routePath.trim().replace(/^\/+/, '').replace(/\/+$/g, '');
const prefixPart = prefix?.trim().replace(/^\/+/, '').replace(/\/+$/g, '');
const joined = prefixPart ? `/${prefixPart}${pathPart ? `/${pathPart}` : ''}` : `/${pathPart}`;
return joined.replace(/\/+/g, '/') || '/';
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Re-exported for existing consumers/tests that import it from the routes phase.
export { normalizeExtractedRoutePath };
/**
* Canonicalize a route's HTTP verb for persistence on the Route node.
* Returns an upper-cased standard method, or `undefined` when the value
@ -189,6 +186,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
allFetchWrapperDefs,
allExtractedRoutes,
allDecoratorRoutes,
routeHandlerSymbols,
} = getPhaseOutput<ParseOutput>(deps, 'parse');
// Local copy — routes phase must not mutate upstream ParseOutput
@ -287,6 +285,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
const middleware = mwResult?.chain;
const routeNodeId = generateId('Route', routeURL);
const handlerSymbolId = routeHandlerSymbols.get(routeURL);
ctx.graph.addNode({
id: routeNodeId,
label: 'Route',
@ -294,6 +293,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
name: routeURL,
filePath: handlerPath,
...(routeMethod ? { method: routeMethod } : {}),
...(handlerSymbolId ? { handlerSymbolId } : {}),
...(responseKeys ? { responseKeys } : {}),
...(errorKeys ? { errorKeys } : {}),
...(middleware && middleware.length > 0 ? { middleware } : {}),

View file

@ -0,0 +1,21 @@
/**
* Shared route-path normalization.
*
* Extracted from the routes phase so both the routes phase (which creates the
* `Route` graph node, keyed by the normalized URL) and the parse phase (which
* resolves each route's handler symbol and needs the SAME key to associate the
* resolved id back to the route) can compute an identical route URL without a
* phase-to-phase import cycle. Pure string logic, no dependencies.
*/
/**
* Join a route's path with its (optional) prefix into a normalized,
* leading-slash URL used as the Route node identity. Collapses duplicate
* slashes and strips trailing ones; an empty result degrades to `/`.
*/
export function normalizeExtractedRoutePath(routePath: string, prefix: string | null): string {
const pathPart = routePath.trim().replace(/^\/+/, '').replace(/\/+$/g, '');
const prefixPart = prefix?.trim().replace(/^\/+/, '').replace(/\/+$/g, '');
const joined = prefixPart ? `/${prefixPart}${pathPart ? `/${pathPart}` : ''}` : `/${pathPart}`;
return joined.replace(/\/+/g, '/') || '/';
}

View file

@ -139,6 +139,9 @@ export function extractSpringRoutes(
if (routePath === null) continue;
const enclosingClass = findEnclosingClass(node);
const classPrefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : '';
// `node` is the annotated `method_declaration`; its name field is the
// handler method name (resolved to a symbol UID later by the routes phase).
const handlerName = node.childForFieldName('name')?.text;
routes.push({
filePath,
@ -147,6 +150,7 @@ export function extractSpringRoutes(
decoratorName: ann,
lineNumber: annNode.startPosition.row + lineOffset,
...(classPrefix ? { prefix: classPrefix } : {}),
...(handlerName ? { handlerName } : {}),
});
}

View file

@ -232,7 +232,6 @@ export function emitFileTaint(
const b = bindings[idx];
return b === undefined ? `#${idx}` : bindingKey(b);
};
// SANITIZES — one edge per kill, REGARDLESS of findings (kills can and do
// exist with zero findings: a fully-sanitized flow IS the kill evidence).
for (const kill of flows.kills) {
@ -263,13 +262,27 @@ export function emitFileTaint(
// `exec(req.body, req.query)`'s two findings; `property` is free-text
// (string-literal subscripts) and rides LAST so it cannot collide into
// another component.
const id = generateId(
'TAINTED',
`${fnAnchor}:${finding.sinkKind}:` +
`${pointKey(source.point)}.${source.siteIndex}:${bKey(source.objectBindingIdx)}:` +
`${pointKey(sink.point)}.${sink.siteIndex}.${sink.argIndex}:${bKey(sink.bindingIdx)}:` +
`${sink.entryName}:${source.property}`,
);
const id =
source.type === 'member-read'
? generateId(
'TAINTED',
`${fnAnchor}:${finding.sinkKind}:` +
`${pointKey(source.point)}.${source.siteIndex}:${bKey(source.objectBindingIdx)}:` +
`${pointKey(sink.point)}.${sink.siteIndex}.${sink.argIndex}:${bKey(
sink.bindingIdx,
)}:` +
`${sink.entryName}:${source.property}`,
)
: generateId(
'TAINTED',
`${fnAnchor}:${finding.sinkKind}:` +
`${pointKey(source.point)}.${source.siteIndex}:call-result:` +
`${bKey(source.resultBindingIdx)}:${source.calleeName}:` +
`${pointKey(sink.point)}.${sink.siteIndex}.${sink.argIndex}:${bKey(
sink.bindingIdx,
)}:` +
`${sink.entryName}`,
);
if (seenEdgeIds.has(id)) continue;
seenEdgeIds.add(id);
// `kind` rides the reason's `;<kind>` header — the only persisted

View file

@ -0,0 +1,24 @@
/**
* Built-in Java taint model (#2261 first slice).
*
* This deliberately starts small. Servlet request input is modeled only when a
* conventional request receiver's call result is assigned to a binding. Sinks
* are limited to static-import-proven JDK filesystem operations that current
* harvested call-site/import data can identify without broad same-name matching.
* No sanitizers are registered in this slice.
*/
import type { SourceSinkSanitizerSpec } from './source-sink-config.js';
export const JAVA_TAINT_MODEL: SourceSinkSanitizerSpec = {
sources: [
{
type: 'call-result',
kind: 'remote-input',
receivers: ['request', 'req'],
methods: ['getParameter', 'getHeader'],
},
],
sinks: [{ name: 'readString', kind: 'path-traversal', args: [0], module: 'java.nio.file.Files' }],
sanitizers: [],
};

View file

@ -76,8 +76,10 @@
import type { ParsedImport } from 'gitnexus-shared';
import type { FunctionCfg, SiteRecord } from '../cfg/types.js';
import type {
TaintCallResultSourceEntry,
SourceSinkSanitizerSpec,
TaintMemberSourceEntry,
TaintSourceEntry,
TaintSanitizerEntry,
TaintSinkEntry,
} from './source-sink-config.js';
@ -92,6 +94,12 @@ export interface TaintImportBinding {
* CJS interop makes the default export ≈ the module object).
*/
readonly member?: string;
/**
* True when the provider says `module` already includes `member`; used for
* class-like imports where a receiver call should resolve as
* `<module>.<method>`, not `<module>.<member>.<method>`.
*/
readonly targetIncludesMember?: boolean;
}
/** Local name → import provenance for one file. Build once per file (U4). */
@ -99,11 +107,24 @@ export type TaintImportIndex = ReadonlyMap<string, TaintImportBinding>;
/** A member-read site matched as a taint source. */
export interface MatchedSourceRead {
readonly type: 'member-read';
/** Index into the owning statement's `sites` array. */
readonly siteIndex: number;
readonly entry: TaintMemberSourceEntry;
}
/** A call-result source matched on a call site with direct result definitions. */
export interface MatchedSourceCall {
readonly type: 'call-result';
/** Index into the owning statement's `sites` array. */
readonly siteIndex: number;
readonly entry: TaintCallResultSourceEntry;
/** Bindings directly defined by this call result. Never empty. */
readonly resultDefs: readonly number[];
}
export type MatchedSource = MatchedSourceRead | MatchedSourceCall;
/** A call/new site matched as a sink. */
export interface MatchedSinkCall {
/** Index into the owning statement's `sites` array. */
@ -141,7 +162,7 @@ export interface StatementMatches {
readonly blockIndex: number;
readonly statementIndex: number;
readonly line: number;
readonly sources: readonly MatchedSourceRead[];
readonly sources: readonly MatchedSource[];
readonly sinks: readonly MatchedSinkCall[];
readonly sanitizers: readonly MatchedSanitizerCall[];
}
@ -157,6 +178,9 @@ export interface FunctionSiteMatches {
const stripNodeScheme = (specifier: string): string =>
specifier.startsWith('node:') ? specifier.slice('node:'.length) : specifier;
const isCallResultSource = (entry: TaintSourceEntry): entry is TaintCallResultSourceEntry =>
entry.type === 'call-result';
/**
* Build the local-name → module/member index from a file's `parsedImports`.
* Only `named`/`alias`/`namespace` kinds bind matcher-visible local names;
@ -169,7 +193,13 @@ export function buildTaintImportIndex(imports: readonly ParsedImport[]): TaintIm
const module = stripNodeScheme(imp.targetRaw);
index.set(
imp.localName,
imp.importedName === 'default' ? { module } : { module, member: imp.importedName },
imp.importedName === 'default'
? { module }
: {
module,
member: imp.importedName,
...(imp.targetIncludesImportedName === true ? { targetIncludesMember: true } : {}),
},
);
} else if (imp.kind === 'namespace') {
index.set(imp.localName, { module: stripNodeScheme(imp.targetRaw) });
@ -239,6 +269,10 @@ export function matchFunctionSites(
const rest = path.slice(1);
const canonical: string[] = [];
let globalRoot = false;
const canonicalBase = (imp: TaintImportBinding): string[] =>
imp.member === undefined || imp.targetIncludesMember === true
? [imp.module]
: [imp.module, imp.member];
if (site.receiver !== undefined) {
// Member chain with an identifier root — origin known by binding index.
@ -246,8 +280,7 @@ export function matchFunctionSites(
if (rb.synthetic === true) {
const imp = imports.get(rb.name);
if (imp !== undefined) {
const base = imp.member === undefined ? [imp.module] : [imp.module, imp.member];
canonical.push([...base, ...rest].join('.'));
canonical.push([...canonicalBase(imp), ...rest].join('.'));
}
} else {
const module = requireByBinding.get(site.receiver);
@ -268,7 +301,11 @@ export function matchFunctionSites(
const imp = imports.get(root);
if (imp !== undefined) {
canonical.push(
imp.member === undefined ? `${imp.module}.default` : `${imp.module}.${imp.member}`,
imp.member === undefined
? `${imp.module}.default`
: imp.targetIncludesMember === true
? imp.module
: `${imp.module}.${imp.member}`,
);
} else {
globalRoot = true;
@ -330,7 +367,7 @@ export function matchFunctionSites(
block.statements?.forEach((stmt, statementIndex) => {
const sites = stmt.sites;
if (sites === undefined || sites.length === 0) return;
const sources: MatchedSourceRead[] = [];
const sources: MatchedSource[] = [];
const sinks: MatchedSinkCall[] = [];
const sanitizers: MatchedSanitizerCall[] = [];
@ -340,8 +377,9 @@ export function matchFunctionSites(
const objectName = bindings[site.object].name;
const property = site.property;
for (const entry of spec.sources) {
if (isCallResultSource(entry)) continue;
if (entry.objects.includes(objectName) && entry.properties.includes(property)) {
sources.push({ siteIndex, entry });
sources.push({ type: 'member-read', siteIndex, entry });
}
}
return;
@ -349,6 +387,26 @@ export function matchFunctionSites(
// call / new
const resolved = resolveCallee(site);
if (resolved === undefined) return;
if (site.kind === 'call') {
const resultDefs = site.resultDefs;
if (resultDefs !== undefined && resultDefs.length > 0) {
for (const entry of spec.sources) {
if (!isCallResultSource(entry)) continue;
if (
resolved.path.length === 2 &&
entry.receivers.includes(resolved.path[0]) &&
entry.methods.includes(resolved.path[1])
) {
sources.push({
type: 'call-result',
siteIndex,
entry,
resultDefs,
});
}
}
}
}
for (const entry of spec.sinks) {
if (!sinkMechanismHit(entry, site, resolved)) continue;
const argPositions: number[] = [];

View file

@ -157,19 +157,33 @@ export interface TaintHop {
}
/**
* The KTD6 rule-(b) source identity material: the matched member-read
* occurrence itself — statement point + site index + object/property. For
* worklist findings this is the ROOT source the taint chain was seeded from.
* The source identity material for a finding: either the matched member-read
* occurrence itself (statement point + site index + object/property) or an
* assigned call-result source. For worklist findings this is the ROOT source
* the taint chain was seeded from.
*/
export interface TaintSourceOccurrence {
interface BaseSourceOccurrence {
readonly point: ProgramPoint;
/** Index into the source statement's `sites` array. */
readonly siteIndex: number;
readonly objectBindingIdx: number;
readonly property: string;
readonly type: 'member-read' | 'call-result';
readonly kind: SourceKind;
}
interface MemberReadSourceOccurrence extends BaseSourceOccurrence {
readonly type: 'member-read';
readonly objectBindingIdx: number;
readonly property: string;
}
interface CallResultSourceOccurrence extends BaseSourceOccurrence {
readonly type: 'call-result';
readonly resultBindingIdx: number;
readonly calleeName: string;
}
export type TaintSourceOccurrence = MemberReadSourceOccurrence | CallResultSourceOccurrence;
/** The sink side of a finding's identity: point + site + argument + binding. */
export interface TaintSinkOccurrence {
readonly point: ProgramPoint;
@ -514,18 +528,33 @@ export function computeTaintFlows(
sinkKind: SinkKind,
source: TaintSourceOccurrence,
sink: Pick<TaintSinkOccurrence, 'point' | 'siteIndex' | 'argIndex' | 'bindingIdx'>,
): string =>
[
): string => {
if (source.type === 'member-read') {
return [
sinkKind,
pointKey(source.point),
source.siteIndex,
source.objectBindingIdx,
source.property,
pointKey(sink.point),
sink.siteIndex,
sink.argIndex,
sink.bindingIdx,
].join('|');
}
return [
sinkKind,
pointKey(source.point),
source.siteIndex,
source.objectBindingIdx,
source.property,
source.type,
source.resultBindingIdx,
source.calleeName,
pointKey(sink.point),
sink.siteIndex,
sink.argIndex,
sink.bindingIdx,
].join('|');
};
const recordFinding = (
sinkKind: SinkKind,
@ -704,11 +733,29 @@ export function computeTaintFlows(
const ctx = contextAt(sm.blockIndex, sm.statementIndex);
if (!ctx) continue;
for (const src of sm.sources) {
if (src.type === 'call-result') {
const srcSite = ctx.sites[src.siteIndex];
if (srcSite?.callee === undefined) continue;
const calleeName = srcSite.callee;
for (const d of src.resultDefs) {
const sourceOcc: CallResultSourceOccurrence = {
point: ctx.point,
siteIndex: src.siteIndex,
type: 'call-result',
resultBindingIdx: d,
calleeName,
kind: src.entry.kind,
};
deriveTaint(d, ctx.point, EMPTY_KINDS, undefined, sourceOcc, false);
}
continue;
}
const srcSite = ctx.sites[src.siteIndex];
if (srcSite?.object === undefined || srcSite.property === undefined) continue;
const sourceOcc: TaintSourceOccurrence = {
const sourceOcc: MemberReadSourceOccurrence = {
point: ctx.point,
siteIndex: src.siteIndex,
type: 'member-read',
objectBindingIdx: srcSite.object,
property: srcSite.property,
kind: src.entry.kind,

View file

@ -97,23 +97,41 @@ export interface TaintSanitizerEntry {
* the property is one of `properties` (`body`, `query`, …). Matching is
* name-based on the harvested `member-read` site (Semgrep-convention, not
* type-aware — the accepted M3 FP/FN trade recorded in the plan's risk
* table). One entry fans out over the objects × properties product.
* table). One entry fans out over the objects × properties product. `type`
* remains optional so existing/custom model objects that predate the
* discriminant continue to load as member-read sources.
*/
export interface TaintMemberSourceEntry {
readonly type?: 'member-read';
readonly kind: SourceKind;
readonly objects: readonly string[];
readonly properties: readonly string[];
}
/**
* A call-result taint source: the result of `<receiver>.<method>(...)` becomes
* tainted, but only when the call site records direct `resultDefs`. This keeps
* source seeding tied to proven data-flow destinations instead of treating an
* arbitrary nested call expression as a value occurrence.
*/
export interface TaintCallResultSourceEntry {
readonly type: 'call-result';
readonly kind: SourceKind;
readonly receivers: readonly string[];
readonly methods: readonly string[];
}
export type TaintSourceEntry = TaintMemberSourceEntry | TaintCallResultSourceEntry;
/**
* The taint configuration for a single language: which member reads introduce
* taint (sources), which callables are dangerous to reach with tainted input
* (sinks), and which callables clear it (sanitizers). M3 sources are
* member-read entries only; call-result sources are a forward extension
* (add a union variant), not a missing case.
* member-read entries for JS/TS/Python and call-result entries for languages
* whose request APIs return tainted values from calls.
*/
export interface SourceSinkSanitizerSpec {
readonly sources: readonly TaintMemberSourceEntry[];
readonly sources: readonly TaintSourceEntry[];
readonly sinks: readonly TaintSinkEntry[];
readonly sanitizers: readonly TaintSanitizerEntry[];
}

View file

@ -297,18 +297,27 @@ export function harvestFunctionSummary(
stmtIndex: sm.statementIndex,
line: facts.line,
};
const memberSources = sm.sources.filter((src) => src.type === 'member-read');
if (returnUseStmtKeys.has(stmtKey)) {
for (const src of sm.sources) sourceReturn.add(src.entry.kind);
for (const src of memberSources) sourceReturn.add(src.entry.kind);
}
for (const d of [...facts.defs, ...(facts.mayDefs ?? [])]) {
enqueue({ bindingIdx: d, point, seedId: -1, exclusions: new Set() });
if (memberSources.length > 0) {
for (const d of [...facts.defs, ...(facts.mayDefs ?? [])]) {
enqueue({ bindingIdx: d, point, seedId: -1, exclusions: new Set() });
}
}
for (const src of sm.sources) {
if (src.type !== 'call-result') continue;
for (const d of src.resultDefs) {
enqueue({ bindingIdx: d, point, seedId: -1, exclusions: new Set() });
}
}
// DIRECT source-in-call-arg (`runIt(req.body)`): no intermediate binding is
// defined, so the floor seed above records nothing. Climb the source
// member-read's `parent` chain — each enclosing call/new site is a
// `sourceToCallArg` (the cross-function fixpoint seed). A sink ancestor is
// M3's intra-procedural concern and harmless to also record here.
for (const src of sm.sources) {
for (const src of memberSources) {
let cur: SiteRecord | undefined = facts.sites?.[src.siteIndex];
const guard = new Set<number>([src.siteIndex]);
while (cur?.parent) {

View file

@ -1,8 +1,8 @@
/**
* Built-in TS/JS taint model (#2083 M3 U2, plan KTD7).
*
* The canonical Express/Node source/sink/sanitizer set, registered for the
* `typescript` and `javascript` language ids via the EXPLICIT
* The canonical Express/Node source/sink/sanitizer set plus the Java and
* Python models, registered for their language ids via the EXPLICIT
* {@link registerBuiltinTaintModels} seam — deliberately not an import
* side-effect, so the U4 emit path controls WHEN registration happens (call
* it once before the pdg window runs; it is idempotent — the registry is
@ -18,6 +18,7 @@
import { createHash } from 'node:crypto';
import { SupportedLanguages } from 'gitnexus-shared';
import type { SourceSinkSanitizerSpec } from './source-sink-config.js';
import { JAVA_TAINT_MODEL } from './java-model.js';
import { PYTHON_TAINT_MODEL } from './python-model.js';
import { registerSourceSinkConfig } from './source-sink-registry.js';
@ -99,6 +100,7 @@ function canonicalJson(value: unknown): string {
}
export const BUILTIN_TAINT_MODELS = {
[SupportedLanguages.Java]: JAVA_TAINT_MODEL,
[SupportedLanguages.JavaScript]: TS_JS_TAINT_MODEL,
[SupportedLanguages.Python]: PYTHON_TAINT_MODEL,
[SupportedLanguages.TypeScript]: TS_JS_TAINT_MODEL,
@ -111,12 +113,13 @@ export const BUILTIN_TAINT_MODELS = {
export const taintModelVersion: string = computeModelDigest(BUILTIN_TAINT_MODELS);
/**
* Register the built-in models for TypeScript, JavaScript, and Python.
* Register the built-in models for Java, TypeScript, JavaScript, and Python.
* Explicit init seam for the U4 emit path (call before the pdg window
* consumes the registry); idempotent. Other language ids remain unregistered
* until they have a dedicated model.
*/
export function registerBuiltinTaintModels(): void {
registerSourceSinkConfig(SupportedLanguages.Java, JAVA_TAINT_MODEL);
registerSourceSinkConfig(SupportedLanguages.TypeScript, TS_JS_TAINT_MODEL);
registerSourceSinkConfig(SupportedLanguages.JavaScript, TS_JS_TAINT_MODEL);
registerSourceSinkConfig(SupportedLanguages.Python, PYTHON_TAINT_MODEL);

View file

@ -317,6 +317,16 @@ export interface ExtractedDecoratorRoute {
* absent ⇒ no prefix applies.
*/
prefix?: string | null;
/**
* Name of the handler the route decorator sits on (the decorated
* method/function — e.g. `create` for `@PostMapping("/orders") Order create()`).
* Captured at extraction where the decorated definition node is in hand, so
* the routes phase can resolve it to a real handler symbol UID via the
* SemanticModel (same `(filePath, name) → nodeId` lookup Laravel routes use).
* Absent when the extractor could not identify the decorated definition;
* resolution then falls back (the Route node simply carries no handlerSymbolId).
*/
handlerName?: string;
}
export interface ExtractedToolDef {

View file

@ -381,7 +381,7 @@ export const streamAllCSVsToDisk = async (
// Route nodes for API endpoint mapping
const routeWriter = new BufferedCSVWriter(
path.join(csvDir, 'route.csv'),
'id,name,filePath,responseKeys,errorKeys,middleware,method',
'id,name,filePath,responseKeys,errorKeys,middleware,method,handlerSymbolId',
);
// Tool nodes for MCP tool definitions
@ -561,6 +561,7 @@ export const streamAllCSVsToDisk = async (
escapeCSVField(errorKeysStr),
escapeCSVField(middlewareStr),
escapeCSVField(String(node.properties.method ?? '')),
escapeCSVField(String(node.properties.handlerSymbolId ?? '')),
].join(','),
);
break;

View file

@ -1337,7 +1337,7 @@ export const getCopyQuery = (table: NodeTableName, filePath: string): string =>
return `COPY ${t}(id, name, filePath, startLine, endLine, level, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Route') {
return `COPY ${t}(id, name, filePath, responseKeys, errorKeys, middleware, method) FROM "${filePath}" ${COPY_CSV_OPTS}`;
return `COPY ${t}(id, name, filePath, responseKeys, errorKeys, middleware, method, handlerSymbolId) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Tool') {
return `COPY ${t}(id, name, filePath, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;

View file

@ -195,6 +195,7 @@ CREATE NODE TABLE Route (
errorKeys STRING[],
middleware STRING[],
method STRING,
handlerSymbolId STRING,
PRIMARY KEY (id)
)`;

View file

@ -38,6 +38,12 @@ export interface CreateLoggerOptions {
debugEnvVar?: string;
/** Override destination stream — primarily for tests. */
destination?: DestinationStream;
/**
* Explicit level for the destination-override path — primarily for tests that
* need to capture below the default `info` (e.g. asserting a `debug` record).
* Ignored unless `destination` is set; `debugEnvVar` still wins when truthy.
*/
level?: string;
}
function isTruthyEnv(value: string | undefined): boolean {
@ -219,7 +225,7 @@ export function createLogger(name: string, opts?: CreateLoggerOptions): Logger {
if (opts?.destination) {
return pino(
{ level: debugRequested ? 'debug' : 'info', base: undefined, name },
{ level: debugRequested ? 'debug' : (opts.level ?? 'info'), base: undefined, name },
opts.destination,
);
}
@ -247,6 +253,7 @@ export function createLogger(name: string, opts?: CreateLoggerOptions): Logger {
/* ------------------------------------------------------------------ */
let _activeDestination: DestinationStream | undefined;
let _activeLevel: string | undefined;
let _cached: Logger | undefined;
function _getInner(): Logger {
@ -256,7 +263,7 @@ function _getInner(): Logger {
// by `_captureLogger()` below.
_cached = createLogger(
'gitnexus',
_activeDestination ? { destination: _activeDestination } : undefined,
_activeDestination ? { destination: _activeDestination, level: _activeLevel } : undefined,
);
return _cached;
}
@ -342,10 +349,13 @@ export interface LoggerCapture {
* expect(cap.records().some(r => r.msg?.includes('clamping'))).toBe(true);
* });
*
* Pass `level` (e.g. 'debug') to capture below the default 'info' — needed to
* assert that a record was emitted at debug rather than merely absent.
*
* Not a public API; underscore-prefixed and called only from test code.
* Throws if a previous capture is still active — see the body for context.
*/
export function _captureLogger(): LoggerCapture {
export function _captureLogger(level?: string): LoggerCapture {
// Guard against double-capture: forgetting `restore()` between two
// `_captureLogger()` calls silently abandoned the previous capture and
// corrupted logger state for the rest of the vitest worker. Throwing here
@ -358,6 +368,7 @@ export function _captureLogger(): LoggerCapture {
}
const w = new MemoryWritable();
_activeDestination = w;
_activeLevel = level;
_cached = undefined;
return {
records: () =>
@ -369,6 +380,7 @@ export function _captureLogger(): LoggerCapture {
text: () => w.chunks.join(''),
restore: () => {
_activeDestination = undefined;
_activeLevel = undefined;
_cached = undefined;
},
};

View file

@ -39,7 +39,13 @@ import {
type RegistryEntry,
type BranchSummary,
} from '../../storage/repo-manager.js';
import { GroupService, type GroupToolPort } from '../../core/group/service.js';
import {
GroupService,
type GroupToolPort,
type GroupSymbolResolution,
type GroupPdgFlowResult,
type GroupPdgFlowHop,
} from '../../core/group/service.js';
import { resolveAtGroupMemberRepoPath } from '../../core/group/resolve-at-member.js';
import { collectBestChunks } from '../../core/embeddings/types.js';
import {
@ -260,10 +266,39 @@ export const IMPACT_RELATION_CONFIDENCE: Readonly<Record<string, number>> = {
const confidenceForRelType = (relType: string | undefined): number =>
IMPACT_RELATION_CONFIDENCE[relType ?? ''] ?? 0.5;
/** Structured error logging for query failures — replaces empty catch blocks */
/**
* Structured logging for *swallowed* query failures — replaces empty catch
* blocks. The level reflects telemetry severity, NOT a promise about the
* caller: most callers catch the failure and degrade to a genuinely safe
* fallback (a usable result, usually with a caller-visible `partial`/`ftsUsed`
* flag), so these are not operation-level errors and must not log at `error`:
*
* - A benign missing optional table/label/column — a repo analyzed without
* processes/communities, or a pre-v3 PDG index lacking the `calleeIds`
* column — is a normal configuration, not a failure. Logged at `debug`
* (suppressed at the default `info` level; surfaced only when troubleshooting).
* - Any other swallowed failure is an unexpected-but-handled degradation:
* logged at `warn` so it stays observable without raising a false `error`
* alarm that would drown genuine, operation-aborting failures.
*
* `error` is intentionally NOT used here — it is reserved for failures that
* actually abort an operation, which log directly rather than through this
* best-effort-degradation helper.
*
* Contract for callers (#2283 review): only route a failure here when the
* caller ALSO surfaces the degradation in its result (a `partial` flag,
* `failed_files`, `traversalComplete:false`, …). A mutating or safety-critical
* path that would otherwise report success/clean (e.g. `rename` apply, the
* `detect_changes` safety gate) MUST set that result-level signal — `warn`
* alone is not a substitute for an honest result.
*/
function logQueryError(context: string, err: unknown): void {
const msg = err instanceof Error ? err.message : String(err);
logger.error({ context, err: msg }, 'GitNexus query failed');
if (isBenignMissingTableError(err)) {
logger.debug({ context, err: msg }, 'GitNexus query skipped (missing optional data)');
return;
}
logger.warn({ context, err: msg }, 'GitNexus query failed (degraded)');
}
/**
@ -276,7 +311,12 @@ function logQueryError(context: string, err: unknown): void {
*/
function isBenignMissingTableError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err ?? '');
return /does not exist|no such (table|label|rel)|unknown (table|label)|not (defined|found)/i.test(
// The `not (defined|found)` arm is scoped to a schema object (table/label/
// rel/column/property), mirroring lbug-adapter's isMissingColumnError
// (`/(table|column|property).*not found/i`): an unscoped "not found" matched
// operation failures like `rg: not found` (ripgrep absent) or `Symbol not
// found`, which this helper would then silently demote to `debug` (#2283).
return /does not exist|no such (table|label|rel)|unknown (table|label)|(table|label|rel|column|property)[^\n]*\bnot (defined|found)\b/i.test(
msg,
);
}
@ -596,12 +636,164 @@ export class LocalBackend {
query: (r, p) => this.query(r as RepoHandle, p),
impactByUid: (id, uid, d, o) => this.impactByUid(id, uid, d, o),
context: (r, p) => this.context(r as RepoHandle, p),
trace: (r, p) => this.trace(r as RepoHandle, p),
resolveSymbol: (r, q) => this.resolveSymbolForGroup(r as RepoHandle, q),
pdgFlows: (r, anchor, opts) => this.pdgFlowsForGroup(r as RepoHandle, anchor, opts),
};
this.groupToolSvc = new GroupService(port);
}
return this.groupToolSvc;
}
/**
* Adapt the shared symbol resolver to the GroupToolPort contract. Used by the
* cross-repo trace path to locate which member repo an endpoint lives in and
* recover its node id (== bridge `Contract.symbolUid`).
*/
private async resolveSymbolForGroup(
repo: RepoHandle,
query: { name?: string; uid?: string; file_path?: string },
): Promise<GroupSymbolResolution> {
await this.ensureInitialized(repo);
const outcome = await this.resolveSymbolCandidates(
repo,
{ uid: query.uid, name: query.name },
{ file_path: query.file_path },
);
if (outcome.kind === 'ok') {
const s = outcome.symbol;
return {
kind: 'ok',
symbol: {
id: s.id,
name: s.name,
type: s.type,
filePath: s.filePath,
startLine: s.startLine,
endLine: s.endLine,
},
};
}
if (outcome.kind === 'ambiguous') {
return {
kind: 'ambiguous',
candidates: outcome.candidates.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
filePath: c.filePath,
startLine: c.startLine,
})),
};
}
return { kind: 'not_found' };
}
/**
* Intra-procedural REACHING_DEF data-flow for a single anchor symbol, adapted
* to the GroupToolPort contract. Reuses the same anchor + `flows` query as the
* `pdg_query` tool. `available:false` (not an error) when the repo has no PDG
* `flows` layer, so the cross-repo trace degrades to call-level hops.
*/
private async pdgFlowsForGroup(
repo: RepoHandle,
anchor: { name?: string; uid?: string; file_path?: string },
opts: { limit?: number },
): Promise<GroupPdgFlowResult> {
try {
await this.ensureInitialized(repo);
return await this._pdgFlowsForGroupImpl(repo, anchor, opts);
} catch {
// Enrichment is auxiliary — never let a PDG query failure fail the trace.
return { available: false, hops: [] };
}
}
/**
* Intra-procedural REACHING_DEF data-flow within the anchor symbol's block
* span. Reuses the same anchored, bind-param-only `flows` query as
* `pdg_query` (no rel-property index ⇒ the BasicBlock id-prefix + line-span
* anchor IS the bound). The anchor is resolved by UID when available (the
* boundary symbol is known precisely), avoiding the name-ambiguity the
* by-name `resolveBlockAnchor` path can hit. Data flow never crosses the repo
* boundary — this only describes how values move toward the boundary call
* inside one function.
*/
private async _pdgFlowsForGroupImpl(
repo: RepoHandle,
anchor: { name?: string; uid?: string; file_path?: string },
opts: { limit?: number },
): Promise<GroupPdgFlowResult> {
const rawLimit = opts.limit ?? PDG_QUERY_DEFAULT_LIMIT;
const limit =
Number.isInteger(rawLimit) && rawLimit >= 1 && rawLimit <= PDG_QUERY_MAX_LIMIT
? rawLimit
: PDG_QUERY_DEFAULT_LIMIT;
// Meta probe: layer present iff the flows cap is stamped. `false` is a
// definitive absence (degrade to call-level); `undefined` is unreadable
// meta (fall through and infer presence from rows found).
const pdgStamped = await pdgStampForMode(repo.lbugPath, 'flows');
if (pdgStamped === false) return { available: false, hops: [] };
// Resolve the anchor symbol (UID is precise; fall back to name/file).
const resolved = await this.resolveSymbolCandidates(
repo,
{ uid: anchor.uid, name: anchor.name },
{ file_path: anchor.file_path },
);
if (resolved.kind !== 'ok') {
// Layer may exist but we couldn't anchor — report availability from the
// stamp so the caller's note reflects the layer, not the miss.
return { available: pdgStamped === true, hops: [] };
}
const sym = resolved.symbol;
// Same span-anchored clause as resolveBlockAnchor's symbol branch: the
// BasicBlock startLine is 1-based vs the 0-based symbol span, so shift both
// bounds +1. `idPrefix`/`symStart`/`symEnd` are bind params; the edge type
// is a hardcoded literal — no user string is ever interpolated.
const hasSpan =
typeof sym.startLine === 'number' &&
typeof sym.endLine === 'number' &&
sym.endLine >= sym.startLine;
const idPrefix = `BasicBlock:${sym.filePath}:`;
const anchorClause = hasSpan
? 'a.id STARTS WITH $idPrefix AND a.startLine >= $symStart AND a.startLine <= $symEnd'
: 'a.id STARTS WITH $idPrefix';
const queryParams: Record<string, unknown> = hasSpan
? { idPrefix, symStart: sym.startLine + 1, symEnd: sym.endLine + 1 }
: { idPrefix };
const rows = await executeParameterized(
repo.lbugPath,
`MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)
WHERE r.type = 'REACHING_DEF' AND ${anchorClause}
RETURN a.startLine AS defLine, b.startLine AS useLine, b.text AS useText, r.reason AS reason
ORDER BY useLine, defLine, reason
LIMIT ${limit + 1}`,
queryParams,
);
const truncated = rows.length > limit;
const capped = truncated ? rows.slice(0, limit) : rows;
const hops: GroupPdgFlowHop[] = capped.map((r: Record<string, unknown>) => ({
// Number()/String() coerce the LadybugDB object/tuple cell; a bare
// `as number` cast on a nullish cell would surface NaN downstream.
line: Number(r.useLine ?? r[1] ?? 0),
text: String(r.useText ?? r[2] ?? '').trim(),
variable: decodeReachingDefReason(String(r.reason ?? r[3] ?? '')).name || undefined,
}));
const available = pdgStamped === true || hops.length > 0;
return {
available,
...(hops[0]?.variable ? { variable: hops[0].variable } : {}),
hops,
...(truncated ? { truncated: true } : {}),
};
}
/** Close all pooled LadybugDB connections (CLI one-shot; optional for long-lived MCP). */
async dispose(): Promise<void> {
await closeLbug();
@ -1323,7 +1515,7 @@ export class LocalBackend {
// — third-party MCP clients may legitimately send "query", so the alias is not slated
// for removal even if Claude Code's argument handling later changes.
if (
(method === 'impact' || method === 'query' || method === 'context') &&
(method === 'impact' || method === 'query' || method === 'context' || method === 'trace') &&
typeof p.repo === 'string' &&
p.repo.startsWith('@')
) {
@ -1797,9 +1989,14 @@ export class LocalBackend {
try {
ftsResponse = await searchFTSFromLbug(query, limit, repo.lbugPath);
} catch (err: any) {
logger.error(
// Swallowed, gracefully-degraded failure: the search falls back to
// semantic-only (a valid result), and the most common cause is simply an
// un-indexed FTS extension — a normal configuration, not an operation
// error. Logged at warn (matching the sibling import-failure fallback
// above), never error, so it does not raise a false alarm.
logger.warn(
{ err: err.message },
'GitNexus: BM25/FTS search failed (FTS indexes may not exist) -',
'GitNexus: BM25/FTS search failed (FTS indexes may not exist) — falling back to semantic-only',
);
return { results: [], ftsUsed: false };
}
@ -3667,6 +3864,9 @@ export class LocalBackend {
// Map diff hunks to indexed symbols via range overlap
const changedSymbols: any[] = [];
// Set if a swallowed graph query fails below — surfaces `partial:true` so a
// degraded run cannot report a false-clean `risk_level:'low'` (#2283).
let queryDegraded = false;
for (const fileDiff of fileDiffs) {
if (fileDiff.hunks.length === 0) continue;
@ -3714,6 +3914,12 @@ export class LocalBackend {
}
} catch (e) {
logQueryError('detect-changes:file-symbols', e);
// The symbol query failed: changedSymbols stays empty and the result
// would otherwise look like a clean no-op (`changed_count:0`,
// `risk_level:'low'`). detect_changes is the pre-commit safety gate, so
// flag the result `partial` rather than let a swallowed failure
// masquerade as "nothing changed" (#2283).
queryDegraded = true;
}
}
@ -3752,6 +3958,7 @@ export class LocalBackend {
}
} catch (e) {
logQueryError('detect-changes:process-lookup', e);
queryDegraded = true;
}
}
@ -3774,6 +3981,9 @@ export class LocalBackend {
},
changed_symbols: changedSymbols,
affected_processes: Array.from(affectedProcesses.values()),
// A swallowed query failure makes the counts/risk above incomplete — tell
// the caller so the safety gate isn't trusted as a clean result (#2283).
...(queryDegraded && { partial: true }),
};
}
@ -3974,6 +4184,7 @@ export class LocalBackend {
const allChanges = Array.from(changes.values());
const totalEdits = allChanges.reduce((sum, c) => sum + c.edits.length, 0);
const failedFiles: string[] = [];
if (!dry_run) {
// Apply edits to files
for (const change of allChanges) {
@ -3984,13 +4195,17 @@ export class LocalBackend {
content = content.replace(regex, new_name);
await fs.writeFile(fullPath, content, 'utf-8');
} catch (e) {
// A swallowed write failure must not be reported as a full success
// (#2283): record the file so the result can degrade to 'partial'
// with the unwritten files listed, rather than masquerading as done.
logQueryError('rename:apply-edit', e);
failedFiles.push(change.file_path);
}
}
}
return {
status: 'success',
status: failedFiles.length > 0 ? 'partial' : 'success',
old_name: oldName,
new_name,
files_affected: allChanges.length,
@ -3999,6 +4214,7 @@ export class LocalBackend {
text_search_edits: astSearchEdits,
changes: allChanges,
applied: !dry_run,
...(failedFiles.length > 0 && { failed_files: failedFiles }),
};
}
@ -4039,6 +4255,22 @@ export class LocalBackend {
};
}
// A single-repo trace needs a target. Omitting `to` is the destination-trace
// shorthand, but that only exists for a cross-repo @group trace — reject a
// to-less single-repo call with an actionable error rather than the opaque
// "Target symbol 'undefined' not found".
const hasTo =
(typeof params.to === 'string' && params.to.trim() !== '') ||
(typeof params.to_uid === 'string' && params.to_uid.trim() !== '');
if (!hasTo) {
return {
status: 'error',
error: 'trace requires `to` (or `to_uid`) for a single-repo trace.',
suggestion:
'Pass a target symbol, or use repo:"@<group>" and omit `to` to trace `from` to its HTTP destination.',
};
}
const fromOutcome = await this.resolveSymbolCandidates(
repo,
{ uid: params.from_uid, name: params.from },
@ -4316,9 +4548,21 @@ export class LocalBackend {
}
const mode = modeResult.mode;
// #2279: some MCP client/agent adapters serialize an *omitted* optional
// numeric field as `0` rather than dropping it, so callgraph calls arrive
// carrying a spurious `line: 0`. `line` is meaningless on the callgraph path
// (the symbol→symbol BFS has no statement notion), so treat a literal `0`
// there as omitted and let the normal traversal run. The coercion is
// deliberately narrow — only the literal `0`, only when mode !== 'pdg':
// a genuine positive `line` on callgraph still errors (real mode mistake),
// negative/fractional values still error, and pdg mode is untouched (the
// normalization is an identity there, so `line: 0` is still rejected below —
// there is no 1-based source line `0` to anchor on).
const effectiveLine = mode !== 'pdg' && params.line === 0 ? undefined : params.line;
// `line` is a PDG-only statement anchor. Reject it on the callgraph path
// rather than silently ignore (the symbol→symbol BFS has no statement notion).
if (params.line !== undefined && mode !== 'pdg') {
if (effectiveLine !== undefined && mode !== 'pdg') {
return {
error: `Parameter 'line' is only supported with mode:'pdg' (it anchors the dependence slice on a statement). Remove it or set mode:'pdg'.`,
target: { name: params.target },
@ -4329,8 +4573,8 @@ export class LocalBackend {
}
// A provided `line` must be a positive integer.
if (
params.line !== undefined &&
(!Number.isInteger(params.line) || (params.line as number) < 1)
effectiveLine !== undefined &&
(!Number.isInteger(effectiveLine) || (effectiveLine as number) < 1)
) {
// Line param fails validation before target resolution → partial-but-typed
// target on the pdg path (typed PdgImpactTarget, not an inline literal).
@ -4666,7 +4910,11 @@ export class LocalBackend {
symType,
direction,
maxDepth,
line: params.line,
// Use the normalized line, not raw params.line, so the gate and the
// engine share one source of truth (#2283). Identity in pdg mode today
// — effectiveLine === params.line when mode === 'pdg' — but this stays
// correct if the normalization ever stops being an identity here.
line: effectiveLine,
limit: Number.isFinite(params.limit) ? params.limit : 100,
// KTD2 extraction-seam discipline: hand the engine its DB dependency
// explicitly rather than `this.`-binding it. LocalBackend owns repo
@ -5782,6 +6030,26 @@ export class LocalBackend {
if (resolved.ok === false) return { error: resolved.error };
const svc = this.getGroupService();
if (method === 'trace') {
// Cross-repo trace resolves `from`/`to` across ALL members (it does not
// anchor on a single member like impact/query/context), so the member
// path in `@group/path` is advisory here — `resolved` above still
// validates that the group exists. groupTrace owns cross-member
// resolution and the single-boundary bridge crossing.
const traceArgs: Record<string, unknown> = { name: groupName };
if (params.from !== undefined) traceArgs.from = params.from;
if (params.to !== undefined) traceArgs.to = params.to;
if (params.from_uid !== undefined) traceArgs.from_uid = params.from_uid;
if (params.to_uid !== undefined) traceArgs.to_uid = params.to_uid;
if (params.from_file !== undefined) traceArgs.from_file = params.from_file;
if (params.to_file !== undefined) traceArgs.to_file = params.to_file;
if (params.maxDepth !== undefined) traceArgs.maxDepth = params.maxDepth;
if (params.crossDepth !== undefined) traceArgs.crossDepth = params.crossDepth;
if (params.includeTests !== undefined) traceArgs.includeTests = params.includeTests;
if (params.pdg !== undefined) traceArgs.pdg = params.pdg;
if (params.limit !== undefined) traceArgs.limit = params.limit;
return svc.groupTrace(traceArgs);
}
if (method === 'impact') {
// KTD5/KTD12 — validate `mode` at the group-forward boundary too (the
// JSON-schema enum is advisory). An invalid mode errors; `mode:'pdg'` is

View file

@ -469,9 +469,14 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
},
line: {
type: 'integer',
minimum: 1,
// `minimum: 0` (not 1) so strict client/agent adapters that materialize
// an omitted optional numeric field as `0` do not reject the request
// before sending (#2279). A positive line is still required for a real
// pdg anchor — the backend enforces that — but `0`/omitted means "no
// statement anchor" and is tolerated on the callgraph path.
minimum: 0,
description:
"1-based source line — PDG statement anchor (mode:'pdg'). Seeds affectedStatements on the statement at this line; inter-procedural symbols are still returned in interproceduralByDepth/pdgInterprocedural and the compatibility byDepth bucket.",
"1-based source line — PDG statement anchor (mode:'pdg'). Seeds affectedStatements on the statement at this line; inter-procedural symbols are still returned in interproceduralByDepth/pdgInterprocedural and the compatibility byDepth bucket. Omit line for whole-symbol pdg (whole-symbol reach + diagnostics); a positive line anchors a statement slice. Literal 0 is tolerated only as an omitted-line compatibility sentinel on the callgraph path and is rejected for mode:'pdg'.",
},
file_path: {
type: 'string',
@ -791,7 +796,11 @@ WHEN TO USE: Debugging "how does A reach B?" — answers in one call what would
Traverses CALLS edges plus HAS_METHOD (class → member) edges, so a trace can descend from a class into its methods. Each hop's edge type is reported in edges[], so call hops and containment hops remain distinguishable.
Returns: ordered hops with file:line, and an aligned edges[] of edge type + confidence. When no path exists, reports the furthest reachable node so you know where the chain breaks (and truncated: true if a traversal cap was hit first).`,
Returns: ordered hops with file:line, and an aligned edges[] of edge type + confidence. When no path exists, reports the furthest reachable node so you know where the chain breaks (and truncated: true if a traversal cap was hit first).
CROSS-REPO (experimental): pass repo as "@groupName" to trace across repositories in a group. When from/to live in different member repos, the trace stitches the two repo-local segments across a single ContractLink boundary (e.g. an HTTP consumer→provider link), clamped to one crossing. The result adds crossings[] (the bridged contract with matchType/confidence), tags each hop with its member repo, and a notes[] channel for degraded states. The boundary hop is reported with edge type CONTRACT_LINK. Pass pdg:true to also attach the intra-procedural data-flow (REACHING_DEF) for boundary-adjacent segments when those repos were indexed with --pdg; absent a PDG layer it degrades to call-level hops with a note.
DESTINATION TRACE (cross-repo): for an "@groupName" trace, OMIT to/to_uid/to_file to trace 'from' to wherever its outgoing HTTP call lands. The result ends at the provider endpoint (reported by route + file even when the handler is an anonymous function with no nameable symbol). This is the way to follow a client call to a backend handler you cannot name.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
@ -799,7 +808,11 @@ Returns: ordered hops with file:line, and an aligned edges[] of edge type + conf
from: { type: 'string', description: 'Source symbol name' },
from_uid: { type: 'string', description: 'Source symbol UID (zero-ambiguity)' },
from_file: { type: 'string', description: 'Source file path hint for disambiguation' },
to: { type: 'string', description: 'Target symbol name' },
to: {
type: 'string',
description:
"Target symbol name. Omit (with to_uid/to_file) on an @group trace to trace 'from' to its HTTP destination.",
},
to_uid: { type: 'string', description: 'Target symbol UID (zero-ambiguity)' },
to_file: { type: 'string', description: 'Target file path hint for disambiguation' },
maxDepth: {
@ -814,9 +827,32 @@ Returns: ordered hops with file:line, and an aligned edges[] of edge type + conf
description: 'Include test-file symbols in traversal (default: false)',
default: false,
},
pdg: {
type: 'boolean',
description:
'Cross-repo only (experimental): attach intra-procedural REACHING_DEF data-flow for boundary-adjacent segments when the repo has a --pdg layer. Default false.',
default: false,
},
crossDepth: {
type: 'number',
description:
'Cross-repo only: number of ContractLink boundaries to cross. Only 1 is supported today (multi-hop deferred); a direct caller that passes a higher value gets it clamped to 1 with a notes[] entry.',
default: 1,
minimum: 1,
maximum: 1,
},
limit: {
type: 'number',
description:
'Cross-repo + pdg:true only: max REACHING_DEF data-flow hops attached per boundary-adjacent segment (default 50, max 200). When a segment dataFlow is truncated, re-issue with a higher limit.',
default: 50,
minimum: 1,
maximum: 200,
},
repo: {
type: 'string',
description: 'Repository name or path. Omit if only one repo is indexed.',
description:
'Repository name or path, or "@groupName" / "@groupName/memberPath" for a cross-repo trace over a group. Omit if only one repo is indexed.',
},
},
required: [],

View file

@ -55,7 +55,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// the main thread (the #1983 OOM). Because the two stores share this version,
// any future change to the `ParsedFile` serialization shape MUST bump
// SCHEMA_BUMP so both invalidate in lockstep.
const SCHEMA_BUMP = 6; // #2082 M2: cfgSideChannel gained bindings + per-block statement facts
const SCHEMA_BUMP = 7; // #2138 Part 2: ExtractedDecoratorRoute gained `handlerName` (route handler symbol resolution)
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -0,0 +1,26 @@
package fixtures
// Functional (SAM) interfaces — the `fun interface` modifier.
// Before tree-sitter-kotlin gained `fun interface` support (fwcd #169), the
// vendored 0.3.8 grammar parsed these as an ERROR node and dropped the whole
// declaration, so neither the interface nor its abstract method was extracted.
fun interface Clicker {
fun onClick(id: Int): Boolean
}
fun interface Mapper<T> {
fun map(value: T): String
}
// A regular interface alongside, to confirm both shapes coexist.
interface Plain {
fun plain(): Int
}
class Button : Plain {
override fun plain(): Int = 0
fun bind(clicker: Clicker) {
clicker.onClick(1)
}
}

View file

@ -0,0 +1,374 @@
/**
* U6 — Cross-repo trace, evaluation-first end-to-end.
*
* Stands up TWO real LadybugDB indexes (a "frontend" consumer repo and a
* "backend" provider repo), a real group bridge linking a consumer symbol to a
* provider symbol, and a real LocalBackend with both repos registered. Then it
* drives the public `callTool('trace', { repo: '@group', pdg: true })` and
* asserts the stitched cross-repo path AND the real REACHING_DEF data-flow
* enrichment — exercising every new query path against a real engine:
* resolveSymbolCandidates, _traceImpl, the bridge `listCrossingsBetween` pair
* query, and `_pdgFlowsForGroupImpl`.
*
* The two indexes are built sequentially with the writable core adapter (one
* open writer at a time) and read back through the MCP pool adapter the backend
* opens lazily. A real two-repo *analyze* pipeline is heavier than this gate
* needs; hand-persisting the minimal real graph keeps it deterministic while
* still hitting real LadybugDB Cypher.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { LocalBackend } from '../../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../../src/storage/repo-manager.js';
import { writeBridge } from '../../../src/core/group/bridge-db.js';
import type { CrossLink } from '../../../src/core/group/types.js';
import { makeContract } from '../../unit/group/fixtures.js';
vi.mock('../../../src/storage/repo-manager.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/storage/repo-manager.js')>();
return {
...actual,
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
// No meta.json for the seeded DBs — pdgStampForMode degrades to the
// row-existence probe (the seeded-DB reality, like pdg-query.test.ts).
loadMeta: vi.fn().mockResolvedValue(null),
};
});
// LadybugDB close-then-reopen is Windows-flaky (file lock held until process
// exit); the bridge write+read and the sequential two-DB build both hit it.
const describeReopen = process.platform === 'win32' ? describe.skip : describe;
/** Restore an env var to a prior value, or unset it if there was none. */
function restoreEnvVar(key: string, prev: string | undefined): void {
if (prev === undefined) delete process.env[key];
else process.env[key] = prev;
}
interface NodeSpec {
label: 'Function' | 'BasicBlock';
props: Record<string, unknown>;
}
interface RelSpec {
type: 'CALLS' | 'REACHING_DEF';
srcLabel: 'Function' | 'BasicBlock';
dstLabel: 'Function' | 'BasicBlock';
src: string;
dst: string;
reason?: string;
}
/** Build a real lbug DB at `lbugPath`, seeding nodes + rels via the writer. */
async function buildRepoDB(lbugPath: string, nodes: NodeSpec[], rels: RelSpec[]): Promise<void> {
const core = await import('../../../src/core/lbug/lbug-adapter.js');
await core.initLbug(lbugPath); // creates the full schema
try {
for (const n of nodes) {
const assignments = Object.keys(n.props)
.map((k) => `${k}: $${k}`)
.join(', ');
await core.executePrepared(`CREATE (x:${n.label} {${assignments}})`, n.props);
}
for (const r of rels) {
await core.executePrepared(
`MATCH (a:${r.srcLabel} {id: $src}), (b:${r.dstLabel} {id: $dst})
CREATE (a)-[:CodeRelation {type: '${r.type}', confidence: 1.0, reason: $reason, step: 0}]->(b)`,
{ src: r.src, dst: r.dst, reason: r.reason ?? '' },
);
}
await core.flushWAL();
} finally {
await core.closeLbug();
}
}
describeReopen('cross-repo trace e2e (two real indexes + bridge)', () => {
let tmpHome: string;
let storageFE: string;
let storageBE: string;
let backend: LocalBackend;
let prevHome: string | undefined;
beforeAll(async () => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cross-trace-e2e-'));
storageFE = path.join(tmpHome, 'fe-storage');
storageBE = path.join(tmpHome, 'be-storage');
fs.mkdirSync(storageFE, { recursive: true });
fs.mkdirSync(storageBE, { recursive: true });
// ── Frontend (consumer) index: checkout -> callUsers, with a REACHING_DEF
// data-flow inside callUsers (def line 3 -> use line 4 of `userId`). ──
await buildRepoDB(
path.join(storageFE, 'lbug'),
[
{
label: 'Function',
props: {
id: 'fn:checkout',
name: 'checkout',
filePath: 'src/checkout.ts',
startLine: 10,
endLine: 14,
},
},
{
label: 'Function',
props: {
id: 'fn:callUsers',
name: 'callUsers',
filePath: 'src/api.ts',
startLine: 2,
endLine: 6,
},
},
{
label: 'BasicBlock',
props: {
id: 'BasicBlock:src/api.ts:2:0:0',
filePath: 'src/api.ts',
startLine: 3,
endLine: 3,
text: 'const userId = req.params.id',
callees: '',
calleeIds: '',
},
},
{
label: 'BasicBlock',
props: {
id: 'BasicBlock:src/api.ts:2:0:1',
filePath: 'src/api.ts',
startLine: 4,
endLine: 4,
text: 'fetchUsers(userId)',
callees: '',
calleeIds: '',
},
},
],
[
{
type: 'CALLS',
srcLabel: 'Function',
dstLabel: 'Function',
src: 'fn:checkout',
dst: 'fn:callUsers',
},
{
type: 'REACHING_DEF',
srcLabel: 'BasicBlock',
dstLabel: 'BasicBlock',
src: 'BasicBlock:src/api.ts:2:0:0',
dst: 'BasicBlock:src/api.ts:2:0:1',
reason: 'userId',
},
],
);
// ── Backend (provider) index: handleUsers -> getUsers. No PDG layer. ──
await buildRepoDB(
path.join(storageBE, 'lbug'),
[
{
label: 'Function',
props: {
id: 'fn:handleUsers',
name: 'handleUsers',
filePath: 'src/routes.ts',
startLine: 5,
endLine: 9,
},
},
{
label: 'Function',
props: {
id: 'fn:getUsers',
name: 'getUsers',
filePath: 'src/users.ts',
startLine: 1,
endLine: 4,
},
},
],
[
{
type: 'CALLS',
srcLabel: 'Function',
dstLabel: 'Function',
src: 'fn:handleUsers',
dst: 'fn:getUsers',
},
],
);
// ── Group config + bridge (consumer callUsers -> provider handleUsers). ──
const groupDir = path.join(tmpHome, 'groups', 'grp');
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(
path.join(groupDir, 'group.yaml'),
`version: 1
name: grp
description: ""
repos:
app/frontend: reg-fe
app/backend: reg-be
links: []
packages: {}
detect:
http: true
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`,
);
const consumer = makeContract({
repo: 'app/frontend',
role: 'consumer',
symbolUid: 'fn:callUsers',
symbolRef: { filePath: 'src/api.ts', name: 'callUsers' },
symbolName: 'callUsers',
contractId: 'http::GET::/api/users',
});
const provider = makeContract({
repo: 'app/backend',
role: 'provider',
symbolUid: 'fn:handleUsers',
symbolRef: { filePath: 'src/routes.ts', name: 'handleUsers' },
symbolName: 'handleUsers',
contractId: 'http::GET::/api/users',
});
const link: CrossLink = {
from: { repo: 'app/frontend', symbolUid: 'fn:callUsers', symbolRef: consumer.symbolRef },
to: { repo: 'app/backend', symbolUid: 'fn:handleUsers', symbolRef: provider.symbolRef },
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'exact',
confidence: 0.9,
};
await writeBridge(groupDir, {
contracts: [consumer, provider],
crossLinks: [link],
repoSnapshots: {},
missingRepos: [],
});
// ── Register both repos + a real backend (lazy pool open). ──
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'reg-fe',
path: path.join(tmpHome, 'fe-repo'),
storagePath: storageFE,
indexedAt: new Date(0).toISOString(),
lastCommit: 'fe',
stats: { files: 1, nodes: 2, communities: 0, processes: 0 },
},
{
name: 'reg-be',
path: path.join(tmpHome, 'be-repo'),
storagePath: storageBE,
indexedAt: new Date(0).toISOString(),
lastCommit: 'be',
stats: { files: 1, nodes: 2, communities: 0, processes: 0 },
},
]);
prevHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome;
backend = new LocalBackend();
await backend.init();
}, 120_000);
afterAll(async () => {
await backend?.dispose();
restoreEnvVar('GITNEXUS_HOME', prevHome);
fs.rmSync(tmpHome, { recursive: true, force: true });
}, 120_000);
it('stitches checkout -> getUsers across the bridge with real PDG enrichment', async () => {
const result = await backend.callTool('trace', {
repo: '@grp',
from: 'checkout',
to: 'getUsers',
pdg: true,
});
expect(result).toMatchObject({
status: 'ok',
crossings: [
{
fromRepo: 'app/frontend',
toRepo: 'app/backend',
contractId: 'http::GET::/api/users',
matchType: 'exact',
},
],
});
// The stitched path spans both repos in order, tagged by member repo.
const hops = (result.hops as Array<{ name: string; repo: string }>).map((h) => ({
name: h.name,
repo: h.repo,
}));
expect(hops).toEqual([
{ name: 'checkout', repo: 'app/frontend' },
{ name: 'callUsers', repo: 'app/frontend' },
{ name: 'handleUsers', repo: 'app/backend' },
{ name: 'getUsers', repo: 'app/backend' },
]);
// The boundary hop carries the CONTRACT_LINK edge.
const edgeTypes = (result.edges as Array<{ relType: string }>).map((e) => e.relType);
expect(edgeTypes).toContain('CONTRACT_LINK');
// Real REACHING_DEF enrichment of the consumer segment (intra-procedural).
expect(result.dataFlow).toEqual([
expect.objectContaining({
repo: 'app/frontend',
variable: 'userId',
hops: expect.arrayContaining([expect.objectContaining({ line: 4, variable: 'userId' })]),
}),
]);
// The provider repo has no PDG layer → a degraded note, but the trace is ok.
expect(result.notes).toEqual(
expect.arrayContaining([expect.stringContaining('No PDG layer in app/backend')]),
);
});
// A SECOND @group call in the same process — exercises the bridge read-only
// reopen that previously failed (closeBridgeDb used to CHECKPOINT read-only
// handles, leaving a lock artifact). Now fixed, so repeated @group traces work.
it('omitting pdg yields the same stitched path with no data-flow enrichment', async () => {
const result = await backend.callTool('trace', {
repo: '@grp',
from: 'checkout',
to: 'getUsers',
});
expect(result.status).toBe('ok');
expect(result.dataFlow).toBeUndefined();
expect((result.crossings as unknown[]).length).toBe(1);
expect((result.hops as Array<{ name: string }>).map((h) => h.name)).toEqual([
'checkout',
'callUsers',
'handleUsers',
'getUsers',
]);
});
it('single-repo trace against one member is unchanged (no group routing)', async () => {
const result = await backend.callTool('trace', {
repo: 'reg-fe',
from: 'checkout',
to: 'callUsers',
});
expect(result.status).toBe('ok');
// Plain single-repo result shape — no crossings field.
expect(result.crossings).toBeUndefined();
expect(result.hops.map((h: { name: string }) => h.name)).toEqual(['checkout', 'callUsers']);
});
});

View file

@ -0,0 +1,114 @@
/**
* End-to-end validation of the INLINE provider source-scan containment path
* (#2276).
*
* The unit suite (`test/unit/group/http-route-extractor.test.ts`) proves the
* resolver logic by MOCKING `CONTAINING_QUERY` with hand-picked spans. That
* leaves one assumption unverified: that the REAL ingestion pipeline records a
* Go enclosing function with a 0-based span that actually contains the emitted
* call-site line. This test closes that gap.
*
* It runs the real pipeline over a Go file whose `http.HandleFunc` handler is an
* inline `func(){…}` (the issue's headline Go example), persists the resulting
* graph into a real LadybugDB, and runs the production `HttpRouteExtractor`
* against the real executor. The provider must resolve to the containing
* `main()` symbol via line-span containment (`source_scan_resolved`) — not the
* file-level fallback. Go does not index anonymous func literals as symbols
* (only `function_declaration`/`method_declaration`), so the innermost
* containing symbol is `main` itself.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { HttpRouteExtractor } from '../../src/core/group/extractors/http-route-extractor.js';
import type { CypherExecutor } from '../../src/core/group/contract-extractor.js';
import type { RepoHandle } from '../../src/core/group/types.js';
let tmpBase: string;
let repoDir: string;
let storagePath: string;
let dbPath: string;
beforeAll(async () => {
// Atomic, unique temp dir (fs.mkdtemp) — avoids the predictable
// os.tmpdir()+name pattern CodeQL flags as an insecure temporary file.
tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-http-inline-e2e-'));
repoDir = path.join(tmpBase, 'repo');
storagePath = path.join(tmpBase, '.gitnexus');
dbPath = path.join(storagePath, 'lbug');
await fs.mkdir(path.join(repoDir, 'cmd'), { recursive: true });
await fs.mkdir(dbPath, { recursive: true });
// net/http inline handler INSIDE main() — the #2276 Go example. Before this
// change the func literal was not even captured; now it emits name:null + the
// call-site line so it resolves to main() by containment.
await fs.writeFile(
path.join(repoDir, 'cmd', 'server.go'),
`package main
import "net/http"
func main() {
\thttp.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
\t\tw.Write([]byte("ok"))
\t})
\thttp.ListenAndServe(":8080", nil)
}
`,
);
const result = await runPipelineFromRepo(repoDir, () => {}, {});
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
await adapter.loadGraphToLbug(result.graph, tmpBase, storagePath);
}, 120_000);
afterAll(async () => {
try {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.closeLbug();
} catch {
/* may not have opened */
}
if (tmpBase) {
for (let attempt = 0; attempt < 5; attempt++) {
try {
await fs.rm(tmpBase, { recursive: true, force: true });
return;
} catch {
if (attempt < 4) await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
}
}
}
});
describe('inline Go provider handler resolves via real source-scan containment (#2276)', () => {
it('resolves an inline http.HandleFunc closure to the containing main() with a real symbolUid', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// Param-aware executor (CONTAINING_QUERY binds $filePath) — the same shape
// production passes to ContractExtractors.
const dbExecutor: CypherExecutor = (query, params = {}) =>
adapter.executePrepared(query, params);
const repo: RepoHandle = {
id: 'test-repo',
path: 'repo',
repoPath: repoDir,
storagePath,
};
const contracts = await new HttpRouteExtractor().extract(dbExecutor, repoDir, repo);
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/health',
);
expect(provider).toBeDefined();
// The real pipeline indexed main() with its true 0-based span; the emitted
// call-site line lands inside it, so containment yields a real symbolUid
// rather than the empty file-level fallback.
expect(provider?.symbolUid).toBeTruthy();
expect(provider?.symbolName).toBe('main');
expect(provider?.meta.extractionStrategy).toBe('source_scan_resolved');
});
});

View file

@ -2900,3 +2900,33 @@ describe('F52 — Kotlin companion-object properties', () => {
expect(getNodesByLabel(result, 'Property')).not.toContain('create');
});
});
// ---------------------------------------------------------------------------
// Functional (SAM) interfaces: `fun interface` (vendored grammar bump, fwcd #169)
// ---------------------------------------------------------------------------
describe('Kotlin functional (fun) interfaces', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'kotlin-fun-interface'), () => {});
}, 60000);
// Pre-fix, the 0.3.8 grammar parsed `fun interface` as an ERROR node and
// dropped the declaration, so Clicker/Mapper were never extracted.
it('extracts `fun interface` declarations as Interface nodes alongside a plain one', () => {
expect(getNodesByLabel(result, 'Interface')).toEqual(['Clicker', 'Mapper', 'Plain']);
expect(getNodesByLabel(result, 'Class')).toEqual(['Button']);
});
it('extracts the abstract methods of fun interfaces', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('onClick'); // Clicker (fun interface)
expect(methods).toContain('map'); // Mapper<T> (generic fun interface)
});
it('still resolves heritage on a class implementing a plain interface', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
expect(edgeSet(implements_)).toContain('Button → Plain');
});
});

View file

@ -0,0 +1,77 @@
/**
* Real-LadybugDB round trip for `Route.handlerSymbolId` (issue #2138, Part 2).
*
* The Part-1 analogue (`route-method-roundtrip.test.ts`) pins `Route.method`;
* this pins the second persisted Route column added in Part 2. It persists a
* `Route` node carrying `handlerSymbolId` through the real CSV generator + the
* production `COPY` path into a real LadybugDB, then runs the exact production
* `HANDLES_ROUTE_QUERY` and asserts the handler UID round-trips.
*
* Covers the three persistence points touched by Part 2's U2:
* - `ROUTE_SCHEMA` (schema.ts) — the `handlerSymbolId` column must exist
* - the Route CSV row (csv-generator.ts) — the value must be written
* - `getCopyQuery('Route')` (lbug-adapter.ts) — the COPY must load it
*/
import { it, expect } from 'vitest';
import path from 'path';
import fs from 'fs/promises';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { buildTestGraph } from '../helpers/test-graph.js';
import { streamAllCSVsToDisk } from '../../src/core/lbug/csv-generator.js';
import { HANDLES_ROUTE_QUERY } from '../../src/core/group/extractors/http-route-extractor.js';
const HANDLER_UID = 'Method:OrderController.java:create';
withTestLbugDB('route-handler-symbol-roundtrip', (handle) => {
it('persists Route.handlerSymbolId through CSV→COPY and HANDLES_ROUTE_QUERY returns it', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// 1. Route node carrying a resolved handlerSymbolId (what the routes phase
// now stamps when resolveRouteHandlerSymbols resolves the handler).
const graph = buildTestGraph([
{
id: 'Route:/api/orders',
label: 'Route',
name: '/api/orders',
filePath: 'OrderController.java',
extra: {
method: 'POST',
handlerSymbolId: HANDLER_UID,
responseKeys: [],
errorKeys: [],
middleware: [],
},
},
]);
// 2. Generate CSVs through the real generator.
const csvDir = path.join(handle.tmpHandle.dbPath, 'csv-handler-roundtrip');
const repoDir = path.join(handle.tmpHandle.dbPath, 'repo-handler-roundtrip');
await fs.mkdir(repoDir, { recursive: true });
await streamAllCSVsToDisk(graph, repoDir, csvDir);
// Sanity: route.csv header + row include the handlerSymbolId column/value.
const routeCsv = await fs.readFile(path.join(csvDir, 'route.csv'), 'utf-8');
expect(routeCsv.split('\n')[0]).toContain('handlerSymbolId');
expect(routeCsv).toContain(HANDLER_UID);
// 3. COPY the Route node via the production COPY query.
const routeCsvPath = path.join(csvDir, 'route.csv').replace(/\\/g, '/');
await adapter.executeQuery(adapter.getCopyQuery('Route', routeCsvPath));
// 4. Seed the handler File node + HANDLES_ROUTE edge.
await adapter.executeQuery(
`CREATE (:File {id: 'File:OrderController.java', name: 'OrderController.java', filePath: 'OrderController.java'})`,
);
await adapter.executeQuery(
`MATCH (f:File {id: 'File:OrderController.java'}), (r:Route {id: 'Route:/api/orders'})
CREATE (f)-[:CodeRelation {type: 'HANDLES_ROUTE', confidence: 1.0, reason: 'framework-route', step: 0}]->(r)`,
);
// 5. Run the EXACT production query and assert the handler UID round-trips.
const rows = (await adapter.executeQuery(HANDLES_ROUTE_QUERY)) as Record<string, unknown>[];
const row = rows.find((r) => String(r.routePath) === '/api/orders');
expect(row, 'HANDLES_ROUTE_QUERY returned no row for the seeded route').toBeTruthy();
expect(row!.handlerSymbolId).toBe(HANDLER_UID);
});
});

View file

@ -0,0 +1,251 @@
/**
* #2138 Part 2 · parse-skip proof + P1 regression guards.
*
* The win: for a file whose provider routes are fully covered by the graph in a
* `routeCoverage: 'complete'` language, `HttpRouteExtractor` skips the source
* scan AND the tree-sitter parse — the graph is authoritative. We spy the real
* `parseSourceSafe` to COUNT parses (deterministic, not wall-time).
*
* PHP/Laravel is the language used for the *win* scenarios: ingestion's Laravel
* route extraction is a superset of the group PHP scan, so PHP is `'complete'`.
*
* Java is deliberately `'partial'` (the graph provider set is a strict subset of
* the group Java scan — array-form, interface-inherited, and same-URL multi-verb
* routes have no graph Route node). The Java cases below are REGRESSION GUARDS:
* they prove those group-only routes survive because Java is never parse-skipped.
* If someone flips Java to `'complete'` without making ingestion provider-
* complete, these tests fail — exactly the #2138 P1 data-loss class.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// Count real parses by wrapping the actual parseSourceSafe.
const parseCalls: string[] = [];
vi.mock('../../src/core/tree-sitter/safe-parse.js', async (importActual) => {
const actual = await importActual<typeof import('../../src/core/tree-sitter/safe-parse.js')>();
return {
...actual,
parseSourceSafe: (parser: unknown, src: unknown) => {
parseCalls.push(typeof src === 'string' ? src : '<non-string>');
return (actual.parseSourceSafe as (p: unknown, s: unknown) => unknown)(parser, src);
},
};
});
import { HttpRouteExtractor } from '../../src/core/group/extractors/http-route-extractor.js';
const repo = { name: 'r', url: 'r' } as never;
beforeEach(() => {
parseCalls.length = 0;
});
function mkRepo(files: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-parse-skip-'));
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), content);
}
return dir;
}
/** HANDLES_ROUTE rows from a compact spec; CONTAINS/FETCHES return empty. */
function makeDb(
rows: Array<{ file: string; routePath: string; method: string; resolved: boolean }>,
) {
return vi.fn(async (query: string) => {
if (query.includes('HANDLES_ROUTE')) {
return rows.map((r, i) => ({
fileId: `File:${r.file}`,
filePath: r.file,
routePath: r.routePath,
routeMethod: r.method,
handlerSymbolId: r.resolved ? `Method:${r.file}:h${i}` : '',
routeSource: 'framework-route',
}));
}
return []; // CONTAINS (basename fallback is fine) + FETCHES (no consumers)
});
}
const providerPaths = (out: Awaited<ReturnType<HttpRouteExtractor['extract']>>) =>
out.filter((c) => c.role === 'provider').map((c) => `${c.meta.method}::${c.meta.path}`);
// ── PHP / Laravel — the `'complete'` language where the skip engages ──────────
const ROUTES_A = `<?php
Route::get('/api/a/list', 'AController@list');
`;
const ROUTES_B = `<?php
Route::post('/api/b/make', 'BController@make');
`;
describe('HttpRouteExtractor — PHP parse-skip for graph-covered files (#2138)', () => {
it('baseline: with no graph, every PHP file is parsed', async () => {
const dir = mkRepo({ 'routes_a.php': ROUTES_A, 'routes_b.php': ROUTES_B });
try {
const out = await new HttpRouteExtractor().extract(null, dir, repo);
expect(providerPaths(out)).toEqual(
expect.arrayContaining(['GET::/api/a/list', 'POST::/api/b/make']),
);
expect(parseCalls.length).toBeGreaterThanOrEqual(2);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('fully covered: zero PHP files parsed (the win)', async () => {
const dir = mkRepo({ 'routes_a.php': ROUTES_A, 'routes_b.php': ROUTES_B });
try {
const out = await new HttpRouteExtractor().extract(
makeDb([
{ file: 'routes_a.php', routePath: '/api/a/list', method: 'GET', resolved: true },
{ file: 'routes_b.php', routePath: '/api/b/make', method: 'POST', resolved: true },
]),
dir,
repo,
);
const providers = out.filter((c) => c.role === 'provider');
expect(providers.map((c) => c.meta.path)).toEqual(
expect.arrayContaining(['/api/a/list', '/api/b/make']),
);
expect(providers.every((c) => c.meta.extractionStrategy === 'graph_assisted')).toBe(true);
expect(parseCalls.length).toBe(0);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('mixed: an unresolved route falls back to a scan; the resolved file stays skipped', async () => {
const dir = mkRepo({ 'routes_a.php': ROUTES_A, 'routes_b.php': ROUTES_B });
try {
await new HttpRouteExtractor().extract(
makeDb([
{ file: 'routes_a.php', routePath: '/api/a/list', method: 'GET', resolved: true },
{ file: 'routes_b.php', routePath: '/api/b/make', method: 'POST', resolved: false },
]),
dir,
repo,
);
expect(parseCalls.some((s) => s.includes('/api/b/make'))).toBe(true); // B scanned
expect(parseCalls.some((s) => s.includes('/api/a/list'))).toBe(false); // A skipped
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('provider-covered file that ALSO calls out is still parsed (consumer not dropped)', async () => {
// routes_c.php is a Laravel provider AND a Laravel Http:: consumer.
const ROUTES_C = `<?php
Route::get('/api/c/list', 'CController@list');
Http::get('/api/inventory');
`;
const dir = mkRepo({ 'routes_c.php': ROUTES_C });
try {
const out = await new HttpRouteExtractor().extract(
makeDb([{ file: 'routes_c.php', routePath: '/api/c/list', method: 'GET', resolved: true }]),
dir,
repo,
);
expect(out.some((c) => c.role === 'provider' && c.meta.path === '/api/c/list')).toBe(true);
// The Http:: consumer lives only in source — it MUST survive because the
// consumer signal kept the file in the scan set (so it was parsed).
expect(parseCalls.some((s) => s.includes('/api/inventory'))).toBe(true);
expect(out.some((c) => c.role === 'consumer' && c.meta.path === '/api/inventory')).toBe(true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
// ── Java — `'partial'`, so the P1 group-only shapes must never be dropped ─────
describe('HttpRouteExtractor — Java parse-skip P1 regression guards (#2138)', () => {
it('array-form @GetMapping({"/a","/b"}) survives a co-located resolved route', async () => {
const AC = `package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
public class AController {
@GetMapping("/covered") public Object covered() { return null; }
@GetMapping({"/a","/b"}) public Object multi() { return null; }
}
`;
const dir = mkRepo({ 'AController.java': AC });
try {
// Graph resolves only /covered (ingestion has no array-form Route node).
const out = await new HttpRouteExtractor().extract(
makeDb([
{ file: 'AController.java', routePath: '/covered', method: 'GET', resolved: true },
]),
dir,
repo,
);
const paths = providerPaths(out);
// The array-form routes are graph-only-absent but survive via source scan.
expect(paths).toEqual(expect.arrayContaining(['GET::/a', 'GET::/b']));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('same-URL multi-verb (GET+POST /orders) keeps both verbs', async () => {
const OC = `package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
public class OrderController {
@GetMapping("/orders") public Object list() { return null; }
@PostMapping("/orders") public Object make() { return null; }
}
`;
const dir = mkRepo({ 'OrderController.java': OC });
try {
// Ingestion's URL-keyed Route node collapses to one verb; resolve only GET.
const out = await new HttpRouteExtractor().extract(
makeDb([
{ file: 'OrderController.java', routePath: '/orders', method: 'GET', resolved: true },
]),
dir,
repo,
);
const paths = providerPaths(out);
expect(paths).toEqual(expect.arrayContaining(['GET::/orders', 'POST::/orders']));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('interface-inherited Spring route survives on the implementing controller', async () => {
const IFACE = `package com.example;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/orders")
public interface OrderApi {
@GetMapping("/{id}") Object get(Long id);
}
`;
const CTRL = `package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
public class OrderController implements OrderApi {
@GetMapping("/direct") public Object direct() { return null; }
public Object get(Long id) { return null; }
}
`;
const dir = mkRepo({ 'OrderApi.java': IFACE, 'OrderController.java': CTRL });
try {
// Graph resolves only the controller-direct route; the inherited route is
// composed only by the group scanProject pass.
const out = await new HttpRouteExtractor().extract(
makeDb([
{ file: 'OrderController.java', routePath: '/direct', method: 'GET', resolved: true },
]),
dir,
repo,
);
const paths = providerPaths(out);
expect(paths).toEqual(expect.arrayContaining(['GET::/orders/{param}']));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

View file

@ -104,4 +104,27 @@ describe('Spring @RequestMapping route ingestion pipeline', () => {
const userRoutes = handlesRouteEdges.filter((e) => e.filePath.includes('UserController.java'));
expect(userRoutes.length).toBeGreaterThanOrEqual(1);
});
it('resolves the decorated handler method to a symbol UID on the Route node (Part 2 #2138)', () => {
// Find the Route node for the @GetMapping("/list") handler.
let routeNode: { properties: Record<string, unknown> } | undefined;
result.graph.forEachNode((n) => {
if (n.label === 'Route' && n.properties.name === '/api/users/list') {
routeNode = n;
}
});
expect(routeNode, 'Route node /api/users/list should exist').toBeTruthy();
// U0+U1: the decorated handler (listUsers) was captured and resolved to a
// real symbol UID, stamped on the Route node.
const handlerSymbolId = routeNode!.properties.handlerSymbolId;
expect(handlerSymbolId, 'Route node should carry handlerSymbolId').toBeTruthy();
// The id resolves to the listUsers handler symbol in UserController.java.
const handler = result.graph.getNode(String(handlerSymbolId));
expect(handler, 'handlerSymbolId should resolve to a graph node').toBeTruthy();
expect(handler!.properties.name).toBe('listUsers');
expect(String(handler!.properties.filePath)).toContain('UserController.java');
expect(['Method', 'Function']).toContain(handler!.label);
});
});

View file

@ -131,6 +131,7 @@ describe('Blade/template static route extraction', () => {
},
],
allDecoratorRoutes: [],
routeHandlerSymbols: new Map(),
} as unknown as ParseOutput;
const output = await routesPhase.execute(

View file

@ -9,6 +9,7 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import fsPromises from 'fs/promises';
import os from 'os';
import path from 'path';
@ -395,12 +396,23 @@ describe('LocalBackend.callTool', () => {
vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(new Error('bm25Results is not iterable'));
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', { query: 'auth' });
const cap = _captureLogger();
try {
const result = await backend.callTool('query', { query: 'auth' });
// Should still return a valid result shape (semantic-only fallback)
expect(result).toHaveProperty('processes');
expect(result).toHaveProperty('definitions');
expect(result).not.toHaveProperty('error');
// Should still return a valid result shape (semantic-only fallback)
expect(result).toHaveProperty('processes');
expect(result).toHaveProperty('definitions');
expect(result).not.toHaveProperty('error');
// The FTS fallback is a gracefully-degraded result, not an operation failure:
// it must log at warn (40), never error (50), matching its sibling
// import-failure fallback. Pins the severity against regression.
const fts = cap.records().find((r) => /BM25\/FTS search failed/.test(String(r.msg ?? '')));
expect(fts).toBeDefined();
expect(fts?.level).toBe(40);
} finally {
cap.restore();
}
});
it('skips vector index query when VECTOR is unsupported by the platform', async () => {
@ -600,6 +612,51 @@ describe('LocalBackend.callTool', () => {
groupQuerySpy.mockRestore();
});
// U3: `trace` with an @group repo routes to the cross-repo groupTrace path
// and forwards the trace params (incl. the experimental pdg/crossDepth flags).
it('group-mode trace routes to groupTrace and forwards trace params', async () => {
resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' });
const groupTraceSpy = vi
.spyOn(backend.getGroupService(), 'groupTrace')
.mockResolvedValue({ status: 'ok' });
await backend.callTool('trace', {
from: 'A',
to: 'B',
pdg: true,
crossDepth: 3,
repo: '@grp',
});
expect(groupTraceSpy).toHaveBeenCalledTimes(1);
const args = groupTraceSpy.mock.calls[0][0] as Record<string, unknown>;
expect(args).toMatchObject({ name: 'grp', from: 'A', to: 'B', pdg: true, crossDepth: 3 });
groupTraceSpy.mockRestore();
});
// U3: a non-@group trace must NOT route to groupTrace — single-repo behavior
// is untouched (here it resolves to not_found against the empty mocked graph).
it('single-repo trace does not route to groupTrace', async () => {
const groupTraceSpy = vi.spyOn(backend.getGroupService(), 'groupTrace');
vi.mocked(executeParameterized).mockResolvedValue([]);
const result = await backend.callTool('trace', { from: 'A', to: 'B' });
expect(groupTraceSpy).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: 'not_found' });
groupTraceSpy.mockRestore();
});
// The destination trace (omit `to`) is a cross-repo @group feature; a single-repo
// trace without `to` must error clearly, not return an opaque "symbol not found".
it('single-repo trace without `to` returns an actionable error', async () => {
const result = await backend.callTool('trace', { from: 'A' });
expect(result).toMatchObject({
status: 'error',
error: expect.stringContaining('requires `to`'),
});
});
// #2175 review: the MCP envelope is not schema-validated, so a client can send a
// non-string value for a string param. Resolve it to a friendly required-param error
// rather than throwing TypeError on `.trim()` (query() and cypher() both).
@ -1243,6 +1300,46 @@ describe('LocalBackend.callTool', () => {
expect(result.error).toContain('Either symbol_name or symbol_uid');
});
it('rename: a swallowed apply-edit write failure degrades to status:partial + failed_files (#2283)', async () => {
// Resolve the definition, no graph refs. readFile succeeds (so a def edit is
// recorded), but writeFile fails on apply — the failure is swallowed via
// logQueryError. The result must NOT report a clean success: it degrades to
// 'partial' and lists the unwritten file, instead of status:'success'.
(executeParameterized as any)
.mockResolvedValueOnce([
{
id: 'func:oldName',
name: 'oldName',
type: 'Function',
filePath: 'src/target.ts',
startLine: 1,
endLine: 5,
},
])
.mockResolvedValue([]);
const readSpy = vi
.spyOn(fsPromises, 'readFile')
.mockResolvedValue('function oldName() {}\n' as unknown as Buffer);
const writeSpy = vi
.spyOn(fsPromises, 'writeFile')
.mockRejectedValue(new Error('EACCES: permission denied'));
try {
const result = await backend.callTool('rename', {
symbol_name: 'oldName',
new_name: 'newName',
dry_run: false,
});
expect(result.status).toBe('partial');
expect(result.failed_files).toContain('src/target.ts');
// It DID attempt to apply (not a dry run) — `applied` stays true; the
// honest signal is the 'partial' status + failed_files, not `applied`.
expect(result.applied).toBe(true);
} finally {
readSpy.mockRestore();
writeSpy.mockRestore();
}
});
// api_impact tool
it('dispatches api_impact tool with route param', async () => {
(executeParameterized as any).mockResolvedValue([
@ -1597,6 +1694,67 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
},
);
// #2279: some MCP client/agent adapters serialize an *omitted* optional
// numeric field as `0`. On the callgraph path `line` is meaningless, so a
// literal `line: 0` must be tolerated as omitted (NOT the PDG-only error) and
// route to the normal BFS — distinct from a genuine positive `line` (above),
// which stays a hard error.
it.each<['callgraph' | undefined]>([['callgraph'], [undefined]])(
'mode:%j + adapter-materialized line:0 is treated as omitted and runs the BFS (#2279)',
async (mode) => {
resolveSingleTarget();
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
const result = await backend.callTool('impact', {
target: 'main',
direction: 'upstream',
mode,
line: 0,
});
// No PDG-only error, no positive-integer error — line:0 is swallowed.
expect(result.error ?? '').not.toMatch(/'line' is only supported with mode:'pdg'/);
expect(result.error ?? '').not.toMatch(/'line' must be a positive integer/);
expect(result.target).toBeDefined();
expect(bfsSpy).toHaveBeenCalledTimes(1);
},
);
it.each<['callgraph' | undefined]>([['callgraph'], [undefined]])(
'mode:%j + line:-1 still errors — the line:0 coercion is narrow, only literal 0 (#2279)',
async (mode) => {
resolveSingleTarget();
const result = await backend.callTool('impact', {
target: 'main',
direction: 'upstream',
mode,
line: -1,
});
// A negative line is a real mistake, not an adapter-materialized "omitted":
// it must NOT be swallowed like line:0, and stays the PDG-only hard error.
expect(result.error).toMatch(/'line' is only supported with mode:'pdg'/);
},
);
it("mode:'callgraph'/undefined + line:0 is byte-identical to omitting line (#2279)", async () => {
resolveSingleTarget();
const omitted = await backend.callTool('impact', { target: 'main', direction: 'upstream' });
const callgraphZero = await backend.callTool('impact', {
target: 'main',
direction: 'upstream',
mode: 'callgraph',
line: 0,
});
const undefZero = await backend.callTool('impact', {
target: 'main',
direction: 'upstream',
mode: undefined,
line: 0,
});
// The normalization must leave the callgraph result indistinguishable from a
// call that never carried `line` — the spurious 0 must not leak into output.
expect(callgraphZero).toEqual(omitted);
expect(undefZero).toEqual(omitted);
});
it.each([[0], [-1], [1.5]])(
"mode:'pdg' + non-positive-integer line %j → structured {error}, never routed to traversal",
async (badLine) => {
@ -1776,15 +1934,121 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
criterionLine: 8,
});
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
const result = await backend.callTool('impact', {
target: 'main',
direction: 'downstream',
mode: 'pdg',
line: 8,
const cap = _captureLogger();
try {
const result = await backend.callTool('impact', {
target: 'main',
direction: 'downstream',
mode: 'pdg',
line: 8,
});
// The error was swallowed: no bridge passed to the BFS, and no error surfaced.
expect(result.error).toBeUndefined();
expect(bfsSpy.mock.calls[0][4].pdgBridge).toBeUndefined();
// The swallowed, gracefully-degraded query failure is logged at warn (40),
// never error (50): it degraded to a safe fallback and is not an operation
// failure. Pinning the severity guards against a regression to a false
// ERROR alarm that would drown genuine, operation-aborting failures.
const slice = cap.records().find((r) => r.context === 'impact:pdg-slice-callees');
expect(slice).toBeDefined();
expect(slice?.level).toBe(40);
} finally {
cap.restore();
}
});
it("mode:'pdg' slice-callees failing with a benign missing-table error logs at debug, not warn", async () => {
// A repo analyzed without the optional column/table (e.g. a pre-v3 PDG index
// missing `calleeIds`, or a BasicBlock table that simply isn't there) makes the
// slice-callees query fail with a benign "missing optional data" error. That is a
// normal configuration, not a degradation, so logQueryError routes it to debug —
// suppressed at the default info level. We capture AT debug so the record is
// visible: the assertion is that it was emitted AND at debug (level 10), which
// distinguishes "logged at debug" from "not logged at all" — an info-level
// absence check could not tell those apart and would pass vacuously if the
// logQueryError call were deleted.
resolveSingleTarget();
vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => {
if (query.includes('RETURN b.callees')) throw new Error('Table BasicBlock does not exist');
return [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }];
});
// The error was swallowed: no bridge passed to the BFS, and no error surfaced.
expect(result.error).toBeUndefined();
expect(bfsSpy.mock.calls[0][4].pdgBridge).toBeUndefined();
vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({
mode: 'pdg',
target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
direction: 'downstream',
risk: 'UNKNOWN',
impactedCount: 0,
epistemic: 'pdg-intra-procedural',
reachableBlocks: ['BasicBlock:src/index.ts:8:0:1'],
intraReachableBlocks: ['BasicBlock:src/index.ts:8:0:1'],
seedBlocks: ['BasicBlock:src/index.ts:8:0:0'],
blockCount: 1,
affectedStatements: [{ line: 8, filePath: 'src/index.ts', text: 'callee()' }],
affectedStatementCount: 1,
criterionLine: 8,
});
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
const cap = _captureLogger('debug');
try {
const result = await backend.callTool('impact', {
target: 'main',
direction: 'downstream',
mode: 'pdg',
line: 8,
});
// Still degrades cleanly to no bridge / no surfaced error.
expect(result.error).toBeUndefined();
expect(bfsSpy.mock.calls[0][4].pdgBridge).toBeUndefined();
// The benign failure was emitted at debug (20) — NOT warn (40)/error (50).
// Capturing at debug proves the call fired and chose the suppressed level.
const slice = cap.records().find((r) => r.context === 'impact:pdg-slice-callees');
expect(slice).toBeDefined();
expect(slice?.level).toBe(20);
} finally {
cap.restore();
}
});
it("mode:'pdg' slice-callees failing with a non-schema 'not found' error logs at warn, not debug (#2283)", async () => {
// "Symbol not found" is an operation failure, not a benign missing optional
// table — isBenignMissingTableError must NOT match an unscoped "not found"
// (only "<table|column|property|…> … not found"), so it stays visible at warn
// rather than being demoted to the suppressed debug level.
resolveSingleTarget();
vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => {
if (query.includes('RETURN b.callees')) throw new Error('Symbol not found');
return [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }];
});
vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({
mode: 'pdg',
target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
direction: 'downstream',
risk: 'UNKNOWN',
impactedCount: 0,
epistemic: 'pdg-intra-procedural',
reachableBlocks: ['BasicBlock:src/index.ts:8:0:1'],
intraReachableBlocks: ['BasicBlock:src/index.ts:8:0:1'],
seedBlocks: ['BasicBlock:src/index.ts:8:0:0'],
blockCount: 1,
affectedStatements: [{ line: 8, filePath: 'src/index.ts', text: 'callee()' }],
affectedStatementCount: 1,
criterionLine: 8,
});
vi.spyOn(backend as any, '_runImpactBFS');
const cap = _captureLogger();
try {
await backend.callTool('impact', {
target: 'main',
direction: 'downstream',
mode: 'pdg',
line: 8,
});
const slice = cap.records().find((r) => r.context === 'impact:pdg-slice-callees');
expect(slice).toBeDefined();
expect(slice?.level).toBe(40);
} finally {
cap.restore();
}
});
it("mode:'pdg' + crossDepth → hard {error} (single-repo PDG impact)", async () => {

View file

@ -147,7 +147,7 @@ describe('CLI commands', () => {
// ships source only) and loaded from vendor/ by absolute path (#2111).
expect(optional['tree-sitter-kotlin']).toBeUndefined();
expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs');
expect(kotlinPkg.default.version).toBe('0.3.8');
expect(kotlinPkg.default.version).toBe('0.4.0');
// No scripts.install / dependencies inside vendor/ (#836 / #1728 hygiene).
expect(kotlinPkg.default.scripts?.install).toBeUndefined();
expect(kotlinPkg.default.dependencies).toBeUndefined();

View file

@ -79,10 +79,14 @@ describe('GRAMMARS registry', () => {
expect(mod.GRAMMARS.dart.github).toContain('tree-sitter-dart');
});
it('monitors c but marks it report-only (ABI-pinned hold); the rest are auto-updatable', () => {
it('marks c and kotlin report-only (holds); swift/dart/proto are auto-updatable', () => {
expect(mod.GRAMMARS.c.npm).toBe('tree-sitter-c');
expect(mod.GRAMMARS.c.hold).toBeTruthy(); // detected/reported, never auto-applied
for (const k of ['swift', 'kotlin', 'dart', 'proto']) {
expect(mod.GRAMMARS.c.hold).toBeTruthy(); // ABI-pinned: detected/reported, never auto-applied
// kotlin is pinned to an unreleased fwcd main commit for `fun interface`
// support (#169); npm latest (0.3.8) lacks it, so the strict-inequality
// isNewer would auto-revert the pin without this hold.
expect(mod.GRAMMARS.kotlin.hold).toBeTruthy();
for (const k of ['swift', 'dart', 'proto']) {
expect(mod.GRAMMARS[k].hold).toBeUndefined();
}
});

View file

@ -22,21 +22,21 @@ import type { CrossLink } from '../../../src/core/group/types.js';
import { makeContract } from './fixtures.js';
/**
* LadybugDB 0.16.0 has a known Windows-only regression: `Database.close()`
* does not release the underlying file lock until the process exits, so any
* read-after-write within the same process fails with Win32 Error 33
* ("process cannot access the file because another process has locked a
* portion of the file"). This blocks the close-then-reopen pattern that
* `writeBridge → openBridgeDbReadOnly` relies on.
* In-process close-then-reopen of `bridge.lbug` (`writeBridge →
* openBridgeDbReadOnly`, and the read path's open→query→close→reopen) — exactly
* what a long-lived MCP server does on repeated `@group` impact/trace calls.
*
* Production code paths are unaffected: `gitnexus analyze`, `serve`, and
* `mcp` each open the database exactly once per process and close it at
* exit. The pattern only manifests in tests and in worker pool reuse.
* On Linux/macOS this is now a supported, exercised pattern thanks to the
* `closeBridgeDb` fix that skips CHECKPOINT on read-only handles (a CHECKPOINT
* on a read-only connection left a lock artifact that failed the next open).
*
* Upstream: see kuzudb/kuzu#3872 / #3883 / #4730 (file-lock UX gaps on
* Windows). Skipping these specific tests on Windows lets the segfault fix
* (the original motivation for the 0.16.0 upgrade) ship while we wait for
* an upstream fix or pivot to a single-process bridge writer.
* On WINDOWS the writable-close → read-open handoff still does not release the
* OS file handle 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), so these tests stay Windows-skipped — the
* pre-existing limitation. A close-side `waitForWindowsHandleRelease` +
* `finalizeLbugSidecarsAfterClose` probe was tried and did not close the gap on
* Windows CI, so it was not kept.
*/
const itLbugReopen = process.platform === 'win32' ? it.skip : it;
@ -219,6 +219,38 @@ describe('writeBridge + read', () => {
await closeBridgeDb(handle!);
});
itLbugReopen('test_openBridgeDbReadOnly_can_reopen_in_same_process', async () => {
// Regression: closeBridgeDb used to issue CHECKPOINT on read-only handles
// too, which left a WAL/shadow lock artifact that made the next read-only
// open of the same file fail in-process — breaking repeated @group
// impact/trace calls in a long-lived MCP server. closeBridgeDb now skips
// the checkpoint for read-only handles, so open→query→close→open works.
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
const first = await openBridgeDbReadOnly(tmpDir);
expect(first).not.toBeNull();
const r1 = await queryBridge<{ n: number }>(first!, 'MATCH (c:Contract) RETURN count(c) AS n');
expect(r1[0].n).toBe(1);
await closeBridgeDb(first!);
// Second open in the SAME process must succeed (previously returned null).
const second = await openBridgeDbReadOnly(tmpDir);
expect(second).not.toBeNull();
const r2 = await queryBridge<{ n: number }>(second!, 'MATCH (c:Contract) RETURN count(c) AS n');
expect(r2[0].n).toBe(1);
await closeBridgeDb(second!);
// And a third, to confirm it is not a one-shot.
const third = await openBridgeDbReadOnly(tmpDir);
expect(third).not.toBeNull();
await closeBridgeDb(third!);
});
it('test_writeBridge_meta_json_persists_missingRepos', async () => {
await writeBridge(tmpDir, {
contracts: [],

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,76 @@
/**
* Unit tests for `HttpLanguagePlugin.hasConsumerSignals` (#2138 Part 2).
*
* The parse-skip consumer-safety gate skips a provider-covered file only when
* its plugin proves (parse-free) the file has no outbound-HTTP call its `scan()`
* would detect. The contract (`types.ts`) requires `hasConsumerSignals` to be a
* SUPERSET of every consumer shape `scan()` emits — otherwise a covered file
* with an undetected consumer call would be wrongly parse-skipped and its
* consumer contract dropped. These tests pin that superset relationship per
* language with the exact idioms each `scan()` matches.
*/
import { describe, it, expect } from 'vitest';
import { JAVA_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/java.js';
import { PHP_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/php.js';
import { PYTHON_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/python.js';
const has = (plugin: { hasConsumerSignals?: (s: string) => boolean }, src: string): boolean => {
if (!plugin.hasConsumerSignals) throw new Error('plugin has no hasConsumerSignals');
return plugin.hasConsumerSignals(src);
};
describe('Java hasConsumerSignals — superset of scan() consumer idioms', () => {
it.each([
['RestTemplate', 'restTemplate.getForObject("/api/x", X.class);'],
['WebClient short-form', 'webClient.get().uri("/api/x").retrieve();'],
['WebClient exchange', 'webClient.method(HttpMethod.GET).uri("/x");'],
['OkHttp', 'new Request.Builder().url("/api/x").build();'],
['Java HttpClient', 'HttpRequest.newBuilder().uri(URI.create("/x")).GET();'],
['Apache HttpGet', 'new HttpGet("/api/x");'],
['OpenFeign @FeignClient', '@FeignClient(name="svc") interface C {}'],
['OpenFeign @RequestLine', '@RequestLine("GET /users/{id}")'],
['Spring HTTP Interface @GetExchange', '@GetExchange("/api/x") Object x();'],
])('detects %s', (_label, src) => {
expect(has(JAVA_HTTP_PLUGIN, src)).toBe(true);
});
it('returns false for a pure provider controller (no outbound calls)', () => {
const src = `@RestController @RequestMapping("/api/a")
class AController { @GetMapping("/list") Object list() { return null; } }`;
expect(has(JAVA_HTTP_PLUGIN, src)).toBe(false);
});
});
describe('PHP hasConsumerSignals — superset of scan() consumer idioms', () => {
it.each([
['Laravel Http facade', "Http::get('/api/x');"],
['Guzzle member call', "$client->post('/api/x', []);"],
['file_get_contents', "file_get_contents('https://x/api');"],
])('detects %s', (_label, src) => {
expect(has(PHP_HTTP_PLUGIN, src)).toBe(true);
});
it('returns false for a pure Laravel route file (provider only)', () => {
expect(has(PHP_HTTP_PLUGIN, "Route::get('/api/a/list', 'AController@list');")).toBe(false);
});
});
describe('Python hasConsumerSignals — superset of scan() consumer idioms', () => {
it.each([
['requests verb', 'requests.get("/api/x")'],
['requests.request', 'requests.request("GET", "/api/x")'],
['httpx', 'client = httpx.AsyncClient()'],
['aiohttp', 'async with aiohttp.ClientSession() as s: ...'],
['urllib', 'urllib.request.urlopen("/api/x")'],
['uri= keyword', 'do_call(uri="/api/x")'],
['url= keyword', 'do_call(url="/api/x")'],
])('detects %s', (_label, src) => {
expect(has(PYTHON_HTTP_PLUGIN, src)).toBe(true);
});
it('returns false for a pure FastAPI provider (decorator route only)', () => {
const src = `@router.get("/api/x")
async def handler(): return {}`;
expect(has(PYTHON_HTTP_PLUGIN, src)).toBe(false);
});
});

File diff suppressed because it is too large Load diff

View file

@ -97,7 +97,7 @@ describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['createOrder']);
if (query.includes('UNION ALL')) return containsFor(['createOrder']);
return [];
});
@ -130,7 +130,7 @@ describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['listOrders', 'replaceOrder']);
if (query.includes('UNION ALL')) return containsFor(['listOrders', 'replaceOrder']);
return [];
});
@ -160,7 +160,7 @@ describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['deleteOrder']);
if (query.includes('UNION ALL')) return containsFor(['deleteOrder']);
return [];
});
@ -188,7 +188,7 @@ describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['listOrders']);
if (query.includes('UNION ALL')) return containsFor(['listOrders']);
return [];
});
@ -200,6 +200,54 @@ describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () =
expect(out[0].symbolName).toBe('listOrders');
});
it('fast path: Route.handlerSymbolId resolves the handler without any source scan', async () => {
// Deliberately leave FILE_DETECTIONS empty: if the extractor still resolves
// the handler, it MUST have used the persisted handlerSymbolId (the graph
// fast path), not a plugin scan of the source.
const HID = 'Method:OrderController.java:OrderController.createOrder#0';
const db = vi.fn(async (query: string) => {
if (query.includes('HANDLES_ROUTE')) {
return [
{
fileId: 'f1',
filePath: 'OrderController.java',
routePath: '/api/orders',
routeMethod: 'POST',
handlerSymbolId: HID,
routeSource: 'framework-route',
},
];
}
if (query.includes('UNION ALL')) {
return [
{
uid: HID,
name: 'createOrder',
filePath: 'OrderController.java',
startLine: 10,
endLine: 12,
labels: ['Method'],
0: HID,
1: 'createOrder',
2: 'OrderController.java',
},
];
}
return [];
});
const out = await new HttpRouteExtractor().extract(db, '/repo', {
name: 'r',
url: 'r',
} as never);
expect(out).toHaveLength(1);
expect(out[0].meta.method).toBe('POST');
// The persisted symbol id is authoritative; name/path come from the cheap
// CONTAINING_QUERY graph lookup by filePath (no source parse).
expect(out[0].symbolUid).toBe(HID);
expect(out[0].symbolName).toBe('createOrder');
});
it('backward-compat: no Route.method and undecodable reason stays at conservative GET', async () => {
FILE_DETECTIONS.set('routes.ts', [detection('provider', 'POST', '/api/orders', 'createOrder')]);
@ -214,7 +262,7 @@ describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['createOrder']);
if (query.includes('UNION ALL')) return containsFor(['createOrder']);
return [];
});

View file

@ -96,7 +96,7 @@ describe('HttpRouteExtractor — graph-assisted multi-verb disambiguation', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['listOrders']);
if (query.includes('UNION ALL')) return containsFor(['listOrders']);
return [];
});
@ -125,7 +125,7 @@ describe('HttpRouteExtractor — graph-assisted multi-verb disambiguation', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['listOrders', 'createOrder']);
if (query.includes('UNION ALL')) return containsFor(['listOrders', 'createOrder']);
return [];
});
@ -155,7 +155,7 @@ describe('HttpRouteExtractor — graph-assisted multi-verb disambiguation', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['listOrders', 'createOrder']);
if (query.includes('UNION ALL')) return containsFor(['listOrders', 'createOrder']);
return [];
});
@ -225,7 +225,7 @@ describe('HttpRouteExtractor — graph-assisted multi-verb disambiguation', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['listOrders', 'createOrder']);
if (query.includes('UNION ALL')) return containsFor(['listOrders', 'createOrder']);
return [];
});
@ -264,7 +264,7 @@ describe('HttpRouteExtractor — graph-assisted multi-verb disambiguation', () =
},
];
}
if (query.includes('CONTAINS'))
if (query.includes('UNION ALL'))
return containsFor(['listOrders', 'createOrder', 'replaceOrder']);
return [];
});
@ -321,7 +321,7 @@ describe('HttpRouteExtractor — graph-assisted multi-verb disambiguation', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['listOrders', 'createOrder']);
if (query.includes('UNION ALL')) return containsFor(['listOrders', 'createOrder']);
return [];
});

View file

@ -0,0 +1,130 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { cleanupTempDir } from '../../helpers/test-db.js';
import { resolveBridgeNeighbors } from '../../../src/core/group/cross-impact.js';
import {
writeBridge,
openBridgeDbReadOnly,
closeBridgeDb,
} from '../../../src/core/group/bridge-db.js';
import type { CrossLink } from '../../../src/core/group/types.js';
import { makeContract } from './fixtures.js';
/**
* U1 — direct coverage for the shared bridge-neighbor join extracted from
* `runGroupImpact`. Mirrors the close-then-reopen Windows guard used by the
* other writeBridge tests (`itLbugReopen`).
*/
const itLbugReopen = process.platform === 'win32' ? it.skip : it;
/** Build a bridge with one consumer→provider ContractLink (both UIDs populated). */
async function writeLinkedBridge(groupDir: string): Promise<void> {
const consumer = makeContract({
repo: 'app/frontend',
role: 'consumer',
symbolUid: 'consumer-uid',
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
symbolName: 'fetchUsers',
contractId: 'http::GET::/api/users',
confidence: 0.5,
});
const provider = makeContract({
repo: 'app/backend',
role: 'provider',
symbolUid: 'provider-uid',
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
symbolName: 'getUsers',
contractId: 'http::GET::/api/users',
confidence: 0.9,
});
const link: CrossLink = {
from: { repo: 'app/frontend', symbolUid: 'consumer-uid', symbolRef: consumer.symbolRef },
to: { repo: 'app/backend', symbolUid: 'provider-uid', symbolRef: provider.symbolRef },
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'exact',
confidence: 0.9,
};
await writeBridge(groupDir, {
contracts: [consumer, provider],
crossLinks: [link],
repoSnapshots: {},
missingRepos: [],
});
}
describe('resolveBridgeNeighbors', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-neighbors-'));
});
afterEach(async () => {
await cleanupTempDir(tmpDir);
});
it('returns [] for an empty uid set without touching the DB', async () => {
// A null handle would throw if the DB were queried; the empty-set guard
// must short-circuit before any query.
const handleSentinel = null as unknown as Parameters<typeof resolveBridgeNeighbors>[0];
const rows = await resolveBridgeNeighbors(handleSentinel, {
localRepo: 'app/backend',
uids: [],
direction: 'upstream',
});
expect(rows).toEqual([]);
});
itLbugReopen('downstream: consumer uid resolves to its provider neighbor', async () => {
await writeLinkedBridge(tmpDir);
const handle = await openBridgeDbReadOnly(tmpDir);
expect(handle).not.toBeNull();
const rows = await resolveBridgeNeighbors(handle!, {
localRepo: 'app/frontend',
uids: ['consumer-uid'],
direction: 'downstream',
});
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
neighborRepo: 'app/backend',
neighborUid: 'provider-uid',
matchType: 'exact',
confidence: 0.9,
contractId: 'http::GET::/api/users',
contractType: 'http',
});
await closeBridgeDb(handle!);
});
itLbugReopen('upstream: provider uid resolves to its consumer neighbor', async () => {
await writeLinkedBridge(tmpDir);
const handle = await openBridgeDbReadOnly(tmpDir);
const rows = await resolveBridgeNeighbors(handle!, {
localRepo: 'app/backend',
uids: ['provider-uid'],
direction: 'upstream',
});
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
neighborRepo: 'app/frontend',
neighborUid: 'consumer-uid',
contractId: 'http::GET::/api/users',
});
await closeBridgeDb(handle!);
});
itLbugReopen('unknown uid yields no neighbors', async () => {
await writeLinkedBridge(tmpDir);
const handle = await openBridgeDbReadOnly(tmpDir);
const rows = await resolveBridgeNeighbors(handle!, {
localRepo: 'app/frontend',
uids: ['no-such-uid'],
direction: 'downstream',
});
expect(rows).toEqual([]);
await closeBridgeDb(handle!);
});
});

View file

@ -0,0 +1,148 @@
/**
* Direct unit tests for `resolveRouteHandlerSymbols` (#2138 Part 2).
*
* Pins the P2 fixes from the review:
* - ambiguity → fail-open: a same-name lookup returning ≠1 yields NO
* handlerSymbolId (never an arbitrary `[0]` guess).
* - first-writer-wins reservation: the first route to claim a URL reserves it
* even when its handler is unresolvable, so a later same-URL route can't
* stamp its handler onto the (node-winning) first route's slot.
* - happy path: a uniquely-resolvable handler is stamped, keyed by the
* normalized URL.
*/
import { describe, it, expect } from 'vitest';
import { createSemanticModel } from '../../src/core/ingestion/model/index.js';
import { resolveRouteHandlerSymbols } from '../../src/core/ingestion/call-processor.js';
import type { ExtractedDecoratorRoute } from '../../src/core/ingestion/workers/parse-worker.js';
import type { ExtractedRoute } from '../../src/core/ingestion/route-extractors/laravel.js';
const FILE = 'src/OrderController.java';
function decoratorRoute(overrides: Partial<ExtractedDecoratorRoute> = {}): ExtractedDecoratorRoute {
return {
filePath: FILE,
routePath: '/orders',
httpMethod: 'GET',
decoratorName: 'GetMapping',
lineNumber: 1,
handlerName: 'list',
...overrides,
};
}
describe('resolveRouteHandlerSymbols — decorator routes', () => {
it('uniquely-resolvable handler is stamped, keyed by normalized URL', () => {
const model = createSemanticModel();
model.symbols.add(FILE, 'list', 'method:OrderController.list', 'Method');
const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute()]);
expect(out.get('/orders')).toBe('method:OrderController.list');
});
it('ambiguous same-name handler (overloads) → fail-open, no stamp', () => {
const model = createSemanticModel();
// Two same-(file,name) defs → lookupExactAll returns 2 → refuse to guess.
model.symbols.add(FILE, 'list', 'method:OrderController.list#1', 'Method');
model.symbols.add(FILE, 'list', 'method:OrderController.list#2', 'Method');
const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute()]);
expect(out.has('/orders')).toBe(false);
});
it('unknown handler name → fail-open, no stamp', () => {
const model = createSemanticModel(); // nothing registered
const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute({ handlerName: 'ghost' })]);
expect(out.has('/orders')).toBe(false);
});
it('same-URL collision: an unresolvable first route reserves the slot so a later resolvable route cannot stamp it', () => {
const model = createSemanticModel();
// Only the SECOND route's handler exists in the model.
model.symbols.add(FILE, 'second', 'method:OrderController.second', 'Method');
const out = resolveRouteHandlerSymbols(
model,
[],
[
// First route at /orders is unresolvable (no such symbol) — but it is the
// route the routes phase makes the Route-node winner, so its slot must be
// reserved (empty), NOT filled by the later same-URL route.
decoratorRoute({ handlerName: 'first_missing' }),
decoratorRoute({ handlerName: 'second' }),
],
);
// Reservation holds: the URL carries no (wrong) handler. Pre-fix this would
// have stamped `method:OrderController.second` onto the first route's node.
expect(out.has('/orders')).toBe(false);
});
it('first-writer-wins among resolvable same-URL routes', () => {
const model = createSemanticModel();
model.symbols.add(FILE, 'winner', 'method:OrderController.winner', 'Method');
model.symbols.add(FILE, 'loser', 'method:OrderController.loser', 'Method');
const out = resolveRouteHandlerSymbols(
model,
[],
[decoratorRoute({ handlerName: 'winner' }), decoratorRoute({ handlerName: 'loser' })],
);
expect(out.get('/orders')).toBe('method:OrderController.winner');
});
});
describe('resolveRouteHandlerSymbols — Laravel framework routes', () => {
const CTRL = 'app/Http/Controllers/OrderController.php';
function laravelRoute(overrides: Partial<ExtractedRoute> = {}): ExtractedRoute {
return {
filePath: 'routes/web.php',
httpMethod: 'get',
routePath: '/orders',
routeName: null,
controllerName: 'OrderController',
methodName: 'index',
middleware: [],
prefix: null,
lineNumber: 1,
...overrides,
};
}
it('resolvable controller + unique method → stamped', () => {
const model = createSemanticModel();
model.symbols.add(CTRL, 'OrderController', 'class:OrderController', 'Class');
model.symbols.add(CTRL, 'index', 'method:OrderController.index', 'Method', {
ownerId: 'class:OrderController',
});
const out = resolveRouteHandlerSymbols(model, [laravelRoute()], []);
expect(out.get('/orders')).toBe('method:OrderController.index');
});
it('ambiguous controller short-name (>1) → fail-open, no stamp', () => {
const model = createSemanticModel();
model.symbols.add(
'app/A/OrderController.php',
'OrderController',
'class:A.OrderController',
'Class',
);
model.symbols.add(
'app/B/OrderController.php',
'OrderController',
'class:B.OrderController',
'Class',
);
const out = resolveRouteHandlerSymbols(model, [laravelRoute()], []);
expect(out.has('/orders')).toBe(false);
});
});

View file

@ -0,0 +1,341 @@
/**
* Java taint model (#2261) over real Java CFG and import capture output.
*/
import { createRequire } from 'node:module';
import type { ParsedImport } from 'gitnexus-shared';
import { assert, describe, expect, it } from 'vitest';
import { createJavaCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/java.js';
import { computeReachingDefs } from '../../../src/core/ingestion/cfg/reaching-defs.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import { emitJavaScopeCaptures } from '../../../src/core/ingestion/languages/java/captures.js';
import { interpretJavaImport } from '../../../src/core/ingestion/languages/java/interpret.js';
import { JAVA_TAINT_MODEL } from '../../../src/core/ingestion/taint/java-model.js';
import { hasTaintSafeSites } from '../../../src/core/ingestion/taint/site-safety.js';
import {
buildTaintImportIndex,
matchFunctionSites,
type FunctionSiteMatches,
type MatchedSinkCall,
type MatchedSource,
} from '../../../src/core/ingestion/taint/match.js';
import { computeTaintFlows } from '../../../src/core/ingestion/taint/propagate.js';
import { makeCfgHarness, bindingIdx, type CfgHarness } from '../../helpers/cfg-harness.js';
const javaGrammar = createRequire(import.meta.url)('tree-sitter-java') as Parameters<
typeof makeCfgHarness
>[0];
const java: CfgHarness = makeCfgHarness(javaGrammar, createJavaCfgVisitor(), 'fixture.java');
function importsFor(src: string): ParsedImport[] {
return emitJavaScopeCaptures(src, 'fixture.java')
.filter((m) => m['@import.statement'] !== undefined)
.map((m) => interpretJavaImport(m))
.filter((p): p is ParsedImport => p !== null);
}
function cfgOf(code: string, fnIndex = 0): FunctionCfg {
const cfg = java.cfgOf(code, fnIndex);
expect(hasTaintSafeSites(cfg)).toBe(true);
return cfg;
}
function matchesOf(code: string, fnIndex = 0): { cfg: FunctionCfg; matches: FunctionSiteMatches } {
const cfg = cfgOf(code, fnIndex);
return {
cfg,
matches: matchFunctionSites(cfg, JAVA_TAINT_MODEL, buildTaintImportIndex(importsFor(code))),
};
}
function analyze(code: string, fnIndex = 0) {
const { cfg, matches } = matchesOf(code, fnIndex);
const defUse = computeReachingDefs(cfg);
return { cfg, matches, flows: computeTaintFlows(cfg, defUse, matches) };
}
const allSources = (m: FunctionSiteMatches): MatchedSource[] =>
m.statements.flatMap((s) => [...s.sources]);
const allSinks = (m: FunctionSiteMatches): MatchedSinkCall[] =>
m.statements.flatMap((s) => [...s.sinks]);
function matchedSinkSite(cfg: FunctionCfg, matches: FunctionSiteMatches, sink: MatchedSinkCall) {
const sinkSite = matches.statements
.flatMap((stmt) => stmt.sinks.map((matched) => ({ stmt, matched })))
.find(({ matched }) => matched === sink);
assert(sinkSite !== undefined, 'expected matched sink site');
const site =
cfg.blocks[sinkSite.stmt.blockIndex].statements?.[sinkSite.stmt.statementIndex]?.sites?.[
sink.siteIndex
];
assert(site !== undefined, 'expected concrete sink site');
return site;
}
const wrap = (body: string, imports = ''): string => `${imports}
class C {
void f(javax.servlet.http.HttpServletRequest request, javax.servlet.http.HttpServletRequest req, Helper helper, String safe) {
${body}
}
}`;
describe('Java taint model (#2261)', () => {
it('matches assigned request call results as remote-input sources', () => {
const { cfg, matches } = matchesOf(wrap(`String p = request.getParameter("id");`));
const sources = allSources(matches);
expect(sources).toHaveLength(1);
expect(sources[0].type).toBe('call-result');
expect(sources[0].entry.kind).toBe('remote-input');
expect(sources[0].type === 'call-result' ? [...sources[0].resultDefs] : []).toEqual([
bindingIdx(cfg, 'p'),
]);
expect(matches.hasSource).toBe(true);
});
it('propagates an assigned request source into a static-import-proven file read sink', () => {
const { cfg, matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
readString(of(p));
`,
`
import static java.nio.file.Files.readString;
import static java.nio.file.Path.of;
`,
),
);
const p = bindingIdx(cfg, 'p');
const sink = allSinks(matches)[0];
expect(sink.entry.name).toBe('readString');
const site = matchedSinkSite(cfg, matches, sink);
expect(site?.callee).toBe('readString');
expect(site?.args?.[0]).toContainEqual([p, expect.any(Number)]);
expect(flows.status).toBe('computed');
expect(flows.findings).toHaveLength(1);
expect(flows.findings[0].sinkKind).toBe('path-traversal');
expect(flows.findings[0].source.type).toBe('call-result');
});
it('propagates regular-import-proven Files.readString into a path-traversal finding', () => {
const { cfg, matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
Files.readString(Path.of(p));
`,
`
import java.nio.file.Files;
import java.nio.file.Path;
`,
),
);
const p = bindingIdx(cfg, 'p');
const sinks = allSinks(matches);
expect(sinks).toHaveLength(1);
expect(sinks[0].entry.kind).toBe('path-traversal');
const site = matchedSinkSite(cfg, matches, sinks[0]);
expect(site.callee).toBe('Files.readString');
expect(site.args?.[0]).toContainEqual([p, expect.any(Number)]);
expect(flows.findings).toHaveLength(1);
expect(flows.findings[0].sinkKind).toBe('path-traversal');
});
it('supports getHeader call-result sources', () => {
const { cfg, matches, flows } = analyze(
wrap(
`
String p = request.getHeader("X-Path");
readString(of(p));
`,
`
import static java.nio.file.Files.readString;
import static java.nio.file.Path.of;
`,
),
);
const p = bindingIdx(cfg, 'p');
const sources = allSources(matches);
expect(sources).toHaveLength(1);
assert(sources[0].type === 'call-result', 'expected getHeader to be a call-result source');
expect(sources[0].entry.kind).toBe('remote-input');
expect([...sources[0].resultDefs]).toEqual([p]);
const sinks = allSinks(matches);
expect(sinks).toHaveLength(1);
expect(sinks[0].entry.kind).toBe('path-traversal');
expect(flows.findings).toHaveLength(1);
expect(flows.findings[0].source.type).toBe('call-result');
});
it('supports the short req receiver for call-result sources', () => {
const { cfg, matches, flows } = analyze(
wrap(
`
String p = req.getParameter("path");
readString(of(p));
`,
`
import static java.nio.file.Files.readString;
import static java.nio.file.Path.of;
`,
),
);
const p = bindingIdx(cfg, 'p');
const [source] = allSources(matches);
assert(source?.type === 'call-result', 'expected req.getParameter source');
expect([...source.resultDefs]).toEqual([p]);
expect(flows.findings).toHaveLength(1);
});
it('does not seed an unassigned request call result', () => {
const { matches, flows } = analyze(
wrap(
`readString(request.getParameter("path"));`,
'import static java.nio.file.Files.readString;',
),
);
expect(allSources(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not treat unrelated same-named receivers as servlet sources', () => {
const { matches, flows } = analyze(
wrap(
`
String p = helper.getParameter("path");
readString(p);
`,
'import static java.nio.file.Files.readString;',
),
);
expect(allSources(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not report a sink when the tainted value is not in the dangerous argument', () => {
const { cfg, matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
String unused = p;
readString(of(safe));
`,
`
import static java.nio.file.Files.readString;
import static java.nio.file.Path.of;
`,
),
);
const p = bindingIdx(cfg, 'p');
const sink = allSinks(matches)[0];
expect(sink.entry.name).toBe('readString');
const site = matchedSinkSite(cfg, matches, sink);
expect(site?.callee).toBe('readString');
expect(site?.args?.[0]).not.toContainEqual([p, expect.any(Number)]);
expect(site?.args?.[0]).not.toContain(p);
expect(flows.status).toBe('computed');
expect(flows.findings).toHaveLength(0);
});
it('does not match same-named readString calls without static import provenance', () => {
const { matches, flows } = analyze(
wrap(`
String p = request.getParameter("path");
readString(p);
helper.readString(p);
`),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not report Paths.get or Path.of constructors as sinks', () => {
const { matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
get(p);
of(p);
`,
`
import static java.nio.file.Paths.get;
import static java.nio.file.Path.of;
`,
),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not match unrelated static imports named readString', () => {
const { matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
readString(p);
`,
'import static com.example.Files.readString;',
),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not match unrelated regular imports named Files', () => {
const { matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
Files.readString(p);
`,
'import com.example.Files;',
),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not let a local Files receiver inherit import provenance', () => {
const { matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
Helper Files = helper;
Files.readString(p);
`,
'import java.nio.file.Files;',
),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not report untainted input reaching the file read sink', () => {
const { matches, flows } = analyze(
wrap(
`readString(of(safe));`,
`
import static java.nio.file.Files.readString;
import static java.nio.file.Path.of;
`,
),
);
expect(allSinks(matches)).toHaveLength(1);
expect(flows.findings).toHaveLength(0);
});
it('does not report regular-import path constructors without a file sink', () => {
const { matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
Path.of(p);
`,
'import java.nio.file.Path;',
),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
});

View file

@ -25,7 +25,7 @@ import {
type FunctionSiteMatches,
type MatchedSanitizerCall,
type MatchedSinkCall,
type MatchedSourceRead,
type MatchedSource,
} from '../../../src/core/ingestion/taint/match.js';
import {
getSourceSinkConfig,
@ -47,7 +47,7 @@ function matchesOf(
const allSinks = (m: FunctionSiteMatches): MatchedSinkCall[] =>
m.statements.flatMap((s) => [...s.sinks]);
const allSources = (m: FunctionSiteMatches): MatchedSourceRead[] =>
const allSources = (m: FunctionSiteMatches): MatchedSource[] =>
m.statements.flatMap((s) => [...s.sources]);
const allSanitizers = (m: FunctionSiteMatches): MatchedSanitizerCall[] =>
m.statements.flatMap((s) => [...s.sanitizers]);
@ -78,6 +78,21 @@ function f(c) { cp.exec(c); }`);
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['exec']);
});
it('named import from a same-tail module is not canonicalized as a namespace handle', () => {
const spec: SourceSinkSanitizerSpec = {
sources: [],
sinks: [{ name: 'readString', kind: 'path-traversal', args: [0], module: 'pkg.Files' }],
sanitizers: [],
};
const m = matchesOf(
`import { Files } from 'pkg.Files';
function f(p) { Files.readString(p); }`,
0,
spec,
);
expect(allSinks(m)).toHaveLength(0);
});
it('node: scheme prefix is normalized — `from "node:child_process"` matches too', () => {
const m = matchesOf(`import { execSync } from 'node:child_process';
function f(c) { execSync(c); }`);
@ -276,7 +291,13 @@ describe('registry + model identity', () => {
it('registerBuiltinTaintModels registers TS, JS, and Python (idempotent); others stay undefined', () => {
registerBuiltinTaintModels();
registerBuiltinTaintModels(); // idempotent — last-write-wins on the same ids
expect(registeredTaintLanguages().sort()).toEqual(['javascript', 'python', 'typescript']);
expect(registeredTaintLanguages().sort()).toEqual([
'java',
'javascript',
'python',
'typescript',
]);
expect(getSourceSinkConfig('java')).toBe(BUILTIN_TAINT_MODELS.java);
expect(getSourceSinkConfig('typescript')).toBe(TS_JS_TAINT_MODEL);
expect(getSourceSinkConfig('javascript')).toBe(TS_JS_TAINT_MODEL);
expect(getSourceSinkConfig('python')).toBe(BUILTIN_TAINT_MODELS.python);

View file

@ -9,13 +9,14 @@ import { createPythonCfgVisitor } from '../../../src/core/ingestion/cfg/visitors
import { emitPythonScopeCaptures } from '../../../src/core/ingestion/languages/python/captures.js';
import { interpretPythonImport } from '../../../src/core/ingestion/languages/python/interpret.js';
import { PYTHON_TAINT_MODEL } from '../../../src/core/ingestion/taint/python-model.js';
import type { SourceSinkSanitizerSpec } from '../../../src/core/ingestion/taint/source-sink-config.js';
import { hasTaintSafeSites } from '../../../src/core/ingestion/taint/site-safety.js';
import {
buildTaintImportIndex,
matchFunctionSites,
type FunctionSiteMatches,
type MatchedSinkCall,
type MatchedSourceRead,
type MatchedSource,
} from '../../../src/core/ingestion/taint/match.js';
import { makeCfgHarness } from '../../helpers/cfg-harness.js';
@ -28,15 +29,19 @@ function importsFor(src: string): ParsedImport[] {
.filter((p): p is ParsedImport => p !== null);
}
function matchesOf(code: string, fnIndex = 0): FunctionSiteMatches {
function matchesOf(
code: string,
fnIndex = 0,
spec: SourceSinkSanitizerSpec = PYTHON_TAINT_MODEL,
): FunctionSiteMatches {
const cfg = harness.cfgOf(code, fnIndex);
expect(hasTaintSafeSites(cfg)).toBe(true);
return matchFunctionSites(cfg, PYTHON_TAINT_MODEL, buildTaintImportIndex(importsFor(code)));
return matchFunctionSites(cfg, spec, buildTaintImportIndex(importsFor(code)));
}
const allSinks = (m: FunctionSiteMatches): MatchedSinkCall[] =>
m.statements.flatMap((s) => [...s.sinks]);
const allSources = (m: FunctionSiteMatches): MatchedSourceRead[] =>
const allSources = (m: FunctionSiteMatches): MatchedSource[] =>
m.statements.flatMap((s) => [...s.sources]);
describe('Python taint model (#2204)', () => {
@ -75,6 +80,25 @@ def f(request):
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['run']);
});
it('does not dedupe named imports from a same-tail module path', () => {
const spec: SourceSinkSanitizerSpec = {
sources: [],
sinks: [{ name: 'read_string', kind: 'path-traversal', args: [0], module: 'pkg.Files' }],
sanitizers: [],
};
const m = matchesOf(
`
from pkg.Files import Files
def f(p):
Files.read_string(p)
`,
0,
spec,
);
expect(allSinks(m)).toHaveLength(0);
});
it('does not guess positional sink slots for keyword arguments', () => {
const m = matchesOf(`
import subprocess as sp

View file

@ -28,6 +28,19 @@ const SPEC: SourceSinkSanitizerSpec = {
sanitizers: [{ name: 'escape', neutralizes: ['command-injection'], global: true }],
};
const CALL_RESULT_SOURCE_SPEC: SourceSinkSanitizerSpec = {
sources: [
{
type: 'call-result',
kind: 'remote-input',
receivers: ['request'],
methods: ['getParameter'],
},
],
sinks: [],
sanitizers: [],
};
function harvest(code: string, spec: SourceSinkSanitizerSpec = SPEC, fnIndex = 0) {
const cfg: FunctionCfg = cfgOf(code, fnIndex);
const defUse = computeReachingDefs(cfg);
@ -93,15 +106,15 @@ describe('harvestFunctionSummary — call-arg sanitizer exclusions (#2084 review
// records that command-injection was neutralised on the path.
const f = harvest(`function f(x: string) { const y = escape(x); helper(y); }`);
const edge = f.paramToCallArg.find((c) => c.calleeName === 'helper');
expect(edge).toBeDefined();
expect(edge!.neutralized).toEqual(['command-injection']);
if (edge === undefined) throw new Error('expected helper call-arg edge');
expect(edge.neutralized).toEqual(['command-injection']);
});
it('records no neutralized when the param reaches the call directly', () => {
const f = harvest(`function f(x: string) { helper(x); }`);
const edge = f.paramToCallArg.find((c) => c.calleeName === 'helper');
expect(edge).toBeDefined();
expect(edge!.neutralized).toBeUndefined();
if (edge === undefined) throw new Error('expected helper call-arg edge');
expect(edge.neutralized).toBeUndefined();
});
});
@ -115,6 +128,19 @@ describe('harvestFunctionSummary — source→callee-arg (fixpoint seed)', () =>
const f = harvest(`function f() { const u = req.body; runIt(u); }`);
expect(f.sourceToCallArg.some((s) => s.calleeName === 'runIt')).toBe(true);
});
it('records an assigned call-result source passed via a local into a callee argument', () => {
const f = harvest(
`function f(request: { getParameter(name: string): string }) {
const u = request.getParameter('path');
runIt(u);
}`,
CALL_RESULT_SOURCE_SPEC,
);
expect(f.sourceToCallArg).toEqual([
{ sourceKind: 'remote-input', callLine: 3, argIndex: 0, calleeName: 'runIt' },
]);
});
});
describe('harvestFunctionSummary — call-result seeds (#2084 review P1-1)', () => {
@ -164,6 +190,17 @@ describe('harvestFunctionSummary — source→return', () => {
expect(f.sourceToReturn).toEqual([{ sourceKind: 'remote-input' }]);
});
it('records an assigned call-result source returned via a local', () => {
const f = harvest(
`function f(request: { getParameter(name: string): string }) {
const u = request.getParameter('path');
return u;
}`,
CALL_RESULT_SOURCE_SPEC,
);
expect(f.sourceToReturn).toEqual([{ sourceKind: 'remote-input' }]);
});
it('is empty when no source is present', () => {
const f = harvest(`function f(x: string) { return x; }`);
expect(f.sourceToReturn).toEqual([]);
@ -185,9 +222,9 @@ describe('harvestFunctionSummary — documented limitations', () => {
// fix (formal-param index from the worker) is deferred.
const f = harvest(`function f([a, b]: string[], x: string) { exec(x); }`);
const xSink = f.paramToSink.find((s) => s.sinkKind === 'command-injection');
expect(xSink).toBeDefined();
if (xSink === undefined) throw new Error('expected command-injection param sink');
// Current (limited) behaviour: ordinal index 2, NOT the formal index 1.
expect(xSink!.param).toBe(2);
expect(xSink.param).toBe(2);
});
});

View file

@ -18,6 +18,7 @@
import { describe, it, expect } from 'vitest';
import { cfgsOf, importsFor } from '../../helpers/ts-cfg-harness.js';
import { emitFileCfgs } from '../../../src/core/ingestion/cfg/emit.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import type { SourceSinkSanitizerSpec } from '../../../src/core/ingestion/taint/source-sink-config.js';
import {
emitFileTaint,
@ -44,6 +45,19 @@ const MECH_ALL_ARGS: SourceSinkSanitizerSpec = {
sinks: [{ name: 'exec', kind: 'command-injection', global: true }],
};
const CALL_RESULT_SOURCE_SPEC: SourceSinkSanitizerSpec = {
sources: [
{
type: 'call-result',
kind: 'remote-input',
receivers: ['request'],
methods: ['getParameter'],
},
],
sinks: [{ name: 'exec', kind: 'command-injection', args: [0], global: true }],
sanitizers: [],
};
interface RunResult {
graph: KnowledgeGraph;
result: TaintEmitResult;
@ -100,6 +114,36 @@ function handler(req: { body: string }) {
expect(tainted[0].id.startsWith('TAINTED:fixture.ts:')).toBe(true);
});
it('preserves the legacy member-read TAINTED edge identity', () => {
const { tainted } = run(CODE);
expect(tainted[0].id).toBe(
'TAINTED:fixture.ts:2:0:command-injection:2:0.0:req:2:17:2:1.0.0:cmd:3:8:exec:body',
);
});
it('persists a call-result source TAINTED edge with deterministic identity', () => {
const { result, tainted } = run(
`
function handler(request: { getParameter(name: string): string }) {
const cmd = request.getParameter('cmd');
exec(cmd);
}`,
{ spec: CALL_RESULT_SOURCE_SPEC },
);
expect(result.functionsAnalyzed).toBe(1);
expect(result.findingsEmitted).toBe(1);
expect(tainted).toHaveLength(1);
expect(tainted[0].id).toBe(
'TAINTED:fixture.ts:2:0:command-injection:2:0.0:call-result:cmd:3:8:request.getParameter:2:1.0.0:cmd:3:8:exec',
);
const decoded = decodeTaintPath(tainted[0].reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) {
expect(decoded.kind).toBe('command-injection');
expect(decoded.hops.map((h) => `${h.variable}@${h.line}`)).toEqual(['cmd@3', 'cmd@4']);
}
});
it('the persisted reason decodes via the shared codec with ordered hops + variables', () => {
const { tainted } = run(CODE);
const decoded = decodeTaintPath(tainted[0].reason);

View file

@ -134,17 +134,27 @@ describe('GITNEXUS_TOOLS', () => {
expect(impactTool.inputSchema.required).toContain('direction');
});
it('impact tool advertises the PDG-only `line` statement anchor (integer, min 1, not required)', () => {
it('impact tool advertises the PDG-only `line` statement anchor (integer, min 0, not required)', () => {
const impactTool = GITNEXUS_TOOLS.find((t) => t.name === 'impact')!;
const line = (impactTool.inputSchema.properties as Record<string, any>).line;
expect(line).toBeDefined();
expect(line.type).toBe('integer');
expect(line.minimum).toBe(1);
// minimum is 0 (not 1) so strict adapters that materialize an omitted
// optional numeric field as `0` are not rejected client-side (#2279); a
// positive line is enforced backend-side for a real pdg anchor.
expect(line.minimum).toBe(0);
// Statement-anchored slice is optional — never required.
expect(impactTool.inputSchema.required).not.toContain('line');
// The description names the mode:'pdg' statement-anchor semantics.
// The description names the mode:'pdg' statement-anchor semantics and the
// literal-0 compatibility convention — without contradicting the top-level
// "omit line for whole-symbol pdg" contract (#2283).
expect(line.description).toMatch(/statement anchor/i);
expect(line.description).toMatch(/pdg/i);
expect(line.description).toMatch(/literal 0 is tolerated only .* on the callgraph path/i);
expect(line.description).toMatch(/omit line for whole-symbol pdg/i);
// Must NOT claim pdg "requires a positive line" — that contradicts the valid
// no-line whole-symbol pdg call documented in the top-level description.
expect(line.description).not.toMatch(/requires a positive line/i);
// The top-level description mentions the statement-anchored slice and result shape.
expect(impactTool.description).toMatch(/statement-anchored|STATEMENT-ANCHORED/);
expect(impactTool.description).toContain('affectedStatements');
@ -157,6 +167,25 @@ describe('GITNEXUS_TOOLS', () => {
expect(renameTool.inputSchema.required).toContain('new_name');
});
it('trace tool advertises cross-repo @group support plus pdg/crossDepth flags (U3)', () => {
const traceTool = GITNEXUS_TOOLS.find((t) => t.name === 'trace')!;
const props = traceTool.inputSchema.properties as Record<
string,
{ type?: string; default?: unknown; minimum?: number; description?: string }
>;
// Experimental cross-repo flags are advertised and optional.
expect(props.pdg).toBeDefined();
expect(props.pdg.type).toBe('boolean');
expect(props.crossDepth).toBeDefined();
expect(props.crossDepth.type).toBe('number');
expect(traceTool.inputSchema.required).toEqual([]);
// The repo param and top-level description both name the @group entry point.
expect(props.repo.description).toMatch(/@groupName/);
expect(traceTool.description).toMatch(/CROSS-REPO/i);
expect(traceTool.description).toContain('ContractLink');
expect(traceTool.description).toContain('crossings');
});
it('detect_changes tool has no required parameters', () => {
const detectTool = GITNEXUS_TOOLS.find((t) => t.name === 'detect_changes')!;
expect(detectTool.inputSchema.required).toEqual([]);

View file

@ -1,12 +1,23 @@
## GitNexus vendor notice
This directory is a GitNexus-managed minimal **runtime** package derived from
`tree-sitter-kotlin@0.3.8` (fwcd). It carries only what the runtime needs:
`bindings/node/`, `src/node-types.json`, `LICENSE`, and the native
`prebuilds/`. The C source (`parser.c`, `scanner.c`, `binding.gyp`) is **not**
vendored — `parser.c` alone is ~23 MB, and the prebuilds are produced from the
published npm package, so committing the source would bloat git history for no
runtime benefit.
`tree-sitter-kotlin` (fwcd), pinned to the **unreleased `main` commit
[`c8ac3d26`](https://github.com/fwcd/tree-sitter-kotlin/commit/c8ac3d2627240160b999a2c100de3babbdb8f419)**
(`package.json` version `0.4.0`). It is pinned to `main` rather than
a tagged release because the latest release, `0.3.8` (tagged 2024-08-03),
predates `fun interface` (functional/SAM interface) support: it parsed
`fun interface Foo` as an `ERROR` node and dropped the declaration. That fix
landed in [PR #169](https://github.com/fwcd/tree-sitter-kotlin/pull/169)
(closing [issue #87](https://github.com/fwcd/tree-sitter-kotlin/issues/87)),
merged into `main` 2025-04-25 but **not yet in any npm release**.
It carries `bindings/node/`, `LICENSE`, the native `prebuilds/`, and the full C
source — `src/parser.c`, `src/scanner.c`, `src/node-types.json`,
`src/tree_sitter/`, and `binding.gyp`. The source IS vendored (despite the
~33 MB generated `parser.c`) for two reasons: it lets `build-tree-sitter-grammars.cjs`
source-build the binding on a toolchain host when no prebuild matches, and —
because the pinned commit is unreleased on npm — it is the source the prebuild
workflow itself compiles from (see below).
### Why this is vendored (unlike the npm grammars)
@ -18,25 +29,31 @@ prebuilds itself and vendors them here. `node-gyp-build` selects the correct
binary at require time; `build-tree-sitter-grammars.cjs` activates the binding
(prefer prebuild, else source-build) at install time.
`tree-sitter-swift` is handled the same way now: its prebuilds were originally
**copied from upstream** (Swift ships them), but it is unified with this pipeline —
its source is vendored and its prebuilds are **GitNexus-cross-built** too, so all
of Dart/Proto/Swift/Kotlin go through one uniform build path.
`tree-sitter-swift` is handled the same way: its source is vendored and its
prebuilds are **GitNexus-cross-built** from that vendored source. Kotlin now
uses this exact path too (workflow registry `kind: 'vendored'`, switched from
`'npm'` when this pin moved to an unreleased commit), so all of
Dart/Proto/Swift/Kotlin go through one uniform `kind: 'vendored'` build.
### Updating this vendor package
1. Bump the upstream version: update `version` in `package.json` (this is the
value the `build-tree-sitter-prebuilds` workflow diffs to decide whether to
rebuild) and refresh `_vendoredBy`.
2. Refresh `bindings/node/*` and `src/node-types.json` from the new upstream
`tree-sitter-kotlin` npm release.
1. Bump the pin: update `version` in `package.json` (this is the value the
`build-tree-sitter-prebuilds` workflow diffs to decide whether to rebuild)
and refresh `_vendoredBy` with the new ref.
2. Refresh `bindings/node/*`, `src/parser.c`, `src/scanner.c`,
`src/node-types.json`, `src/tree_sitter/*`, and `binding.gyp` from the new
upstream ref (a release tag, or — as now — a pinned `main` commit). For a
pinned commit the generated `parser.c` is committed upstream, so copy it
directly; if you re-pin to a ref that does not commit `parser.c`, regenerate
it with `tree-sitter generate` first.
3. Regenerate the six native prebuilds by running the
**`build-tree-sitter-prebuilds`** GitHub Actions workflow (it builds
`{linux,darwin,win32}-{x64,arm64}` from the published package and opens a PR
committing them under `prebuilds/`).
`{linux,darwin,win32}-{x64,arm64}` from this vendored source and opens a PR
committing them under `prebuilds/`). While `kind: 'vendored'`, the workflow
does NOT touch npm for kotlin.
4. Verify the packed GitNexus tarball can `require('tree-sitter-kotlin')` and
parse a Kotlin snippet on each target platform-arch (the workflow's validate
step does this in CI).
parse a Kotlin snippet (including a `fun interface`) on each target
platform-arch (the workflow's validate step does this in CI).
> Note: `darwin-x64` prebuilds depend on GitHub's `macos-15-intel` image, whose
> x86_64 macOS runners sunset ~Aug 2027. After that, darwin-x64 needs

View file

@ -1,6 +1,10 @@
const root = require("path").join(__dirname, "..", "..");
module.exports = require("node-gyp-build")(root);
module.exports =
typeof process.versions.bun === "string"
// Support `bun build --compile` by being statically analyzable enough to find the .node file at build-time
? require(`../../prebuilds/${process.platform}-${process.arch}/tree-sitter-kotlin.node`)
: require("node-gyp-build")(root);
try {
module.exports.nodeTypeInfo = require("../../src/node-types.json");

View file

@ -1,12 +1,12 @@
{
"name": "tree-sitter-kotlin",
"version": "0.3.8",
"version": "0.4.0",
"description": "Kotlin grammar for tree-sitter",
"repository": "https://github.com/fwcd/tree-sitter-kotlin",
"license": "MIT",
"main": "bindings/node/index.js",
"types": "bindings/node/index.d.ts",
"_vendoredBy": "gitnexus - runtime package derived from tree-sitter-kotlin@0.3.8 (fwcd). Unlike Swift's upstream-shipped prebuilds, upstream tree-sitter-kotlin ships SOURCE ONLY (no prebuilds/); the native prebuilds/ here are GitNexus-cross-built by .github/workflows/build-tree-sitter-prebuilds.yml. The grammar source (parser.c/scanner.c/binding.gyp + src/) is ALSO vendored so build-tree-sitter-grammars.cjs can source-build the binding on a toolchain host when no prebuild matches (e.g. CI before prebuilds land). The generated parser.c is large (~23 MB on disk; it compresses heavily in git); once the prebuilds cover every platform-arch the source serves only as the fallback. Loaded from vendor/ by absolute path at runtime (vendored-grammars.ts) — NEVER copied to node_modules (#2111) (no scripts.install here — #836/#1728).",
"_vendoredBy": "gitnexus - runtime package derived from tree-sitter-kotlin (fwcd) at unreleased main commit c8ac3d2627240160b999a2c100de3babbdb8f419 (package.json version 0.4.0; latest npm/tag is still 0.3.8). Pinned to main rather than a release to pull in `fun interface` (functional/SAM interface) support — PR fwcd/tree-sitter-kotlin#169, fixing issue #87 — which 0.3.8 (tagged 2024-08-03) lacks: it parsed `fun interface Foo` as an ERROR node and dropped the declaration. Because the fix is unreleased on npm, the prebuild workflow builds kotlin from THIS vendored source (kind 'vendored', like swift), NOT from the npm package. The grammar source (parser.c/scanner.c/binding.gyp + src/) is vendored so build-tree-sitter-grammars.cjs can source-build the binding on a toolchain host when no prebuild matches; the native prebuilds/ are GitNexus-cross-built by .github/workflows/build-tree-sitter-prebuilds.yml (regenerated whenever this `version` changes). The generated parser.c is large (~33 MB on disk; it compresses heavily in git). Loaded from vendor/ by absolute path at runtime (vendored-grammars.ts) — NEVER copied to node_modules (#2111) (no scripts.install here — #836/#1728). To re-pin: bump `version`, refresh src/ + bindings/node/ from the new upstream ref, update this note, and let the prebuild workflow rebuild the binaries.",
"peerDependencies": {
"tree-sitter": "^0.21.0"
},

View file

@ -1,6 +1,6 @@
11e63706f960303259b842f98030bf7c5845f312ed9cfa1fcc44e6ff4c002841 ./darwin-arm64/tree-sitter-kotlin.node
e5cd10bf993a2d20f8187d39486e3cbcb0567ca192576fae745c70e8be43f223 ./darwin-x64/tree-sitter-kotlin.node
a73110ce49a421b4d09acfbbdbb05bc1d1a54b6c88017d2e59199144bb9c1853 ./linux-arm64/tree-sitter-kotlin.node
7409baf83f363d15bdcbc09698acc9ca8e7e25ccb62eb6074e787166ff0bf9ef ./linux-x64/tree-sitter-kotlin.node
1467a068fd28de07cd333e3e577ee2ccc34631780b7bd34b151c4c8118dddaf6 ./win32-arm64/tree-sitter-kotlin.node
c53bcf3c651e33d2778c83e9ef7cb7f18e547e1941132f6ade69d0717b9325eb ./win32-x64/tree-sitter-kotlin.node
eead3dd8a0144a00fbc1564e27d86217c9134a83ed85a435933969652fcdb1d1 ./darwin-arm64/tree-sitter-kotlin.node
931c1d0844e857d7eb16e537bc3791e9e696f3e9074342c7920d11888371e6f9 ./darwin-x64/tree-sitter-kotlin.node
e450af11745811646af4be54c6e1ac69767184b878de790ee37e92546e4685d3 ./linux-arm64/tree-sitter-kotlin.node
220f109e4e2ce3f27e5889bd326cf7dc1ec83c5a99ec022f60f023c240fc52a8 ./linux-x64/tree-sitter-kotlin.node
ea26652d30ac4e75a16ec8fca7194bfe0324c96d169f5ccbdf1fde42ed99de83 ./win32-arm64/tree-sitter-kotlin.node
c2b049fe147f40df5546163c07c50cbfcdb1e0dbe348cd003a998e47a61307bb ./win32-x64/tree-sitter-kotlin.node

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -8,12 +8,15 @@
enum TokenType {
AUTOMATIC_SEMICOLON,
IMPORT_LIST_DELIMITER,
SAFE_NAV,
MULTILINE_COMMENT,
STRING_START,
STRING_END,
STRING_CONTENT,
PRIMARY_CONSTRUCTOR_KEYWORD,
IMPORT_DOT,
INTERPOLATION_EXPRESSION_START,
INTERPOLATION_IDENTIFIER_START,
BY_DELEGATION_HINT,
};
/* Pretty much all of this code is taken from the Julia tree-sitter
@ -43,16 +46,21 @@ enum TokenType {
typedef char Delimiter;
// We use a stack to keep track of the string delimiters.
// Each entry is two bytes: [delimiter_byte, prefix_len_byte].
// delimiter_byte: '"' for single-quoted, '"'+1 for triple-quoted.
// prefix_len_byte: number of '$' signs required to trigger interpolation
// (1 for regular strings and $"...", 2 for $$"...", etc.; max 255).
typedef Array(Delimiter) Stack;
static inline void stack_push(Stack *stack, char chr, bool triple) {
if (stack->size >= TREE_SITTER_SERIALIZATION_BUFFER_SIZE) abort();
static inline void stack_push(Stack *stack, char chr, bool triple, uint8_t prefix_len) {
if (stack->size + 1 >= TREE_SITTER_SERIALIZATION_BUFFER_SIZE) abort();
array_push(stack, (Delimiter)(triple ? (chr + 1) : chr));
array_push(stack, (Delimiter)prefix_len);
}
static inline Delimiter stack_pop(Stack *stack) {
if (stack->size == 0) abort();
return array_pop(stack);
static inline void stack_pop(Stack *stack) {
if (stack->size < 2) abort();
stack->size -= 2;
}
static inline void skip(TSLexer *lexer) { lexer->advance(lexer, true); }
@ -62,51 +70,102 @@ static inline void advance(TSLexer *lexer) { lexer->advance(lexer, false); }
// Scanner functions
static bool scan_string_start(TSLexer *lexer, Stack *stack) {
// Count leading '$' signs (the interpolation prefix). Capped at 255.
uint8_t prefix_len = 0;
while (lexer->lookahead == '$') {
advance(lexer);
if (prefix_len < 255) prefix_len++;
}
// Regular strings with no prefix still use a single '$' as the trigger.
if (prefix_len == 0) prefix_len = 1;
if (lexer->lookahead != '"') return false;
advance(lexer);
lexer->mark_end(lexer);
for (unsigned count = 1; count < DELIMITER_LENGTH; ++count) {
if (lexer->lookahead != '"') {
// It's not a triple quoted delimiter.
stack_push(stack, '"', false);
stack_push(stack, '"', false, prefix_len);
return true;
}
advance(lexer);
}
lexer->mark_end(lexer);
stack_push(stack, '"', true);
stack_push(stack, '"', true, prefix_len);
return true;
}
static bool scan_string_content(TSLexer *lexer, Stack *stack) {
if (stack->size == 0) return false; // Stack is empty. We're not in a string.
Delimiter end_char = stack->contents[stack->size - 1]; // peek
bool is_triple = false;
static bool scan_string_content(TSLexer *lexer, Stack *stack,
const bool *valid_symbols) {
if (stack->size < 2) return false; // Stack is empty. We're not in a string.
uint8_t prefix_len = (uint8_t)stack->contents[stack->size - 1];
Delimiter raw_delim = stack->contents[stack->size - 2];
bool is_triple = (raw_delim & 1) != 0;
char end_char = is_triple ? (char)(raw_delim - 1) : (char)raw_delim;
bool has_content = false;
if (end_char & 1) {
is_triple = true;
end_char -= 1;
}
while (lexer->lookahead) {
if (lexer->lookahead == '$') {
// if we did not just start reading stuff, then we should stop
// lexing right here, so we can offer the opportunity to lex a
// interpolated identifier
// If we already have content, stop here so the caller can emit it
// before we deal with the potential interpolation.
if (has_content) {
lexer->result_symbol = STRING_CONTENT;
return has_content;
return true;
}
// otherwise, if this is the start, determine if it is an
// interpolated identifier.
// otherwise, it's just string content, so continue
// Kotlin 2.1 multi-dollar interpolation: in a string with prefix_len N,
// exactly N consecutive '$' followed by alpha/'{' triggers interpolation.
// Excess leading '$' signs are literal string content.
//
// Strategy: consume the first '$' and mark_end there, then count
// remaining '$' signs. If total > prefix_len, return STRING_CONTENT
// for just the first '$' (tree-sitter rewinds to mark_end). On the
// next scan call, the remaining dollars will be re-examined.
advance(lexer);
if (iswalpha(lexer->lookahead) || lexer->lookahead == '{') {
// this must be a string interpolation, let's
// fail so we parse it as such
lexer->mark_end(lexer);
uint16_t additional_dollars = 0;
while (lexer->lookahead == '$') {
advance(lexer);
additional_dollars++;
}
uint16_t total_dollars = 1 + additional_dollars;
if (total_dollars >= prefix_len &&
(iswalpha(lexer->lookahead) || lexer->lookahead == '_' || lexer->lookahead == '{')) {
if (total_dollars > prefix_len) {
// Excess: emit first '$' as literal STRING_CONTENT.
// mark_end is after the first '$'; tree-sitter rewinds there.
lexer->result_symbol = STRING_CONTENT;
return true;
}
// Exact match: emit interpolation start token.
if (additional_dollars > 0) {
lexer->mark_end(lexer);
}
if (valid_symbols[INTERPOLATION_EXPRESSION_START] &&
lexer->lookahead == '{') {
advance(lexer);
// Empty interpolation "${}" is invalid Kotlin (compile error:
// "Expecting an expression"). Refuse to emit the interpolation
// token so the parser produces an ERROR node instead of matching
// a zero-width expression.
if (lexer->lookahead == '}') {
return false;
}
lexer->mark_end(lexer);
lexer->result_symbol = INTERPOLATION_EXPRESSION_START;
return true;
}
if (valid_symbols[INTERPOLATION_IDENTIFIER_START] &&
(iswalpha(lexer->lookahead) || lexer->lookahead == '_')) {
lexer->result_symbol = INTERPOLATION_IDENTIFIER_START;
return true;
}
return false;
}
// Not enough '$' signs or not followed by alpha/'{':
// all consumed dollars are literal string content.
if (additional_dollars > 0) {
lexer->mark_end(lexer);
}
lexer->result_symbol = STRING_CONTENT;
lexer->mark_end(lexer);
return true;
}
if (lexer->lookahead == '\\') {
@ -128,6 +187,13 @@ static bool scan_string_content(TSLexer *lexer, Stack *stack) {
lexer->result_symbol = STRING_END;
return true;
}
} else if (is_triple && lexer->lookahead == end_char) {
// In triple-quoted strings, `\` is NOT an escape character. So `\"` is
// also literal backslash + quote, and the `"` might be the start of
// the closing `"""`. Don't advance past it (at the end of the while
// loop). Let the next iteration handle it.
has_content = true;
continue;
}
} else if (lexer->lookahead == end_char) {
if (is_triple) {
@ -189,6 +255,7 @@ static bool scan_string_content(TSLexer *lexer, Stack *stack) {
return false;
}
static bool scan_multiline_comment(TSLexer *lexer) {
if (lexer->lookahead != '/') return false;
advance(lexer);
@ -222,6 +289,16 @@ static bool scan_multiline_comment(TSLexer *lexer) {
}
break;
case '\0':
// Accept unterminated block comments at EOF rather than rejecting them.
// This matches JetBrains PSI behavior which recognizes unclosed /* as a
// BLOCK_COMMENT token (plus an error element). Without this, the scanner
// returns false and tree-sitter tries to parse the comment delimiters
// as operators/expressions.
if (lexer->eof(lexer)) {
lexer->result_symbol = MULTILINE_COMMENT;
lexer->mark_end(lexer);
return true;
}
return false;
default:
advance(lexer);
@ -233,19 +310,160 @@ static bool scan_multiline_comment(TSLexer *lexer) {
static bool scan_whitespace_and_comments(TSLexer *lexer) {
while (iswspace(lexer->lookahead)) skip(lexer);
return lexer->lookahead != '/';
return true;
}
// Test for any identifier character other than the first character.
// This is meant to match the regexp [\p{L}_\p{Nd}]
// as found in '_alpha_identifier' (see grammar.js).
static bool is_word_char(int32_t c) {
return (iswalnum(c) || c == '_');
}
// Scan for [the end of] a nonempty alphanumeric identifier or
// alphanumeric keyword (including '_').
static bool scan_for_word(TSLexer *lexer, const char* word, unsigned len) {
skip(lexer);
for (unsigned i = 0; i < len; ++i) {
if (lexer->lookahead != word[i]) return false;
skip(lexer);
}
// check that the identifier stops here
if (is_word_char(lexer->lookahead)) return false;
return true;
}
static bool scan_automatic_semicolon(TSLexer *lexer) {
// Check if a sequence of characters matches the given word and is followed
// by a non-word character. Uses skip() so characters are not included in
// the current token.
static bool check_word(TSLexer *lexer, const char *word, unsigned len) {
for (unsigned i = 0; i < len; i++) {
if (lexer->lookahead != word[i]) return false;
skip(lexer);
}
return !is_word_char(lexer->lookahead);
}
// Skip whitespace (space, tab, newline, CR) and comments (// and nested /* */)
// using skip() so characters are not included in the current token.
// Returns false if a bare '/' is encountered (not a comment), true otherwise.
static bool skip_whitespace_and_comments(TSLexer *lexer) {
for (;;) {
while (iswspace(lexer->lookahead)) skip(lexer);
if (lexer->lookahead != '/') return true;
skip(lexer);
if (lexer->lookahead == '/') {
// Line comment — skip to end of line
skip(lexer);
while (lexer->lookahead != '\n' && lexer->lookahead != '\r' &&
!lexer->eof(lexer)) {
skip(lexer);
}
} else if (lexer->lookahead == '*') {
// Block comment — skip to */ (with nesting)
skip(lexer);
unsigned depth = 1;
while (depth > 0 && !lexer->eof(lexer)) {
if (lexer->lookahead == '*') {
skip(lexer);
if (lexer->lookahead == '/') { skip(lexer); depth--; }
} else if (lexer->lookahead == '/') {
skip(lexer);
if (lexer->lookahead == '*') { skip(lexer); depth++; }
} else {
skip(lexer);
}
}
} else {
// Bare '/' — not a comment
return false;
}
}
}
// After scan_for_word has matched "else", peek past optional whitespace
// and comments for "->". If found, this is a when-entry's `else ->`,
// not an if-else. Uses skip() so characters are not included in the
// current token.
static bool followed_by_arrow(TSLexer *lexer) {
if (!skip_whitespace_and_comments(lexer)) return false;
if (lexer->lookahead != '-') return false;
skip(lexer);
return lexer->lookahead == '>';
}
// Check if the current position has a visibility modifier (public, private,
// protected, internal) followed by horizontal whitespace and "constructor".
// Uses skip() — safe to call speculatively since no token boundary is changed.
static bool check_modifier_then_constructor(TSLexer *lexer) {
// Buffer the first word to identify the modifier
char word[20];
unsigned len = 0;
while (is_word_char(lexer->lookahead) && len < 19) {
word[len++] = (char)lexer->lookahead;
skip(lexer);
}
word[len] = '\0';
if (strcmp(word, "public") != 0 && strcmp(word, "private") != 0 &&
strcmp(word, "protected") != 0 && strcmp(word, "internal") != 0) {
return false;
}
// Skip horizontal whitespace (not newlines)
while (lexer->lookahead == ' ' || lexer->lookahead == '\t') skip(lexer);
return check_word(lexer, "constructor", 11);
}
// Look ahead past one or more annotations (e.g. @Bar, @com.example.Bar,
// @Bar(x=1)) and optional visibility modifier, then check for 'constructor'.
// All characters are consumed with skip() so nothing affects token boundaries.
static bool check_annotation_then_constructor(TSLexer *lexer) {
// Skip one or more '@annotation' sequences
while (lexer->lookahead == '@') {
skip(lexer); // skip '@'
if (!is_word_char(lexer->lookahead)) return false;
// Read annotation name, including dot-separated qualifiers
// (e.g. com.example.Inject)
while (is_word_char(lexer->lookahead)) skip(lexer);
while (lexer->lookahead == '.') {
skip(lexer); // skip '.'
if (!is_word_char(lexer->lookahead)) break;
while (is_word_char(lexer->lookahead)) skip(lexer);
}
// Skip optional '(...)' argument list (handle nested parens and strings)
if (lexer->lookahead == '(') {
unsigned depth = 1;
skip(lexer);
while (depth > 0 && lexer->lookahead != '\0' && !lexer->eof(lexer)) {
if (lexer->lookahead == '"') {
// Skip over string literal to avoid miscounting parens inside strings
skip(lexer);
while (lexer->lookahead != '"' && lexer->lookahead != '\0' && !lexer->eof(lexer)) {
if (lexer->lookahead == '\\') skip(lexer); // skip escaped char
skip(lexer);
}
if (lexer->lookahead == '"') skip(lexer); // skip closing quote
} else {
if (lexer->lookahead == '(') depth++;
else if (lexer->lookahead == ')') depth--;
skip(lexer);
}
}
}
// Skip whitespace and newlines between annotations or before constructor
while (iswspace(lexer->lookahead)) skip(lexer);
}
// Allow an optional visibility modifier before 'constructor'
if (is_word_char(lexer->lookahead) && lexer->lookahead != 'c') {
return check_modifier_then_constructor(lexer);
}
// Check directly for 'constructor'
return check_word(lexer, "constructor", 11);
}
static bool scan_automatic_semicolon(TSLexer *lexer, const bool *valid_symbols) {
lexer->result_symbol = AUTOMATIC_SEMICOLON;
lexer->mark_end(lexer);
@ -285,10 +503,8 @@ static bool scan_automatic_semicolon(TSLexer *lexer) {
if (sameline) {
switch (lexer->lookahead) {
// Don't insert a semicolon before an else
case 'e':
return !scan_for_word(lexer, "lse", 3);
// Insert imaginary semicolon before an 'import' but not in front
// of other words or keywords starting with 'i'
case 'i':
return scan_for_word(lexer, "mport", 5);
@ -297,186 +513,400 @@ static bool scan_automatic_semicolon(TSLexer *lexer) {
lexer->mark_end(lexer);
return true;
// Don't insert a semicolon in other cases
default:
return false;
}
}
switch (lexer->lookahead) {
case ',':
case '.':
case ':':
case '*':
case '%':
case '>':
case '<':
case '=':
case '{':
case '[':
case '(':
case '?':
case '|':
case '&':
case '/':
return false;
case ',':
case '.':
case ':':
case '*':
case '%':
case '>':
case '<':
case '=':
case '{':
case '[':
case '(':
case '?':
case '|':
case '&':
return false;
// Insert a semicolon before `--` and `++`, but not before binary `+` or `-`.
// Insert before +/-Float
case '+':
skip(lexer);
if (lexer->lookahead == '+') return true;
return iswdigit(lexer->lookahead);
// Handle `/` — could be division, line comment, or block comment.
// For division: no ASI (continuation operator).
// For line comments (`//`): skip the comment(s) and check the next
// real token. If continuation, suppress ASI (return false — tree-sitter
// resets, parses line_comment internally, then re-checks ASI).
// If non-continuation, insert ASI (return true at original mark_end).
// For block comments (`/*`): advance through the comment and produce
// MULTILINE_COMMENT. The parser then re-calls the scanner for the ASI
// decision on whatever token follows the comment.
case '/': {
advance(lexer);
if (lexer->lookahead == '/') {
// Line comment — skip to end of line using skip() since
// line_comment is an internal token (the grammar handles it).
skip(lexer);
while (lexer->lookahead != '\n' && lexer->lookahead != '\r' &&
lexer->lookahead != 0 && !lexer->eof(lexer)) {
skip(lexer);
}
// Skip any whitespace and further comments after this line comment.
// A bare '/' (division) after comments is a continuation operator.
if (!skip_whitespace_and_comments(lexer)) return false;
// Now check the next real token.
switch (lexer->lookahead) {
case '.': case ',': case ':': case '*': case '%':
case '>': case '<': case '=': case '{': case '[':
case '(': case '?': case '|': case '&': case '/':
return false;
case '!':
skip(lexer);
if (lexer->lookahead == '=') return false;
return true;
case 'e':
if (scan_for_word(lexer, "lse", 3)) {
if (followed_by_arrow(lexer)) return true;
return false;
}
return true;
case 'a':
if (scan_for_word(lexer, "s", 1)) return false;
return true;
case 'w':
if (scan_for_word(lexer, "here", 4)) return false;
return true;
case 'c':
if (scan_for_word(lexer, "atch", 4)) return false;
return true;
case 'b':
if (valid_symbols[BY_DELEGATION_HINT] &&
scan_for_word(lexer, "y", 1)) return false;
return true;
case 'f':
if (scan_for_word(lexer, "inally", 6)) return false;
return true;
default:
return true;
}
} else if (lexer->lookahead == '*') {
// Block comment after a newline. Use advance() to read through the
// comment so the content is available for MULTILINE_COMMENT if we
// decide to produce it. DON'T call mark_end yet — we defer that
// decision until we know what follows the comment.
advance(lexer);
unsigned nesting_depth = 1;
bool after_star = false;
while (nesting_depth > 0 && !lexer->eof(lexer)) {
switch (lexer->lookahead) {
case '*':
advance(lexer);
after_star = true;
break;
case '/':
advance(lexer);
if (after_star) {
after_star = false;
nesting_depth--;
} else {
if (lexer->lookahead == '*') {
nesting_depth++;
advance(lexer);
}
after_star = false;
}
break;
case '\0':
if (lexer->eof(lexer)) {
// Unterminated block comment at EOF — produce it.
lexer->result_symbol = MULTILINE_COMMENT;
lexer->mark_end(lexer);
return true;
}
// fallthrough
default:
advance(lexer);
after_star = false;
break;
}
}
// Skip whitespace after the block comment. Don't skip further
// comments — the continuation switch handles '/' and '*', so
// subsequent comments will be correctly treated as continuation.
// Skipping them here would swallow them (they'd never appear
// as separate tokens in the parse tree).
while (iswspace(lexer->lookahead)) skip(lexer);
// Check the next real token to decide: MULTILINE_COMMENT or ASI?
//
// IMPORTANT: For keyword checks (else, as, where, !=), we must
// call mark_end BEFORE scan_for_word/skip, because those functions
// advance the cursor past the keyword. If mark_end were called
// after, the MULTILINE_COMMENT span would swallow the keyword
// and the parser would never see it.
switch (lexer->lookahead) {
case '.': case ',': case ':': case '%':
case '>': case '<': case '=': case '{': case '[':
case '(': case '?': case '|': case '&': case '/':
case '*':
// Continuation operator — produce MULTILINE_COMMENT.
lexer->mark_end(lexer);
lexer->result_symbol = MULTILINE_COMMENT;
return true;
case '!':
// mark_end before consuming '!' so it's not swallowed.
lexer->mark_end(lexer);
skip(lexer);
if (lexer->lookahead == '=') {
// != is continuation — produce MULTILINE_COMMENT.
lexer->result_symbol = MULTILINE_COMMENT;
return true;
}
// Unary ! — not continuation. Produce ASI at original
// position (mark_end was at P0 before, now at '!' position,
// but the token has no advance()d content past the comment,
// so tree-sitter will re-scan from here).
return true;
case 'e':
lexer->mark_end(lexer);
if (scan_for_word(lexer, "lse", 3)) {
if (followed_by_arrow(lexer)) return true;
lexer->result_symbol = MULTILINE_COMMENT;
return true;
}
return true;
case 'a':
lexer->mark_end(lexer);
if (scan_for_word(lexer, "s", 1)) {
lexer->result_symbol = MULTILINE_COMMENT;
return true;
}
return true;
case 'w':
lexer->mark_end(lexer);
if (scan_for_word(lexer, "here", 4)) {
lexer->result_symbol = MULTILINE_COMMENT;
return true;
}
return true;
case 'b':
if (valid_symbols[BY_DELEGATION_HINT]) {
lexer->mark_end(lexer);
if (scan_for_word(lexer, "y", 1)) {
lexer->result_symbol = MULTILINE_COMMENT;
return true;
}
}
return true;
default:
// the original position (P0, before the comment), so the
// ASI token is zero-width. The block comment will be
// re-scanned as MULTILINE_COMMENT on the next parse step.
return true;
}
}
// Bare `/` (not `//` or `/*`) — division. No ASI.
return false;
}
case '-':
skip(lexer);
if (lexer->lookahead == '-') return true;
return iswdigit(lexer->lookahead);
// In Kotlin, `+` and `-` after a newline are always prefix operators,
// not binary continuation. If a binary operation is intended, the
// operator must be placed at the end of the previous line:
// a + // binary: a + b
// b
// a // prefix: a; +b
// + b
// The grammar ensures AUTOMATIC_SEMICOLON is only valid where a
// statement could end, so this won't fire inside () or [] where
// newlines don't terminate statements.
case '+':
case '-':
return true;
// Don't insert a semicolon before `!=`, but do insert one before a unary `!`.
case '!':
skip(lexer);
return lexer->lookahead != '=';
// Don't insert a semicolon before `!=`, but do insert one before a unary `!`.
case '!':
skip(lexer);
return lexer->lookahead != '=';
// Don't insert a semicolon before an else
case 'e':
return !scan_for_word(lexer, "lse", 3);
// Don't insert a semicolon before 'by' in delegation contexts.
// Gated on BY_DELEGATION_HINT so `by` remains a usable soft-keyword
// identifier in non-delegation positions.
case 'b':
return !(valid_symbols[BY_DELEGATION_HINT] &&
scan_for_word(lexer, "y", 1));
// Don't insert a semicolon before `in` or `instanceof`, but do insert one
// before an identifier or an import.
case 'i':
skip(lexer);
if (lexer->lookahead != 'n') return true;
skip(lexer);
if (!iswalpha(lexer->lookahead)) return false;
return !scan_for_word(lexer, "stanceof", 8);
// Don't insert a semicolon before an else, unless it's
// followed by "->" (a when-entry's else, not an if-else).
case 'e':
if (!scan_for_word(lexer, "lse", 3)) return true;
return followed_by_arrow(lexer);
case ';':
advance(lexer);
lexer->mark_end(lexer);
return true;
// Don't insert a semicolon before an as
case 'a':
return !scan_for_word(lexer, "s", 1);
default:
return true;
// Don't insert a semicolon before a where
case 'w':
return !scan_for_word(lexer, "here", 4);
// Don't insert a semicolon before `instanceof`, or before `internal`
// when followed by `constructor` in a class declaration context.
case 'i':
if (valid_symbols[PRIMARY_CONSTRUCTOR_KEYWORD] &&
!valid_symbols[STRING_CONTENT] &&
check_modifier_then_constructor(lexer)) {
return false;
}
// Note: lexer has advanced past the word. For "instanceof", scan_for_word
// can no longer match. But since "instanceof" is not a Kotlin keyword
// (Kotlin uses "is"), this is acceptable — ASI is inserted, which is
// the correct behavior for any non-constructor identifier.
return true;
// Don't insert a semicolon before `public/private/protected constructor`
// in class declaration context.
case 'p':
if (valid_symbols[PRIMARY_CONSTRUCTOR_KEYWORD] &&
!valid_symbols[STRING_CONTENT] &&
check_modifier_then_constructor(lexer)) {
return false;
}
return true;
// Don't insert a semicolon before `constructor` if the parser expects
// a primary constructor (class declaration context). In class body
// context, PRIMARY_CONSTRUCTOR_KEYWORD won't be valid, so ASI is
// inserted normally before secondary constructors.
// Guard against error recovery mode where all symbols are valid.
// Instead of suppressing ASI, we emit the constructor keyword directly
// since it's an external token and the internal lexer won't match it.
case 'c':
if (valid_symbols[PRIMARY_CONSTRUCTOR_KEYWORD] &&
!valid_symbols[STRING_CONTENT]) {
const char *kw = "constructor";
bool matched = true;
for (unsigned i = 0; i < 11; i++) {
if (lexer->lookahead != kw[i]) { matched = false; break; }
advance(lexer);
}
if (matched && !is_word_char(lexer->lookahead)) {
lexer->result_symbol = PRIMARY_CONSTRUCTOR_KEYWORD;
lexer->mark_end(lexer);
return true;
}
// If constructor didn't match, we've advanced past some chars.
// Can't reliably check 'catch' now. Just insert ASI.
return true;
}
// Not in constructor context — check for 'catch'
return !scan_for_word(lexer, "atch", 4);
// Don't insert a semicolon before finally (continues try_expression)
case 'f':
return !scan_for_word(lexer, "inally", 6);
// Don't insert a semicolon before an annotation that precedes 'constructor'
// e.g. `class Foo\n@Bar\nconstructor(...)` — the @Bar is a constructor modifier
case '@':
if (valid_symbols[PRIMARY_CONSTRUCTOR_KEYWORD] &&
!valid_symbols[STRING_CONTENT] &&
check_annotation_then_constructor(lexer)) {
return false;
}
return true;
case ';':
advance(lexer);
lexer->mark_end(lexer);
return true;
default:
return true;
}
}
static bool scan_safe_nav(TSLexer *lexer) {
lexer->result_symbol = SAFE_NAV;
// Scan a dot in import identifiers. Matches '.' normally, but when the dot
// is followed by a newline and then the 'import' keyword, produces an
// AUTOMATIC_SEMICOLON (zero-width, before the dot) instead. This cleanly
// terminates the current import_header, preventing malformed imports
// (e.g. trailing dots) from bleeding into subsequent valid imports.
static bool scan_import_dot(TSLexer *lexer) {
if (lexer->lookahead != '.') return false;
// Mark end BEFORE consuming the dot — this is where ASI would go
lexer->mark_end(lexer);
// skip white space
if (!scan_whitespace_and_comments(lexer))
return false;
if (lexer->lookahead != '?')
return false;
advance(lexer);
if (!scan_whitespace_and_comments(lexer))
return false;
// Peek ahead: skip horizontal whitespace, check for newline
bool found_newline = false;
while (iswspace(lexer->lookahead)) {
if (lexer->lookahead == '\n' || lexer->lookahead == '\r') {
found_newline = true;
}
skip(lexer);
}
if (lexer->lookahead != '.')
return false;
if (found_newline && lexer->lookahead == 'i' &&
scan_for_word(lexer, "mport", 5)) {
// Trailing dot followed by 'import' on next line — produce ASI
// instead of the dot. mark_end was set before the dot, so the
// semicolon is zero-width at that position.
lexer->result_symbol = AUTOMATIC_SEMICOLON;
return true;
}
advance(lexer);
// Normal dot — include it in the token
lexer->result_symbol = IMPORT_DOT;
lexer->mark_end(lexer);
return true;
}
static bool scan_line_sep(TSLexer *lexer) {
// Line Seps: [ CR, LF, CRLF ]
int state = 0;
while (true) {
switch(lexer->lookahead) {
case ' ':
case '\t':
case '\v':
// Skip whitespace
advance(lexer);
break;
case '\n':
advance(lexer);
return true;
case '\r':
if (state == 1)
return true;
state = 1;
advance(lexer);
break;
default:
// We read a CR
if (state == 1)
return true;
return false;
}
}
}
static bool scan_import_list_delimiter(TSLexer *lexer) {
// Import lists are terminated either by an empty line or a non import statement
lexer->result_symbol = IMPORT_LIST_DELIMITER;
lexer->mark_end(lexer);
// if eof; return true
if (lexer->eof(lexer))
return true;
// Scan for the first line seperator
if (!scan_line_sep(lexer))
return false;
// if line.sep line.sep; return true
if (scan_line_sep(lexer)) {
lexer->mark_end(lexer);
return true;
}
// if line.sep [^import]; return true
while (true) {
switch (lexer->lookahead) {
case ' ':
case '\t':
case '\v':
// Skip whitespace
advance(lexer);
break;
case 'i':
return !scan_for_word(lexer, "mport", 5);
default:
return true;
}
return false;
}
}
bool tree_sitter_kotlin_external_scanner_scan(void *payload, TSLexer *lexer, const bool *valid_symbols) {
// BY_DELEGATION_HINT is declared in the grammar (optional, before `by` in
// explicit_delegation and property_delegate) purely so it appears in
// valid_symbols when the parser is in a delegation context. The scanner
// never emits it; it's used only as a context flag in scan_automatic_semicolon.
if (valid_symbols[AUTOMATIC_SEMICOLON]) {
bool ret = scan_automatic_semicolon(lexer);
if (!ret && valid_symbols[SAFE_NAV] && lexer->lookahead == '?') {
return scan_safe_nav(lexer);
}
bool ret = scan_automatic_semicolon(lexer, valid_symbols);
// if we fail to find an automatic semicolon, it's still possible that we may
// want to lex a string or comment later
if (ret) return ret;
}
if (valid_symbols[IMPORT_LIST_DELIMITER]) {
return scan_import_list_delimiter(lexer);
// Match dots in import identifiers, refusing dots that would cause
// malformed imports to bleed into subsequent import statements.
if (valid_symbols[IMPORT_DOT]) {
if (scan_import_dot(lexer)) return true;
}
// content or end
if (valid_symbols[STRING_CONTENT] && scan_string_content(lexer, payload)) {
return true;
// Match 'constructor' keyword for primary constructors when on the same line
// (the cross-newline case is handled inside scan_automatic_semicolon)
if (valid_symbols[PRIMARY_CONSTRUCTOR_KEYWORD] && !valid_symbols[STRING_CONTENT]) {
while (iswspace(lexer->lookahead)) skip(lexer);
if (lexer->lookahead == 'c') {
const char *kw = "constructor";
bool matched = true;
for (unsigned i = 0; i < 11; i++) {
if (lexer->lookahead != kw[i]) { matched = false; break; }
advance(lexer);
}
if (matched && !is_word_char(lexer->lookahead)) {
lexer->result_symbol = PRIMARY_CONSTRUCTOR_KEYWORD;
lexer->mark_end(lexer);
return true;
}
}
}
// content, end, or interpolation start
if (valid_symbols[STRING_CONTENT] || valid_symbols[INTERPOLATION_EXPRESSION_START] ||
valid_symbols[INTERPOLATION_IDENTIFIER_START]) {
if (scan_string_content(lexer, payload, valid_symbols)) return true;
}
// a string might follow after some whitespace, so we can't lookahead
@ -492,10 +922,6 @@ bool tree_sitter_kotlin_external_scanner_scan(void *payload, TSLexer *lexer, con
return true;
}
if (valid_symbols[SAFE_NAV]) {
return scan_safe_nav(lexer);
}
return false;
}
@ -514,13 +940,22 @@ void tree_sitter_kotlin_external_scanner_destroy(void *payload) {
unsigned tree_sitter_kotlin_external_scanner_serialize(void *payload, char *buffer) {
Stack *stack = (Stack *)payload;
memcpy(buffer, stack->contents, stack->size);
return stack->size;
unsigned n = stack->size;
if (n > TREE_SITTER_SERIALIZATION_BUFFER_SIZE) {
n = TREE_SITTER_SERIALIZATION_BUFFER_SIZE;
}
if (n > 0) {
// it's an undefined behavior to memcpy 0 bytes
memcpy(buffer, stack->contents, n);
}
return n;
}
void tree_sitter_kotlin_external_scanner_deserialize(void *payload, const char *buffer, unsigned length) {
Stack *stack = (Stack *)payload;
if (length > 0) {
// Stack entries are 2 bytes each (delimiter + prefix_len).
// Discard corrupted state with odd length.
if (length > 0 && length % 2 == 0) {
array_reserve(stack, length);
memcpy(stack->contents, buffer, length);
stack->size = length;

View file

@ -12,10 +12,10 @@ extern "C" {
// Allow clients to override allocation functions
#ifdef TREE_SITTER_REUSE_ALLOCATOR
extern void *(*ts_current_malloc)(size_t);
extern void *(*ts_current_calloc)(size_t, size_t);
extern void *(*ts_current_realloc)(void *, size_t);
extern void (*ts_current_free)(void *);
extern void *(*ts_current_malloc)(size_t size);
extern void *(*ts_current_calloc)(size_t count, size_t size);
extern void *(*ts_current_realloc)(void *ptr, size_t size);
extern void (*ts_current_free)(void *ptr);
#ifndef ts_malloc
#define ts_malloc ts_current_malloc

View file

@ -14,6 +14,7 @@ extern "C" {
#include <string.h>
#ifdef _MSC_VER
#pragma warning(push)
#pragma warning(disable : 4101)
#elif defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic push
@ -278,7 +279,7 @@ static inline void _array__splice(Array *self, size_t element_size,
#define _compare_int(a, b) ((int)*(a) - (int)(b))
#ifdef _MSC_VER
#pragma warning(default : 4101)
#pragma warning(pop)
#elif defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic pop
#endif

View file

@ -47,6 +47,7 @@ struct TSLexer {
uint32_t (*get_column)(TSLexer *);
bool (*is_at_included_range_start)(const TSLexer *);
bool (*eof)(const TSLexer *);
void (*log)(const TSLexer *, const char *, ...);
};
typedef enum {