From 5c04544c2617b82e5cd965b4f4cc362af06b63b8 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Wed, 9 Sep 2026 17:22:04 +0530 Subject: [PATCH] feat(kotlin): add optional Kotlin parser support and related tests --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++ README.md | 5 ++++ package-lock.json | 8 ++++-- package.json | 4 ++- src/graph/build.ts | 24 +++++++++++++++-- src/graph/check.ts | 9 ++++++- src/graph/extract-cache.ts | 3 +++ src/graph/extract.ts | 50 +++++++++++++++++++++++++++++++++--- test/graph-languages.test.ts | 47 +++++++++++++++++++++++++++++++-- 9 files changed, 174 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5234beda1..158c212f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,3 +77,39 @@ jobs: - name: Exercise every breadth grammar run: node --import tsx --test test/generic-node24.test.ts + + windows-node26-install: + name: Windows Node 26 install regression + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '26' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Test + run: npm test + + - name: Pack and install the published artifact shape + shell: pwsh + run: | + $tarball = (npm pack --json | ConvertFrom-Json)[0].filename + $consumer = Join-Path $env:RUNNER_TEMP 'graft-packed-install' + New-Item -ItemType Directory -Force -Path $consumer | Out-Null + Push-Location $consumer + npm init --yes + npm install (Join-Path $env:GITHUB_WORKSPACE $tarball) + npx graft --version + Pop-Location diff --git a/README.md b/README.md index f2101dcdf..1ad7f879c 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,11 @@ compiler-grade layer — all `$0` and deterministic (no model, no key): functions, S3/S4/R6 classes and methods, roxygen `@export`, `library()`/`source()` imports). + Kotlin's native parser is an optional install so a platform without a compatible + native build can still install and use Graft. If it is unavailable, a build that + encounters `.kt` or `.kts` files prints one warning and skips only those files; + every other supported language remains indexed. + - **Broad** — symbols (functions, classes, methods, types, …) plus name-resolved call edges via a generic tree-sitter extractor, one grammar per language: **Rust, C, C++, C#, Ruby, Scala, Elixir, Solidity, diff --git a/package-lock.json b/package-lock.json index c18df39a0..856c472e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "tree-sitter": "^0.21.1", "tree-sitter-go": "^0.23.4", "tree-sitter-java": "^0.23.5", - "tree-sitter-kotlin": "^0.3.8", "tree-sitter-php": "^0.23.4", "tree-sitter-python": "^0.21.0", "tree-sitter-r": "npm:@davisvaughan/tree-sitter-r@^1.3.0", @@ -43,6 +42,9 @@ }, "engines": { "node": ">=20" + }, + "optionalDependencies": { + "tree-sitter-kotlin": "^0.3.8" } }, "node_modules/@anthropic-ai/sdk": { @@ -1156,6 +1158,7 @@ "integrity": "sha512-A4obq6bjzmYrA+F0JLLoheFPcofFkctNaZSpnDd+GPn1SfVZLY4/GG4C0cYVBTOShuPBGGAOPLM1JWLZQV4m1g==", "hasInstallScript": true, "license": "MIT", + "optional": true, "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" @@ -1173,7 +1176,8 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/tree-sitter-php": { "version": "0.23.12", diff --git a/package.json b/package.json index 03fa2b876..d6a164ac1 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "tree-sitter": "^0.21.1", "tree-sitter-go": "^0.23.4", "tree-sitter-java": "^0.23.5", - "tree-sitter-kotlin": "^0.3.8", "tree-sitter-php": "^0.23.4", "tree-sitter-python": "^0.21.0", "tree-sitter-r": "npm:@davisvaughan/tree-sitter-r@^1.3.0", @@ -74,6 +73,9 @@ "vscode-languageserver-protocol": "^3.18.2", "web-tree-sitter": "^0.26.13" }, + "optionalDependencies": { + "tree-sitter-kotlin": "^0.3.8" + }, "devDependencies": { "@types/d3-force": "^3.0.10", "@types/node": "^26.1.0", diff --git a/src/graph/build.ts b/src/graph/build.ts index b3094051d..9ce72bc89 100644 --- a/src/graph/build.ts +++ b/src/graph/build.ts @@ -17,7 +17,13 @@ import { readFileSync } from "node:fs"; import { basename, dirname, resolve } from "node:path"; import { walkDir } from "../ingest/fs.js"; import { contextDirFor, ensureGitignored, ensureSearchable } from "../context/node-file.js"; -import { extractFile, languageLabelOf, languageOf, type RawEdge } from "./extract.js"; +import { + extractFile, + kotlinParserUnavailableMessage, + languageLabelOf, + languageOf, + type RawEdge, +} from "./extract.js"; import { extractGeneric, genericLangOf, warmGenericGrammars } from "./generic.js"; import { containerLangOf, extractContainer, warmContainerGrammars } from "./container.js"; import { contentHash } from "../util/id.js"; @@ -173,6 +179,13 @@ export async function buildGraph( * javascript, or the banner claims a repo's JavaScript went unindexed. */ const langs = new Set(); const errors: string[] = []; + // Kotlin is the only optional depth-tier grammar. Probe it once, and only when + // the repository actually contains Kotlin, so a failed native install cannot + // affect a TypeScript/JavaScript-only build or produce startup noise. + const kotlinUnavailable = files.some((f) => languageOf(f.abs) === "kotlin") + ? kotlinParserUnavailableMessage() + : null; + if (kotlinUnavailable) errors.push(kotlinUnavailable); // In a git worktree there is nothing to reuse *yet* — `graft/` is gitignored, so // git never checked it out — but the parent checkout's graph is one directory away. @@ -244,7 +257,14 @@ export async function buildGraph( } const hash = contentHash(source); - if (cached && hash === cached.hash) { + if (lang === "kotlin" && kotlinUnavailable) { + // Keep the source fingerprint current, but don't cache this as a parse + // failure: a later install may make the optional addon available. + entries[rel] = { size: f.size, mtimeMs: f.mtimeMs, hash, nodes: [], rawEdges: [], skipped: true }; + return; + } + + if (cached && hash === cached.hash && !cached.skipped) { entries[rel] = { ...cached, size: f.size, mtimeMs: f.mtimeMs }; sources.set(rel, source); reused++; diff --git a/src/graph/check.ts b/src/graph/check.ts index d36f2bd27..807c7c987 100644 --- a/src/graph/check.ts +++ b/src/graph/check.ts @@ -20,7 +20,7 @@ import { resolve } from "node:path"; import { relPosix } from "../util/paths.js"; import { contextDirFor } from "../context/node-file.js"; -import { extractFile, languageOf } from "./extract.js"; +import { extractFile, kotlinParserUnavailableMessage, languageOf } from "./extract.js"; import { extractGeneric, genericLangOf, warmGenericGrammars } from "./generic.js"; import { containerLangOf, extractContainer, warmContainerGrammars } from "./container.js"; import { listSourceFiles } from "./build.js"; @@ -87,6 +87,9 @@ export async function checkGraph( const fpOnlyDirs = readFingerprint(outDir)?.onlyDirs; const onlyDirs = fpOnlyDirs && fpOnlyDirs.length > 0 ? new Set(fpOnlyDirs) : undefined; const sourceFiles = listSourceFiles(root, outDir, undefined, onlyDirs); + const kotlinUnavailable = sourceFiles.some((file) => languageOf(file) === "kotlin") + ? kotlinParserUnavailableMessage() + : null; await warmGenericGrammars( new Set(sourceFiles.map((f) => genericLangOf(f)?.name).filter((n): n is string => !!n)), ); @@ -104,6 +107,7 @@ export async function checkGraph( // `removed` forever, and the `graft build` the check tells you to run can never // repair it. const lang = languageOf(file); + if (lang === "kotlin" && kotlinUnavailable) continue; const container = lang ? null : containerLangOf(file); const generic = lang || container ? null : genericLangOf(file); let source: string | null; @@ -138,6 +142,9 @@ export async function checkGraph( const committedById = new Map(committed.nodes.map((n) => [n.id, n])); result.nodes = committedById.size; for (const [id, node] of committedById) { + // Do not call a graph stale merely because an optional parser isn't installed + // in this environment. The next build will give the actionable diagnostic. + if (kotlinUnavailable && languageOf(node.path) === "kotlin") continue; const now = current.get(id); if (now === undefined) result.removed.push(id); else if (now !== node.body_hash) result.changed.push(id); diff --git a/src/graph/extract-cache.ts b/src/graph/extract-cache.ts index 2acf8b258..792e52a35 100644 --- a/src/graph/extract-cache.ts +++ b/src/graph/extract-cache.ts @@ -48,6 +48,9 @@ export interface ExtractEntry { * re-reports the same error and contributes no nodes, exactly as a cold build * would. */ error?: string; + /** The optional parser for this file's language was unavailable. Unlike `error`, + * this entry is re-parsed if that parser becomes available on a later run. */ + skipped?: true; } export interface ExtractCache { diff --git a/src/graph/extract.ts b/src/graph/extract.ts index d0f9dff19..7a7b07119 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -7,12 +7,12 @@ * resolved against the whole-repo node index later, in build.ts. */ import Parser from "tree-sitter"; +import { createRequire } from "node:module"; import TypeScript from "tree-sitter-typescript"; import Python from "tree-sitter-python"; import Go from "tree-sitter-go"; import R from "tree-sitter-r"; import Java from "tree-sitter-java"; -import Kotlin from "tree-sitter-kotlin"; import Swift from "tree-sitter-swift"; import PHP from "tree-sitter-php"; import { basename } from "node:path"; @@ -311,18 +311,60 @@ const FUNCTION_VALUE_TYPES = new Set([ const EMPTY_SET: ReadonlySet = new Set(); const parser = new Parser(); -const GRAMMARS: Record = { +const require = createRequire(import.meta.url); + +const GRAMMARS: Record, unknown> = { typescript: TypeScript.typescript, tsx: TypeScript.tsx, python: Python, go: Go, r: R, java: Java, - kotlin: Kotlin, swift: Swift, php: PHP.php, }; +const KOTLIN_PARSER_UNAVAILABLE = + "Kotlin files were skipped because the optional tree-sitter-kotlin parser is unavailable. " + + "Reinstall @nanonets/graft in an environment where tree-sitter-kotlin can build to index .kt and .kts files."; + +let kotlinGrammar: unknown | null | undefined; +let loadKotlinGrammar = (): unknown => require("tree-sitter-kotlin"); + +/** + * Resolve the optional Kotlin native addon only when a Kotlin file is encountered. + * A missing or unloadable addon must never prevent another language from building. + */ +export function kotlinParserUnavailableMessage(): string | null { + if (kotlinGrammar === undefined) { + try { + kotlinGrammar = loadKotlinGrammar(); + } catch { + kotlinGrammar = null; + } + } + return kotlinGrammar === null ? KOTLIN_PARSER_UNAVAILABLE : null; +} + +/** Test seam for the optional native addon. Not part of Graft's package API. */ +export function swapKotlinGrammarLoaderForTest(loader: () => unknown): () => void { + const previousLoader = loadKotlinGrammar; + const previousGrammar = kotlinGrammar; + loadKotlinGrammar = loader; + kotlinGrammar = undefined; + return () => { + loadKotlinGrammar = previousLoader; + kotlinGrammar = previousGrammar; + }; +} + +function grammarFor(lang: Language): unknown { + if (lang !== "kotlin") return GRAMMARS[lang]; + const unavailable = kotlinParserUnavailableMessage(); + if (unavailable) throw new Error(unavailable); + return kotlinGrammar; +} + export interface WalkCtx { rel: string; source: string; @@ -385,7 +427,7 @@ function parseSource(source: string): Parser.SyntaxNode { } export function extractFile(rel: string, source: string, lang: Language): ExtractResult { - parser.setLanguage(GRAMMARS[lang] as never); + parser.setLanguage(grammarFor(lang) as never); const root = parseSource(source); const bindings = collectBindings(root, lang); const importedSymbols = collectImportedSymbols(root, lang); diff --git a/test/graph-languages.test.ts b/test/graph-languages.test.ts index 6b28daea6..d4edcd1d0 100644 --- a/test/graph-languages.test.ts +++ b/test/graph-languages.test.ts @@ -17,7 +17,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildGraph } from "../src/graph/build.js"; import { buildRepoMap } from "../src/graph/map.js"; -import { languageLabelOf, languageOf } from "../src/graph/extract.js"; +import { + kotlinParserUnavailableMessage, + languageLabelOf, + languageOf, + swapKotlinGrammarLoaderForTest, +} from "../src/graph/extract.js"; import { readGraph, wiringPath } from "../src/graph/write.js"; /** Every extension graft claims to index, one per grammar/label pairing. */ @@ -92,7 +97,11 @@ test("the build banner and repo map report every language they indexed", async ( writeFileSync(join(d, "src", "a.swift"), "func swiftOnly() -> Int {\n return 1\n}\n"); const r = await buildGraph(d); - assert.deepEqual(r.languages, ["javascript", "jsx", "kotlin", "python", "swift", "tsx", "typescript"]); + const kotlinAvailable = !r.errors.some((error) => error.startsWith("Kotlin files were skipped")); + assert.deepEqual( + r.languages, + ["javascript", "jsx", ...(kotlinAvailable ? ["kotlin"] : []), "python", "swift", "tsx", "typescript"], + ); // The reported symbol was queryable all along — that mismatch is what the issue was // about, so pin both halves together. @@ -105,3 +114,37 @@ test("the build banner and repo map report every language they indexed", async ( // `map` derives labels from paths independently, so it gets its own assertion. assert.deepEqual(buildRepoMap(graph!).totals.languages, r.languages); }); + +test("Kotlin is indexed when its optional parser is available", async (t) => { + if (kotlinParserUnavailableMessage()) { + t.skip("tree-sitter-kotlin is unavailable in this runtime"); + return; + } + const dir = mkdtempSync(join(tmpdir(), "graft-kotlin-available-")); + writeFileSync(join(dir, "app.kt"), "fun indexedKotlin(): Int = 1\n"); + const result = await buildGraph(dir, { reuse: false }); + assert.deepEqual(result.errors, []); + assert.deepEqual(result.languages, ["kotlin"]); + const graph = readGraph(wiringPath(result.contextDir))!; + assert.ok(graph.nodes.some((node) => node.name === "indexedKotlin"), "Kotlin function is indexed"); +}); + +test("a missing Kotlin parser skips Kotlin only and leaves TypeScript indexed", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-kotlin-optional-")); + writeFileSync(join(dir, "app.ts"), "export function stillIndexed() { return 1; }\n"); + writeFileSync(join(dir, "app.kt"), "fun skippedKotlin(): Int = 1\n"); + const restore = swapKotlinGrammarLoaderForTest(() => { + throw new Error("Cannot find module 'tree-sitter-kotlin'"); + }); + try { + const result = await buildGraph(dir, { reuse: false }); + assert.equal(result.errors.length, 1, "one Kotlin availability warning"); + assert.match(result.errors[0], /Kotlin files were skipped/); + assert.deepEqual(result.languages, ["typescript"]); + const graph = readGraph(wiringPath(result.contextDir))!; + assert.ok(graph.nodes.some((node) => node.name === "stillIndexed"), "TypeScript remains indexed"); + assert.ok(!graph.nodes.some((node) => node.path === "app.kt"), "Kotlin contributes no partial nodes"); + } finally { + restore(); + } +});