Skip to main content

Nodes

A node is a flat record with a kind discriminator rather than a class hierarchy — cheaper to build in a hot loop, and trivial to serialize.

node.id # str, deterministic
node.kind # NodeKind
node.qualified_name # str, unique within (project, kind)
node.name # str, the short name
node.file_path # str | None, always relative to the project root
node.span # Span | None
node.metadata # dict[str, object]

The 14 kinds

KindMeaning
PROJECTone analysed root
MODULEa namespace — the shape differs per language, see below
FILEa source file
CLASSclass, struct, enum, trait, interface
FUNCTIONa free function
METHODa function bound to a type
VARIABLEmodule- or package-level binding
ATTRIBUTEa field on a type
PARAMETERa function parameter
TYPE_ALIASa named alias for a type
IMPORTan import statement
EXTERNAL_SYMBOLa target outside the graph
DEPENDENCYa package the manifests declare, whether or not anything imports it
BOUNDARYa cross-service port

MODULE is the kind whose meaning moves most between languages:

LanguageA MODULE is
Python, TypeScripta dotted name — app.services.billing
Goa package directory, whose qualified_name is the import path
Rusta module path — crate::net::http
PHPa namespace — App\Service
C, C++the directory; a C++ namespace lives in the qualified names instead
YAMLnone — a FILE hangs off the PROJECT directly

CLASS is similarly wide: a PHP interface, trait and enum are all CLASS with a metadata flag, and so are a C struct, union and enum. The kind is a slot in the graph, not a claim that the languages agree.

Identifiers

id = sha256(f"{project}::{kind}::{qualified_name}")[:16]

Nothing machine-specific enters it — no absolute path, no timestamp, no run counter. Two runs on unchanged source produce identical IDs, which is what makes diffing, merging and incremental updates possible.

BOUNDARY is the exception, and deliberately so:

id = sha256(f"boundary::{mechanism}::{key}")[:16]

No project and no language, so the same contract seen from two services collapses into one node.

Spans

Every span is 1-based on both line and column, matching what an editor shows. Tree-sitter reports 0-based positions; the conversion happens once, at the visitor boundary.

node.span covers the whole declaration. node.metadata["name_span"] covers just the identifier, and that is the one resolution uses: a resolver answers with the position of a definition's name, so the lookup has to be against name spans.

Paths are relative, always

file_path is relative to the root passed to analyze(), on every node that has one. Nothing machine-specific may enter a graph: an absolute path would make two runs of the same source on two machines produce different values, which breaks diffing and merging — and would quietly break nodes_in_file() for callers who pass the relative path the FILE node advertises.

Ordering

Nodes come out in insertion order, not sorted and not hashed. That is part of the value: it keeps serialization byte-stable and keeps CONTAINS edges in a readable sequence.