feat(kernel): R1 scaffold — napi-rs extraction kernel, buffer contract, routing + fallback, grammar-parity CI
Phase 0 of the Rust extraction-kernel migration (docs/design/ rust-kernel-migration-plan.md, now checked in with §3a recording the shipped state): - codegraph-kernel/ napi-rs crate: extractFile(path, content, language) → five flat buffers (meta/nodes/edges/refs/arena), one JS boundary crossing per file. Node ids computed Rust-side, byte-identical to generateNodeId (pinned by test vector). Reserved per-node metrics slot for the Arc 3.2 code-metrics work. - Generic .scm-driven emitter (@def.<kind>/@name/@ref.<kind> captures, byte-range scope stack → ::-joined qualified names, contains edges, refs attributed to the innermost enclosing symbol). Seed TS/JS queries are smoke-level; R2 replaces them with the full port. - Routing seam in extractFromSource with per-file wasm fallback. DEFAULT_ROUTED is empty — no behavior change until a language passes its equivalence gate (R3). Dev opt-in: CODEGRAPH_KERNEL_LANGS. Kill switch: CODEGRAPH_KERNEL=0. Loader verifies ABI + kind tables before routing; EDGE_KINDS became a runtime array because kind order is now wire contract. - Grammar-source parity: vendored TS/TSX/JS wasm grammars built from the exact crate revisions (tree-sitter-typescript v0.23.2, tree-sitter-javascript v0.25.0, checked-in parser.c, ts-cli 0.25.10) — the tree-sitter-wasms builds were 2023-era, which the new kernel-grammar-parity test caught on day one. Production TS/JS parsing gets 2.5 years of grammar fixes; full suite green (2456 tests). - Build/release wiring: scripts/build-kernel.sh + npm run build:kernel; release.yml kernel prebuild matrix (continue-on-error — the kernel is optional everywhere, bundles fall back to the wasm path); bundles stage lib/kernel/codegraph-kernel.node; release job runs the kernel suites with CODEGRAPH_KERNEL_EXPECT=1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4efc6c70e2
commit
c5eebe6beb
@@ -0,0 +1,387 @@
|
||||
//! 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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! Generic query-driven emitter: parse the file, run the language's `.scm`
|
||||
//! query, and emit flat rows. The whole tree walk happens native-side; the
|
||||
//! only JS boundary crossing is the returned buffers.
|
||||
//!
|
||||
//! Mechanics mirrored from `TreeSitterExtractor` (src/extraction/tree-sitter.ts):
|
||||
//! - node row 0 is the file node (`file:<path>`, endLine = newline count + 1,
|
||||
//! isExported present+false — byte-parity with the TS file node);
|
||||
//! - definitions form a scope stack by byte-range nesting; qualifiedName is
|
||||
//! the stack's names joined with `::` (file excluded);
|
||||
//! - every definition gets a `contains` edge from its parent scope (the
|
||||
//! file node when top-level);
|
||||
//! - references attach to the innermost enclosing definition, falling back
|
||||
//! to the file node — same as the TS extractor's nodeStack semantics;
|
||||
//! - definitions with empty names are skipped (issue #42 semantics).
|
||||
|
||||
use crate::buffers::{
|
||||
build_meta, edge_kind_index, node_kind_index, Arena, BoolFlags, EdgeRow, NodeRow, RefRow,
|
||||
Tables, FLAG_IS_EXPORTED, FUNCTION_REF_CODE, NODE_KINDS, NONE, NONE_STR,
|
||||
};
|
||||
use crate::ids;
|
||||
use crate::langs::LangSpec;
|
||||
use streaming_iterator::StreamingIterator;
|
||||
use tree_sitter::{Node, Parser, QueryCursor};
|
||||
|
||||
pub struct EmitOut {
|
||||
pub meta: Vec<u8>,
|
||||
pub nodes: Vec<u8>,
|
||||
pub edges: Vec<u8>,
|
||||
pub refs: Vec<u8>,
|
||||
pub arena: Vec<u8>,
|
||||
}
|
||||
|
||||
/// What a query capture name means. Resolved once per query.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Role {
|
||||
/// `@def.<NodeKind>` — value is the NODE_KINDS index.
|
||||
Def(u8),
|
||||
/// `@name` — the paired definition's name node.
|
||||
Name,
|
||||
/// `@ref.<EdgeKind>` / `@ref.function_ref` — value is the wire code.
|
||||
Ref(u8),
|
||||
/// Helper captures (`@_anchor` etc.) — ignored.
|
||||
Ignore,
|
||||
}
|
||||
|
||||
fn resolve_roles(capture_names: &[&str], lang: &str) -> Result<Vec<Role>, String> {
|
||||
capture_names
|
||||
.iter()
|
||||
.map(|name| {
|
||||
if let Some(kind) = name.strip_prefix("def.") {
|
||||
let idx = node_kind_index(kind)
|
||||
.ok_or_else(|| format!("{lang}: unknown NodeKind in capture @{name}"))?;
|
||||
Ok(Role::Def(idx))
|
||||
} else if let Some(kind) = name.strip_prefix("ref.") {
|
||||
if kind == "function_ref" {
|
||||
return Ok(Role::Ref(FUNCTION_REF_CODE));
|
||||
}
|
||||
let idx = edge_kind_index(kind)
|
||||
.ok_or_else(|| format!("{lang}: unknown EdgeKind in capture @{name}"))?;
|
||||
Ok(Role::Ref(idx))
|
||||
} else if *name == "name" {
|
||||
Ok(Role::Name)
|
||||
} else {
|
||||
Ok(Role::Ignore)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Def {
|
||||
kind: u8,
|
||||
name_start: usize,
|
||||
name_end: usize,
|
||||
start_byte: usize,
|
||||
end_byte: usize,
|
||||
start_line: u32,
|
||||
end_line: u32,
|
||||
start_column: u32,
|
||||
end_column: u32,
|
||||
/// Node-table row index, assigned during the scope sweep.
|
||||
row: u32,
|
||||
}
|
||||
|
||||
struct RefCap {
|
||||
kind: u8,
|
||||
name_start: usize,
|
||||
name_end: usize,
|
||||
start_byte: usize,
|
||||
line: u32,
|
||||
column: u32,
|
||||
}
|
||||
|
||||
pub fn extract(file_path: &str, source: &str, spec: &LangSpec) -> Result<EmitOut, String> {
|
||||
let t0 = std::time::Instant::now();
|
||||
|
||||
let mut parser = Parser::new();
|
||||
parser
|
||||
.set_language(spec.language())
|
||||
.map_err(|e| format!("set_language({}) failed: {e}", spec.name))?;
|
||||
let tree = parser
|
||||
.parse(source, None)
|
||||
.ok_or_else(|| "parser returned null tree".to_string())?;
|
||||
let root = tree.root_node();
|
||||
|
||||
let query = spec.query()?;
|
||||
let roles = resolve_roles(&query.capture_names(), spec.name)?;
|
||||
|
||||
// ---- Collect definition + reference captures from the query. ----
|
||||
let mut defs: Vec<Def> = Vec::new();
|
||||
let mut refs: Vec<RefCap> = Vec::new();
|
||||
// A node can match several patterns (e.g. nested alternations); first
|
||||
// pattern wins, mirroring the TS walk's one-node-one-symbol behaviour.
|
||||
let mut seen_defs = std::collections::HashSet::<usize>::new();
|
||||
|
||||
let mut cursor = QueryCursor::new();
|
||||
let mut matches = cursor.matches(query, root, source.as_bytes());
|
||||
while let Some(m) = matches.next() {
|
||||
let mut def_node: Option<(Node, u8)> = None;
|
||||
let mut name_node: Option<Node> = None;
|
||||
for cap in m.captures {
|
||||
match roles[cap.index as usize] {
|
||||
Role::Def(kind) => def_node = Some((cap.node, kind)),
|
||||
Role::Name => name_node = Some(cap.node),
|
||||
Role::Ref(kind) => {
|
||||
let p = cap.node.start_position();
|
||||
refs.push(RefCap {
|
||||
kind,
|
||||
name_start: cap.node.start_byte(),
|
||||
name_end: cap.node.end_byte(),
|
||||
start_byte: cap.node.start_byte(),
|
||||
line: p.row as u32 + 1,
|
||||
column: p.column as u32,
|
||||
});
|
||||
}
|
||||
Role::Ignore => {}
|
||||
}
|
||||
}
|
||||
if let (Some((node, kind)), Some(name)) = (def_node, name_node) {
|
||||
// Empty names are not meaningful symbols (issue #42).
|
||||
if name.end_byte() > name.start_byte() && seen_defs.insert(node.id()) {
|
||||
let sp = node.start_position();
|
||||
let ep = node.end_position();
|
||||
defs.push(Def {
|
||||
kind,
|
||||
name_start: name.start_byte(),
|
||||
name_end: name.end_byte(),
|
||||
start_byte: node.start_byte(),
|
||||
end_byte: node.end_byte(),
|
||||
start_line: sp.row as u32 + 1,
|
||||
end_line: ep.row as u32 + 1,
|
||||
start_column: sp.column as u32,
|
||||
end_column: ep.column as u32,
|
||||
row: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic pre-order regardless of query-match ordering.
|
||||
defs.sort_by(|a, b| {
|
||||
a.start_byte
|
||||
.cmp(&b.start_byte)
|
||||
.then(b.end_byte.cmp(&a.end_byte))
|
||||
});
|
||||
refs.sort_by_key(|r| r.start_byte);
|
||||
|
||||
// ---- Emit rows: file node first, then the scope-stack sweep. ----
|
||||
let mut arena = Arena::default();
|
||||
let mut tables = Tables::default();
|
||||
|
||||
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 file_flags = BoolFlags::default();
|
||||
file_flags.set(FLAG_IS_EXPORTED, false);
|
||||
let file_id = arena.put(&ids::file_node_id(file_path));
|
||||
let file_name = arena.put(base_name);
|
||||
let file_qn = arena.put(file_path);
|
||||
tables.push_node(&NodeRow {
|
||||
kind: node_kind_index("file").unwrap(),
|
||||
visibility: 0,
|
||||
flags: file_flags,
|
||||
start_line: 1,
|
||||
end_line: line_count,
|
||||
start_column: 0,
|
||||
end_column: 0,
|
||||
name: file_name,
|
||||
qualified_name: file_qn,
|
||||
id: file_id,
|
||||
docstring: NONE_STR,
|
||||
signature: NONE_STR,
|
||||
decorators: NONE_STR,
|
||||
type_parameters: NONE_STR,
|
||||
return_type: NONE_STR,
|
||||
extra_json: NONE_STR,
|
||||
});
|
||||
|
||||
// Merged sweep over definitions and references in byte order, maintaining
|
||||
// the scope stack (indices into `defs`).
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
let mut ref_i = 0usize;
|
||||
|
||||
fn pop_to(stack: &mut Vec<usize>, defs: &[Def], byte: usize) {
|
||||
while let Some(&top) = stack.last() {
|
||||
if defs[top].end_byte <= byte {
|
||||
stack.pop();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let emit_ref = |r: &RefCap, stack: &[usize], defs: &[Def], arena: &mut Arena, tables: &mut Tables| {
|
||||
let from_idx = stack.last().map(|&i| defs[i].row).unwrap_or(0);
|
||||
let name = arena.put(&source[r.name_start..r.name_end]);
|
||||
tables.push_ref(&RefRow {
|
||||
from_idx,
|
||||
kind: r.kind,
|
||||
line: r.line,
|
||||
column: r.column,
|
||||
reference_name: name,
|
||||
candidates: NONE_STR,
|
||||
from_id_str: NONE_STR,
|
||||
});
|
||||
};
|
||||
|
||||
for i in 0..defs.len() {
|
||||
let def_start = defs[i].start_byte;
|
||||
while ref_i < refs.len() && refs[ref_i].start_byte < def_start {
|
||||
pop_to(&mut stack, &defs, refs[ref_i].start_byte);
|
||||
emit_ref(&refs[ref_i], &stack, &defs, &mut arena, &mut tables);
|
||||
ref_i += 1;
|
||||
}
|
||||
pop_to(&mut stack, &defs, def_start);
|
||||
|
||||
let name = &source[defs[i].name_start..defs[i].name_end];
|
||||
let kind_str = NODE_KINDS[defs[i].kind as usize];
|
||||
// qualifiedName = enclosing definition names + own name, `::`-joined
|
||||
// (buildQualifiedName semantics; file node excluded).
|
||||
let mut qn = String::new();
|
||||
for &s in stack.iter() {
|
||||
qn.push_str(&source[defs[s].name_start..defs[s].name_end]);
|
||||
qn.push_str("::");
|
||||
}
|
||||
qn.push_str(name);
|
||||
|
||||
let id = ids::node_id(file_path, kind_str, name, defs[i].start_line);
|
||||
let id_ref = arena.put(&id);
|
||||
let name_ref = arena.put(name);
|
||||
let qn_ref = arena.put(&qn);
|
||||
let row = tables.push_node(&NodeRow {
|
||||
kind: defs[i].kind,
|
||||
visibility: 0,
|
||||
flags: BoolFlags::default(),
|
||||
start_line: defs[i].start_line,
|
||||
end_line: defs[i].end_line,
|
||||
start_column: defs[i].start_column,
|
||||
end_column: defs[i].end_column,
|
||||
name: name_ref,
|
||||
qualified_name: qn_ref,
|
||||
id: id_ref,
|
||||
docstring: NONE_STR,
|
||||
signature: NONE_STR,
|
||||
decorators: NONE_STR,
|
||||
type_parameters: NONE_STR,
|
||||
return_type: NONE_STR,
|
||||
extra_json: NONE_STR,
|
||||
});
|
||||
defs[i].row = row;
|
||||
|
||||
let parent_row = stack.last().map(|&s| defs[s].row).unwrap_or(0);
|
||||
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,
|
||||
});
|
||||
|
||||
stack.push(i);
|
||||
}
|
||||
while ref_i < refs.len() {
|
||||
pop_to(&mut stack, &defs, refs[ref_i].start_byte);
|
||||
emit_ref(&refs[ref_i], &stack, &defs, &mut arena, &mut tables);
|
||||
ref_i += 1;
|
||||
}
|
||||
|
||||
let duration_ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
let meta = build_meta(&tables, arena.len(), NONE_STR, duration_ms);
|
||||
Ok(EmitOut {
|
||||
meta,
|
||||
nodes: tables.nodes,
|
||||
edges: tables.edges,
|
||||
refs: tables.refs,
|
||||
arena: arena.into_vec(),
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Per-language specs: grammar + `.scm` query + (later) per-language config.
|
||||
//!
|
||||
//! Tier-1 languages are meant to be *mostly* a query file plus a small config
|
||||
//! here; logic queries can't express stays TS-side as a per-language `post()`
|
||||
//! hook over the returned buffers (see `src/extraction/kernel/route.ts`).
|
||||
//!
|
||||
//! Language strings are codegraph `Language` values (src/types.ts), not
|
||||
//! grammar names — `tsx` and `jsx` are separate entries that reuse another
|
||||
//! entry's grammar exactly like `WASM_GRAMMAR_FILES` does on the wasm path.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use tree_sitter::{Language, Query};
|
||||
|
||||
pub struct LangSpec {
|
||||
/// codegraph Language string (src/types.ts).
|
||||
pub name: &'static str,
|
||||
get_language: fn() -> Language,
|
||||
query_src: &'static str,
|
||||
language: OnceLock<Language>,
|
||||
query: OnceLock<Result<Query, String>>,
|
||||
}
|
||||
|
||||
impl LangSpec {
|
||||
const fn new(name: &'static str, get_language: fn() -> Language, query_src: &'static str) -> Self {
|
||||
LangSpec {
|
||||
name,
|
||||
get_language,
|
||||
query_src,
|
||||
language: OnceLock::new(),
|
||||
query: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn language(&self) -> &Language {
|
||||
self.language.get_or_init(self.get_language)
|
||||
}
|
||||
|
||||
pub fn query(&self) -> Result<&Query, String> {
|
||||
self.query
|
||||
.get_or_init(|| {
|
||||
Query::new(self.language(), self.query_src)
|
||||
.map_err(|e| format!("query compile failed for {}: {e}", self.name))
|
||||
})
|
||||
.as_ref()
|
||||
.map_err(|e| e.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_language() -> Language {
|
||||
tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
|
||||
}
|
||||
|
||||
fn tsx_language() -> Language {
|
||||
tree_sitter_typescript::LANGUAGE_TSX.into()
|
||||
}
|
||||
|
||||
fn js_language() -> Language {
|
||||
tree_sitter_javascript::LANGUAGE.into()
|
||||
}
|
||||
|
||||
static TYPESCRIPT: LangSpec = LangSpec::new(
|
||||
"typescript",
|
||||
ts_language,
|
||||
include_str!("../queries/typescript.scm"),
|
||||
);
|
||||
static TSX: LangSpec = LangSpec::new("tsx", tsx_language, include_str!("../queries/typescript.scm"));
|
||||
static JAVASCRIPT: LangSpec = LangSpec::new(
|
||||
"javascript",
|
||||
js_language,
|
||||
include_str!("../queries/javascript.scm"),
|
||||
);
|
||||
static JSX: LangSpec = LangSpec::new("jsx", js_language, include_str!("../queries/javascript.scm"));
|
||||
|
||||
pub static ALL: [&LangSpec; 4] = [&TYPESCRIPT, &TSX, &JAVASCRIPT, &JSX];
|
||||
|
||||
pub fn spec_for(language: &str) -> Option<&'static LangSpec> {
|
||||
ALL.iter().find(|s| s.name == language).copied()
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! 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).
|
||||
|
||||
#![deny(clippy::all)]
|
||||
|
||||
mod buffers;
|
||||
mod emitter;
|
||||
mod ids;
|
||||
mod langs;
|
||||
|
||||
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::ALL.iter().map(|s| s.name.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn grammar_info(language: String) -> Option<GrammarInfo> {
|
||||
let spec = langs::spec_for(&language)?;
|
||||
let lang = spec.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 spec = langs::spec_for(&language)
|
||||
.ok_or_else(|| Error::from_reason(format!("kernel does not support language: {language}")))?;
|
||||
let out = emitter::extract(&file_path, &content, spec).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(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user