What is codeanalyzer-typescript?
codeanalyzer-typescript is a static-analysis tool for TypeScript and JavaScript source code. You point it at a project directory and it produces one typed artifact — a TSApplication — that captures the project’s symbol table (modules, classes, interfaces, enums, type aliases, callables), its call graph (who-calls-whom), and the external symbols it reaches (phantom stubs for imported-library targets). You stop grepping source by hand and start querying a structured model of the program. The CLI ships as a self-contained binary named cants.
It is the TypeScript backend behind CLDK, the multilingual analysis SDK — the same role codeanalyzer-python and codeanalyzer play for Python and Java. You can use it through CLDK’s typed facade, or directly as a CLI that writes analysis.json — or projects the same analysis into a Neo4j property graph, a queryable, persistent system of record you populate once and read from everywhere.
:TSEntrypoint;
the TS_CALLS edge is the resolved call graph.
The mental model
Section titled “The mental model”Every run follows the same shape: point at a project, build the artifact, consume the typed model — as JSON, or as a graph.
-
Point at a project.
cants --input ./my-project. The tool discovers every.ts/.tsx/.jsfile (test trees excluded by default) and, by default, materializes the project’snode_modulesso the compiler can resolve imported types and call targets. -
It builds a
TSApplication. The TypeScript compiler — driven through ts-morph — extracts the symbol table; the same checker resolves each call site into the call graph, with Rapid Type Analysis expanding virtual dispatch and phantom nodes capturing external calls. -
Choose an output target.
--emit json(the default) writesanalysis.jsonor streams JSON to stdout.--emit neo4jprojects the same in-memory analysis into a labeled property graph — either a self-containedgraph.cyphersnapshot or an incremental push to a live Neo4j over Bolt.--emit schemapublishes the versioned graph schema contract.
flowchart LR
A["cants
--input"] --> B["materialize
node_modules"]
B --> C["Symbol table<br/>ts-morph checker"]
C --> D["Call graph<br/>resolver + RTA"]
D --> E["External symbols<br/>phantom nodes"]
E --> G["TSApplication
(in-memory analysis)"]
G -->|"--emit json"| J["analysis.json
symbol_table · call_graph · external_symbols"]
G -->|"--emit neo4j"| N["Neo4j property graph<br/>:TSApplication anchor · labeled nodes + typed rels"]
N --> S["Snapshot<br/>graph.cypher (scoped wipe + batched MERGE)"]
N --> L["Live Bolt push<br/>incremental, content-hash diff"]
G -.->|"--emit schema"| H["schema.json<br/>schema_version 2.0.0"]
What you get back
Section titled “What you get back”The --emit json artifact is a single TSApplication. The live artifact has three top-level pieces:
| Field | Type | What it holds |
|---|---|---|
symbol_table | Record<string, TSModule> | One TSModule per source file — its imports, exports, classes, interfaces, enums, type aliases, functions, namespaces, and variables. |
call_graph | TSCallEdge[] | Identity-keyed source -> target edges (by TSCallable.signature) with a weight, provenance, and tags. |
external_symbols | Record<string, TSExternalSymbol> | Phantom stubs for call targets outside the project — imported libraries and Node builtins. |
How identity works
Section titled “How identity works”A single canonicalizer, signatureOf, computes both caller- and callee-side identifiers, so a call graph source/target value byte-matches the corresponding symbol_table (or external_symbols) key. A signature is the project-relative file path (without extension) dot-joined with the member path — e.g. src/user.UserService.getUser. Constructors normalize to <ClassSignature>.constructor. See Core concepts.
From one JSON file to a property graph
Section titled “From one JSON file to a property graph”analysis.json is one file per project, and that is its ceiling. To answer a question across a portfolio you have to load each artifact whole into memory and parse it — the documents don’t compose, and a large monorepo’s blob is a memory problem before it is an analysis problem.
--emit neo4j projects the same in-memory analysis into a labeled property graph instead. Many applications live in one Neo4j database, each anchored at its own :TSApplication node whose unique name is the --app-name you passed. Cross-service and whole-monorepo questions become a Cypher traversal across all of them, not a parse of giant JSON.
Everything is scoped by the application anchor. --app-name sets the name of the single :TSApplication node (a MERGE key enforced by a uniqueness constraint) and defaults to the input directory’s basename. Every module hangs off that node via TS_HAS_MODULE; a re-load wipes only that app’s prior subgraph before writing the new one, so apps never clobber each other. Shared :TSExternal, :TSPackage, and :TSDecorator nodes carry no _module property — they are MERGE-only and survive across apps, so externals, packages, and decorators are shared infrastructure rather than per-app duplicates.
Snapshot or live, by one flag
Section titled “Snapshot or live, by one flag”--emit neo4j chooses between two mutually-exclusive writers based on whether --neo4j-uri is set:
# No --neo4j-uri => write a self-contained Cypher script to ./out/graph.cyphercants --input ./my-ts-project \ --emit neo4j \ --app-name my-ts-app \ --output ./out
# Load it into any Neo4j with cypher-shellcypher-shell -u neo4j < ./out/graph.cypherThe snapshot is one portable file: DDL for constraints and indexes, a scoped DETACH DELETE of this app’s prior subgraph, then batched UNWIND … MERGE for nodes and relationships. It is deliberately not incremental — a static script has no view of the live database.
# With --neo4j-uri => push incrementally over Bolt. Password comes from NEO4J_PASSWORD.export NEO4J_PASSWORD=secretcants --input ./my-ts-project \ --emit neo4j \ --app-name my-ts-app \ --neo4j-uri bolt://localhost:7687 \ --neo4j-user neo4j \ --neo4j-database neo4jThe Bolt writer ensures constraints and indexes, then diffs each module’s content_hash against the database and only re-pushes changed modules. On a full run, modules whose source file vanished are pruned; a targeted run (--target-files) skips pruning because it can’t tell deleted files from untargeted ones.
A versioned schema contract
Section titled “A versioned schema contract”--emit schema writes schema.json — the machine-readable, version-stamped catalog of every node label, relationship type, and key property. It needs no --input, and the schema_version (currently 2.0.0) is also stamped onto every :TSApplication node, so a consumer can detect a producer/consumer mismatch before it reads stale shapes.
cants --emit schema --output ./out # writes ./out/schema.jsonProducer and consumer, at scale
Section titled “Producer and consumer, at scale”The graph splits analysis into two roles that scale independently.
The producer is heavy and runs out-of-band — a CI step or a Kubernetes Job / CronJob that runs cants --emit neo4j --neo4j-uri … and pushes app-scoped subgraphs into one shared, managed or clustered Neo4j (Aura, Enterprise, or a cluster for HA). Many such jobs write into the same database, each scoped to its own --app-name.
The consumers are lightweight, read-only clients — agents, dashboards, code search, and the CLDK Python SDK. They hold only read credentials and a Bolt URI; they never run the analyzer, carry no JDK or native binary, and don’t need the project source. Reads fan out from the cluster and scale separately from the analysis pods. A fulltext index (code_fts over callable code and docstrings) plus name indexes make code search a first-class query over the graph.
Reading the graph from CLDK — no re-analysis
Section titled “Reading the graph from CLDK — no re-analysis”This is the enterprise payoff: analysis is produced once, centrally, then read cheaply everywhere. CLDK has a read-only Neo4j backend. Point CLDK.typescript at a Neo4jConnectionConfig and it reconstructs the same typed model objects (TSClass, TSCallable, …) and the same networkx call graph the in-process analyzer would build — with no JDK, no cants binary, and no project source on the consumer. It only needs the graph and read-only credentials.
# TypeScript project — read-only Neo4j backendfrom cldk import CLDKfrom cldk.analysis.commons.backend_config import Neo4jConnectionConfig
analysis = CLDK.typescript( backend=Neo4jConnectionConfig( uri="bolt://localhost:7687", username="neo4j", password="neo4j", # read-only credentials are sufficient application_name="my-ts-app", # MUST equal 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 reachabilitycg = analysis.get_call_graph() # networkx.DiGraphInstall the driver extra with pip install cldk[neo4j] (or pip install neo4j). The application_name scopes every query to one :TSApplication, so it must match the --app-name the graph was loaded with. Because the graph is external, project_path is optional for this backend — the SDK polls a graph someone else produced.
You can also drop to raw Cypher for cross-application questions the typed facade doesn’t model — for example, every external library symbol reached from a given app:
MATCH (:TSApplication {name: "my-ts-app"})-[:TS_HAS_MODULE]->(:TSModule) -[:TS_DECLARES*0..]->(c:TSCallable)-[:TS_CALLS]->(x:TSExternal)RETURN x.module AS package, x.name AS symbol, count(*) AS callsORDER BY calls DESCTwo (or three) ways to use it
Section titled “Two (or three) ways to use it”# Write analysis.json to ./outcants --input ./my-project --output ./out
# Or stream JSON to stdout (no --output)cants --input ./my-project | jq '.call_graph | length'export NEO4J_PASSWORD=secretcants --input ./my-project \ --emit neo4j \ --app-name my-project \ --neo4j-uri bolt://localhost:7687from cldk import CLDKfrom cldk.analysis.commons.backend_config import Neo4jConnectionConfig
analysis = CLDK.typescript( backend=Neo4jConnectionConfig( uri="bolt://localhost:7687", application_name="my-project", ),)print(analysis.get_call_graph()) # -> networkx.DiGraphWhy a dedicated tool
Section titled “Why a dedicated tool”A code LLM asked “what calls this function?” without analysis crawls: file read after file read, grep after grep, burning tokens on an answer it still can’t be sure of. codeanalyzer-typescript resolves that once, statically, into a graph — so the answer is a lookup, not a guess. The TypeScript compiler gives you precise resolution for free on every run; RTA recovers the virtual-dispatch targets a naive resolver would miss; phantom nodes keep the calls into third-party libraries visible instead of silently dropped. With --emit neo4j, that resolution becomes a persistent, shared graph many tools query instead of each one re-deriving it.