Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .claude/SCHEMA_DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,16 @@ an unresolved read is `reason: "non-literal"` (key never closes on one literal)
| L3 | `def → actual_in:k` binds when the reaching variable's head is a whole word in argument k's TEXT at the call site | derived at emit time from intra ddg + `PARAM_IN` + the callable's own `call_sites` (python's assembler produces these as `extra_edges`; ours does not) | no per-argument AST at this stage; the ceiling is a local/property name clash (`f(o.v)` with local `v`), which over-binds |
| L4 | `actual_out → callsite` (python's class), not `actual_out → use stmt` | the existing `L → use` edges carry the value onward | derivable without guessing which defined variable is the return |
| L5 | ddg deduped on `(src, dst, var, prov)` and sorted | a binding can be reached from more than one sdg edge | determinism |

## Neo4j bindings, parameters, unresolved config reads (2026-09-07, #182 — python `PY_IMPORTS`/`parameters_json`/`PY_READS_CONFIG_UNRESOLVED` parity)

Spec: `docs/design/specs/neo4j-bindings-parameters-config.md`. Additive on contract 2.0.0.

| # | Concept | Decision | Rationale |
|---|---|---|---|
| D1 | **`resolved_module`** on `TSImport` / re-export `TSExport` | project-relative file key from `ts.resolveModuleName` under the importer's program (`moduleResolution.ts`), kept whether or not the target module was emitted this run; absent for external/builtin/unresolvable; a PER-RUN stamp over every module, cached ones included (set or deleted each run) | python `PyImport.resolved_module` re-resolved per run by `resolve_imports`; the compiler already handles `paths`, index files, `.js`→`.ts` — no second resolver; per-run because tsconfig and file existence are state the content-hash cache cannot see (a build-time value goes stale or wrong while the importer is byte-identical). Incremental Bolt ceiling: an edge flipping absent→present under an unchanged importer waits for `--eager` (same class as `TS_CALLS`) |
| D2 | **`TS_IMPORTS`** | `:TSModule → :TSModule \| :TSExternal`, one edge per (importer, target): `spellings[]`, `imported_names[]`, `aliases[]`, `type_only_names[]`; external target = `<app>/@external/<specifierRoot>` ghost; unresolved relative spellings dropped from the graph | python's aggregation rule (MERGE overwrites a second row for the same pair); ghost on the package root joins `TS_PROVIDES`; `type_only_names[]` is TS's one structural difference |
| D3 | **Exports** | `exports_json` on `:TSModule` (lossless) + `TS_RE_EXPORTS` aggregated like D2 (`exported_names[]`); local exports stay `is_exported` on the declaration node | no reference precedent — coined once here; the edge makes barrel chains walkable, the property keeps `export { x as y }` and spans |
| D4 | **`parameters_json`** on `:TSCallable` | `JSON.stringify(c.parameters)` verbatim, `null` when empty | python's property and encoding; SDK already decodes it; 1.4 % of graph on cants self; a property on the existing node does not worsen #177 |
| D5 | **`TS_READS_CONFIG_UNRESOLVED`** | `:TSApplication → :TSExternal \| :TSCallable`, `key`/`reason`/`prov`, `_k = key\|reason`, no `site`; env-root reads ghost under `@external/<root>`, call-rule reads target the resolved callee id | python shape verbatim incl. its documented per-site collapse; retires the #101 "config_reads stay JSON-only" note (python overturned it in #162) |
| D6 | **Version / tracking** | contract `2.0.0`, analyzer 1.4.0, one PR closing #182 | every addition optional-with-absent; the SDK pins `analyzer_version` |
10 changes: 8 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,14 @@ literal→dataflow-intra→dataflow-interproc tier (`src/semantic_analysis/confi
`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/`.
prefix exception — plus TS_PROVIDES/TS_UNRESOLVED_IMPORT into :TSExternal ghosts, TS_USES_CONFIG
into :ConfigKey, and (#182, python parity) TS_READS_CONFIG_UNRESOLVED (app → ghost/callee,
`_k=key|reason`), TS_IMPORTS/TS_RE_EXPORTS (one per module pair, aggregated names; externals on
the same ghosts), `exports_json` on :TSModule, `parameters_json` on :TSCallable. `resolved_module`
on TSImport/TSExport = the compiler's answer (`ts.resolveModuleName`), a PER-RUN stamp over
cached modules too (`syntactic_analysis/moduleResolution.ts`) — tsconfig/file existence is
state the content-hash cache cannot see.
Consumer query skill: `docs/skills/analyzing-cants-graphs/`.

## Commands

Expand Down
82 changes: 82 additions & 0 deletions docs/design/specs/neo4j-bindings-parameters-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Neo4j projection: import/export bindings, callable parameters, unresolved config reads

Tracking: codeanalyzer-typescript #182. Ships in 1.4.0, one PR, Neo4j contract unchanged at
2.0.0 (additive). Sibling references: codeanalyzer-python `neo4j/schema.py` (`PY_IMPORTS`,
`parameters_json`, `PY_READS_CONFIG_UNRESOLVED`), python-sdk leg 2.5 spec
(`docs/design/specs/2026-09-06-leg-2.5-typescript.md`), codeanalyzer-java #231.

## Problem

Three python-sdk accessors refuse on a TypeScript graph and answer on the in-process backend, and
every one of them is recoverable from data `analysis.json` already carries. Measured on
`main @ 2064b6b` (cants on itself: 139 modules, 1008 callables):

| gap | `analysis.json` | graph |
|---|---|---|
| import / export bindings | 801 imports, 67 exports | none |
| callable parameters | 1546 parameters on 970 callables | none |
| unresolved config reads | `config_reads` 10 (artifacts-app, `-a 2`) | none — `TS_USES_CONFIG` (resolved reads) IS projected; only the unresolved counterpart is missing |

The third row corrects the issue as filed: TypeScript does not emit `DEFINES_CONFIG` alone.

## Contract-impact triage

- **Schema v2 output moves.** `analysis.json`: `resolved_module` on `TSImport` and `TSExport`.
Neo4j: relationship types `TS_IMPORTS`, `TS_RE_EXPORTS`, `TS_READS_CONFIG_UNRESOLVED`;
properties `:TSCallable.parameters_json`, `:TSModule.exports_json`. Ids, levels, the containment
spine, and every existing label/type are untouched. Additive → `SCHEMA_VERSION` stays `2.0.0`
(decision D1 of the v1.3.0-parity block: every analyzer re-baselines together).
- **Repos.** codeanalyzer-typescript emits (this PR). python-sdk consumes in leg 2.5b, which
re-points its TypeScript Neo4j backend from the v1 vocabulary and pins the release carrying this
work. codeanalyzer-java #231 must spell the unresolved-config edge the same way
(`JAVA_READS_CONFIG_UNRESOLVED`) — a naming constraint, not a child. Docs: the consumer skill
`docs/skills/analyzing-cants-graphs/` and the regenerated `schema.neo4j.json`.

## Decisions

| # | Concept | Decision | Rationale |
|---|---|---|---|
| D1 | **Import resolution** | `resolved_module?: string` on `TSImport` and on re-export `TSExport`s: the project-relative **file key** the specifier resolves to under the importer's own program, from `ts.resolveModuleName` (`syntactic_analysis/moduleResolution.ts`), keyed the same way the symbol table keys modules — **whether or not that module was emitted this run** (`--skip-tests` / `--program` can exclude the target; the graph gates on presence, the JSON keeps the key). Absent when the specifier is external (`isExternalLibraryImport`, outside the root, `node_modules`), a builtin, or unresolvable. **A per-run stamp over every module, cached ones included** — set or deleted each run, never a build-time fact. | Python parity (`PyImport.resolved_module`, re-resolved per run by `resolve_imports`; canonical keystone `import.path`). The compiler already resolved it — `tsconfig` `paths`, `index.ts`, `.js`→`.ts`, `.d.ts` — so the analyzer neither re-implements a resolver nor guesses; `ts.resolveModuleName` rather than ts-morph's symbol route because a side-effect-imported script with no exports has no module symbol. Per-run because the answer depends on the tsconfig and on which files exist, neither of which the per-file content hash sees: a build-time value would go silently stale (a target added later) or wrong (a `paths` edit) while the importer stays byte-identical. |
| D2 | **`TS_IMPORTS`** | `:TSModule → :TSModule \| :TSExternal`, **one edge per (importer, target)** aggregating every binding: `spellings[]` (the raw specifiers), `imported_names[]`, `aliases[]`, plus the TS-only `type_only_names[]` (names imported `import type` / `{ type X }`). Target: the resolved module's node when `resolved_module` is set; otherwise the existing import ghost `<app-id>/@external/<specifierRoot>` (`binding.ts::specifierRoot`; `node:`-prefixed and bare builtins ghost under their own spelling, e.g. `@external/node:fs`). Relative spellings that failed to resolve are dropped from the graph (they survive in JSON). | Python's exact shape and aggregation rule (a second row for the same pair would overwrite the first under MERGE). Ghosting on the package root joins the edge to `TS_PROVIDES` / `TS_UNRESOLVED_IMPORT`, closing `:Package → :TSExternal ← :TSModule`. `type_only_names[]` lets a consumer drop type-only edges from a runtime dependency graph — TypeScript's one structural difference here. |
| D3 | **Exports** | Two carriers. `exports_json` on `:TSModule`: `JSON.stringify(mod.exports)` verbatim, `null` when empty — lossless, answers `get_exports`. `TS_RE_EXPORTS`: `:TSModule → :TSModule \| :TSExternal`, aggregated per (module, target) like D2 with `spellings[]`, `exported_names[]` (`"*"` for `export * from`), `aliases[]`, `type_only_names[]`; targets resolved as in D2. Local exports need no edge — `is_exported` already sits on every declaration node. | No reference precedent (ES modules only; python and java have none), so the shape is coined here once. The edge is what makes barrel chains (`index.ts` re-exporting a tree) walkable in Cypher; the property is what keeps `export { x as y }` locals and per-binding spans recoverable. The 1.x graph carried `RE_EXPORTS`; the name is prefixed like every other TS claim. |
| D4 | **`parameters_json`** on `:TSCallable` (and the `:TSAnonymousCallable` twin) | `JSON.stringify(c.parameters)` verbatim — name, `id` (`@formal_in:N`), type, default, `is_optional`/`is_rest`/`is_readonly`, accessibility, decorators, span — `null` when the list is empty. | Python's property, python's encoding; the SDK's `reconstruct.callable_` already decodes `parameters_json` for TypeScript, so the accessor answers with no SDK change. Cost measured at 430 KB on cants self = 1.4 % of `graph.cypher` (body nodes dominate); a minimal shape (148 KB) would be a second parameter vocabulary to keep in sync for 0.9 %. Property on the existing node, so declaration merging (#177) is not made worse: the collapsed node keeps the last writer's list exactly as it keeps the last writer's `kind`. |
| D5 | **`TS_READS_CONFIG_UNRESOLVED`** | `:TSApplication → :TSExternal \| :TSCallable`, one edge per `config_reads` record with `key`, `reason`, `prov`, discriminant `_k = key\|reason`. Target: for env-root reads (`callee` = `process.env`, `import.meta.env`, `Bun.env`) the ghost `<app-id>/@external/<root>`; for detector-table call rules the resolved callee id as-is (an `:TSExternal` ghost or an in-project `:TSCallable`). `site` is not carried. | Python's shape verbatim, including its documented ceiling: several sites reading the same (callee, key, reason) collapse into one edge, so counts differ from `analysis.json` while presence/absence agrees — the python-sdk comment on `get_unresolved_config_reads` applies unchanged and the shared reconstruct needs no TS branch. Retires the #101 note "config_reads stay JSON-only"; python overturned that in its #162. |
| D6 | **Version and tracking** | Contract `2.0.0`, analyzer 1.4.0 minor. One work item (#182), one PR, spec in this repo. | Every addition is optional-with-absent; a graph from 1.3.0 differs only by missing rows. The SDK's generation probe keys on `analyzer_version`, which is what it pins. |

## Projection rules (what the backend rung implements)

- `project.ts::projectModule`-side: after the module node, aggregate `mod.imports` by target
(`resolved_module` → `moduleIdOf(app, key)`; else `specifierRoot(spec) ?? spec` → import ghost),
emit one `TS_IMPORTS` per bucket; same for re-export `TSExport`s into `TS_RE_EXPORTS`.
`exports_json` rides `moduleProps`. Sorted arrays, so the snapshot is byte-stable.
- `callableProps` gains `parameters_json`. `prune` keeps the python `null`-when-empty rule.
- After the `config_uses` loop: `config_reads` → `TS_READS_CONFIG_UNRESOLVED` with key
`${key ?? ""}|${reason}`.
- `schema.ts`: three `RelType`s, two properties, `_k` declared on the config edge. `bun run
gen:schema` re-baselines `schema.neo4j.json`; the conformance test enforces it.
- Incremental Bolt (#140): every new edge's owner is its source module (`TS_IMPORTS`/`TS_RE_EXPORTS`)
or the application (`TS_READS_CONFIG_UNRESOLVED`). The per-module purge covers the removal
direction (a target that vanishes takes its incoming edges with it under `--eager`; a default push
leaves them, as it leaves the target's nodes). **Known ceiling, same class as `TS_CALLS`:** a
binding edge's presence depends on the *target* existing, so an edge that flips absent → present
while the importer is byte-identical (target file added, shard widened) is computed but filtered
by the "shared or changed-owner" rule and not written until the importer changes or the push is
`--eager`. Ghost targets are shared nodes, never pruned — unchanged.

## Definition of done

- `analysis.json`: `resolved_module` present on every relative import/re-export of the sample-app
fixture that names a module in the symbol table; absent on externals. L1 ⊆ L2 ⊆ L3 ⊆ L4 gate
still green (the field is level-free).
- Snapshot on `test/fixtures/sample-app`: `TS_IMPORTS` to `:TSModule` for `./controllers`,
`./models`, `./services`, `./util`, to ghosts for `commander`, `neo4j-driver`, `node:crypto`,
`node:fs`, `node:path`; `parameters_json` on every callable with parameters; `exports_json`
where `exports[]` is non-empty. On `artifacts-app`: `TS_READS_CONFIG_UNRESOLVED` count = number
of distinct (callee, key, reason) triples in `config_reads`.
- Conformance test green against the regenerated `schema.neo4j.json`; edge-identity test covers
the `_k` discriminant on the new config edge; bolt container test exercises an incremental
re-push where an import target module vanishes.
- `.claude/SCHEMA_DECISIONS.md` carries D1–D6; `docs/skills/analyzing-cants-graphs/` documents
the three query shapes.
- Propagation: comment on codeanalyzer-java #231 naming `JAVA_READS_CONFIG_UNRESOLVED` and the
`_k` rule; python-sdk #55 / leg 2.5b pointed at the release.
27 changes: 18 additions & 9 deletions docs/skills/analyzing-cants-graphs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ query answers the wrong question.

The repository-artifact layer — `Artifact`/`Package`/`ConfigKey` and every edge among them
(`HAS_ARTIFACT`, `DECLARES_DEPENDENCY`, `LOCKS`, `DEFINES_CONFIG`, `TS_PROVIDES`,
`TS_UNRESOLVED_IMPORT`) — is **L1 data, level-free**: identical at every `-a`. Neo4j is always
`TS_UNRESOLVED_IMPORT`) — is **L1 data, level-free**: identical at every `-a`. So are the module
binding edges `TS_IMPORTS` / `TS_RE_EXPORTS`, `exports_json` on `TSModule`, and `parameters_json` on
`TSCallable`. `TS_READS_CONFIG_UNRESOLVED` exists from `-a 2` and SHRINKS as the level rises (a read
resolved at a higher tier moves to `TS_USES_CONFIG`). Neo4j is always
projected **full-depth** for the level actually analyzed (`--emit neo4j` + `-a`/`--graphs`
together is a CLI error, not a partial graph).

Expand Down Expand Up @@ -76,9 +79,10 @@ section (`config_uses` is the opposite: asserted superset-monotonic, L2 ⊆ L3
this branch's fixture at 21/25/29 uses and 10/9/8 reads across L2/L3/L4). A read unresolved at the
literal tier can close at a higher dataflow tier, so it *moves* from `config_reads` into
`config_uses` as the level climbs. Diffing two levels and seeing a `config_reads` record vanish
means "resolved at the higher tier," never "fixed in the code." Pair the two: `config_uses` is the
graph edge (`TS_USES_CONFIG`); `config_reads` never became an edge — it is JSON-only, a record of
absence, not a graph fact (analyses.md §7).
means "resolved at the higher tier," never "fixed in the code." Pair the two: `config_uses` is
`TS_USES_CONFIG` (body node → key); `config_reads` is `TS_READS_CONFIG_UNRESOLVED` (application →
the read root's ghost or the resolved callee), one edge per distinct (target, key, reason) — several
sites collapse onto it, so its count is a floor on the JSON list's, never equal (analyses.md §7).

**`--app-name` is the cross-analyzer join precondition.** Artifact ids are language-neutral
specifically so a TS and a Python analysis of one repository MERGE onto the same `:Artifact` node
Expand Down Expand Up @@ -114,11 +118,16 @@ from the body node straight to the `ConfigKey` instead.
no name segment) sit on the same label but different ids — join them by the `module` property when
a query needs both (`references/vocabulary.md`, "External ghosts").

**No entrypoint or import-graph vocabulary exists yet.** `TSCallable` carries no `is_entrypoint`; a
"reachable from the entrypoints" query needs a root set you supply yourself (analyses.md §2). A
module's `imports[]`/`exports[]` (specifiers, aliases, type-only flags) live only in
`analysis.json`'s `TSModule` — `TS_UNRESOLVED_IMPORT`/`TS_PROVIDES` cover the dependency-hygiene
case only, not a general per-module import graph.
**The import graph is aggregated, the export list is not.** `TS_IMPORTS` / `TS_RE_EXPORTS` carry ONE
relationship per (module, target) with every binding folded into `spellings[]` /
`imported_names[]` (`exported_names[]`) / `aliases[]` / `type_only_names[]` — per-binding spans and
kinds are not on the edge. `exports_json` on `TSModule` is the verbatim `exports[]` list (including
`export { x as y }` locals, which are no edge); imports have no such property — read
`analysis.json` for per-binding detail. An unresolved *relative* import (`./missing`) has no node to
land on and is absent from the graph while present in JSON; externals land on the same
`@external/<package root>` ghost `TS_PROVIDES` / `TS_UNRESOLVED_IMPORT` address, builtins under
their own spelling (`@external/node:fs`). A "reachable from the entrypoints" query still needs a
root set: `is_entrypoint` on `TSCallable`/`TSClass` (analyses.md §2).

Every trap above is a silent-empty-result failure, not an error: Cypher does not reject a query
naming a nonexistent label, property, or relationship — it just returns nothing. Cross-check
Expand Down
Loading
Loading