Skip to content

Commit 8032d08

Browse files
authored
Merge pull request #78 from codellm-devkit/fix/issue-46-bolt-symbol-labels
feat!: schema v2 — CanNode CPG, L3 dataflow, TS-prefixed graph vocabulary (1.0.0, graph schema 2.0.0)
2 parents 834ed16 + 7ff2edf commit 8032d08

74 files changed

Lines changed: 6824 additions & 838 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
push:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Check out code
14+
uses: actions/checkout@v4
15+
16+
- name: Set up Bun
17+
uses: oven-sh/setup-bun@v2
18+
19+
- name: Install dependencies
20+
run: bun install --frozen-lockfile
21+
22+
- name: Typecheck
23+
run: bun run typecheck
24+
25+
- name: Unit + conformance tests
26+
run: bun test
27+
28+
# Container suite is opt-in; ubuntu-latest ships Docker, so testcontainers works out of the box.
29+
- name: Neo4j bolt container tests
30+
run: RUN_CONTAINER_TESTS=1 bun test test/neo4j-bolt.test.ts

CLAUDE.md

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,46 @@ Agent guidance for `codellm-devkit/codeanalyzer-typescript` (`cants`).
66

77
`cants` is a TypeScript/JavaScript static analyzer built on the TypeScript compiler
88
(via [ts-morph](https://ts-morph.com/)). It is the CLDK TypeScript backend: it emits
9-
the canonical CLDK `analysis.json`a **symbol table** plus a **resolver-based call
10-
graph**and can project that same analysis into a **Neo4j** property graph. It
11-
mirrors its [Python](https://github.com/codellm-devkit/codeanalyzer-python) and
9+
the **canonical schema v2**one additive Code Property Graph — in **two projections**,
10+
`analysis.json` and a **Neo4j** property graph. It mirrors its
11+
[Python](https://github.com/codellm-devkit/codeanalyzer-python) and
1212
[Java](https://github.com/codellm-devkit/codeanalyzer-java) sibling analyzers, so
1313
output-shape parity with them is a first-class concern.
1414

15+
## Schema v2 — the additive CPG (read this before touching output)
16+
17+
The output is **one scale-free structure**: a containment tree of nodes (id / kind /
18+
`span` / children) with **typed edge overlays** (a CPG). Every classic artifact — symbol
19+
table, call graph, CFG, PDG, SDG — is a *projection* of that one structure, and analysis
20+
**levels** are how deeply it is populated (each level only ever *adds*, never rewrites):
21+
22+
- **L1** (`-a 1`): the tree to callable depth — `application → symbol_table{module} →
23+
types{}/functions{}/fields{} → callables{}` — plus `call` nodes in each callable's
24+
`body{}` (with `callee` unresolved). `source` is stored once per module; every node's
25+
text slices off it via `span.bytes`.
26+
- **L2** (`-a 2`): the `call_graph` edge list (callable→callable) at the application scope,
27+
and the `callee` slot on each call node refined `null → id` (the one sanctioned mutation).
28+
- **L3** (`-a 3`): the rest of `body{}` (statements + `@entry`/`@exit`) and the intra-callable
29+
edge lists `cfg`/`cdg`/`ddg` (reaching-definitions, `prov:["reaching-defs"]`) hung on each callable.
30+
- **L4** (`-a 4`): the synthetic `@formal_in:N`/`@formal_out`/`<L>/actual_in:N`/`<L>/actual_out`
31+
vertices, the intra-caller `summary` edges, and the application-scope `param_in`/`param_out`
32+
lists (the interprocedural SDG).
33+
34+
**Identity is two-tier**: durable `can://<lang>/<app>/<file>/<type>/<sig>` ids at callable
35+
depth and above; ordinal `<callable-id>@<line>:<col>` (or `@<tag>`) below. Intra-callable
36+
edge lists use **bare local ids**; cross-callable lists use **fully-qualified `can://…@local`**
37+
ids. `L1 ⊆ L2 ⊆ L3 ⊆ L4` is a CI-checkable monotonicity gate (`test/schema-v2.test.ts`). The
38+
model + every decision live in `.claude/SCHEMA_DECISIONS.md` (§ "Schema v2 migration") and the
39+
skillset's `canonical-schema.md`.
40+
41+
**Provider/client boundary:** the analyzer is a *pure graph provider* — it emits the graph
42+
substrate (CFG/PDG/SDG + `summary` edges) and stops. Slicing and taint are reachability
43+
*queries* over it and belong to the frontend SDK; never add a `taint_flows` section here.
44+
45+
The v1→v2 emitter is a pure transform in **`src/schema/v2/`** (`emit.ts` reshapes the v1
46+
in-memory model, `dataflow.ts` maps `program_graphs` into the tree) — the parse/resolve/
47+
dataflow *compute* is untouched; only serialization is v2.
48+
1549
The call graph defaults to the **union** of two backends: the TS compiler's resolver
1650
and the embedded [Jelly](https://github.com/cs-au-dk/jelly) flow analyzer (which
1751
recovers higher-order/callback edges the resolver misses). Merged edges keep a
@@ -29,14 +63,23 @@ it first; everything else is a stage it calls, in order:
2963
JSDoc, with precise source spans.
3064
3. **call graph** (`src/semantic_analysis`) — `selectProvider()` picks tsc / jelly /
3165
union; each provider returns edges + external (phantom) symbols.
32-
4. **cache** (`src/utils/cache.ts`) — content-hash cache under `.codeanalyzer/`, so
33-
re-analysis only touches what changed.
34-
5. **output** (`src/build`, `src/build/neo4j`) — `analysis.json`, a self-contained
35-
`graph.cypher` snapshot, or an incremental Bolt push to a live database.
36-
37-
The shape of everything is the **schema** in `src/schema` (`TSApplication` is the top
38-
type). The Neo4j schema is versioned and enforced by a conformance test — treat it as
39-
a contract.
66+
4. **program graphs** (`src/dataflow`) — levels 3–4 (`-a 3`/`-a 4`): CFG → post-dominance/CDG →
67+
access-path def-use → PDG → SCC-condensed bottom-up summaries → SDG. This is the *compute*;
68+
it produces the internal `program_graphs` model, which `src/schema/v2/dataflow.ts` then maps
69+
**into the v2 tree** (`body{}` + `cfg`/`cdg`/`ddg`/`summary` per callable + `param_in`/
70+
`param_out`). Decisions: `.claude/SCHEMA_DECISIONS.md`; contract + staged follow-ups: issue #2.
71+
5. **cache** (`src/utils/cache.ts`) — content-hash cache under `.codeanalyzer/`, so
72+
re-analysis only touches what changed (levels 3–4 also record summaries +
73+
dependency edges in `graphs_summaries.json`).
74+
6. **output** (`src/schema/v2`, `src/build/neo4j`) — `src/schema/v2/emit.ts` reshapes the v1
75+
compute model into the schema-v2 `analysis.json`; `src/build/neo4j` projects the *same* v2
76+
tree into a `graph.cypher` snapshot or an incremental Bolt push. `--emit neo4j` is always
77+
**full-depth** (levels gate the JSON path only; combining `-a`/`--graphs` with it is an error).
78+
79+
The **output** shape is schema v2 (`src/schema/v2/model.ts`, `V2Application` the top type); the
80+
types in `src/schema` (`TSApplication`) are the *internal compute model* the emitter transforms.
81+
The Neo4j schema (`src/build/neo4j/schema.ts`, v2.0.0) is versioned and enforced by a conformance
82+
test — treat both as contracts and keep them in lockstep with the JSON.
4083

4184
## Directory map
4285

@@ -47,10 +90,12 @@ a contract.
4790
| `src/options` | Parsed CLI options / `AnalysisOptions` |
4891
| `src/syntactic_analysis` | Symbol table (ts-morph traversal) |
4992
| `src/semantic_analysis` | Call-graph providers (tsc, jelly, union), phantoms |
50-
| `src/schema` | `TSApplication` types + signatures (the output contract) |
51-
| `src/build` | Dep materialization + output; `build/neo4j` = graph projection |
52-
| `src/utils` | fs, caching, logging, serialization, version |
53-
| `test` | Bun tests + `fixtures/sample-app` |
93+
| `src/dataflow` | L3/L4 program-graph **compute**: CFG, dominance/CDG, def-use, summaries, SDG |
94+
| `src/schema` | `TSApplication` — the internal compute model + `signatureOf` + `program_graphs` |
95+
| `src/schema/v2` | **the schema-v2 emitter**: `model.ts` (target shape) + `emit.ts` (tree/L1/L2) + `dataflow.ts` (L3/L4) |
96+
| `src/build` | Dep materialization; `build/neo4j` = the v2 graph projection (project/rows/cypher/bolt/schema) |
97+
| `src/utils` | fs, caching, logging, serialization (`serialize.ts``toV2`), version |
98+
| `test` | Bun tests + `fixtures/sample-app` + `fixtures/dataflow-app`; `schema-v2.test.ts` = the L1–L4 gates |
5499

55100
## Commands
56101

README.md

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
# codeanalyzer-typescript (`cants`)
66

7-
**A TypeScript/JavaScript static-analysis toolkit — the CLDK backend that emits a canonical symbol table and call graph, as `analysis.json` or a Neo4j property graph.**
7+
**A TypeScript/JavaScript static-analysis toolkit — the CLDK backend that emits the canonical schema-v2 Code Property Graph (symbol table call graph → intraprocedural dataflow → interprocedural SDG), as `analysis.json` or a Neo4j property graph.**
88

99
[![PyPI](https://img.shields.io/pypi/v/codeanalyzer-typescript?style=for-the-badge&logo=pypi&logoColor=white)](https://pypi.org/project/codeanalyzer-typescript/)
1010
[![Python](https://img.shields.io/pypi/pyversions/codeanalyzer-typescript?style=for-the-badge&logo=python&logoColor=white)](https://pypi.org/project/codeanalyzer-typescript/)
@@ -16,9 +16,10 @@
1616
---
1717

1818
`cants` is a static analyzer for TypeScript/JavaScript built on the TypeScript compiler (via
19-
[ts-morph](https://ts-morph.com/)). It produces the canonical CodeLLM-DevKit (CLDK)
20-
`analysis.json` — a symbol table plus a resolver-based call graph — and can project that same
21-
analysis into a **Neo4j property graph**. It is the TypeScript backend behind
19+
[ts-morph](https://ts-morph.com/)). It produces the canonical CodeLLM-DevKit (CLDK) **schema v2**
20+
— one additive Code Property Graph, built up level by level (symbol table → call graph →
21+
intraprocedural dataflow → interprocedural SDG) — as `analysis.json` and can project that same
22+
structure into a **Neo4j property graph**. It is the TypeScript backend behind
2223
[CLDK](https://github.com/codellm-devkit/python-sdk), mirroring its
2324
[Python](https://github.com/codellm-devkit/codeanalyzer-python) and
2425
[Java](https://github.com/codellm-devkit/codeanalyzer-java) siblings.
@@ -135,8 +136,8 @@ written to `analysis.json` (or `graph.cypher` for `--emit neo4j`) in that direct
135136
```text
136137
Usage: cants [options]
137138
138-
CLDK TypeScript analyzer — emits the canonical analysis.json (symbol table +
139-
resolver call graph), or a Neo4j graph.
139+
CLDK TypeScript analyzer — emits the canonical schema-v2 CPG (symbol table
140+
call graph → dataflow → SDG) as analysis.json, or a Neo4j graph.
140141
141142
Options:
142143
-i, --input <path> project root to analyze (not required for
@@ -157,9 +158,19 @@ Options:
157158
visible in shell history / process list)
158159
(default: "neo4j", env: NEO4J_PASSWORD)
159160
--neo4j-database <db> Neo4j database name (env: NEO4J_DATABASE)
160-
-a, --analysis-level <n> analysis depth: 1 = symbol table + tsc resolver
161-
call graph + RTA (default); 2 = call graph
162-
(default: "1")
161+
-a, --analysis-level <n> analysis depth: 1 = symbol table (default); 2 =
162+
+ resolver call graph; 3 = + intraprocedural
163+
dataflow (cfg/cdg/ddg); 4 = + interprocedural
164+
SDG (param_in/param_out/summary) (default: "1")
165+
--graphs <list> dataflow sections to emit, comma-separated: cfg
166+
| dfg | pdg (require -a 3) | sdg (requires -a
167+
4); default: all rungs at or below the level
168+
--graph-field-depth <k> access-path depth bound (k-limit) for level-3
169+
dataflow (default: "3")
170+
-j, --jobs <n> worker parallelism for level-3 graphs (default:
171+
sequential; opt in with N ≥ 2 on large projects
172+
— each worker loads its own copy of the
173+
program)
163174
-t, --target-files <paths...> restrict analysis to specific files
164175
(incremental)
165176
--skip-tests skip test trees (default)
@@ -213,32 +224,96 @@ Options:
213224
cants --input ./my-ts-project --eager --cache-dir /path/to/custom-cache
214225
```
215226

227+
6. **Program graphs (level 3): CFG/PDG/SDG in `analysis.json`:**
228+
```sh
229+
cants --input ./my-ts-project -a 3 # full program_graphs section
230+
cants --input ./my-ts-project -a 3 --graphs cfg,pdg # scope the emitted graphs
231+
```
232+
216233
## Output targets
217234

218235
`cants` builds one analysis in memory and can emit it three ways (`--emit`):
219236

220237
### `analysis.json` (default)
221238

222-
A `TSApplication` document — the canonical CLDK contract the Python SDK parses:
239+
The **canonical schema v2** — one additive Code Property Graph: a containment tree of nodes
240+
(`id` / `kind` / `span` / children) with typed edge overlays. Analysis **levels** populate it more
241+
deeply; each level only ever *adds*.
223242

224243
```jsonc
225244
{
226-
"symbol_table": { /* file path → module (classes, interfaces, enums,
227-
type aliases, functions, namespaces, variables, …) */ },
228-
"call_graph": [ /* CALL_DEP edges: { source, target, type, weight,
229-
provenance, tags } keyed by callable signature */ ],
230-
"external_symbols": { /* phantom stubs for call targets outside the project */ }
245+
"schema_version": "2.0.0", "language": "typescript", "max_level": 4, "k_limit": 3,
246+
"application": {
247+
"id": "can://typescript/<app>", "kind": "application",
248+
"symbol_table": { // L1: the tree, keyed by file path
249+
"<file>": { "kind": "module", "source": "",
250+
"types": { /* class | interface | enum | type_alias | namespace nodes */ },
251+
"functions": { /* callable nodes: { id, kind, span, body{}, cfg[], cdg[], ddg[], summary[] } */ },
252+
"fields": { /* module-level bindings */ } } },
253+
"call_graph": [ /* L2: { src, dst, prov, weight } — callable → callable, can:// ids */ ],
254+
"param_in": [ /* L4: actual_in → formal_in, fully-qualified can://…@local ids */ ],
255+
"param_out": [ /* L4: formal_out → actual_out */ ]
256+
}
231257
}
232258
```
233259

234-
Caller- and callee-side identifiers come from a single signature canonicalizer, so call-graph
235-
`source`/`target` values byte-match the corresponding `symbol_table` / `external_symbols` keys.
260+
Each callable's `body{}` is keyed by local id (`line:col`, or `@entry`/`@formal_in:N`/… for
261+
synthetic vertices); intra-callable edge lists (`cfg`/`cdg`/`ddg`/`summary`) use those bare local
262+
ids, cross-callable lists use fully-qualified `can://…@local` ids. A single signature canonicalizer
263+
underlies every `can://` id, so call edges, dataflow edges, and tree nodes all join. The full model
264+
is `.claude/SCHEMA_DECISIONS.md` (§ "Schema v2 migration") and the CLDK `canonical-schema.md`.
265+
266+
### Dataflow (`-a 3` intraprocedural, `-a 4` interprocedural)
267+
268+
Native dependence graphs, built in-process from the same ts-morph AST (no external engine), grown
269+
**into the tree** (not a separate section):
270+
271+
- **`-a 3`** completes each callable's `body{}` with statement nodes and hangs the intra-callable
272+
edge lists `cfg` (exceptional control flow), `cdg` (control dependence), and `ddg` (data
273+
dependence via reaching-definitions, `prov:["reaching-defs"]`) on the callable.
274+
- **`-a 4`** adds the synthetic `@formal_in:N` / `@formal_out` / `<L>/actual_in:N` / `<L>/actual_out`
275+
vertices, the intra-caller `summary` edges, and the application-scope `param_in` / `param_out`
276+
lists — the whole-program System Dependence Graph.
277+
278+
`-a 3` implies `-a 2`; `-a 4` implies `-a 3`. `--graphs cfg,dfg,pdg,sdg` scopes which rungs emit
279+
(`cfg`/`dfg`/`pdg` require `-a 3`, `sdg` requires `-a 4`). `L1 ⊆ L2 ⊆ L3 ⊆ L4` is a monotonicity
280+
gate. Every node is addressed by its `can://…@local` id, so dataflow edges, call edges, and tree
281+
nodes all join.
282+
283+
**Substrate (locked in [issue #2](https://github.com/codellm-devkit/codeanalyzer-typescript/issues/2)):**
284+
the CFG and reaching-definitions are hand-built from the ts-morph AST; the call-graph oracle is
285+
the existing provenance-merged tsc ∪ Jelly graph; aliasing is a flow-insensitive copy-alias MVP
286+
(Jelly points-to-backed propagation is a staged upgrade). Function summaries are composed
287+
bottom-up over the SCC condensation of the call graph, with k-limited access paths; module
288+
globals ride the SDG as extra parameters. The analysis is deliberately sound-leaning and
289+
over-approximate; known unsoundness (dynamic `eval`, reflection/monkey-patching, npm-internal
290+
effects) is recorded in `.claude/SCHEMA_DECISIONS.md`. The analyzer is a **pure graph provider**:
291+
it emits the dependence-graph substrate (CFG/PDG/SDG + `summary` edges) and stops — backward
292+
slicing and taint are reachability *queries* over the SDG that live in the frontend SDK, not here.
293+
294+
**Parallelism (`-j/--jobs`).** The pipeline implements the level-3 parallel execution model:
295+
stage-1–4 extraction fans out per callable over a Bun worker pool (partitioned by file) and is
296+
posted *before* the call-graph solve so the two overlap; summary composition runs as a
297+
Kahn-style ready-queue wavefront over the SCC condensation DAG (the SCC is the atomic unit).
298+
`--jobs N` output is **byte-identical** to `--jobs 1` (node ids are span-ordered, all edge lists
299+
are collect-then-sorted, and the SCC fixpoint is a pure function of its inputs) — enforced by a
300+
differential test. It is off by default and worth opting into only on large codebases: ts-morph
301+
ASTs cannot cross the worker boundary, so each extraction worker loads its own copy of the
302+
program, which dominates the parallelizable graph math on small/mid repos (self-analysis runs
303+
2.5× slower at `-j 14`). Worker failure at any stage degrades to the sequential path with a
304+
warning — never to wrong or missing output.
305+
306+
Levels 1/2 are unaffected: nothing in level 3 runs unless `-a 3` is requested.
236307

237308
### Neo4j graph
238309

239-
`--emit neo4j` projects the same analysis into a labeled property graph (declarations keyed by
240-
their signature under a shared `:Symbol` label; calls, imports, inheritance, decorators, and call
241-
sites as relationships):
310+
`--emit neo4j` projects the **same v2 tree** into a labeled property graph: every node keyed by its
311+
`can://` id under a shared `:CanNode` merge label (+ a `TS`-prefixed specific kind label, e.g.
312+
`:TSModule`, `:TSCallable`), containment as `TS_HAS_MODULE`/`TS_DECLARES`/`TS_HAS_METHOD`/
313+
`TS_HAS_FIELD`/`TS_HAS_BODY_NODE` edges, and the overlays (`TS_CALLS`, `TS_CFG_NEXT`, `TS_CDG`,
314+
`TS_DDG`, `TS_SUMMARY`, `TS_PARAM_IN`, `TS_PARAM_OUT`) as typed relationships. The graph is
315+
**always full-depth** — analysis levels gate the JSON path only, so combining `-a`/`--graphs` with
316+
`--emit neo4j` is an error:
242317

243318
- **Without `--neo4j-uri`** — writes a self-contained `graph.cypher` (constraints + indexes, a
244319
scoped wipe, then batched `MERGE`s). Load it with `cypher-shell < graph.cypher`.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
{
22
"name": "codeanalyzer-typescript",
3-
"version": "0.5.0",
4-
"description": "CLDK TypeScript analyzer — emits the canonical CLDK analysis.json (symbol table + resolver-based call graph) via ts-morph.",
3+
"version": "1.0.0",
4+
"description": "CLDK TypeScript analyzer — emits the canonical schema v2 (additive CPG: symbol table call graph → intraprocedural dataflow → interprocedural SDG) as analysis.json and Neo4j, via ts-morph.",
55
"type": "module",
66
"module": "src/index.ts",
77
"bin": {
88
"cants": "dist/cants"
99
},
1010
"scripts": {
1111
"start": "bun run src/index.ts",
12-
"build": "bun build ./src/main.ts --compile --external @babel/preset-typescript --outfile dist/cants",
12+
"build": "bun build ./src/main.ts ./src/dataflow/worker.ts --compile --external @babel/preset-typescript --outfile dist/cants",
1313
"gen:schema": "bun run src/index.ts --emit schema > schema.neo4j.json",
1414
"gen:readme": "bun run scripts/update-readme.ts",
1515
"test:container": "RUN_CONTAINER_TESTS=1 bun test test/neo4j-bolt.test.ts",

0 commit comments

Comments
 (0)