The first level of the additive schema
(skills/designing-cldk-changes/references/canonical-schema.md): grow the containment tree to
callable depth — application → symbol_table{module} → types{}/functions{} → callables{} — file by
file. This is the floor everything else hangs off. You build it by studying how the mature
reference analyzers do it (Java's SymbolTable, Python's symbol_table_builder) and replicating
the pattern for the new language — they have already solved file discovery, per-file building,
caching, and the whole-project / target-files / single-source modes.
Every node carries an id (the can://… path), a kind, and a span with byte
offsets. Above the callable the containment is named maps — types{}, functions{},
callables{}, fields{} — keyed for lookup, each node holding its full id. Specifically:
module(per file): itskind,span,package/namespace,imports[],types{},functions{},content_hash, and — crucially —source: the whole file's text, stored once. Every node's text is a slice ofmodule.source[span.bytes]; there is no per-callablecodefield. (get_method_body(sig)=module.source[callable.span.bytes].)type(class/struct/interface/enum/trait/…): the specifickind(not a pile ofis_*booleans),base_types[],interfaces[],modifiers[], structureddecorators[],callables{},fields{},nesting.callable(function/method/constructor/…):signature(the last segment of its id, from the onesignatureOf()), orderedparameters[],return_type,error_channel[](the generalized channel — Go(T, error), RustResult<T,E>, Javathrows— one field, notthrown_exceptions),modifiers,decorators,metrics.cyclomatic, cheaprefs, and abody{}map that at L1 holds onlycallnodes.callnodes inbody: recorded at L1 as{ kind:"call", span, callee:null, arguments:[…] }. Recording call sites here is what makesget_call_sitesan L1 accessor;calleestaysnulluntil L2 backfills it (the one sanctioned refinement). L1 records sites; it does not resolve them into edges.
Populate the language-native kinds/fields the spec confirmed for this language (the parity clause: add at the leaves, never rename the shared spine).
The three reference analyzers share one shape: discover files → (cache check) → per-file build a Module → put it in the map keyed by relative path, with three entry modes.
- Discover source files. Recursively glob the language's extensions; skip vendored and test
trees (
node_modules,.venv,vendor, test dirs), honoring--skip-tests. Sort for deterministic output. - Compute a stable
file_key— the path relative to the project root (thesymbol_tablekey). It must be identical across runs so caching and the SDK's file lookups work. Never absolute, never..-prefixed. - Per file, cache-check then build. If a prior
analysis_cache.jsonhas this file and itscontent_hash/last_modified/file_sizeare unchanged, reuse the cachedmodule. Otherwise run the per-file builder (the analog ofbuild_pymodule_from_file/processCompilationUnit): parse with the structural tool, retain the file's text asmodule.source, walk the tree, filltypes/functions/native kinds/callables— each node withid,kind,span(byte offsets) — and record thecallnodes in each callable'sbodywithcallee:null. Stamp cache metadata. - Assemble
symbol_table[file_key] = modulefor every file. - Support the three CLI modes (
references/cli-contract.md): whole-project (extractAll-style),-t/--target-filesincremental (extract-style), and optionally single-source. - Parallelism is optional — add the
-jper-file fan-out only once the serial path is correct, and keep output deterministic regardless.
Dependency materialization runs before this pass so the resolver can populate type fields
(references/project-materialization.md); if your structural tool also resolves (ts-morph, clang),
the L1 type fields are filled here, otherwise they fill when the resolver runs at L2.
The per-file builder is the largest single piece, so it is exactly where modularity slips. Make it a
cohesive unit split by node kind — a class (or one focused module per kind: class_builder,
callable_builder, …) sharing one resolver/context object — the way Python's SymbolTableBuilder
groups _add_class/_callables/_call_sites/…, not the flat pile of 36+ free functions
codeanalyzer-ts shipped. See references/analyzer-architecture.md rule 2.
Build a tiny fixture project that exercises every language-specific field you added — a field
with no test is a silent regression point (compilation passes, validation passes, the value is wrong
in production). Assert a specific value, not just len > 0:
- Every added-beyond-spine field with a concrete assertion (a method whose receiver type is non-empty;
a callable whose
metrics.cyclomatic > 1; a decorator with structured args). - At least one multi-file compilation unit (two+ source files in one package/module/namespace) — the cross-file method-attachment bug surfaces only here.
- Both exported and unexported symbols; assert the unexported one's visibility encoding.
- The language's idiomatic compound-return / error pattern into
error_channel((T, error),Result<T,E>,throws). - At least one variadic / spread parameter, if the language has them, with
is_variadicasserted. - Each callable carrying its
callnodes withcallee:nulland correctarguments. - One file where a callable's text =
module.source[span.bytes]exactly (theget_method_bodyslice).
Run the analyzer on the fixture and confirm all of:
- output validates against the SDK
Applicationmodel (Application(**json.load(...))does not raise); symbol_tableis non-empty and keyed by stable relative paths (assert no key is absolute or..-prefixed);- a known file's
modulehas the expectedtypes/functions, asourceblob, and callables carryingcallnodes withcallee:null; theget_method_bodyslice matches; can://ids (≥ callable) are stable across two runs on unchanged source;- re-running reuses cache for unchanged files (no rebuild;
analysis.jsonmtime unchanged).
Only when this is green do you advance to L2 (references/level-2-call-graph.md). Full gate commands
and fixture rules: references/testing-and-validation.md.