Skip to content

Core concepts

Every run produces one TSApplication — a typed model of a project with three top-level pieces: a symbol table, a call graph, and external symbols. This page explains what each contains and the cross-cutting ideas you’ll meet everywhere: signatures as identity, provenance, the analysis cache, and the graph projection that emits the same model as a Neo4j property graph instead of JSON.

flowchart TB
    APP["TSApplication"]
    ST["symbol_table: Record<string, TSModule>"]
    CG["call_graph: TSCallEdge[]"]
    EX["external_symbols: Record<string, TSExternalSymbol>"]
    APP --> ST
    APP --> CG
    APP --> EX

    APP -->|"--emit json (default)"| JSON["analysis.json"]
    APP -->|"--emit neo4j"| PROJ["project to labeled property graph"]
    PROJ -->|"no --neo4j-uri"| SNAP["graph.cypher snapshot"]
    PROJ -->|"--neo4j-uri (live)"| BOLT["incremental Bolt push"]
    SNAP --> DB[("Neo4j")]
    BOLT --> DB

The symbol table is the structured inventory of the project: one TSModule per source file, each holding its imports, exports, and the declarations it contains — classes, interfaces, enums, type aliases, functions, namespaces, and module-level variables. It’s the foundation every other piece is built on.

flowchart LR
    M[TSModule] --> C[TSClass]
    M --> I[TSInterface]
    M --> E[TSEnum]
    M --> T[TSTypeAlias]
    M --> F["TSCallable (function)"]
    M --> N[TSNamespace]
    C --> ME["TSCallable (method)"]
    ME --> CS[TSCallsite]
    ME --> P[TSCallableParameter]
    C --> A[TSClassAttribute]

A TSCallable (function, method, constructor, accessor, or arrow) carries its signature, source code, parameters, type_parameters, decorators, call_sites, accessed symbols, cyclomatic complexity, and TypeScript-native flags (is_async, is_static, is_abstract, accessibility, …). A TSClass carries its base_classes, implements_types, methods, attributes, and decorators. The TypeScript node kinds Python and Java don’t have — interfaces, enums, type aliases, namespaces — are first-class. Each node records line/column spans so you can map any element back to source.

Construction is done by the TypeScript compiler through ts-morph: the same checker that types the project resolves references, so the analyzer materializes the project’s node_modules first (see Installation).

The call graph records who-calls-whom as a flat list of TSCallEdge objects. Each edge is identity-only: a source signature, a target signature, a weight, a provenance list, and free-form tags. The nodes of the graph are the TSCallable entries already in the symbol table (or external_symbols keys for library targets) — there’s no separate vertex type. Rich per-call detail (receiver, argument types, location) lives on the TSCallsite entries inside each callable.

flowchart LR
    A["src/app.main"] -->|tsc| B["src/parser.Parser.parse"]
    B -->|tsc| C["src/model.Order.constructor"]
    B -->|tsc, rta| D["src/model.PremiumOrder.total"]
    B -->|import| E["node:fs.readFileSync"]

The TypeScript checker resolves each recorded call site to a callee declaration and backfills callee_signature in place; the resulting edges are guaranteed to point at real signatures (no dangling edges). Virtual dispatch is expanded with Rapid Type Analysis, and calls leaving the project become phantom external symbols. The full mechanism is its own page — Call graph & dispatch.

Because it’s a plain edge list keyed by signature, loading it into a graph is direct:

import json, networkx as nx
app = json.load(open("analysis.json"))
g = nx.DiGraph()
for e in app["call_graph"]:
g.add_edge(e["source"], e["target"])
nx.has_path(g, caller_sig, sink_sig) # reachability — a query, not a guess

The same DiGraph comes back from analysis.get_call_graph() when CLDK reads the graph projection — see The graph projection.

When a call leaves the project — into an imported library or a Node builtin — the target isn’t in the symbol table. Rather than drop the edge, codeanalyzer-typescript keeps it and points it at a phantom node: a TSExternalSymbol recorded in external_symbols, keyed by a synthetic signature like node:fs.readFileSync or express.Router.get. This is the WALA-style phantom-node technique — it preserves cross-boundary call structure instead of silently truncating the graph at the project edge.

flowchart LR
    A["src/server.start"] -->|import| P["express.Router.get
(phantom)"]
    P --> EX["external_symbols
{ signature, name, module, kind }"]

Only bare specifiers become phantoms — packages like express, scoped packages like @scope/pkg, and node: URLs. Relative specifiers (./x, ../lib/y) are internal and are left to the checker, never faked. Phantom edges carry provenance: ["import"]. Phantom resolution is cheap: it reads the file’s imports and requires, so it works identically for TypeScript (import) and JavaScript (require).

In the graph projection these become shared :TSExternal nodes (grouped under :TSPackage via TS_MEMBER_OF) — shared across every application in the database, since a library is the same library no matter which app imports it.

Entrypoints are the framework-dispatched roots of an application — the functions a framework calls that your own code never calls directly: an HTTP route handler, a message consumer, a CLI command. The schema reserves a :TSEntrypoint marker label (with framework, detection_source, route_path, and http_methods properties) for them.

Identity is the linchpin of the whole artifact, and a single canonicalizer produces it on both sides of every edge — so a call graph source/target value byte-matches the corresponding symbol_table (or external_symbols) key. There’s no separate node table to keep in sync.

A signature is built from two parts:

  • The file key — the project-relative POSIX path with extension (e.g. src/user.ts) — which is the symbol_table key.
  • The signature prefix — that same path without extension (e.g. src/user) — dot-joined with the member path.

So getUser on UserService in src/user.ts has the signature src/user.UserService.getUser. Constructors normalize to <ClassSignature>.constructor (e.g. src/user.UserService.constructor). Because caller- and callee-side ids come from the same function, edges always line up with the table.

This is what makes the graph projection clean. In Neo4j, signature is not just a convention — it’s an enforced constraint: every signature-keyed declaration carries a :TSSymbol merge label with CREATE CONSTRAINT ... REQUIRE s.signature IS UNIQUE. A TS_CALLS edge and a TS_RESOLVES_TO edge therefore MERGE onto the same node a TS_DECLARES edge created, with no dangling vertices — the same guarantee the JSON edge list gives you, made structural.

Every TSCallEdge carries a provenance list recording how it was resolved: "tsc" for a checker-resolved edge, "import" for a phantom edge into a library, "codeql" once level-2 enrichment lands, or an extension’s own token. It’s an open vocabulary — a stored analysis.json round-trips no matter which engines produced it. Provenance lets a consumer weigh edges by confidence or filter to a single resolver’s view. RTA-expanded edges additionally carry a tags["ts.dispatch"] = "rta" marker so you can tell an exact declared-type edge from a subtype-expansion edge.

Provenance survives the projection: the :TS_CALLS relationship in Neo4j carries provenance (string list), dispatch (e.g. "rta"), external (boolean), weight (integer), and module as edge properties. So the same confidence filtering is a Cypher predicate over an edge property instead of a list filter over JSON:

MATCH (a:TSApplication {name: $app})-[:TS_HAS_MODULE]->(:TSModule)
-[:TS_DECLARES]->(c:TSCallable)-[r:TS_CALLS]->(t)
WHERE NOT r.external AND 'tsc' IN r.provenance
RETURN c.signature, t.signature, r.weight

Analysis is lazy by default. codeanalyzer-typescript stores its results under .codeanalyzer/ in the project (override with --cache-dir) and, on the next run, reuses the cached symbol table and base call graph when nothing has changed — detected by file content hash, mtime, and size. --eager forces a full rebuild from scratch (and reinstalls dependencies).

flowchart LR
    R[analyze] --> Cache{cached &<br/>unchanged?}
    Cache -->|yes| Reuse[reuse symbol table<br/>+ call graph]
    Cache -->|no| Build[rebuild from source]
    Reuse --> Out[TSApplication]
    Build --> Out

The same content_hash that drives the cache also drives incremental graph loads. Each :TSModule node stores its content_hash, last_modified, and file_size. When you push to a live database (--emit neo4j --neo4j-uri …), the Bolt writer diffs each module’s content_hash against the copy already in the graph and only rewrites the modules that changed — re-analysis and re-load touch the same changed set. See Incremental loads below.

--emit neo4j is an alternative output target, not an addition: the analyzer builds exactly one TSApplication in memory — the same model behind analysis.json — and then projects it into a labeled property graph instead of serializing JSON. The projection is mechanical: signature-keyed declarations (:TSClass, :TSInterface, :TSEnum, :TSTypeAlias, :TSNamespace, :TSCallable) share a :TSSymbol merge label keyed on signature, and the detail that lived inside a callable in JSON — call sites, attributes, variables, decorators — becomes first-class nodes (:TSCallSite, :TSAttribute, :TSVariable, :TSDecorator) wired with explicit relationships (TS_HAS_CALLSITE, TS_HAS_ATTRIBUTE, TS_DECLARES_VAR, TS_DECORATED_BY). The flat call-graph edge list becomes :TSCallable-[:TS_CALLS]-> edges, and each call site’s resolution becomes a TS_RESOLVES_TO edge.

Why move off a single JSON file? An analysis.json has to be loaded whole into memory and doesn’t compose across a portfolio — two services are two files you parse and join by hand. The graph composes: many applications live in one Neo4j database, each anchored at its own :TSApplication node, and whole-monorepo or cross-service reachability becomes one Cypher traversal instead of a memory problem.

--app-name sets the name property of the single :TSApplication node every module hangs off (via TS_HAS_MODULE), and it is the scoping handle for everything. It defaults to the input directory’s basename when omitted, and it’s a MERGE key enforced by CREATE CONSTRAINT app_name … REQUIRE a.name IS UNIQUE, so re-loading the same app is idempotent and two apps never collide. The schema_version (currently 2.0.0) is stamped onto this node so a consumer can detect a producer/consumer mismatch.

This anchor is what makes the database multi-tenant: a snapshot’s prior-subgraph wipe matches only (:TSApplication {name: <app-name>}) and detach-deletes just that app’s modules and declarations — shared :TSExternal, :TSPackage, and :TSDecorator nodes survive, because a library is shared across every app that imports it. And it’s the key the Python SDK reads back by: application_name in CLDK must equal the --app-name the graph was loaded with.

Which writer runs is decided by one thing — whether --neo4j-uri is set.

Without --neo4j-uri, the projection is rendered to a self-contained <output>/graph.cypher (falling back to the current directory): DDL constraints and indexes, then a scoped DETACH DELETE of this app’s prior subgraph, then batched UNWIND … MERGE for nodes and relationships. It is not incremental by design — a static script has no view of the live database. Load it with cypher-shell:

Terminal window
cants --input ./my-ts-project --emit neo4j --app-name my-ts-app --output ./out
cypher-shell -u neo4j -p "$NEO4J_PASSWORD" < ./out/graph.cypher

This split is the deployment story: the analyzer runs out of band — a CI step or a Kubernetes Job/CronJob — pushing app-scoped subgraphs into a shared, managed or clustered Neo4j. Consumers (agents, dashboards, the CLDK SDK) are lightweight read-only clients that scale independently of the heavier analysis pods.

The payoff is that the Python SDK reads the graph instead of re-analyzing. Point CLDK.typescript at a Neo4jConnectionConfig and it reconstructs the same typed model objects and the same networkx call graph as the in-process analyzer — with no Node/Bun runtime, no cants binary, and no project source on the consumer. It needs only the Bolt URI and read-only credentials:

# TypeScript project — read-only Neo4j backend
from cldk import CLDK
from cldk.analysis.commons.backend_config import Neo4jConnectionConfig
analysis = CLDK.typescript(
backend=Neo4jConnectionConfig(
uri="bolt://localhost:7687",
username="neo4j",
password="neo4j",
application_name="my-ts-app", # == the --app-name the graph was loaded with
),
)
classes = analysis.get_classes() # Dict[str, TSClass]
externals = analysis.get_external_symbols() # phantom library targets, for source->sink reachability
cg = analysis.get_call_graph() # networkx.DiGraph — same as the JSON path

Install the driver extra with pip install cldk[neo4j]. Because the graph is external, project_path is optional for this backend, and every query is scoped by application_name — the same anchor name you passed to --app-name. The full read API and Cypher recipes are on the Neo4j graph page.