Agent guidance for codellm-devkit/codeanalyzer-typescript (cants).
cants = TypeScript/JavaScript static analyzer built on TypeScript compiler
(via ts-morph). CLDK TypeScript backend: emits
canonical schema v2 — one additive Code Property Graph — in two projections,
analysis.json and Neo4j property graph. Mirrors
Python and
Java sibling analyzers, so
output-shape parity with them first-class concern.
Output = one scale-free structure: containment tree of nodes (id / kind /
span / children) with typed edge overlays (CPG). Every classic artifact — symbol
table, call graph, CFG, PDG, SDG — is projection of that one structure. Analysis
levels = how deep it populated (each level only adds, never rewrites):
- L1 (
-a 1): tree to callable depth —application → symbol_table{module} → types{}/functions{}/fields{} → callables{}— pluscallnodes in each callable'sbody{}(calleeunresolved).sourcestored once per module; every node's text slices off it viaspan.bytes. - L2 (
-a 2):call_graphedge list (callable→callable) at application scope, andcalleeslot on each call node refinednull → id(only sanctioned mutation). - L3 (
-a 3): rest ofbody{}(statements +@entry/@exit) and intra-callable edge listscfg/cdg/ddg(reaching-definitions,prov:["reaching-defs"]) hung on each callable. - L4 (
-a 4): synthetic@formal_in:N/@formal_out/<L>/actual_in:N/<L>/actual_outvertices, intra-callersummaryedges, and application-scopeparam_in/param_outlists (interprocedural SDG).
Identity two-tier: durable can://<lang>/<app>/<file>/<type>/<sig> ids at callable
depth and above; ordinal <callable-id>@<line>:<col> (or @<tag>) below. Intra-callable
edge lists use bare local ids; cross-callable lists use fully-qualified can://…@local
ids. L1 ⊆ L2 ⊆ L3 ⊆ L4 = CI-checkable monotonicity gate (test/schema-v2.test.ts).
Model + every decision live in .claude/SCHEMA_DECISIONS.md (§ "Schema v2 migration") and
skillset's canonical-schema.md.
Provider/client boundary: analyzer = pure graph provider — emits graph
substrate (CFG/PDG/SDG + summary edges) and stops. Slicing and taint = reachability
queries over it, belong to frontend SDK; never add taint_flows section here.
Schema v2 = native model (#96): stages build v2 tree directly (src/schema/schema.ts,
one model family — no v1 model, no emit-time reshape). Per-run passes stamp derived
layers (python parity): assignIds (can:// ids — per-run because ids embed --app-name
while cache round-trips tree), l1Body (call_sites → body{}), heritage,
homing + l2Callees (L2), dataflow/attach (L3/L4). finalizeAnalysis
(src/schema/emit.ts) runs them + assembles envelope + strips INTERNAL fields
(call_sites, abs_path, cache trio).
Call graph = tsc resolver + defuse linker (#98): deterministic per-callable
pass over resolver leftovers — alias chains, decorator edges, library-callback
edges, bounded interprocedural votes, CHA-by-name fallback. No whole-program
fixpoint, no backend flag, one code path. Module-scope calls attributed to
MODULE (python #131 parity). prov tags: tsc / defuse / import. Joern
superset ledger: docs/design/specs/defuse-linker-joern-ledger.md.
Whole analyzer = one orchestration function: analyze() in src/core.ts. Read
it first; everything else is stage it calls, in order:
- materialize (
src/build) — resolve/prepare target project deps. - buildSymbolTable (
src/syntactic_analysis) — modules, classes, interfaces, enums, type aliases, namespaces, functions, methods, variables, decorators, JSDoc, with precise source spans. - call graph (
src/semantic_analysis) — tsc resolver (callGraph.ts, incl. module-scope sweep + RTA + phantoms) thendefuseLinker.tstiers T1–T5; merged with provenance union. - program graphs (
src/dataflow) — levels 3–4 (-a 3/-a 4): CFG → post-dominance/CDG → access-path def-use → PDG → SCC-condensed bottom-up summaries → SDG. This is compute (IR insrc/schema/graphs.ts);src/dataflow/attach.tswrites it onto tree (body{}+cfg/cdg/ddg/summaryper callable +param_in/param_out). Decisions:.claude/SCHEMA_DECISIONS.md; contract + staged follow-ups: issue #2. - cache (
src/utils/cache.ts) — content-hash cache under.codeanalyzer/; stores id-free builder tree only (ids/body/heritage = per-run layers; levels 3–4 also record summaries + dependency edges ingraphs_summaries.json). - finalize + output —
finalizeAnalysis(src/schema/emit.ts, called byanalyze()) runs pass spine, returnsAnalysisResult{application(wireTSAnalysisenvelope),internal,program_graphs, gates};src/utils/serialize.tswrites envelope verbatim;src/build/neo4jprojects same envelope intograph.cyphersnapshot or incremental Bolt push.--emit neo4jalways full-depth (levels gate JSON path only; combining-a/--graphswith it = error).
Output shape = schema v2 (src/schema/schema.ts: TSAnalysis envelope →
TSApplication root → TSModule/TSType/TSCallable/TSField/TSBodyNode).
Same types = the model stages build; INTERNAL fields never reach wire.
Neo4j schema (src/build/neo4j/schema.ts) versioned and enforced by conformance
test — treat both as contracts, keep in lockstep with JSON.
| Path | Responsibility |
|---|---|
src/main.ts, src/cli.ts |
Entry point + Commander CLI |
src/core.ts |
analyze() orchestrator — the spine |
src/options |
Parsed CLI options / AnalysisOptions |
src/syntactic_analysis |
Symbol table (ts-morph traversal) |
src/semantic_analysis |
Call graph: tsc resolver + defuse linker (T1–T5), phantoms |
src/dataflow |
L3/L4 program-graph compute (CFG, dominance/CDG, def-use, summaries, SDG) + attach.ts (IR → tree) |
src/schema |
the native v2 model (schema.ts) + per-run passes (assignIds/l1Body/heritage/homing/l2Callees) + emit.ts (finalizeAnalysis) + signatureOf + graphs IR |
src/build |
Dep materialization; build/neo4j = the v2 graph projection (project/rows/cypher/bolt/schema) |
src/utils |
fs, caching, logging, serialization (serialize.ts writes the envelope), version |
test |
Bun tests + fixtures/sample-app + fixtures/dataflow-app; schema-v2.test.ts = the L1–L4 gates |
Repository-artifact layer (#101, python v1.3.0 parity): three level-free sections —
application.artifacts{} (never-drop inventory, LANGUAGE-NEUTRAL can://artifact/<app>/<path>
ids, roles[], text-capture policy: --no-artifact-text/--artifact-text-max-bytes, sha256/
size_bytes always full-file even when source is a truncated prefix), dependencies[] (npm
kinds incl. coined peer, direct:false lockfile-only transitives), unresolved_imports[]
(@types type-only rule; --resolve-installed opt-in probe). Each artifact also carries
config_keys[] (env/JSONC/YAML/TOML/INI/dockerfile namespaces, @key/-suffixed ids,
arg./env. internal id disambiguation). config_uses/config_reads join a config_access L1
body-node read (or a detector-table call) to a declared key through a level-graded
literal→dataflow-intra→dataflow-interproc tier (src/semantic_analysis/configUse.ts,
src/dataflow/configUse.ts); config_reads deliberately SHRINKS as -a rises — the layer's one
non-monotonic section. src/artifacts/. Neo4j contract 2.1.0 (SCHEMA_VERSION unmoved — every
analyzer re-baselines together later): NEUTRAL :Artifact/:Package/:ConfigKey (purl) — sanctioned
prefix exception — plus TS_PROVIDES/TS_UNRESOLVED_IMPORT into :TSExternal ghosts and
TS_USES_CONFIG into :ConfigKey. Consumer query skill: docs/skills/analyzing-cants-graphs/.
bun run start -- --input /path/to/project— run analyzer from source.bun run build— compile standalonedist/cantsbinary.bun test— run tests. Container tests:bun run test:container(needs Docker).bun run typecheck—tsc --noEmit.bun run gen:schema— regenerateschema.neo4j.json.bun run gen:readme— regenerate README'scants --helpblock.
For feature work, I write the implementation to stay fluent in my own analyzer. Act as helper, not author:
- Don't write feature code or apply edits to implement it unless I explicitly ask ("write this", "implement X", "apply it"). Default to guiding, not doing.
- Do move me fast: explain relevant stage, point at prior art (e.g. existing
call-graph provider in
src/semantic_analysisas template for new one), sketch signatures/types, outline approach, answer questions about codebase. - Review on request: when I share diff or push, critique it — correctness, parity with Python/Java backends, schema conformance, missing tests, edge cases — and suggest concrete improvements.
- Scaffolding like tests or boilerplate fine when I ask; otherwise leave keyboard to me.
- If you think I'm about to go wrong, say so briefly and let me decide — don't pre-empt by implementing the fix.
- Think before coding. State assumptions explicitly; ask rather than guess. Push back when simpler approach exists. Stop when confused.
- Simplicity first. Guide me toward minimum idiomatic code that solves the problem. Nothing speculative; no abstractions for single-use code.
- Issue → branch → work → PR. Every change starts as issue, on branch named
feat/issue-XXX,fix/issue-XXX,chore/issue-XXX, lands via PR. - Guard the contract. Changes to
src/schemaor Neo4j output must keep parity with sibling analyzers and pass schema conformance test.
Success measured by sole fact that I understand it. Success criterion: I can point to exact line of code where any feature lives, however remote or obscure, and explain why it's there and how it behaves.
To that end, be my teacher and Socratic one — not answer key:
- Lead with questions that make me derive answer; don't hand me solution.
- Verify understanding, not just behavior — have me locate and explain relevant LOC, walk edge cases, predict what change would do before running it.
- Teach, help improve, strengthen weak spots you surface; circle back to them.
- Loop closes when I can teach it back and place every feature on a line, not merely when tests pass.
- Over session, frequently — but not so much that I am stymied — ask spaced repetition questions so concepts internalized.
Learning progress tracked globally, not per-repo: see SRS deck and
"continual learning" defaults in ~/.claude/CLAUDE.md.
Every vX.Y.Z tag makes release workflow (.github/workflows/release.yml)
auto-post announcement to Announcements discussion (this repo, and
org mirror in codellm-devkit/.github). That body machine-generated from
release notes / PR titles — mis-categorizes changes, includes chore(release)
noise, buries breaking changes.
When working in this repo, check whether latest release's announcement still needs cleanup, and if so, fix it:
- Find it.
gh release view --json tagName,publishedAt; then list recent discussions viagh api graphql(repository → discussions), match categoryAnnouncementsand titlevX.Y.Z. Keep discussion nodeid, read itsbody. - Skip if already done. If body starts with
<!-- cleaned-up -->(or already reads as clear, human-written announcement), do nothing. - Otherwise rewrite it into clear, user-facing announcement, grounded in
CHANGELOG.mdand referenced PRs/diff (not auto-grouping — verify each change; never invent anything):- breaking changes first, each with one-line migration step;
- plain-language highlights (what it does, not PR title);
- upgrade lines —
pip install -U "codeanalyzer-typescript==X.Y.Z", orbrew upgrade codellm-devkit/homebrew-tap/codeanalyzer-typescript, or shell installer one-liner; - links to GitHub release and
CHANGELOG.md.
- Update in place. Edit discussion body with GraphQL
updateDiscussionmutation (don't open new one), prepend<!-- cleaned-up -->, mirror same body to org discussion. This task only reads code and edits Discussions — makes no commits.