Merge branch 'main' into docs/kilo-code-mcp

This commit is contained in:
Tanishq Khatri 2026-06-23 23:18:02 +05:30 • committed by GitHub
commit 5bc70ce77c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 890291 additions and 662835 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

@ -13,7 +13,7 @@ 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: **9/9 checks passed**.
(exit non-zero on any failure). Expected verdict: **16/16 checks passed**.
## Cases covered (one scenario each)
@ -32,6 +32,18 @@ analyze → sync → trace/impact pipeline, and prints PASS/FAIL per assertion
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

View file

@ -271,6 +271,177 @@ def fetch_items():
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`);

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

@ -1036,12 +1036,13 @@ const FILE_BASENAME_RE =
/**
* A provider endpoint's display label. A resolved handler has a real function
* name; the source-scan fallbacks leave a generic token (`'handler'`/`'fetch'`)
* or a file basename. Those are treated as anonymous and shown as
* name; the source-scan fallbacks leave a generic token (`'handler'`/`'fetch'`,
* or `'route'` for an unresolved named-controller / closure Laravel route) or a
* file basename. Those are treated as anonymous and shown as
* `<METHOD /path handler>` so the endpoint is still identifiable by route. When
* the bridge row carries a resolved `providerUid`, the name IS a real symbol —
* the `'handler'`/`'fetch'` sentinel check is suppressed so a function genuinely
* named `handler` is not mislabeled anonymous.
* the sentinel check is suppressed so a function genuinely named `handler` (or,
* hypothetically, `route`) is not mislabeled anonymous.
*/
function providerLabel(
providerName: string,
@ -1052,7 +1053,8 @@ function providerLabel(
const generic =
providerName === '' ||
FILE_BASENAME_RE.test(providerName) ||
(!resolved && (providerName === 'handler' || providerName === 'fetch'));
(!resolved &&
(providerName === 'handler' || providerName === 'fetch' || providerName === 'route'));
return generic
? { label: `<${contractId} handler>`, anon: true }
: { label: providerName, anon: false };

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

View file

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

View file

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

View file

@ -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.
@ -364,14 +409,19 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
// → `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.
// 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: handlerNode?.type === 'identifier' ? handlerNode.text : null,
name: imported ? imported.name : localHandler,
handlerImport: imported,
line: (handlerNode ?? pathNode).startPosition.row + 1,
confidence: 0.8,
});

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

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>();
@ -943,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)) {
@ -959,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,
});
}
@ -1003,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,
});
}

View file

@ -45,6 +45,17 @@ export interface HttpDetection {
* 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;
}

View file

@ -80,6 +80,67 @@ WHERE sym.filePath = $filePath AND sym.startLine IS NOT NULL AND sym.endLine IS
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;
@ -357,22 +418,114 @@ export class HttpRouteExtractor implements ContractExtractor {
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);
if (syms.length === 0) return null;
// 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 it FIRST; only the containment fallback
// requires a line.
// 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;
}
if (d.line == null) 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);
};

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

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

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

@ -478,6 +478,73 @@ describe('runGroupTrace', () => {
},
);
itLbugReopen(
'destination trace anonymizes an unresolved Laravel `route` placeholder (#2276)',
async () => {
// A named-controller / closure Laravel provider that did not resolve keeps
// the synthetic `'route'` placeholder (never a real symbol name). It must
// be treated as anonymous — shown as `<route handler>` — exactly like the
// `'handler'`/`'fetch'` sentinels, not displayed as the literal `route`.
const consumer = makeContract({
repo: 'app/frontend',
role: 'consumer',
symbolUid: 'callUsers-uid',
symbolRef: { filePath: 'src/api.ts', name: 'callUsers' },
symbolName: 'callUsers',
contractId: 'http::GET::/api/users',
});
const provider = makeContract({
repo: 'app/backend',
role: 'provider',
symbolUid: '', // unresolved file-scope closure / named-controller route
symbolRef: { filePath: 'routes/web.php', name: 'route' },
symbolName: 'route',
contractId: 'http::GET::/api/users',
});
const link: CrossLink = {
from: { repo: 'app/frontend', symbolUid: 'callUsers-uid', symbolRef: consumer.symbolRef },
to: { repo: 'app/backend', symbolUid: '', symbolRef: provider.symbolRef },
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'exact',
confidence: 1,
};
await writeBridge(groupDir, {
contracts: [consumer, provider],
crossLinks: [link],
repoSnapshots: {},
missingRepos: [],
});
const port = makePort(
{ 'reg-fe:callUsers': okSym('callUsers-uid', 'callUsers', 'src/api.ts', 3) },
{
'reg-fe:callUsers-uid->callUsers-uid': okTrace(
[{ name: 'callUsers', filePath: 'src/api.ts', startLine: 3 }],
[],
),
},
);
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'callUsers' },
);
expect(r).toMatchObject({
status: 'ok',
to: {
name: '<http::GET::/api/users handler>',
repo: 'app/backend',
filePath: 'routes/web.php',
},
hops: [
{ name: 'callUsers', repo: 'app/frontend' },
{ name: '<http::GET::/api/users handler>', repo: 'app/backend' },
],
notes: expect.arrayContaining([expect.stringContaining('anonymous')]),
});
},
);
itLbugReopen('destination trace not_found when no HTTP link leaves the repo', async () => {
await writeUnlinkedBridge(groupDir);
const port = makePort(

File diff suppressed because it is too large Load diff

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 {