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. One with a file of its own (.py, __init__.py, index.ts) carries that file_path and a zero-width 1:1 span; a namespace package or a directory without an index file has no position. A TypeScript namespace block is also a MODULE (metadata["is_namespace"]), with the file and span of its declaration
Goa package directory, whose qualified_name is the import path; it carries the file_path of its first non-test file and a name_span at the package clause, and no span
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.

node.metadata["declared_at"] lists the other places a definition is written down. An entry is either a bare span in the node's own file (Python and TypeScript overload signatures) or {"file": ..., "span": ...} (C and C++ prototypes, forward declarations and in-class declarations, which are one node with their definition). In Rust the bare spans also cover the later declarations of a qualified name in the same file, such as the add of impl Add<S> and of impl Add<&S> for one type, or the variants of a cfg-gated function. A resolver's answer at any of these positions lands on the node.

Documentation​

node.metadata["doc"] holds what the author wrote about a definition, as plain text with the comment markers removed. It is on classes, functions, methods, variables, attributes and type aliases, and absent when there is nothing:

Where it is read fromLanguages
the docstring, first statement of a function or class body (cleaned the way PEP 257 describes)Python
the comment block directly above the definition: ///, /** … */, //, #all of them

"Directly above" means the last line of the comment is the line before the definition, with only attributes or decorators between them; a blank line ends the block. A comment that trails another statement (x = 1 # why) belongs to that statement, not to the next one. In C and C++ a definition with no comment takes the one on its prototype, since that is where headers keep it; the definition's own comment wins when both exist.

node.metadata["doc"] # 'Create a member.\n\nRaises ValueError if the name is empty.'

A Python module's docstring is the doc of its MODULE and of its FILE.

Everything else the author wrote is kept in separate keys, and none of them replaces doc:

KeyWhat it holdsWhere
leading_commentthe comment block directly above, when a docstring took docPython definitions
commentsthe comments written inside the body, as [{"line": 12, "text": "…"}]: one entry per block of adjacent comment lines, or per comment that follows code; the comments of a definition nested in it are its own; at most 200classes, functions, methods, variables, attributes (Python, TypeScript, PHP, Go, Rust)
trailing_commentthe comment after a definition on its line: x = 1 # whyvariables and attributes
node.metadata["comments"] # [{'line': 7, 'text': 'a second explanation, inside the body'}]
node.metadata["trailing_comment"] # 'the commit happens in the caller'

License headers, TODO notes outside any definition and the comments of C and C++ bodies are not recorded.

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.