Răsfoiți Sursa

Merge pull request #1326 from colbymchenry/rust-kernel

Native extraction kernel: Rust parse+extract for TS/JS/Java/Python/Go, byte-identical, default-on (R1-R6)
Colby Mchenry 1 lună în urmă
părinte
comite
c1dc78d3fa
50 a modificat fișierele cu 10537 adăugiri și 55 ștergeri
  1. 3 0
      .dockerignore
  2. 63 0
      .github/workflows/release.yml
  3. 2 0
      CHANGELOG.md
  4. 102 0
      __tests__/fixtures/kernel-parity/Torture.java
  5. 61 0
      __tests__/fixtures/kernel-parity/torture.go
  6. 75 0
      __tests__/fixtures/kernel-parity/torture.js
  7. 49 0
      __tests__/fixtures/kernel-parity/torture.py
  8. 214 0
      __tests__/fixtures/kernel-parity/torture.tsx
  9. 70 0
      __tests__/kernel-grammar-parity.test.ts
  10. 204 0
      __tests__/kernel-scaffold.test.ts
  11. 149 0
      __tests__/kernel-tsjs-parity.test.ts
  12. 3 0
      codegraph-kernel/.gitignore
  13. 575 0
      codegraph-kernel/Cargo.lock
  14. 35 0
      codegraph-kernel/Cargo.toml
  15. 3 0
      codegraph-kernel/build.rs
  16. 396 0
      codegraph-kernel/src/buffers.rs
  17. 140 0
      codegraph-kernel/src/docstring.rs
  18. 1223 0
      codegraph-kernel/src/go.rs
  19. 53 0
      codegraph-kernel/src/ids.rs
  20. 1610 0
      codegraph-kernel/src/java.rs
  21. 31 0
      codegraph-kernel/src/langs.rs
  22. 115 0
      codegraph-kernel/src/lib.rs
  23. 978 0
      codegraph-kernel/src/python.rs
  24. 190 0
      codegraph-kernel/src/textutil.rs
  25. 1332 0
      codegraph-kernel/src/tsjs/extractors.rs
  26. 133 0
      codegraph-kernel/src/tsjs/fnref.rs
  27. 905 0
      codegraph-kernel/src/tsjs/mod.rs
  28. 578 0
      docs/design/rust-kernel-migration-plan.md
  29. 1 0
      package.json
  30. 20 0
      scripts/build-bundle.sh
  31. 82 0
      scripts/build-kernel.sh
  32. 57 0
      scripts/dump-graph.mjs
  33. 242 0
      scripts/kernel-parity.mjs
  34. 17 0
      src/extraction/grammars.ts
  35. 52 33
      src/extraction/index.ts
  36. 177 0
      src/extraction/kernel/decode.ts
  37. 194 0
      src/extraction/kernel/index.ts
  38. 102 0
      src/extraction/kernel/layout.ts
  39. 148 0
      src/extraction/kernel/loader.ts
  40. 32 1
      src/extraction/parse-worker.ts
  41. 22 3
      src/extraction/store-worker.ts
  42. 46 2
      src/extraction/store-writer.ts
  43. 11 2
      src/extraction/tree-sitter.ts
  44. BIN
      src/extraction/wasm/tree-sitter-go.wasm
  45. BIN
      src/extraction/wasm/tree-sitter-java.wasm
  46. BIN
      src/extraction/wasm/tree-sitter-javascript.wasm
  47. BIN
      src/extraction/wasm/tree-sitter-python.wasm
  48. BIN
      src/extraction/wasm/tree-sitter-tsx.wasm
  49. BIN
      src/extraction/wasm/tree-sitter-typescript.wasm
  50. 42 14
      src/types.ts

+ 3 - 0
.dockerignore

@@ -5,3 +5,6 @@ dist
 .kommandr
 docs
 assets
+codegraph-kernel/target
+codegraph-kernel/prebuilds
+release

+ 63 - 0
.github/workflows/release.yml

@@ -29,8 +29,45 @@ permissions:
   attestations: write  # store the GitHub artifact attestations for the bundles
 
 jobs:
+  # Native extraction-kernel prebuilds (docs/design/rust-kernel-migration-plan.md).
+  # The kernel is an OPTIONAL per-language speedup: a bundle without a .node
+  # runs the wasm extraction path unchanged. continue-on-error keeps a Rust
+  # toolchain flake from ever blocking a release — the release job runs with
+  # whatever prebuilds succeeded. (Runner images ship rustup; build-kernel.sh
+  # adds each cross target itself.)
+  kernel:
+    continue-on-error: true
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - runner: macos-14
+            targets: aarch64-apple-darwin x86_64-apple-darwin
+          - runner: ubuntu-22.04 # oldest glibc runner → widest compatibility
+            targets: x86_64-unknown-linux-gnu
+          - runner: ubuntu-22.04-arm
+            targets: aarch64-unknown-linux-gnu
+          - runner: windows-latest
+            targets: x86_64-pc-windows-msvc aarch64-pc-windows-msvc
+    runs-on: ${{ matrix.runner }}
+    steps:
+      - uses: actions/checkout@v6
+      - name: Build kernel prebuilds
+        shell: bash
+        run: |
+          for t in ${{ matrix.targets }}; do
+            bash scripts/build-kernel.sh --target "$t"
+          done
+          ls -R codegraph-kernel/prebuilds
+      - uses: actions/upload-artifact@v4
+        with:
+          name: kernel-${{ matrix.runner }}
+          path: codegraph-kernel/prebuilds/
+          if-no-files-found: error
+
   release:
     runs-on: ubuntu-latest
+    needs: kernel
     steps:
       - uses: actions/checkout@v6
         with:
@@ -127,6 +164,32 @@ jobs:
             git push origin "HEAD:${GITHUB_REF#refs/heads/}"
           fi
 
+      - name: Download kernel prebuilds
+        # Best-effort: whatever platform legs succeeded land in release/kernel/
+        # (<target>/codegraph-kernel.node); build-bundle.sh includes a target's
+        # kernel when present and falls back to the wasm path when not.
+        continue-on-error: true
+        uses: actions/download-artifact@v4
+        with:
+          pattern: kernel-*
+          merge-multiple: true
+          path: release/kernel/
+
+      - name: Kernel contract + grammar-parity gate
+        # Asserts the native grammars and the vendored wasm grammars are built
+        # from the same grammar revisions (node-kind/field tables compared id
+        # by id) and that the .node speaks the expected wire contract.
+        # CODEGRAPH_KERNEL_EXPECT=1 turns a missing binary into a FAILURE here
+        # so the gate can't silently pass by not building the kernel.
+        run: |
+          if [ -f release/kernel/linux-x64/codegraph-kernel.node ]; then
+            mkdir -p codegraph-kernel/prebuilds/linux-x64
+            cp release/kernel/linux-x64/codegraph-kernel.node codegraph-kernel/prebuilds/linux-x64/
+            CODEGRAPH_KERNEL_EXPECT=1 npx vitest run __tests__/kernel-scaffold.test.ts __tests__/kernel-grammar-parity.test.ts
+          else
+            echo "::warning::no linux-x64 kernel prebuild — skipping kernel gate (bundles ship wasm-only)"
+          fi
+
       - name: Build all platform bundles
         run: |
           for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64 win32-x64 win32-arm64; do

+ 2 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- Indexing TypeScript, TSX, JavaScript, JSX, Java, Python, and Go projects is faster: parsing and symbol extraction now run in a native engine when a prebuilt binary is available for your platform (release bundles include one), producing exactly the same graph — verified byte-for-byte against the previous engine on real repositories, from small libraries up to vscode-, dubbo-, and django-scale codebases (Lombok-generated members included). The speedup is largest on resource-constrained machines like CI runners. No setup needed: platforms without the native binary, and individual files with syntax errors, automatically use the previous engine, and `CODEGRAPH_KERNEL=0` turns the native path off entirely.
 - Reference resolution now runs in parallel on large projects. When a project has enough pending references to make it worthwhile (roughly 150k+, typical for big Java/Kotlin/Spring codebases), resolution fans out across worker threads while results are applied in the exact order the single-threaded path would have used — the graph comes out byte-for-byte identical, about twice as fast end-to-end on a 4,000-file Java project in our testing. Small projects keep the single-threaded path automatically (the fan-out costs more than it saves there). Set `CODEGRAPH_NO_PARALLEL_RESOLVE=1` to disable, or `CODEGRAPH_PARALLEL_RESOLVE_MIN=<count>` to tune when it engages.
 - Indexing large projects got another sizeable speedup — about a quarter less wall-clock on the same 4,000-file Java project, with the graph still byte-for-byte identical. Two changes: the database no longer interleaves expensive checkpoint housekeeping into the middle of resolution on a fresh index (it's folded once at the end instead), and while one batch's results are being written out, the worker threads are already resolving the next batch instead of sitting idle.
 - The dynamic-dispatch analysis that runs at the end of indexing (callback, event, and framework wiring) now runs its passes in parallel on large projects, cutting that stage roughly in half there — and a pass that crashes now retries safely instead of failing the whole index, which also makes very large codebases that previously died in this stage more likely to index to completion. Graphs remain byte-for-byte identical.
@@ -22,6 +23,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- TypeScript, TSX, and JavaScript files now parse with up-to-date grammars — modern syntax such as `using` declarations and import attributes no longer trips parse errors that could drop surrounding symbols. (The previously bundled grammars dated from 2023.)
 - Searching or exploring by field names now finds the code that defines them. A query made of object keys or API field names (`profileInfo isTrialEligible quotaInfo billingMethod`) used to return unrelated results while the defining files never appeared, because three retrieval steps each dropped multi-word camelCase terms: an internal case-comparison bug, a match step that only considered classes (never functions or methods), and exploration seeding that required exact symbol-name matches. All three are fixed — `codegraph_explore` with a bag of field names now surfaces the controllers and services that assemble those fields. (#1196)
 - `codegraph.json`'s `includeIgnored` works again for the "folder of repos" layout: when one `.gitignore` rule covers a parent directory (`/repos/`) holding several embedded git repositories, opting in the individual repos (`"includeIgnored": ["repos/a/"]` — the exact spelling `codegraph init`'s own hint suggests) previously matched nothing and indexed zero files, looping the same suggestion back at you. Both spellings now work — name the parent directory to opt in everything under it, or name individual repos to opt in just those — and the hint no longer re-suggests repos that are already configured. (#1295)
 - Method calls on literals (`", ".join(...)` in Python, `"x".split(...)` in JavaScript, and the like) no longer produce call edges to unrelated project functions that happen to share the builtin's name — a codebase with a function called `join`, `get`, or `update` could show phantom callers from every string-builtin use. Additionally, a function nested inside another function is now only matched as a call target from inside its container, since it isn't reachable from anywhere else. Blast-radius and affected-test results get cleaner on Python and JavaScript codebases especially. (#1230)

+ 102 - 0
__tests__/fixtures/kernel-parity/Torture.java

@@ -0,0 +1,102 @@
+/**
+ * Java torture fixture — exercises every Java extraction path the kernel
+ * ports: package namespace, imports, javadoc, annotations, inheritance,
+ * fields/constants, enums, anonymous classes, method references, static
+ * member reads, fluent chains, Lombok synthesis, value refs + shadowing.
+ */
+package com.example.torture;
+
+import java.util.List;
+import java.util.Map;
+import static java.util.Objects.requireNonNull;
+import com.example.other.OtherClass;
+import lombok.Data;
+
+/** Javadoc for the service. */
+@Service
+@Component("torture")
+public class TortureService extends BaseService implements Runnable, AutoCloseable {
+  /** A shared constant table (value-ref target). */
+  public static final Map<String, Integer> RETRY_LIMITS = Map.of("a", 1);
+  private static final String API_BASE = "https://example.test";
+  protected int count = 0;
+  private final List<String> names;
+  int packagePrivate, secondDeclarator;
+
+  /** Ctor javadoc. */
+  public TortureService(List<String> names) {
+    this.names = requireNonNull(names);
+    register(this::onEvent);
+    queue(TortureService::compute);
+    queue(OtherClass::handle);
+    Runnable r = () -> helper(RETRY_LIMITS);
+    executor.submit(new Runnable() {
+      @Override
+      public void run() {
+        helper(RETRY_LIMITS);
+      }
+    });
+  }
+
+  @Override
+  @Deprecated
+  public void run() {
+    Config cfg = ConfigLoader.getInstance().load();
+    this.registry.lookup("x");
+    helper(Direction.UP);
+    String base = API_BASE;
+    int max = Limits.MAX_VALUE;
+    new StringBuilder(16).append(base);
+  }
+
+  private static Config helper(Object arg) {
+    return new Config();
+  }
+
+  private void onEvent() {}
+  private static void compute() {}
+
+  public void shadowed() {
+    String API_BASE = "local"; // shadows the class constant
+    log(API_BASE);
+  }
+
+  enum Direction {
+    UP,
+    DOWN;
+
+    Direction opposite() {
+      return this == UP ? DOWN : UP;
+    }
+  }
+
+  interface Listener {
+    void onChange(TortureService svc);
+  }
+}
+
+@Data
+class LombokBean {
+  private String name;
+  private boolean isActive;
+  private final int id;
+  private static int counter;
+  private String toString; // taken: no synthetic toString field collision
+
+  public String getName() { return name; } // explicit getter — never overridden
+}
+
+@lombok.Getter
+@lombok.extern.slf4j.Slf4j
+@Builder
+class LombokBuilderBean {
+  private List<String> items;
+}
+
+interface Shape extends Comparable<Shape>, Cloneable {
+  double area();
+}
+
+@interface Marker {
+  String value() default "";
+}

+ 61 - 0
__tests__/fixtures/kernel-parity/torture.go

@@ -0,0 +1,61 @@
+// Go torture fixture — receivers, embedding, interfaces, composite literals.
+package torture
+
+import (
+	"fmt"
+	pkga "example.com/other/pkga"
+)
+
+const MAX_ITEMS = 128
+
+var DefaultRegistry = NewRegistry()
+
+var handlerTable = map[string]func(int){
+	"recv": TargetCb,
+}
+
+type Widget struct {
+	*Base
+	Queryable
+	name string
+}
+
+type Stack[T any] struct {
+	items []T
+}
+
+type Core interface {
+	Reader
+	Marshal(v any) ([]byte, error)
+	Unmarshal(data []byte) error
+}
+
+type Dur int
+
+func NewRegistry() *Registry {
+	w := Widget{name: "w"}
+	q := pkga.Widget{}
+	fmt.Println(w, q, MAX_ITEMS)
+	cfg := loadConfig()
+	cfg.conn.Exec("x")
+	return New().Init()
+}
+
+func (s *Stack[T]) Push(item T) {
+	s.items = append(s.items, item)
+}
+
+func (w Widget) Render() string {
+	return w.name
+}
+
+func TargetCb(n int) {}
+
+func shadowed() {
+	MAX_ITEMS := 5
+	fmt.Println(MAX_ITEMS)
+}
+
+func reads() int {
+	return MAX_ITEMS
+}

+ 75 - 0
__tests__/fixtures/kernel-parity/torture.js

@@ -0,0 +1,75 @@
+/**
+ * JS-grammar torture fixture (javascript variant: no type machinery, JS class
+ * fields use `field_definition` with a `property` field).
+ */
+import { EventEmitter } from 'node:events';
+const { promisify } = require('node:util');
+
+/** Legacy prototype-style helper. */
+function legacyHelper(a, b) {
+  return a + b;
+}
+
+const arrow = (x) => legacyHelper(x, 1);
+
+class Widget extends EventEmitter {
+  static registry = new Map();
+  #privateField = 1;
+  label = 'w';
+  onTick = () => {
+    this.render();
+  };
+  wrapped = debounce(function () {
+    expensive();
+  }, 50);
+
+  constructor(opts) {
+    super();
+    this.opts = opts;
+    register(this.onTick);
+  }
+
+  render() {
+    paint(this.label);
+  }
+
+  static create(opts) {
+    return new Widget(opts);
+  }
+}
+
+// AMD-style wrapper — anonymous, but inner functions must still surface.
+(function () {
+  function hiddenInner() {
+    return 7;
+  }
+  hiddenInner();
+})();
+
+module.exports.makeWidget = function makeWidget(opts) {
+  return Widget.create(opts);
+};
+
+// Vuex module shape (store-file signals: mutations + actions + getters).
+const mutations = {
+  SET_USER(state, user) {
+    state.user = user;
+  },
+};
+const actions = {
+  async loadUser({ commit }, id) {
+    const user = await fetchUser(id);
+    commit('SET_USER', user);
+  },
+};
+export default {
+  namespaced: true,
+  state: () => ({ user: null }),
+  mutations,
+  actions,
+  getters: {
+    userName(state) {
+      return state.user?.name;
+    },
+  },
+};

+ 49 - 0
__tests__/fixtures/kernel-parity/torture.py

@@ -0,0 +1,49 @@
+"""Python torture fixture — decorators, self fn-refs, imports, shadowing."""
+import os, sys
+import os.path as osp
+from collections import OrderedDict, defaultdict
+from .relative import thing
+from mypkg.handlers import target_cb
+
+RETRY_LIMITS = {"a": 1}
+API_BASE = "https://example.test"
+x = compute(RETRY_LIMITS)
+
+
+class Service(BaseService, mixins.LoggerMixin):
+    """Class docs."""
+
+    def __init__(self, registry):
+        self.registry = registry
+        register(self.on_event)
+        queue(target_cb)
+
+    @staticmethod
+    def helper(arg):
+        return transform(arg)
+
+    async def run(self):
+        cfg = self.registry.lookup("x")
+        limit = RETRY_LIMITS
+        obj.method_chain().deep(cfg)
+        ", ".join(cfg)
+        return await fetch(API_BASE)
+
+    def on_event(self):
+        pass
+
+
+@app.route("/x")
+def view():
+    def inner():
+        return API_BASE
+    return inner()
+
+
+def shadowed():
+    API_BASE = "local"
+    return API_BASE
+
+
+handlers = {"recv": target_cb}
+callbacks = [target_cb, view]

+ 214 - 0
__tests__/fixtures/kernel-parity/torture.tsx

@@ -0,0 +1,214 @@
+/**
+ * Torture fixture — exercises every TS/TSX extraction path the kernel ports.
+ */
+import React, { forwardRef, memo, useState as useStateAlias } from 'react';
+import * as NS from './namespace-module';
+import DefaultThing from './default-module';
+import './side-effect';
+import { helperFn, CONFIG_TABLE } from './helpers';
+
+export { reExported, orig as aliased } from './barrel-source';
+export * from './star-source';
+
+// A documented constant table (value-ref target).
+const RETRY_LIMITS = { a: 1, b: 2 };
+export const API_BASE = 'https://example.test';
+let plainLet = 42;
+var oldVar = 'x';
+
+/** Class docs.
+ * Multi-line.
+ */
+@Injectable()
+@scoped.Registry<Config>('name')
+export abstract class BaseService extends EventTarget implements Disposable, Serializable {
+  static instances = 0;
+  private readonly cache: Map<string, Config> = new Map();
+  public fonts: FontConfig;
+  count = 0;
+  onScroll = throttle((e: Event) => {
+    this.handleScroll(e);
+  }, 100);
+  handleClick = (ev: MouseEvent): void => {
+    emitTelemetry(ev);
+    new AbortController();
+  };
+
+  @Get('/list')
+  async list(query: QueryOpts): Promise<Result<Item>> {
+    const local: ItemMapper = makeMapper();
+    const limit = RETRY_LIMITS;
+    return this.cache.get(query.key) ?? helperFn(query);
+  }
+
+  private static compute(n: number): number {
+    return n * RETRY_LIMITS.a;
+  }
+
+  get size(): number {
+    return this.count;
+  }
+
+  protected dispose(): void {
+    listeners.forEach((l) => unregister(l));
+  }
+}
+
+class Plain {
+  constructor(private svc: BaseService) {
+    register(this.onEvent);
+    btn.on('click', this.handleClick);
+  }
+  onEvent() {}
+  handleClick() {}
+}
+
+export interface Shape extends Named, Sized {
+  area: number;
+  resize(f: number): Shape;
+  onchange: (s: Shape) => void;
+}
+
+export enum Direction {
+  Up,
+  Down = 2,
+  Left,
+}
+
+export type Handle = {
+  stop: () => void;
+  id: string;
+  refresh(force: boolean): Promise<void>;
+};
+
+export type ServiceList = [
+  Service<'query_apply_record', Req, Resp>,
+  Service<'apply_confirm', Req, Resp>,
+];
+
+export type MaybeShape = Shape | null;
+
+export function topLevel(a: ShapeConfig): Shape {
+  const inner = () => reallyDeep();
+  function namedInner(): void {
+    chained(a).value;
+  }
+  namedInner();
+  return makeShape(a);
+}
+
+async function* generatorFn(items: Item[]) {
+  yield* items.map((i) => transform(i));
+}
+
+export const arrowConst = async (x: number) => {
+  return x + plainLet;
+};
+
+const AnonClassHolder = class {
+  method() {}
+};
+
+// React components (#841).
+export const Button = forwardRef((props: ButtonProps, ref) => {
+  const [state, setState] = useStateAlias(0);
+  useEffect(() => {
+    trackRender();
+  });
+  return <BaseButton ref={ref} onClick={() => setState(state + 1)} {...props} />;
+});
+
+export const MemoRow = memo(function Row(props: RowProps) {
+  return <tr className={props.cls}>{props.children}</tr>;
+});
+
+export const Wrapped = React.memo(ImportedComponent);
+export const Styled = styled.button`
+  color: red;
+`;
+const memoCache = memo(computeThing); // lowercase — stays a constant
+
+export function App() {
+  const nodes: GraphNode[] = [];
+  return (
+    <div>
+      <Button label={CONFIG_TABLE.label} />
+      <NS.Panel />
+      {nodes.map((n) => (
+        <MemoRow key={n.id} cls={n.cls} />
+      ))}
+    </div>
+  );
+}
+
+// Object-of-functions (SvelteKit-style actions map).
+export const actionsMap = {
+  create: async (input: CreateInput) => {
+    return persistNew(input);
+  },
+  update(input: UpdateInput) {
+    return persistExisting(input);
+  },
+};
+
+// Zustand-style store through middleware wrappers.
+export const useStore = create(
+  persist(
+    (set, get) => ({
+      fetchUser: async (id: string) => {
+        const user = await api.load(id);
+        set({ user });
+      },
+      reset: () => set({ user: null }),
+    }),
+    { name: 'store' }
+  )
+);
+
+// RTK Query.
+export const widgetApi = createApi({
+  reducerPath: 'widgets',
+  endpoints: (build) => ({
+    getWidget: build.query({
+      query: (id: string) => fetchWidget(id),
+    }),
+    updateWidget: build.mutation({
+      queryFn: async (patch) => {
+        return applyPatch(patch);
+      },
+    }),
+    prebuilt: build.query(makeEndpointConfig()),
+  }),
+});
+
+export const { useGetWidgetQuery, useUpdateWidgetMutation } = widgetApi;
+const { notAHook } = widgetApi;
+
+// Value-ref shadowing: SHADOWED_LIMIT re-bound locally must be pruned.
+const SHADOWED_LIMIT = 10;
+export function readsShadowed() {
+  const SHADOWED_LIMIT = 20;
+  return SHADOWED_LIMIT;
+}
+export function readsTable() {
+  return RETRY_LIMITS.b + API_BASE.length;
+}
+
+// Fn-ref registrations.
+registerHandler(helperFn);
+queueMicrotask(topLevel);
+const routeTable = { home: topLevel, missing: notDefinedAnywhere };
+const handlerList = [helperFn, DefaultThing];
+target.cb = topLevel;
+const aliasFn = topLevel;
+
+// Calls with interesting callees.
+;(topLevel)(cfg);
+NS.helper.deep(1);
+"literal".includes('x');
+[1, 2].map(String);
+obj?.optMethod?.(3);
+import('./dynamic-module');
+new NS.Widget(makeArg());
+new Map<string, number>();
+super_weird?.();

+ 70 - 0
__tests__/kernel-grammar-parity.test.ts

@@ -0,0 +1,70 @@
+/**
+ * Grammar-source parity gate (R1, migration plan §3.5).
+ *
+ * The native kernel compiles grammars from crates.io / vendored sources; the
+ * wasm fallback loads grammars from tree-sitter-wasms / src/extraction/wasm.
+ * If the two are built from different grammar revisions, a language's graph
+ * would depend on WHICH path extracted it — per-language routing (and the
+ * kernel-absent fallback) must be graph-neutral.
+ *
+ * Rather than trusting version metadata, this asserts the grammars are
+ * behaviorally identical where extraction can observe them: ABI version and
+ * the full node-kind and field tables, compared id by id.
+ *
+ * Runs wherever a kernel binary is staged (scripts/build-kernel.sh); skips
+ * otherwise. CI that builds the kernel sets CODEGRAPH_KERNEL_EXPECT=1 so the
+ * skip can't mask a missing build (asserted in kernel-scaffold.test.ts).
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import type { Language as WasmLanguage } from 'web-tree-sitter';
+import { getKernel, resetKernelForTests } from '../src/extraction/kernel';
+import { initGrammars, loadGrammarsForLanguages, getParser } from '../src/extraction/grammars';
+import type { Language } from '../src/types';
+
+const KERNEL_PATH = path.join(
+  __dirname,
+  '..',
+  'codegraph-kernel',
+  'prebuilds',
+  `${process.platform}-${process.arch}`,
+  'codegraph-kernel.node'
+);
+const kernelBuilt = fs.existsSync(KERNEL_PATH);
+
+// Every kernel-capable language. `jsx` shares the javascript grammar on BOTH
+// paths (langs.rs mirrors WASM_GRAMMAR_FILES), so the distinct grammars are:
+const GRAMMAR_LANGUAGES: Language[] = ['typescript', 'tsx', 'javascript', 'java', 'python', 'go'];
+
+describe.skipIf(!kernelBuilt)('kernel↔wasm grammar parity', () => {
+  beforeAll(async () => {
+    resetKernelForTests();
+    await initGrammars();
+    await loadGrammarsForLanguages(GRAMMAR_LANGUAGES);
+  });
+
+  it.each(GRAMMAR_LANGUAGES)('%s: node-kind and field tables are identical', (language) => {
+    const kernel = getKernel();
+    expect(kernel).not.toBeNull();
+    const native = kernel!.grammarInfo(language);
+    expect(native, `kernel has no grammar for ${language}`).not.toBeNull();
+
+    const wasmLang = getParser(language)?.language as WasmLanguage | null | undefined;
+    expect(wasmLang, `wasm grammar for ${language} not loaded`).toBeTruthy();
+
+    expect(native!.abiVersion, 'grammar ABI version').toBe(wasmLang!.abiVersion);
+    expect(native!.nodeKindCount, 'node-kind count').toBe(wasmLang!.nodeTypeCount);
+    expect(native!.fieldCount, 'field count').toBe(wasmLang!.fieldCount);
+
+    const wasmKinds: (string | null)[] = [];
+    for (let i = 0; i < wasmLang!.nodeTypeCount; i++) wasmKinds.push(wasmLang!.nodeTypeForId(i));
+    expect(native!.nodeKinds).toEqual(wasmKinds.map((k) => k ?? ''));
+
+    // Field ids are 1-based on both sides.
+    const wasmFields: (string | null)[] = [];
+    for (let i = 1; i <= wasmLang!.fieldCount; i++) wasmFields.push(wasmLang!.fieldNameForId(i));
+    expect(native!.fieldNames).toEqual(wasmFields.map((f) => f ?? ''));
+  });
+});

+ 204 - 0
__tests__/kernel-scaffold.test.ts

@@ -0,0 +1,204 @@
+/**
+ * Native-kernel scaffold tests (R1, docs/design/rust-kernel-migration-plan.md).
+ *
+ * Covers the wire contract, decoder, routing policy, kill switch, and
+ * per-file fallback. These are SCAFFOLD tests — behavioral parity with the
+ * wasm extractors is R3's equivalence gate, not asserted here.
+ *
+ * The kernel binary is optional: without a staged .node
+ * (scripts/build-kernel.sh) the suite skips. CI that builds the kernel sets
+ * CODEGRAPH_KERNEL_EXPECT=1, which turns "missing binary" into a FAILURE so
+ * the gate can't silently pass by not building the kernel.
+ */
+
+import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import { NODE_KINDS, EDGE_KINDS } from '../src/types';
+import { generateNodeId } from '../src/extraction/tree-sitter-helpers';
+import { getKernel, tryKernelExtract, kernelRoutes, resetKernelForTests } from '../src/extraction/kernel';
+import { extractFromSource } from '../src/extraction';
+import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
+
+const KERNEL_PATH = path.join(
+  __dirname,
+  '..',
+  'codegraph-kernel',
+  'prebuilds',
+  `${process.platform}-${process.arch}`,
+  'codegraph-kernel.node'
+);
+const kernelBuilt = fs.existsSync(KERNEL_PATH);
+const expectKernel = process.env.CODEGRAPH_KERNEL_EXPECT === '1';
+
+const FIXTURE = [
+  'export class MathHelper {',
+  '  calculateTotal(a: number): number { return helper(a); }',
+  '}',
+  'function helper(x: number): number { return x * 2; }',
+  'helper(3);',
+  '',
+].join('\n');
+
+const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS', 'CODEGRAPH_KERNEL_PATH'] as const;
+let savedEnv: Record<string, string | undefined>;
+
+beforeEach(() => {
+  savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
+  for (const k of ENV_KEYS) delete process.env[k];
+  resetKernelForTests();
+});
+
+afterEach(() => {
+  for (const k of ENV_KEYS) {
+    if (savedEnv[k] === undefined) delete process.env[k];
+    else process.env[k] = savedEnv[k];
+  }
+  resetKernelForTests();
+});
+
+it.runIf(expectKernel)('kernel binary must exist when CODEGRAPH_KERNEL_EXPECT=1', () => {
+  expect(kernelBuilt, `expected kernel at ${KERNEL_PATH} — run scripts/build-kernel.sh`).toBe(true);
+});
+
+describe.skipIf(!kernelBuilt)('kernel scaffold', () => {
+  it('loads and its kind tables match src/types.ts exactly', () => {
+    const kernel = getKernel();
+    expect(kernel).not.toBeNull();
+    const info = kernel!.contractInfo();
+    expect(info.nodeKinds).toEqual([...NODE_KINDS]);
+    expect(info.edgeKinds).toEqual([...EDGE_KINDS]);
+    expect(info.languages).toContain('typescript');
+    expect(info.languages).toContain('javascript');
+  });
+
+  it('TS/JS family + Java + Python + Go route to the kernel by default; others stay wasm', () => {
+    for (const lang of ['typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go'] as const) {
+      expect(kernelRoutes(lang), lang).toBe(true);
+    }
+    expect(kernelRoutes('ruby')).toBe(false);
+    expect(tryKernelExtract('src/a.rb', 'def f\nend\n', 'ruby')).toBeNull();
+    // CODEGRAPH_KERNEL_LANGS REPLACES the default set when present.
+    process.env.CODEGRAPH_KERNEL_LANGS = 'tsx';
+    expect(kernelRoutes('typescript')).toBe(false);
+    expect(kernelRoutes('tsx')).toBe(true);
+  });
+
+  describe('with typescript routed (CODEGRAPH_KERNEL_LANGS)', () => {
+    beforeEach(() => {
+      process.env.CODEGRAPH_KERNEL_LANGS = 'typescript';
+    });
+
+    it('decodes nodes, contains edges, and calls refs from the buffers', () => {
+      const result = tryKernelExtract('src/utils.ts', FIXTURE, 'typescript');
+      expect(result).not.toBeNull();
+      const { nodes, edges, unresolvedReferences, errors } = result!;
+      expect(errors).toEqual([]);
+
+      const byKind = (kind: string) => nodes.filter((n) => n.kind === kind);
+      expect(byKind('file')).toHaveLength(1);
+      expect(byKind('class').map((n) => n.name)).toEqual(['MathHelper']);
+      expect(byKind('method').map((n) => n.qualifiedName)).toEqual(['MathHelper::calculateTotal']);
+      expect(byKind('function').map((n) => n.name)).toEqual(['helper']);
+
+      const file = byKind('file')[0]!;
+      expect(file.id).toBe('file:src/utils.ts');
+      expect(file.qualifiedName).toBe('src/utils.ts');
+      expect(file.endLine).toBe(FIXTURE.split('\n').length);
+      expect(file.isExported).toBe(false);
+
+      // Every node carries the decode-call constants.
+      for (const n of nodes) {
+        expect(n.filePath).toBe('src/utils.ts');
+        expect(n.language).toBe('typescript');
+        expect(n.updatedAt).toBeGreaterThan(0);
+      }
+
+      // contains: file→class, class→method, file→function.
+      const contains = edges.filter((e) => e.kind === 'contains');
+      const cls = byKind('class')[0]!;
+      const method = byKind('method')[0]!;
+      const fn = byKind('function')[0]!;
+      expect(contains).toContainEqual({ source: file.id, target: cls.id, kind: 'contains' });
+      expect(contains).toContainEqual({ source: cls.id, target: method.id, kind: 'contains' });
+      expect(contains).toContainEqual({ source: file.id, target: fn.id, kind: 'contains' });
+
+      // calls refs attach to the innermost enclosing symbol (method for the
+      // in-body call, file node for the top-level call).
+      const calls = unresolvedReferences.filter((r) => r.referenceKind === 'calls');
+      expect(calls.map((r) => [r.fromNodeId, r.referenceName])).toEqual([
+        [method.id, 'helper'],
+        [file.id, 'helper'],
+      ]);
+      for (const r of calls) {
+        // No denormalized filePath/language at the extraction seam — the wasm
+        // extractors leave them unset (the store fills them, `?? filePath`),
+        // and the kernel matches that exactly (see decode.ts).
+        expect(r.filePath).toBeUndefined();
+        expect(r.language).toBeUndefined();
+        expect(r.line).toBeGreaterThan(0);
+      }
+    });
+
+    it('kernel node ids are byte-identical to generateNodeId', () => {
+      const result = tryKernelExtract('src/utils.ts', FIXTURE, 'typescript')!;
+      for (const n of result.nodes) {
+        if (n.kind === 'file') continue;
+        expect(n.id).toBe(generateNodeId('src/utils.ts', n.kind, n.name, n.startLine));
+      }
+    });
+
+    it('CODEGRAPH_KERNEL=0 kill switch disables routing', () => {
+      process.env.CODEGRAPH_KERNEL = '0';
+      expect(kernelRoutes('typescript')).toBe(false);
+      expect(tryKernelExtract('src/a.ts', FIXTURE, 'typescript')).toBeNull();
+    });
+
+    it('languages outside the route stay on the wasm path', () => {
+      expect(kernelRoutes('javascript')).toBe(false);
+      expect(tryKernelExtract('src/a.js', 'function f() {}', 'javascript')).toBeNull();
+    });
+
+    it('tsx routes with its own entry and returns a graph', () => {
+      process.env.CODEGRAPH_KERNEL_LANGS = 'typescript,tsx';
+      const result = tryKernelExtract(
+        'src/App.tsx',
+        'export function App() { return render(); }\n',
+        'tsx'
+      );
+      expect(result).not.toBeNull();
+      expect(result!.nodes.some((n) => n.kind === 'function' && n.name === 'App')).toBe(true);
+    });
+  });
+
+  describe('extractFromSource seam', () => {
+    beforeAll(async () => {
+      await initGrammars();
+      await loadGrammarsForLanguages(['typescript']);
+    });
+
+    it('kill switch routes through the wasm extractor unchanged', () => {
+      process.env.CODEGRAPH_KERNEL = '0';
+      const result = extractFromSource('src/a.ts', 'export const f = () => 1;\n', 'typescript');
+      expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'f')).toBe(true);
+      delete process.env.CODEGRAPH_KERNEL;
+      // Default-routed path produces the same node (R2 parity).
+      const viaKernel = extractFromSource('src/a.ts', 'export const f = () => 1;\n', 'typescript');
+      expect(viaKernel.nodes.some((n) => n.kind === 'function' && n.name === 'f')).toBe(true);
+    });
+
+    it('routed language takes the kernel and falls back per file on kernel absence', () => {
+      process.env.CODEGRAPH_KERNEL_LANGS = 'typescript';
+      const viaKernel = extractFromSource('src/utils.ts', FIXTURE, 'typescript');
+      expect(viaKernel.nodes.map((n) => n.kind)).toContain('method');
+
+      // Point the loader at a nonexistent binary: routing is requested but the
+      // kernel can't load, so the SAME call must fall back to wasm, not fail.
+      process.env.CODEGRAPH_KERNEL_PATH = path.join(__dirname, 'nope', 'missing.node');
+      process.env.CODEGRAPH_KERNEL = '0'; // and belt-and-braces the kill switch
+      resetKernelForTests();
+      const viaWasm = extractFromSource('src/utils.ts', FIXTURE, 'typescript');
+      expect(viaWasm.nodes.some((n) => n.kind === 'class' && n.name === 'MathHelper')).toBe(true);
+    });
+  });
+});

+ 149 - 0
__tests__/kernel-tsjs-parity.test.ts

@@ -0,0 +1,149 @@
+/**
+ * Kernel↔wasm TS/JS extraction parity (R2 of the kernel migration).
+ *
+ * Asserts the native walker (codegraph-kernel/src/tsjs/) produces the SAME
+ * ExtractionResult as the wasm TreeSitterExtractor — nodes, edges, and
+ * unresolved refs compared as canonicalized multisets — over:
+ *   - the checked-in torture fixtures (every ported feature: components/HOCs,
+ *     stores, RTK, vuex, fn-refs, value-ref shadowing, decorators, enums,
+ *     type-alias members/tuple contracts, re-exports, JSX, field methods), and
+ *   - this repo's own extraction sources (real-world TS).
+ *
+ * The full-repo sweep lives in scripts/kernel-parity.mjs (excalidraw et al.,
+ * run for the §5 gate); this suite keeps the invariant alive in `npm test`.
+ * Skips when no kernel binary is staged; CODEGRAPH_KERNEL_EXPECT=1 turns that
+ * into a failure (wired in kernel-scaffold.test.ts).
+ */
+
+import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import { extractFromSource } from '../src/extraction';
+import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
+import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel';
+import type { ExtractionResult, Language } from '../src/types';
+
+const KERNEL_PATH = path.join(
+  __dirname,
+  '..',
+  'codegraph-kernel',
+  'prebuilds',
+  `${process.platform}-${process.arch}`,
+  'codegraph-kernel.node'
+);
+const kernelBuilt = fs.existsSync(KERNEL_PATH);
+
+const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'kernel-parity');
+const REAL_SOURCES = [
+  'src/extraction/kernel/loader.ts',
+  'src/extraction/kernel/decode.ts',
+  'src/extraction/parse-pool.ts',
+  'src/extraction/function-ref.ts',
+  'src/mcp/tools.ts',
+];
+
+function canon(result: ExtractionResult): { nodes: string[]; edges: string[]; refs: string[] } {
+  return {
+    nodes: result.nodes
+      .map(({ updatedAt: _u, ...n }) => JSON.stringify(n, Object.keys(n).sort()))
+      .sort(),
+    edges: result.edges.map((e) => JSON.stringify(e, Object.keys(e).sort())).sort(),
+    refs: result.unresolvedReferences
+      .map((r) => JSON.stringify(r, Object.keys(r).sort()))
+      .sort(),
+  };
+}
+
+const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const;
+let savedEnv: Record<string, string | undefined>;
+
+describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => {
+  beforeAll(async () => {
+    await initGrammars();
+    await loadGrammarsForLanguages(['typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go']);
+  });
+
+  beforeEach(() => {
+    savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
+    resetKernelForTests();
+  });
+
+  afterEach(() => {
+    for (const k of ENV_KEYS) {
+      if (savedEnv[k] === undefined) delete process.env[k];
+      else process.env[k] = savedEnv[k];
+    }
+    resetKernelForTests();
+  });
+
+  function assertParity(filePath: string, source: string, language: Language): void {
+    process.env.CODEGRAPH_KERNEL_LANGS = 'all';
+    delete process.env.CODEGRAPH_KERNEL;
+    const viaKernel = tryKernelExtract(filePath, source, language);
+    expect(viaKernel, `kernel extraction failed for ${filePath}`).not.toBeNull();
+
+    process.env.CODEGRAPH_KERNEL = '0';
+    const viaWasm = extractFromSource(filePath, source, language);
+    delete process.env.CODEGRAPH_KERNEL;
+
+    const k = canon(viaKernel!);
+    const w = canon(viaWasm);
+    expect(k.nodes, `${filePath}: nodes`).toEqual(w.nodes);
+    expect(k.edges, `${filePath}: edges`).toEqual(w.edges);
+    expect(k.refs, `${filePath}: refs`).toEqual(w.refs);
+    // Meaningful comparison, not empty-vs-empty.
+    expect(viaWasm.nodes.length).toBeGreaterThan(3);
+  }
+
+  it('torture fixture (tsx): components, stores, RTK, fn-refs, value-refs, decorators', () => {
+    const file = path.join(FIXTURE_DIR, 'torture.tsx');
+    assertParity('fixtures/torture.tsx', fs.readFileSync(file, 'utf8'), 'tsx');
+  });
+
+  it('torture fixture (js): field methods, wrappers, vuex module shape', () => {
+    const file = path.join(FIXTURE_DIR, 'torture.js');
+    assertParity('fixtures/torture.js', fs.readFileSync(file, 'utf8'), 'javascript');
+  });
+
+  it('torture fixture (java): Lombok, anonymous classes, method refs, chains', () => {
+    const file = path.join(FIXTURE_DIR, 'Torture.java');
+    assertParity('fixtures/Torture.java', fs.readFileSync(file, 'utf8'), 'java');
+  });
+
+  it('torture fixture (python): decorators, self fn-refs, imports, shadowing', () => {
+    const file = path.join(FIXTURE_DIR, 'torture.py');
+    assertParity('fixtures/torture.py', fs.readFileSync(file, 'utf8'), 'python');
+  });
+
+  it('torture fixture (go): receivers, embedding, interfaces, composite literals', () => {
+    const file = path.join(FIXTURE_DIR, 'torture.go');
+    assertParity('fixtures/torture.go', fs.readFileSync(file, 'utf8'), 'go');
+  });
+
+  it.each(REAL_SOURCES)('real source parity: %s', (rel) => {
+    const file = path.join(__dirname, '..', rel);
+    assertParity(rel, fs.readFileSync(file, 'utf8'), 'typescript');
+  });
+
+  it('files with parse errors defer to the wasm extractor (recovery is encoding-dependent)', () => {
+    // tree-sitter error RECOVERY differs between UTF-8 (native) and UTF-16
+    // (web-tree-sitter) parsing — same grammar, same core version — so the
+    // kernel defers any erroring file to keep routing graph-neutral.
+    const broken = 'export function f( {\n  return }} 12 (\n';
+    process.env.CODEGRAPH_KERNEL_LANGS = 'all';
+    delete process.env.CODEGRAPH_KERNEL;
+    expect(tryKernelExtract('src/broken.ts', broken, 'typescript')).toBeNull();
+    // The seam still serves the file — through the wasm path.
+    process.env.CODEGRAPH_KERNEL = '0';
+    const viaWasm = extractFromSource('src/broken.ts', broken, 'typescript');
+    delete process.env.CODEGRAPH_KERNEL;
+    expect(viaWasm.nodes.some((n) => n.kind === 'file')).toBe(true);
+  });
+
+  it('typescript fixture parsed as plain typescript variant', () => {
+    // Same content through the non-tsx grammar exercises the typescript
+    // (vs tsx) LangSpec pairing.
+    const file = path.join(__dirname, '..', 'src/extraction/kernel/index.ts');
+    assertParity('src/extraction/kernel/index.ts', fs.readFileSync(file, 'utf8'), 'typescript');
+  });
+});

+ 3 - 0
codegraph-kernel/.gitignore

@@ -0,0 +1,3 @@
+target/
+prebuilds/
+*.node

+ 575 - 0
codegraph-kernel/Cargo.lock

@@ -0,0 +1,575 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.67"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "codegraph-kernel"
+version = "0.1.0"
+dependencies = [
+ "napi",
+ "napi-build",
+ "napi-derive",
+ "regex",
+ "sha2",
+ "tree-sitter",
+ "tree-sitter-go",
+ "tree-sitter-java",
+ "tree-sitter-javascript",
+ "tree-sitter-python",
+ "tree-sitter-typescript",
+]
+
+[[package]]
+name = "convert_case"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "ctor"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a394189d59f9befacce833f337f7b1eca5e9a91221bcdd4d28e0114d96e597b3"
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libloading"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "napi"
+version = "3.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6826e5ddc15589b2d68c8ad5321c18e85d40488e93e32962f362e572669bccf6"
+dependencies = [
+ "bitflags",
+ "ctor",
+ "futures",
+ "napi-build",
+ "napi-sys",
+ "nohash-hasher",
+ "rustc-hash",
+]
+
+[[package]]
+name = "napi-build"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
+
+[[package]]
+name = "napi-derive"
+version = "3.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fe526e81c105d3640516fcde83909dd1afe757c0d7a15af58830b5bc0fb9a1"
+dependencies = [
+ "convert_case",
+ "ctor",
+ "napi-derive-backend",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "napi-derive-backend"
+version = "5.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "514281397bcddd9ea9a876c7a21a57bff2374237a000ca9a64ea0211ec1993e2"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "semver",
+ "syn",
+]
+
+[[package]]
+name = "napi-sys"
+version = "3.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73e43cf2eb0bd1bf95a43c07c076ebd2da5d1e015a71c3d201faeffffcc0ecac"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "nohash-hasher"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tree-sitter"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
+dependencies = [
+ "cc",
+ "regex",
+ "regex-syntax",
+ "serde_json",
+ "streaming-iterator",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-go"
+version = "0.23.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-java"
+version = "0.23.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-javascript"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-language"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
+
+[[package]]
+name = "tree-sitter-python"
+version = "0.23.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-typescript"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

+ 35 - 0
codegraph-kernel/Cargo.toml

@@ -0,0 +1,35 @@
+[package]
+name = "codegraph-kernel"
+version = "0.1.0"
+edition = "2021"
+license = "MIT"
+publish = false
+description = "Native extraction kernel for CodeGraph — tree-sitter parse+extract with one JS boundary crossing per file"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+napi = { version = "3", default-features = false, features = ["napi8"] }
+napi-derive = "3"
+tree-sitter = "0.25"
+sha2 = "0.10"
+regex = "1"
+
+# Grammars — MUST stay revision-matched with the wasm grammars the fallback
+# path loads (tree-sitter-wasms npm package / src/extraction/wasm/). The
+# kernel-grammar-parity test asserts node-kind-table equality at test time;
+# bump these together with the wasm side or that gate fails.
+tree-sitter-typescript = "0.23"
+tree-sitter-javascript = "0.25"
+tree-sitter-java = "0.23"
+tree-sitter-python = "0.23"
+tree-sitter-go = "0.23"
+
+[build-dependencies]
+napi-build = "2"
+
+[profile.release]
+lto = true
+codegen-units = 1
+strip = "symbols"

+ 3 - 0
codegraph-kernel/build.rs

@@ -0,0 +1,3 @@
+fn main() {
+    napi_build::setup();
+}

+ 396 - 0
codegraph-kernel/src/buffers.rs

@@ -0,0 +1,396 @@
+//! Flat buffer contract — the ONE boundary crossing per file.
+//!
+//! The kernel returns five Buffers: meta, nodes, edges, refs, arena. All rows
+//! are fixed-width little-endian; every string is an (offset, len) pair into
+//! the UTF-8 arena. `OFFSET == NONE (0xFFFF_FFFF)` means "field absent".
+//!
+//! THIS FILE AND `src/extraction/kernel/layout.ts` MUST MATCH BYTE FOR BYTE.
+//! Any layout change bumps `KERNEL_ABI_VERSION` — the TS loader refuses a
+//! version it doesn't know and falls back to the wasm path.
+//!
+//! Layout (v1):
+//!
+//! meta (36 bytes):
+//!   0   u8   KERNEL_ABI_VERSION
+//!   1   [3]  pad
+//!   4   u32  node count
+//!   8   u32  edge count
+//!   12  u32  ref count
+//!   16  u32  arena byte length
+//!   20  u32  errors-JSON arena offset (NONE = no errors)
+//!   24  u32  errors-JSON byte length
+//!   28  f64  kernel-side wall duration (ms) — introspection only; the TS
+//!            wrapper measures the ExtractionResult.durationMs it reports
+//!
+//! node row (96 bytes):
+//!   0   u8   NodeKind index (NODE_KINDS order)
+//!   1   u8   visibility (0 absent, 1 public, 2 private, 3 protected, 4 internal)
+//!   2   u16  bool flags — bit pairs (present, value):
+//!            0/1 isExported, 2/3 isAsync, 4/5 isStatic, 6/7 isAbstract
+//!   4   u32  startLine (1-based)
+//!   8   u32  endLine
+//!   12  u32  startColumn (0-based)
+//!   16  u32  endColumn
+//!   20  str  name
+//!   28  str  qualifiedName
+//!   36  str  id (kernel-computed: "kind:hash32", or "file:<path>" for the file node)
+//!   44  str  docstring
+//!   52  str  signature
+//!   60  str  decorators (NUL-joined list)
+//!   68  str  typeParameters (NUL-joined list)
+//!   76  str  returnType
+//!   84  str  extraJson (escape hatch: JSON of any extra Node props)
+//!   92  u32  metrics slot (reserved for Arc 3.2 per-node code metrics; 0)
+//!
+//! edge row (44 bytes):
+//!   0   u32  source node row index (NONE → use sourceIdStr)
+//!   4   u32  target node row index (NONE → use targetIdStr)
+//!   8   u8   EdgeKind index (EDGE_KINDS order)
+//!   9   u8   provenance (0 absent, 1 tree-sitter, 2 scip, 3 heuristic)
+//!   10  u16  pad
+//!   12  u32  line (NONE absent)
+//!   16  u32  column (NONE absent)
+//!   20  str  metadataJson
+//!   28  str  sourceIdStr
+//!   36  str  targetIdStr
+//!
+//! ref row (40 bytes):
+//!   0   u32  fromNode row index (NONE → use fromNodeIdStr)
+//!   4   u8   ReferenceKind (EDGE_KINDS index, or 200 = function_ref)
+//!   5   [3]  pad
+//!   8   u32  line (1-based)
+//!   12  u32  column (0-based)
+//!   16  str  referenceName
+//!   24  str  candidates (NUL-joined list)
+//!   32  str  fromNodeIdStr
+
+pub const KERNEL_ABI_VERSION: u8 = 1;
+pub const NONE: u32 = 0xFFFF_FFFF;
+
+pub const META_SIZE: usize = 36;
+pub const NODE_ROW_SIZE: usize = 96;
+pub const EDGE_ROW_SIZE: usize = 44;
+pub const REF_ROW_SIZE: usize = 40;
+
+/// Mirror of NODE_KINDS in src/types.ts — order is the wire contract.
+pub const NODE_KINDS: [&str; 22] = [
+    "file",
+    "module",
+    "class",
+    "struct",
+    "interface",
+    "trait",
+    "protocol",
+    "function",
+    "method",
+    "property",
+    "field",
+    "variable",
+    "constant",
+    "enum",
+    "enum_member",
+    "type_alias",
+    "namespace",
+    "parameter",
+    "import",
+    "export",
+    "route",
+    "component",
+];
+
+/// Mirror of EDGE_KINDS in src/types.ts — order is the wire contract.
+pub const EDGE_KINDS: [&str; 12] = [
+    "contains",
+    "calls",
+    "imports",
+    "exports",
+    "extends",
+    "implements",
+    "references",
+    "type_of",
+    "returns",
+    "instantiates",
+    "overrides",
+    "decorates",
+];
+
+/// ReferenceKind code for the internal-only `function_ref` (#756).
+pub const FUNCTION_REF_CODE: u8 = 200;
+
+pub fn node_kind_index(kind: &str) -> Option<u8> {
+    NODE_KINDS.iter().position(|k| *k == kind).map(|i| i as u8)
+}
+
+pub fn edge_kind_index(kind: &str) -> Option<u8> {
+    EDGE_KINDS.iter().position(|k| *k == kind).map(|i| i as u8)
+}
+
+/// (offset, len) arena reference. `NONE_STR` encodes an absent field.
+pub type StrRef = (u32, u32);
+pub const NONE_STR: StrRef = (NONE, 0);
+
+/// UTF-8 string arena. Strings are appended verbatim; no dedup (per-file
+/// buffers are transient and small — intern later if profiling says so).
+#[derive(Default)]
+pub struct Arena {
+    buf: Vec<u8>,
+}
+
+impl Arena {
+    pub fn put(&mut self, s: &str) -> StrRef {
+        let off = self.buf.len() as u32;
+        self.buf.extend_from_slice(s.as_bytes());
+        (off, s.len() as u32)
+    }
+
+    /// Not used by the seed emitter yet — R2 (docstring/signature/etc.). Kept
+    /// so the arena API is complete alongside the layout it feeds.
+    #[allow(dead_code)]
+    pub fn put_opt(&mut self, s: Option<&str>) -> StrRef {
+        match s {
+            Some(s) => self.put(s),
+            None => NONE_STR,
+        }
+    }
+
+    /// NUL-joined list; absent when the list is empty. (R2 surface: decorators,
+    /// typeParameters, candidates.)
+    #[allow(dead_code)]
+    pub fn put_list(&mut self, items: &[String]) -> StrRef {
+        if items.is_empty() {
+            return NONE_STR;
+        }
+        let joined = items.join("\0");
+        self.put(&joined)
+    }
+
+    pub fn len(&self) -> u32 {
+        self.buf.len() as u32
+    }
+
+    pub fn into_vec(self) -> Vec<u8> {
+        self.buf
+    }
+}
+
+/// Tri-state booleans packed as (present, value) bit pairs.
+#[derive(Default, Clone, Copy)]
+pub struct BoolFlags(pub u16);
+
+impl BoolFlags {
+    pub fn set(&mut self, pair: u16, value: bool) {
+        self.0 |= 1 << (pair * 2);
+        if value {
+            self.0 |= 1 << (pair * 2 + 1);
+        }
+    }
+}
+
+pub const FLAG_IS_EXPORTED: u16 = 0;
+#[allow(dead_code)] // R2 surface — part of the v1 wire contract
+pub const FLAG_IS_ASYNC: u16 = 1;
+#[allow(dead_code)] // R2 surface — part of the v1 wire contract
+pub const FLAG_IS_STATIC: u16 = 2;
+#[allow(dead_code)] // R2 surface — part of the v1 wire contract
+pub const FLAG_IS_ABSTRACT: u16 = 3;
+
+pub struct NodeRow {
+    pub kind: u8,
+    pub visibility: u8,
+    pub flags: BoolFlags,
+    pub start_line: u32,
+    pub end_line: u32,
+    pub start_column: u32,
+    pub end_column: u32,
+    pub name: StrRef,
+    pub qualified_name: StrRef,
+    pub id: StrRef,
+    pub docstring: StrRef,
+    pub signature: StrRef,
+    pub decorators: StrRef,
+    pub type_parameters: StrRef,
+    pub return_type: StrRef,
+    pub extra_json: StrRef,
+}
+
+pub struct EdgeRow {
+    pub source_idx: u32,
+    pub target_idx: u32,
+    pub kind: u8,
+    pub provenance: u8,
+    pub line: u32,
+    pub column: u32,
+    pub metadata_json: StrRef,
+    pub source_id_str: StrRef,
+    pub target_id_str: StrRef,
+}
+
+pub struct RefRow {
+    pub from_idx: u32,
+    pub kind: u8,
+    pub line: u32,
+    pub column: u32,
+    pub reference_name: StrRef,
+    pub candidates: StrRef,
+    pub from_id_str: StrRef,
+}
+
+fn push_str_ref(buf: &mut Vec<u8>, r: StrRef) {
+    buf.extend_from_slice(&r.0.to_le_bytes());
+    buf.extend_from_slice(&r.1.to_le_bytes());
+}
+
+pub struct Tables {
+    pub nodes: Vec<u8>,
+    pub edges: Vec<u8>,
+    pub refs: Vec<u8>,
+    pub node_count: u32,
+    pub edge_count: u32,
+    pub ref_count: u32,
+}
+
+impl Default for Tables {
+    fn default() -> Self {
+        Tables {
+            nodes: Vec::with_capacity(NODE_ROW_SIZE * 64),
+            edges: Vec::with_capacity(EDGE_ROW_SIZE * 64),
+            refs: Vec::with_capacity(REF_ROW_SIZE * 64),
+            node_count: 0,
+            edge_count: 0,
+            ref_count: 0,
+        }
+    }
+}
+
+impl Tables {
+    pub fn push_node(&mut self, r: &NodeRow) -> u32 {
+        let buf = &mut self.nodes;
+        buf.push(r.kind);
+        buf.push(r.visibility);
+        buf.extend_from_slice(&r.flags.0.to_le_bytes());
+        buf.extend_from_slice(&r.start_line.to_le_bytes());
+        buf.extend_from_slice(&r.end_line.to_le_bytes());
+        buf.extend_from_slice(&r.start_column.to_le_bytes());
+        buf.extend_from_slice(&r.end_column.to_le_bytes());
+        push_str_ref(buf, r.name);
+        push_str_ref(buf, r.qualified_name);
+        push_str_ref(buf, r.id);
+        push_str_ref(buf, r.docstring);
+        push_str_ref(buf, r.signature);
+        push_str_ref(buf, r.decorators);
+        push_str_ref(buf, r.type_parameters);
+        push_str_ref(buf, r.return_type);
+        push_str_ref(buf, r.extra_json);
+        buf.extend_from_slice(&0u32.to_le_bytes()); // metrics slot (Arc 3.2)
+        let idx = self.node_count;
+        self.node_count += 1;
+        idx
+    }
+
+    pub fn push_edge(&mut self, r: &EdgeRow) {
+        let buf = &mut self.edges;
+        buf.extend_from_slice(&r.source_idx.to_le_bytes());
+        buf.extend_from_slice(&r.target_idx.to_le_bytes());
+        buf.push(r.kind);
+        buf.push(r.provenance);
+        buf.extend_from_slice(&0u16.to_le_bytes()); // pad
+        buf.extend_from_slice(&r.line.to_le_bytes());
+        buf.extend_from_slice(&r.column.to_le_bytes());
+        push_str_ref(buf, r.metadata_json);
+        push_str_ref(buf, r.source_id_str);
+        push_str_ref(buf, r.target_id_str);
+        self.edge_count += 1;
+    }
+
+    pub fn push_ref(&mut self, r: &RefRow) {
+        let buf = &mut self.refs;
+        buf.extend_from_slice(&r.from_idx.to_le_bytes());
+        buf.push(r.kind);
+        buf.extend_from_slice(&[0u8; 3]); // pad
+        buf.extend_from_slice(&r.line.to_le_bytes());
+        buf.extend_from_slice(&r.column.to_le_bytes());
+        push_str_ref(buf, r.reference_name);
+        push_str_ref(buf, r.candidates);
+        push_str_ref(buf, r.from_id_str);
+        self.ref_count += 1;
+    }
+}
+
+/// One file's encoded tables, ready to hand across the JS boundary.
+pub struct EmitOut {
+    pub meta: Vec<u8>,
+    pub nodes: Vec<u8>,
+    pub edges: Vec<u8>,
+    pub refs: Vec<u8>,
+    pub arena: Vec<u8>,
+}
+
+pub fn build_meta(t: &Tables, arena_len: u32, errors_json: StrRef, duration_ms: f64) -> Vec<u8> {
+    let mut m = Vec::with_capacity(META_SIZE);
+    m.push(KERNEL_ABI_VERSION);
+    m.extend_from_slice(&[0u8; 3]);
+    m.extend_from_slice(&t.node_count.to_le_bytes());
+    m.extend_from_slice(&t.edge_count.to_le_bytes());
+    m.extend_from_slice(&t.ref_count.to_le_bytes());
+    m.extend_from_slice(&arena_len.to_le_bytes());
+    m.extend_from_slice(&errors_json.0.to_le_bytes());
+    m.extend_from_slice(&errors_json.1.to_le_bytes());
+    m.extend_from_slice(&duration_ms.to_le_bytes());
+    debug_assert_eq!(m.len(), META_SIZE);
+    m
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn row_sizes_match_constants() {
+        let mut t = Tables::default();
+        let mut a = Arena::default();
+        let name = a.put("x");
+        t.push_node(&NodeRow {
+            kind: 0,
+            visibility: 0,
+            flags: BoolFlags::default(),
+            start_line: 1,
+            end_line: 1,
+            start_column: 0,
+            end_column: 0,
+            name,
+            qualified_name: name,
+            id: name,
+            docstring: NONE_STR,
+            signature: NONE_STR,
+            decorators: NONE_STR,
+            type_parameters: NONE_STR,
+            return_type: NONE_STR,
+            extra_json: NONE_STR,
+        });
+        assert_eq!(t.nodes.len(), NODE_ROW_SIZE);
+        t.push_edge(&EdgeRow {
+            source_idx: 0,
+            target_idx: 0,
+            kind: 0,
+            provenance: 0,
+            line: NONE,
+            column: NONE,
+            metadata_json: NONE_STR,
+            source_id_str: NONE_STR,
+            target_id_str: NONE_STR,
+        });
+        assert_eq!(t.edges.len(), EDGE_ROW_SIZE);
+        t.push_ref(&RefRow {
+            from_idx: 0,
+            kind: 1,
+            line: 1,
+            column: 0,
+            reference_name: name,
+            candidates: NONE_STR,
+            from_id_str: NONE_STR,
+        });
+        assert_eq!(t.refs.len(), REF_ROW_SIZE);
+        let meta = build_meta(&t, a.len(), NONE_STR, 0.0);
+        assert_eq!(meta.len(), META_SIZE);
+    }
+}

+ 140 - 0
codegraph-kernel/src/docstring.rs

@@ -0,0 +1,140 @@
+//! getPrecedingDocstring / cleanCommentMarkers — faithful port of
+//! src/extraction/tree-sitter-helpers.ts (#780 wrapper-climb semantics).
+
+use regex::Regex;
+use std::sync::OnceLock;
+use tree_sitter::Node;
+
+/// DOCSTRING_WRAPPER_TYPES (tree-sitter-helpers.ts).
+fn is_wrapper(kind: &str) -> bool {
+    matches!(
+        kind,
+        "export_statement"
+            | "decorated_definition"
+            | "lexical_declaration"
+            | "variable_declaration"
+            | "variable_declarator"
+            | "ambient_declaration"
+    )
+}
+
+fn is_comment(kind: &str) -> bool {
+    matches!(
+        kind,
+        "comment" | "line_comment" | "block_comment" | "documentation_comment"
+    )
+}
+
+struct Cleaners {
+    block_open: Regex,
+    block_close: Regex,
+    lua_open: Regex,
+    lua_close: Regex,
+    paren_star_open: Regex,
+    paren_star_close: Regex,
+    brace_open: Regex,
+    brace_close: Regex,
+    slashes: Regex,
+    dashes: Regex,
+    hash: Regex,
+    percent: Regex,
+    star_cont: Regex,
+}
+
+fn cleaners() -> &'static Cleaners {
+    static C: OnceLock<Cleaners> = OnceLock::new();
+    C.get_or_init(|| Cleaners {
+        block_open: Regex::new(r"^/\*+!?").unwrap(),
+        block_close: Regex::new(r"\*+/$").unwrap(),
+        lua_open: Regex::new(r"^--\[=*\[").unwrap(),
+        lua_close: Regex::new(r"\]=*\]$").unwrap(),
+        paren_star_open: Regex::new(r"^\(\*").unwrap(),
+        paren_star_close: Regex::new(r"\*\)$").unwrap(),
+        brace_open: Regex::new(r"^\{").unwrap(),
+        brace_close: Regex::new(r"\}$").unwrap(),
+        slashes: Regex::new(r"(?m)^//[/!]?\s?").unwrap(),
+        dashes: Regex::new(r"(?m)^--\s?").unwrap(),
+        hash: Regex::new(r"(?m)^#\s?").unwrap(),
+        percent: Regex::new(r"(?m)^%+\s?").unwrap(),
+        star_cont: Regex::new(r"(?m)^\s*\*\s?").unwrap(),
+    })
+}
+
+/// cleanCommentMarkers — strip comment syntax, keep the prose.
+pub fn clean_comment_markers(comment: &str) -> String {
+    let c = cleaners();
+    let mut s = comment.trim().to_string();
+    if s.starts_with("/*") {
+        s = c.block_open.replace(&s, "").into_owned();
+        s = c.block_close.replace(&s, "").into_owned();
+    } else if s.starts_with("--[") {
+        s = c.lua_open.replace(&s, "").into_owned();
+        s = c.lua_close.replace(&s, "").into_owned();
+    } else if s.starts_with("(*") {
+        s = c.paren_star_open.replace(&s, "").into_owned();
+        s = c.paren_star_close.replace(&s, "").into_owned();
+    } else if s.starts_with('{') {
+        s = c.brace_open.replace(&s, "").into_owned();
+        s = c.brace_close.replace(&s, "").into_owned();
+    }
+    s = c.slashes.replace_all(&s, "").into_owned();
+    s = c.dashes.replace_all(&s, "").into_owned();
+    s = c.hash.replace_all(&s, "").into_owned();
+    s = c.percent.replace_all(&s, "").into_owned();
+    s = c.star_cont.replace_all(&s, "").into_owned();
+    s.trim().to_string()
+}
+
+/// getPrecedingDocstring — collect the comment run immediately preceding the
+/// node (climbing out of declaration wrappers first), cleaned and joined.
+/// Returns None when there is no preceding comment (a PRESENT-but-empty
+/// docstring after cleaning still returns Some(""), matching the TS helper).
+pub fn preceding_docstring(node: Node, src: &str) -> Option<String> {
+    let mut anchor = node;
+    while let Some(parent) = anchor.parent() {
+        if is_wrapper(parent.kind()) {
+            anchor = parent;
+        } else {
+            break;
+        }
+    }
+
+    let mut comments: Vec<&str> = Vec::new();
+    let mut sibling = anchor.prev_named_sibling();
+    while let Some(s) = sibling {
+        if is_comment(s.kind()) {
+            comments.push(&src[s.byte_range()]);
+            sibling = s.prev_named_sibling();
+        } else {
+            break;
+        }
+    }
+    if comments.is_empty() {
+        return None;
+    }
+    comments.reverse(); // collected nearest-first; TS unshifts to keep source order
+    Some(
+        comments
+            .iter()
+            .map(|c| clean_comment_markers(c))
+            .collect::<Vec<_>>()
+            .join("\n")
+            .trim()
+            .to_string(),
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn strips_line_and_block_markers() {
+        assert_eq!(clean_comment_markers("// hello"), "hello");
+        assert_eq!(clean_comment_markers("/// doc line"), "doc line");
+        assert_eq!(
+            clean_comment_markers("/**\n * Adds things.\n * @param a first\n */"),
+            "Adds things.\n@param a first"
+        );
+    }
+}

+ 1223 - 0
codegraph-kernel/src/go.rs

@@ -0,0 +1,1223 @@
+//! Go extraction — a faithful Rust port of `TreeSitterExtractor`'s Go paths
+//! (src/extraction/tree-sitter.ts) plus languages/go.ts.
+//!
+//! Go's shape quirks, mirrored exactly: methods are top-level with a receiver
+//! (qualifiedName override `Recv::name` + a contains edge to the FIRST
+//! earlier-in-file struct of that name), structs/interfaces arrive as
+//! `type_spec` and classify via the inner type node (struct embedding →
+//! extends; interface method_elems become method nodes), composite literals
+//! (`pkga.Widget{}`) keep their package qualifier as `instantiates` refs,
+//! top-level var/const specs walk their initializers ATTRIBUTED to the
+//! declared symbol (#693), 2-hop field chains (`t.conn.Exec`) keep the chain
+//! (#1276), and `New().Method()` re-encodes as `New().Method` (#645/#608)
+//! only for bare-identifier factories. Files with parse errors defer to wasm.
+
+use crate::buffers::{
+    build_meta, edge_kind_index, node_kind_index, Arena, BoolFlags, EdgeRow, EmitOut, NodeRow,
+    RefRow, StrRef, Tables, FLAG_IS_EXPORTED, FUNCTION_REF_CODE, NONE, NONE_STR,
+};
+use crate::docstring::preceding_docstring;
+use crate::ids;
+use crate::textutil as util;
+use regex::Regex;
+use std::collections::{HashMap, HashSet};
+use std::sync::OnceLock;
+use tree_sitter::{Node, Parser};
+
+const MAX_VALUE_REF_NODES: usize = 20_000;
+
+fn receiver_re() -> &'static Regex {
+    static RE: OnceLock<Regex> = OnceLock::new();
+    RE.get_or_init(|| Regex::new(r"\(\s*(?:[A-Za-z_]\w*\s+)?\*?\s*([A-Za-z_]\w*)").unwrap())
+}
+fn simple_ident_re() -> &'static Regex {
+    static RE: OnceLock<Regex> = OnceLock::new();
+    RE.get_or_init(|| Regex::new(r"^[A-Za-z_]\w*$").unwrap())
+}
+fn go_two_hop_re() -> &'static Regex {
+    static RE: OnceLock<Regex> = OnceLock::new();
+    RE.get_or_init(|| Regex::new(r"^[A-Za-z_]\w*\.[A-Za-z_]\w*$").unwrap())
+}
+fn generic_angle_re() -> &'static Regex {
+    static RE: OnceLock<Regex> = OnceLock::new();
+    RE.get_or_init(|| Regex::new(r"<[^>]*>").unwrap())
+}
+fn bracket_args_re() -> &'static Regex {
+    static RE: OnceLock<Regex> = OnceLock::new();
+    RE.get_or_init(|| Regex::new(r"\[[^\]]*\]").unwrap())
+}
+
+struct Scope {
+    row: u32,
+    kind: &'static str,
+    name: String,
+}
+
+#[derive(Default)]
+struct Extra {
+    docstring: Option<String>,
+    signature: Option<String>,
+    is_exported: Option<bool>,
+    return_type: Option<String>,
+    qualified_name: Option<String>,
+}
+
+struct ValueScope<'t> {
+    row: u32,
+    node: Node<'t>,
+    name: String,
+}
+
+struct Cand {
+    from: u32,
+    name: String,
+    line: u32,
+    column_byte: usize,
+    row: usize,
+}
+
+/// Per-node metadata for the receiver-method owner lookup (mirrors the TS
+/// side's scan over `this.nodes` — FIRST match wins, earlier-in-file only).
+struct NodeMeta {
+    kind: &'static str,
+    name: String,
+}
+
+pub struct Walker<'t> {
+    src: &'t str,
+    file_path: &'t str,
+    line_starts: Vec<usize>,
+    arena: Arena,
+    tables: Tables,
+    stack: Vec<Scope>,
+    nodes_meta: Vec<NodeMeta>,
+    node_ids: Vec<String>,
+    defined_fn_names: HashSet<String>,
+    imported_names: HashSet<String>,
+    fn_ref_cands: Vec<Cand>,
+    fs_values: HashMap<String, u32>,
+    fs_value_counts: HashMap<String, u32>,
+    value_scopes: Vec<ValueScope<'t>>,
+}
+
+pub fn extract(file_path: &str, source: &str) -> Result<EmitOut, String> {
+    let grammar = crate::langs::grammar_for("go").ok_or("no go grammar")?;
+    let t0 = std::time::Instant::now();
+    let mut parser = Parser::new();
+    parser
+        .set_language(&grammar)
+        .map_err(|e| format!("set_language(go) failed: {e}"))?;
+    let tree = parser
+        .parse(source, None)
+        .ok_or_else(|| "parser returned null tree".to_string())?;
+    if tree.root_node().has_error() {
+        return Err("defer: parse tree contains errors — wasm recovery is canonical".to_string());
+    }
+
+    let mut w = Walker {
+        src: source,
+        file_path,
+        line_starts: util::line_starts(source),
+        arena: Arena::default(),
+        tables: Tables::default(),
+        stack: Vec::new(),
+        nodes_meta: Vec::new(),
+        node_ids: Vec::new(),
+        defined_fn_names: HashSet::new(),
+        imported_names: HashSet::new(),
+        fn_ref_cands: Vec::new(),
+        fs_values: HashMap::new(),
+        fs_value_counts: HashMap::new(),
+        value_scopes: Vec::new(),
+    };
+
+    let line_count = source.bytes().filter(|b| *b == b'\n').count() as u32 + 1;
+    let base_name = file_path.rsplit(['/', '\\']).next().unwrap_or(file_path);
+    let mut flags = BoolFlags::default();
+    flags.set(FLAG_IS_EXPORTED, false);
+    let file_id = w.arena.put(&ids::file_node_id(file_path));
+    let name_ref = w.arena.put(base_name);
+    let qn_ref = w.arena.put(file_path);
+    w.tables.push_node(&NodeRow {
+        kind: node_kind_index("file").unwrap(),
+        visibility: 0,
+        flags,
+        start_line: 1,
+        end_line: line_count,
+        start_column: 0,
+        end_column: 0,
+        name: name_ref,
+        qualified_name: qn_ref,
+        id: file_id,
+        docstring: NONE_STR,
+        signature: NONE_STR,
+        decorators: NONE_STR,
+        type_parameters: NONE_STR,
+        return_type: NONE_STR,
+        extra_json: NONE_STR,
+    });
+    w.nodes_meta.push(NodeMeta { kind: "file", name: base_name.to_string() });
+    w.node_ids.push(ids::file_node_id(file_path));
+    w.stack.push(Scope { row: 0, kind: "file", name: base_name.to_string() });
+
+    w.visit_node(tree.root_node());
+    w.flush_fn_ref_candidates();
+    w.flush_value_refs(tree.root_node());
+    w.stack.pop();
+
+    let duration_ms = t0.elapsed().as_secs_f64() * 1000.0;
+    let meta = build_meta(&w.tables, w.arena.len(), NONE_STR, duration_ms);
+    Ok(EmitOut {
+        meta,
+        nodes: w.tables.nodes,
+        edges: w.tables.edges,
+        refs: w.tables.refs,
+        arena: w.arena.into_vec(),
+    })
+}
+
+impl<'t> Walker<'t> {
+    fn text(&self, node: Node) -> &'t str {
+        &self.src[node.byte_range()]
+    }
+    fn line_of(&self, node: Node) -> u32 {
+        node.start_position().row as u32 + 1
+    }
+    fn col_of(&self, node: Node) -> u32 {
+        util::col16(self.src, &self.line_starts, node.start_position().row, node.start_byte())
+    }
+    fn end_col_of(&self, node: Node) -> u32 {
+        util::col16(self.src, &self.line_starts, node.end_position().row, node.end_byte())
+    }
+    fn top_row(&self) -> u32 {
+        self.stack.last().map(|s| s.row).unwrap_or(0)
+    }
+    fn inside_class_like(&self) -> bool {
+        self.stack
+            .last()
+            .map(|s| matches!(s.kind, "class" | "struct" | "interface" | "trait" | "enum" | "module"))
+            .unwrap_or(false)
+    }
+
+    fn push_ref_at(&mut self, from_row: u32, name: &str, kind_code: u8, node: Node) {
+        let name_ref = self.arena.put(name);
+        self.tables.push_ref(&RefRow {
+            from_idx: from_row,
+            kind: kind_code,
+            line: self.line_of(node),
+            column: self.col_of(node),
+            reference_name: name_ref,
+            candidates: NONE_STR,
+            from_id_str: NONE_STR,
+        });
+        if kind_code == edge_kind_index("imports").unwrap() {
+            if util::simple_name().is_match(name) {
+                self.imported_names.insert(name.to_string());
+            } else if let Some(c) = util::qualified_import().captures(name) {
+                self.imported_names.insert(c[1].to_string());
+            }
+        }
+    }
+
+    fn create_node(&mut self, kind: &'static str, name: &str, node: Node<'t>, extra: Extra) -> Option<u32> {
+        if name.is_empty() {
+            return None;
+        }
+        let start_line = self.line_of(node);
+        let id = ids::node_id(self.file_path, kind, name, start_line);
+        let end_line = node.end_position().row as u32 + 1;
+
+        let qualified = extra.qualified_name.unwrap_or_else(|| {
+            let mut parts: Vec<&str> = Vec::new();
+            for s in &self.stack {
+                if s.kind != "file" {
+                    parts.push(&s.name);
+                }
+            }
+            let mut qn = parts.join("::");
+            if !qn.is_empty() {
+                qn.push_str("::");
+            }
+            qn.push_str(name);
+            qn
+        });
+
+        let mut flags = BoolFlags::default();
+        if let Some(v) = extra.is_exported {
+            flags.set(FLAG_IS_EXPORTED, v);
+        }
+        let name_ref = self.arena.put(name);
+        let qn_ref = self.arena.put(&qualified);
+        let id_ref = self.arena.put(&id);
+        let doc_ref = opt_str(&mut self.arena, extra.docstring.as_deref());
+        let sig_ref = opt_str(&mut self.arena, extra.signature.as_deref());
+        let ret_ref = opt_str(&mut self.arena, extra.return_type.as_deref());
+        let row = self.tables.push_node(&NodeRow {
+            kind: node_kind_index(kind).unwrap(),
+            visibility: 0,
+            flags,
+            start_line,
+            end_line,
+            start_column: self.col_of(node),
+            end_column: self.end_col_of(node),
+            name: name_ref,
+            qualified_name: qn_ref,
+            id: id_ref,
+            docstring: doc_ref,
+            signature: sig_ref,
+            decorators: NONE_STR,
+            type_parameters: NONE_STR,
+            return_type: ret_ref,
+            extra_json: NONE_STR,
+        });
+        self.nodes_meta.push(NodeMeta { kind, name: name.to_string() });
+        self.node_ids.push(id);
+
+        let parent_row = self.top_row();
+        self.tables.push_edge(&EdgeRow {
+            source_idx: parent_row,
+            target_idx: row,
+            kind: edge_kind_index("contains").unwrap(),
+            provenance: 0,
+            line: NONE,
+            column: NONE,
+            metadata_json: NONE_STR,
+            source_id_str: NONE_STR,
+            target_id_str: NONE_STR,
+        });
+
+        if kind == "function" || kind == "method" {
+            self.defined_fn_names.insert(name.to_string());
+        }
+        let target_kind_ok = kind == "constant" || kind == "variable";
+        if target_kind_ok
+            && util::utf16_len(name) >= 3
+            && util::has_upper_or_underscore().is_match(name)
+        {
+            let parent_ok = self
+                .stack
+                .last()
+                .map(|s| matches!(s.kind, "file" | "class" | "module" | "struct" | "enum"))
+                .unwrap_or(false);
+            if parent_ok {
+                self.fs_values.insert(name.to_string(), row);
+                *self.fs_value_counts.entry(name.to_string()).or_insert(0) += 1;
+            }
+        }
+        if matches!(kind, "function" | "method" | "constant" | "variable") {
+            self.value_scopes.push(ValueScope { row, node, name: name.to_string() });
+        }
+        Some(row)
+    }
+
+    fn extract_name(&self, node: Node) -> String {
+        if let Some(name_node) = node.child_by_field_name("name") {
+            return self.text(name_node).to_string();
+        }
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                if matches!(c.kind(), "identifier" | "type_identifier" | "simple_identifier" | "constant") {
+                    return self.text(c).to_string();
+                }
+            }
+        }
+        "<anonymous>".to_string()
+    }
+
+    /// goExtractor.getSignature: params + ' ' + result.
+    fn signature_of(&self, node: Node) -> Option<String> {
+        let params = node.child_by_field_name("parameters")?;
+        let mut sig = self.text(params).to_string();
+        if let Some(result) = node.child_by_field_name("result") {
+            sig.push(' ');
+            sig.push_str(self.text(result));
+        }
+        Some(sig)
+    }
+
+    /// goExtractor.isExported: uppercase first letter of the name field.
+    fn is_exported(&self, node: Node) -> bool {
+        if let Some(name_node) = node.child_by_field_name("name") {
+            let text = self.text(name_node);
+            return text.as_bytes().first().map(|b| b.is_ascii_uppercase()).unwrap_or(false);
+        }
+        false
+    }
+
+    /// extractGoReturnType (languages/go.ts).
+    fn return_type_of(&self, node: Node) -> Option<String> {
+        let mut result = node.child_by_field_name("result")?;
+        if result.kind() == "parameter_list" {
+            let first = (0..result.named_child_count())
+                .filter_map(|i| result.named_child(i))
+                .find(|c| c.kind() == "parameter_declaration")?;
+            result = first.child_by_field_name("type").unwrap_or(first);
+        }
+        if result.kind() == "pointer_type" {
+            result = (0..result.named_child_count())
+                .filter_map(|i| result.named_child(i))
+                .find(|c| matches!(c.kind(), "type_identifier" | "qualified_type" | "generic_type"))
+                .unwrap_or(result);
+        }
+        let text = self.text(result).trim();
+        let text = text.strip_prefix('*').unwrap_or(text);
+        let text = generic_angle_re().replace_all(text, "");
+        let text = bracket_args_re().replace_all(&text, "");
+        let last = text.rsplit('.').next().unwrap_or("").trim().to_string();
+        if last.is_empty() || !simple_ident_re().is_match(&last) {
+            return None;
+        }
+        Some(last)
+    }
+
+    /// goExtractor.getReceiverType: the regex over the receiver's text.
+    fn receiver_type_of(&self, node: Node) -> Option<String> {
+        let receiver = node.child_by_field_name("receiver")?;
+        let text = self.text(receiver);
+        receiver_re().captures(text).map(|c| c[1].to_string())
+    }
+
+    // --- visitNode ------------------------------------------------------------
+
+    fn visit_node(&mut self, node: Node<'t>) {
+        let kind = node.kind();
+        let mut skip_children = false;
+
+        self.maybe_capture_fn_refs(node);
+
+        if kind == "function_declaration" {
+            self.extract_function(node);
+            skip_children = true;
+        } else if kind == "method_declaration" {
+            self.extract_method(node);
+            skip_children = true;
+        } else if kind == "type_spec" {
+            skip_children = self.extract_type_alias(node);
+        } else if matches!(kind, "var_declaration" | "short_var_declaration" | "const_declaration")
+            && !self.inside_class_like()
+        {
+            self.extract_variable(node);
+            self.scan_fn_ref_subtree(node, 0);
+            skip_children = true;
+        } else if kind == "import_declaration" {
+            self.extract_import(node);
+        } else if kind == "call_expression" {
+            self.extract_call(node);
+        } else if kind == "composite_literal" {
+            self.extract_instantiation(node);
+        }
+
+        if !skip_children {
+            for i in 0..node.named_child_count() {
+                if let Some(c) = node.named_child(i) {
+                    self.visit_node(c);
+                }
+            }
+        }
+    }
+
+    fn visit_function_body(&mut self, body: Node<'t>) {
+        self.visit_for_calls_and_structure(body);
+    }
+
+    fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        let kind = node.kind();
+        self.maybe_capture_fn_refs(node);
+
+        if kind == "call_expression" {
+            self.extract_call(node);
+        } else if kind == "composite_literal" {
+            self.extract_instantiation(node);
+        }
+
+        if kind == "function_declaration" {
+            let name = self.extract_name(node);
+            if name != "<anonymous>" {
+                self.extract_function(node);
+                return;
+            }
+        }
+
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                self.visit_for_calls_and_structure(c);
+            }
+        }
+    }
+
+    // --- extractors --------------------------------------------------------------
+
+    fn extract_function(&mut self, node: Node<'t>) {
+        // (getReceiverType only matches method_declaration's receiver field —
+        // function_declaration has none, so no reroute happens here)
+        let name = self.extract_name(node);
+        if name == "<anonymous>" {
+            if let Some(body) = node.child_by_field_name("body") {
+                self.visit_function_body(body);
+            }
+            return;
+        }
+        let extra = Extra {
+            docstring: preceding_docstring(node, self.src),
+            signature: self.signature_of(node),
+            is_exported: Some(self.is_exported(node)),
+            return_type: self.return_type_of(node),
+            ..Extra::default()
+        };
+        let Some(row) = self.create_node("function", &name, node, extra) else { return };
+        self.extract_type_annotations(node, row);
+        self.stack.push(Scope { row, kind: "function", name });
+        if let Some(body) = node.child_by_field_name("body") {
+            self.visit_function_body(body);
+        }
+        self.stack.pop();
+    }
+
+    fn extract_method(&mut self, node: Node<'t>) {
+        // methodsAreTopLevel: always a method. Receiver-qualified name +
+        // a contains edge from the FIRST earlier struct/class/enum/trait
+        // node of the receiver's name (mirrors the this.nodes.find scan).
+        let receiver_type = self.receiver_type_of(node);
+        let name = self.extract_name(node);
+        let extra = Extra {
+            docstring: preceding_docstring(node, self.src),
+            signature: self.signature_of(node),
+            return_type: self.return_type_of(node),
+            qualified_name: receiver_type.as_ref().map(|r| format!("{r}::{name}")),
+            ..Extra::default() // extractMethod passes no isExported
+        };
+        let Some(row) = self.create_node("method", &name, node, extra) else { return };
+
+        if let Some(receiver_type) = &receiver_type {
+            if !self.inside_class_like() {
+                let owner_row = self
+                    .nodes_meta
+                    .iter()
+                    .position(|m| {
+                        m.name == *receiver_type
+                            && matches!(m.kind, "struct" | "class" | "enum" | "trait")
+                    })
+                    .map(|i| i as u32);
+                if let Some(owner_row) = owner_row {
+                    self.tables.push_edge(&EdgeRow {
+                        source_idx: owner_row,
+                        target_idx: row,
+                        kind: edge_kind_index("contains").unwrap(),
+                        provenance: 0,
+                        line: NONE,
+                        column: NONE,
+                        metadata_json: NONE_STR,
+                        source_id_str: NONE_STR,
+                        target_id_str: NONE_STR,
+                    });
+                }
+            }
+        }
+
+        self.extract_type_annotations(node, row);
+        self.stack.push(Scope { row, kind: "method", name });
+        if let Some(body) = node.child_by_field_name("body") {
+            self.visit_function_body(body);
+        }
+        self.stack.pop();
+    }
+
+    /// extractTypeAlias for Go: type_spec → struct / interface / plain alias.
+    fn extract_type_alias(&mut self, node: Node<'t>) -> bool {
+        let name = self.extract_name(node);
+        if name == "<anonymous>" {
+            return false;
+        }
+        let docstring = preceding_docstring(node, self.src);
+        let is_exported = Some(self.is_exported(node));
+        let type_child = node.child_by_field_name("type");
+        let resolved = type_child.map(|t| t.kind());
+
+        if resolved == Some("struct_type") {
+            let Some(row) = self.create_node(
+                "struct",
+                &name,
+                node,
+                Extra { docstring, is_exported, ..Extra::default() },
+            ) else {
+                return true;
+            };
+            self.stack.push(Scope { row, kind: "struct", name });
+            if let Some(type_child) = type_child {
+                // Struct embedding → extends (field_declaration without a
+                // field_identifier), reached via the inheritance recursion.
+                self.extract_inheritance(type_child, row);
+                let body = type_child.child_by_field_name("body").unwrap_or(type_child);
+                for i in 0..body.named_child_count() {
+                    if let Some(c) = body.named_child(i) {
+                        self.visit_node(c);
+                    }
+                }
+            }
+            self.stack.pop();
+            return true;
+        }
+
+        if resolved == Some("interface_type") {
+            let Some(row) = self.create_node(
+                "interface",
+                &name,
+                node,
+                Extra { docstring, is_exported, ..Extra::default() },
+            ) else {
+                return true;
+            };
+            if let Some(type_child) = type_child {
+                self.extract_inheritance(type_child, row);
+                self.extract_go_interface_methods(type_child, row, &name);
+            }
+            return true;
+        }
+
+        self.create_node(
+            "type_alias",
+            &name,
+            node,
+            Extra { docstring, is_exported, ..Extra::default() },
+        );
+        // (go type_spec has no `value` field — no type-ref walk; TS/tsx member
+        // extraction is TS-family-only)
+        false
+    }
+
+    /// extractGoInterfaceMethods: method_elem/method_spec → method nodes.
+    fn extract_go_interface_methods(&mut self, interface_type: Node<'t>, iface_row: u32, iface_name: &str) {
+        self.stack.push(Scope { row: iface_row, kind: "interface", name: iface_name.to_string() });
+        for i in 0..interface_type.named_child_count() {
+            let Some(m) = interface_type.named_child(i) else { continue };
+            if !matches!(m.kind(), "method_elem" | "method_spec") {
+                continue;
+            }
+            let name_node = m.child_by_field_name("name").or_else(|| m.named_child(0));
+            let Some(name_node) = name_node else { continue };
+            let mname = self.text(name_node).to_string();
+            if !mname.is_empty() {
+                let signature = self.signature_of(m);
+                self.create_node("method", &mname, m, Extra { signature, ..Extra::default() });
+            }
+        }
+        self.stack.pop();
+    }
+
+    /// extractVariable's Go branch: var/const specs + short_var_declaration.
+    fn extract_variable(&mut self, node: Node<'t>) {
+        let docstring = preceding_docstring(node, self.src);
+        let is_const_decl = node.kind() == "const_declaration";
+
+        for i in 0..node.named_child_count() {
+            let Some(spec) = node.named_child(i) else { continue };
+            if !matches!(spec.kind(), "var_spec" | "const_spec") {
+                continue;
+            }
+            let mut var_row: Option<u32> = None;
+            if let Some(name_node) = spec.named_child(0) {
+                if name_node.kind() == "identifier" {
+                    let name = self.text(name_node).to_string();
+                    let value_node = if spec.named_child_count() > 1 {
+                        spec.named_child(spec.named_child_count() - 1)
+                    } else {
+                        None
+                    };
+                    let signature = value_node.map(|v| util::init_signature(self.text(v)));
+                    var_row = self.create_node(
+                        if is_const_decl { "constant" } else { "variable" },
+                        &name,
+                        spec,
+                        Extra { docstring: docstring.clone(), signature, ..Extra::default() },
+                    );
+                }
+            }
+            // Walk the initializer ATTRIBUTED to the declared symbol (#693).
+            if let Some(value_field) = spec.child_by_field_name("value") {
+                if let Some(row) = var_row {
+                    let name = self.nodes_meta[row as usize].name.clone();
+                    self.stack.push(Scope { row, kind: "variable", name });
+                    self.visit_function_body(value_field);
+                    self.stack.pop();
+                } else {
+                    self.visit_function_body(value_field);
+                }
+            }
+        }
+
+        if node.kind() == "short_var_declaration" {
+            let left = node.child_by_field_name("left");
+            let right = node.child_by_field_name("right");
+            if let Some(left) = left {
+                let identifiers: Vec<Node> = if left.kind() == "expression_list" {
+                    (0..left.named_child_count())
+                        .filter_map(|i| left.named_child(i))
+                        .filter(|c| c.kind() == "identifier")
+                        .collect()
+                } else {
+                    vec![left]
+                };
+                for id in identifiers {
+                    let name = self.text(id).to_string();
+                    let signature = right.map(|r| util::init_signature(self.text(r)));
+                    self.create_node(
+                        "variable",
+                        &name,
+                        node,
+                        Extra { docstring: docstring.clone(), signature, ..Extra::default() },
+                    );
+                }
+            }
+        }
+    }
+
+    /// extractImport's Go branch: one import node + ref per import_spec.
+    fn extract_import(&mut self, node: Node<'t>) {
+        let parent = self.top_row();
+        let imports_kind = edge_kind_index("imports").unwrap();
+        let mut handle_spec = |w: &mut Self, spec: Node<'t>| {
+            let lit = (0..spec.named_child_count())
+                .filter_map(|i| spec.named_child(i))
+                .find(|c| c.kind() == "interpreted_string_literal");
+            let Some(lit) = lit else { return };
+            let import_path: String = w
+                .text(lit)
+                .chars()
+                .filter(|c| *c != '\'' && *c != '"')
+                .collect();
+            if import_path.is_empty() {
+                return;
+            }
+            let signature = w.text(spec).trim().to_string();
+            w.create_node(
+                "import",
+                &import_path,
+                spec,
+                Extra { signature: Some(signature), ..Extra::default() },
+            );
+            w.push_ref_at(parent, &import_path, imports_kind, spec);
+        };
+
+        let spec_list = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| c.kind() == "import_spec_list");
+        if let Some(list) = spec_list {
+            for i in 0..list.named_child_count() {
+                if let Some(spec) = list.named_child(i) {
+                    if spec.kind() == "import_spec" {
+                        handle_spec(self, spec);
+                    }
+                }
+            }
+        } else {
+            let spec = (0..node.named_child_count())
+                .filter_map(|i| node.named_child(i))
+                .find(|c| c.kind() == "import_spec");
+            if let Some(spec) = spec {
+                handle_spec(self, spec);
+            }
+        }
+    }
+
+    /// extractCall — Go's generic-tail paths (selector_expression callees).
+    fn extract_call(&mut self, node: Node<'t>) {
+        if self.stack.is_empty() {
+            return;
+        }
+        let func = node
+            .child_by_field_name("function")
+            .or_else(|| node.named_child(0));
+        let mut callee_name = String::new();
+
+        if let Some(func) = func {
+            if func.kind() == "selector_expression" {
+                let property = func
+                    .child_by_field_name("property")
+                    .or_else(|| func.child_by_field_name("field"));
+                if let Some(property) = property {
+                    let method_name = self.text(property);
+                    let receiver = func
+                        .child_by_field_name("object")
+                        .or_else(|| func.child_by_field_name("operand"))
+                        .or_else(|| func.child_by_field_name("argument"))
+                        .or_else(|| func.named_child(0));
+                    if let Some(r) = receiver {
+                        if is_literal_receiver(r.kind()) {
+                            return;
+                        }
+                    }
+                    if let Some(r) = receiver {
+                        match r.kind() {
+                            "identifier" | "simple_identifier" | "field_identifier" => {
+                                let receiver_name = self.text(r);
+                                if !matches!(receiver_name, "self" | "this" | "cls" | "super") {
+                                    callee_name = format!("{receiver_name}.{method_name}");
+                                } else {
+                                    callee_name = method_name.to_string();
+                                }
+                            }
+                            "call_expression" => {
+                                // Bare package-level factory chain `New().Method()`
+                                // re-encodes; instance chains keep the bare name.
+                                let inner_fn = r.child_by_field_name("function");
+                                let reencode =
+                                    inner_fn.map(|f| f.kind() == "identifier").unwrap_or(false);
+                                if reencode {
+                                    let inner: String = self
+                                        .text(inner_fn.unwrap())
+                                        .replace("->", ".")
+                                        .chars()
+                                        .filter(|c| !c.is_whitespace())
+                                        .collect();
+                                    callee_name = format!("{inner}().{method_name}");
+                                } else {
+                                    callee_name = method_name.to_string();
+                                }
+                            }
+                            "selector_expression" => {
+                                // 2-hop field chain `t.conn.Exec` (#1276).
+                                let chain: String = self
+                                    .text(r)
+                                    .chars()
+                                    .filter(|c| !c.is_whitespace())
+                                    .collect();
+                                if go_two_hop_re().is_match(&chain) {
+                                    callee_name = format!("{chain}.{method_name}");
+                                } else {
+                                    callee_name = method_name.to_string();
+                                }
+                            }
+                            _ => {
+                                callee_name = method_name.to_string();
+                            }
+                        }
+                    } else {
+                        callee_name = method_name.to_string();
+                    }
+                }
+            } else {
+                callee_name = self.text(func).to_string();
+            }
+        }
+
+        if !callee_name.is_empty() {
+            // `(*T)(x)` conversions normalize to `T`.
+            if let Some(c) = util::paren_conversion().captures(&callee_name) {
+                callee_name = c[1].to_string();
+            }
+            let from = self.top_row();
+            self.push_ref_at(from, &callee_name.clone(), edge_kind_index("calls").unwrap(), node);
+        }
+    }
+
+    /// extractInstantiation's composite_literal branch: named struct types
+    /// only; the package qualifier is KEPT.
+    fn extract_instantiation(&mut self, node: Node<'t>) {
+        if self.stack.is_empty() {
+            return;
+        }
+        let ctor = node
+            .child_by_field_name("constructor")
+            .or_else(|| node.child_by_field_name("type"))
+            .or_else(|| node.child_by_field_name("name"))
+            .or_else(|| node.named_child(0));
+        let Some(ctor) = ctor else { return };
+        if !matches!(ctor.kind(), "type_identifier" | "qualified_type") {
+            return;
+        }
+        let mut go_type = self.text(ctor).trim().to_string();
+        if let Some(br) = go_type.find('[') {
+            if br > 0 {
+                go_type.truncate(br);
+                go_type = go_type.trim().to_string();
+            }
+        }
+        if !go_type.is_empty() {
+            let from = self.top_row();
+            self.push_ref_at(from, &go_type, edge_kind_index("instantiates").unwrap(), node);
+        }
+    }
+
+    /// extractInheritance — the Go branches: interface embedding
+    /// (constraint_elem) and struct embedding (field_declaration without a
+    /// field_identifier), plus the field_declaration_list recursion.
+    fn extract_inheritance(&mut self, node: Node<'t>, class_row: u32) {
+        let extends_kind = edge_kind_index("extends").unwrap();
+        for i in 0..node.named_child_count() {
+            let Some(child) = node.named_child(i) else { continue };
+            match child.kind() {
+                "constraint_elem" => {
+                    let type_id = (0..child.named_child_count())
+                        .filter_map(|j| child.named_child(j))
+                        .find(|c| c.kind() == "type_identifier");
+                    if let Some(type_id) = type_id {
+                        let name = self.text(type_id).to_string();
+                        self.push_ref_at(class_row, &name, extends_kind, type_id);
+                    }
+                }
+                "field_declaration" => {
+                    let has_field_identifier = (0..child.named_child_count())
+                        .filter_map(|j| child.named_child(j))
+                        .any(|c| c.kind() == "field_identifier");
+                    if !has_field_identifier {
+                        let type_id = (0..child.named_child_count())
+                            .filter_map(|j| child.named_child(j))
+                            .find(|c| c.kind() == "type_identifier");
+                        if let Some(type_id) = type_id {
+                            let name = self.text(type_id).to_string();
+                            self.push_ref_at(class_row, &name, extends_kind, type_id);
+                        }
+                    }
+                }
+                "field_declaration_list" | "class_heritage" => {
+                    self.extract_inheritance(child, class_row);
+                }
+                _ => {}
+            }
+        }
+    }
+
+    /// extractTypeAnnotations — Go's returnField is `result`.
+    fn extract_type_annotations(&mut self, node: Node<'t>, from_row: u32) {
+        if let Some(params) = node.child_by_field_name("parameters") {
+            self.extract_type_refs_from_subtree(params, from_row);
+        }
+        if let Some(ret) = node.child_by_field_name("result") {
+            self.extract_type_refs_from_subtree(ret, from_row);
+        }
+        let type_annotation = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| c.kind() == "type_annotation");
+        if let Some(ta) = type_annotation {
+            self.extract_type_refs_from_subtree(ta, from_row);
+        }
+    }
+
+    fn extract_type_refs_from_subtree(&mut self, node: Node<'t>, from_row: u32) {
+        if node.kind() == "type_identifier" {
+            let type_name = self.text(node).to_string();
+            if !type_name.is_empty() && !is_builtin_type(&type_name) {
+                self.push_ref_at(from_row, &type_name, edge_kind_index("references").unwrap(), node);
+            }
+            return;
+        }
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                self.extract_type_refs_from_subtree(c, from_row);
+            }
+        }
+    }
+
+    // --- fn refs (GO_SPEC, with the literal_element/expression_list layers) --------
+
+    fn maybe_capture_fn_refs(&mut self, node: Node<'t>) {
+        let (mode, field): (&str, &str) = match node.kind() {
+            "argument_list" => ("args", ""),
+            "assignment_statement" => ("rhs", "right"),
+            "short_var_declaration" => ("rhs", "right"),
+            "var_spec" => ("varinit", "value"),
+            "keyed_element" => ("value", ""), // value = LAST named child
+            "literal_value" => ("list", ""),
+            _ => return,
+        };
+        if self.stack.is_empty() {
+            return;
+        }
+        let from = self.top_row();
+
+        let mut values: Vec<Node> = Vec::new();
+        match mode {
+            "args" | "list" => {
+                for i in 0..node.named_child_count() {
+                    if let Some(c) = node.named_child(i) {
+                        values.push(c);
+                    }
+                }
+            }
+            "rhs" => {
+                if let Some(rhs) = node.child_by_field_name(field) {
+                    let lhs_text = node
+                        .child_by_field_name("left")
+                        .map(|l| self.text(l))
+                        .unwrap_or("");
+                    let lhs_last = util::lhs_last_name()
+                        .captures(lhs_text)
+                        .and_then(|c| c.get(1))
+                        .map(|m| m.as_str());
+                    if !(lhs_last.is_some() && lhs_last == Some(self.text(rhs).trim())) {
+                        values.push(rhs);
+                    }
+                }
+            }
+            "value" => {
+                let v = node
+                    .child_by_field_name("value")
+                    .or_else(|| {
+                        if node.named_child_count() > 0 {
+                            node.named_child(node.named_child_count() - 1)
+                        } else {
+                            None
+                        }
+                    });
+                if let Some(v) = v {
+                    values.push(v);
+                }
+            }
+            _ => {
+                // varinit — Go var_spec names are plain identifiers (no
+                // destructuring patterns to skip).
+                if let Some(v) = node.child_by_field_name(field) {
+                    values.push(v);
+                }
+            }
+        }
+
+        for v in values {
+            self.normalize_fn_ref_value(v, from, 0);
+        }
+    }
+
+    /// normalizeValue with GO_SPEC's transparent layers (literal_element,
+    /// expression_list — both fan out to named children).
+    fn normalize_fn_ref_value(&mut self, v: Node<'t>, from: u32, depth: u32) {
+        if depth > 4 {
+            return;
+        }
+        match v.kind() {
+            "identifier" => {
+                let name = self.text(v).to_string();
+                if name.is_empty() || is_stoplisted(&name) {
+                    return;
+                }
+                let p = v.start_position();
+                self.fn_ref_cands.push(Cand {
+                    from,
+                    name,
+                    line: p.row as u32 + 1,
+                    column_byte: v.start_byte(),
+                    row: p.row,
+                });
+            }
+            "literal_element" | "expression_list" => {
+                for i in 0..v.named_child_count() {
+                    if let Some(c) = v.named_child(i) {
+                        self.normalize_fn_ref_value(c, from, depth + 1);
+                    }
+                }
+            }
+            _ => {}
+        }
+    }
+
+    fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        if depth > 12 {
+            return;
+        }
+        if depth > 0
+            && matches!(
+                node.kind(),
+                "function_declaration" | "arrow_function" | "function_expression"
+                    | "lambda_literal" | "lambda_expression"
+            )
+        {
+            return;
+        }
+        self.maybe_capture_fn_refs(node);
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                self.scan_fn_ref_subtree(c, depth + 1);
+            }
+        }
+    }
+
+    fn flush_fn_ref_candidates(&mut self) {
+        let cands = std::mem::take(&mut self.fn_ref_cands);
+        if cands.is_empty() || util::is_generated_file(self.file_path) {
+            return;
+        }
+        let mut seen: HashSet<(String, String)> = HashSet::new();
+        for c in cands {
+            if !c.name.starts_with("this.")
+                && !c.name.contains("::")
+                && !self.defined_fn_names.contains(&c.name)
+                && !self.imported_names.contains(&c.name)
+            {
+                continue;
+            }
+            if !seen.insert((self.node_ids[c.from as usize].clone(), c.name.clone())) {
+                continue;
+            }
+            let column = util::col16(self.src, &self.line_starts, c.row, c.column_byte);
+            let name_ref = self.arena.put(&c.name);
+            self.tables.push_ref(&RefRow {
+                from_idx: c.from,
+                kind: FUNCTION_REF_CODE,
+                line: c.line,
+                column,
+                reference_name: name_ref,
+                candidates: NONE_STR,
+                from_id_str: NONE_STR,
+            });
+        }
+    }
+
+    // --- value refs -------------------------------------------------------------------
+
+    fn flush_value_refs(&mut self, root: Node<'t>) {
+        let scopes = std::mem::take(&mut self.value_scopes);
+        let mut targets = std::mem::take(&mut self.fs_values);
+        let counts = std::mem::take(&mut self.fs_value_counts);
+        if std::env::var("CODEGRAPH_VALUE_REFS").as_deref() == Ok("0") {
+            return;
+        }
+        if targets.is_empty() || scopes.is_empty() || util::is_generated_file(self.file_path) {
+            return;
+        }
+
+        // Shadow prune — Go declarator shapes: const_spec/var_spec (name =
+        // first child) and short_var_declaration (left / expression_list).
+        let mut decl_counts: HashMap<&str, u32> = HashMap::new();
+        let mut bump = |decl_counts: &mut HashMap<&'t str, u32>, name_node: Option<Node<'t>>, src: &'t str, targets: &HashMap<String, u32>| {
+            if let Some(n) = name_node {
+                if matches!(n.kind(), "identifier" | "simple_identifier") {
+                    let nm = &src[n.byte_range()];
+                    if targets.contains_key(nm) {
+                        *decl_counts.entry(nm).or_insert(0) += 1;
+                    }
+                }
+            }
+        };
+        let mut dstack: Vec<Node> = vec![root];
+        let mut dvisited = 0usize;
+        while let Some(n) = dstack.pop() {
+            if dvisited >= MAX_VALUE_REF_NODES {
+                break;
+            }
+            dvisited += 1;
+            match n.kind() {
+                "const_spec" | "var_spec" => bump(&mut decl_counts, n.named_child(0), self.src, &targets),
+                "short_var_declaration" => {
+                    let left = n
+                        .child_by_field_name("left")
+                        .or_else(|| n.child_by_field_name("pattern"))
+                        .or_else(|| n.named_child(0));
+                    if let Some(left) = left {
+                        if left.kind() == "identifier" {
+                            bump(&mut decl_counts, Some(left), self.src, &targets);
+                        } else {
+                            for i in 0..left.named_child_count() {
+                                bump(&mut decl_counts, left.named_child(i), self.src, &targets);
+                            }
+                        }
+                    }
+                }
+                _ => {}
+            }
+            for i in 0..n.named_child_count() {
+                if let Some(c) = n.named_child(i) {
+                    dstack.push(c);
+                }
+            }
+        }
+        let shadowed: Vec<String> = decl_counts
+            .iter()
+            .filter(|(nm, c)| **c > counts.get(**nm).copied().unwrap_or(1))
+            .map(|(nm, _)| nm.to_string())
+            .collect();
+        for nm in shadowed {
+            targets.remove(&nm);
+        }
+        if targets.is_empty() {
+            return;
+        }
+
+        let refs_kind = edge_kind_index("references").unwrap();
+        for scope in &scopes {
+            let mut seen: HashSet<&str> = HashSet::new();
+            let mut stack: Vec<Node> = vec![scope.node];
+            let mut visited = 0usize;
+            while let Some(n) = stack.pop() {
+                if visited >= MAX_VALUE_REF_NODES {
+                    break;
+                }
+                visited += 1;
+                if matches!(n.kind(), "identifier" | "constant" | "name" | "simple_identifier") {
+                    let ref_name = self.text(n);
+                    if let Some(&target_row) = targets.get(ref_name) {
+                        let target_id = self.node_ids[target_row as usize].as_str();
+                        if target_id != self.node_ids[scope.row as usize]
+                            && ref_name != scope.name
+                            && !seen.contains(&target_id)
+                        {
+                            seen.insert(target_id);
+                            let meta = self.arena.put(r#"{"valueRef":true}"#);
+                            self.tables.push_edge(&EdgeRow {
+                                source_idx: scope.row,
+                                target_idx: target_row,
+                                kind: refs_kind,
+                                provenance: 0,
+                                line: NONE,
+                                column: NONE,
+                                metadata_json: meta,
+                                source_id_str: NONE_STR,
+                                target_id_str: NONE_STR,
+                            });
+                        }
+                    }
+                }
+                for i in 0..n.named_child_count() {
+                    if let Some(c) = n.named_child(i) {
+                        stack.push(c);
+                    }
+                }
+            }
+        }
+    }
+}
+
+fn is_stoplisted(name: &str) -> bool {
+    matches!(
+        name,
+        "this" | "self" | "super" | "null" | "nil" | "true" | "false" | "undefined" | "new"
+            | "NULL" | "nullptr" | "None"
+    )
+}
+
+fn is_literal_receiver(kind: &str) -> bool {
+    matches!(
+        kind,
+        "string" | "string_literal" | "interpreted_string_literal" | "raw_string_literal"
+            | "template_string" | "concatenated_string" | "formatted_string" | "f_string"
+            | "line_string_literal" | "string_content" | "heredoc_body"
+            | "number" | "number_literal" | "integer" | "integer_literal" | "float"
+            | "float_literal" | "int_literal" | "decimal_integer_literal" | "real_literal"
+            | "char_literal" | "character_literal" | "rune_literal" | "regex" | "regex_literal"
+            | "true" | "false" | "boolean_literal" | "bool_literal" | "none" | "null" | "nil"
+            | "null_literal" | "undefined"
+            | "list" | "list_literal" | "array" | "array_literal" | "array_creation_expression"
+            | "dictionary" | "dict_literal" | "object" | "tuple" | "set"
+    )
+}
+
+/// BUILTIN_TYPES (shared table).
+fn is_builtin_type(name: &str) -> bool {
+    matches!(
+        name,
+        "string" | "number" | "boolean" | "void" | "null" | "undefined" | "never" | "any"
+            | "unknown" | "object" | "symbol" | "bigint" | "true" | "false"
+            | "str" | "bool" | "i8" | "i16" | "i32" | "i64" | "i128" | "isize"
+            | "u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "f32" | "f64" | "char"
+            | "int" | "long" | "short" | "byte" | "float" | "double"
+            | "int8" | "int16" | "int32" | "int64" | "uint8" | "uint16" | "uint32" | "uint64"
+            | "float32" | "float64" | "complex64" | "complex128" | "rune" | "error"
+            | "Int" | "Long" | "Short" | "Byte" | "Float" | "Double" | "Boolean" | "Char"
+            | "Unit" | "String" | "Any" | "AnyRef" | "AnyVal" | "Nothing" | "Null"
+    )
+}
+
+fn opt_str(arena: &mut Arena, s: Option<&str>) -> StrRef {
+    match s {
+        Some(s) => arena.put(s),
+        None => NONE_STR,
+    }
+}

+ 53 - 0
codegraph-kernel/src/ids.rs

@@ -0,0 +1,53 @@
+//! Node-ID generation — MUST produce byte-identical output to
+//! `generateNodeId` in `src/extraction/tree-sitter-helpers.ts`:
+//!
+//!   `${kind}:${sha256(`${filePath}:${kind}:${name}:${line}`).hex[0..32]}`
+//!
+//! and the file-node special case in `TreeSitterExtractor.extract()`:
+//!
+//!   `file:${filePath}`
+//!
+//! Node identity is how the wasm path and the kernel path agree on the same
+//! graph — a drift here breaks every edge. Pinned by the node-id parity test
+//! in `__tests__/kernel-scaffold.test.ts`.
+
+use sha2::{Digest, Sha256};
+
+pub fn node_id(file_path: &str, kind: &str, name: &str, line: u32) -> String {
+    let mut hasher = Sha256::new();
+    hasher.update(file_path.as_bytes());
+    hasher.update(b":");
+    hasher.update(kind.as_bytes());
+    hasher.update(b":");
+    hasher.update(name.as_bytes());
+    hasher.update(b":");
+    hasher.update(line.to_string().as_bytes());
+    let digest = hasher.finalize();
+    // 32 hex chars = first 16 bytes.
+    let mut hex = String::with_capacity(kind.len() + 1 + 32);
+    hex.push_str(kind);
+    hex.push(':');
+    for b in &digest[..16] {
+        hex.push_str(&format!("{b:02x}"));
+    }
+    hex
+}
+
+pub fn file_node_id(file_path: &str) -> String {
+    format!("file:{file_path}")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn matches_known_ts_output() {
+        // Pinned vector: node -e "crypto.createHash('sha256')
+        //   .update('src/a.ts:function:foo:3').digest('hex').substring(0,32)"
+        assert_eq!(
+            node_id("src/a.ts", "function", "foo", 3),
+            "function:bfb15544fed707794274a5c61006ea7b"
+        );
+    }
+}

+ 1610 - 0
codegraph-kernel/src/java.rs

@@ -0,0 +1,1610 @@
+//! Java extraction — a faithful Rust port of `TreeSitterExtractor`'s Java
+//! paths (src/extraction/tree-sitter.ts) plus languages/java.ts, including
+//! the Lombok member synthesizer (#912).
+//!
+//! Same porting contract as tsjs/: behavior parity with the wasm path,
+//! bug-for-bug, verified by scripts/kernel-parity.mjs and the full-index
+//! dump-diff gate. Positions in UTF-16 code units. Files whose parse tree
+//! contains ERRORS defer to the wasm extractor (encoding-dependent recovery —
+//! see tsjs/mod.rs).
+
+use crate::buffers::{
+    build_meta, edge_kind_index, node_kind_index, Arena, BoolFlags, EdgeRow, EmitOut, NodeRow,
+    RefRow, StrRef, Tables, FLAG_IS_STATIC, FUNCTION_REF_CODE, NONE, NONE_STR,
+};
+use crate::docstring::preceding_docstring;
+use crate::ids;
+use crate::textutil as util;
+use regex::Regex;
+use std::collections::{HashMap, HashSet};
+use std::sync::OnceLock;
+use tree_sitter::{Node, Parser};
+
+const MAX_VALUE_REF_NODES: usize = 20_000;
+
+fn is_method_type(kind: &str) -> bool {
+    matches!(kind, "method_declaration" | "constructor_declaration")
+}
+fn is_interface_type(kind: &str) -> bool {
+    matches!(kind, "interface_declaration" | "annotation_type_declaration")
+}
+
+/// JAVA_NON_CLASS_RETURN_NODES (languages/java.ts).
+fn is_non_class_return(kind: &str) -> bool {
+    matches!(kind, "void_type" | "integral_type" | "floating_point_type" | "boolean_type")
+}
+
+/// BUILTIN_TYPES (tree-sitter.ts) — shared table; only the Java-relevant names
+/// fire here but membership is what the TS code tests.
+fn is_builtin_type(name: &str) -> bool {
+    matches!(
+        name,
+        "string" | "number" | "boolean" | "void" | "null" | "undefined" | "never" | "any"
+            | "unknown" | "object" | "symbol" | "bigint" | "true" | "false"
+            | "str" | "bool" | "i8" | "i16" | "i32" | "i64" | "i128" | "isize"
+            | "u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "f32" | "f64" | "char"
+            | "int" | "long" | "short" | "byte" | "float" | "double"
+            | "int8" | "int16" | "int32" | "int64" | "uint8" | "uint16" | "uint32" | "uint64"
+            | "float32" | "float64" | "complex64" | "complex128" | "rune" | "error"
+            | "Int" | "Long" | "Short" | "Byte" | "Float" | "Double" | "Boolean" | "Char"
+            | "Unit" | "String" | "Any" | "AnyRef" | "AnyVal" | "Nothing" | "Null"
+    )
+}
+
+/// LOMBOK_LOG_ANNOTATIONS (languages/java.ts).
+fn is_lombok_log_annotation(name: &str) -> bool {
+    matches!(
+        name,
+        "Slf4j" | "Log4j" | "Log4j2" | "Log" | "CommonsLog" | "JBossLog" | "Flogger" | "XSlf4j"
+            | "CustomLog"
+    )
+}
+
+fn generic_args_re() -> &'static Regex {
+    static RE: OnceLock<Regex> = OnceLock::new();
+    RE.get_or_init(|| Regex::new(r"<[^>]*>").unwrap())
+}
+fn simple_ident_re() -> &'static Regex {
+    static RE: OnceLock<Regex> = OnceLock::new();
+    RE.get_or_init(|| Regex::new(r"^[A-Za-z_]\w*$").unwrap())
+}
+fn capitalized_re() -> &'static Regex {
+    static RE: OnceLock<Regex> = OnceLock::new();
+    RE.get_or_init(|| Regex::new(r"^[A-Z][A-Za-z0-9_]*$").unwrap())
+}
+fn method_ref_type_re() -> &'static Regex {
+    static RE: OnceLock<Regex> = OnceLock::new();
+    RE.get_or_init(|| Regex::new(r"^([A-Z][A-Za-z0-9_]*)\s*::").unwrap())
+}
+fn is_prefix_re(word: &str) -> bool {
+    // /^is[A-Z]/ for Lombok boolean getters.
+    word.len() > 2 && word.starts_with("is") && word.as_bytes()[2].is_ascii_uppercase()
+}
+
+struct Scope {
+    row: u32,
+    kind: &'static str,
+    name: String,
+}
+
+/// Per-node metadata kept for the Lombok synthesizer's taken-member scan
+/// (mirrors its walk over ctx.nodes by qualifiedName).
+struct NodeMeta {
+    kind: &'static str,
+    name: String,
+    qualified_name: String,
+}
+
+#[derive(Default)]
+struct Extra {
+    docstring: Option<String>,
+    signature: Option<String>,
+    visibility: Option<u8>,
+    is_static: Option<bool>,
+    return_type: Option<String>,
+    decorators: Option<Vec<String>>,
+}
+
+struct ValueScope<'t> {
+    row: u32,
+    node: Node<'t>,
+    name: String,
+}
+
+struct Cand {
+    from: u32,
+    name: String,
+    line: u32,
+    column_byte: usize,
+    row: usize,
+}
+
+pub struct Walker<'t> {
+    src: &'t str,
+    file_path: &'t str,
+    line_starts: Vec<usize>,
+    arena: Arena,
+    tables: Tables,
+    stack: Vec<Scope>,
+    nodes_meta: Vec<NodeMeta>,
+    /// Node id string per row — ids COLLIDE for same-(kind, name, line) nodes
+    /// and the TS side's fn-ref dedupe / value-ref self-checks key on the id.
+    node_ids: Vec<String>,
+    defined_fn_names: HashSet<String>,
+    imported_names: HashSet<String>,
+    fn_ref_cands: Vec<Cand>,
+    fs_values: HashMap<String, u32>,
+    fs_value_counts: HashMap<String, u32>,
+    value_scopes: Vec<ValueScope<'t>>,
+}
+
+pub fn extract(file_path: &str, source: &str) -> Result<EmitOut, String> {
+    let grammar = crate::langs::grammar_for("java").ok_or("no java grammar")?;
+    let t0 = std::time::Instant::now();
+    let mut parser = Parser::new();
+    parser
+        .set_language(&grammar)
+        .map_err(|e| format!("set_language(java) failed: {e}"))?;
+    let tree = parser
+        .parse(source, None)
+        .ok_or_else(|| "parser returned null tree".to_string())?;
+    if tree.root_node().has_error() {
+        return Err("defer: parse tree contains errors — wasm recovery is canonical".to_string());
+    }
+
+    let mut w = Walker {
+        src: source,
+        file_path,
+        line_starts: util::line_starts(source),
+        arena: Arena::default(),
+        tables: Tables::default(),
+        stack: Vec::new(),
+        nodes_meta: Vec::new(),
+        node_ids: Vec::new(),
+        defined_fn_names: HashSet::new(),
+        imported_names: HashSet::new(),
+        fn_ref_cands: Vec::new(),
+        fs_values: HashMap::new(),
+        fs_value_counts: HashMap::new(),
+        value_scopes: Vec::new(),
+    };
+
+    // File node (TreeSitterExtractor.extract).
+    let line_count = source.bytes().filter(|b| *b == b'\n').count() as u32 + 1;
+    let base_name = file_path.rsplit(['/', '\\']).next().unwrap_or(file_path);
+    let mut flags = BoolFlags::default();
+    flags.set(crate::buffers::FLAG_IS_EXPORTED, false);
+    let file_id = w.arena.put(&ids::file_node_id(file_path));
+    let name_ref = w.arena.put(base_name);
+    let qn_ref = w.arena.put(file_path);
+    w.tables.push_node(&NodeRow {
+        kind: node_kind_index("file").unwrap(),
+        visibility: 0,
+        flags,
+        start_line: 1,
+        end_line: line_count,
+        start_column: 0,
+        end_column: 0,
+        name: name_ref,
+        qualified_name: qn_ref,
+        id: file_id,
+        docstring: NONE_STR,
+        signature: NONE_STR,
+        decorators: NONE_STR,
+        type_parameters: NONE_STR,
+        return_type: NONE_STR,
+        extra_json: NONE_STR,
+    });
+    w.nodes_meta.push(NodeMeta {
+        kind: "file",
+        name: base_name.to_string(),
+        qualified_name: file_path.to_string(),
+    });
+    w.node_ids.push(ids::file_node_id(file_path));
+    w.stack.push(Scope { row: 0, kind: "file", name: base_name.to_string() });
+
+    // extractFilePackage: wrap top-level declarations in a `namespace` node
+    // carrying the package FQN.
+    let root = tree.root_node();
+    let mut pkg_pushed = false;
+    for i in 0..root.named_child_count() {
+        let Some(child) = root.named_child(i) else { continue };
+        if child.kind() != "package_declaration" {
+            continue;
+        }
+        let id_node = (0..child.named_child_count())
+            .filter_map(|j| child.named_child(j))
+            .find(|c| matches!(c.kind(), "scoped_identifier" | "identifier"));
+        if let Some(id_node) = id_node {
+            let pkg = w.text(id_node).trim().to_string();
+            if !pkg.is_empty() {
+                if let Some(row) = w.create_node("namespace", &pkg, child, Extra::default()) {
+                    w.stack.push(Scope { row, kind: "namespace", name: pkg });
+                    pkg_pushed = true;
+                }
+            }
+        }
+        break;
+    }
+
+    w.visit_node(root);
+    w.flush_fn_ref_candidates();
+    w.flush_value_refs(root);
+    if pkg_pushed {
+        w.stack.pop();
+    }
+    w.stack.pop();
+
+    let duration_ms = t0.elapsed().as_secs_f64() * 1000.0;
+    let meta = build_meta(&w.tables, w.arena.len(), NONE_STR, duration_ms);
+    Ok(EmitOut {
+        meta,
+        nodes: w.tables.nodes,
+        edges: w.tables.edges,
+        refs: w.tables.refs,
+        arena: w.arena.into_vec(),
+    })
+}
+
+impl<'t> Walker<'t> {
+    fn text(&self, node: Node) -> &'t str {
+        &self.src[node.byte_range()]
+    }
+    fn line_of(&self, node: Node) -> u32 {
+        node.start_position().row as u32 + 1
+    }
+    fn col_of(&self, node: Node) -> u32 {
+        util::col16(self.src, &self.line_starts, node.start_position().row, node.start_byte())
+    }
+    fn end_col_of(&self, node: Node) -> u32 {
+        util::col16(self.src, &self.line_starts, node.end_position().row, node.end_byte())
+    }
+    fn top_row(&self) -> u32 {
+        self.stack.last().map(|s| s.row).unwrap_or(0)
+    }
+    fn inside_class_like(&self) -> bool {
+        self.stack
+            .last()
+            .map(|s| matches!(s.kind, "class" | "struct" | "interface" | "trait" | "enum" | "module"))
+            .unwrap_or(false)
+    }
+
+    fn push_ref(&mut self, from_row: u32, name: &str, kind_code: u8, line: u32, column: u32) {
+        let name_ref = self.arena.put(name);
+        self.tables.push_ref(&RefRow {
+            from_idx: from_row,
+            kind: kind_code,
+            line,
+            column,
+            reference_name: name_ref,
+            candidates: NONE_STR,
+            from_id_str: NONE_STR,
+        });
+        if kind_code == edge_kind_index("imports").unwrap() {
+            if util::simple_name().is_match(name) {
+                self.imported_names.insert(name.to_string());
+            } else if let Some(c) = util::qualified_import().captures(name) {
+                self.imported_names.insert(c[1].to_string());
+            }
+        }
+    }
+
+    fn push_ref_at(&mut self, from_row: u32, name: &str, kind_code: u8, node: Node) {
+        self.push_ref(from_row, name, kind_code, self.line_of(node), self.col_of(node));
+    }
+
+    // --- createNode ------------------------------------------------------------
+
+    fn create_node(&mut self, kind: &'static str, name: &str, node: Node<'t>, extra: Extra) -> Option<u32> {
+        if name.is_empty() {
+            return None;
+        }
+        let start_line = self.line_of(node);
+        let id = ids::node_id(self.file_path, kind, name, start_line);
+        let end_line = node.end_position().row as u32 + 1; // no resolveBody for java
+
+        let qualified = {
+            let mut parts: Vec<&str> = Vec::new();
+            for s in &self.stack {
+                if s.kind != "file" {
+                    parts.push(&s.name);
+                }
+            }
+            let mut qn = parts.join("::");
+            if !qn.is_empty() {
+                qn.push_str("::");
+            }
+            qn.push_str(name);
+            qn
+        };
+
+        let mut flags = BoolFlags::default();
+        if let Some(v) = extra.is_static {
+            flags.set(FLAG_IS_STATIC, v);
+        }
+        let name_ref = self.arena.put(name);
+        let qn_ref = self.arena.put(&qualified);
+        let id_ref = self.arena.put(&id);
+        let doc_ref = opt_str(&mut self.arena, extra.docstring.as_deref());
+        let sig_ref = opt_str(&mut self.arena, extra.signature.as_deref());
+        let ret_ref = opt_str(&mut self.arena, extra.return_type.as_deref());
+        let dec_ref: StrRef = match &extra.decorators {
+            Some(list) if !list.is_empty() => self.arena.put_list(list),
+            _ => NONE_STR,
+        };
+        let row = self.tables.push_node(&NodeRow {
+            kind: node_kind_index(kind).unwrap(),
+            visibility: extra.visibility.unwrap_or(0),
+            flags,
+            start_line,
+            end_line,
+            start_column: self.col_of(node),
+            end_column: self.end_col_of(node),
+            name: name_ref,
+            qualified_name: qn_ref,
+            id: id_ref,
+            docstring: doc_ref,
+            signature: sig_ref,
+            decorators: dec_ref,
+            type_parameters: NONE_STR,
+            return_type: ret_ref,
+            extra_json: NONE_STR,
+        });
+        self.nodes_meta.push(NodeMeta { kind, name: name.to_string(), qualified_name: qualified });
+        self.node_ids.push(id);
+
+        let parent_row = self.top_row();
+        self.tables.push_edge(&EdgeRow {
+            source_idx: parent_row,
+            target_idx: row,
+            kind: edge_kind_index("contains").unwrap(),
+            provenance: 0,
+            line: NONE,
+            column: NONE,
+            metadata_json: NONE_STR,
+            source_id_str: NONE_STR,
+            target_id_str: NONE_STR,
+        });
+
+        if kind == "function" || kind == "method" {
+            self.defined_fn_names.insert(name.to_string());
+        }
+        self.capture_value_ref_scope(kind, name, row, node);
+        Some(row)
+    }
+
+    fn capture_value_ref_scope(&mut self, kind: &'static str, name: &str, row: u32, node: Node<'t>) {
+        let target_kind_ok = kind == "constant" || kind == "variable";
+        if target_kind_ok
+            && util::utf16_len(name) >= 3
+            && util::has_upper_or_underscore().is_match(name)
+        {
+            let parent_ok = self
+                .stack
+                .last()
+                .map(|s| matches!(s.kind, "file" | "class" | "module" | "struct" | "enum"))
+                .unwrap_or(false);
+            if parent_ok {
+                self.fs_values.insert(name.to_string(), row);
+                *self.fs_value_counts.entry(name.to_string()).or_insert(0) += 1;
+            }
+        }
+        if matches!(kind, "function" | "method" | "constant" | "variable") {
+            self.value_scopes.push(ValueScope { row, node, name: name.to_string() });
+        }
+    }
+
+    // --- modifiers / hooks (languages/java.ts) -----------------------------------
+
+    fn modifiers_child(&self, node: Node<'t>) -> Option<Node<'t>> {
+        (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| c.kind() == "modifiers")
+    }
+
+    fn visibility_of(&self, node: Node) -> Option<u8> {
+        for i in 0..node.child_count() {
+            let child = node.child(i)?;
+            if child.kind() == "modifiers" {
+                let text = self.text(child);
+                if text.contains("public") {
+                    return Some(1);
+                }
+                if text.contains("private") {
+                    return Some(2);
+                }
+                if text.contains("protected") {
+                    return Some(3);
+                }
+            }
+        }
+        None
+    }
+
+    fn is_static(&self, node: Node) -> bool {
+        for i in 0..node.child_count() {
+            if let Some(child) = node.child(i) {
+                if child.kind() == "modifiers" && self.text(child).contains("static") {
+                    return true;
+                }
+            }
+        }
+        false
+    }
+
+    /// javaExtractor.isConst: `static final` field → constant.
+    fn is_const(&self, node: Node) -> bool {
+        for i in 0..node.child_count() {
+            if let Some(child) = node.child(i) {
+                if child.kind() == "modifiers" {
+                    let text = self.text(child);
+                    return word_re("static").is_match(text) && word_re("final").is_match(text);
+                }
+            }
+        }
+        false
+    }
+
+    fn signature_of(&self, node: Node) -> Option<String> {
+        let params = node.child_by_field_name("parameters")?;
+        let params_text = self.text(params);
+        match node.child_by_field_name("type") {
+            Some(ret) => Some(format!("{} {}", self.text(ret), params_text)),
+            None => Some(params_text.to_string()),
+        }
+    }
+
+    /// normalizeJavaType (languages/java.ts).
+    fn normalize_java_type(&self, type_node: Option<Node>) -> Option<String> {
+        let t = type_node?;
+        if is_non_class_return(t.kind()) || t.kind() == "array_type" {
+            return None;
+        }
+        let raw = generic_args_re().replace_all(self.text(t).trim(), "").into_owned();
+        let last = raw.rsplit('.').next().unwrap_or("").trim().to_string();
+        if last.is_empty() || !simple_ident_re().is_match(&last) {
+            return None;
+        }
+        Some(last)
+    }
+
+    fn extract_name(&self, node: Node) -> String {
+        if let Some(name_node) = node.child_by_field_name("name") {
+            return self.text(name_node).to_string();
+        }
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                if matches!(c.kind(), "identifier" | "type_identifier" | "simple_identifier" | "constant") {
+                    return self.text(c).to_string();
+                }
+            }
+        }
+        "<anonymous>".to_string()
+    }
+
+    // --- the dispatcher (visitNode, Java-relevant branches) -----------------------
+
+    fn visit_node(&mut self, node: Node<'t>) {
+        let kind = node.kind();
+        let mut skip_children = false;
+
+        self.maybe_capture_fn_refs(node);
+
+        if kind == "class_declaration" {
+            self.extract_class(node);
+            skip_children = true;
+        } else if is_method_type(kind) {
+            self.extract_method(node);
+            skip_children = true;
+        } else if is_interface_type(kind) {
+            self.extract_interface(node);
+            skip_children = true;
+        } else if kind == "enum_declaration" {
+            self.extract_enum(node);
+            skip_children = true;
+        } else if kind == "field_declaration" && self.inside_class_like() {
+            self.extract_field(node);
+            self.scan_fn_ref_subtree(node, 0);
+            skip_children = true;
+        } else if kind == "local_variable_declaration" && !self.inside_class_like() {
+            self.extract_variable(node);
+            self.scan_fn_ref_subtree(node, 0);
+            skip_children = true;
+        } else if kind == "import_declaration" {
+            self.extract_import(node);
+        } else if kind == "method_invocation" {
+            self.extract_call(node);
+        } else if kind == "object_creation_expression" {
+            self.extract_instantiation(node);
+            if let Some(anon_body) = find_anonymous_class_body(node) {
+                self.extract_anonymous_class(node, anon_body);
+                skip_children = true;
+            }
+        }
+
+        if !skip_children {
+            for i in 0..node.named_child_count() {
+                if let Some(c) = node.named_child(i) {
+                    self.visit_node(c);
+                }
+            }
+        }
+    }
+
+    // --- visitFunctionBody ----------------------------------------------------------
+
+    fn visit_function_body(&mut self, body: Node<'t>) {
+        self.visit_for_calls_and_structure(body);
+    }
+
+    fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        let kind = node.kind();
+        self.maybe_capture_fn_refs(node);
+
+        if kind == "method_invocation" {
+            self.extract_call(node);
+        } else if kind == "object_creation_expression" {
+            self.extract_instantiation(node);
+            if let Some(anon_body) = find_anonymous_class_body(node) {
+                self.extract_anonymous_class(node, anon_body);
+                return;
+            }
+        }
+
+        // Static-member / value-read (`Type.CONST`) — self-gates on field_access.
+        self.extract_static_member_ref(node);
+
+        if kind == "class_declaration" {
+            self.extract_class(node);
+            return;
+        }
+        if kind == "enum_declaration" {
+            self.extract_enum(node);
+            return;
+        }
+        if is_interface_type(kind) {
+            self.extract_interface(node);
+            return;
+        }
+
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                self.visit_for_calls_and_structure(c);
+            }
+        }
+    }
+
+    // --- extractors --------------------------------------------------------------
+
+    fn extract_class(&mut self, node: Node<'t>) {
+        let name = self.extract_name(node);
+        let extra = Extra {
+            docstring: preceding_docstring(node, self.src),
+            visibility: self.visibility_of(node),
+            ..Extra::default() // java has no isExported hook
+        };
+        let Some(row) = self.create_node("class", &name, node, extra) else { return };
+        self.extract_inheritance(node, row);
+        self.extract_decorators_for(node, row);
+
+        self.stack.push(Scope { row, kind: "class", name });
+        let body = node.child_by_field_name("body").unwrap_or(node);
+        for i in 0..body.named_child_count() {
+            if let Some(c) = body.named_child(i) {
+                self.visit_node(c);
+            }
+        }
+        // Lombok member synthesis (#912) — class still on the stack.
+        self.synthesize_lombok_members(node, row);
+        self.stack.pop();
+    }
+
+    fn extract_method(&mut self, node: Node<'t>) {
+        if !self.inside_class_like() {
+            // (object-literal parents don't exist in Java; a stray top-level
+            // method extracts as a function, mirroring extractMethod's tail)
+            self.extract_function(node);
+            return;
+        }
+        let name = self.extract_name(node);
+        let extra = Extra {
+            docstring: preceding_docstring(node, self.src),
+            signature: self.signature_of(node),
+            visibility: self.visibility_of(node),
+            is_static: Some(self.is_static(node)),
+            return_type: self.normalize_java_type(node.child_by_field_name("type")),
+            ..Extra::default()
+        };
+        let Some(row) = self.create_node("method", &name, node, extra) else { return };
+        self.extract_type_annotations(node, row);
+        self.extract_decorators_for(node, row);
+        self.stack.push(Scope { row, kind: "method", name });
+        if let Some(body) = node.child_by_field_name("body") {
+            self.visit_function_body(body);
+        }
+        self.stack.pop();
+    }
+
+    /// extractFunction — only reachable for a method outside any class.
+    fn extract_function(&mut self, node: Node<'t>) {
+        let name = self.extract_name(node);
+        if name == "<anonymous>" {
+            if let Some(body) = node.child_by_field_name("body") {
+                self.visit_function_body(body);
+            }
+            return;
+        }
+        let extra = Extra {
+            docstring: preceding_docstring(node, self.src),
+            signature: self.signature_of(node),
+            visibility: self.visibility_of(node),
+            is_static: Some(self.is_static(node)),
+            return_type: self.normalize_java_type(node.child_by_field_name("type")),
+            ..Extra::default()
+        };
+        let Some(row) = self.create_node("function", &name, node, extra) else { return };
+        self.extract_type_annotations(node, row);
+        self.extract_decorators_for(node, row);
+        self.stack.push(Scope { row, kind: "function", name });
+        if let Some(body) = node.child_by_field_name("body") {
+            self.visit_function_body(body);
+        }
+        self.stack.pop();
+    }
+
+    fn extract_interface(&mut self, node: Node<'t>) {
+        let name = self.extract_name(node);
+        let extra = Extra {
+            docstring: preceding_docstring(node, self.src),
+            ..Extra::default()
+        };
+        let Some(row) = self.create_node("interface", &name, node, extra) else { return };
+        self.extract_inheritance(node, row);
+        self.stack.push(Scope { row, kind: "interface", name });
+        let body = node.child_by_field_name("body").unwrap_or(node);
+        for i in 0..body.named_child_count() {
+            if let Some(c) = body.named_child(i) {
+                self.visit_node(c);
+            }
+        }
+        self.stack.pop();
+    }
+
+    fn extract_enum(&mut self, node: Node<'t>) {
+        let Some(body) = node.child_by_field_name("body") else { return };
+        let name = self.extract_name(node);
+        let extra = Extra {
+            docstring: preceding_docstring(node, self.src),
+            visibility: self.visibility_of(node),
+            ..Extra::default()
+        };
+        let Some(row) = self.create_node("enum", &name, node, extra) else { return };
+        self.extract_inheritance(node, row);
+        self.stack.push(Scope { row, kind: "enum", name });
+        for i in 0..body.named_child_count() {
+            let Some(child) = body.named_child(i) else { continue };
+            if child.kind() == "enum_constant" {
+                self.extract_enum_members(child);
+            } else {
+                self.visit_node(child);
+            }
+        }
+        self.stack.pop();
+    }
+
+    fn extract_enum_members(&mut self, node: Node<'t>) {
+        if let Some(name_node) = node.child_by_field_name("name") {
+            let name = self.text(name_node).to_string();
+            self.create_node("enum_member", &name, node, Extra::default());
+        }
+        // (identifier-children / leaf fallbacks are other grammars' shapes)
+    }
+
+    /// extractField — each declarator becomes a field/constant node.
+    fn extract_field(&mut self, node: Node<'t>) {
+        let docstring = preceding_docstring(node, self.src);
+        let visibility = self.visibility_of(node);
+        let is_static = Some(self.is_static(node));
+        let field_kind: &'static str = if self.is_const(node) { "constant" } else { "field" };
+
+        let declarators: Vec<Node> = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .filter(|c| c.kind() == "variable_declarator")
+            .collect();
+
+        if !declarators.is_empty() {
+            let type_node = (0..node.named_child_count())
+                .filter_map(|i| node.named_child(i))
+                .find(|c| {
+                    !matches!(
+                        c.kind(),
+                        "modifiers" | "modifier" | "variable_declarator" | "variable_declaration"
+                            | "marker_annotation" | "annotation"
+                    )
+                });
+            let type_text = type_node.map(|t| self.text(t).to_string());
+
+            for decl in declarators {
+                let name_node = decl.child_by_field_name("name").or_else(|| {
+                    (0..decl.named_child_count())
+                        .filter_map(|i| decl.named_child(i))
+                        .find(|c| c.kind() == "identifier")
+                });
+                let Some(name_node) = name_node else { continue };
+                let name = self.text(name_node).to_string();
+                let signature = match &type_text {
+                    Some(t) => format!("{t} {name}"),
+                    None => name.clone(),
+                };
+                let row = self.create_node(
+                    field_kind,
+                    &name,
+                    decl,
+                    Extra {
+                        docstring: docstring.clone(),
+                        signature: Some(signature),
+                        visibility,
+                        is_static,
+                        ..Extra::default()
+                    },
+                );
+                if let Some(row) = row {
+                    self.extract_decorators_for(node, row);
+                    self.extract_type_annotations(node, row);
+                }
+            }
+        } else {
+            let name_node = node.child_by_field_name("name").or_else(|| {
+                (0..node.named_child_count())
+                    .filter_map(|i| node.named_child(i))
+                    .find(|c| c.kind() == "identifier")
+            });
+            if let Some(name_node) = name_node {
+                let name = self.text(name_node).to_string();
+                self.create_node(
+                    field_kind,
+                    &name,
+                    node,
+                    Extra { docstring, visibility, is_static, ..Extra::default() },
+                );
+            }
+        }
+    }
+
+    /// extractVariable's generic fallback (top-level locals — rare in Java).
+    fn extract_variable(&mut self, node: Node<'t>) {
+        let kind: &'static str = if self.is_const(node) { "constant" } else { "variable" };
+        let docstring = preceding_docstring(node, self.src);
+        for i in 0..node.named_child_count() {
+            let Some(child) = node.named_child(i) else { continue };
+            let name = match child.kind() {
+                "identifier" => self.text(child).to_string(),
+                "variable_declarator" => self.extract_name(child),
+                _ => continue,
+            };
+            if name.is_empty() || name == "<anonymous>" {
+                continue;
+            }
+            self.create_node(
+                kind,
+                &name,
+                child,
+                Extra { docstring: docstring.clone(), ..Extra::default() },
+            );
+        }
+    }
+
+    fn extract_import(&mut self, node: Node<'t>) {
+        let import_text = self.text(node).trim().to_string();
+        let scoped = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| c.kind() == "scoped_identifier");
+        let Some(scoped) = scoped else { return }; // hook declined
+        let module_name = self.text(scoped).to_string();
+        if module_name.is_empty() {
+            return;
+        }
+        self.create_node(
+            "import",
+            &module_name,
+            node,
+            Extra { signature: Some(import_text), ..Extra::default() },
+        );
+        let parent = self.top_row();
+        self.push_ref_at(parent, &module_name.clone(), edge_kind_index("imports").unwrap(), node);
+    }
+
+    /// extractCall — the Java method_invocation paths.
+    fn extract_call(&mut self, node: Node<'t>) {
+        if self.stack.is_empty() {
+            return;
+        }
+        let caller = self.top_row();
+        let name_field = node.child_by_field_name("name");
+        let object_field = node
+            .child_by_field_name("object")
+            .or_else(|| node.child_by_field_name("scope"));
+
+        let mut callee_name = String::new();
+        if let (Some(name_field), Some(object_field)) = (name_field, object_field) {
+            let method_name = self.text(name_field);
+
+            // Static-factory / fluent chain: `Foo.getInstance().bar()` →
+            // `<inner-receiver>.<inner-method>().<method>` (#645/#608).
+            if !method_name.is_empty() && object_field.kind() == "method_invocation" {
+                let inner_obj = object_field.child_by_field_name("object");
+                let inner_name = object_field.child_by_field_name("name");
+                if let (Some(io), Some(inm)) = (inner_obj, inner_name) {
+                    let callee = format!("{}.{}().{}", self.text(io), self.text(inm), method_name);
+                    self.push_ref_at(caller, &callee, edge_kind_index("calls").unwrap(), node);
+                    return;
+                }
+            }
+
+            // `this.userbo.toLogin2()` — unwrap the field after `this.`.
+            let receiver_name = if object_field.kind() == "field_access" {
+                let inner = object_field.child_by_field_name("object");
+                let fld = object_field.child_by_field_name("field");
+                match (inner, fld) {
+                    (Some(inner), Some(fld))
+                        if matches!(inner.kind(), "this" | "this_expression") =>
+                    {
+                        self.text(fld).to_string()
+                    }
+                    _ => self.text(object_field).to_string(),
+                }
+            } else {
+                self.text(object_field).to_string()
+            };
+            let receiver_name = receiver_name.strip_prefix('$').unwrap_or(&receiver_name);
+
+            if !method_name.is_empty() {
+                if matches!(receiver_name, "self" | "this" | "cls" | "super" | "parent" | "static") {
+                    callee_name = method_name.to_string();
+                } else {
+                    callee_name = format!("{receiver_name}.{method_name}");
+                }
+            }
+        } else {
+            // Bare call `foo()` — the generic tail: function field ?? first child.
+            let func = node
+                .child_by_field_name("function")
+                .or_else(|| node.named_child(0));
+            if let Some(func) = func {
+                callee_name = self.text(func).to_string();
+            }
+        }
+
+        if !callee_name.is_empty() {
+            if let Some(c) = util::paren_conversion().captures(&callee_name) {
+                callee_name = c[1].to_string();
+            }
+            self.push_ref_at(caller, &callee_name.clone(), edge_kind_index("calls").unwrap(), node);
+        }
+    }
+
+    fn extract_instantiation(&mut self, node: Node<'t>) {
+        if self.stack.is_empty() {
+            return;
+        }
+        let ctor = node
+            .child_by_field_name("constructor")
+            .or_else(|| node.child_by_field_name("type"))
+            .or_else(|| node.child_by_field_name("name"))
+            .or_else(|| node.named_child(0));
+        let Some(ctor) = ctor else { return };
+        let class_name = strip_generic_and_qualifier(self.text(ctor));
+        if !class_name.is_empty() {
+            let from = self.top_row();
+            self.push_ref_at(from, &class_name, edge_kind_index("instantiates").unwrap(), node);
+        }
+    }
+
+    /// extractAnonymousClass — `new T() { ... }`.
+    fn extract_anonymous_class(&mut self, node: Node<'t>, body: Node<'t>) {
+        let type_node = node
+            .child_by_field_name("constructor")
+            .or_else(|| node.child_by_field_name("type"))
+            .or_else(|| node.child_by_field_name("name"))
+            .or_else(|| node.named_child(0));
+        let mut type_name = type_node.map(|t| self.text(t).to_string()).unwrap_or_else(|| "Object".to_string());
+        type_name = strip_generic_and_qualifier(&type_name);
+        if type_name.is_empty() {
+            type_name = "Object".to_string();
+        }
+
+        let anon_name = format!("<{type_name}$anon@{}>", node.start_position().row + 1);
+        let Some(row) = self.create_node("class", &anon_name, node, Extra::default()) else {
+            return;
+        };
+        // Bug-for-bug: the TS code uses `startPosition.row` (0-based) as the
+        // LINE here — the one place it forgets the +1.
+        let (line, column) = match type_node {
+            Some(t) => (t.start_position().row as u32, self.col_of(t)),
+            None => (node.start_position().row as u32, self.col_of(node)),
+        };
+        self.push_ref(row, &type_name, edge_kind_index("extends").unwrap(), line, column);
+
+        self.stack.push(Scope { row, kind: "class", name: anon_name });
+        for i in 0..body.named_child_count() {
+            if let Some(c) = body.named_child(i) {
+                self.visit_node(c);
+            }
+        }
+        self.stack.pop();
+    }
+
+    /// extractStaticMemberRef — `Type.CONST` value reads (java: field_access).
+    fn extract_static_member_ref(&mut self, node: Node<'t>) {
+        if node.kind() != "field_access" {
+            return;
+        }
+        if self.stack.is_empty() {
+            return;
+        }
+        let owner = self.top_row();
+        // Skip `Type.method()` — the access is a call's callee, already linked.
+        if let Some(parent) = node.parent() {
+            if parent.kind() == "method_invocation" {
+                let callee = parent
+                    .child_by_field_name("function")
+                    .or_else(|| parent.child_by_field_name("method"))
+                    .or_else(|| parent.named_child(0));
+                if let Some(callee) = callee {
+                    if callee.start_byte() == node.start_byte() {
+                        return;
+                    }
+                }
+            }
+        }
+        let recv = node
+            .child_by_field_name("object")
+            .or_else(|| node.child_by_field_name("expression"))
+            .or_else(|| node.child_by_field_name("scope"))
+            .or_else(|| node.named_child(0));
+        let Some(recv) = recv else { return };
+        if matches!(
+            recv.kind(),
+            "identifier" | "type_identifier" | "simple_identifier" | "name" | "scoped_type_identifier"
+        ) {
+            let text = self.text(recv);
+            if capitalized_re().is_match(text) {
+                self.push_ref_at(owner, &text.to_string(), edge_kind_index("references").unwrap(), recv);
+            }
+        }
+    }
+
+    /// extractInheritance — the Java clauses (type_list-aware).
+    fn extract_inheritance(&mut self, node: Node<'t>, class_row: u32) {
+        let extends_kind = edge_kind_index("extends").unwrap();
+        let implements_kind = edge_kind_index("implements").unwrap();
+        for i in 0..node.named_child_count() {
+            let Some(child) = node.named_child(i) else { continue };
+            match child.kind() {
+                "superclass" | "extends_interfaces" => {
+                    let type_list = (0..child.named_child_count())
+                        .filter_map(|j| child.named_child(j))
+                        .find(|c| c.kind() == "type_list");
+                    let targets: Vec<Node> = match type_list {
+                        Some(tl) => (0..tl.named_child_count()).filter_map(|j| tl.named_child(j)).collect(),
+                        None => child.named_child(0).into_iter().collect(),
+                    };
+                    for target in targets {
+                        let name = self.text(target).to_string();
+                        self.push_ref_at(class_row, &name, extends_kind, target);
+                    }
+                }
+                "super_interfaces" => {
+                    let type_list = (0..child.named_child_count())
+                        .filter_map(|j| child.named_child(j))
+                        .find(|c| c.kind() == "type_list");
+                    let targets: Vec<Node> = match type_list {
+                        Some(tl) => (0..tl.named_child_count()).filter_map(|j| tl.named_child(j)).collect(),
+                        None => (0..child.named_child_count()).filter_map(|j| child.named_child(j)).collect(),
+                    };
+                    for iface in targets {
+                        let name = self.text(iface).to_string();
+                        self.push_ref_at(class_row, &name, implements_kind, iface);
+                    }
+                }
+                _ => {}
+            }
+        }
+    }
+
+    /// extractDecoratorsFor — Java annotations live inside `modifiers`.
+    fn extract_decorators_for(&mut self, decl: Node<'t>, decorated_row: u32) {
+        for i in 0..decl.named_child_count() {
+            let Some(child) = decl.named_child(i) else { continue };
+            self.consider_decorator(child, decorated_row);
+            if child.kind() == "modifiers" {
+                for j in 0..child.named_child_count() {
+                    if let Some(m) = child.named_child(j) {
+                        self.consider_decorator(m, decorated_row);
+                    }
+                }
+            }
+        }
+        // Preceding-sibling scan (TS-style class decorators) — Java annotations
+        // are inside modifiers, so this is inert here; kept for parity of shape.
+        let Some(parent) = decl.parent() else { return };
+        let decl_start = decl.start_byte();
+        let mut decl_idx: isize = -1;
+        for i in 0..parent.named_child_count() {
+            if let Some(sib) = parent.named_child(i) {
+                if sib.start_byte() == decl_start {
+                    decl_idx = i as isize;
+                    break;
+                }
+            }
+        }
+        if decl_idx > 0 {
+            let mut j = decl_idx - 1;
+            while j >= 0 {
+                let Some(sib) = parent.named_child(j as usize) else {
+                    j -= 1;
+                    continue;
+                };
+                if !matches!(sib.kind(), "decorator" | "annotation" | "marker_annotation") {
+                    break;
+                }
+                self.consider_decorator(sib, decorated_row);
+                j -= 1;
+            }
+        }
+    }
+
+    fn consider_decorator(&mut self, n: Node<'t>, decorated_row: u32) {
+        if !matches!(n.kind(), "decorator" | "annotation" | "marker_annotation" | "attribute") {
+            return;
+        }
+        let mut target: Option<Node> = None;
+        for i in 0..n.named_child_count() {
+            let Some(child) = n.named_child(i) else { continue };
+            if child.kind() == "call_expression" {
+                target = child.child_by_field_name("function").or_else(|| child.named_child(0));
+                if target.is_some() {
+                    break;
+                }
+            }
+            if matches!(
+                child.kind(),
+                "identifier" | "member_expression" | "scoped_identifier" | "navigation_expression"
+                    | "user_type" | "type_identifier"
+            ) {
+                target = Some(child);
+                break;
+            }
+        }
+        let Some(target) = target else { return };
+        let name = strip_generic_and_qualifier(self.text(target));
+        if name.is_empty() {
+            return;
+        }
+        self.push_ref_at(decorated_row, &name, edge_kind_index("decorates").unwrap(), n);
+    }
+
+    /// extractTypeAnnotations — Java's returnField is `type`.
+    fn extract_type_annotations(&mut self, node: Node<'t>, from_row: u32) {
+        if let Some(params) = node.child_by_field_name("parameters") {
+            self.extract_type_refs_from_subtree(params, from_row);
+        }
+        if let Some(ret) = node.child_by_field_name("type") {
+            self.extract_type_refs_from_subtree(ret, from_row);
+        }
+        let type_annotation = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| c.kind() == "type_annotation");
+        if let Some(ta) = type_annotation {
+            self.extract_type_refs_from_subtree(ta, from_row);
+        }
+    }
+
+    fn extract_type_refs_from_subtree(&mut self, node: Node<'t>, from_row: u32) {
+        if node.kind() == "type_identifier" {
+            let type_name = self.text(node).to_string();
+            if !type_name.is_empty() && !is_builtin_type(&type_name) {
+                self.push_ref_at(from_row, &type_name, edge_kind_index("references").unwrap(), node);
+            }
+            return;
+        }
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                self.extract_type_refs_from_subtree(c, from_row);
+            }
+        }
+    }
+
+    // --- function-as-value refs (JAVA_SPEC: method references only) ----------------
+
+    fn maybe_capture_fn_refs(&mut self, node: Node<'t>) {
+        let mode_field: Option<&str> = match node.kind() {
+            "argument_list" => Some(""),          // args: every named child
+            "assignment_expression" => Some("right"),
+            "variable_declarator" => Some("value"),
+            _ => None,
+        };
+        let Some(field) = mode_field else { return };
+        if self.stack.is_empty() {
+            return;
+        }
+        let from = self.top_row();
+
+        let mut values: Vec<Node> = Vec::new();
+        if field.is_empty() {
+            for i in 0..node.named_child_count() {
+                if let Some(c) = node.named_child(i) {
+                    values.push(c);
+                }
+            }
+        } else if field == "right" {
+            if let Some(rhs) = node.child_by_field_name("right") {
+                let lhs_text = node
+                    .child_by_field_name("left")
+                    .map(|l| self.text(l))
+                    .unwrap_or("");
+                let lhs_last = util::lhs_last_name()
+                    .captures(lhs_text)
+                    .and_then(|c| c.get(1))
+                    .map(|m| m.as_str());
+                if !(lhs_last.is_some() && lhs_last == Some(self.text(rhs).trim())) {
+                    values.push(rhs);
+                }
+            }
+        } else if let Some(v) = node.child_by_field_name("value") {
+            // varinit — destructuring patterns don't exist in Java.
+            values.push(v);
+        }
+
+        for v in values {
+            if v.kind() != "method_reference" {
+                continue; // idTypes is EMPTY for Java — only method references
+            }
+            let mut last_ident: Option<Node> = None;
+            for i in 0..v.named_child_count() {
+                if let Some(c) = v.named_child(i) {
+                    if c.kind() == "identifier" {
+                        last_ident = Some(c);
+                    }
+                }
+            }
+            let Some(last) = last_ident else { continue };
+            let m = self.text(last);
+            let text = self.text(v);
+            let name = if text.starts_with("this::") || text.starts_with("super::") {
+                format!("this.{m}")
+            } else if let Some(c) = method_ref_type_re().captures(text) {
+                if m == "new" {
+                    continue;
+                }
+                format!("{}::{m}", &c[1])
+            } else {
+                continue;
+            };
+            let p = last.start_position();
+            self.fn_ref_cands.push(Cand {
+                from,
+                name,
+                line: p.row as u32 + 1,
+                column_byte: last.start_byte(),
+                row: p.row,
+            });
+        }
+    }
+
+    fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        if depth > 12 {
+            return;
+        }
+        // (functionTypes is empty for Java; lambda_expression halts the scan)
+        if depth > 0 && matches!(node.kind(), "lambda_literal" | "lambda_expression") {
+            return;
+        }
+        self.maybe_capture_fn_refs(node);
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                self.scan_fn_ref_subtree(c, depth + 1);
+            }
+        }
+    }
+
+    fn flush_fn_ref_candidates(&mut self) {
+        let cands = std::mem::take(&mut self.fn_ref_cands);
+        if cands.is_empty() || util::is_generated_file(self.file_path) {
+            return;
+        }
+        let mut seen: HashSet<(String, String)> = HashSet::new();
+        for c in cands {
+            if !c.name.starts_with("this.")
+                && !c.name.contains("::")
+                && !self.defined_fn_names.contains(&c.name)
+                && !self.imported_names.contains(&c.name)
+            {
+                continue;
+            }
+            // Dedupe on the node ID string (ids collide; the TS side keys on
+            // `${fromNodeId}|${name}`).
+            if !seen.insert((self.node_ids[c.from as usize].clone(), c.name.clone())) {
+                continue;
+            }
+            let column = util::col16(self.src, &self.line_starts, c.row, c.column_byte);
+            let name_ref = self.arena.put(&c.name);
+            self.tables.push_ref(&RefRow {
+                from_idx: c.from,
+                kind: FUNCTION_REF_CODE,
+                line: c.line,
+                column,
+                reference_name: name_ref,
+                candidates: NONE_STR,
+                from_id_str: NONE_STR,
+            });
+        }
+    }
+
+    // --- value references ------------------------------------------------------------
+
+    fn flush_value_refs(&mut self, root: Node<'t>) {
+        let scopes = std::mem::take(&mut self.value_scopes);
+        let mut targets = std::mem::take(&mut self.fs_values);
+        let counts = std::mem::take(&mut self.fs_value_counts);
+        if std::env::var("CODEGRAPH_VALUE_REFS").as_deref() == Ok("0") {
+            return;
+        }
+        if targets.is_empty() || scopes.is_empty() || util::is_generated_file(self.file_path) {
+            return;
+        }
+
+        let mut decl_counts: HashMap<&str, u32> = HashMap::new();
+        let mut dstack: Vec<Node> = vec![root];
+        let mut dvisited = 0usize;
+        while let Some(n) = dstack.pop() {
+            if dvisited >= MAX_VALUE_REF_NODES {
+                break;
+            }
+            dvisited += 1;
+            if n.kind() == "variable_declarator" {
+                if let Some(first) = n.named_child(0) {
+                    if first.kind() == "identifier" {
+                        let nm = self.text(first);
+                        if targets.contains_key(nm) {
+                            *decl_counts.entry(nm).or_insert(0) += 1;
+                        }
+                    }
+                }
+            }
+            for i in 0..n.named_child_count() {
+                if let Some(c) = n.named_child(i) {
+                    dstack.push(c);
+                }
+            }
+        }
+        let shadowed: Vec<String> = decl_counts
+            .iter()
+            .filter(|(nm, c)| **c > counts.get(**nm).copied().unwrap_or(1))
+            .map(|(nm, _)| nm.to_string())
+            .collect();
+        for nm in shadowed {
+            targets.remove(&nm);
+        }
+        if targets.is_empty() {
+            return;
+        }
+
+        let refs_kind = edge_kind_index("references").unwrap();
+        for scope in &scopes {
+            // ID-string comparisons, matching the TS side (ids collide).
+            let mut seen: HashSet<&str> = HashSet::new();
+            let mut stack: Vec<Node> = vec![scope.node];
+            let mut visited = 0usize;
+            while let Some(n) = stack.pop() {
+                if visited >= MAX_VALUE_REF_NODES {
+                    break;
+                }
+                visited += 1;
+                if matches!(n.kind(), "identifier" | "constant" | "name" | "simple_identifier") {
+                    let ref_name = self.text(n);
+                    if let Some(&target_row) = targets.get(ref_name) {
+                        let target_id = self.node_ids[target_row as usize].as_str();
+                        if target_id != self.node_ids[scope.row as usize]
+                            && ref_name != scope.name
+                            && !seen.contains(&target_id)
+                        {
+                            seen.insert(target_id);
+                            let meta = self.arena.put(r#"{"valueRef":true}"#);
+                            self.tables.push_edge(&EdgeRow {
+                                source_idx: scope.row,
+                                target_idx: target_row,
+                                kind: refs_kind,
+                                provenance: 0,
+                                line: NONE,
+                                column: NONE,
+                                metadata_json: meta,
+                                source_id_str: NONE_STR,
+                                target_id_str: NONE_STR,
+                            });
+                        }
+                    }
+                }
+                for i in 0..n.named_child_count() {
+                    if let Some(c) = n.named_child(i) {
+                        stack.push(c);
+                    }
+                }
+            }
+        }
+    }
+
+    // --- Lombok synthesis (#912, languages/java.ts synthesizeLombokMembers) ------------
+
+    fn lombok_annotation_names(&self, node: Node<'t>) -> HashSet<String> {
+        let mut names = HashSet::new();
+        let Some(modifiers) = self.modifiers_child(node) else { return names };
+        for i in 0..modifiers.named_child_count() {
+            let Some(child) = modifiers.named_child(i) else { continue };
+            if matches!(child.kind(), "marker_annotation" | "annotation") {
+                if let Some(name_node) = child.child_by_field_name("name") {
+                    if let Some(simple) = self.text(name_node).trim().rsplit('.').next() {
+                        if !simple.is_empty() {
+                            names.insert(simple.to_string());
+                        }
+                    }
+                }
+            }
+        }
+        names
+    }
+
+    fn synthesize_lombok_members(&mut self, class_node: Node<'t>, class_row: u32) {
+        let class_anns = self.lombok_annotation_names(class_node);
+        let class_getter = class_anns.contains("Getter");
+        let class_setter = class_anns.contains("Setter");
+        let is_data = class_anns.contains("Data");
+        let is_value = class_anns.contains("Value");
+        let has_builder = class_anns.contains("Builder") || class_anns.contains("SuperBuilder");
+        let has_to_string = is_data || is_value || class_anns.contains("ToString");
+        let has_equals = is_data || is_value || class_anns.contains("EqualsAndHashCode");
+        let log_ann = class_anns.iter().find(|a| is_lombok_log_annotation(a)).cloned();
+
+        let Some(body) = class_node.child_by_field_name("body") else { return };
+        let fields: Vec<Node> = (0..body.named_child_count())
+            .filter_map(|i| body.named_child(i))
+            .filter(|c| c.kind() == "field_declaration")
+            .collect();
+
+        let class_has_lombok = class_getter
+            || class_setter
+            || is_data
+            || is_value
+            || has_builder
+            || has_to_string
+            || has_equals
+            || log_ann.is_some();
+        if !class_has_lombok && !fields.iter().any(|f| !self.lombok_annotation_names(*f).is_empty()) {
+            return;
+        }
+
+        // Members the source already declares (exact `classQN::name` matches).
+        let class_qn = self.nodes_meta[class_row as usize].qualified_name.clone();
+        let class_name = self.nodes_meta[class_row as usize].name.clone();
+        let mut taken_methods: HashSet<String> = HashSet::new();
+        let mut taken_fields: HashSet<String> = HashSet::new();
+        for m in &self.nodes_meta {
+            if m.qualified_name == format!("{class_qn}::{}", m.name) {
+                match m.kind {
+                    "method" | "function" => {
+                        taken_methods.insert(m.name.clone());
+                    }
+                    "field" | "variable" | "constant" | "property" => {
+                        taken_fields.insert(m.name.clone());
+                    }
+                    _ => {}
+                }
+            }
+        }
+
+        let class_name_node = class_node.child_by_field_name("name").unwrap_or(class_node);
+
+        macro_rules! emit_method {
+            ($name:expr, $anchor:expr, $sig:expr, $from:expr, $is_static:expr, $ret:expr) => {{
+                let name: String = $name;
+                if !name.is_empty() && !taken_methods.contains(&name) {
+                    taken_methods.insert(name.clone());
+                    self.create_node(
+                        "method",
+                        &name,
+                        $anchor,
+                        Extra {
+                            visibility: Some(1),
+                            signature: Some($sig),
+                            docstring: Some(format!("Lombok-generated ({})", $from)),
+                            decorators: Some(vec!["lombok".to_string()]),
+                            is_static: $is_static,
+                            return_type: $ret,
+                        },
+                    );
+                }
+            }};
+        }
+
+        // Per-field getters/setters.
+        for fd in &fields {
+            let mods = self
+                .modifiers_child(*fd)
+                .map(|m| self.text(m))
+                .unwrap_or("");
+            if word_re("static").is_match(mods) {
+                continue;
+            }
+            let is_final = word_re("final").is_match(mods);
+            let field_anns = self.lombok_annotation_names(*fd);
+            let field_getter = field_anns.contains("Getter");
+            let field_setter = field_anns.contains("Setter");
+
+            let want_getter = class_getter || is_data || is_value || field_getter;
+            let want_setter = (class_setter || is_data || field_setter) && !is_final;
+            if !want_getter && !want_setter {
+                continue;
+            }
+
+            let type_node = fd.child_by_field_name("type");
+            let type_text = type_node
+                .map(|t| self.text(t).trim().to_string())
+                .unwrap_or_else(|| "Object".to_string());
+            let is_boolean_primitive = type_node.map(|t| t.kind() == "boolean_type").unwrap_or(false);
+            let return_type = self.normalize_java_type(type_node);
+
+            for i in 0..fd.named_child_count() {
+                let Some(vd) = fd.named_child(i) else { continue };
+                if vd.kind() != "variable_declarator" {
+                    continue;
+                }
+                let Some(name_node) = vd.child_by_field_name("name") else { continue };
+                let field_name = self.text(name_node).trim().to_string();
+                if field_name.is_empty() {
+                    continue;
+                }
+
+                if want_getter {
+                    let g = if is_boolean_primitive {
+                        if is_prefix_re(&field_name) {
+                            field_name.clone()
+                        } else {
+                            format!("is{}", capitalize(&field_name))
+                        }
+                    } else {
+                        format!("get{}", capitalize(&field_name))
+                    };
+                    let from = if field_getter {
+                        "@Getter"
+                    } else if is_data {
+                        "@Data"
+                    } else if is_value {
+                        "@Value"
+                    } else {
+                        "@Getter"
+                    };
+                    emit_method!(g.clone(), name_node, format!("{type_text} {g}()"), from, None, return_type.clone());
+                }
+                if want_setter {
+                    let base = if is_boolean_primitive && is_prefix_re(&field_name) {
+                        field_name[2..].to_string()
+                    } else {
+                        field_name.clone()
+                    };
+                    let s = format!("set{}", capitalize(&base));
+                    let from = if field_setter {
+                        "@Setter"
+                    } else if is_data {
+                        "@Data"
+                    } else {
+                        "@Setter"
+                    };
+                    emit_method!(s.clone(), name_node, format!("void {s}({type_text} {field_name})"), from, None, None);
+                }
+            }
+        }
+
+        // Class-level synthesized methods.
+        if has_builder {
+            let from = if class_anns.contains("SuperBuilder") { "@SuperBuilder" } else { "@Builder" };
+            emit_method!(
+                "builder".to_string(),
+                class_name_node,
+                format!("static {class_name}.{class_name}Builder builder()"),
+                from,
+                Some(true),
+                Some(format!("{class_name}Builder"))
+            );
+        }
+        if has_to_string {
+            let from = if is_data { "@Data" } else if is_value { "@Value" } else { "@ToString" };
+            emit_method!("toString".to_string(), class_name_node, "String toString()".to_string(), from, None, None);
+        }
+        if has_equals {
+            let from = if is_data { "@Data" } else if is_value { "@Value" } else { "@EqualsAndHashCode" };
+            emit_method!("equals".to_string(), class_name_node, "boolean equals(Object o)".to_string(), from, None, None);
+            emit_method!("hashCode".to_string(), class_name_node, "int hashCode()".to_string(), from, None, None);
+        }
+
+        // Logger field (@Slf4j and friends).
+        if let Some(log_ann) = log_ann {
+            if !taken_fields.contains("log") {
+                self.create_node(
+                    "field",
+                    "log",
+                    class_name_node,
+                    Extra {
+                        visibility: Some(2),
+                        is_static: Some(true),
+                        signature: Some("Logger log".to_string()),
+                        docstring: Some(format!("Lombok-generated (@{log_ann})")),
+                        decorators: Some(vec!["lombok".to_string()]),
+                        ..Extra::default()
+                    },
+                );
+            }
+        }
+    }
+}
+
+fn find_anonymous_class_body(node: Node) -> Option<Node> {
+    for i in 0..node.named_child_count() {
+        if let Some(child) = node.named_child(i) {
+            if matches!(child.kind(), "class_body" | "declaration_list") {
+                return Some(child);
+            }
+        }
+    }
+    None
+}
+
+/// The `new ns.Foo<T>()` name normalization shared by instantiation /
+/// anonymous-class / decorator extraction: strip `<...` from the first `<`
+/// (index > 0), keep the segment after the last `.`/`::`, strip ONE leading
+/// `:` or `.`, trim.
+fn strip_generic_and_qualifier(raw: &str) -> String {
+    let mut name = raw.to_string();
+    if let Some(lt) = name.find('<') {
+        if lt > 0 {
+            name.truncate(lt);
+        }
+    }
+    let last_dot = name
+        .rfind('.')
+        .map(|i| i as isize)
+        .unwrap_or(-1)
+        .max(name.rfind("::").map(|i| i as isize).unwrap_or(-1));
+    if last_dot >= 0 {
+        name = name[(last_dot as usize + 1)..].to_string();
+        if name.starts_with(':') || name.starts_with('.') {
+            name.remove(0);
+        }
+    }
+    name.trim().to_string()
+}
+
+fn capitalize(name: &str) -> String {
+    let mut chars = name.chars();
+    match chars.next() {
+        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
+        None => String::new(),
+    }
+}
+
+/// `\bword\b` matcher (modifier keyword tests in languages/java.ts).
+fn word_re(word: &'static str) -> &'static Regex {
+    static STATIC_RE: OnceLock<Regex> = OnceLock::new();
+    static FINAL_RE: OnceLock<Regex> = OnceLock::new();
+    match word {
+        "static" => STATIC_RE.get_or_init(|| Regex::new(r"\bstatic\b").unwrap()),
+        "final" => FINAL_RE.get_or_init(|| Regex::new(r"\bfinal\b").unwrap()),
+        _ => unreachable!("word_re only supports static/final"),
+    }
+}
+
+fn opt_str(arena: &mut Arena, s: Option<&str>) -> StrRef {
+    match s {
+        Some(s) => arena.put(s),
+        None => NONE_STR,
+    }
+}

+ 31 - 0
codegraph-kernel/src/langs.rs

@@ -0,0 +1,31 @@
+//! Grammar registry: codegraph `Language` string → native tree-sitter grammar.
+//!
+//! Mirrors the wasm side's `WASM_GRAMMAR_FILES` mapping (src/extraction/
+//! grammars.ts): `tsx` and `jsx` reuse another language's grammar exactly the
+//! way the wasm map does. The kernel-grammar-parity test asserts each entry is
+//! built from the SAME grammar revision as the vendored wasm — bump the crate
+//! and the wasm together.
+//!
+//! (R1 shipped a generic `.scm`-query emitter here; R2 replaced it with the
+//! bespoke per-language walker — see tsjs/ and the migration plan §3a — because
+//! extraction parity needs logic queries can't express. New languages add a
+//! grammar entry + a walker module.)
+
+use tree_sitter::Language;
+
+/// Languages this kernel binary can extract (reported by contractInfo;
+/// TS-side routing policy decides what actually routes).
+pub const LANGUAGES: [&str; 7] =
+    ["typescript", "tsx", "javascript", "jsx", "java", "python", "go"];
+
+pub fn grammar_for(language: &str) -> Option<Language> {
+    match language {
+        "typescript" => Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
+        "tsx" => Some(tree_sitter_typescript::LANGUAGE_TSX.into()),
+        "javascript" | "jsx" => Some(tree_sitter_javascript::LANGUAGE.into()),
+        "java" => Some(tree_sitter_java::LANGUAGE.into()),
+        "python" => Some(tree_sitter_python::LANGUAGE.into()),
+        "go" => Some(tree_sitter_go::LANGUAGE.into()),
+        _ => None,
+    }
+}

+ 115 - 0
codegraph-kernel/src/lib.rs

@@ -0,0 +1,115 @@
+//! codegraph-kernel — native extraction kernel (napi-rs).
+//!
+//! Replaces ONLY the parse+extract walk inside the parse workers, behind the
+//! existing `ExtractionResult` contract. Input `(filePath, content, language)`
+//! per file; output flat typed buffers — one boundary crossing per file.
+//! Everything downstream (resolution, synthesis, frameworks, MCP) is
+//! untouched and consumes the decoded result exactly as before.
+//!
+//! Calls are synchronous by design: the existing `ParseWorkerPool` workers
+//! already parallelize per-file, so each worker thread drives its own kernel
+//! call (do NOT rebuild the pool on the Rust side — see the migration plan §3).
+//!
+//! Per-language extraction lives in a dedicated walker module (tsjs/ for
+//! typescript/tsx/javascript/jsx) that mirrors the TS extractor for behavioral
+//! parity — verified by scripts/kernel-parity.mjs and the §5 gate.
+
+#![deny(clippy::all)]
+
+mod buffers;
+mod docstring;
+mod ids;
+mod go;
+mod java;
+mod langs;
+mod textutil;
+mod python;
+mod tsjs;
+
+use napi::bindgen_prelude::*;
+use napi_derive::napi;
+
+/// The five flat tables for one file. See buffers.rs for the byte layout;
+/// `src/extraction/kernel/layout.ts` is the TS mirror.
+#[napi(object)]
+pub struct ExtractBuffers {
+    pub meta: Buffer,
+    pub nodes: Buffer,
+    pub edges: Buffer,
+    pub refs: Buffer,
+    pub arena: Buffer,
+}
+
+/// Wire-contract description — the TS loader verifies this against
+/// src/types.ts before routing anything to the kernel, so an out-of-date
+/// `.node` degrades to the wasm path instead of mis-decoding.
+#[napi(object)]
+pub struct ContractInfo {
+    pub abi_version: u32,
+    pub kernel_version: String,
+    pub node_kinds: Vec<String>,
+    pub edge_kinds: Vec<String>,
+    /// Languages this binary can extract (routing is still TS-side policy).
+    pub languages: Vec<String>,
+}
+
+/// Grammar identity for the grammar-source-parity gate: the wasm grammar and
+/// the native grammar must expose identical node-kind/field tables, or
+/// kernel-vs-fallback routing would be non-deterministic.
+#[napi(object)]
+pub struct GrammarInfo {
+    pub abi_version: u32,
+    pub node_kind_count: u32,
+    pub field_count: u32,
+    pub node_kinds: Vec<String>,
+    pub field_names: Vec<String>,
+}
+
+#[napi]
+pub fn contract_info() -> ContractInfo {
+    ContractInfo {
+        abi_version: buffers::KERNEL_ABI_VERSION as u32,
+        kernel_version: env!("CARGO_PKG_VERSION").to_string(),
+        node_kinds: buffers::NODE_KINDS.iter().map(|s| s.to_string()).collect(),
+        edge_kinds: buffers::EDGE_KINDS.iter().map(|s| s.to_string()).collect(),
+        languages: langs::LANGUAGES.iter().map(|s| s.to_string()).collect(),
+    }
+}
+
+#[napi]
+pub fn grammar_info(language: String) -> Option<GrammarInfo> {
+    let lang = langs::grammar_for(&language)?;
+    let node_kind_count = lang.node_kind_count();
+    let field_count = lang.field_count();
+    let node_kinds = (0..node_kind_count)
+        .map(|i| lang.node_kind_for_id(i as u16).unwrap_or("").to_string())
+        .collect();
+    // Field ids are 1-based in tree-sitter.
+    let field_names = (1..=field_count)
+        .map(|i| lang.field_name_for_id(i as u16).unwrap_or("").to_string())
+        .collect();
+    Some(GrammarInfo {
+        abi_version: lang.abi_version() as u32,
+        node_kind_count: node_kind_count as u32,
+        field_count: field_count as u32,
+        node_kinds,
+        field_names,
+    })
+}
+
+#[napi]
+pub fn extract_file(file_path: String, content: String, language: String) -> Result<ExtractBuffers> {
+    let out = match language.as_str() {
+        "java" => java::extract(&file_path, &content).map_err(Error::from_reason)?,
+        "python" => python::extract(&file_path, &content).map_err(Error::from_reason)?,
+        "go" => go::extract(&file_path, &content).map_err(Error::from_reason)?,
+        _ => tsjs::extract(&file_path, &content, &language).map_err(Error::from_reason)?,
+    };
+    Ok(ExtractBuffers {
+        meta: out.meta.into(),
+        nodes: out.nodes.into(),
+        edges: out.edges.into(),
+        refs: out.refs.into(),
+        arena: out.arena.into(),
+    })
+}

+ 978 - 0
codegraph-kernel/src/python.rs

@@ -0,0 +1,978 @@
+//! Python extraction — a faithful Rust port of `TreeSitterExtractor`'s Python
+//! paths (src/extraction/tree-sitter.ts) plus languages/python.ts.
+//!
+//! Same porting contract as tsjs/java: behavior parity, bug-for-bug —
+//! including the quirks: decorates refs only fire for bare-identifier
+//! decorators (`@staticmethod` yes, `@app.route(...)` no — python's `call`
+//! kind isn't `call_expression`), module-level assignments always extract as
+//! `variable` (no isConst hook), and `self.method` fn-ref candidates carry the
+//! BARE attribute name. Python is not a TYPE_ANNOTATION language — no type
+//! refs anywhere. Files with parse errors defer to wasm.
+
+use crate::buffers::{
+    build_meta, edge_kind_index, node_kind_index, Arena, BoolFlags, EdgeRow, EmitOut, NodeRow,
+    RefRow, StrRef, Tables, FLAG_IS_ASYNC, FLAG_IS_STATIC, FUNCTION_REF_CODE, NONE, NONE_STR,
+};
+use crate::docstring::preceding_docstring;
+use crate::ids;
+use crate::textutil as util;
+use std::collections::{HashMap, HashSet};
+use tree_sitter::{Node, Parser};
+
+const MAX_VALUE_REF_NODES: usize = 20_000;
+
+struct Scope {
+    row: u32,
+    kind: &'static str,
+    name: String,
+}
+
+#[derive(Default)]
+struct Extra {
+    docstring: Option<String>,
+    signature: Option<String>,
+    is_async: Option<bool>,
+    is_static: Option<bool>,
+}
+
+struct ValueScope<'t> {
+    row: u32,
+    node: Node<'t>,
+    name: String,
+}
+
+struct Cand {
+    from: u32,
+    name: String,
+    line: u32,
+    column_byte: usize,
+    row: usize,
+}
+
+pub struct Walker<'t> {
+    src: &'t str,
+    file_path: &'t str,
+    line_starts: Vec<usize>,
+    arena: Arena,
+    tables: Tables,
+    stack: Vec<Scope>,
+    node_ids: Vec<String>,
+    defined_fn_names: HashSet<String>,
+    imported_names: HashSet<String>,
+    fn_ref_cands: Vec<Cand>,
+    fs_values: HashMap<String, u32>,
+    fs_value_counts: HashMap<String, u32>,
+    value_scopes: Vec<ValueScope<'t>>,
+}
+
+pub fn extract(file_path: &str, source: &str) -> Result<EmitOut, String> {
+    let grammar = crate::langs::grammar_for("python").ok_or("no python grammar")?;
+    let t0 = std::time::Instant::now();
+    let mut parser = Parser::new();
+    parser
+        .set_language(&grammar)
+        .map_err(|e| format!("set_language(python) failed: {e}"))?;
+    let tree = parser
+        .parse(source, None)
+        .ok_or_else(|| "parser returned null tree".to_string())?;
+    if tree.root_node().has_error() {
+        return Err("defer: parse tree contains errors — wasm recovery is canonical".to_string());
+    }
+
+    let mut w = Walker {
+        src: source,
+        file_path,
+        line_starts: util::line_starts(source),
+        arena: Arena::default(),
+        tables: Tables::default(),
+        stack: Vec::new(),
+        node_ids: Vec::new(),
+        defined_fn_names: HashSet::new(),
+        imported_names: HashSet::new(),
+        fn_ref_cands: Vec::new(),
+        fs_values: HashMap::new(),
+        fs_value_counts: HashMap::new(),
+        value_scopes: Vec::new(),
+    };
+
+    let line_count = source.bytes().filter(|b| *b == b'\n').count() as u32 + 1;
+    let base_name = file_path.rsplit(['/', '\\']).next().unwrap_or(file_path);
+    let mut flags = BoolFlags::default();
+    flags.set(crate::buffers::FLAG_IS_EXPORTED, false);
+    let file_id = w.arena.put(&ids::file_node_id(file_path));
+    let name_ref = w.arena.put(base_name);
+    let qn_ref = w.arena.put(file_path);
+    w.tables.push_node(&NodeRow {
+        kind: node_kind_index("file").unwrap(),
+        visibility: 0,
+        flags,
+        start_line: 1,
+        end_line: line_count,
+        start_column: 0,
+        end_column: 0,
+        name: name_ref,
+        qualified_name: qn_ref,
+        id: file_id,
+        docstring: NONE_STR,
+        signature: NONE_STR,
+        decorators: NONE_STR,
+        type_parameters: NONE_STR,
+        return_type: NONE_STR,
+        extra_json: NONE_STR,
+    });
+    w.node_ids.push(ids::file_node_id(file_path));
+    w.stack.push(Scope { row: 0, kind: "file", name: base_name.to_string() });
+
+    w.visit_node(tree.root_node());
+    w.flush_fn_ref_candidates();
+    w.flush_value_refs(tree.root_node());
+    w.stack.pop();
+
+    let duration_ms = t0.elapsed().as_secs_f64() * 1000.0;
+    let meta = build_meta(&w.tables, w.arena.len(), NONE_STR, duration_ms);
+    Ok(EmitOut {
+        meta,
+        nodes: w.tables.nodes,
+        edges: w.tables.edges,
+        refs: w.tables.refs,
+        arena: w.arena.into_vec(),
+    })
+}
+
+impl<'t> Walker<'t> {
+    fn text(&self, node: Node) -> &'t str {
+        &self.src[node.byte_range()]
+    }
+    fn line_of(&self, node: Node) -> u32 {
+        node.start_position().row as u32 + 1
+    }
+    fn col_of(&self, node: Node) -> u32 {
+        util::col16(self.src, &self.line_starts, node.start_position().row, node.start_byte())
+    }
+    fn end_col_of(&self, node: Node) -> u32 {
+        util::col16(self.src, &self.line_starts, node.end_position().row, node.end_byte())
+    }
+    fn top_row(&self) -> u32 {
+        self.stack.last().map(|s| s.row).unwrap_or(0)
+    }
+    fn inside_class_like(&self) -> bool {
+        self.stack
+            .last()
+            .map(|s| matches!(s.kind, "class" | "struct" | "interface" | "trait" | "enum" | "module"))
+            .unwrap_or(false)
+    }
+
+    fn push_ref_at(&mut self, from_row: u32, name: &str, kind_code: u8, node: Node) {
+        let name_ref = self.arena.put(name);
+        self.tables.push_ref(&RefRow {
+            from_idx: from_row,
+            kind: kind_code,
+            line: self.line_of(node),
+            column: self.col_of(node),
+            reference_name: name_ref,
+            candidates: NONE_STR,
+            from_id_str: NONE_STR,
+        });
+        if kind_code == edge_kind_index("imports").unwrap() {
+            if util::simple_name().is_match(name) {
+                self.imported_names.insert(name.to_string());
+            } else if let Some(c) = util::qualified_import().captures(name) {
+                self.imported_names.insert(c[1].to_string());
+            }
+        }
+    }
+
+    fn create_node(&mut self, kind: &'static str, name: &str, node: Node<'t>, extra: Extra) -> Option<u32> {
+        if name.is_empty() {
+            return None;
+        }
+        let start_line = self.line_of(node);
+        let id = ids::node_id(self.file_path, kind, name, start_line);
+        let end_line = node.end_position().row as u32 + 1;
+
+        let qualified = {
+            let mut parts: Vec<&str> = Vec::new();
+            for s in &self.stack {
+                if s.kind != "file" {
+                    parts.push(&s.name);
+                }
+            }
+            let mut qn = parts.join("::");
+            if !qn.is_empty() {
+                qn.push_str("::");
+            }
+            qn.push_str(name);
+            qn
+        };
+
+        let mut flags = BoolFlags::default();
+        if let Some(v) = extra.is_async {
+            flags.set(FLAG_IS_ASYNC, v);
+        }
+        if let Some(v) = extra.is_static {
+            flags.set(FLAG_IS_STATIC, v);
+        }
+        let name_ref = self.arena.put(name);
+        let qn_ref = self.arena.put(&qualified);
+        let id_ref = self.arena.put(&id);
+        let doc_ref = opt_str(&mut self.arena, extra.docstring.as_deref());
+        let sig_ref = opt_str(&mut self.arena, extra.signature.as_deref());
+        let row = self.tables.push_node(&NodeRow {
+            kind: node_kind_index(kind).unwrap(),
+            visibility: 0,
+            flags,
+            start_line,
+            end_line,
+            start_column: self.col_of(node),
+            end_column: self.end_col_of(node),
+            name: name_ref,
+            qualified_name: qn_ref,
+            id: id_ref,
+            docstring: doc_ref,
+            signature: sig_ref,
+            decorators: NONE_STR,
+            type_parameters: NONE_STR,
+            return_type: NONE_STR,
+            extra_json: NONE_STR,
+        });
+        self.node_ids.push(id);
+
+        let parent_row = self.top_row();
+        self.tables.push_edge(&EdgeRow {
+            source_idx: parent_row,
+            target_idx: row,
+            kind: edge_kind_index("contains").unwrap(),
+            provenance: 0,
+            line: NONE,
+            column: NONE,
+            metadata_json: NONE_STR,
+            source_id_str: NONE_STR,
+            target_id_str: NONE_STR,
+        });
+
+        if kind == "function" || kind == "method" {
+            self.defined_fn_names.insert(name.to_string());
+        }
+        // captureValueRefScope
+        let target_kind_ok = kind == "constant" || kind == "variable";
+        if target_kind_ok
+            && util::utf16_len(name) >= 3
+            && util::has_upper_or_underscore().is_match(name)
+        {
+            let parent_ok = self
+                .stack
+                .last()
+                .map(|s| matches!(s.kind, "file" | "class" | "module" | "struct" | "enum"))
+                .unwrap_or(false);
+            if parent_ok {
+                self.fs_values.insert(name.to_string(), row);
+                *self.fs_value_counts.entry(name.to_string()).or_insert(0) += 1;
+            }
+        }
+        if matches!(kind, "function" | "method" | "constant" | "variable") {
+            self.value_scopes.push(ValueScope { row, node, name: name.to_string() });
+        }
+        Some(row)
+    }
+
+    fn extract_name(&self, node: Node) -> String {
+        if let Some(name_node) = node.child_by_field_name("name") {
+            return self.text(name_node).to_string();
+        }
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                if matches!(c.kind(), "identifier" | "type_identifier" | "simple_identifier" | "constant") {
+                    return self.text(c).to_string();
+                }
+            }
+        }
+        "<anonymous>".to_string()
+    }
+
+    /// pythonExtractor.getSignature: params + ` -> returnType`.
+    fn signature_of(&self, node: Node) -> Option<String> {
+        let params = node.child_by_field_name("parameters")?;
+        let mut sig = self.text(params).to_string();
+        if let Some(ret) = node.child_by_field_name("return_type") {
+            sig.push_str(" -> ");
+            sig.push_str(self.text(ret));
+        }
+        Some(sig)
+    }
+
+    /// pythonExtractor.isAsync: the PREVIOUS SIBLING token is `async`.
+    fn is_async(&self, node: Node) -> bool {
+        node.prev_sibling().map(|p| p.kind() == "async").unwrap_or(false)
+    }
+
+    /// pythonExtractor.isStatic: preceding decorator mentioning `staticmethod`.
+    fn is_static(&self, node: Node) -> bool {
+        if let Some(prev) = node.prev_named_sibling() {
+            if prev.kind() == "decorator" {
+                return self.text(prev).contains("staticmethod");
+            }
+        }
+        false
+    }
+
+    // --- visitNode ------------------------------------------------------------
+
+    fn visit_node(&mut self, node: Node<'t>) {
+        let kind = node.kind();
+        let mut skip_children = false;
+
+        self.maybe_capture_fn_refs(node);
+
+        if kind == "function_definition" {
+            // functionTypes ∩ methodTypes: inside a class-like ⇒ method.
+            if self.inside_class_like() {
+                self.extract_method(node);
+            } else {
+                self.extract_function(node);
+            }
+            skip_children = true;
+        } else if kind == "class_definition" {
+            self.extract_class(node);
+            skip_children = true;
+        } else if kind == "assignment" && !self.inside_class_like() {
+            self.extract_variable(node);
+            self.scan_fn_ref_subtree(node, 0);
+            skip_children = true;
+        } else if kind == "import_statement" || kind == "import_from_statement" {
+            self.extract_import(node);
+        } else if kind == "call" {
+            self.extract_call(node);
+        }
+
+        if !skip_children {
+            for i in 0..node.named_child_count() {
+                if let Some(c) = node.named_child(i) {
+                    self.visit_node(c);
+                }
+            }
+        }
+    }
+
+    fn visit_function_body(&mut self, body: Node<'t>) {
+        self.visit_for_calls_and_structure(body);
+    }
+
+    fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        let kind = node.kind();
+        self.maybe_capture_fn_refs(node);
+
+        if kind == "call" {
+            self.extract_call(node);
+        }
+
+        // Nested NAMED functions become their own nodes.
+        if kind == "function_definition" {
+            let name = self.extract_name(node);
+            if name != "<anonymous>" {
+                self.extract_function(node);
+                return;
+            }
+        }
+        if kind == "class_definition" {
+            self.extract_class(node);
+            return;
+        }
+
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                self.visit_for_calls_and_structure(c);
+            }
+        }
+    }
+
+    // --- extractors --------------------------------------------------------------
+
+    fn extract_function(&mut self, node: Node<'t>) {
+        let name = self.extract_name(node);
+        if name == "<anonymous>" {
+            if let Some(body) = node.child_by_field_name("body") {
+                self.visit_function_body(body);
+            }
+            return;
+        }
+        let extra = Extra {
+            docstring: preceding_docstring(node, self.src),
+            signature: self.signature_of(node),
+            is_async: Some(self.is_async(node)),
+            is_static: Some(self.is_static(node)),
+        };
+        let Some(row) = self.create_node("function", &name, node, extra) else { return };
+        // (python is not a TYPE_ANNOTATION language — no type refs)
+        self.extract_decorators_for(node, row);
+        self.stack.push(Scope { row, kind: "function", name });
+        if let Some(body) = node.child_by_field_name("body") {
+            self.visit_function_body(body);
+        }
+        self.stack.pop();
+    }
+
+    fn extract_method(&mut self, node: Node<'t>) {
+        let name = self.extract_name(node);
+        let extra = Extra {
+            docstring: preceding_docstring(node, self.src),
+            signature: self.signature_of(node),
+            is_async: Some(self.is_async(node)),
+            is_static: Some(self.is_static(node)),
+        };
+        let Some(row) = self.create_node("method", &name, node, extra) else { return };
+        self.extract_decorators_for(node, row);
+        self.stack.push(Scope { row, kind: "method", name });
+        if let Some(body) = node.child_by_field_name("body") {
+            self.visit_function_body(body);
+        }
+        self.stack.pop();
+    }
+
+    fn extract_class(&mut self, node: Node<'t>) {
+        let name = self.extract_name(node);
+        let extra = Extra {
+            docstring: preceding_docstring(node, self.src),
+            ..Extra::default()
+        };
+        let Some(row) = self.create_node("class", &name, node, extra) else { return };
+
+        // Inheritance: `class Flask(Scaffold, Mixin):` — argument_list children.
+        let extends_kind = edge_kind_index("extends").unwrap();
+        for i in 0..node.named_child_count() {
+            let Some(child) = node.named_child(i) else { continue };
+            if child.kind() == "argument_list" {
+                for j in 0..child.named_child_count() {
+                    let Some(arg) = child.named_child(j) else { continue };
+                    if matches!(arg.kind(), "identifier" | "attribute") {
+                        let name = self.text(arg).to_string();
+                        self.push_ref_at(row, &name, extends_kind, arg);
+                    }
+                }
+            }
+        }
+        self.extract_decorators_for(node, row);
+
+        self.stack.push(Scope { row, kind: "class", name });
+        let body = node.child_by_field_name("body").unwrap_or(node);
+        for i in 0..body.named_child_count() {
+            if let Some(c) = body.named_child(i) {
+                self.visit_node(c);
+            }
+        }
+        self.stack.pop();
+    }
+
+    /// extractVariable's python branch: `left = right` at module scope.
+    fn extract_variable(&mut self, node: Node<'t>) {
+        let docstring = preceding_docstring(node, self.src);
+        let left = node.child_by_field_name("left").or_else(|| node.named_child(0));
+        let right = node.child_by_field_name("right").or_else(|| node.named_child(1));
+        let Some(left) = left else { return };
+        if !matches!(left.kind(), "identifier" | "constant") {
+            return;
+        }
+        let name = self.text(left).to_string();
+        let signature = right.map(|r| util::init_signature(self.text(r)));
+        // No isConst hook ⇒ always `variable` (UPPER_CASE constants included).
+        self.create_node("variable", &name, node, Extra { docstring, signature, ..Extra::default() });
+    }
+
+    fn extract_import(&mut self, node: Node<'t>) {
+        let import_text = self.text(node).trim().to_string();
+        let imports_kind = edge_kind_index("imports").unwrap();
+
+        if node.kind() == "import_from_statement" {
+            // Hook path: module_name field → import node + module ref, then
+            // per-name binding refs (emitPyFromImportRefs).
+            let Some(module_node) = node.child_by_field_name("module_name") else { return };
+            let module_name = self.text(module_node).to_string();
+            if module_name.is_empty() {
+                return;
+            }
+            self.create_node(
+                "import",
+                &module_name,
+                node,
+                Extra { signature: Some(import_text), ..Extra::default() },
+            );
+            let parent = self.top_row();
+            self.push_ref_at(parent, &module_name.clone(), imports_kind, node);
+
+            // emitPyFromImportRefs: one `imports` ref per imported name.
+            for i in 0..node.named_child_count() {
+                let Some(child) = node.named_child(i) else { continue };
+                if child.start_byte() == module_node.start_byte()
+                    && child.end_byte() == module_node.end_byte()
+                {
+                    continue;
+                }
+                if child.kind() == "wildcard_import" {
+                    continue;
+                }
+                let name_node = match child.kind() {
+                    "aliased_import" => child
+                        .child_by_field_name("alias")
+                        .or_else(|| child.child_by_field_name("name"))
+                        .or_else(|| child.named_child(0)),
+                    "dotted_name" => Some(child),
+                    _ => None,
+                };
+                let Some(name_node) = name_node else { continue };
+                let raw = self.text(name_node);
+                let local = raw.rsplit('.').next().unwrap_or("");
+                if local.is_empty() {
+                    continue;
+                }
+                self.push_ref_at(parent, &local.to_string(), imports_kind, name_node);
+            }
+            return;
+        }
+
+        // import_statement: `import a.b, x as y` — one import node + module ref
+        // per dotted name (the python multi-import branch).
+        let parent = self.top_row();
+        for i in 0..node.named_child_count() {
+            let Some(child) = node.named_child(i) else { continue };
+            if child.kind() == "dotted_name" {
+                let name = self.text(child).to_string();
+                self.create_node(
+                    "import",
+                    &name,
+                    node,
+                    Extra { signature: Some(import_text.clone()), ..Extra::default() },
+                );
+                self.push_ref_at(parent, &name, imports_kind, child);
+            } else if child.kind() == "aliased_import" {
+                let dotted = (0..child.named_child_count())
+                    .filter_map(|j| child.named_child(j))
+                    .find(|c| c.kind() == "dotted_name");
+                if let Some(dotted) = dotted {
+                    let name = self.text(dotted).to_string();
+                    self.create_node(
+                        "import",
+                        &name,
+                        node,
+                        Extra { signature: Some(import_text.clone()), ..Extra::default() },
+                    );
+                    self.push_ref_at(parent, &name, imports_kind, dotted);
+                }
+            }
+        }
+    }
+
+    /// extractCall — python `call` through the generic tail (attribute callees).
+    fn extract_call(&mut self, node: Node<'t>) {
+        if self.stack.is_empty() {
+            return;
+        }
+        let func = node
+            .child_by_field_name("function")
+            .or_else(|| node.named_child(0));
+        let mut callee_name = String::new();
+
+        if let Some(func) = func {
+            if func.kind() == "attribute" {
+                // `property` and `field` fields don't exist on attribute —
+                // the generic path falls back to namedChild(1) (the attr name).
+                let property = func
+                    .child_by_field_name("property")
+                    .or_else(|| func.child_by_field_name("field"))
+                    .or_else(|| func.named_child(1));
+                if let Some(property) = property {
+                    let method_name = self.text(property);
+                    let receiver = func
+                        .child_by_field_name("object")
+                        .or_else(|| func.child_by_field_name("operand"))
+                        .or_else(|| func.child_by_field_name("argument"))
+                        .or_else(|| func.named_child(0));
+                    if let Some(r) = receiver {
+                        if is_literal_receiver(r.kind()) {
+                            return;
+                        }
+                    }
+                    let recv_ident = receiver.filter(|r| {
+                        matches!(r.kind(), "identifier" | "simple_identifier" | "field_identifier")
+                    });
+                    if let Some(r) = recv_ident {
+                        let receiver_name = self.text(r);
+                        if !matches!(receiver_name, "self" | "this" | "cls" | "super") {
+                            callee_name = format!("{receiver_name}.{method_name}");
+                        } else {
+                            callee_name = method_name.to_string();
+                        }
+                    } else {
+                        callee_name = method_name.to_string();
+                    }
+                }
+            } else {
+                callee_name = self.text(func).to_string();
+            }
+        }
+
+        if !callee_name.is_empty() {
+            if let Some(c) = util::paren_conversion().captures(&callee_name) {
+                callee_name = c[1].to_string();
+            }
+            let from = self.top_row();
+            self.push_ref_at(from, &callee_name.clone(), edge_kind_index("calls").unwrap(), node);
+        }
+    }
+
+    /// extractDecoratorsFor — python decorators are PRECEDING SIBLINGS inside
+    /// decorated_definition. Only bare-identifier decorators yield a target
+    /// (python's `call` kind isn't `call_expression`, and `attribute` isn't in
+    /// the target-kind list — mirrored exactly).
+    fn extract_decorators_for(&mut self, decl: Node<'t>, decorated_row: u32) {
+        for i in 0..decl.named_child_count() {
+            if let Some(child) = decl.named_child(i) {
+                self.consider_decorator(child, decorated_row);
+            }
+        }
+        let Some(parent) = decl.parent() else { return };
+        let decl_start = decl.start_byte();
+        let mut decl_idx: isize = -1;
+        for i in 0..parent.named_child_count() {
+            if let Some(sib) = parent.named_child(i) {
+                if sib.start_byte() == decl_start {
+                    decl_idx = i as isize;
+                    break;
+                }
+            }
+        }
+        if decl_idx > 0 {
+            let mut j = decl_idx - 1;
+            while j >= 0 {
+                let Some(sib) = parent.named_child(j as usize) else {
+                    j -= 1;
+                    continue;
+                };
+                if !matches!(sib.kind(), "decorator" | "annotation" | "marker_annotation") {
+                    break;
+                }
+                self.consider_decorator(sib, decorated_row);
+                j -= 1;
+            }
+        }
+    }
+
+    fn consider_decorator(&mut self, n: Node<'t>, decorated_row: u32) {
+        if !matches!(n.kind(), "decorator" | "annotation" | "marker_annotation" | "attribute") {
+            return;
+        }
+        let mut target: Option<Node> = None;
+        for i in 0..n.named_child_count() {
+            let Some(child) = n.named_child(i) else { continue };
+            if child.kind() == "call_expression" {
+                target = child.child_by_field_name("function").or_else(|| child.named_child(0));
+                if target.is_some() {
+                    break;
+                }
+            }
+            if matches!(
+                child.kind(),
+                "identifier" | "member_expression" | "scoped_identifier" | "navigation_expression"
+                    | "user_type" | "type_identifier"
+            ) {
+                target = Some(child);
+                break;
+            }
+        }
+        let Some(target) = target else { return };
+        let mut name = self.text(target).to_string();
+        if let Some(lt) = name.find('<') {
+            if lt > 0 {
+                name.truncate(lt);
+            }
+        }
+        let last_dot = name
+            .rfind('.')
+            .map(|i| i as isize)
+            .unwrap_or(-1)
+            .max(name.rfind("::").map(|i| i as isize).unwrap_or(-1));
+        if last_dot >= 0 {
+            name = name[(last_dot as usize + 1)..].to_string();
+            if name.starts_with(':') || name.starts_with('.') {
+                name.remove(0);
+            }
+        }
+        let name = name.trim().to_string();
+        if name.is_empty() {
+            return;
+        }
+        self.push_ref_at(decorated_row, &name, edge_kind_index("decorates").unwrap(), n);
+    }
+
+    // --- fn refs (PYTHON_SPEC) ------------------------------------------------------
+
+    fn maybe_capture_fn_refs(&mut self, node: Node<'t>) {
+        let (mode, field): (&str, &str) = match node.kind() {
+            "argument_list" => ("args", ""),
+            "assignment" => ("rhs", "right"),
+            "keyword_argument" => ("value", "value"),
+            "pair" => ("value", "value"),
+            "list" => ("list", ""),
+            _ => return,
+        };
+        if self.stack.is_empty() {
+            return;
+        }
+        let from = self.top_row();
+
+        let mut values: Vec<Node> = Vec::new();
+        match mode {
+            "args" | "list" => {
+                for i in 0..node.named_child_count() {
+                    if let Some(c) = node.named_child(i) {
+                        values.push(c);
+                    }
+                }
+            }
+            "rhs" => {
+                if let Some(rhs) = node.child_by_field_name(field) {
+                    let lhs_text = node
+                        .child_by_field_name("left")
+                        .map(|l| self.text(l))
+                        .unwrap_or("");
+                    let lhs_last = util::lhs_last_name()
+                        .captures(lhs_text)
+                        .and_then(|c| c.get(1))
+                        .map(|m| m.as_str());
+                    if !(lhs_last.is_some() && lhs_last == Some(self.text(rhs).trim())) {
+                        values.push(rhs);
+                    }
+                }
+            }
+            _ => {
+                if let Some(v) = node.child_by_field_name(field) {
+                    values.push(v);
+                }
+            }
+        }
+
+        for v in values {
+            let (name, anchor) = match v.kind() {
+                "identifier" => (self.text(v).to_string(), v),
+                // `self.handle_click` — object EXACTLY `self`; BARE attr name.
+                "attribute" => {
+                    let obj = v.child_by_field_name("object");
+                    let attr = v.child_by_field_name("attribute");
+                    match (obj, attr) {
+                        (Some(o), Some(a))
+                            if o.kind() == "identifier" && self.text(o) == "self" =>
+                        {
+                            (self.text(a).to_string(), a)
+                        }
+                        _ => continue,
+                    }
+                }
+                _ => continue,
+            };
+            if name.is_empty() || is_stoplisted(&name) {
+                continue;
+            }
+            let p = anchor.start_position();
+            self.fn_ref_cands.push(Cand {
+                from,
+                name,
+                line: p.row as u32 + 1,
+                column_byte: anchor.start_byte(),
+                row: p.row,
+            });
+        }
+    }
+
+    fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        if depth > 12 {
+            return;
+        }
+        // Halts at functionTypes ∪ the fixed arrow/lambda list — python's
+        // `lambda` kind is NOT in that list (mirrored).
+        if depth > 0
+            && matches!(
+                node.kind(),
+                "function_definition" | "arrow_function" | "function_expression" | "lambda_literal"
+                    | "lambda_expression"
+            )
+        {
+            return;
+        }
+        self.maybe_capture_fn_refs(node);
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                self.scan_fn_ref_subtree(c, depth + 1);
+            }
+        }
+    }
+
+    fn flush_fn_ref_candidates(&mut self) {
+        let cands = std::mem::take(&mut self.fn_ref_cands);
+        if cands.is_empty() || util::is_generated_file(self.file_path) {
+            return;
+        }
+        let mut seen: HashSet<(String, String)> = HashSet::new();
+        for c in cands {
+            if !c.name.starts_with("this.")
+                && !c.name.contains("::")
+                && !self.defined_fn_names.contains(&c.name)
+                && !self.imported_names.contains(&c.name)
+            {
+                continue;
+            }
+            if !seen.insert((self.node_ids[c.from as usize].clone(), c.name.clone())) {
+                continue;
+            }
+            let column = util::col16(self.src, &self.line_starts, c.row, c.column_byte);
+            let name_ref = self.arena.put(&c.name);
+            self.tables.push_ref(&RefRow {
+                from_idx: c.from,
+                kind: FUNCTION_REF_CODE,
+                line: c.line,
+                column,
+                reference_name: name_ref,
+                candidates: NONE_STR,
+                from_id_str: NONE_STR,
+            });
+        }
+    }
+
+    // --- value refs -------------------------------------------------------------------
+
+    fn flush_value_refs(&mut self, root: Node<'t>) {
+        let scopes = std::mem::take(&mut self.value_scopes);
+        let mut targets = std::mem::take(&mut self.fs_values);
+        let counts = std::mem::take(&mut self.fs_value_counts);
+        if std::env::var("CODEGRAPH_VALUE_REFS").as_deref() == Ok("0") {
+            return;
+        }
+        if targets.is_empty() || scopes.is_empty() || util::is_generated_file(self.file_path) {
+            return;
+        }
+
+        // Shadow prune — python's declarator shape is `assignment`.
+        let mut decl_counts: HashMap<&str, u32> = HashMap::new();
+        let mut dstack: Vec<Node> = vec![root];
+        let mut dvisited = 0usize;
+        while let Some(n) = dstack.pop() {
+            if dvisited >= MAX_VALUE_REF_NODES {
+                break;
+            }
+            dvisited += 1;
+            if n.kind() == "assignment" {
+                let left = n
+                    .child_by_field_name("left")
+                    .or_else(|| n.child_by_field_name("pattern"))
+                    .or_else(|| n.named_child(0));
+                if let Some(left) = left {
+                    if left.kind() == "identifier" {
+                        let nm = self.text(left);
+                        if targets.contains_key(nm) {
+                            *decl_counts.entry(nm).or_insert(0) += 1;
+                        }
+                    } else {
+                        for i in 0..left.named_child_count() {
+                            if let Some(c) = left.named_child(i) {
+                                if c.kind() == "identifier" {
+                                    let nm = self.text(c);
+                                    if targets.contains_key(nm) {
+                                        *decl_counts.entry(nm).or_insert(0) += 1;
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+            for i in 0..n.named_child_count() {
+                if let Some(c) = n.named_child(i) {
+                    dstack.push(c);
+                }
+            }
+        }
+        let shadowed: Vec<String> = decl_counts
+            .iter()
+            .filter(|(nm, c)| **c > counts.get(**nm).copied().unwrap_or(1))
+            .map(|(nm, _)| nm.to_string())
+            .collect();
+        for nm in shadowed {
+            targets.remove(&nm);
+        }
+        if targets.is_empty() {
+            return;
+        }
+
+        let refs_kind = edge_kind_index("references").unwrap();
+        for scope in &scopes {
+            let mut seen: HashSet<&str> = HashSet::new();
+            let mut stack: Vec<Node> = vec![scope.node];
+            let mut visited = 0usize;
+            while let Some(n) = stack.pop() {
+                if visited >= MAX_VALUE_REF_NODES {
+                    break;
+                }
+                visited += 1;
+                if matches!(n.kind(), "identifier" | "constant" | "name" | "simple_identifier") {
+                    let ref_name = self.text(n);
+                    if let Some(&target_row) = targets.get(ref_name) {
+                        let target_id = self.node_ids[target_row as usize].as_str();
+                        if target_id != self.node_ids[scope.row as usize]
+                            && ref_name != scope.name
+                            && !seen.contains(&target_id)
+                        {
+                            seen.insert(target_id);
+                            let meta = self.arena.put(r#"{"valueRef":true}"#);
+                            self.tables.push_edge(&EdgeRow {
+                                source_idx: scope.row,
+                                target_idx: target_row,
+                                kind: refs_kind,
+                                provenance: 0,
+                                line: NONE,
+                                column: NONE,
+                                metadata_json: meta,
+                                source_id_str: NONE_STR,
+                                target_id_str: NONE_STR,
+                            });
+                        }
+                    }
+                }
+                for i in 0..n.named_child_count() {
+                    if let Some(c) = n.named_child(i) {
+                        stack.push(c);
+                    }
+                }
+            }
+        }
+    }
+}
+
+/// NAME_STOPLIST (function-ref.ts).
+fn is_stoplisted(name: &str) -> bool {
+    matches!(
+        name,
+        "this" | "self" | "super" | "null" | "nil" | "true" | "false" | "undefined" | "new"
+            | "NULL" | "nullptr" | "None"
+    )
+}
+
+/// LITERAL_RECEIVER_TYPES membership (shared table; python names among them).
+fn is_literal_receiver(kind: &str) -> bool {
+    matches!(
+        kind,
+        "string" | "string_literal" | "interpreted_string_literal" | "raw_string_literal"
+            | "template_string" | "concatenated_string" | "formatted_string" | "f_string"
+            | "line_string_literal" | "string_content" | "heredoc_body"
+            | "number" | "number_literal" | "integer" | "integer_literal" | "float"
+            | "float_literal" | "int_literal" | "decimal_integer_literal" | "real_literal"
+            | "char_literal" | "character_literal" | "rune_literal" | "regex" | "regex_literal"
+            | "true" | "false" | "boolean_literal" | "bool_literal" | "none" | "null" | "nil"
+            | "null_literal" | "undefined"
+            | "list" | "list_literal" | "array" | "array_literal" | "array_creation_expression"
+            | "dictionary" | "dict_literal" | "object" | "tuple" | "set"
+    )
+}
+
+fn opt_str(arena: &mut Arena, s: Option<&str>) -> StrRef {
+    match s {
+        Some(s) => arena.put(s),
+        None => NONE_STR,
+    }
+}

+ 190 - 0
codegraph-kernel/src/textutil.rs

@@ -0,0 +1,190 @@
+//! Shared utilities for the TS/JS walker: compiled regexes, UTF-16 position
+//! conversion, generated-file detection, and small text helpers — each
+//! mirroring a specific helper in src/extraction/tree-sitter.ts (noted inline).
+
+use regex::Regex;
+use std::sync::OnceLock;
+
+macro_rules! re {
+    ($name:ident, $pat:expr) => {
+        pub fn $name() -> &'static Regex {
+            static RE: OnceLock<Regex> = OnceLock::new();
+            RE.get_or_init(|| Regex::new($pat).expect(concat!("regex ", stringify!($name))))
+        }
+    };
+}
+
+// RTK_HOOK_NAME_RE (tree-sitter.ts)
+re!(rtk_hook_name, r"^use[A-Z][A-Za-z0-9]*(?:Query|Mutation)$");
+// reactComponentHoc's styled test
+re!(styled_callee, r"^styled\b");
+// PascalCase component gate (#841)
+re!(pascal_case, r"^[A-Z]");
+// extractCall parenthesized-conversion normalization
+re!(paren_conversion, r"^\(\s*\*?\s*([A-Za-z_][\w.]*)\s*\)$");
+// flushFnRefCandidates SIMPLE_NAME
+re!(simple_name, r"^[A-Za-z_$][A-Za-z0-9_$]*$");
+// flushFnRefCandidates QUALIFIED_IMPORT
+re!(qualified_import, r"^[A-Za-z_$][A-Za-z0-9_$.\\]*[.\\]([A-Za-z_$][A-Za-z0-9_$]*)$");
+// captureFnRefCandidates rhs param-storage skip — trailing identifier of LHS
+re!(lhs_last_name, r"([A-Za-z_$][A-Za-z0-9_$]*)\s*$");
+// extractTsTupleContractNames identifier test
+re!(ident_dollar, r"^[A-Za-z_$][A-Za-z0-9_$]*$");
+// looksLikeVueStoreFile signal (VUE_STORE_FILE_SIGNAL)
+re!(
+    vue_store_signal,
+    r"\bdefineStore\b|\bcreateStore\b|\bVuex\b|\bmutations\b|\bactions\b|\bgetters\b|\bnamespaced\b"
+);
+// value-ref target-name distinctiveness: /[A-Z_]/
+re!(has_upper_or_underscore, r"[A-Z_]");
+
+/// isGeneratedFile (src/extraction/generated-detection.ts) — full pattern list
+/// ported so future language walkers share it.
+pub fn is_generated_file(file_path: &str) -> bool {
+    static RES: OnceLock<Vec<Regex>> = OnceLock::new();
+    let patterns = RES.get_or_init(|| {
+        [
+            r"\.pb\.go$",
+            r"\.pulsar\.go$",
+            r"_grpc\.pb\.go$",
+            r"_mock\.go$",
+            r"_mocks\.go$",
+            r"^mock_[^/]+\.go$",
+            r"\.generated\.[jt]sx?$",
+            r"\.gen\.[jt]sx?$",
+            r"\.pb\.[jt]s$",
+            r"_pb\.[jt]s$",
+            r"_grpc_pb\.[jt]s$",
+            r"\.min\.m?js$",
+            r"_pb2(_grpc)?\.py$",
+            r"_pb2\.pyi$",
+            r"\.pb\.(cc|h)$",
+            r"\.g\.cs$",
+            r"Grpc\.cs$",
+            r"OuterClass\.java$",
+            r"Grpc\.java$",
+            r"\.pb\.swift$",
+            r"\.g\.dart$",
+            r"\.freezed\.dart$",
+            r"\.pb\.dart$",
+            r"\.pbgrpc\.dart$",
+            r"\.chopper\.dart$",
+            r"\.generated\.rs$",
+        ]
+        .iter()
+        .map(|p| Regex::new(p).expect("generated pattern"))
+        .collect()
+    });
+    patterns.iter().any(|p| p.is_match(file_path))
+}
+
+/// Byte offsets of each line start, for UTF-16 column conversion.
+pub fn line_starts(src: &str) -> Vec<usize> {
+    let mut out = vec![0usize];
+    for (i, b) in src.bytes().enumerate() {
+        if b == b'\n' {
+            out.push(i + 1);
+        }
+    }
+    out
+}
+
+/// UTF-16 code units in `s` — what web-tree-sitter (and JS string ops)
+/// count, so kernel-emitted columns are byte-identical to the wasm path's.
+pub fn utf16_len(s: &str) -> usize {
+    s.chars().map(|c| c.len_utf16()).sum()
+}
+
+/// Column (UTF-16 units) of `byte_pos` on line `row`, given `line_starts`.
+pub fn col16(src: &str, starts: &[usize], row: usize, byte_pos: usize) -> u32 {
+    let ls = starts.get(row).copied().unwrap_or(0);
+    if byte_pos <= ls {
+        return 0;
+    }
+    utf16_len(&src[ls..byte_pos]) as u32
+}
+
+/// JS `String.prototype.slice(0, n)` in UTF-16 units, without splitting a
+/// surrogate pair (when the cut would split one, we stop one code unit short —
+/// a lone surrogate isn't representable in Rust and never round-trips through
+/// SQLite anyway). Returns (sliced, was_truncated_at_or_beyond_n).
+pub fn slice_utf16(s: &str, n: usize) -> (String, bool) {
+    let mut used = 0usize;
+    let mut out = String::new();
+    for c in s.chars() {
+        let w = c.len_utf16();
+        if used + w > n {
+            return (out, true);
+        }
+        used += w;
+        out.push(c);
+        if used == n {
+            // Exactly at the limit: truncated iff any source remains.
+            let truncated = out.len() < s.len();
+            return (out, truncated);
+        }
+    }
+    (out, false)
+}
+
+/// objectKeyName (tree-sitter.ts): strip ONE leading and ONE trailing quote
+/// character (`'`, `"`, or backtick).
+pub fn object_key_name(s: &str) -> String {
+    let mut out = s;
+    if let Some(first) = out.chars().next() {
+        if first == '\'' || first == '"' || first == '`' {
+            out = &out[first.len_utf8()..];
+        }
+    }
+    if let Some(last) = out.chars().last() {
+        if last == '\'' || last == '"' || last == '`' {
+            out = &out[..out.len() - last.len_utf8()];
+        }
+    }
+    out.to_string()
+}
+
+/// The `= <first 100 UTF-16 units>[...]` initializer signature used by
+/// extractVariable (its `.length >= 100` check fires exactly when the slice
+/// hit the cap).
+pub fn init_signature(value_text: &str) -> String {
+    let (sliced, _) = slice_utf16(value_text, 100);
+    if utf16_len(&sliced) >= 100 {
+        format!("= {sliced}...")
+    } else {
+        format!("= {sliced}")
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn utf16_cols() {
+        let src = "aé😀b";
+        // 'a'=1, 'é'=1, '😀'=2 utf16 units; bytes: a=1, é=2, 😀=4
+        assert_eq!(utf16_len(src), 5);
+        let starts = line_starts(src);
+        assert_eq!(col16(src, &starts, 0, 1), 1); // after 'a'
+        assert_eq!(col16(src, &starts, 0, 3), 2); // after 'é'
+        assert_eq!(col16(src, &starts, 0, 7), 4); // after '😀'
+    }
+
+    #[test]
+    fn init_sig_short_and_long() {
+        assert_eq!(init_signature("[1, 2]"), "= [1, 2]");
+        let long = "x".repeat(150);
+        let sig = init_signature(&long);
+        assert!(sig.starts_with("= "));
+        assert!(sig.ends_with("..."));
+        assert_eq!(utf16_len(&sig[2..sig.len() - 3]), 100);
+    }
+
+    #[test]
+    fn generated_patterns() {
+        assert!(is_generated_file("src/api.generated.ts"));
+        assert!(is_generated_file("vendor/jquery.min.js"));
+        assert!(!is_generated_file("src/app.ts"));
+    }
+}

+ 1332 - 0
codegraph-kernel/src/tsjs/extractors.rs

@@ -0,0 +1,1332 @@
+//! The extract_* family — continuation of the Walker impl (see mod.rs for the
+//! porting contract). Each function mirrors its namesake in
+//! src/extraction/tree-sitter.ts; TS-file line references are as of the R2
+//! port. Bug-for-bug fidelity is deliberate — fix the TS side first.
+
+use crate::textutil as util;
+use super::{
+    body_of, is_builtin_type, is_literal_receiver, is_react_hoc, is_variable_type,
+    is_vue_collection_name, Extra, Scope, Walker,
+};
+use crate::buffers::edge_kind_index;
+use tree_sitter::Node;
+
+impl<'t> Walker<'t> {
+    // --- extractFunction --------------------------------------------------------
+
+    pub(super) fn extract_function(&mut self, node: Node<'t>, name_override: Option<String>) {
+        let mut name = name_override
+            .clone()
+            .unwrap_or_else(|| self.extract_name(node));
+
+        // Arrow/function-expression values: resolve the name from the parent
+        // variable_declarator (`export const useAuth = () => {}`).
+        if name_override.is_none()
+            && name == "<anonymous>"
+            && matches!(node.kind(), "arrow_function" | "function_expression")
+        {
+            if let Some(parent) = node.parent() {
+                if parent.kind() == "variable_declarator" {
+                    if let Some(var_name) = parent.child_by_field_name("name") {
+                        name = self.text(var_name).to_string();
+                    }
+                }
+            }
+        }
+        if name == "<anonymous>" {
+            // Still walk the body: module wrappers hold named inner functions
+            // and calls that would otherwise be lost (#528).
+            if let Some(body) = body_of(node) {
+                self.visit_function_body(body);
+            }
+            return;
+        }
+
+        let extra = Extra {
+            docstring: crate::docstring::preceding_docstring(node, self.src),
+            signature: self.signature_of(node),
+            visibility: self.visibility_of(node),
+            is_exported: Some(self.is_exported(node)),
+            is_async: Some(self.is_async(node)),
+            is_static: self.is_static(node),
+            ..Extra::default()
+        };
+        let Some(row) = self.create_node("function", &name, node, extra) else {
+            return;
+        };
+
+        self.extract_type_annotations(node, row);
+        self.extract_decorators_for(node, row);
+
+        self.stack.push(Scope { row, kind: "function", name });
+        if let Some(body) = body_of(node) {
+            self.visit_function_body(body);
+        }
+        self.stack.pop();
+    }
+
+    // --- reactComponentHoc / extractReactComponentNode (#841) --------------------
+
+    /// Some(inner) when the initializer is a recognized component wrapper —
+    /// inner is the inline render function, or None for `styled.x`/`memo(Ref)`.
+    /// Outer None = not a component wrapper.
+    fn react_component_hoc(&self, value: Node<'t>) -> Option<Option<Node<'t>>> {
+        if value.kind() != "call_expression" {
+            return None;
+        }
+        let callee = value.child_by_field_name("function")?;
+        let callee_text = self.text(callee);
+        if util::styled_callee().is_match(callee_text) {
+            return Some(None);
+        }
+        if !is_react_hoc(callee_text) {
+            return None;
+        }
+        let mut inner: Option<Node> = None;
+        if let Some(args) = value.child_by_field_name("arguments") {
+            for i in 0..args.named_child_count() {
+                if let Some(a) = args.named_child(i) {
+                    if matches!(a.kind(), "arrow_function" | "function_expression") {
+                        inner = Some(a);
+                        break;
+                    }
+                }
+            }
+        }
+        Some(inner)
+    }
+
+    fn extract_react_component_node(
+        &mut self,
+        name: &str,
+        declarator: Node<'t>,
+        inner_fn: Option<Node<'t>>,
+        extra: Extra,
+    ) {
+        let Some(row) = self.create_node("component", name, declarator, extra) else {
+            return;
+        };
+        let Some(inner) = inner_fn else { return };
+        self.stack.push(Scope { row, kind: "component", name: name.to_string() });
+        if let Some(body) = body_of(inner) {
+            self.visit_function_body(body);
+        }
+        self.stack.pop();
+    }
+
+    // --- extractClass ------------------------------------------------------------
+
+    pub(super) fn extract_class(&mut self, node: Node<'t>) {
+        let resolved_body = body_of(node); // skipBodilessClass unset for TS/JS
+        let name = self.extract_name(node);
+        let extra = Extra {
+            docstring: crate::docstring::preceding_docstring(node, self.src),
+            visibility: self.visibility_of(node),
+            is_exported: Some(self.is_exported(node)),
+            ..Extra::default()
+        };
+        let Some(row) = self.create_node("class", &name, node, extra) else {
+            return;
+        };
+
+        self.extract_inheritance(node, row);
+        self.extract_decorators_for(node, row);
+
+        self.stack.push(Scope { row, kind: "class", name });
+        let body = resolved_body.unwrap_or(node);
+        for i in 0..body.named_child_count() {
+            if let Some(c) = body.named_child(i) {
+                self.visit_node(c);
+            }
+        }
+        self.stack.pop();
+    }
+
+    // --- extractMethod -------------------------------------------------------------
+
+    pub(super) fn extract_method(&mut self, node: Node<'t>) {
+        if !self.inside_class_like() {
+            // Object-literal methods are ephemeral: walk the body only.
+            if let Some(parent) = node.parent() {
+                if matches!(parent.kind(), "object" | "object_expression") {
+                    if let Some(body) = body_of(node) {
+                        self.visit_function_body(body);
+                    }
+                    return;
+                }
+            }
+            self.extract_function(node, None);
+            return;
+        }
+
+        let name = self.extract_name(node);
+        let extra = Extra {
+            docstring: crate::docstring::preceding_docstring(node, self.src),
+            signature: self.signature_of(node),
+            visibility: self.visibility_of(node),
+            is_async: Some(self.is_async(node)),
+            is_static: self.is_static(node),
+            ..Extra::default() // methods carry no isExported (mirrors extractMethod)
+        };
+        let Some(row) = self.create_node("method", &name, node, extra) else {
+            return;
+        };
+
+        self.extract_type_annotations(node, row);
+        self.extract_decorators_for(node, row);
+
+        self.stack.push(Scope { row, kind: "method", name });
+        if let Some(body) = body_of(node) {
+            self.visit_function_body(body);
+        }
+        self.stack.pop();
+    }
+
+    // --- extractInterface / extractEnum / members -----------------------------------
+
+    pub(super) fn extract_interface(&mut self, node: Node<'t>) {
+        let name = self.extract_name(node);
+        let extra = Extra {
+            docstring: crate::docstring::preceding_docstring(node, self.src),
+            is_exported: Some(self.is_exported(node)),
+            ..Extra::default()
+        };
+        let Some(row) = self.create_node("interface", &name, node, extra) else {
+            return;
+        };
+        self.extract_inheritance(node, row);
+        self.stack.push(Scope { row, kind: "interface", name });
+        let body = body_of(node).unwrap_or(node);
+        for i in 0..body.named_child_count() {
+            if let Some(c) = body.named_child(i) {
+                self.visit_node(c);
+            }
+        }
+        self.stack.pop();
+    }
+
+    pub(super) fn extract_enum(&mut self, node: Node<'t>) {
+        let Some(body) = body_of(node) else { return };
+        let name = self.extract_name(node);
+        let extra = Extra {
+            docstring: crate::docstring::preceding_docstring(node, self.src),
+            visibility: self.visibility_of(node),
+            is_exported: Some(self.is_exported(node)),
+            ..Extra::default()
+        };
+        let Some(row) = self.create_node("enum", &name, node, extra) else {
+            return;
+        };
+        self.extract_inheritance(node, row);
+        self.stack.push(Scope { row, kind: "enum", name });
+        for i in 0..body.named_child_count() {
+            let Some(child) = body.named_child(i) else { continue };
+            if matches!(child.kind(), "property_identifier" | "enum_assignment") {
+                self.extract_enum_members(child);
+            } else {
+                self.visit_node(child);
+            }
+        }
+        self.stack.pop();
+    }
+
+    fn extract_enum_members(&mut self, node: Node<'t>) {
+        if let Some(name_node) = node.child_by_field_name("name") {
+            let name = self.text(name_node).to_string();
+            self.create_node("enum_member", &name, node, Extra::default());
+            return;
+        }
+        let mut found = false;
+        for i in 0..node.named_child_count() {
+            if let Some(child) = node.named_child(i) {
+                if matches!(child.kind(), "simple_identifier" | "identifier" | "property_identifier") {
+                    let name = self.text(child).to_string();
+                    self.create_node("enum_member", &name, child, Extra::default());
+                    found = true;
+                }
+            }
+        }
+        if !found && node.named_child_count() == 0 {
+            let name = self.text(node).to_string();
+            self.create_node("enum_member", &name, node, Extra::default());
+        }
+    }
+
+    // --- extractProperty (#808 property-classified class fields) ---------------------
+
+    pub(super) fn extract_property(&mut self, node: Node<'t>) -> Option<(u32, String)> {
+        let docstring = crate::docstring::preceding_docstring(node, self.src);
+        let visibility = self.visibility_of(node);
+        let is_static = Some(self.is_static(node).unwrap_or(false)); // `?? false` — always present
+
+        let name_node = node
+            .child_by_field_name("name")
+            .or_else(|| node.child_by_field_name("property"))
+            .or_else(|| {
+                (0..node.named_child_count())
+                    .filter_map(|i| node.named_child(i))
+                    .find(|c| c.kind() == "identifier")
+            })?;
+        let name = self.text(name_node).to_string();
+
+        // TS/JS field definitions carry an explicit `type` field; the generic
+        // scan is for other languages (#808).
+        let type_text = node.child_by_field_name("type").map(|t| {
+            let raw = self.text(t);
+            raw.strip_prefix(':').unwrap_or(raw).trim_start().to_string()
+        });
+        let signature = match &type_text {
+            Some(t) => format!("{t} {name}"),
+            None => name.clone(),
+        };
+
+        let row = self.create_node(
+            "property",
+            &name,
+            node,
+            Extra { docstring, signature: Some(signature), visibility, is_static, ..Extra::default() },
+        )?;
+        self.extract_decorators_for(node, row);
+        self.extract_type_annotations(node, row);
+        Some((row, name))
+    }
+
+    // --- extractVariable (TS/JS branch) ------------------------------------------------
+
+    pub(super) fn extract_variable(&mut self, node: Node<'t>) {
+        let is_const = self.is_const_decl(node);
+        let kind: &'static str = if is_const { "constant" } else { "variable" };
+        let docstring = crate::docstring::preceding_docstring(node, self.src);
+        let is_exported = self.is_exported(node); // `?? false` — always present
+
+        for i in 0..node.named_child_count() {
+            let Some(child) = node.named_child(i) else { continue };
+            if child.kind() != "variable_declarator" {
+                continue;
+            }
+            let Some(name_node) = child.child_by_field_name("name") else { continue };
+            let value = child.child_by_field_name("value");
+
+            // Destructured patterns are skipped — except RTK Query generated
+            // hooks (`export const { useGetXQuery } = api`).
+            if matches!(name_node.kind(), "object_pattern" | "array_pattern") {
+                if name_node.kind() == "object_pattern"
+                    && value.map(|v| v.kind() == "identifier").unwrap_or(false)
+                {
+                    self.extract_rtk_hook_bindings(name_node, is_exported);
+                }
+                continue;
+            }
+            let name = self.text(name_node).to_string();
+
+            // Arrow/function values extract as functions, named by the declarator.
+            if let Some(v) = value {
+                if matches!(v.kind(), "arrow_function" | "function_expression") {
+                    self.extract_function(v, None);
+                    continue;
+                }
+            }
+
+            let init_signature = value.map(|v| util::init_signature(self.text(v)));
+
+            // React HOC-wrapped components (#841), PascalCase-gated.
+            if let Some(v) = value {
+                if util::pascal_case().is_match(&name) {
+                    if let Some(inner) = self.react_component_hoc(v) {
+                        self.extract_react_component_node(
+                            &name,
+                            child,
+                            inner,
+                            Extra {
+                                docstring: docstring.clone(),
+                                signature: init_signature.clone(),
+                                is_exported: Some(is_exported),
+                                ..Extra::default()
+                            },
+                        );
+                        continue;
+                    }
+                }
+            }
+
+            let var_row = self.create_node(
+                kind,
+                &name,
+                child,
+                Extra {
+                    docstring: docstring.clone(),
+                    signature: init_signature.clone(),
+                    is_exported: Some(is_exported),
+                    ..Extra::default()
+                },
+            );
+            if let Some(row) = var_row {
+                self.extract_variable_type_annotation(child, row);
+            }
+
+            // Exported const object-of-functions / store shapes.
+            let object_of_fns: Option<Node> = match value {
+                Some(v) if matches!(v.kind(), "object" | "object_expression") => Some(v),
+                Some(v) if v.kind() == "call_expression" => self.find_initializer_returned_object(v, 0),
+                _ => None,
+            };
+            let has_inline_fns = object_of_fns
+                .map(|o| self.object_has_inline_functions(o))
+                .unwrap_or(false);
+            let extract_object_methods = is_exported && object_of_fns.is_some() && has_inline_fns;
+
+            let rtk_endpoints = match value {
+                Some(v) if v.kind() == "call_expression" => self.find_rtk_endpoints_object(v),
+                _ => None,
+            };
+            let pinia_setup = match value {
+                Some(v) if v.kind() == "call_expression" => self.find_pinia_setup_fn(v),
+                _ => None,
+            };
+            let mut store_collections: Vec<Node> = Vec::new();
+            if let Some(v) = value {
+                if matches!(v.kind(), "call_expression" | "new_expression") {
+                    store_collections.extend(self.find_vue_store_collection_objects(v));
+                }
+            }
+            if let Some(obj) = object_of_fns {
+                if !extract_object_methods
+                    && is_vue_collection_name(&name)
+                    && self.looks_like_vue_store_file()
+                {
+                    store_collections.push(obj);
+                }
+            }
+
+            // Walk the initializer for calls — except the object/store shapes
+            // whose members are extracted method-by-method below.
+            if let Some(v) = value {
+                let vk = v.kind();
+                if vk != "object"
+                    && vk != "object_expression"
+                    && !(extract_object_methods && vk == "call_expression")
+                    && rtk_endpoints.is_none()
+                    && pinia_setup.is_none()
+                    && store_collections.is_empty()
+                {
+                    self.visit_function_body(v);
+                }
+            }
+
+            if extract_object_methods {
+                if let Some(obj) = object_of_fns {
+                    self.extract_object_literal_functions(obj);
+                }
+            }
+            if let Some(rtk) = rtk_endpoints {
+                self.extract_rtk_endpoints(rtk);
+            }
+            if let Some(setup) = pinia_setup {
+                self.extract_pinia_setup_body(setup);
+            }
+            for coll in store_collections {
+                self.extract_object_literal_functions(coll);
+            }
+        }
+    }
+
+    /// extractRtkHookBindings — `export const { useGetXQuery } = api`.
+    fn extract_rtk_hook_bindings(&mut self, pattern: Node<'t>, is_exported: bool) {
+        for i in 0..pattern.named_child_count() {
+            let Some(binding) = pattern.named_child(i) else { continue };
+            if binding.kind() != "shorthand_property_identifier_pattern" {
+                continue;
+            }
+            let name = self.text(binding).to_string();
+            if !util::rtk_hook_name().is_match(&name) {
+                continue;
+            }
+            self.create_node(
+                "function",
+                &name,
+                binding,
+                Extra {
+                    is_exported: Some(is_exported),
+                    signature: Some("= RTK Query generated hook".to_string()),
+                    ..Extra::default()
+                },
+            );
+        }
+    }
+
+    // --- object-literal / store helpers -------------------------------------------------
+
+    pub(super) fn extract_object_literal_functions(&mut self, obj: Node<'t>) {
+        for i in 0..obj.named_child_count() {
+            let Some(member) = obj.named_child(i) else { continue };
+            if member.kind() == "pair" {
+                let key = member.child_by_field_name("key");
+                let value = member.child_by_field_name("value");
+                if let (Some(k), Some(v)) = (key, value) {
+                    if matches!(v.kind(), "arrow_function" | "function_expression") {
+                        let name = util::object_key_name(self.text(k));
+                        self.extract_function(v, Some(name));
+                    }
+                }
+            } else if member.kind() == "method_definition" {
+                if let Some(k) = member.child_by_field_name("name") {
+                    let name = util::object_key_name(self.text(k));
+                    self.extract_function(member, Some(name));
+                }
+            }
+        }
+    }
+
+    fn find_initializer_returned_object(&self, call: Node<'t>, depth: u32) -> Option<Node<'t>> {
+        if depth > 4 {
+            return None;
+        }
+        let args = call.child_by_field_name("arguments")?;
+        for i in 0..args.named_child_count() {
+            let Some(arg) = args.named_child(i) else { continue };
+            if matches!(arg.kind(), "arrow_function" | "function_expression") {
+                if let Some(obj) = self.function_returned_object(arg) {
+                    return Some(obj);
+                }
+            } else if arg.kind() == "call_expression" {
+                if let Some(obj) = self.find_initializer_returned_object(arg, depth + 1) {
+                    return Some(obj);
+                }
+            }
+        }
+        None
+    }
+
+    fn function_returned_object(&self, fn_node: Node<'t>) -> Option<Node<'t>> {
+        fn as_object<'t>(n: Node<'t>) -> Option<Node<'t>> {
+            match n.kind() {
+                "object" | "object_expression" => Some(n),
+                "parenthesized_expression" => {
+                    for i in 0..n.named_child_count() {
+                        if let Some(inner) = n.named_child(i).and_then(as_object) {
+                            return Some(inner);
+                        }
+                    }
+                    None
+                }
+                _ => None,
+            }
+        }
+        let body = fn_node.child_by_field_name("body")?;
+        if let Some(direct) = as_object(body) {
+            return Some(direct);
+        }
+        if body.kind() == "statement_block" {
+            for i in 0..body.named_child_count() {
+                let Some(stmt) = body.named_child(i) else { continue };
+                if stmt.kind() != "return_statement" {
+                    continue;
+                }
+                for j in 0..stmt.named_child_count() {
+                    if let Some(obj) = stmt.named_child(j).and_then(as_object) {
+                        return Some(obj);
+                    }
+                }
+            }
+        }
+        None
+    }
+
+    pub(super) fn object_has_inline_functions(&self, obj: Node) -> bool {
+        for i in 0..obj.named_child_count() {
+            let Some(member) = obj.named_child(i) else { continue };
+            if member.kind() == "method_definition" {
+                return true;
+            }
+            if member.kind() == "pair" {
+                if let Some(v) = member.child_by_field_name("value") {
+                    if matches!(v.kind(), "arrow_function" | "function_expression") {
+                        return true;
+                    }
+                }
+            }
+        }
+        false
+    }
+
+    fn find_rtk_endpoints_object(&self, call: Node<'t>) -> Option<Node<'t>> {
+        let callee = call.child_by_field_name("function")?;
+        let callee_name = match callee.kind() {
+            "identifier" => self.text(callee),
+            "member_expression" => {
+                let prop = callee.child_by_field_name("property").unwrap_or(callee);
+                self.text(prop)
+            }
+            _ => "",
+        };
+        if callee_name != "createApi" && callee_name != "injectEndpoints" {
+            return None;
+        }
+        let args = call.child_by_field_name("arguments")?;
+        for i in 0..args.named_child_count() {
+            let Some(arg) = args.named_child(i) else { continue };
+            if !matches!(arg.kind(), "object" | "object_expression") {
+                continue;
+            }
+            for j in 0..arg.named_child_count() {
+                let Some(member) = arg.named_child(j) else { continue };
+                if member.kind() == "pair" {
+                    let Some(key) = member.child_by_field_name("key") else { continue };
+                    if self.text(key) != "endpoints" {
+                        continue;
+                    }
+                    if let Some(value) = member.child_by_field_name("value") {
+                        if matches!(value.kind(), "arrow_function" | "function_expression") {
+                            return self.function_returned_object(value);
+                        }
+                    }
+                } else if member.kind() == "method_definition" {
+                    let Some(key) = member.child_by_field_name("name") else { continue };
+                    if self.text(key) != "endpoints" {
+                        continue;
+                    }
+                    return self.function_returned_object(member);
+                }
+            }
+        }
+        None
+    }
+
+    fn extract_rtk_endpoints(&mut self, obj: Node<'t>) {
+        for i in 0..obj.named_child_count() {
+            let Some(member) = obj.named_child(i) else { continue };
+            if member.kind() != "pair" {
+                continue;
+            }
+            let key = member.child_by_field_name("key");
+            let value = member.child_by_field_name("value");
+            let (Some(key), Some(value)) = (key, value) else { continue };
+            if value.kind() != "call_expression" {
+                continue;
+            }
+            let Some(callee) = value.child_by_field_name("function") else { continue };
+            if callee.kind() != "member_expression" {
+                continue;
+            }
+            let method = self.text(callee.child_by_field_name("property").unwrap_or(callee));
+            if method != "query" && method != "mutation" && method != "infiniteQuery" {
+                continue;
+            }
+            let key_name = util::object_key_name(self.text(key));
+            if let Some(handler) = self.rtk_endpoint_handler(value) {
+                self.extract_function(handler, Some(key_name));
+            } else {
+                // Config-only endpoint: bare node spanning the builder call.
+                let (sig, _) = util::slice_utf16(self.text(value), 80);
+                let row = self.create_node(
+                    "function",
+                    &key_name,
+                    value,
+                    Extra { signature: Some(sig), ..Extra::default() },
+                );
+                if let Some(row) = row {
+                    self.stack.push(Scope { row, kind: "function", name: key_name });
+                    self.visit_function_body(value);
+                    self.stack.pop();
+                }
+            }
+        }
+    }
+
+    fn rtk_endpoint_handler(&self, call: Node<'t>) -> Option<Node<'t>> {
+        let args = call.child_by_field_name("arguments")?;
+        for i in 0..args.named_child_count() {
+            let Some(arg) = args.named_child(i) else { continue };
+            if !matches!(arg.kind(), "object" | "object_expression") {
+                continue;
+            }
+            let mut query_fn: Option<Node> = None;
+            let mut query: Option<Node> = None;
+            let mut first_fn: Option<Node> = None;
+            for j in 0..arg.named_child_count() {
+                let Some(member) = arg.named_child(j) else { continue };
+                let mut fn_node: Option<Node> = None;
+                let mut key_name = "";
+                if member.kind() == "pair" {
+                    if let Some(v) = member.child_by_field_name("value") {
+                        if matches!(v.kind(), "arrow_function" | "function_expression") {
+                            fn_node = Some(v);
+                            if let Some(k) = member.child_by_field_name("key") {
+                                key_name = self.text(k);
+                            }
+                        }
+                    }
+                } else if member.kind() == "method_definition" {
+                    fn_node = Some(member);
+                    if let Some(k) = member.child_by_field_name("name") {
+                        key_name = self.text(k);
+                    }
+                }
+                let Some(f) = fn_node else { continue };
+                if key_name == "queryFn" {
+                    query_fn = Some(f);
+                } else if key_name == "query" {
+                    query = Some(f);
+                }
+                if first_fn.is_none() {
+                    first_fn = Some(f);
+                }
+            }
+            if let Some(f) = query_fn.or(query).or(first_fn) {
+                return Some(f);
+            }
+        }
+        None
+    }
+
+    pub(super) fn looks_like_vue_store_file(&mut self) -> bool {
+        if let Some(v) = self.vue_store_file {
+            return v;
+        }
+        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
+        for m in util::vue_store_signal().find_iter(self.src) {
+            seen.insert(m.as_str());
+            if seen.len() >= 2 {
+                break;
+            }
+        }
+        let v = seen.len() >= 2;
+        self.vue_store_file = Some(v);
+        v
+    }
+
+    fn find_vue_store_collection_objects(&self, call: Node<'t>) -> Vec<Node<'t>> {
+        let callee = call
+            .child_by_field_name("function")
+            .or_else(|| call.child_by_field_name("constructor"));
+        let Some(callee) = callee else { return vec![] };
+        let callee_name = match callee.kind() {
+            "identifier" => self.text(callee),
+            "member_expression" => self.text(callee.child_by_field_name("property").unwrap_or(callee)),
+            _ => "",
+        };
+        if !matches!(callee_name, "defineStore" | "createStore" | "Store") {
+            return vec![];
+        }
+        let Some(args) = call.child_by_field_name("arguments") else { return vec![] };
+        let mut objects = Vec::new();
+        for i in 0..args.named_child_count() {
+            let Some(arg) = args.named_child(i) else { continue };
+            if !matches!(arg.kind(), "object" | "object_expression") {
+                continue;
+            }
+            for j in 0..arg.named_child_count() {
+                let Some(member) = arg.named_child(j) else { continue };
+                if member.kind() != "pair" {
+                    continue;
+                }
+                let Some(key) = member.child_by_field_name("key") else { continue };
+                if !is_vue_collection_name(self.text(key)) {
+                    continue;
+                }
+                if let Some(value) = member.child_by_field_name("value") {
+                    if matches!(value.kind(), "object" | "object_expression") {
+                        objects.push(value);
+                    }
+                }
+            }
+        }
+        objects
+    }
+
+    pub(super) fn extract_store_collection_methods(&mut self, config: Node<'t>) {
+        for i in 0..config.named_child_count() {
+            let Some(member) = config.named_child(i) else { continue };
+            if member.kind() != "pair" {
+                continue;
+            }
+            let Some(key) = member.child_by_field_name("key") else { continue };
+            if !is_vue_collection_name(self.text(key)) {
+                continue;
+            }
+            if let Some(value) = member.child_by_field_name("value") {
+                if matches!(value.kind(), "object" | "object_expression") {
+                    self.extract_object_literal_functions(value);
+                }
+            }
+        }
+    }
+
+    fn find_pinia_setup_fn(&self, call: Node<'t>) -> Option<Node<'t>> {
+        let callee = call.child_by_field_name("function")?;
+        if callee.kind() != "identifier" || self.text(callee) != "defineStore" {
+            return None;
+        }
+        let args = call.child_by_field_name("arguments")?;
+        for i in 0..args.named_child_count() {
+            let Some(arg) = args.named_child(i) else { continue };
+            if !matches!(arg.kind(), "arrow_function" | "function_expression") {
+                continue;
+            }
+            if let Some(body) = arg.child_by_field_name("body") {
+                if body.kind() == "statement_block" {
+                    return Some(arg);
+                }
+            }
+        }
+        None
+    }
+
+    fn extract_pinia_setup_body(&mut self, setup: Node<'t>) {
+        let Some(body) = setup.child_by_field_name("body") else { return };
+        if body.kind() != "statement_block" {
+            return;
+        }
+        for i in 0..body.named_child_count() {
+            let Some(stmt) = body.named_child(i) else { continue };
+            if stmt.kind() == "function_declaration" {
+                self.extract_function(stmt, None);
+            } else if is_variable_type(stmt.kind()) {
+                for j in 0..stmt.named_child_count() {
+                    let Some(decl) = stmt.named_child(j) else { continue };
+                    if decl.kind() != "variable_declarator" {
+                        continue;
+                    }
+                    if let Some(v) = decl.child_by_field_name("value") {
+                        if matches!(v.kind(), "arrow_function" | "function_expression") {
+                            self.extract_function(v, None);
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    // --- extractTypeAlias + members (#359, #634) -------------------------------------
+
+    /// Returns skipChildren (always false on the TS path — the alias value is
+    /// still traversed by the dispatcher).
+    pub(super) fn extract_type_alias(&mut self, node: Node<'t>) -> bool {
+        let name = self.extract_name(node);
+        if name == "<anonymous>" {
+            return false;
+        }
+        let extra = Extra {
+            docstring: crate::docstring::preceding_docstring(node, self.src),
+            is_exported: Some(self.is_exported(node)),
+            ..Extra::default()
+        };
+        let Some(row) = self.create_node("type_alias", &name, node, extra) else {
+            return false;
+        };
+        if let Some(value) = node.child_by_field_name("value") {
+            self.extract_type_refs_from_subtree(value, row);
+            self.extract_ts_type_alias_members(value, row, &name);
+            self.extract_ts_tuple_contract_names(value, row, &name);
+        }
+        false
+    }
+
+    fn extract_ts_type_alias_members(&mut self, value: Node<'t>, alias_row: u32, alias_name: &str) {
+        let mut object_types: Vec<Node> = Vec::new();
+        if value.kind() == "object_type" {
+            object_types.push(value);
+        } else if value.kind() == "intersection_type" {
+            for i in 0..value.named_child_count() {
+                if let Some(op) = value.named_child(i) {
+                    if op.kind() == "object_type" {
+                        object_types.push(op);
+                    }
+                }
+            }
+        } else {
+            return;
+        }
+
+        self.stack.push(Scope { row: alias_row, kind: "type_alias", name: alias_name.to_string() });
+        for obj_type in object_types {
+            for i in 0..obj_type.named_child_count() {
+                let Some(child) = obj_type.named_child(i) else { continue };
+                if !matches!(child.kind(), "property_signature" | "method_signature") {
+                    continue;
+                }
+                let Some(name_node) = child.child_by_field_name("name") else { continue };
+                let member_name = self.text(name_node).to_string();
+                if member_name.is_empty() {
+                    continue;
+                }
+                let member_kind: &'static str = if child.kind() == "method_signature"
+                    || self.is_ts_function_typed_property(child)
+                {
+                    "method"
+                } else {
+                    "property"
+                };
+                let extra = Extra {
+                    docstring: crate::docstring::preceding_docstring(child, self.src),
+                    signature: Some(self.text(child).to_string()),
+                    qualified_name: Some(format!("{alias_name}::{member_name}")),
+                    ..Extra::default()
+                };
+                self.create_node(member_kind, &member_name, child, extra);
+                self.extract_type_annotations(child, alias_row);
+            }
+        }
+        self.stack.pop();
+    }
+
+    fn extract_ts_tuple_contract_names(&mut self, value: Node<'t>, alias_row: u32, alias_name: &str) {
+        let mut tuples: Vec<Node> = Vec::new();
+        fn collect<'t>(n: Node<'t>, depth: u32, out: &mut Vec<Node<'t>>) {
+            if depth > 6 {
+                return;
+            }
+            if n.kind() == "tuple_type" {
+                out.push(n);
+            }
+            for i in 0..n.named_child_count() {
+                if let Some(c) = n.named_child(i) {
+                    collect(c, depth + 1, out);
+                }
+            }
+        }
+        collect(value, 0, &mut tuples);
+        if tuples.is_empty() {
+            return;
+        }
+
+        self.stack.push(Scope { row: alias_row, kind: "type_alias", name: alias_name.to_string() });
+        for tuple in tuples {
+            for i in 0..tuple.named_child_count() {
+                let Some(entry) = tuple.named_child(i) else { continue };
+                if entry.kind() != "generic_type" {
+                    continue;
+                }
+                let Some(type_args) = entry.child_by_field_name("type_arguments") else { continue };
+                for j in 0..type_args.named_child_count() {
+                    let Some(arg) = type_args.named_child(j) else { continue };
+                    if arg.kind() != "literal_type" {
+                        continue;
+                    }
+                    let Some(str_node) = arg.named_child(0) else { continue };
+                    if str_node.kind() != "string" {
+                        continue;
+                    }
+                    let name = util::object_key_name(self.text(str_node).trim());
+                    if !util::ident_dollar().is_match(&name) {
+                        continue;
+                    }
+                    let collapsed = collapse_ws(self.text(entry));
+                    let (signature, _) = util::slice_utf16(collapsed.trim(), 120);
+                    let extra = Extra {
+                        signature: Some(signature),
+                        qualified_name: Some(format!("{alias_name}::{name}")),
+                        ..Extra::default()
+                    };
+                    self.create_node("method", &name, entry, extra);
+                }
+            }
+        }
+        self.stack.pop();
+    }
+
+    fn is_ts_function_typed_property(&self, property_signature: Node) -> bool {
+        let Some(type_anno) = property_signature.child_by_field_name("type") else {
+            return false;
+        };
+        for i in 0..type_anno.named_child_count() {
+            if let Some(inner) = type_anno.named_child(i) {
+                if inner.kind() == "function_type" {
+                    return true;
+                }
+            }
+        }
+        false
+    }
+
+    // --- extractImport + binding refs ---------------------------------------------------
+
+    pub(super) fn extract_import(&mut self, node: Node<'t>) {
+        let import_text = self.text(node).trim().to_string();
+        // typescriptExtractor.extractImport: the `source` field, quotes stripped
+        // globally. A missing/empty module means the hook declined — no node.
+        let Some(source_field) = node.child_by_field_name("source") else { return };
+        let module_name: String = self
+            .text(source_field)
+            .chars()
+            .filter(|c| *c != '\'' && *c != '"')
+            .collect();
+        if module_name.is_empty() {
+            return;
+        }
+        self.create_node(
+            "import",
+            &module_name,
+            node,
+            Extra { signature: Some(import_text), ..Extra::default() },
+        );
+        let parent = self.top_row();
+        self.push_ref(parent, &module_name.clone(), edge_kind_index("imports").unwrap(), node);
+        self.emit_import_binding_refs(node, parent);
+    }
+
+    fn emit_import_binding_refs(&mut self, node: Node<'t>, from_row: u32) {
+        let clause = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| c.kind() == "import_clause");
+        let Some(clause) = clause else { return }; // side-effect import
+
+        let imports_kind = edge_kind_index("imports").unwrap();
+        let push = |w: &mut Self, name_node: Option<Node>| {
+            let Some(n) = name_node else { return };
+            let name = w.text(n).to_string();
+            if name.is_empty() {
+                return;
+            }
+            w.push_ref(from_row, &name, imports_kind, n);
+        };
+
+        for i in 0..clause.named_child_count() {
+            let Some(child) = clause.named_child(i) else { continue };
+            match child.kind() {
+                "identifier" => push(self, Some(child)),
+                "named_imports" => {
+                    for j in 0..child.named_child_count() {
+                        let Some(spec) = child.named_child(j) else { continue };
+                        if spec.kind() != "import_specifier" {
+                            continue;
+                        }
+                        let n = spec
+                            .child_by_field_name("alias")
+                            .or_else(|| spec.child_by_field_name("name"))
+                            .or_else(|| spec.named_child(0));
+                        push(self, n);
+                    }
+                }
+                "namespace_import" => {
+                    let n = (0..child.named_child_count())
+                        .filter_map(|k| child.named_child(k))
+                        .find(|c| c.kind() == "identifier")
+                        .or_else(|| child.named_child(0));
+                    push(self, n);
+                }
+                _ => {}
+            }
+        }
+    }
+
+    pub(super) fn emit_re_export_refs(&mut self, node: Node<'t>) {
+        let from_row = self.top_row();
+        let clause = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| c.kind() == "export_clause");
+        let Some(clause) = clause else { return }; // `export * from './y'`
+        let imports_kind = edge_kind_index("imports").unwrap();
+        for i in 0..clause.named_child_count() {
+            let Some(spec) = clause.named_child(i) else { continue };
+            if spec.kind() != "export_specifier" {
+                continue;
+            }
+            let name_node = spec.child_by_field_name("name").or_else(|| spec.named_child(0));
+            let Some(n) = name_node else { continue };
+            let name = self.text(n).to_string();
+            if name.is_empty() || name == "default" {
+                continue;
+            }
+            self.push_ref(from_row, &name, imports_kind, n);
+        }
+    }
+
+    // --- extractCall (TS/JS generic tail) -------------------------------------------------
+
+    pub(super) fn extract_call(&mut self, node: Node<'t>) {
+        if self.stack.is_empty() {
+            return;
+        }
+        let func = node
+            .child_by_field_name("function")
+            .or_else(|| node.named_child(0));
+        let mut callee_name = String::new();
+
+        if let Some(func) = func {
+            if func.kind() == "member_expression" {
+                let property = func
+                    .child_by_field_name("property")
+                    .or_else(|| func.child_by_field_name("field"))
+                    .or_else(|| func.named_child(1));
+                if let Some(property) = property {
+                    let method_name = self.text(property);
+                    let receiver = func
+                        .child_by_field_name("object")
+                        .or_else(|| func.child_by_field_name("operand"))
+                        .or_else(|| func.child_by_field_name("argument"))
+                        .or_else(|| func.named_child(0));
+                    // Literal receivers call builtins, never project symbols (#1230).
+                    if let Some(r) = receiver {
+                        if is_literal_receiver(r.kind()) {
+                            return;
+                        }
+                    }
+                    let recv_ident = receiver.filter(|r| {
+                        matches!(r.kind(), "identifier" | "simple_identifier" | "field_identifier")
+                    });
+                    if let Some(r) = recv_ident {
+                        let receiver_name = self.text(r);
+                        if !matches!(receiver_name, "self" | "this" | "cls" | "super") {
+                            callee_name = format!("{receiver_name}.{method_name}");
+                        } else {
+                            callee_name = method_name.to_string();
+                        }
+                    } else {
+                        // (the call-receiver re-encode branches are other
+                        // languages'; TS/JS keeps the bare method name)
+                        callee_name = method_name.to_string();
+                    }
+                }
+            } else {
+                callee_name = self.text(func).to_string();
+            }
+        }
+
+        // Parenthesized-callee normalization (`(fn)()` → fn).
+        if !callee_name.is_empty() {
+            if let Some(c) = util::paren_conversion().captures(&callee_name) {
+                callee_name = c[1].to_string();
+            }
+        }
+
+        if !callee_name.is_empty() {
+            self.push_call_ref(&callee_name.clone(), node);
+        }
+    }
+
+    // --- extractInstantiation -----------------------------------------------------------
+
+    pub(super) fn extract_instantiation(&mut self, node: Node<'t>) {
+        if self.stack.is_empty() {
+            return;
+        }
+        let ctor = node
+            .child_by_field_name("constructor")
+            .or_else(|| node.child_by_field_name("type"))
+            .or_else(|| node.child_by_field_name("name"))
+            .or_else(|| node.named_child(0));
+        let Some(ctor) = ctor else { return };
+
+        let mut class_name = self.text(ctor).to_string();
+        // `new Map<K, V>()` → Map.
+        if let Some(lt) = class_name.find('<') {
+            if lt > 0 {
+                class_name.truncate(lt);
+            }
+        }
+        // `new ns.Foo()` → Foo.
+        let last_dot = class_name
+            .rfind('.')
+            .map(|i| i as isize)
+            .unwrap_or(-1)
+            .max(class_name.rfind("::").map(|i| i as isize).unwrap_or(-1));
+        if last_dot >= 0 {
+            class_name = class_name[(last_dot as usize + 1)..].to_string();
+            // TS: .replace(/^[:.]/, '') — one leading colon-or-dot.
+            if class_name.starts_with(':') || class_name.starts_with('.') {
+                class_name.remove(0);
+            }
+        }
+        let class_name = class_name.trim().to_string();
+        if !class_name.is_empty() {
+            let from = self.top_row();
+            self.push_ref(from, &class_name, edge_kind_index("instantiates").unwrap(), node);
+        }
+    }
+
+    // --- extractDecoratorsFor --------------------------------------------------------------
+
+    pub(super) fn extract_decorators_for(&mut self, decl: Node<'t>, decorated_row: u32) {
+        // 1. Direct children (method/property style).
+        for i in 0..decl.named_child_count() {
+            let Some(child) = decl.named_child(i) else { continue };
+            self.consider_decorator(child, decorated_row);
+            if child.kind() == "modifiers" {
+                for j in 0..child.named_child_count() {
+                    if let Some(m) = child.named_child(j) {
+                        self.consider_decorator(m, decorated_row);
+                    }
+                }
+            }
+        }
+        // 2. Preceding siblings (TypeScript class style), stopping at the
+        //    first non-decorator so an earlier declaration's decorators never
+        //    leak in. Matching by startIndex, not object identity.
+        let Some(parent) = decl.parent() else { return };
+        let decl_start = decl.start_byte();
+        let mut decl_idx: isize = -1;
+        for i in 0..parent.named_child_count() {
+            if let Some(sib) = parent.named_child(i) {
+                if sib.start_byte() == decl_start {
+                    decl_idx = i as isize;
+                    break;
+                }
+            }
+        }
+        if decl_idx > 0 {
+            let mut j = decl_idx - 1;
+            while j >= 0 {
+                let Some(sib) = parent.named_child(j as usize) else {
+                    j -= 1;
+                    continue;
+                };
+                if !matches!(sib.kind(), "decorator" | "annotation" | "marker_annotation") {
+                    break;
+                }
+                self.consider_decorator(sib, decorated_row);
+                j -= 1;
+            }
+        }
+    }
+
+    fn consider_decorator(&mut self, n: Node<'t>, decorated_row: u32) {
+        if !matches!(n.kind(), "decorator" | "annotation" | "marker_annotation" | "attribute") {
+            return;
+        }
+        let mut target: Option<Node> = None;
+        for i in 0..n.named_child_count() {
+            let Some(child) = n.named_child(i) else { continue };
+            if child.kind() == "call_expression" {
+                target = child.child_by_field_name("function").or_else(|| child.named_child(0));
+                if target.is_some() {
+                    break;
+                }
+            }
+            if matches!(
+                child.kind(),
+                "identifier" | "member_expression" | "scoped_identifier" | "navigation_expression"
+                    | "user_type" | "type_identifier"
+            ) {
+                target = Some(child);
+                break;
+            }
+        }
+        let Some(target) = target else { return };
+        let mut name = self.text(target).to_string();
+        if let Some(lt) = name.find('<') {
+            if lt > 0 {
+                name.truncate(lt);
+            }
+        }
+        let last_dot = name
+            .rfind('.')
+            .map(|i| i as isize)
+            .unwrap_or(-1)
+            .max(name.rfind("::").map(|i| i as isize).unwrap_or(-1));
+        if last_dot >= 0 {
+            name = name[(last_dot as usize + 1)..].to_string();
+            if name.starts_with(':') || name.starts_with('.') {
+                name.remove(0);
+            }
+        }
+        let name = name.trim().to_string();
+        if name.is_empty() {
+            return;
+        }
+        self.push_ref(decorated_row, &name, edge_kind_index("decorates").unwrap(), n);
+    }
+
+    // --- extractInheritance (TS/JS clauses) ---------------------------------------------------
+
+    pub(super) fn extract_inheritance(&mut self, node: Node<'t>, class_row: u32) {
+        let extends_kind = edge_kind_index("extends").unwrap();
+        let implements_kind = edge_kind_index("implements").unwrap();
+        for i in 0..node.named_child_count() {
+            let Some(child) = node.named_child(i) else { continue };
+            match child.kind() {
+                // TS `extends_clause` (the other spellings are other grammars').
+                "extends_clause" | "superclass" | "base_clause" | "extends_interfaces" => {
+                    if let Some(target) = child.named_child(0) {
+                        let name = self.text(target).to_string();
+                        self.push_ref(class_row, &name, extends_kind, target);
+                    }
+                }
+                "implements_clause" | "class_interface_clause" | "super_interfaces" | "interfaces" => {
+                    for j in 0..child.named_child_count() {
+                        if let Some(iface) = child.named_child(j) {
+                            let name = self.text(iface).to_string();
+                            self.push_ref(class_row, &name, implements_kind, iface);
+                        }
+                    }
+                }
+                // JS `class Foo extends Bar` — class_heritage holds a bare
+                // identifier without an extends_clause wrapper.
+                "identifier" | "type_identifier" if node.kind() == "class_heritage" => {
+                    let name = self.text(child).to_string();
+                    self.push_ref(class_row, &name, extends_kind, child);
+                }
+                // TS class_heritage wraps extends/implements — recurse.
+                "field_declaration_list" | "class_heritage" => {
+                    self.extract_inheritance(child, class_row);
+                }
+                _ => {}
+            }
+        }
+    }
+
+    // --- type annotations (#381 — TS family only) ----------------------------------------------
+
+    pub(super) fn extract_type_annotations(&mut self, node: Node<'t>, from_row: u32) {
+        if !self.variant.is_ts() {
+            return;
+        }
+        if let Some(params) = node.child_by_field_name("parameters") {
+            self.extract_type_refs_from_subtree(params, from_row);
+        }
+        if let Some(ret) = node.child_by_field_name("return_type") {
+            self.extract_type_refs_from_subtree(ret, from_row);
+        }
+        let type_annotation = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| c.kind() == "type_annotation");
+        if let Some(ta) = type_annotation {
+            self.extract_type_refs_from_subtree(ta, from_row);
+        }
+    }
+
+    pub(super) fn extract_variable_type_annotation(&mut self, node: Node<'t>, from_row: u32) {
+        if !self.variant.is_ts() {
+            return;
+        }
+        let type_annotation = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| c.kind() == "type_annotation");
+        if let Some(ta) = type_annotation {
+            self.extract_type_refs_from_subtree(ta, from_row);
+        }
+    }
+
+    fn extract_type_refs_from_subtree(&mut self, node: Node<'t>, from_row: u32) {
+        if node.kind() == "type_identifier" {
+            let type_name = self.text(node).to_string();
+            if !type_name.is_empty() && !is_builtin_type(&type_name) {
+                self.push_ref(from_row, &type_name, edge_kind_index("references").unwrap(), node);
+            }
+            return;
+        }
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                self.extract_type_refs_from_subtree(c, from_row);
+            }
+        }
+    }
+}
+
+/// `.replace(/\s+/g, ' ')` for the tuple-contract signature.
+fn collapse_ws(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    let mut in_ws = false;
+    for c in s.chars() {
+        if c.is_whitespace() {
+            if !in_ws {
+                out.push(' ');
+                in_ws = true;
+            }
+        } else {
+            out.push(c);
+            in_ws = false;
+        }
+    }
+    out
+}

+ 133 - 0
codegraph-kernel/src/tsjs/fnref.rs

@@ -0,0 +1,133 @@
+//! Function-as-value capture (#756) — the TS/JS slice of
+//! src/extraction/function-ref.ts (TS_JS_SPEC): container dispatch, value
+//! normalization, and the `this.member` special form. The flush-time gate
+//! lives in the walker (it needs the file's nodes and import refs).
+
+use tree_sitter::Node;
+
+/// CaptureMode (function-ref.ts) — gate policy keys on it.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum Mode {
+    Args,
+    Rhs,
+    Value,
+    List,
+    VarInit,
+}
+
+pub struct Candidate {
+    pub name: String,
+    pub line: u32,
+    pub column_byte: usize, // converted to UTF-16 at emit time
+    pub row: usize,
+}
+
+/// NAME_STOPLIST (function-ref.ts).
+fn stoplisted(name: &str) -> bool {
+    matches!(
+        name,
+        "this" | "self" | "super" | "null" | "nil" | "true" | "false" | "undefined" | "new"
+            | "NULL" | "nullptr" | "None"
+    )
+}
+
+/// TS_JS_SPEC.dispatch: container node type → capture mode.
+pub fn dispatch(kind: &str) -> Option<Mode> {
+    match kind {
+        "arguments" => Some(Mode::Args),
+        "assignment_expression" => Some(Mode::Rhs),
+        "variable_declarator" => Some(Mode::VarInit),
+        "pair" => Some(Mode::Value),
+        "array" => Some(Mode::List),
+        _ => None,
+    }
+}
+
+/// captureFnRefCandidates for the TS/JS spec. Returns (candidate, mode) pairs.
+pub fn capture(container: Node, mode: Mode, src: &str) -> Vec<(Candidate, Mode)> {
+    let mut value_nodes: Vec<Node> = Vec::new();
+
+    match mode {
+        Mode::Args | Mode::List => {
+            for i in 0..container.named_child_count() {
+                if let Some(c) = container.named_child(i) {
+                    value_nodes.push(c);
+                }
+            }
+        }
+        Mode::Rhs => {
+            if let Some(rhs) = container.child_by_field_name("right") {
+                // Param-storage skip: `this.status = status` — LHS's trailing
+                // identifier equals the RHS text ⇒ a stored local/parameter.
+                let lhs_text = container
+                    .child_by_field_name("left")
+                    .map(|l| &src[l.byte_range()])
+                    .unwrap_or("");
+                let lhs_last = super::util::lhs_last_name()
+                    .captures(lhs_text)
+                    .and_then(|c| c.get(1))
+                    .map(|m| m.as_str());
+                let rhs_text = src[rhs.byte_range()].trim();
+                if !(lhs_last.is_some() && lhs_last == Some(rhs_text)) {
+                    value_nodes.push(rhs);
+                }
+            }
+        }
+        Mode::Value => {
+            if let Some(v) = container.child_by_field_name("value") {
+                value_nodes.push(v);
+            }
+        }
+        Mode::VarInit => {
+            // Destructuring extracts DATA, never a function alias.
+            let name_node = container.child_by_field_name("name");
+            let is_pattern = name_node
+                .map(|n| matches!(n.kind(), "object_pattern" | "array_pattern"))
+                .unwrap_or(false);
+            if !is_pattern {
+                if let Some(v) = container.child_by_field_name("value") {
+                    value_nodes.push(v);
+                }
+            }
+        }
+    }
+
+    let mut out = Vec::new();
+    for v in value_nodes {
+        for (name, node) in normalize(v, src) {
+            if name.is_empty() || stoplisted(&name) {
+                continue;
+            }
+            let p = node.start_position();
+            out.push((
+                Candidate {
+                    name,
+                    line: p.row as u32 + 1,
+                    column_byte: node.start_byte(),
+                    row: p.row,
+                },
+                mode,
+            ));
+        }
+    }
+    out
+}
+
+/// normalizeValue for the TS/JS spec: bare identifiers, plus the
+/// `this.<member>` member_expression special form (object EXACTLY `this`).
+fn normalize<'t>(node: Node<'t>, src: &str) -> Vec<(String, Node<'t>)> {
+    match node.kind() {
+        "identifier" => vec![(src[node.byte_range()].to_string(), node)],
+        "member_expression" => {
+            let obj = node.child_by_field_name("object");
+            let prop = node.child_by_field_name("property");
+            if let (Some(o), Some(p)) = (obj, prop) {
+                if o.kind() == "this" && p.kind() == "property_identifier" {
+                    return vec![(format!("this.{}", &src[p.byte_range()]), p)];
+                }
+            }
+            vec![]
+        }
+        _ => vec![],
+    }
+}

+ 905 - 0
codegraph-kernel/src/tsjs/mod.rs

@@ -0,0 +1,905 @@
+//! TypeScript / TSX / JavaScript / JSX extraction — a faithful Rust port of
+//! `TreeSitterExtractor`'s TS/JS paths (src/extraction/tree-sitter.ts) plus
+//! the typescript/javascript LanguageExtractor configs.
+//!
+//! Porting contract (R2 of the migration plan): behavior parity with the wasm
+//! path, verified by scripts/kernel-parity.mjs over real repos — including
+//! bug-for-bug fidelity where the TS code has quirks. Every function notes the
+//! TS function it mirrors; if you change one side, change the other or the
+//! parity gate fails. Positions are emitted in UTF-16 code units (what
+//! web-tree-sitter reports), see util::col16.
+
+mod extractors;
+mod fnref;
+use crate::textutil as util;
+
+use crate::buffers::{
+    build_meta, edge_kind_index, node_kind_index, Arena, BoolFlags, EdgeRow, EmitOut, NodeRow,
+    RefRow, StrRef, Tables, FLAG_IS_ASYNC, FLAG_IS_EXPORTED, FLAG_IS_STATIC, FUNCTION_REF_CODE,
+    NONE, NONE_STR,
+};
+use crate::ids;
+use crate::langs;
+use std::collections::{HashMap, HashSet};
+use tree_sitter::{Node, Parser};
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum Variant {
+    Typescript,
+    Tsx,
+    Javascript,
+    Jsx,
+}
+
+impl Variant {
+    pub fn from_language(language: &str) -> Option<Variant> {
+        match language {
+            "typescript" => Some(Variant::Typescript),
+            "tsx" => Some(Variant::Tsx),
+            "javascript" => Some(Variant::Javascript),
+            "jsx" => Some(Variant::Jsx),
+            _ => None,
+        }
+    }
+    /// TS-family (typescript/tsx): type annotations, interfaces, enums,
+    /// aliases, visibility, isStatic. The JS family lacks all of those hooks.
+    fn is_ts(self) -> bool {
+        matches!(self, Variant::Typescript | Variant::Tsx)
+    }
+    /// VALUE_REF_LANGS includes typescript/tsx/javascript but NOT jsx.
+    fn value_refs(self) -> bool {
+        !matches!(self, Variant::Jsx)
+    }
+}
+
+/// typescriptExtractor.methodTypes / javascriptExtractor.methodTypes.
+fn is_method_type(v: Variant, kind: &str) -> bool {
+    kind == "method_definition"
+        || (v.is_ts() && kind == "public_field_definition")
+        || (!v.is_ts() && kind == "field_definition")
+}
+
+fn is_function_type(kind: &str) -> bool {
+    matches!(kind, "function_declaration" | "arrow_function" | "function_expression")
+}
+
+fn is_class_type(v: Variant, kind: &str) -> bool {
+    kind == "class_declaration" || (v.is_ts() && kind == "abstract_class_declaration")
+}
+
+fn is_variable_type(kind: &str) -> bool {
+    matches!(kind, "lexical_declaration" | "variable_declaration")
+}
+
+/// LITERAL_RECEIVER_TYPES (tree-sitter.ts) — full set; only a handful occur in
+/// TS/JS grammars but membership is what the TS code tests.
+fn is_literal_receiver(kind: &str) -> bool {
+    matches!(
+        kind,
+        "string" | "string_literal" | "interpreted_string_literal" | "raw_string_literal"
+            | "template_string" | "concatenated_string" | "formatted_string" | "f_string"
+            | "line_string_literal" | "string_content" | "heredoc_body"
+            | "number" | "number_literal" | "integer" | "integer_literal" | "float"
+            | "float_literal" | "int_literal" | "decimal_integer_literal" | "real_literal"
+            | "char_literal" | "character_literal" | "rune_literal" | "regex" | "regex_literal"
+            | "true" | "false" | "boolean_literal" | "bool_literal" | "none" | "null" | "nil"
+            | "null_literal" | "undefined"
+            | "list" | "list_literal" | "array" | "array_literal" | "array_creation_expression"
+            | "dictionary" | "dict_literal" | "object" | "tuple" | "set"
+    )
+}
+
+/// BUILTIN_TYPES (tree-sitter.ts) — names that never become type references.
+fn is_builtin_type(name: &str) -> bool {
+    matches!(
+        name,
+        "string" | "number" | "boolean" | "void" | "null" | "undefined" | "never" | "any"
+            | "unknown" | "object" | "symbol" | "bigint" | "true" | "false"
+            | "str" | "bool" | "i8" | "i16" | "i32" | "i64" | "i128" | "isize"
+            | "u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "f32" | "f64" | "char"
+            | "int" | "long" | "short" | "byte" | "float" | "double"
+            | "int8" | "int16" | "int32" | "int64" | "uint8" | "uint16" | "uint32" | "uint64"
+            | "float32" | "float64" | "complex64" | "complex128" | "rune" | "error"
+            | "Int" | "Long" | "Short" | "Byte" | "Float" | "Double" | "Boolean" | "Char"
+            | "Unit" | "String" | "Any" | "AnyRef" | "AnyVal" | "Nothing" | "Null"
+    )
+}
+
+/// REACT_COMPONENT_HOCS (tree-sitter.ts, #841).
+fn is_react_hoc(callee: &str) -> bool {
+    matches!(callee, "forwardRef" | "memo" | "React.forwardRef" | "React.memo")
+}
+
+fn is_vue_collection_name(name: &str) -> bool {
+    matches!(name, "actions" | "mutations" | "getters")
+}
+
+/// One scope-stack entry (TS keeps node IDs; rows are our equivalent).
+struct Scope {
+    row: u32,
+    kind: &'static str,
+    name: String,
+}
+
+/// Extra node properties, per-extract-site (mirrors createNode's `extra`).
+#[derive(Default)]
+struct Extra {
+    docstring: Option<String>,
+    signature: Option<String>,
+    visibility: Option<u8>,
+    is_exported: Option<bool>,
+    is_async: Option<bool>,
+    is_static: Option<bool>,
+    qualified_name: Option<String>,
+}
+
+struct ValueScope<'t> {
+    row: u32,
+    node: Node<'t>,
+    name: String,
+}
+
+pub struct Walker<'t> {
+    src: &'t str,
+    file_path: &'t str,
+    variant: Variant,
+    line_starts: Vec<usize>,
+    arena: Arena,
+    tables: Tables,
+    stack: Vec<Scope>,
+    /// Node id string per row. Rows are unique but IDS COLLIDE for same
+    /// (kind, name, line) nodes — routine in minified one-line files — and the
+    /// TS extractor's fn-ref dedupe and value-ref self-checks key on the ID,
+    /// so parity requires comparing ids, not rows.
+    node_ids: Vec<String>,
+    /// Function/method names defined in this file (fn-ref flush gate).
+    defined_fn_names: HashSet<String>,
+    /// Simple names from `imports` refs (fn-ref flush gate).
+    imported_names: HashSet<String>,
+    fn_ref_cands: Vec<(u32, fnref::Candidate)>,
+    // Value-reference bookkeeping (flushValueRefs).
+    fs_values: HashMap<String, u32>,
+    fs_value_counts: HashMap<String, u32>,
+    value_scopes: Vec<ValueScope<'t>>,
+    vue_store_file: Option<bool>,
+}
+
+const MAX_VALUE_REF_NODES: usize = 20_000;
+
+pub fn extract(file_path: &str, source: &str, language: &str) -> Result<EmitOut, String> {
+    let variant = Variant::from_language(language)
+        .ok_or_else(|| format!("tsjs walker does not handle language: {language}"))?;
+    let grammar = langs::grammar_for(language)
+        .ok_or_else(|| format!("no grammar for language: {language}"))?;
+
+    let t0 = std::time::Instant::now();
+    let mut parser = Parser::new();
+    parser
+        .set_language(&grammar)
+        .map_err(|e| format!("set_language({language}) failed: {e}"))?;
+    let tree = parser
+        .parse(source, None)
+        .ok_or_else(|| "parser returned null tree".to_string())?;
+
+    // Files with parse ERRORS defer to the wasm extractor (the `defer:` prefix
+    // tells the TS side this is expected routing, not a malfunction). Reason:
+    // tree-sitter's error RECOVERY — same grammar, same core version — resolves
+    // differently under UTF-8 (native) vs UTF-16 (web-tree-sitter) parsing, so
+    // an erroring file's tree can differ between the paths (proven on vscode:
+    // `readonly import('x').T[]` recovered with the ERROR inside vs outside the
+    // type annotation). Erroring files are rare (0-0.42% across express/
+    // excalidraw/vscode) and per-file wasm fallback keeps routing graph-neutral
+    // by construction; clean files — 99.6%+ — stay on the fast path.
+    if tree.root_node().has_error() {
+        return Err("defer: parse tree contains errors — wasm recovery is canonical".to_string());
+    }
+
+    let mut w = Walker {
+        src: source,
+        file_path,
+        variant,
+        line_starts: util::line_starts(source),
+        arena: Arena::default(),
+        tables: Tables::default(),
+        stack: Vec::new(),
+        node_ids: Vec::new(),
+        defined_fn_names: HashSet::new(),
+        imported_names: HashSet::new(),
+        fn_ref_cands: Vec::new(),
+        fs_values: HashMap::new(),
+        fs_value_counts: HashMap::new(),
+        value_scopes: Vec::new(),
+        vue_store_file: None,
+    };
+
+    // File node (TreeSitterExtractor.extract): id `file:<path>`, endLine =
+    // newline count + 1, isExported explicitly false.
+    let line_count = source.bytes().filter(|b| *b == b'\n').count() as u32 + 1;
+    let base_name = file_path.rsplit(['/', '\\']).next().unwrap_or(file_path);
+    let mut flags = BoolFlags::default();
+    flags.set(FLAG_IS_EXPORTED, false);
+    let file_id = w.arena.put(&ids::file_node_id(file_path));
+    let name_ref = w.arena.put(base_name);
+    let qn_ref = w.arena.put(file_path);
+    w.tables.push_node(&NodeRow {
+        kind: node_kind_index("file").unwrap(),
+        visibility: 0,
+        flags,
+        start_line: 1,
+        end_line: line_count,
+        start_column: 0,
+        end_column: 0,
+        name: name_ref,
+        qualified_name: qn_ref,
+        id: file_id,
+        docstring: NONE_STR,
+        signature: NONE_STR,
+        decorators: NONE_STR,
+        type_parameters: NONE_STR,
+        return_type: NONE_STR,
+        extra_json: NONE_STR,
+    });
+    w.node_ids.push(ids::file_node_id(file_path));
+    w.stack.push(Scope { row: 0, kind: "file", name: base_name.to_string() });
+
+    w.visit_node(tree.root_node());
+
+    // End-of-file passes, in the TS extract() order.
+    w.flush_fn_ref_candidates();
+    w.flush_value_refs(tree.root_node());
+    w.stack.pop();
+
+    let duration_ms = t0.elapsed().as_secs_f64() * 1000.0;
+    let meta = build_meta(&w.tables, w.arena.len(), NONE_STR, duration_ms);
+    Ok(EmitOut {
+        meta,
+        nodes: w.tables.nodes,
+        edges: w.tables.edges,
+        refs: w.tables.refs,
+        arena: w.arena.into_vec(),
+    })
+}
+
+impl<'t> Walker<'t> {
+    // --- small helpers --------------------------------------------------------
+
+    fn text(&self, node: Node) -> &'t str {
+        &self.src[node.byte_range()]
+    }
+
+    fn line_of(&self, node: Node) -> u32 {
+        node.start_position().row as u32 + 1
+    }
+
+    fn col_of(&self, node: Node) -> u32 {
+        util::col16(self.src, &self.line_starts, node.start_position().row, node.start_byte())
+    }
+
+    fn end_col_of(&self, node: Node) -> u32 {
+        util::col16(self.src, &self.line_starts, node.end_position().row, node.end_byte())
+    }
+
+    fn top_row(&self) -> u32 {
+        self.stack.last().map(|s| s.row).unwrap_or(0)
+    }
+
+    /// isInsideClassLikeNode.
+    fn inside_class_like(&self) -> bool {
+        self.stack
+            .last()
+            .map(|s| matches!(s.kind, "class" | "struct" | "interface" | "trait" | "enum" | "module"))
+            .unwrap_or(false)
+    }
+
+    fn push_ref(&mut self, from_row: u32, name: &str, kind_code: u8, node: Node) {
+        let name_ref = self.arena.put(name);
+        self.tables.push_ref(&RefRow {
+            from_idx: from_row,
+            kind: kind_code,
+            line: self.line_of(node),
+            column: self.col_of(node),
+            reference_name: name_ref,
+            candidates: NONE_STR,
+            from_id_str: NONE_STR,
+        });
+        if kind_code == edge_kind_index("imports").unwrap() {
+            // Feed the fn-ref flush gate the same way flushFnRefCandidates
+            // derives importedNames from `imports` refs.
+            if util::simple_name().is_match(name) {
+                self.imported_names.insert(name.to_string());
+            } else if let Some(c) = util::qualified_import().captures(name) {
+                self.imported_names.insert(c[1].to_string());
+            }
+        }
+    }
+
+    fn push_call_ref(&mut self, name: &str, node: Node) {
+        self.push_ref(self.top_row(), name, edge_kind_index("calls").unwrap(), node);
+    }
+
+    // --- createNode -----------------------------------------------------------
+
+    /// createNode (tree-sitter.ts): id, qualified name from the scope stack,
+    /// contains edge from the parent scope, value-ref bookkeeping.
+    fn create_node(&mut self, kind: &'static str, name: &str, node: Node<'t>, extra: Extra) -> Option<u32> {
+        if name.is_empty() {
+            return None;
+        }
+        let start_line = self.line_of(node);
+        let id = ids::node_id(self.file_path, kind, name, start_line);
+
+        // endLine body extension: resolveBody only (TS/JS: function-valued
+        // class fields whose body nests in the arrow / HOF-wrapped arrow).
+        let mut end_line = node.end_position().row as u32 + 1;
+        if (kind == "function" || kind == "method") && matches!(node.kind(), "public_field_definition" | "field_definition")
+        {
+            if let Some(body) = resolve_field_body(node) {
+                let be = body.end_position().row as u32 + 1;
+                if be > end_line {
+                    end_line = be;
+                }
+            }
+        }
+
+        let qualified = extra.qualified_name.unwrap_or_else(|| {
+            let mut parts: Vec<&str> = Vec::new();
+            for s in &self.stack {
+                if s.kind != "file" {
+                    parts.push(&s.name);
+                }
+            }
+            let mut qn = parts.join("::");
+            if !qn.is_empty() {
+                qn.push_str("::");
+            }
+            qn.push_str(name);
+            qn
+        });
+
+        let mut flags = BoolFlags::default();
+        if let Some(v) = extra.is_exported {
+            flags.set(FLAG_IS_EXPORTED, v);
+        }
+        if let Some(v) = extra.is_async {
+            flags.set(FLAG_IS_ASYNC, v);
+        }
+        if let Some(v) = extra.is_static {
+            flags.set(FLAG_IS_STATIC, v);
+        }
+
+        let name_ref = self.arena.put(name);
+        let qn_ref = self.arena.put(&qualified);
+        let id_ref = self.arena.put(&id);
+        let doc_ref = opt_str(&mut self.arena, extra.docstring.as_deref());
+        let sig_ref = opt_str(&mut self.arena, extra.signature.as_deref());
+        let row = self.tables.push_node(&NodeRow {
+            kind: node_kind_index(kind).unwrap(),
+            visibility: extra.visibility.unwrap_or(0),
+            flags,
+            start_line,
+            end_line,
+            start_column: self.col_of(node),
+            end_column: self.end_col_of(node),
+            name: name_ref,
+            qualified_name: qn_ref,
+            id: id_ref,
+            docstring: doc_ref,
+            signature: sig_ref,
+            decorators: NONE_STR,
+            type_parameters: NONE_STR,
+            return_type: NONE_STR,
+            extra_json: NONE_STR,
+        });
+
+        // Containment edge from the current scope.
+        let parent_row = self.top_row();
+        self.tables.push_edge(&EdgeRow {
+            source_idx: parent_row,
+            target_idx: row,
+            kind: edge_kind_index("contains").unwrap(),
+            provenance: 0,
+            line: NONE,
+            column: NONE,
+            metadata_json: NONE_STR,
+            source_id_str: NONE_STR,
+            target_id_str: NONE_STR,
+        });
+
+        self.node_ids.push(id);
+        if kind == "function" || kind == "method" {
+            self.defined_fn_names.insert(name.to_string());
+        }
+        self.capture_value_ref_scope(kind, name, row, node);
+        Some(row)
+    }
+
+    // --- value references (captureValueRefScope / flushValueRefs) --------------
+
+    fn capture_value_ref_scope(&mut self, kind: &'static str, name: &str, row: u32, node: Node<'t>) {
+        if !self.variant.value_refs() {
+            return;
+        }
+        let target_kind_ok = kind == "constant" || kind == "variable";
+        if target_kind_ok
+            && util::utf16_len(name) >= 3
+            && util::has_upper_or_underscore().is_match(name)
+        {
+            let parent_ok = self
+                .stack
+                .last()
+                .map(|s| matches!(s.kind, "file" | "class" | "module" | "struct" | "enum"))
+                .unwrap_or(false);
+            if parent_ok {
+                self.fs_values.insert(name.to_string(), row);
+                *self.fs_value_counts.entry(name.to_string()).or_insert(0) += 1;
+            }
+        }
+        if matches!(kind, "function" | "method" | "constant" | "variable") {
+            self.value_scopes.push(ValueScope { row, node, name: name.to_string() });
+        }
+    }
+
+    fn flush_value_refs(&mut self, root: Node<'t>) {
+        let scopes = std::mem::take(&mut self.value_scopes);
+        let mut targets = std::mem::take(&mut self.fs_values);
+        let counts = std::mem::take(&mut self.fs_value_counts);
+        if !self.variant.value_refs() || std::env::var("CODEGRAPH_VALUE_REFS").as_deref() == Ok("0") {
+            return;
+        }
+        if targets.is_empty() || scopes.is_empty() || util::is_generated_file(self.file_path) {
+            return;
+        }
+
+        // Shadow prune: count declarators of each target name across the whole
+        // tree; more declarators than file-scope nodes ⇒ an inner re-binding
+        // shadows the target. (TS/JS declarators are `variable_declarator`;
+        // the other kinds in the TS switch belong to other grammars.)
+        let mut decl_counts: HashMap<&str, u32> = HashMap::new();
+        let mut dstack: Vec<Node> = vec![root];
+        let mut dvisited = 0usize;
+        while let Some(n) = dstack.pop() {
+            if dvisited >= MAX_VALUE_REF_NODES {
+                break;
+            }
+            dvisited += 1;
+            if n.kind() == "variable_declarator" {
+                if let Some(first) = n.named_child(0) {
+                    if first.kind() == "identifier" {
+                        let nm = self.text(first);
+                        if targets.contains_key(nm) {
+                            *decl_counts.entry(nm).or_insert(0) += 1;
+                        }
+                    }
+                }
+            }
+            for i in 0..n.named_child_count() {
+                if let Some(c) = n.named_child(i) {
+                    dstack.push(c);
+                }
+            }
+        }
+        let shadowed: Vec<String> = decl_counts
+            .iter()
+            .filter(|(nm, c)| **c > counts.get(**nm).copied().unwrap_or(1))
+            .map(|(nm, _)| nm.to_string())
+            .collect();
+        for nm in shadowed {
+            targets.remove(&nm);
+        }
+        if targets.is_empty() {
+            return;
+        }
+
+        let refs_kind = edge_kind_index("references").unwrap();
+        for scope in &scopes {
+            // Self-skip and per-scope dedupe compare node ID STRINGS (which
+            // collide for same-(kind, name, line) nodes), matching the TS side.
+            let mut seen: HashSet<&str> = HashSet::new();
+            let mut stack: Vec<Node> = vec![scope.node];
+            let mut visited = 0usize;
+            while let Some(n) = stack.pop() {
+                if visited >= MAX_VALUE_REF_NODES {
+                    break;
+                }
+                visited += 1;
+                if matches!(n.kind(), "identifier" | "constant" | "name" | "simple_identifier") {
+                    let ref_name = self.text(n);
+                    if let Some(&target_row) = targets.get(ref_name) {
+                        let target_id = self.node_ids[target_row as usize].as_str();
+                        if target_id != self.node_ids[scope.row as usize]
+                            && ref_name != scope.name
+                            && !seen.contains(&target_id)
+                        {
+                            seen.insert(target_id);
+                            let meta = self.arena.put(r#"{"valueRef":true}"#);
+                            self.tables.push_edge(&EdgeRow {
+                                source_idx: scope.row,
+                                target_idx: target_row,
+                                kind: refs_kind,
+                                provenance: 0,
+                                line: NONE,
+                                column: NONE,
+                                metadata_json: meta,
+                                source_id_str: NONE_STR,
+                                target_id_str: NONE_STR,
+                            });
+                        }
+                    }
+                }
+                for i in 0..n.named_child_count() {
+                    if let Some(c) = n.named_child(i) {
+                        stack.push(c);
+                    }
+                }
+            }
+        }
+    }
+
+    // --- function-as-value refs (#756) -----------------------------------------
+
+    fn maybe_capture_fn_refs(&mut self, node: Node<'t>) {
+        let Some(mode) = fnref::dispatch(node.kind()) else { return };
+        if self.stack.is_empty() {
+            return;
+        }
+        let from = self.top_row();
+        for (cand, _mode) in fnref::capture(node, mode, self.src) {
+            self.fn_ref_cands.push((from, cand));
+        }
+    }
+
+    /// scanFnRefSubtree: capture-only walk of subtrees the main walkers skip.
+    fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        if depth > 12 {
+            return;
+        }
+        let kind = node.kind();
+        if depth > 0
+            && (is_function_type(kind) || matches!(kind, "lambda_literal" | "lambda_expression"))
+        {
+            return;
+        }
+        self.maybe_capture_fn_refs(node);
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                self.scan_fn_ref_subtree(c, depth + 1);
+            }
+        }
+    }
+
+    fn flush_fn_ref_candidates(&mut self) {
+        let cands = std::mem::take(&mut self.fn_ref_cands);
+        if cands.is_empty() || util::is_generated_file(self.file_path) {
+            return;
+        }
+        let mut seen: HashSet<(String, String)> = HashSet::new();
+        for (from, c) in cands {
+            // Gate: `this.<member>` always flushes; everything else must match
+            // a same-file function/method or an imported name. (The `::` and
+            // ungated-mode policies belong to other languages' specs.)
+            if !c.name.starts_with("this.")
+                && !c.name.contains("::")
+                && !self.defined_fn_names.contains(&c.name)
+                && !self.imported_names.contains(&c.name)
+            {
+                continue;
+            }
+            // Dedupe on the node ID STRING, not the row — ids collide for
+            // same-(kind, name, line) nodes (minified one-liners) and the TS
+            // side keys its dedupe on `${fromNodeId}|${name}`.
+            if !seen.insert((self.node_ids[from as usize].clone(), c.name.clone())) {
+                continue;
+            }
+            let column = util::col16(self.src, &self.line_starts, c.row, c.column_byte);
+            let name_ref = self.arena.put(&c.name);
+            self.tables.push_ref(&RefRow {
+                from_idx: from,
+                kind: FUNCTION_REF_CODE,
+                line: c.line,
+                column,
+                reference_name: name_ref,
+                candidates: NONE_STR,
+                from_id_str: NONE_STR,
+            });
+        }
+    }
+
+    // --- the dispatcher (visitNode) --------------------------------------------
+
+    fn visit_node(&mut self, node: Node<'t>) {
+        let kind = node.kind();
+        let mut skip_children = false;
+
+        // Function-as-value capture — independent of the dispatch ladder.
+        self.maybe_capture_fn_refs(node);
+
+        if is_function_type(kind) {
+            // (the isInsideClassLike + methodTypes overlap is Python/Ruby-only)
+            self.extract_function(node, None);
+            skip_children = true;
+        } else if is_class_type(self.variant, kind) {
+            self.extract_class(node);
+            skip_children = true;
+        } else if is_method_type(self.variant, kind) {
+            if classify_ts_class_member(node) == Member::Property {
+                let prop = self.extract_property(node);
+                if let (Some((row, name)), Some(value)) = (prop, node.child_by_field_name("value")) {
+                    self.stack.push(Scope { row, kind: "property", name });
+                    self.visit_function_body(value);
+                    self.stack.pop();
+                }
+                self.scan_fn_ref_subtree(node, 0);
+            } else {
+                self.extract_method(node);
+            }
+            skip_children = true;
+        } else if self.variant.is_ts() && kind == "interface_declaration" {
+            self.extract_interface(node);
+            skip_children = true;
+        } else if self.variant.is_ts() && kind == "enum_declaration" {
+            self.extract_enum(node);
+            skip_children = true;
+        } else if self.variant.is_ts() && kind == "type_alias_declaration" {
+            skip_children = self.extract_type_alias(node);
+        } else if is_variable_type(kind) && !self.inside_class_like() {
+            self.extract_variable(node);
+            self.scan_fn_ref_subtree(node, 0);
+            skip_children = true;
+        } else if kind == "import_statement" {
+            self.extract_import(node);
+        } else if kind == "export_statement" && node.child_by_field_name("source").is_some() {
+            // Re-export: `export { X } from './y'`.
+            self.emit_re_export_refs(node);
+        } else if kind == "export_statement" && self.looks_like_vue_store_file() {
+            // Vuex MODULE default export (`export default { actions: {…} }`).
+            if let Some(exported) = node.child_by_field_name("value") {
+                if matches!(exported.kind(), "object" | "object_expression") {
+                    self.extract_store_collection_methods(exported);
+                    skip_children = true;
+                }
+            }
+        } else if kind == "call_expression" {
+            self.extract_call(node);
+        } else if kind == "new_expression" {
+            self.extract_instantiation(node);
+        } else if self.variant.is_ts()
+            && matches!(kind, "property_signature" | "method_signature")
+            && self.inside_class_like()
+        {
+            let parent = self.top_row();
+            self.extract_type_annotations(node, parent);
+        }
+
+        if !skip_children {
+            for i in 0..node.named_child_count() {
+                if let Some(c) = node.named_child(i) {
+                    self.visit_node(c);
+                }
+            }
+        }
+    }
+
+    // --- visitFunctionBody ------------------------------------------------------
+
+    fn visit_function_body(&mut self, body: Node<'t>) {
+        self.visit_for_calls_and_structure(body);
+    }
+
+    fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        let kind = node.kind();
+        self.maybe_capture_fn_refs(node);
+
+        if kind == "call_expression" {
+            self.extract_call(node);
+        } else if kind == "new_expression" {
+            self.extract_instantiation(node);
+        }
+
+        // Local variable type annotations (TS family only).
+        if self.variant.is_ts() && kind == "variable_declarator" {
+            let owner = self.top_row();
+            self.extract_variable_type_annotation(node, owner);
+        }
+
+        // Nested NAMED functions become their own nodes.
+        if is_function_type(kind) {
+            let name = self.extract_name(node);
+            if name != "<anonymous>" {
+                self.extract_function(node, None);
+                return;
+            }
+        }
+
+        if is_class_type(self.variant, kind) {
+            self.extract_class(node);
+            return;
+        }
+        if self.variant.is_ts() && kind == "enum_declaration" {
+            self.extract_enum(node);
+            return;
+        }
+        if self.variant.is_ts() && kind == "interface_declaration" {
+            self.extract_interface(node);
+            return;
+        }
+
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                self.visit_for_calls_and_structure(c);
+            }
+        }
+    }
+
+    // --- name / signature / modifier helpers ------------------------------------
+
+    /// extractName / extractNameRaw for the TS/JS configs.
+    fn extract_name(&self, node: Node) -> String {
+        // javascriptExtractor.resolveName: field_definition names its key the
+        // `property` field.
+        if !self.variant.is_ts() && node.kind() == "field_definition" {
+            if let Some(prop) = node.child_by_field_name("property") {
+                return self.text(prop).to_string();
+            }
+        }
+        if let Some(name_node) = node.child_by_field_name("name") {
+            return self.text(name_node).to_string();
+        }
+        if matches!(node.kind(), "arrow_function" | "function_expression") {
+            return "<anonymous>".to_string();
+        }
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                if matches!(c.kind(), "identifier" | "type_identifier" | "simple_identifier" | "constant") {
+                    return self.text(c).to_string();
+                }
+            }
+        }
+        "<anonymous>".to_string()
+    }
+
+    /// typescriptExtractor.getSignature / javascriptExtractor.getSignature.
+    fn signature_of(&self, node: Node) -> Option<String> {
+        let params = node.child_by_field_name("parameters")?;
+        let mut sig = self.text(params).to_string();
+        if self.variant.is_ts() {
+            if let Some(ret) = node.child_by_field_name("return_type") {
+                let ret_text = self.text(ret);
+                let stripped = ret_text.strip_prefix(':').unwrap_or(ret_text).trim_start();
+                sig.push_str(": ");
+                sig.push_str(stripped);
+            }
+        }
+        Some(sig)
+    }
+
+    /// typescriptExtractor.getVisibility (TS only — JS has no hook).
+    fn visibility_of(&self, node: Node) -> Option<u8> {
+        if !self.variant.is_ts() {
+            return None;
+        }
+        for i in 0..node.child_count() {
+            let child = node.child(i)?;
+            if child.kind() == "accessibility_modifier" {
+                return match self.text(child) {
+                    "public" => Some(1),
+                    "private" => Some(2),
+                    "protected" => Some(3),
+                    _ => None,
+                };
+            }
+        }
+        None
+    }
+
+    /// isExported: walk the parent chain for an export_statement.
+    fn is_exported(&self, node: Node) -> bool {
+        let mut cur = node.parent();
+        while let Some(p) = cur {
+            if p.kind() == "export_statement" {
+                return true;
+            }
+            cur = p.parent();
+        }
+        false
+    }
+
+    fn has_keyword_child(&self, node: Node, kw: &str) -> bool {
+        for i in 0..node.child_count() {
+            if let Some(c) = node.child(i) {
+                if c.kind() == kw {
+                    return true;
+                }
+            }
+        }
+        false
+    }
+
+    fn is_async(&self, node: Node) -> bool {
+        self.has_keyword_child(node, "async")
+    }
+
+    /// TS has an isStatic hook; JS does not (None = field absent).
+    fn is_static(&self, node: Node) -> Option<bool> {
+        if self.variant.is_ts() {
+            Some(self.has_keyword_child(node, "static"))
+        } else {
+            None
+        }
+    }
+
+    fn is_const_decl(&self, node: Node) -> bool {
+        node.kind() == "lexical_declaration" && self.has_keyword_child(node, "const")
+    }
+
+    // (extract_* functions continue in impl blocks below)
+}
+
+/// classifyTsClassMember (#808): a class field is a METHOD only when its value
+/// is callable (arrow / function expression / HOF call wrapping one).
+#[derive(PartialEq)]
+enum Member {
+    Method,
+    Property,
+}
+
+fn classify_ts_class_member(node: Node) -> Member {
+    if !matches!(node.kind(), "public_field_definition" | "field_definition") {
+        return Member::Method;
+    }
+    for i in 0..node.named_child_count() {
+        let Some(child) = node.named_child(i) else { continue };
+        if matches!(child.kind(), "arrow_function" | "function_expression") {
+            return Member::Method;
+        }
+        if child.kind() == "call_expression" {
+            if let Some(args) = child.child_by_field_name("arguments") {
+                for j in 0..args.named_child_count() {
+                    if let Some(arg) = args.named_child(j) {
+                        if matches!(arg.kind(), "arrow_function" | "function_expression") {
+                            return Member::Method;
+                        }
+                    }
+                }
+            }
+        }
+    }
+    Member::Property
+}
+
+/// typescriptExtractor.resolveBody / javascriptExtractor.resolveBody: the body
+/// of a function-valued class field, nested in the arrow / HOF-wrapped arrow.
+fn resolve_field_body(node: Node) -> Option<Node> {
+    if !matches!(node.kind(), "public_field_definition" | "field_definition") {
+        return None;
+    }
+    for i in 0..node.named_child_count() {
+        let child = node.named_child(i)?;
+        if matches!(child.kind(), "arrow_function" | "function_expression") {
+            return child.child_by_field_name("body");
+        }
+        if child.kind() == "call_expression" {
+            if let Some(args) = child.child_by_field_name("arguments") {
+                for j in 0..args.named_child_count() {
+                    if let Some(arg) = args.named_child(j) {
+                        if matches!(arg.kind(), "arrow_function" | "function_expression") {
+                            return arg.child_by_field_name("body");
+                        }
+                    }
+                }
+            }
+        }
+    }
+    None
+}
+
+/// resolveBody ?? getChildByField(node, 'body') — the body-walk resolution.
+fn body_of(node: Node) -> Option<Node> {
+    resolve_field_body(node).or_else(|| node.child_by_field_name("body"))
+}
+
+fn opt_str(arena: &mut Arena, s: Option<&str>) -> StrRef {
+    match s {
+        Some(s) => arena.put(s),
+        None => NONE_STR,
+    }
+}

+ 578 - 0
docs/design/rust-kernel-migration-plan.md

@@ -0,0 +1,578 @@
+# Rust extraction-kernel migration plan + post-kernel roadmap
+
+**Audience:** the agent/engineer executing the native-kernel project. Self-contained handoff:
+context, current state, per-language tracker, gates, and the follow-on roadmap.
+**Companion:** `docs/design/native-extraction-kernel.md` (architecture + spike detail).
+**Written:** 2026-06-12 planning → executed 2026-07-16/17. **R1–R6 ARE DONE.** The shipped
+records live in §3a and §4a–§4f; the per-language tracker is current; §0a is the
+cold-start handoff for the next session. Read §0 + §0a first — parts of §1/§6 below
+them are the ORIGINAL plan and carry expectations that measurement later corrected
+(each is annotated where superseded).
+
+---
+
+## 0. Status checklist (R1–R6 done; what remains)
+
+- [x] **R1. Scaffold the napi-rs crate** — done 2026-07-16, §3a. Buffer contract v1,
+      routing + per-file wasm fallback, kill switch, build/release wiring,
+      grammar-source-parity CI.
+- [x] **R2. Port TypeScript/JavaScript (tsx/jsx)** — done 2026-07-16, §4a. The generic
+      `.scm` emitter was SUPERSEDED by bespoke per-language walkers (queries can't
+      express extraction parity); byte-parity from day one of the harness.
+- [x] **R3. TS/JS equivalence gate → DEFAULT-ON** — done 2026-07-16, §4b. Dumps
+      byte-identical (express/excalidraw/vscode + flask control). Found + fixed:
+      encoding-dependent error recovery → per-file `defer:` policy.
+- [x] **R4. Java (incl. Lombok synthesis) → DEFAULT-ON** — done 2026-07-16, §4c.
+      dubbo 441k-row dump byte-identical. Found + fixed: node-ID-collision dedupe
+      (cross-language). Found: many-core parse-loop wall is NOT extraction (→ §4d).
+- [x] **Direct-to-store decode** — done 2026-07-16, §4d. Main thread never
+      materializes nodes; measured: the many-core fresh-index wall is single-writer
+      SQLite ingest (94% of dubbo's parse-loop) — a store-architecture arc, out of
+      scope here.
+- [x] **R5. Python + Go → DEFAULT-ON** — done 2026-07-16, §4e. django 360.8k /
+      prometheus 213.8k row dumps byte-identical; 2-CPU envelope 1.32× / 1.46×.
+- [x] **R6. Kernel-scale re-validation (cg1212)** — done 2026-07-17, §4f. No
+      regression (26.4min vs ~27min, identical 2.05M-node graph). The "parse 6m→2m"
+      premise was wrong for THIS repo: the Linux tree is ~99% C (unported T2) —
+      the expectation transfers to the C/C++ port.
+
+**Open, in recommended order (rationale in §0a):**
+
+- [ ] **O1. Merge the `rust-kernel` branch** (9 commits, fully gated) — everything
+      below builds on it; the vendored grammar upgrades alone are worth landing.
+- [ ] **O2. Windows VM validation** — the one deferred gate leg. Blocked on the
+      MAINTAINER starting the VM in Parallels (`prlctl start` needs Pro). Then:
+      install Rust + MSVC Build Tools on the guest, `bash scripts/build-kernel.sh`,
+      run the three kernel suites with `CODEGRAPH_KERNEL_EXPECT=1`. Close before the
+      first release that ships prebuilds (fallback makes a broken win32 .node safe —
+      Windows silently gets wasm — but safe ≠ validated).
+- [ ] **P1. Kernel-scale resolution speed** (§7a) — NOW THE TOP PERF LEVER: 19.2m of
+      the 26.4m Linux-kernel wall (73%). First step is cheap and may reshape it:
+      the 2-CPU run resolves SEQUENTIALLY BY DESIGN (the resolver pool needs ≥4
+      cores) — re-run cg1212 at ≥4 cores where the pool + parallel synthesis
+      (#1321/#1322) engage, then profile what remains. Target: <10min on 8 cores.
+- [ ] **R7a. C/C++ port** — biggest single-language effort; unlocks cg1212's parse
+      expectation (6.2m → ~1.5–2m, 23% of that wall) + CARLA/UE/llvm-class repos;
+      Metal + CUDA ride along (their blanking pre-passes stay TS-side — `preParse`
+      is offset-preserving and the route point can apply it before the kernel call;
+      see the T2 note in `src/extraction/kernel/index.ts`). Largest per-language
+      surface in tree-sitter.ts: namespace prefix stacks (#1291), local fn-pointer
+      tables (#932), operator calls (#1247), stack construction (#1035), macro
+      salvage + `.h` content detection (stays at detectLanguage, upstream — free).
+- [ ] **R7b. Remaining long tail** per the tracker (§4) — ruby/php/csharp/rust/… T1s
+      are now ~1-day-each with the walker pattern; T3 may stay TS forever (fine).
+- [ ] **P2. Arc 3, graph richness** (§7b) — product-priority call, standard gates.
+- [ ] **P3. Parked items** (§7c) — only with explicit maintainer approval.
+
+## 0a. Cold-start handoff (state as of 2026-07-17)
+
+**Where the work lives:** branch `rust-kernel` off `main`, 9 commits (`c5eebe6` R1 →
+`2a79432` R6 record), unmerged, suite green (2,471 tests). All scratchpad clones
+(excalidraw/vscode/dubbo/django/…) were throwaway; re-clone fresh for new gate runs.
+The cg1212 docker container (Linux kernel, 2 CPU/6GB) is long-lived on the dev Mac
+and has the current build deployed at `/app` (tree at `/work/linux`).
+
+**What exists:**
+- `codegraph-kernel/` — napi-rs crate. One WALKER MODULE per language
+  (`tsjs/`, `java.rs`, `python.rs`, `go.rs`) mirroring `TreeSitterExtractor`'s
+  per-language paths bug-for-bug; shared `buffers.rs` (wire contract — twin of
+  `src/extraction/kernel/layout.ts`, byte-matched, ABI-versioned), `ids.rs`
+  (sha node ids, test-pinned to `generateNodeId`), `docstring.rs`, `textutil.rs`
+  (UTF-16 columns/slices, generated-file patterns, shared regexes), `langs.rs`
+  (grammar registry).
+- `src/extraction/kernel/` — loader (contract-verifies before routing; a stale
+  .node silently degrades to wasm; `CODEGRAPH_KERNEL_DEBUG=1` explains), decode,
+  routing (`DEFAULT_ROUTED` = ts/tsx/js/jsx/java/python/go;
+  `CODEGRAPH_KERNEL_LANGS` REPLACES the set; `CODEGRAPH_KERNEL=0` kills), and the
+  deferred-decode transport (`tryKernelExtractRaw` → buffers ride to the store
+  worker; files with applicable framework `extract()` hooks keep the decoded path).
+- Gates in-repo: `scripts/kernel-parity.mjs` (per-file kernel↔wasm diff,
+  ORDER-sensitive, full-object; deferral-rate guard), `scripts/dump-graph.mjs`
+  (natural-key full-DB dump for the byte-identical diff),
+  `__tests__/kernel-{scaffold,grammar-parity,tsjs-parity}.test.ts` (+ torture
+  fixtures under `__tests__/fixtures/kernel-parity/`) — all in `npm test`;
+  the release workflow builds a 6-target prebuild matrix (continue-on-error;
+  kernel is optional everywhere) and runs the suites with
+  `CODEGRAPH_KERNEL_EXPECT=1`.
+
+**Build/run:** `npm run build:kernel` (needs rustup; stages
+`codegraph-kernel/prebuilds/<plat>-<arch>/codegraph-kernel.node`) → `npm run build`
+→ `npm test`. Parity sweep: `node scripts/kernel-parity.mjs <dir>`. Dump gate:
+init twice (kernel arm vs `CODEGRAPH_KERNEL=0`), `dump-graph.mjs` each, `cmp`.
+
+**Adding a language (the proven recipe, ~a day for a T1):**
+1. Read its `languages/<lang>.ts` config AND every branch of tree-sitter.ts it
+   exercises (visitNode dispatch, extractCall's language branch, inheritance
+   clauses, fn-ref spec in function-ref.ts, value-ref prune cases). Port
+   bug-for-bug — quirks included (each walker's header comments list its own).
+2. Add the crates.io grammar; **vendor the wasm from the SAME tag** (clone tag,
+   sha-match parser.c against the cargo registry copy, `tree-sitter-cli 0.25.10
+   build --wasm` from CHECKED-IN parser.c, drop into `src/extraction/wasm/`, add
+   to VENDORED_WASM_LANGS) — tree-sitter-wasms is 2023-era for most languages.
+3. Torture fixture + parity sweeps (small/medium/large real repos) → full-init
+   dump-diffs byte-identical → add to DEFAULT_ROUTED + tests + changelog.
+
+**Traps already paid for (do not relearn):**
+- **Error recovery is ENCODING-dependent** (UTF-8 native vs UTF-16 web-tree-sitter,
+  same grammar bytes + same core) → every walker defers `has_error()` files via
+  the `defer:` signal. Incidence 0–0.42%; the harness fails >10% deferral.
+- **Node IDs collide** for same-(kind,name,line) — routine in minified one-liners.
+  Any dedupe/self-check that the TS side keys on node IDs must compare ID STRINGS,
+  not table rows (`node_ids` vec in every walker).
+- **Positions and JS string slices are UTF-16** (`textutil::col16`/`slice_utf16`) —
+  that's what web-tree-sitter reports and what `.slice(0,100)` means.
+- The extraction seam contract is **exactly what extractFromSource returns** — e.g.
+  refs carry NO denormalized filePath/language (the store fills them). The strict
+  full-object parity compare exists because a loose one masked precisely this.
+- Grammar bumps: crate + vendored wasm move TOGETHER or kernel-grammar-parity fails.
+- Perf claims: measure before believing — the plan's own §1/§6 expectations were
+  corrected twice (many-core parse-loop wall = store-writer, §4d; cg1212 parse =
+  C-bound, §4f).
+
+---
+
+## 1. Mission and the numbers that motivate it
+
+CodeGraph's remaining fresh-index gap vs codebase-memory-mcp (cbm) is the parse+extract
+phase, and its floor is per-node JS↔WASM marshaling — proven, not suspected:
+
+| Measurement (2026-07-16, M3 Pro) | Result |
+|---|---|
+| dubbo (4,402 Java files) parse-loop, current 7-wasm-worker pipeline | 4,700ms |
+| Same files, Rust tree-sitter parse+walk, rayon (spike) | **202ms** |
+| Same, single Rust thread | 1,067ms |
+| dubbo fresh init today / cbm | 11.1s / 7.1s (1.55×) |
+| Linux kernel, same 2-CPU/6GB container | **we complete 27min; cbm dies at 0.16%, twice** |
+
+Spike source: session scratchpad `cg-kernel-spike/` (tree-sitter 0.25 + tree-sitter-java,
+TreeCursor walk touching kind/range/name-field, flat-row output). Reproduce before starting —
+it's ~80 lines and doubles as the emitter's seed.
+
+Expected end state: parse-loop 4.7s → ~1.0–1.5s on dubbo-class repos → total ≈ 7.5s,
+**parity with cbm on their best surface**, while keeping every win we already hold
+(sync 2.4–2.8×, agent A/B decisive, call-graph density 1.3–2.3×, byte-identical
+determinism, constrained-hardware envelope).
+
+> **SUPERSEDED BY MEASUREMENT (§4c/§4d):** the many-core parse-loop wall turned out
+> to be the single-writer SQLite ingest (94% of it on dubbo), not extraction — 8 wasm
+> workers already hid extraction CPU behind the main thread on big-core machines. So
+> the Mac dubbo total stays ~11s and closing the remaining cbm gap there is a
+> STORE-ARCHITECTURE arc, not a kernel task. The kernel's wins are real where worker
+> CPU binds: the 2-CPU/6GB CI envelope (excalidraw ~1.5×, dubbo ~1.25×, django 1.32×,
+> prometheus 1.46×) and vscode-scale-on-Mac (1.28×). Every "keep" item held —
+> byte-identical determinism is now enforced per language by the dump gate.
+
+## 2. What the kernel is — and the boundary that makes it safe
+
+One napi-rs crate (`codegraph-kernel`) linking tree-sitter's C library and native grammars.
+Input `(filePath, content, language)` per file; output **flat typed buffers** (nodes, edges,
+unresolved refs) — one boundary crossing per file. It replaces ONLY the parse+extract walk
+inside the parse workers, behind the existing `ExtractionResult` contract.
+
+**Never ported (works unchanged for all languages from day one):** name-matcher +
+import-resolver, all framework resolvers (`src/resolution/frameworks/`), all 36 synthesis
+passes, MCP/explore, sync/watcher, installer. They consume the graph and raw source, not
+the parse tree.
+
+**Coexistence is permanent:** a language routes to the kernel only after its gate passes;
+everything else stays on the wasm path forever if need be. No flag-day. Rollback per
+language = flipping the route.
+
+**Distribution:** prebuilt `.node` per platform through the existing release-bundle
+pipeline (`scripts/build-bundle.sh` + per-platform npm packages); the same crate compiled
+to wasm is the universal fallback. Zero-native-build-on-install stays true.
+
+## 3. Phase 0 — scaffold (do first, ~days)
+
+1. `codegraph-kernel/` crate: napi-rs, tree-sitter C, rayon optional (workers already
+   parallelize per-file — start synchronous per call, one kernel call per file from the
+   existing `ParseWorkerPool` workers; do NOT rebuild the pool).
+2. Buffer contract: decide the flat encoding (suggest: one `Buffer` per table,
+   fixed-width rows + a string arena; version byte first). Write the TS decoder next to
+   `parse-worker.ts`.
+3. Generic emitter driven by per-language `.scm` query files + a small per-language Rust
+   config (node-kind → NodeKind mapping, name-field conventions). Escape hatch: a
+   per-language `post(buffers, source)` TS hook for logic queries can't express.
+4. Build integration: napi prebuilds wired into the release workflow next to the Node
+   bundles; `CODEGRAPH_KERNEL=0` kill switch; wasm fallback auto-selected when the
+   `.node` is absent (source runs, unsupported platforms).
+5. CI: assert native grammars and wasm grammars are built from the SAME grammar source
+   revisions (ABI drift between paths would make per-language routing non-deterministic).
+
+### 3a. Phase 0 — SHIPPED 2026-07-16 (what exists and the decisions made)
+
+- **Crate:** `codegraph-kernel/` (napi 3, tree-sitter 0.25, no CLI dependency —
+  `scripts/build-kernel.sh` does cargo build + stage into
+  `codegraph-kernel/prebuilds/<platform>-<arch>/codegraph-kernel.node`; `npm run
+  build:kernel`). Exports `extractFile`, `contractInfo`, `grammarInfo`.
+- **Buffer contract v1:** five Buffers (meta/nodes/edges/refs/arena), fixed-width LE rows,
+  string arena with `(offset,len)` refs, `0xFFFFFFFF` = absent, version byte first, node
+  IDs computed Rust-side (sha256, byte-identical to `generateNodeId` — pinned by test),
+  tri-state bool flags, `extraJson` escape slot per node row, and a RESERVED u32 metrics
+  slot (Arc 3.2). Layout doc lives twice and must match: `codegraph-kernel/src/buffers.rs`
+  ↔ `src/extraction/kernel/layout.ts`. NODE_KINDS/EDGE_KINDS array ORDER in src/types.ts
+  is wire contract now (EDGE_KINDS became a runtime array for this).
+- **Emitter:** generic, `.scm`-driven (`@def.<NodeKind>` + `@name` + `@ref.<EdgeKind>`
+  capture convention), scope stack by byte-range nesting → `::`-joined qualifiedNames,
+  contains edges, refs attached to innermost enclosing def (file node fallback) — the
+  TreeSitterExtractor conventions. Seed TS/JS queries are SMOKE-level only; R2 replaces.
+- **Routing:** inside `extractFromSource` (tree-sitter.ts) — `tryKernelExtract` first,
+  wasm `TreeSitterExtractor` as fallback (also per-FILE fallback on any kernel error).
+  DEFAULT_ROUTED is EMPTY; dev opt-in via `CODEGRAPH_KERNEL_LANGS=<langs|all>`; global
+  kill switch `CODEGRAPH_KERNEL=0`; loader verifies ABI + kind tables before routing
+  (stale .node → silent wasm, `CODEGRAPH_KERNEL_DEBUG=1` to see why). The escape hatch
+  landed as `post(result, source)` over the DECODED result (not raw buffers) — decoded
+  is what TS logic wants; see POST_PASSES in `src/extraction/kernel/index.ts`.
+- **Grammar parity (the §3.5 CI) — and a decision that changed the wasm path:** the
+  parity test (`__tests__/kernel-grammar-parity.test.ts`, behavioral: ABI + node-kind +
+  field tables compared id-by-id) caught on day one that tree-sitter-wasms ships
+  2023-era TS/JS grammars (^0.20.x) vs crates.io current. Resolution: **vendored fresh
+  wasm into `src/extraction/wasm/` built from the exact crate revisions** —
+  tree-sitter-typescript v0.23.2 (f975a62) for typescript+tsx, tree-sitter-javascript
+  v0.25.0 (44c892e) for javascript+jsx — from each repo's CHECKED-IN parser.c (no
+  `generate`), tree-sitter-cli 0.25.10, emcc. So the production wasm TS/JS grammars are
+  UPGRADED as of this change (full suite green, 2456 tests) and **R2/R3 parity diffs
+  are grammar-neutral**. Bump crate + vendored wasm together, or the parity test fails.
+- **Release wiring:** `kernel` matrix job in release.yml (macos-14 ×2 targets,
+  ubuntu-22.04, ubuntu-22.04-arm, windows-latest ×2 — all continue-on-error: kernel is
+  optional, a toolchain flake never blocks a release) → artifacts → `release/kernel/` →
+  build-bundle.sh stages `lib/kernel/codegraph-kernel.node` when present. The release
+  job runs the kernel tests with `CODEGRAPH_KERNEL_EXPECT=1` (missing binary = FAILURE
+  there, skip elsewhere).
+- **Loader search order:** `CODEGRAPH_KERNEL_PATH` → `<pkgroot>/kernel/` (bundle) →
+  `<pkgroot>/codegraph-kernel/prebuilds/<plat>-<arch>/` (source runs).
+- **Known R2 gate item:** native columns are UTF-8 byte offsets; web-tree-sitter's are
+  UTF-16-derived — column NUMBERS on non-ASCII lines will differ in parity dumps
+  (text, lines, IDs unaffected). Classify or normalize when it shows up.
+  **RESOLVED in R2:** the walker emits UTF-16 columns natively (util::col16), and JS
+  string-slicing semantics (signature truncation at 100/80/120 units) are reproduced in
+  UTF-16 units too — no column/slice diff class exists.
+
+### 4a. R2 — TS/JS port SHIPPED 2026-07-16 (and a §3 design revision)
+
+- **The generic `.scm` emitter is superseded.** Real TS/JS parity needs logic queries
+  can't express (extractCall's receiver-qualified callees, store/RTK/component
+  recognition, fn-ref capture+gating, value-ref shadow pruning, docstring wrapper
+  climbs) — so R2 replaced the R1 query emitter with a **bespoke per-language walker**
+  (`codegraph-kernel/src/tsjs/`, ~1,900 lines) that mirrors `TreeSitterExtractor`'s
+  TS/JS paths function-for-function, bug-for-bug. emitter.rs + queries/ are deleted
+  (git has them); expect T1 languages (java/python/go) to be walkers too. The
+  `post(result, source)` TS escape hatch remains available but TS/JS needed none.
+- **Parity evidence (macOS):** `scripts/kernel-parity.mjs` (multiset diff of
+  canonicalized nodes/edges/refs per file, FULL objects) — this repo 353/353 files,
+  excalidraw 643/643 files (10,650 nodes / 10,726 edges / 68,307 refs), plus
+  checked-in torture fixtures (`__tests__/fixtures/kernel-parity/`) covering
+  components/HOCs/styled, zustand-through-middleware, RTK endpoints+hooks, vuex/pinia,
+  fn-refs (incl `this.x` + shadowing gates), value-refs (incl the shadow prune),
+  decorators, enums, type-alias members + tuple contracts, re-exports, JSX. Kept alive
+  in `npm test` by `__tests__/kernel-tsjs-parity.test.ts` (strict full-object compare).
+- **One decoder bug found by the strict compare:** decode.ts pre-filled
+  `filePath`/`language` on refs; wasm extractors leave them unset (the store
+  denormalizes via `?? filePath`). Fixed — the seam contract is "exactly what
+  extractFromSource returns", not "what the store makes of it".
+- **Perf (M3 Pro, excalidraw 643 files / 7MB):** extraction single-thread 487ms kernel
+  vs 1,255ms wasm (**2.6×**, identical outputs). End-to-end `init` on an 11-core host
+  moves only ~3.4s → ~3.2s — parse is a small, already-pool-parallelized slice there;
+  the win concentrates on constrained hardware (2-core CI class) and kernel-scale
+  parse (R6). Headroom if R4's dubbo target needs it: arena interning, memoized
+  UTF-16 line prefixes, and skipping wasm-grammar loads in workers for kernel-routed
+  languages (worker cold-start).
+- **Not yet done (R3 gate):** large-repo parity (vscode-class), full-repo dump-diff
+  through the DB, retrieval invariants, agent A/B, Linux docker + Windows VM parity
+  runs, control-repo perf. Routing stays opt-in (`CODEGRAPH_KERNEL_LANGS`) until then.
+  **→ Done same day, §4b.**
+
+### 4b. R3 — gate PASSED, TS/JS DEFAULT-ON (2026-07-16)
+
+Evidence (tools: `scripts/kernel-parity.mjs` now ORDER-sensitive — identical multisets
+in a different emission order would shift rowids and change resolution — and
+`scripts/dump-graph.mjs`, natural-key full-DB dumps):
+
+1. **Graph parity — byte-identical, not ≤0.5%:** full `init` dump-diff kernel-vs-wasm:
+   express (13,712 rows), excalidraw (89,898), **vscode (2,378,238 rows)** — all
+   byte-identical. Control repo (flask, Python) byte-identical + timing unchanged.
+   Extraction-level order-sensitive sweeps: repo 352/354 (+2 deferred), express
+   141/141, excalidraw 643/643, vscode 12,055/12,106 (+51 deferred), 0 diffs.
+2. **The one real find — encoding-dependent error recovery:** same grammar bytes
+   (sha-verified parser.c/scanner.h), same tree-sitter core (0.25.10), but error
+   RECOVERY on files with parse errors differs between UTF-8 (native) and UTF-16
+   (web-tree-sitter) parsing — proven by parsing the divergent vscode file natively
+   in UTF-16, which reproduced the wasm tree exactly. Incidence: 0% (express) /
+   0.31% (excalidraw) / 0.42% (vscode) of files. **Policy: the kernel defers any
+   file whose tree `has_error()` to the wasm extractor** (`defer:` signal, silent,
+   per-file) — parity by construction on erroring files, 99.6%+ keep the fast path,
+   and the harness fails if deferrals exceed 10% (a broken kernel can't hide).
+3. **Retrieval invariants:** kernel-indexed excalidraw — `mutateElement →
+   renderStaticScene` connects end-to-end via explore (callback + react-render +
+   jsx hops shown); synthesized-edge families present (408 jsx-render / 46
+   react-render / 14 interface-impl / 1 callback); byte-identical DB ⇒ counts equal
+   by construction.
+4. **Agent A/B:** byte-identical DBs make the A/B vacuous (identical graph, identical
+   MCP server) — same justification as the #1320–#1322 perf PRs, which shipped on the
+   dump-diff gate. Not burned.
+5. **Perf:** vscode init 105.4s → 82.1s (**1.28×**) on the 11-core Mac; excalidraw on
+   a 2-CPU/6GB Linux container (the CI-runner envelope) 6.2–7.1s → 4.3–4.8s
+   (**~1.5×**, n=2 interleaved); Mac excalidraw ≈ neutral-to-slightly-better (parse
+   already a small pool-parallelized slice at 11 cores). Control unchanged.
+6. **Platforms:** Linux (arm64 bookworm container, in-container cargo build): all 22
+   kernel tests green under `CODEGRAPH_KERNEL_EXPECT=1`. **Windows VM: deferred** —
+   VM stopped and `prlctl start` needs Parallels Pro; benign because a missing/broken
+   `.node` falls back to wasm, and the release workflow builds + gates win32
+   prebuilds. Run the kernel suites on the VM when it's next up.
+7. **Suite:** 2,465 tests pass WITH default-on routing — the entire extraction test
+   corpus now exercises the kernel for TS/JS on machines with a staged `.node`.
+
+Default routing: `DEFAULT_ROUTED = {typescript, tsx, javascript, jsx}` in
+`src/extraction/kernel/index.ts`. `CODEGRAPH_KERNEL_LANGS` REPLACES the set;
+`CODEGRAPH_KERNEL=0` kills. Changelog entry added under [Unreleased].
+
+### 4c. R4 — Java PORTED + gate PASSED + DEFAULT-ON (2026-07-16)
+
+- **Walker:** `codegraph-kernel/src/java.rs` (self-contained, sharing the crate-level
+  docstring/textutil modules) — package namespaces, imports, javadoc, annotations →
+  decorates, type_list inheritance, fields/constants (static-final → constant),
+  enum_constant members, anonymous classes (`<T$anon@line>` incl. the TS side's
+  0-based-line quirk, mirrored bug-for-bug), method_invocation calls with the
+  `this.field` unwrap + the `Foo.getInstance().bar()` chain encoding, static-member
+  value reads, method_reference fn-refs (`this::x` / `Type::m`), value refs, and the
+  **full Lombok member synthesizer** (#912: @Getter/@Setter/@Data/@Value/@Builder/
+  @ToString/@EqualsAndHashCode/@Slf4j-family, taken-member dedup by exact
+  `classQN::name`). Grammar: tree-sitter-java crate 0.23.5; wasm vendored from the
+  SAME tag (94703d5, parser.c sha-matched), replacing tree-sitter-wasms' ^0.20.2 build.
+- **Parity:** extraction sweeps — gson 262/262, retrofit 341/341, dubbo 4,048/4,048,
+  torture fixture (`__tests__/fixtures/kernel-parity/Torture.java`, in `npm test`).
+  Full-init dump-diffs byte-identical: gson (49,766 rows), retrofit (62,735),
+  **dubbo (441,266 rows)**. All R2/R3 repos re-verified after the fix below.
+- **The gate caught a REAL cross-language bug:** retrofit's minified website JS
+  exposed that fn-ref dedupe and value-ref self-checks must compare node **ID
+  strings**, not table rows — IDs collide for same-(kind,name,line) nodes (routine in
+  minified one-liners: many `function e` on line 3) and the TS side keys on
+  `${fromNodeId}|${name}`. Fixed in BOTH walkers (`node_ids` per row); this affected
+  tsjs too (latent since R2, never released).
+- **Benchmark honesty (the §6 expectation was wrong about WHERE the win lands):**
+  dubbo fresh-init on the 11-core M3 Pro is ~FLAT end-to-end (11.3–11.5 wasm →
+  11.0–11.6 kernel; parse-loop wall 5,020→4,394ms) because that phase's wall is
+  **main-thread-bound** (file reads + result store + SQLite), not worker-CPU-bound —
+  8 wasm workers already hide extraction CPU behind the main thread on big-core
+  machines. Where worker CPU binds, the kernel delivers: **dubbo on 2-CPU/6GB Linux
+  27.8–28.6s → 22.3–22.8s (~1.25×)**; excalidraw same envelope ~1.5×; vscode-on-Mac
+  1.28×. The **cbm-parity Mac headline therefore needs the next lever: decode the
+  kernel's buffers DIRECTLY into store rows** (skip per-node JS object
+  materialization on the main thread) — buffer contract already carries everything;
+  tracked as the top §7a-adjacent follow-up.
+- **Platforms:** Linux container (arm64): all 23 kernel tests green EXPECT=1;
+  Windows VM still deferred (same fallback rationale as §4b).
+- Default routing now includes `java`.
+
+### 4e. R5 — Python + Go PORTED + gates PASSED + DEFAULT-ON (2026-07-16)
+
+- **Walkers:** `codegraph-kernel/src/python.rs` + `src/go.rs` (the java.rs pattern).
+  Python: decorated_definition docstring/decorator handling (decorates only for
+  bare-identifier decorators — the `call`-kind quirk mirrored), fn-in-class → method,
+  module assignments always `variable` (no isConst hook), from-import binding refs,
+  `self.x` fn-ref candidates as BARE names, attribute callees via the namedChild(1)
+  fallback. Go: receiver methods with `Recv::name` QNs + first-earlier-struct
+  contains edges, type_spec → struct/interface classification (embedding → extends;
+  interface method_elems → method nodes), composite-literal instantiates keeping the
+  package qualifier, top-level var/const initializer walks attributed to the symbol
+  (#693), 2-hop field chains (#1276), `New().Method()` re-encode (#645/#608),
+  GO_SPEC fn-ref layers (literal_element/expression_list fan-out).
+- **Grammars:** crates tree-sitter-python 0.23.6 (bffb65a) + tree-sitter-go 0.23.4
+  (3c3775f); wasm vendored from the same tags, parser.c sha-matched (both were
+  2023-era in tree-sitter-wasms).
+- **Parity:** extraction sweeps 100% — flask 83/83, django 3,035/3,038 (+3 error-file
+  deferrals), gin 99/99, prometheus 978/979 (+1). Full-init dumps byte-identical:
+  flask (10,833 rows), gin (17,540), **django (360,794)**, **prometheus (213,758)**.
+  Torture fixtures in `npm test`. Even Mac-side init already moves where extraction
+  matters: prometheus 5.7→4.5s, django 9.0→8.7s. On the 2-CPU/6GB envelope (the
+  CI-runner class): **django 22.0→16.7s (1.32×), prometheus 15.0→10.3s (1.46×)**.
+- Default routing now: typescript, tsx, javascript, jsx, java, python, go.
+
+### 4f. R6 — kernel-scale re-validation (2026-07-17)
+
+Fresh init of the Linux kernel in the cg1212 container (2 CPUs / 6GB), current build
+(R5 kernel + direct-to-store active), CODEGRAPH_SYNTH_TIMINGS:
+
+- **Completes, exit 0: 1,586s (26.4min) vs the ~27min #1212/#1323 baseline — no
+  regression** with per-file routing checks, error-file deferral, and the d2s store
+  path live. Graph scale identical: 2,048,664 nodes / 6,405,964 edges (baseline
+  2.05M/6.4M).
+- Phase walls: scan 1.3s (70,239 files), **parse-loop 371.9s (6.2m — unchanged)**,
+  fts-rebuild 6.4s, edge-index-recreate 78.9s, callback-synthesis 350.3s,
+  **resolution 1,149.7s (19.2m — the wall, P1's territory)**, maintenance 47.2s.
+- **The §6 parse expectation (6m → ~2m) was mis-premised:** the Linux tree is
+  63,810 C/H files vs 422 Python — ~99% C, an UNPORTED T2 language, so the kernel
+  can't touch its parse time. The expectation transfers to the C/C++ port (R7,
+  with the blanking pre-passes staying TS-side per §4). The tree's own Python
+  tooling: 99/99 files byte-parity.
+- Kernel-scale priority order after this run: **P1 resolution (73% of the wall)** >
+  C/C++ port (23%) > everything else.
+
+### 4d. Direct-to-store decode (2026-07-16) — and where the wall ACTUALLY is
+
+Kernel-routed files now ship their flat buffers from the parse worker all the way
+to the STORE WORKER, which decodes + finalizes them there (`tryKernelExtractRaw` →
+`ExtractionResult.kernelBuffers` → `KernelStoreBundle` → `decodeKernelBundle`;
+filter semantics shared via `finalizeStoreBundle`). The main thread's per-file work
+drops to O(1) + the content hash — it never materializes per-node objects, and both
+postMessage hops move flat bytes instead of object graphs. Files whose applicable
+frameworks carry an `extract()` hook keep the decoded path (hooks merge into decoded
+results); non-writer paths (main-thread store, tests) materialize via
+`materializeKernelResult`. Byte-identical dumps re-verified on dubbo, excalidraw,
+express, gson.
+
+**Measurement that closes the §4c question:** with the store worker instrumented,
+dubbo's parse-loop wall is **94% store-writer busy time** (4,202ms of 4,493ms on the
+kernel arm). The many-core fresh-index wall is the single-writer SQLite ingest —
+not extraction, not main-thread work. d2s still improves the writer lane ~11%
+(4,726→4,202ms: buffers skip structured-clone deserialization ON the writer) and
+frees the main thread, but the remaining cbm gap on many-core medium repos is a
+STORE-ARCHITECTURE question (their RAM-first design defers all durability). Next
+levers there (a separate perf arc, not this project): deferred/bulk index builds
+during the parse phase, multi-file write transactions, buffer→bind without object
+materialization. Note the #1320-arc post-mortem already measured statement batching
+and sorted inserts as ~zero on this path — B-tree maintenance is the floor.
+
+## 4. Per-language tracker
+
+Tiers: **T1** = mostly `.scm` + mapping config. **T2** = needs bespoke pre/post passes kept
+in TS (listed). **T3** = not a plain tree-sitter walk (standalone/multi-grammar extractor)
+— migrate last or never; wasm/TS path is a fine permanent home.
+
+The user-facing language contract is `README.md → Language Support` (34 logos incl.
+Metal, CUDA, Terraform/OpenTofu, Pascal/Delphi). Keep this tracker in sync with it —
+every README language must have a row here, even the ones that only ride another
+language's port.
+
+Grammar column: `crates.io` = mainstream native grammar crate exists; `vendored` = we ship
+a rebuilt/patched wasm (ABI-15) and the kernel must compile OUR fork natively — verify
+parity before porting the language.
+
+| Language(s) | Today | Tier | Grammar source | Migration notes / known traps | Status |
+|---|---|---|---|---|---|
+| typescript, tsx, javascript, jsx | `languages/typescript.ts`, `javascript.ts` + shared branches | T1 | crates.io | First target. Value-reference edges (#895/#897) and component recognition (#841 forwardRef/memo/styled) must survive — they're extraction-side. Largest test surface; gate is strictest here. **PORTED + GATE PASSED + DEFAULT-ON (§4a/§4b); erroring files defer to wasm per-file.** | ✅ |
+| java | `languages/java.ts` | T1 | crates.io | Second target; unlocks the dubbo-parity claim. Lombok member synthesis (#912) is a NODE synthesizer hook in extraction (`synthesizeMembers`) — port or keep as TS post-pass. **PORTED incl. Lombok + gate passed + DEFAULT-ON (§4c).** | ✅ |
+| python | `languages/python.ts` | T1 | crates.io | Third. Decorator extraction feeds framework route detection — parity required. **PORTED + DEFAULT-ON (§4e).** | ✅ |
+| go | `languages/go.ts` | T1 | crates.io | Third (tie). Value-reference edges ship here too (#897). **PORTED + DEFAULT-ON (§4e).** | ✅ |
+| ruby, php | dedicated files | T1 | crates.io | Straightforward; PHP property-receiver shapes (#1220/#1251) are RESOLUTION-side, unaffected. | ☐ |
+| csharp | `languages/csharp.ts` | T1 | crates.io | Plain. | ☐ |
+| rust, dart, scala, lua, luau, r | dedicated files | T1 | crates.io (luau/r/scala: verify crate freshness vs our wasm) | Long-tail T1; port opportunistically after the big five. | ☐ |
+| kotlin | `languages/kotlin.ts` | T1½ | crates.io | Expect/actual pairing is synthesis-side (fine); extraction is clean but validate against a KMP repo. | ☐ |
+| swift | shared + dedicated branch | T1½ | crates.io | **Trap:** in-class property extraction lives in `tree-sitter.ts`'s DEDICATED branch, not `swift.ts` (#1020 — Alamofire went 0→348 props). Gate on Alamofire. | ☐ |
+| c, cpp | `languages/c-cpp.ts` | **T2** | crates.io | Keep as TS pre-passes: `blankCppExportMacros`/`blankCppInlineMacros` (UE `class MACRO Name` phantom-function misparse, #1096–#1102, CARLA 440→6), in-body reflection collapse guard (#1206), content-based `.h` C-vs-C++ detection. | ☐ |
+| metal, cuda | dialects over the cpp grammar | **T2** (rides c/cpp) | crates.io (cpp) | README-listed as first-class languages. Both are dialect-gated cpp: Metal = specifier/`[[attribute]]` blanking (#1121, the preParse-takes-filePath pattern); CUDA = `<<<>>>` blanking + content-gated `.h` (#1172). Their pre-passes must run before the kernel parse or stay TS-side; gate them WITH the c/cpp port, not separately. | ☐ |
+| objc | `languages/objc.ts` | T2 | crates.io | Rides the c-cpp trap family; RN bridge extraction feeds `rnCrossPlatformEdges` (synthesis-side, fine). | ☐ |
+| arkts | `languages/arkts.ts` | T2 | **vendored** (harmony-contrib) | Dot-prefixed refs + decorator-gated matching fixed 36,840 wrong edges — that logic must port exactly or stay TS-side. Compile our grammar fork natively. | ☐ |
+| pascal | `languages/pascal.ts` | T2 | **vendored** | Paired with dfm-extractor (T3); `extractPascalDefProc` indexed lookups. | ☐ |
+| vbnet | `languages/vbnet.ts` | T2 | **vendored, patched + external scanner** | Our wasm is a patched grammar WITH a C external scanner — the kernel must build that scanner; ts-cli 0.24 dropped `\p{...}` classes during the original build (#1164). Highest grammar-build risk of any language. | ☐ |
+| cobol | `languages/cobol.ts` | T2 | **vendored fork** | Paragraph-extent reconstruction + copybook resolution are extraction logic (#1161, CardDemo 43/44). Port carefully or keep TS post-pass. | ☐ |
+| erlang | `languages/erlang.ts` | T2 | **vendored (WhatsApp/ELP)** | npm `tree-sitter-erlang` is HIJACKED — never source from it (#1165). gen_server dispatch is synthesis-side (fine). | ☐ |
+| nix | `languages/nix.ts` | T2 | **vendored (ABI-15 rebuild)** | Option-path synthesizer is synthesis-side; the `===`-always-false → `.equals()` lesson (#1190) is wasm-binding-specific and disappears natively — still gate on nixpkgs (44k files). | ☐ |
+| solidity | `languages/solidity.ts` | T2 | **vendored** | `modifier_invocation` outside body walk (#1170) is extraction-side; port it. | ☐ |
+| terraform | `languages/terraform.ts` | T2 | **vendored** | `:`-scoped refs for module-boundary bridging (#1173); metadata does NOT persist — re-read source (#1174). | ☐ |
+| cfml, cfscript, cfquery | `cfml-extractor.ts` + 3 grammar files | **T3** | **vendored ×3** | 3-grammar family with BOM-sensitive dialect sniffing (#1118/#1153–55). Leave on wasm until the very end, possibly forever. | ☐ |
+| svelte, vue, astro, liquid | standalone extractors | **T3** | n/a (custom/embedded parsing) | Not tree-sitter walks. Permanent TS home is acceptable — file counts are small and these repos are small. | ☐ |
+| dfm (Delphi forms), razor, mybatis XML | standalone extractors | **T3** | n/a | Same as above. mybatis pairs with a synthesis pass (fine). | ☐ |
+
+**Do-not-regress invariants during any port** (extraction-side, will show up in the gate):
+node metadata is re-read from source, never persisted; parse commits stay in FILE ORDER
+(#1015); `MAX_FILE_SIZE` skip; generated-file detection; `CODEGRAPH_PARSE_WORKERS`
+semantics; framework `extract()` hooks keep running TS-side per file after the kernel pass.
+
+## 5. Equivalence gate (run per language, no exceptions)
+
+Byte-identity vs hand-written extractors is NOT expected — the gate is behavioral parity:
+
+1. **Graph parity:** fresh-index 3 real repos (small/medium/large for the language) on
+   wasm-path vs kernel-path builds. Dump with the `dump-graph.mjs` pattern (natural keys).
+   Node/edge/ref deltas ≤0.5% AND every diff category manually classified (the 13-edge
+   supertype-visibility bug this week was caught exactly this way — small diffs are real).
+2. **Retrieval invariants:** the language's canonical flows still connect end-to-end in
+   `codegraph_explore` (playbook: `docs/design/dynamic-dispatch-coverage-playbook.md`);
+   node counts stable; synthesized-edge spot-check.
+3. **Agent A/B non-regression** per the standard methodology (CLAUDE.md): `--model sonnet
+   --effort high` ALWAYS, ≥2 runs/arm, pre-warmed daemon, `CODEGRAPH_NO_PROMPT_HOOK=1`,
+   forbid subagent delegation in the prompt.
+4. **Perf:** fresh-index improves on the language's repos; a NON-migrated control repo is
+   unchanged; suite green; Linux docker + Windows VM passes for platform-sensitive bits.
+
+## 6. Rollout order and expected wins
+
+> **Executed 2026-07-16/17; outcomes vs these expectations are in §4a–§4f.** Two
+> expectations below were corrected by measurement: (2) the dubbo-on-Mac headline is
+> store-writer-bound, not extraction-bound (§4c/§4d — the win lands on the low-core
+> envelope instead); (4) cg1212 is ~99% C, an unported T2 language, so its parse
+> expectation belongs to the C/C++ port (§4f).
+
+1. **TS/JS/TSX/JSX** — most indexed files in the funnel; excalidraw 3.3s → ~2.3s expected.
+2. **Java** — dubbo 11.1s → ~7.5s expected (**the cbm-parity headline**).
+3. **Python, Go** — rounds out ~90% of real-world indexed files.
+4. Kernel-scale re-run in the cg1212 container after (2): parse 6.0m → ~1.5–2m expected.
+5. Long tail opportunistically; T3 possibly never — that's fine by design.
+
+Measurement discipline (hard-won this week — do NOT relearn these):
+- Profile first. Ideas killed by measurement this week: sorted-chunk inserts (zero),
+  statement-batching the persist (zero — B-tree maintenance is the cost), RAM-disk/
+  in-memory DB build (SLOWER — fastInit already writes at page-cache speed).
+- `CODEGRAPH_SYNTH_TIMINGS=1` now emits full phase walls (`[phase-timing]`) + pool/batch
+  timings. UI distorts phase walls — pipe stdout away.
+- Check host load before timing (iOS simulators inflated every phase ~30%); the
+  Monitor-on-loadavg pattern (fire <3.5) gives clean windows.
+- `grep` is aliased to ugrep and silently treats `callback-synthesizer.ts` as binary —
+  use `grep -a`.
+
+## 7. AFTER the kernel: the follow-on roadmap (in order)
+
+### 7a. Kernel-scale resolution speed — NOW THE TOP OPEN PERF ITEM
+Confirmed by the R6 run (§4f): resolution is 19.2min of the 26.4min Linux-kernel
+wall (73%) — sequential BY DESIGN in the 2-CPU container (the resolver pool requires
+≥4 cores to engage). Parse is 6.2min (23%) and belongs to the C/C++ port (R7a).
+Steps: re-run cg1212 validation on ≥4-core allocation (pool + parallel synthesis
+#1321/#1322 engage — this first measurement is cheap and may reshape the whole
+problem); profile; likely levers: worker count scaling, batch size at scale,
+`warmCachesYielding` on multi-GB DBs. Target: kernel <10min on a normal 8-core host.
+
+### 7b. Arc 3 — graph richness (forensics-backed; adopt cbm's real extras, skip inflation)
+Priority order, each gated by the standard A/B + node-explosion probes:
+1. **Test→subject edges** (first-class `tests` edges at index time; we compute covering
+   tests at query time today; cbm materializes 14.8k on dubbo). Feeds test-gap detection
+   (Lite headline) + Pro risk signals. Cheapest, do first.
+2. **Per-node code metrics** (complexity, cognitive, `is_test`, `is_entry_point`,
+   param counts) — computed during extraction (the kernel makes this nearly free —
+   design the buffer contract with a metrics slot!). Feeds Pro risk-ranking verdicts +
+   explore ranking de-noise.
+3. **Read/write distinction on references** (`USAGE` vs `WRITES`). The measured agent
+   frontier ("who mutates this state" — the canvasNonce class). HIGHEST value, HIGHEST
+   risk: scope to exported/state-relevant symbols; the tracking-every-local explosion is
+   the known failure mode (#999/#1212 class). Full validation methodology.
+4. **Exception-flow edges** (`raises`) — throw→handler; moderate.
+5. **Doc Section nodes** (markdown headings as nodes, linked to code) — maps onto Pro's
+   synced-business-docs story.
+6. **IaC nodes** (k8s/docker/kustomize as graph nodes with cross-references).
+NOT worth chasing (verified in their cache schema): per-variable node inflation (85% of
+their node count), DB size parity (theirs is ~60% allocation slack), similarity vectors
+in the core engine.
+
+### 7c. Deferred/parked (needs explicit approval before starting)
+- Single-file SEA binary (distribution polish; zero speed).
+- Team-shared graph artifact (cbm's `graph.db.zst` idea — good, but design it for the
+  Pro shared-worker story, not as an OSS clone).
+- Full native rewrite: rejected with data — the moat (2,444 tests, byte-identical
+  determinism, this week's two caught-by-gate bugs) lives in the TS reference.
+
+## 8. Context for the executing agent
+
+- House rules live in `CLAUDE.md` (repo root) — the retrieval invariants, A/B model
+  policy, release rules (never `npm publish`/push tags), changelog format.
+- This week's PR trail tells the story and the style: #1305, #1320 (checkpoint deferral +
+  double-buffered persist; THE invariant: batch k+1 READS batch k's edges — supertype
+  walks — so edges insert before fan-out), #1321 (parallel synthesis via pool reuse,
+  registry order = merge order), #1322 (bulk edge load, identity index stays), #1323
+  (kernel-scale hardening: skip-don't-retry-on-main >1.5M nodes, yielding index recreate).
+- Every perf PR shipped byte-identical with the dump-diff gate; keep that bar.
+- Competitive context (validated 2026-07-16): cbm wins medium-repo fresh index 1.55–1.8×
+  (their RAM-first design); we win sync 2.4–2.8×, agent A/B (their 14 tools drew ZERO
+  calls in 8/8 runs), call-graph density 1.3–2.3×, and the constrained-hardware envelope
+  (Linux kernel on 2-CPU/6GB: we complete in 27min, they die at 0.16% — their speed IS
+  their memory floor). The kernel project closes their last number without giving up any
+  of ours.

+ 1 - 0
package.json

@@ -21,6 +21,7 @@
     "preuninstall": "node dist/bin/uninstall.js",
     "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"",
     "dev": "tsc --watch",
+    "build:kernel": "bash scripts/build-kernel.sh",
     "cli": "npm run build && node dist/bin/codegraph.js",
     "test": "vitest run",
     "test:watch": "vitest",

+ 20 - 0
scripts/build-bundle.sh

@@ -68,6 +68,26 @@ echo "[bundle] installing production dependencies"
 ( cd "$STAGE/lib" && npm ci --omit=dev --ignore-scripts >/dev/null 2>&1 )
 rm -f "$STAGE/lib/package-lock.json"
 
+# 3b. Native extraction kernel (optional). Included when a prebuilt .node for
+#     the target exists — release/kernel/<target>/codegraph-kernel.node (the
+#     release workflow's prebuild artifacts) or the locally staged
+#     codegraph-kernel/prebuilds/<target>/ (scripts/build-kernel.sh). Absent →
+#     the bundle simply runs the wasm extraction path; the kernel is a
+#     per-language speedup, never a requirement (see
+#     docs/design/rust-kernel-migration-plan.md).
+KERNEL_NODE=""
+for candidate in "$ROOT/release/kernel/${TARGET}/codegraph-kernel.node" \
+                 "$ROOT/codegraph-kernel/prebuilds/${TARGET}/codegraph-kernel.node"; do
+  if [ -f "$candidate" ]; then KERNEL_NODE="$candidate"; break; fi
+done
+if [ -n "$KERNEL_NODE" ]; then
+  mkdir -p "$STAGE/lib/kernel"
+  cp "$KERNEL_NODE" "$STAGE/lib/kernel/codegraph-kernel.node"
+  echo "[bundle] native kernel included ($KERNEL_NODE)"
+else
+  echo "[bundle] no native kernel for ${TARGET} — bundle uses the wasm extraction path"
+fi
+
 # 4. Vendored Node + launcher (the launcher uses the bundled Node by relative
 #    path, so no system Node is ever needed).
 #

+ 82 - 0
scripts/build-kernel.sh

@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+#
+# Build the native extraction kernel (codegraph-kernel) and stage the .node
+# where the TS loader (src/extraction/kernel/loader.ts) finds it for
+# from-source runs and tests:
+#
+#   codegraph-kernel/prebuilds/<platform>-<arch>/codegraph-kernel.node
+#
+# The kernel is OPTIONAL everywhere: when the .node is absent the extraction
+# path falls back to the wasm pipeline. This script needs a Rust toolchain
+# (rustup.rs); nothing else in the repo does.
+#
+# Usage:
+#   scripts/build-kernel.sh                 # host platform
+#   scripts/build-kernel.sh --target <rust-triple> [--platform <plat-arch>]
+#
+# The cross-compile form is what the release workflow uses (e.g.
+# --target x86_64-apple-darwin --platform darwin-x64 on a macos-arm runner).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+CRATE="$ROOT/codegraph-kernel"
+
+TARGET=""
+PLATFORM=""
+while [ $# -gt 0 ]; do
+  case "$1" in
+    --target)   TARGET="$2"; shift 2 ;;
+    --platform) PLATFORM="$2"; shift 2 ;;
+    *) echo "unknown arg: $1" >&2; exit 1 ;;
+  esac
+done
+
+# Map a rust triple (or the host) to the bundle-target naming used across the
+# release pipeline (darwin-arm64, linux-x64, win32-arm64, ...).
+if [ -z "$PLATFORM" ]; then
+  if [ -n "$TARGET" ]; then
+    case "$TARGET" in
+      aarch64-apple-darwin)         PLATFORM="darwin-arm64" ;;
+      x86_64-apple-darwin)          PLATFORM="darwin-x64" ;;
+      x86_64-unknown-linux-gnu)     PLATFORM="linux-x64" ;;
+      aarch64-unknown-linux-gnu)    PLATFORM="linux-arm64" ;;
+      x86_64-pc-windows-msvc)       PLATFORM="win32-x64" ;;
+      aarch64-pc-windows-msvc)      PLATFORM="win32-arm64" ;;
+      *) echo "cannot map rust target '$TARGET' to a platform name; pass --platform" >&2; exit 1 ;;
+    esac
+  else
+    case "$(uname -s)-$(uname -m)" in
+      Darwin-arm64)  PLATFORM="darwin-arm64" ;;
+      Darwin-x86_64) PLATFORM="darwin-x64" ;;
+      Linux-x86_64)  PLATFORM="linux-x64" ;;
+      Linux-aarch64) PLATFORM="linux-arm64" ;;
+      MINGW*-x86_64|MSYS*-x86_64)   PLATFORM="win32-x64" ;;
+      MINGW*-aarch64|MSYS*-aarch64) PLATFORM="win32-arm64" ;;
+      *) echo "unrecognized host $(uname -s)-$(uname -m); pass --platform" >&2; exit 1 ;;
+    esac
+  fi
+fi
+
+echo "[kernel] building codegraph-kernel for ${PLATFORM}${TARGET:+ (target $TARGET)}"
+cd "$CRATE"
+if [ -n "$TARGET" ]; then
+  rustup target add "$TARGET" >/dev/null 2>&1 || true
+  cargo build --release --target "$TARGET"
+  OUTDIR="$CRATE/target/$TARGET/release"
+else
+  cargo build --release
+  OUTDIR="$CRATE/target/release"
+fi
+
+# cdylib name differs per OS; the staged name is always codegraph-kernel.node.
+case "$PLATFORM" in
+  darwin-*) LIB="$OUTDIR/libcodegraph_kernel.dylib" ;;
+  linux-*)  LIB="$OUTDIR/libcodegraph_kernel.so" ;;
+  win32-*)  LIB="$OUTDIR/codegraph_kernel.dll" ;;
+esac
+[ -f "$LIB" ] || { echo "[kernel] error: built library not found at $LIB" >&2; exit 1; }
+
+DEST="$CRATE/prebuilds/$PLATFORM"
+mkdir -p "$DEST"
+cp "$LIB" "$DEST/codegraph-kernel.node"
+echo "[kernel] staged $DEST/codegraph-kernel.node ($(du -h "$DEST/codegraph-kernel.node" | cut -f1))"

+ 57 - 0
scripts/dump-graph.mjs

@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+/**
+ * Dump a .codegraph/codegraph.db graph by NATURAL KEYS (no rowids, no
+ * timestamps), sorted — two dumps diff clean iff the graphs are semantically
+ * identical. The byte-identical gate used by every perf/kernel PR:
+ *
+ *   node scripts/dump-graph.mjs <repo-or-db> > a.dump
+ *   node scripts/dump-graph.mjs <repo-or-db> > b.dump
+ *   diff a.dump b.dump
+ *
+ * Volatile fields excluded: nodes.updated_at, files.modified_at/indexed_at/
+ * content_hash+size (environment-dependent), edges.id / unresolved_refs.id
+ * (insertion rowids), and unresolved_refs.status (resolution bookkeeping —
+ * kept, actually: status is deterministic given the same input; excluded only
+ * if it proves flaky. We keep status.)
+ */
+
+import { DatabaseSync } from 'node:sqlite';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+
+const arg = process.argv[2];
+if (!arg) {
+  console.error('usage: dump-graph.mjs <repo-root-or-db-path>');
+  process.exit(2);
+}
+let dbPath = arg;
+if (fs.statSync(arg).isDirectory()) {
+  dbPath = path.join(arg, '.codegraph', 'codegraph.db');
+}
+const db = new DatabaseSync(dbPath, { readOnly: true });
+
+function dump(title, sql) {
+  const rows = db.prepare(sql).all();
+  const lines = rows.map((r) => JSON.stringify(r)).sort();
+  process.stdout.write(`== ${title} (${lines.length})\n`);
+  for (const l of lines) process.stdout.write(l + '\n');
+}
+
+dump(
+  'nodes',
+  `SELECT id, kind, name, qualified_name, file_path, language, start_line, end_line,
+          start_column, end_column, docstring, signature, visibility, is_exported,
+          is_async, is_static, is_abstract, decorators, type_parameters, return_type
+   FROM nodes`
+);
+dump(
+  'edges',
+  `SELECT source, target, kind, metadata, line, col, provenance FROM edges`
+);
+dump(
+  'refs',
+  `SELECT from_node_id, reference_name, reference_kind, line, col, candidates,
+          file_path, language, status, name_tail
+   FROM unresolved_refs`
+);
+dump('files', `SELECT path, language, node_count FROM files`);

+ 242 - 0
scripts/kernel-parity.mjs

@@ -0,0 +1,242 @@
+#!/usr/bin/env node
+/**
+ * Kernel↔wasm extraction parity harness (R2/R3 of the kernel migration).
+ *
+ * Runs BOTH extraction paths over the given files/directories and diffs the
+ * per-file ExtractionResults as sets (nodes/edges/refs, canonicalized), so a
+ * behavioral gap in the native kernel shows up as a categorized diff instead
+ * of a graph-dump surprise later. This is the fast inner loop; the §5 gate's
+ * full-repo dump-diff still runs before any default-on.
+ *
+ * Usage:
+ *   node scripts/kernel-parity.mjs <file-or-dir>... [--lang typescript,tsx]
+ *        [--max-samples N] [--list-files]
+ *
+ * Requires: npm run build (dist/) and a staged kernel (npm run build:kernel).
+ * Exit code: 0 = parity, 1 = diffs found, 2 = setup error.
+ */
+
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const dist = (p) => path.join(ROOT, 'dist', p);
+
+const args = process.argv.slice(2);
+const paths = [];
+let langFilter = null;
+let maxSamples = 5;
+let listFiles = false;
+for (let i = 0; i < args.length; i++) {
+  if (args[i] === '--lang') langFilter = new Set(args[++i].split(','));
+  else if (args[i] === '--max-samples') maxSamples = Number(args[++i]);
+  else if (args[i] === '--list-files') listFiles = true;
+  else paths.push(args[i]);
+}
+if (paths.length === 0) {
+  console.error('usage: kernel-parity.mjs <file-or-dir>... [--lang ts,tsx] [--max-samples N]');
+  process.exit(2);
+}
+
+const KERNEL_LANGS = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go']);
+const EXTS = new Map([
+  ['.ts', 'typescript'], ['.mts', 'typescript'], ['.cts', 'typescript'],
+  ['.tsx', 'tsx'], ['.js', 'javascript'], ['.mjs', 'javascript'],
+  ['.cjs', 'javascript'], ['.jsx', 'jsx'], ['.java', 'java'], ['.py', 'python'], ['.pyw', 'python'], ['.go', 'go'],
+]);
+
+/** Collect candidate files. */
+function collect(p, out) {
+  let st;
+  try {
+    st = fs.statSync(p); // dangling symlinks (Linux-tree dtc fixtures) throw
+  } catch {
+    return;
+  }
+  if (st.isDirectory()) {
+    const base = path.basename(p);
+    if (base === 'node_modules' || base === '.git' || base === 'dist' || base === '.codegraph') return;
+    for (const e of fs.readdirSync(p)) collect(path.join(p, e), out);
+  } else if (EXTS.has(path.extname(p))) {
+    const lang = EXTS.get(path.extname(p));
+    if (!langFilter || langFilter.has(lang)) out.push({ file: p, lang });
+  }
+}
+
+const files = [];
+for (const p of paths) collect(path.resolve(p), files);
+if (files.length === 0) {
+  console.error('no matching files');
+  process.exit(2);
+}
+
+// --- load the built engine ---------------------------------------------------
+const { extractFromSource } = await import(dist('extraction/tree-sitter.js'));
+const { initGrammars, loadGrammarsForLanguages } = await import(dist('extraction/grammars.js'));
+const kernel = await import(dist('extraction/kernel/index.js'));
+
+await initGrammars();
+await loadGrammarsForLanguages([...KERNEL_LANGS]);
+
+if (!kernel.getKernel()) {
+  console.error('kernel .node not found — run: npm run build:kernel');
+  process.exit(2);
+}
+
+// --- canonicalization ---------------------------------------------------------
+/**
+ * Node identity for cross-referencing edges/refs: the node id itself (both
+ * paths compute the same deterministic ids, and id embeds kind+name+line).
+ */
+function canonNode(n) {
+  const out = {
+    id: n.id, kind: n.kind, name: n.name, qualifiedName: n.qualifiedName,
+    filePath: n.filePath, language: n.language,
+    startLine: n.startLine, endLine: n.endLine,
+    startColumn: n.startColumn, endColumn: n.endColumn,
+  };
+  for (const k of ['docstring', 'signature', 'visibility', 'isExported', 'isAsync', 'isStatic', 'isAbstract', 'returnType']) {
+    if (n[k] !== undefined) out[k] = n[k];
+  }
+  if (n.decorators !== undefined) out.decorators = n.decorators;
+  if (n.typeParameters !== undefined) out.typeParameters = n.typeParameters;
+  return JSON.stringify(out);
+}
+
+function canonEdge(e) {
+  const out = { source: e.source, target: e.target, kind: e.kind };
+  if (e.line !== undefined) out.line = e.line;
+  if (e.column !== undefined) out.column = e.column;
+  if (e.provenance !== undefined) out.provenance = e.provenance;
+  if (e.metadata !== undefined) out.metadata = e.metadata;
+  return JSON.stringify(out);
+}
+
+function canonRef(r) {
+  // FULL object — a field only one path sets is a parity bug (the vitest
+  // parity suite caught decode.ts pre-filling filePath/language this way).
+  const out = {
+    from: r.fromNodeId, name: r.referenceName, kind: r.referenceKind,
+    line: r.line, column: r.column,
+  };
+  for (const k of ['filePath', 'language', 'candidates', 'rowId']) {
+    if (r[k] !== undefined) out[k] = r[k];
+  }
+  return JSON.stringify(out);
+}
+
+function diffSets(aList, bList) {
+  const a = new Map(); // canon -> count (multiset — duplicates matter)
+  const b = new Map();
+  for (const x of aList) a.set(x, (a.get(x) ?? 0) + 1);
+  for (const x of bList) b.set(x, (b.get(x) ?? 0) + 1);
+  const onlyA = [];
+  const onlyB = [];
+  for (const [k, c] of a) {
+    const d = c - (b.get(k) ?? 0);
+    for (let i = 0; i < d; i++) onlyA.push(k);
+  }
+  for (const [k, c] of b) {
+    const d = c - (a.get(k) ?? 0);
+    for (let i = 0; i < d; i++) onlyB.push(k);
+  }
+  return { onlyA, onlyB };
+}
+
+// --- run ----------------------------------------------------------------------
+const buckets = new Map(); // category -> {count, samples[]}
+function report(category, sample) {
+  let b = buckets.get(category);
+  if (!b) buckets.set(category, (b = { count: 0, samples: [] }));
+  b.count++;
+  if (b.samples.length < maxSamples) b.samples.push(sample);
+}
+
+let filesWithDiffs = 0;
+let filesOk = 0;
+let deferred = 0;
+let totals = { nodes: 0, edges: 0, refs: 0 };
+
+process.env.CODEGRAPH_KERNEL_LANGS = 'all';
+
+for (const { file, lang } of files) {
+  const source = fs.readFileSync(file, 'utf8');
+  const rel = path.relative(ROOT, file);
+
+  delete process.env.CODEGRAPH_KERNEL; // kernel path on
+  const kres = kernel.tryKernelExtract(rel, source, lang);
+  if (!kres) {
+    // Expected: files with parse errors defer to wasm (parity by
+    // construction — both arms run the same extractor). Counted, and
+    // guarded below so a broken kernel can't silently defer everything.
+    deferred++;
+    report('kernel-deferred', rel);
+    continue;
+  }
+  process.env.CODEGRAPH_KERNEL = '0'; // wasm path
+  const wres = extractFromSource(rel, source, lang);
+  delete process.env.CODEGRAPH_KERNEL;
+
+  totals.nodes += wres.nodes.length;
+  totals.edges += wres.edges.length;
+  totals.refs += wres.unresolvedReferences.length;
+
+  let fileHasDiff = false;
+  const tables = [
+    ['node', wres.nodes.map(canonNode), kres.nodes.map(canonNode)],
+    ['edge', wres.edges.map(canonEdge), kres.edges.map(canonEdge)],
+    ['ref', wres.unresolvedReferences.map(canonRef), kres.unresolvedReferences.map(canonRef)],
+  ];
+  for (const [table, wasm, kern] of tables) {
+    const { onlyA, onlyB } = diffSets(wasm, kern);
+    for (const x of onlyA) {
+      fileHasDiff = true;
+      const o = JSON.parse(x);
+      report(`${table}:missing-in-kernel:${o.kind ?? ''}`, `${rel}: ${x}`);
+    }
+    for (const x of onlyB) {
+      fileHasDiff = true;
+      const o = JSON.parse(x);
+      report(`${table}:extra-in-kernel:${o.kind ?? ''}`, `${rel}: ${x}`);
+    }
+    // ORDER matters too: identical multisets in a different emission order
+    // change DB rowids, and resolution iterates refs in rowid order — the
+    // full-index dump-diff would surface it as a downstream mystery. Catch it
+    // here instead.
+    if (onlyA.length === 0 && onlyB.length === 0) {
+      for (let i = 0; i < wasm.length; i++) {
+        if (wasm[i] !== kern[i]) {
+          fileHasDiff = true;
+          report(`${table}:order-mismatch`, `${rel}: index ${i}: wasm=${wasm[i]} kernel=${kern[i]}`);
+          break;
+        }
+      }
+    }
+  }
+  if (fileHasDiff) {
+    filesWithDiffs++;
+    if (listFiles) console.log(`DIFF ${rel}`);
+  } else {
+    filesOk++;
+  }
+}
+
+console.log(`\n=== kernel parity: ${filesOk}/${files.length} files byte-parity` +
+  ` (${filesWithDiffs} with diffs, ${deferred} deferred-to-wasm)` +
+  ` | wasm totals: ${totals.nodes} nodes / ${totals.edges} edges / ${totals.refs} refs ===\n`);
+
+const sorted = [...buckets.entries()].sort((a, b) => b[1].count - a[1].count);
+for (const [cat, { count, samples }] of sorted) {
+  console.log(`--- ${cat}: ${count}`);
+  for (const s of samples) console.log(`    ${s.length > 400 ? s.slice(0, 400) + '…' : s}`);
+}
+
+// Deferrals are per-file parse-error routing (expected, rare). A high rate
+// means the kernel is broken and hiding behind the fallback — fail loudly.
+const deferralRate = deferred / files.length;
+if (deferralRate > 0.1) {
+  console.error(`deferral rate ${(deferralRate * 100).toFixed(1)}% exceeds 10% — kernel likely broken`);
+  process.exit(1);
+}
+process.exit(filesWithDiffs > 0 ? 1 : 0);

+ 17 - 0
src/extraction/grammars.ts

@@ -271,10 +271,27 @@ export async function initGrammars(): Promise<void> {
  * nix-community/tree-sitter-nix @ 3d0173d (MIT) with tree-sitter-cli 0.25.10
  * (`generate` + `build --wasm`, ABI 15 — upstream's checked-in parser.c is
  * still ABI 13; all 54 upstream corpus tests pass on the regenerated parser).
+ *
+ * TypeScript/TSX/JavaScript (+jsx, which shares the javascript grammar): the
+ * tree-sitter-wasms builds are 2023-era (^0.20.x); we vendor wasm built from
+ * the SAME grammar revisions the native extraction kernel compiles
+ * (codegraph-kernel/Cargo.toml), so the kernel path and the wasm fallback
+ * parse identically and per-language routing stays graph-neutral:
+ *   - tree-sitter/tree-sitter-typescript v0.23.2 (f975a62) → typescript + tsx
+ *   - tree-sitter/tree-sitter-javascript v0.25.0 (44c892e) → javascript + jsx
+ *   - tree-sitter/tree-sitter-java v0.23.5 (94703d5) → java
+ *   - tree-sitter/tree-sitter-python v0.23.6 (bffb65a) → python
+ *   - tree-sitter/tree-sitter-go v0.23.4 (3c3775f) → go
+ * Built from each repo's CHECKED-IN parser.c (no `generate`) with
+ * tree-sitter-cli 0.25.10 `build --wasm` — the same tables crates.io compiles
+ * (parser.c sha-matched against the crates.io tarball).
+ * The kernel-grammar-parity test asserts this alignment; bump the crate and
+ * the vendored wasm together.
  */
 const VENDORED_WASM_LANGS: ReadonlySet<GrammarLanguage> = new Set([
   'pascal', 'scala', 'lua', 'luau', 'csharp', 'r', 'cfml', 'cfscript', 'cfquery',
   'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix',
+  'typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go',
 ]);
 
 /** Absolute path of a language's grammar WASM (vendored or tree-sitter-wasms). */

+ 52 - 33
src/extraction/index.ts

@@ -23,7 +23,8 @@ import {
 import { QueryBuilder } from '../db/queries';
 import { extractFromSource } from './tree-sitter';
 import { ParseWorkerPool, resolveParsePoolSize, resolveParseTimeoutMs } from './parse-pool';
-import { StoreWriter, StoreBundle } from './store-writer';
+import { StoreWriter, StoreBundle, finalizeStoreBundle } from './store-writer';
+import { materializeKernelResult } from './kernel';
 import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages, readGrammarWasmBytes } from './grammars';
 import { loadExtensionOverrides, loadIncludeIgnoredPatterns, loadExcludePatterns, loadIncludePatterns } from '../project-config';
 import { isCodeGraphDataDir } from '../directory';
@@ -1706,16 +1707,34 @@ export class ExtractionOrchestrator {
       const bp = walBackpressure?.();
       if (bp) await bp;
 
+      // Kernel deferred-decode results carry table sizes in kernelCounts
+      // (their object arrays are empty — decode happens at the store).
+      const nodeCount = result.kernelCounts?.nodes ?? result.nodes.length;
+      const edgeCount = result.kernelCounts?.edges ?? result.edges.length;
+
       // Store: on the writer thread when active (fresh DB — bundles applied
       // in the same file order this chain dispatches them), else on the main
       // thread (SQLite connections are per-thread).
-      if (result.nodes.length > 0 || result.errors.length === 0) {
+      if (nodeCount > 0 || result.errors.length === 0) {
         const language = detectLanguage(filePath, content, overrides);
         if (storeWriter) {
-          storeWriter.send(this.buildFreshStoreBundle(filePath, content, language, stats, result));
+          if (result.kernelBuffers) {
+            // Buffers go to the writer as-is; the worker decodes + finalizes.
+            // The main thread's only per-file work stays O(1) + the content hash.
+            storeWriter.send({
+              kernel: true,
+              filePath,
+              language,
+              buffers: result.kernelBuffers,
+              file: this.buildFileRecord(filePath, content, language, stats, nodeCount, result.errors),
+            });
+          } else {
+            storeWriter.send(this.buildFreshStoreBundle(filePath, content, language, stats, result));
+          }
           await storeWriter.waitBelow(STORE_WRITER_WINDOW);
         } else {
-          await this.storeExtractionResult(filePath, content, language, stats, result, commitYield);
+          const materialized = materializeKernelResult(result, filePath, language);
+          await this.storeExtractionResult(filePath, content, language, stats, materialized, commitYield);
         }
       }
 
@@ -1726,10 +1745,10 @@ export class ExtractionOrchestrator {
         errors.push(...result.errors);
       }
 
-      if (result.nodes.length > 0) {
+      if (nodeCount > 0) {
         filesIndexed++;
-        totalNodes += result.nodes.length;
-        totalEdges += result.edges.length;
+        totalNodes += nodeCount;
+        totalEdges += edgeCount;
       } else if (result.errors.some((e) => e.severity === 'error')) {
         filesErrored++;
       } else {
@@ -2383,6 +2402,27 @@ export class ExtractionOrchestrator {
    * check, no cross-file edge snapshot (both are re-index concerns — a fresh
    * database has neither). Filters mirror storeExtractionResult exactly.
    */
+  /** The FileRecord for a fresh-index store (nodeCount is the PRE-filter count). */
+  private buildFileRecord(
+    filePath: string,
+    content: string,
+    language: Language,
+    stats: fs.Stats,
+    nodeCount: number,
+    resultErrors: ExtractionResult['errors']
+  ): FileRecord {
+    return {
+      path: filePath,
+      contentHash: hashContent(content),
+      language,
+      size: stats.size,
+      modifiedAt: stats.mtimeMs,
+      indexedAt: Date.now(),
+      nodeCount,
+      errors: resultErrors.length > 0 ? resultErrors : undefined,
+    };
+  }
+
   private buildFreshStoreBundle(
     filePath: string,
     content: string,
@@ -2390,33 +2430,12 @@ export class ExtractionOrchestrator {
     stats: fs.Stats,
     result: ExtractionResult
   ): StoreBundle {
-    const validNodes = result.nodes.filter((n) => n.id && n.kind && n.name && n.filePath && n.language);
-    const insertedIds = new Set(validNodes.map((n) => n.id));
-    const validEdges = result.edges.filter(
-      (e) => insertedIds.has(e.source) && insertedIds.has(e.target)
+    return finalizeStoreBundle(
+      result,
+      filePath,
+      language,
+      this.buildFileRecord(filePath, content, language, stats, result.nodes.length, result.errors)
     );
-    const validRefs = result.unresolvedReferences
-      .filter((ref) => insertedIds.has(ref.fromNodeId))
-      .map((ref) => ({
-        ...ref,
-        filePath: ref.filePath ?? filePath,
-        language: ref.language ?? language,
-      }));
-    return {
-      nodes: validNodes,
-      edges: validEdges,
-      refs: validRefs,
-      file: {
-        path: filePath,
-        contentHash: hashContent(content),
-        language,
-        size: stats.size,
-        modifiedAt: stats.mtimeMs,
-        indexedAt: Date.now(),
-        nodeCount: result.nodes.length,
-        errors: result.errors.length > 0 ? result.errors : undefined,
-      },
-    };
   }
 
   /**

+ 177 - 0
src/extraction/kernel/decode.ts

@@ -0,0 +1,177 @@
+/**
+ * Decode the kernel's flat buffers into an ExtractionResult — the single
+ * JS-side pass over the per-file tables. See layout.ts for the byte layout
+ * and codegraph-kernel/src/buffers.rs for the writer.
+ */
+
+import type {
+  Edge,
+  EdgeKind,
+  ExtractionError,
+  ExtractionResult,
+  Language,
+  Node,
+  NodeKind,
+  ReferenceKind,
+  UnresolvedReference,
+} from '../../types';
+import { NODE_KINDS, EDGE_KINDS } from '../../types';
+import type { KernelBuffers } from './loader';
+import {
+  EDGE,
+  EDGE_ROW_SIZE,
+  FLAG,
+  FUNCTION_REF_CODE,
+  KERNEL_ABI_VERSION,
+  META,
+  META_SIZE,
+  NODE,
+  NODE_ROW_SIZE,
+  NONE,
+  PROVENANCES,
+  REF,
+  REF_ROW_SIZE,
+  VISIBILITIES,
+} from './layout';
+
+/** Read an (offset, len) arena string; undefined when absent. */
+function str(arena: Buffer, row: Buffer, at: number): string | undefined {
+  const off = row.readUInt32LE(at);
+  if (off === NONE) return undefined;
+  const len = row.readUInt32LE(at + 4);
+  return arena.toString('utf8', off, off + len);
+}
+
+/** NUL-joined list field; undefined when absent. */
+function strList(arena: Buffer, row: Buffer, at: number): string[] | undefined {
+  const joined = str(arena, row, at);
+  return joined === undefined ? undefined : joined.split('\0');
+}
+
+/** Tri-state boolean from a (present, value) bit pair. */
+function flag(flags: number, pair: number): boolean | undefined {
+  if ((flags & (1 << (pair * 2))) === 0) return undefined;
+  return (flags & (1 << (pair * 2 + 1))) !== 0;
+}
+
+function u32opt(row: Buffer, at: number): number | undefined {
+  const v = row.readUInt32LE(at);
+  return v === NONE ? undefined : v;
+}
+
+export function decodeExtractBuffers(
+  buffers: KernelBuffers,
+  filePath: string,
+  language: Language
+): ExtractionResult {
+  const { meta, arena } = buffers;
+  if (meta.length < META_SIZE) throw new Error(`kernel meta too short: ${meta.length}`);
+  const version = meta.readUInt8(META.version);
+  if (version !== KERNEL_ABI_VERSION) {
+    throw new Error(`kernel buffer ABI ${version} != expected ${KERNEL_ABI_VERSION}`);
+  }
+  const nodeCount = meta.readUInt32LE(META.nodeCount);
+  const edgeCount = meta.readUInt32LE(META.edgeCount);
+  const refCount = meta.readUInt32LE(META.refCount);
+
+  const now = Date.now();
+  const nodes: Node[] = new Array(nodeCount);
+  // Node-table row index → node id, for edge/ref endpoint resolution.
+  const idByRow: string[] = new Array(nodeCount);
+
+  for (let i = 0; i < nodeCount; i++) {
+    const row = buffers.nodes.subarray(i * NODE_ROW_SIZE, (i + 1) * NODE_ROW_SIZE);
+    const id = str(arena, row, NODE.id)!;
+    idByRow[i] = id;
+    const flags = row.readUInt16LE(NODE.flags);
+    const node: Node = {
+      id,
+      kind: NODE_KINDS[row.readUInt8(NODE.kind)] as NodeKind,
+      name: str(arena, row, NODE.name)!,
+      qualifiedName: str(arena, row, NODE.qualifiedName)!,
+      filePath,
+      language,
+      startLine: row.readUInt32LE(NODE.startLine),
+      endLine: row.readUInt32LE(NODE.endLine),
+      startColumn: row.readUInt32LE(NODE.startColumn),
+      endColumn: row.readUInt32LE(NODE.endColumn),
+      updatedAt: now,
+    };
+    const docstring = str(arena, row, NODE.docstring);
+    if (docstring !== undefined) node.docstring = docstring;
+    const signature = str(arena, row, NODE.signature);
+    if (signature !== undefined) node.signature = signature;
+    const visibility = VISIBILITIES[row.readUInt8(NODE.visibility)];
+    if (visibility !== undefined) node.visibility = visibility;
+    const isExported = flag(flags, FLAG.isExported);
+    if (isExported !== undefined) node.isExported = isExported;
+    const isAsync = flag(flags, FLAG.isAsync);
+    if (isAsync !== undefined) node.isAsync = isAsync;
+    const isStatic = flag(flags, FLAG.isStatic);
+    if (isStatic !== undefined) node.isStatic = isStatic;
+    const isAbstract = flag(flags, FLAG.isAbstract);
+    if (isAbstract !== undefined) node.isAbstract = isAbstract;
+    const decorators = strList(arena, row, NODE.decorators);
+    if (decorators !== undefined) node.decorators = decorators;
+    const typeParameters = strList(arena, row, NODE.typeParameters);
+    if (typeParameters !== undefined) node.typeParameters = typeParameters;
+    const returnType = str(arena, row, NODE.returnType);
+    if (returnType !== undefined) node.returnType = returnType;
+    const extraJson = str(arena, row, NODE.extraJson);
+    if (extraJson !== undefined) Object.assign(node, JSON.parse(extraJson) as Partial<Node>);
+    nodes[i] = node;
+  }
+
+  const edges: Edge[] = new Array(edgeCount);
+  for (let i = 0; i < edgeCount; i++) {
+    const row = buffers.edges.subarray(i * EDGE_ROW_SIZE, (i + 1) * EDGE_ROW_SIZE);
+    const sourceIdx = row.readUInt32LE(EDGE.sourceIdx);
+    const targetIdx = row.readUInt32LE(EDGE.targetIdx);
+    const edge: Edge = {
+      source: sourceIdx === NONE ? str(arena, row, EDGE.sourceIdStr)! : idByRow[sourceIdx]!,
+      target: targetIdx === NONE ? str(arena, row, EDGE.targetIdStr)! : idByRow[targetIdx]!,
+      kind: EDGE_KINDS[row.readUInt8(EDGE.kind)] as EdgeKind,
+    };
+    const line = u32opt(row, EDGE.line);
+    if (line !== undefined) edge.line = line;
+    const column = u32opt(row, EDGE.column);
+    if (column !== undefined) edge.column = column;
+    const provenance = PROVENANCES[row.readUInt8(EDGE.provenance)];
+    if (provenance !== undefined) edge.provenance = provenance;
+    const metadataJson = str(arena, row, EDGE.metadataJson);
+    if (metadataJson !== undefined) edge.metadata = JSON.parse(metadataJson) as Record<string, unknown>;
+    edges[i] = edge;
+  }
+
+  const unresolvedReferences: UnresolvedReference[] = new Array(refCount);
+  for (let i = 0; i < refCount; i++) {
+    const row = buffers.refs.subarray(i * REF_ROW_SIZE, (i + 1) * REF_ROW_SIZE);
+    const fromIdx = row.readUInt32LE(REF.fromIdx);
+    const kindByte = row.readUInt8(REF.kind);
+    // No filePath/language here: the wasm extractors emit refs WITHOUT the
+    // denormalized fields (the store fills them via `ref.filePath ?? filePath`),
+    // and the kernel must match the extractFromSource seam exactly.
+    const ref: UnresolvedReference = {
+      fromNodeId: fromIdx === NONE ? str(arena, row, REF.fromIdStr)! : idByRow[fromIdx]!,
+      referenceName: str(arena, row, REF.referenceName)!,
+      referenceKind:
+        kindByte === FUNCTION_REF_CODE
+          ? 'function_ref'
+          : (EDGE_KINDS[kindByte] as ReferenceKind),
+      line: row.readUInt32LE(REF.line),
+      column: row.readUInt32LE(REF.column),
+    };
+    const candidates = strList(arena, row, REF.candidates);
+    if (candidates !== undefined) ref.candidates = candidates;
+    unresolvedReferences[i] = ref;
+  }
+
+  let errors: ExtractionError[] = [];
+  const errorsOff = meta.readUInt32LE(META.errorsOff);
+  if (errorsOff !== NONE) {
+    const errorsLen = meta.readUInt32LE(META.errorsLen);
+    errors = JSON.parse(arena.toString('utf8', errorsOff, errorsOff + errorsLen)) as ExtractionError[];
+  }
+
+  return { nodes, edges, unresolvedReferences, errors, durationMs: 0 };
+}

+ 194 - 0
src/extraction/kernel/index.ts

@@ -0,0 +1,194 @@
+/**
+ * Kernel routing — which languages go through the native kernel, and the
+ * single entry point the extraction path calls.
+ *
+ * Routing policy is deliberately TS-side and per-language (migration plan §2):
+ * a language routes to the kernel only after its equivalence gate passes;
+ * everything else stays on the wasm path forever if need be. Rollback per
+ * language = removing it from DEFAULT_ROUTED (or CODEGRAPH_KERNEL=0 for all).
+ *
+ * Routing status: TypeScript/TSX/JavaScript/JSX are default-routed (R3 gate
+ * passed 2026-07-16 — full-index dumps byte-identical on express/excalidraw/
+ * vscode, control repo unchanged; see the migration plan §4a). Override with
+ *   CODEGRAPH_KERNEL_LANGS=<langs|all>  (replaces the default set), or
+ *   CODEGRAPH_KERNEL=0                  (kill switch, everything → wasm).
+ */
+
+import type { ExtractionResult, Language } from '../../types';
+import { getKernel, kernelSupports } from './loader';
+import { decodeExtractBuffers } from './decode';
+import {
+  KERNEL_ABI_VERSION as LAYOUT_ABI,
+  META as LAYOUT_META,
+  NONE as LAYOUT_NONE,
+} from './layout';
+
+export { getKernel, kernelSupports, resetKernelForTests } from './loader';
+export { decodeExtractBuffers } from './decode';
+
+/**
+ * Languages routed to the kernel by default (gate-passed only — see the
+ * per-language tracker in docs/design/rust-kernel-migration-plan.md §4).
+ * Per-file safety valve regardless of routing: a file whose parse tree
+ * contains ERRORS defers to the wasm extractor (error recovery differs
+ * between UTF-8 and UTF-16 parsing — wasm's recovery is canonical).
+ */
+const DEFAULT_ROUTED: ReadonlySet<Language> = new Set<Language>([
+  'typescript',
+  'tsx',
+  'javascript',
+  'jsx',
+  'java',
+  'python',
+  'go',
+]);
+
+/**
+ * Per-language TS post-pass over the decoded result — the escape hatch for
+ * logic `.scm` queries can't express (macro salvage, dialect sniffing,
+ * wrapper-based component recognition). Runs synchronously after decode,
+ * before the framework extract() hooks the caller applies. Keep these SMALL:
+ * anything heavy belongs in the Rust emitter.
+ */
+export type KernelPostPass = (result: ExtractionResult, source: string) => void;
+const POST_PASSES: Partial<Record<Language, KernelPostPass>> = {
+  // (none yet — R2+)
+};
+
+function isRouted(language: Language): boolean {
+  const env = process.env.CODEGRAPH_KERNEL_LANGS;
+  if (env === undefined || env === '') return DEFAULT_ROUTED.has(language);
+  if (env === 'all') return true;
+  return env
+    .split(',')
+    .map((s) => s.trim())
+    .includes(language);
+}
+
+/** True when `language` would be extracted by the kernel right now. */
+export function kernelRoutes(language: Language): boolean {
+  return isRouted(language) && kernelSupports(language);
+}
+
+/** Warned-once registry so a broken language logs a single line, not one per file. */
+const warned = new Set<string>();
+
+/** The raw table buffers + the cheap facts the orchestrator needs pre-decode. */
+export interface KernelRawResult {
+  buffers: NonNullable<ExtractionResult['kernelBuffers']>;
+  counts: { nodes: number; edges: number; refs: number };
+  errors: ExtractionResult['errors'];
+}
+
+/**
+ * Extract via the kernel WITHOUT decoding — the bulk-index fast path. The
+ * tables ride to the store boundary as buffers (decoded on the store worker),
+ * so the main thread never materializes per-node objects. Returns null under
+ * exactly the conditions tryKernelExtract does, PLUS when the language has a
+ * registered post() pass (post passes operate on decoded results, so those
+ * languages keep the decoded path).
+ */
+export function tryKernelExtractRaw(
+  filePath: string,
+  source: string,
+  language: Language
+): KernelRawResult | null {
+  if (!kernelRoutes(language) || POST_PASSES[language]) return null;
+  const kernel = getKernel();
+  if (!kernel) return null;
+  try {
+    const buffers = kernel.extractFile(filePath, source, language);
+    const meta = buffers.meta;
+    if (meta.readUInt8(LAYOUT_META.version) !== LAYOUT_ABI) {
+      throw new Error(`kernel buffer ABI ${meta.readUInt8(0)} != expected ${LAYOUT_ABI}`);
+    }
+    const counts = {
+      nodes: meta.readUInt32LE(LAYOUT_META.nodeCount),
+      edges: meta.readUInt32LE(LAYOUT_META.edgeCount),
+      refs: meta.readUInt32LE(LAYOUT_META.refCount),
+    };
+    let errors: ExtractionResult['errors'] = [];
+    const errorsOff = meta.readUInt32LE(LAYOUT_META.errorsOff);
+    if (errorsOff !== LAYOUT_NONE) {
+      const errorsLen = meta.readUInt32LE(LAYOUT_META.errorsLen);
+      errors = JSON.parse(
+        buffers.arena.toString('utf8', errorsOff, errorsOff + errorsLen)
+      ) as ExtractionResult['errors'];
+    }
+    return { buffers, counts, errors };
+  } catch (err) {
+    const message = err instanceof Error ? err.message : String(err);
+    if (message.includes('defer:')) return null;
+    if (!warned.has(language)) {
+      warned.add(language);
+      process.stderr.write(
+        `[codegraph-kernel] ${language} extraction failed (${message}) — falling back to the wasm path\n`
+      );
+    }
+    return null;
+  }
+}
+
+/**
+ * Decode a buffer-carrying result (see ExtractionResult.kernelBuffers) into a
+ * plain, fully-materialized ExtractionResult — the fallback for store paths
+ * that need objects (main-thread store, tests).
+ */
+export function materializeKernelResult(
+  result: ExtractionResult,
+  filePath: string,
+  language: Language
+): ExtractionResult {
+  if (!result.kernelBuffers) return result;
+  const b = result.kernelBuffers;
+  const asBuf = (u: Uint8Array) => Buffer.from(u.buffer, u.byteOffset, u.byteLength);
+  const decoded = decodeExtractBuffers(
+    { meta: asBuf(b.meta), nodes: asBuf(b.nodes), edges: asBuf(b.edges), refs: asBuf(b.refs), arena: asBuf(b.arena) },
+    filePath,
+    language
+  );
+  decoded.durationMs = result.durationMs;
+  return decoded;
+}
+
+/**
+ * Extract via the native kernel. Returns null when the kernel doesn't apply
+ * (not routed / not available / kill switch) — the caller falls back to the
+ * wasm TreeSitterExtractor. A kernel ERROR on a routed file also returns
+ * null: per-file fallback keeps indexing correct while a kernel bug costs
+ * only that file's speedup.
+ */
+export function tryKernelExtract(
+  filePath: string,
+  source: string,
+  language: Language
+): ExtractionResult | null {
+  if (!kernelRoutes(language)) return null;
+  const kernel = getKernel();
+  if (!kernel) return null;
+  const t0 = Date.now();
+  try {
+    // NOTE(T2 languages): when a preParse-carrying language (csharp #237,
+    // metal #1121, cuda #1172, c/cpp macro blanking) routes here, its
+    // offset-preserving preParse hook must be applied to `source` first —
+    // wire that alongside the language's port, gated WITH its equivalence run.
+    const buffers = kernel.extractFile(filePath, source, language);
+    const result = decodeExtractBuffers(buffers, filePath, language);
+    POST_PASSES[language]?.(result, source);
+    result.durationMs = Date.now() - t0;
+    return result;
+  } catch (err) {
+    const message = err instanceof Error ? err.message : String(err);
+    // `defer:` is the kernel's expected-routing signal (files with parse
+    // errors take the wasm path — its error RECOVERY is the canonical one;
+    // recovery differs between UTF-8 and UTF-16 parsing). Silent by design.
+    if (message.includes('defer:')) return null;
+    if (!warned.has(language)) {
+      warned.add(language);
+      process.stderr.write(
+        `[codegraph-kernel] ${language} extraction failed (${message}) — falling back to the wasm path\n`
+      );
+    }
+    return null;
+  }
+}

+ 102 - 0
src/extraction/kernel/layout.ts

@@ -0,0 +1,102 @@
+/**
+ * Native-kernel buffer layout — TS mirror of codegraph-kernel/src/buffers.rs.
+ *
+ * The kernel returns five Buffers per file: meta, nodes, edges, refs, arena.
+ * Rows are fixed-width little-endian; strings are (offset, len) pairs into
+ * the UTF-8 arena; `offset === NONE` means "field absent".
+ *
+ * THIS FILE AND buffers.rs MUST MATCH BYTE FOR BYTE. Any layout change bumps
+ * KERNEL_ABI_VERSION on both sides — the loader refuses a version it doesn't
+ * know and the extraction path falls back to wasm.
+ *
+ * NodeKind / EdgeKind / provenance / visibility cross the boundary as indexes
+ * into NODE_KINDS / EDGE_KINDS (src/types.ts) and the small tables below, so
+ * those array orders are part of the contract (append, never reorder). The
+ * loader additionally verifies the kernel's own kind tables against
+ * NODE_KINDS/EDGE_KINDS at load time, so a stale .node degrades to the wasm
+ * path instead of mis-decoding.
+ */
+
+export const KERNEL_ABI_VERSION = 1;
+
+/** Sentinel for "absent" in u32 slots and string-ref offsets. */
+export const NONE = 0xffffffff;
+
+export const META_SIZE = 36;
+export const NODE_ROW_SIZE = 96;
+export const EDGE_ROW_SIZE = 44;
+export const REF_ROW_SIZE = 40;
+
+/** meta byte offsets */
+export const META = {
+  version: 0, // u8
+  nodeCount: 4, // u32
+  edgeCount: 8, // u32
+  refCount: 12, // u32
+  arenaLen: 16, // u32
+  errorsOff: 20, // u32 (NONE = no errors)
+  errorsLen: 24, // u32
+  durationMs: 28, // f64 (kernel-side wall; introspection only)
+} as const;
+
+/** node row byte offsets */
+export const NODE = {
+  kind: 0, // u8 — NODE_KINDS index
+  visibility: 1, // u8 — VISIBILITIES index (0 = absent)
+  flags: 2, // u16 — (present, value) bit pairs, see FLAG
+  startLine: 4, // u32
+  endLine: 8, // u32
+  startColumn: 12, // u32
+  endColumn: 16, // u32
+  name: 20, // str
+  qualifiedName: 28, // str
+  id: 36, // str — kernel-computed node id
+  docstring: 44, // str
+  signature: 52, // str
+  decorators: 60, // str — NUL-joined list
+  typeParameters: 68, // str — NUL-joined list
+  returnType: 76, // str
+  extraJson: 84, // str — JSON of any extra Node props (escape hatch)
+  metrics: 92, // u32 — reserved (Arc 3.2 per-node code metrics)
+} as const;
+
+/** edge row byte offsets */
+export const EDGE = {
+  sourceIdx: 0, // u32 (NONE → sourceIdStr)
+  targetIdx: 4, // u32 (NONE → targetIdStr)
+  kind: 8, // u8 — EDGE_KINDS index
+  provenance: 9, // u8 — PROVENANCES index (0 = absent)
+  line: 12, // u32 (NONE = absent)
+  column: 16, // u32 (NONE = absent)
+  metadataJson: 20, // str
+  sourceIdStr: 28, // str
+  targetIdStr: 36, // str
+} as const;
+
+/** ref row byte offsets */
+export const REF = {
+  fromIdx: 0, // u32 (NONE → fromIdStr)
+  kind: 4, // u8 — EDGE_KINDS index, or FUNCTION_REF_CODE
+  line: 8, // u32
+  column: 12, // u32
+  referenceName: 16, // str
+  candidates: 24, // str — NUL-joined list
+  fromIdStr: 32, // str
+} as const;
+
+/** ReferenceKind wire code for the internal-only `function_ref` (#756). */
+export const FUNCTION_REF_CODE = 200;
+
+/** Node bool-flag bit pairs: bit(2n) = present, bit(2n+1) = value. */
+export const FLAG = {
+  isExported: 0,
+  isAsync: 1,
+  isStatic: 2,
+  isAbstract: 3,
+} as const;
+
+/** visibility byte values (0 = absent). */
+export const VISIBILITIES = [undefined, 'public', 'private', 'protected', 'internal'] as const;
+
+/** provenance byte values (0 = absent). */
+export const PROVENANCES = [undefined, 'tree-sitter', 'scip', 'heuristic'] as const;

+ 148 - 0
src/extraction/kernel/loader.ts

@@ -0,0 +1,148 @@
+/**
+ * Native-kernel loader — finds, loads, and contract-verifies the
+ * codegraph-kernel .node addon.
+ *
+ * The kernel is OPTIONAL everywhere. Every failure mode here (no binary for
+ * this platform, dlopen error, ABI/kind-table mismatch) resolves to `null`
+ * and the extraction path silently keeps using the wasm pipeline — a missing
+ * or stale kernel must never break indexing, only skip the speedup. Set
+ * CODEGRAPH_KERNEL_DEBUG=1 to see why a kernel didn't load.
+ *
+ * Kill switch: CODEGRAPH_KERNEL=0 disables the kernel entirely (checked per
+ * call so tests and embedders can flip it at runtime).
+ *
+ * Search order:
+ *   1. CODEGRAPH_KERNEL_PATH — explicit .node path (dev/testing override)
+ *   2. <up3>/kernel/codegraph-kernel.node — the release bundle layout
+ *      (lib/dist/** next to lib/kernel/; see scripts/build-bundle.sh)
+ *   3. <up3>/codegraph-kernel/prebuilds/<platform>-<arch>/codegraph-kernel.node
+ *      — from-source runs and tests (staged by scripts/build-kernel.sh)
+ *
+ * "up3" = three directories above this file, which is the package root both
+ * from src/extraction/kernel/ and from dist/extraction/kernel/.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { createRequire } from 'module';
+import { NODE_KINDS, EDGE_KINDS } from '../../types';
+import { KERNEL_ABI_VERSION } from './layout';
+
+/** Raw buffer tables for one file — see layout.ts for the byte layout. */
+export interface KernelBuffers {
+  meta: Buffer;
+  nodes: Buffer;
+  edges: Buffer;
+  refs: Buffer;
+  arena: Buffer;
+}
+
+export interface KernelContractInfo {
+  abiVersion: number;
+  kernelVersion: string;
+  nodeKinds: string[];
+  edgeKinds: string[];
+  languages: string[];
+}
+
+export interface KernelGrammarInfo {
+  abiVersion: number;
+  nodeKindCount: number;
+  fieldCount: number;
+  nodeKinds: string[];
+  fieldNames: string[];
+}
+
+export interface KernelModule {
+  extractFile(filePath: string, content: string, language: string): KernelBuffers;
+  contractInfo(): KernelContractInfo;
+  grammarInfo(language: string): KernelGrammarInfo | null;
+}
+
+const debugEnabled = () => process.env.CODEGRAPH_KERNEL_DEBUG === '1';
+function debug(msg: string): void {
+  if (debugEnabled()) process.stderr.write(`[codegraph-kernel] ${msg}\n`);
+}
+
+/** Languages the loaded binary supports (contract-verified). Empty when no kernel. */
+let kernelLanguages: ReadonlySet<string> = new Set();
+/** undefined = not attempted yet; null = attempted and unavailable. */
+let cached: KernelModule | null | undefined;
+
+function candidatePaths(): string[] {
+  const candidates: string[] = [];
+  if (process.env.CODEGRAPH_KERNEL_PATH) candidates.push(process.env.CODEGRAPH_KERNEL_PATH);
+  const packageRoot = path.resolve(__dirname, '..', '..', '..');
+  candidates.push(path.join(packageRoot, 'kernel', 'codegraph-kernel.node'));
+  candidates.push(
+    path.join(
+      packageRoot,
+      'codegraph-kernel',
+      'prebuilds',
+      `${process.platform}-${process.arch}`,
+      'codegraph-kernel.node'
+    )
+  );
+  return candidates;
+}
+
+/**
+ * Verify the binary speaks our wire contract: same ABI version and byte-equal
+ * NodeKind/EdgeKind tables (kinds cross the boundary as indexes into these).
+ */
+function verifyContract(mod: KernelModule, from: string): boolean {
+  const info = mod.contractInfo();
+  if (info.abiVersion !== KERNEL_ABI_VERSION) {
+    debug(`${from}: ABI ${info.abiVersion} != expected ${KERNEL_ABI_VERSION} — ignoring kernel`);
+    return false;
+  }
+  const sameTable = (a: readonly string[], b: readonly string[]) =>
+    a.length === b.length && a.every((v, i) => v === b[i]);
+  if (!sameTable(info.nodeKinds, NODE_KINDS) || !sameTable(info.edgeKinds, EDGE_KINDS)) {
+    debug(`${from}: NodeKind/EdgeKind tables differ from src/types.ts — ignoring kernel`);
+    return false;
+  }
+  return true;
+}
+
+/**
+ * Load (once per process) and return the kernel module, or null when
+ * unavailable. The kill switch is NOT checked here — callers route through
+ * `kernelAvailable()` / `tryKernelExtract()` which check it per call.
+ */
+export function getKernel(): KernelModule | null {
+  if (cached !== undefined) return cached;
+  cached = null;
+  for (const candidate of candidatePaths()) {
+    try {
+      if (!fs.existsSync(candidate)) continue;
+      // createRequire: works identically from CJS output and future ESM.
+      const req = createRequire(__filename);
+      const mod = req(candidate) as KernelModule;
+      if (typeof mod.extractFile !== 'function' || typeof mod.contractInfo !== 'function') {
+        debug(`${candidate}: missing expected exports — ignoring`);
+        continue;
+      }
+      if (!verifyContract(mod, candidate)) continue;
+      kernelLanguages = new Set(mod.contractInfo().languages);
+      debug(`loaded ${candidate} (languages: ${[...kernelLanguages].join(', ')})`);
+      cached = mod;
+      break;
+    } catch (err) {
+      debug(`${candidate}: failed to load — ${err instanceof Error ? err.message : String(err)}`);
+    }
+  }
+  return cached;
+}
+
+/** True when the kill switch is off, a verified binary is loaded, and it supports `language`. */
+export function kernelSupports(language: string): boolean {
+  if (process.env.CODEGRAPH_KERNEL === '0') return false;
+  return getKernel() !== null && kernelLanguages.has(language);
+}
+
+/** Test hook: forget the loaded module so a changed env is re-evaluated. */
+export function resetKernelForTests(): void {
+  cached = undefined;
+  kernelLanguages = new Set();
+}

+ 32 - 1
src/extraction/parse-worker.ts

@@ -16,6 +16,8 @@ try {
 import { parentPort } from 'worker_threads';
 import { extractFromSource } from './tree-sitter';
 import { detectLanguage, loadGrammarsForLanguages, resetParser } from './grammars';
+import { tryKernelExtractRaw } from './kernel';
+import { getAllFrameworkResolvers, getApplicableFrameworks } from '../resolution/frameworks';
 import type { Language, ExtractionResult } from '../types';
 
 // Emscripten prints `Aborted()` (and a follow-up RuntimeError diag
@@ -80,7 +82,36 @@ parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: st
       // codegraph.json extension overrides) and sends it; fall back to detection
       // for older callers / safety.
       const language = msg.language ?? detectLanguage(filePath!, content);
-      const result: ExtractionResult = extractFromSource(filePath!, content!, language, frameworkNames);
+
+      // Kernel deferred-decode fast path: ship the file's tables as flat
+      // buffers and decode at the STORE boundary, so the main thread never
+      // materializes per-node objects (nor pays their structured-clone cost —
+      // buffer clone is a flat memcpy). Only when no applicable framework has
+      // an extract() hook: those merge extra nodes/refs into the DECODED
+      // result inside extractFromSource, so such files keep the decoded path.
+      let result: ExtractionResult | undefined;
+      const frameworksNeedDecode =
+        frameworkNames && frameworkNames.length > 0
+          ? getApplicableFrameworks(
+              getAllFrameworkResolvers().filter((r) => frameworkNames.includes(r.name)),
+              language
+            ).some((fw) => !!fw.extract)
+          : false;
+      if (!frameworksNeedDecode) {
+        const raw = tryKernelExtractRaw(filePath!, content!, language);
+        if (raw) {
+          result = {
+            nodes: [],
+            edges: [],
+            unresolvedReferences: [],
+            errors: raw.errors,
+            durationMs: 0,
+            kernelBuffers: raw.buffers,
+            kernelCounts: raw.counts,
+          };
+        }
+      }
+      result ??= extractFromSource(filePath!, content!, language, frameworkNames);
 
       // Periodic parser reset to reclaim WASM heap memory
       const count = (parseCounts.get(language) ?? 0) + 1;

+ 22 - 3
src/extraction/store-worker.ts

@@ -30,7 +30,8 @@ try {
 import { parentPort } from 'worker_threads';
 import { QueryBuilder } from '../db/queries';
 import { createDatabase, SqliteDatabase } from '../db/sqlite-adapter';
-import type { StoreBundle } from './store-writer';
+import { finalizeStoreBundle, type KernelStoreBundle, type StoreBundle } from './store-writer';
+import { decodeExtractBuffers } from './kernel/decode';
 
 if (!parentPort) {
   throw new Error('store-worker must be run as a worker thread');
@@ -42,10 +43,27 @@ let queries: QueryBuilder | null = null;
 
 type InMessage =
   | { type: 'open'; dbPath: string; fastInit: boolean }
-  | { type: 'bundle'; bundle: StoreBundle }
+  | { type: 'bundle'; bundle: StoreBundle | KernelStoreBundle }
   | { type: 'drain'; id: number }
   | { type: 'close' };
 
+/** Decode a kernel bundle's buffers into the standard pre-filtered StoreBundle. */
+function decodeKernelBundle(bundle: KernelStoreBundle): StoreBundle {
+  const asBuf = (u: Uint8Array) => Buffer.from(u.buffer, u.byteOffset, u.byteLength);
+  const decoded = decodeExtractBuffers(
+    {
+      meta: asBuf(bundle.buffers.meta),
+      nodes: asBuf(bundle.buffers.nodes),
+      edges: asBuf(bundle.buffers.edges),
+      refs: asBuf(bundle.buffers.refs),
+      arena: asBuf(bundle.buffers.arena),
+    },
+    bundle.filePath,
+    bundle.language
+  );
+  return finalizeStoreBundle(decoded, bundle.filePath, bundle.language, bundle.file);
+}
+
 port.on('message', (msg: InMessage) => {
   try {
     switch (msg.type) {
@@ -70,7 +88,8 @@ port.on('message', (msg: InMessage) => {
       }
       case 'bundle': {
         if (!queries) throw new Error('store-worker: bundle before open');
-        queries.storeFileBundle(msg.bundle);
+        const bundle = 'kernel' in msg.bundle ? decodeKernelBundle(msg.bundle) : msg.bundle;
+        queries.storeFileBundle(bundle);
         port.postMessage({ type: 'ack' });
         break;
       }

+ 46 - 2
src/extraction/store-writer.ts

@@ -8,7 +8,7 @@
  */
 
 import { Worker } from 'worker_threads';
-import { Node, Edge, UnresolvedReference, FileRecord } from '../types';
+import { ExtractionResult, Language, Node, Edge, UnresolvedReference, FileRecord } from '../types';
 
 /** One file's complete store payload (pre-filtered — see storeFileBundle). */
 export interface StoreBundle {
@@ -18,6 +18,50 @@ export interface StoreBundle {
   file: FileRecord;
 }
 
+/**
+ * A kernel deferred-decode payload: the file's raw table buffers plus the
+ * FileRecord the main thread built from meta counts. The store WORKER decodes
+ * and finalizes (same filters as the object path), so per-node objects never
+ * exist on the main thread.
+ */
+export interface KernelStoreBundle {
+  kernel: true;
+  filePath: string;
+  language: Language;
+  buffers: NonNullable<ExtractionResult['kernelBuffers']>;
+  file: FileRecord;
+}
+
+/**
+ * The validation/denormalization every bundle gets before storeFileBundle —
+ * shared by the orchestrator's object path and the store worker's kernel
+ * decode path so the two can never drift:
+ *   - nodes missing identity fields are dropped (#42-class safety),
+ *   - edges must connect inserted nodes (FK integrity),
+ *   - refs must originate from inserted nodes and carry the denormalized
+ *     filePath/language the resolver reads.
+ */
+export function finalizeStoreBundle(
+  result: Pick<ExtractionResult, 'nodes' | 'edges' | 'unresolvedReferences'>,
+  filePath: string,
+  language: Language,
+  file: FileRecord
+): StoreBundle {
+  const validNodes = result.nodes.filter((n) => n.id && n.kind && n.name && n.filePath && n.language);
+  const insertedIds = new Set(validNodes.map((n) => n.id));
+  const validEdges = result.edges.filter(
+    (e) => insertedIds.has(e.source) && insertedIds.has(e.target)
+  );
+  const validRefs = result.unresolvedReferences
+    .filter((ref) => insertedIds.has(ref.fromNodeId))
+    .map((ref) => ({
+      ...ref,
+      filePath: ref.filePath ?? filePath,
+      language: ref.language ?? language,
+    }));
+  return { nodes: validNodes, edges: validEdges, refs: validRefs, file };
+}
+
 export class StoreWriter {
   private worker: Worker;
   private readyPromise: Promise<void>;
@@ -102,7 +146,7 @@ export class StoreWriter {
   }
 
   /** Post one file's bundle. Throws immediately if the writer already failed. */
-  send(bundle: StoreBundle): void {
+  send(bundle: StoreBundle | KernelStoreBundle): void {
     if (this.firstError) throw this.firstError;
     if (this.exited) throw new Error('store worker already exited');
     this.outstanding++;

+ 11 - 2
src/extraction/tree-sitter.ts

@@ -30,6 +30,7 @@ import { DfmExtractor } from './dfm-extractor';
 import { VueExtractor } from './vue-extractor';
 import { MyBatisExtractor } from './mybatis-extractor';
 import { CfmlExtractor } from './cfml-extractor';
+import { tryKernelExtract } from './kernel';
 import {
   getAllFrameworkResolvers,
   getApplicableFrameworks,
@@ -6700,8 +6701,16 @@ export function extractFromSource(
     const extractor = new DfmExtractor(filePath, source);
     result = extractor.extract();
   } else {
-    const extractor = new TreeSitterExtractor(filePath, source, detectedLanguage);
-    result = extractor.extract();
+    // Native-kernel route (docs/design/rust-kernel-migration-plan.md): gated
+    // per language, null when not routed/available or on a kernel error —
+    // the wasm TreeSitterExtractor below stays the fallback either way.
+    const kernelResult = tryKernelExtract(filePath, source, detectedLanguage);
+    if (kernelResult) {
+      result = kernelResult;
+    } else {
+      const extractor = new TreeSitterExtractor(filePath, source, detectedLanguage);
+      result = extractor.extract();
+    }
   }
 
   // Framework-specific extraction (routes, middleware, etc.)

BIN
src/extraction/wasm/tree-sitter-go.wasm


BIN
src/extraction/wasm/tree-sitter-java.wasm


BIN
src/extraction/wasm/tree-sitter-javascript.wasm


BIN
src/extraction/wasm/tree-sitter-python.wasm


BIN
src/extraction/wasm/tree-sitter-tsx.wasm


BIN
src/extraction/wasm/tree-sitter-typescript.wasm


+ 42 - 14
src/types.ts

@@ -14,6 +14,10 @@
  * Defined as a runtime-iterable `as const` array so the same source
  * of truth backs both the TS type and any runtime validation
  * (e.g. the search query parser).
+ *
+ * The ARRAY ORDER is part of the native kernel's wire contract (kinds cross
+ * the boundary as indexes — see src/extraction/kernel/layout.ts); append new
+ * kinds, never reorder.
  */
 export const NODE_KINDS = [
   'file',
@@ -43,21 +47,28 @@ export const NODE_KINDS = [
 export type NodeKind = (typeof NODE_KINDS)[number];
 
 /**
- * Types of edges (relationships) between nodes
+ * Types of edges (relationships) between nodes.
+ *
+ * Runtime-iterable like NODE_KINDS. The ARRAY ORDER is part of the native
+ * kernel's wire contract (kinds cross the boundary as indexes — see
+ * src/extraction/kernel/layout.ts); append new kinds, never reorder.
  */
-export type EdgeKind =
-  | 'contains'        // Parent contains child (file→class, class→method)
-  | 'calls'           // Function/method calls another
-  | 'imports'         // File imports from another
-  | 'exports'         // File exports a symbol
-  | 'extends'         // Class/interface extends another
-  | 'implements'      // Class implements interface
-  | 'references'      // Generic reference to another symbol
-  | 'type_of'         // Variable/parameter has type
-  | 'returns'         // Function returns type
-  | 'instantiates'    // Creates instance of class
-  | 'overrides'       // Method overrides parent method
-  | 'decorates';      // Decorator applied to symbol
+export const EDGE_KINDS = [
+  'contains',        // Parent contains child (file→class, class→method)
+  'calls',           // Function/method calls another
+  'imports',         // File imports from another
+  'exports',         // File exports a symbol
+  'extends',         // Class/interface extends another
+  'implements',      // Class implements interface
+  'references',      // Generic reference to another symbol
+  'type_of',         // Variable/parameter has type
+  'returns',         // Function returns type
+  'instantiates',    // Creates instance of class
+  'overrides',       // Method overrides parent method
+  'decorates',       // Decorator applied to symbol
+] as const;
+
+export type EdgeKind = (typeof EDGE_KINDS)[number];
 
 /**
  * Supported programming languages. See NODE_KINDS for why this is a
@@ -265,6 +276,23 @@ export interface ExtractionResult {
 
   /** Extraction duration in milliseconds */
   durationMs: number;
+
+  /**
+   * Deferred-decode transport (native kernel, bulk-index path): when present,
+   * `nodes`/`edges`/`unresolvedReferences` are EMPTY and the file's tables
+   * ride as flat buffers to be decoded at the store boundary (the store
+   * worker), so the MAIN thread never materializes per-node objects.
+   * `kernelCounts` carries the table sizes for bookkeeping. Decode into a
+   * plain result with `materializeKernelResult` (src/extraction/kernel).
+   */
+  kernelBuffers?: {
+    meta: Uint8Array;
+    nodes: Uint8Array;
+    edges: Uint8Array;
+    refs: Uint8Array;
+    arena: Uint8Array;
+  };
+  kernelCounts?: { nodes: number; edges: number; refs: number };
 }
 
 /**