Hybrid CPG (two-resolution) — Implementation plan
Goal: Reach Joern-style CPG query capability for agent loops (mutation proof, data slice, call bridging) without collapsing the whole repo into one mega-graph or regressing default discover performance.
Architecture (Option 6):
L_repo = CALL + type + Contains (+ existing Uses/…) → graph.snapshot.bin (always)
L_proc = CFG + DFG/PDG [+ field writes] [+ optional AST skeleton] → cfg_pdg archive / on-demand
L_query = virtual CPG API joins L_repo ⟷ L_proc by function UUID (+ call sites)
Driving use case: Prove OrderDTO fields are never mutated outside constructors before converting to a Java record (see §8). Grep/setters alone are insufficient; agents need typed field-write reachability + local data slices.
Non-goals (this plan):
- Joern/Neo4j as primary store; full-repo fused CPG in
graph.snapshot.bin - Full AST
PARENT/CHILDfor every syntax node in default discover - Sound points-to / must–may alias as v1 (tier later; see DFG tiers)
- Claiming absolute “100% safety” against reflection / frameworks / JNI
Related: analysis-architecture.md, cfg-design.md, pdg-design.md, program-slicing-design.md, gql-design.md, taint-analysis-design.md.
1. Why this shape
| Concern | Choice |
|---|---|
| Scale (linux-class repos) | Keep topology digest stable; deep facts stay opt-in sidecars |
| Agent UX | One CPG view API, not “remember three CLIs” |
| Mutation queries | Need typed field writes, not only name-based PDG locals |
| Incremental | L_proc rebuilds with CFG archive; L_repo unchanged by CPG façade |
| Interop | Optional export phase later; not blocking hybrid query |
flowchart TB
subgraph L_repo["L_repo — topology"]
SNAP[graph.snapshot.bin]
end
subgraph L_proc["L_proc — per-function"]
ARC[cfg_pdg.archive.bin]
IDX[field_write.index.bin optional]
end
subgraph L_query["L_query — virtual CPG"]
API[cpg CLI / HTTP / GQL extensions]
end
SNAP --> API
ARC --> API
IDX --> API
API --> AGENT[Agent ReAct loop]
Invariant: Default discover (no --with-cfg) never builds L_proc or field-write index. Snapshot digest rules unchanged (analysis-architecture.md).
2. Phased delivery
| Phase | Outcome | User-visible |
|---|---|---|
| P0 | CPG façade over existing CFG/PDG + CALL | rg-build cpg … / HTTP; docs for agents |
| P1 | Field-write IR + type-linked mutation index | OrderDTO Turn-2 query works on fixtures |
| P1 status | Shipped: field_write.index.bin, cpg mutations --type … --exclude-ctors | |
| P2 | Unified slice / flows in CPG API | OrderDTO Turn-5 without separate mental model |
| P2 status | Shipped: cpg flows + shared ForwardSlicer | |
| P3 | DFG fidelity tiers (loop / must–may opt-in) | Fewer false negatives on hard dataflow |
| P3 status | Shipped: --with-dfg-loops, cpg flows --with-alias | |
| P4 | Optional AST skeleton + CPG export | Syntax queries + Joern/Neo4j interop |
| P4 status | Shipped: --with-ast-skeleton, cpg ast, cpg export |
Ship P0→P2 before P3/P4. P1 is the gate for the record-refactor agent story.
3. Phase 0 — Virtual CPG façade (no new IR)
Problem
Agents must stitch gql / blast-radius / inspect / slice and know --with-cfg. No single “CPG” entrypoint.
Design
- Do not duplicate CFG/PDG into the snapshot.
- Add a thin join layer that loads:
PreparedGraphSnapshot/ cold metadata for L_repoCfgPdgArchivewhen present (else clear error: rundiscover --with-cfg)
- Surface stable JSON schemas (
schema_version) under-f json.
Steps
- Module —
crates/rgbuilder-analysis/src/cpg/(orcpg_query.rs):CpgContext { graph, archive, call_graph }- Resolvers:
function_by_name,type_by_name,cfg(fn),pdg(fn)
- CLI —
rg-build cpgsubcommands (v0):cpg status— archive present? function count with CFG/PDGcpg function <name>— L_repo node + whether L_proc existscpg calls <name>— CALL neighborhood from snapshot (bridge demo)cpg pdg <name> [--edge-layer data|control]— thin wrap of inspectcpg slice <file> --line N --variable V …— thin wrap of slice
- HTTP —
POST /api/cpgwith{ "op": "…", … }mirroring CLI (same JSON shapes as-f json); wire inserve. - Docs — AGENTS.md + agent-recipes: “prefer
cpgwhen reasoning across control/data/calls”; note requires--with-cfg. - Tests — Fixture with one Java/Rust function: status ok; slice/pdg round-trip through façade.
Acceptance
- After
discover --with-cfg, agent can run mutation/slice workflows viacpgwithout inventing multi-tool glue (even if mutation query still missing until P1). - Default discover unchanged (no new artifacts).
Perf
- Façade is load-on-query only; no discover-time cost.
4. Phase 1 — Field writes + typed mutation index (OrderDTO Turn 2)
Problem
Joern: cpg.typeDecl.name("OrderDTO").member…assignment.filterNot(_.method.name == "<init>").
Today:
- Class nodes exist; members are not first-class write targets in L_proc.
- PDG def/use is identifier-name based;
order.status = …does not reliably produce a typed write toOrderDTO.status. - No repo-wide “all writes to type T’s fields” query.
Design
Field write fact (stored in L_proc record and/or compact index):
| Field | Meaning |
|---|---|
function_id | Enclosing method UUID (L_repo) |
is_constructor | true for <init> / language ctor |
receiver_local | e.g. order (optional) |
receiver_type | Resolved type name / FQN when known |
member | Field name e.g. status |
file, line, code_snippet | For agent observation |
kind | DirectField | ThisField | … |
Resolution v1 (soundness-bounded):
- LHS is
field_access/ equivalent (obj.f,this.f). - Resolve
obj’s type from: local decl, parameter type, field type of enclosing class (reuse Javafind_field_type-style helpers; extend per language plugin). - Map to L_repo
Class/Structby name/FQN when possible; store type string even if Class UUID missing. - Skip unresolved receivers into
kind=Unresolvedbucket (queryable with flag; not counted as proof).
Index: Prefer sidecar .rgbuilder/analysis/field_write.index.bin (function_id → writes, secondary map type→writes) built during --with-cfg pass—avoid bloating every PDG if scan-only is enough. Rebuild when archive rebuilds; invalidate with same digest rules as other sidecars.
Do not stamp field-write edges into graph.snapshot.bin in v1 (digest + RSS). Optional later: Modifies edges Function→Variable if Variable nodes for fields are extracted.
Steps
- Def/use fix — In
def_use.rs(and CFG statement extraction): treat field-access LHS as write to member (recordmember+ base), not silent no-def / wrong local-only def. - Type recovery helper — Shared in analysis or lang plugins:
resolve_local_type(fn_ast, local) -> Option<TypeName>for Java first (driving case); stub other langs. - Extract writes — During CFG/PDG build (or post-pass over CFG statements + source), emit
Vec<FieldWrite>intoCfgPdgRecordor parallel index writer. - Query API —
cpg mutations --type OrderDTO [--exclude-ctors] [--member status]:- Returns file/line/code JSON list
- Filters
is_constructorwhen requested
- GQL (optional P1.5) — Virtual pattern or macro
field_mutations(type, …)documented as overlay (like:Community); not topology edges. - Setter policy (P1b) — Also list
CallsintosetX/ known mutators on type (name heuristic + optional config). Document as advisory, not complete. - Fixture —
OrderDTO+OrderProcessorwithorder.status = "PROCESSED"; assert mutation query finds exactly that line; ctor assigns excluded. - Negative — Unrelated class with
.status =must not appear when type resolves; unresolved writes appear only with--include-unresolved.
Acceptance
- Turn 2 of the record-refactor loop works on the Java fixture via
rg-build -f json cpg mutations --type OrderDTO --exclude-ctors. - Empty result ⇒ agent may proceed to record conversion under documented resolution limits.
--with-cfgwall time / RSS: measure on ecommerce-java; no default-discover impact. Target: index build ≪ CFG/PDG build (profile stage[profile] field_writes).
Perf
- Opt-in with
--with-cfgonly. - Index size O(#field writes), not O(AST).
- Query: O(writes for type) via inverted map; avoid full archive deserialize if index is standalone.
5. Phase 2 — Flows / slice in the CPG API (OrderDTO Turns 5–6)
Problem
Agents switch to slice / Joern reachableByFlows mentally; hybrid CPG should expose one vocabulary.
Design
Map:
| CPG concept | rgBuilder |
|---|---|
reachableByFlows (forward) | Forward PDG slice / data-dep BFS |
| Backward slice | Existing slice default |
| Interproc (best-effort) | Existing interprocedural slicer when archive + call graph allow |
Steps
cpg flows—--file --line --variable [--direction forward|backward] [--function] [--interprocedural]- Reuse
slicing.rs/ interprocedural path; JSON schema aligned withslice(schema_version).
- Reuse
- Handoff recipe — Docs: mutations → pick site →
cpg flowson receiver local → decide wither/copy. - Agent recipe — Copy-paste ReAct loop for DTO→record in agent-recipes.md.
- Tests — Fixture flow: find → mutate → return; forward flows lists assign, write, return lines.
Acceptance
- Turns 5–6 runnable solely through
cpg(+ discover--with-cfg). - Token story: JSON returns locs/snippets; agent need not load whole files.
6. Phase 3 — DFG fidelity tiers (opt-in)
Tighten L_proc data edges without changing L_repo.
| Tier | Flag (illustrative) | Adds |
|---|---|---|
| T0 | (default with --with-cfg) | Today’s Flow/Anti/Output (name-based) |
| T1 | --with-dfg-loops | Loop-carried dependence classification |
| T2 | --with-dfg-alias / on-demand | Must/May alias for selected functions |
Steps
- Design note in pdg-design.md: tier flags, archive schema bump if needed.
- Implement T1 on CFG back-edges; expose on
DataDepor parallel tag. - T2 on-demand for
cpg flows/inspect(never whole-linux default). - Benchmarks: ecommerce-java + one medium C repo; gate default T0.
Acceptance
- Documented false-negative classes shrink for loop-heavy code when T1 enabled.
- Default
--with-cfgcost remains T0-class.
7. Phase 4 — AST skeleton + export (optional)
4a AST skeleton (sidecar only)
- Coarse nodes: decl, block, if, loop, call, assign — plus nesting edges — per function or per file, not in snapshot.
- Enables syntax-oriented queries; not required for OrderDTO mutation if field-write IR exists.
4b Export
rg-build cpg export --format graphml|graphsonmaterializing L_repo CALL/type ∪ selected L_proc edges for one package or whole repo (explicit scope).- No change to primary store.
Acceptance
- Export validates on small fixture; large export is opt-in and scoped.
8. Driving scenario — acceptance script
End-to-end agent simulation (CI or tests/ harness):
1. discover fixture --with-cfg
2. cpg mutations --type OrderDTO --exclude-ctors
→ exactly OrderProcessor.java:114 (or fixture line)
3. cpg flows --file OrderProcessor.java --line 114 --variable order --direction forward
→ includes assign, field write, return
4. (manual/agent) apply record + withStatus; re-discover
5. cpg mutations --type OrderDTO --exclude-ctors → empty
Document known gaps: reflection, Object receivers without cast, cross-language, Lombok-generated setters unless bytecode/source present.
9. API sketch (stable for agents)
rg-build -r "$REPO" discover . --with-cfg
rg-build -r "$REPO" -f json cpg status
rg-build -r "$REPO" -f json cpg mutations --type OrderDTO --exclude-ctors
rg-build -r "$REPO" -f json cpg flows \
--file src/.../OrderProcessor.java --line 114 --variable order --direction forward
rg-build -r "$REPO" -f json cpg calls OrderProcessor::process
HTTP: POST /api/cpg with the same op + args; stdout/JSON only on success path (stderr diagnostics).
GQL: keep topology pure; add macros or documented virtual ops rather than fake ASSIGNMENT topology edges in v1.
10. Schema / storage
| Artifact | Role | When written |
|---|---|---|
graph.snapshot.bin | L_repo | Always (unchanged role) |
analysis/cfg_pdg.archive.bin | L_proc CFG/PDG (+ optional embedded field writes) | --with-cfg |
analysis/field_write.index.bin | Inverted mutation index | --with-cfg (P1) |
(future) analysis/ast_skeleton… | Coarse AST | P4 opt-in |
Bump archive / index version fields; reject stale with clear CLI errors. Invalidate on graph digest change like other sidecars.
11. Testing strategy
| Layer | Focus |
|---|---|
| Unit | def_use field-access write; type resolve; mutation filter ctors |
| Integration | OrderDTO fixture script (§8) |
| Perf | --with-cfg + field index vs baseline on ecommerce-java; RSS/wall in CI job or manual profile log |
| JSON schema | json-api.md + golden -f json |
12. Docs & agent surface
| Doc | Update |
|---|---|
| AGENTS.md | Hybrid CPG commands table |
| agent-recipes.md | DTO→record ReAct recipe |
| user-guide.md | cpg section; link CFG requirement |
| http-api.md | /api/cpg |
| This plan → later | Split into hybrid-cpg-design.md after P2 ships (screenshots if UI) |
Dashboard: not required for P0–P2 (CLI/HTTP agent-first). Optional later: mutations panel.
13. Risk register
| Risk | Mitigation |
|---|---|
| Type recovery too weak → false “safe” | Require --exclude-unresolved default off for “proof” mode; print unresolved count |
| Index doubles archive I/O | Standalone mmap index; lazy per-type |
| Agents expect Joern CPGQL | Document mapping table; do not pretend Scala DSL |
| Scope creep into unified store | Explicit non-goal; review any PR touching snapshot digest for CPG |
14. Suggested implementation order (engineering)
- P0 façade + docs (unblocks agent vocabulary)
- P1 def/use + Java type recovery + index +
cpg mutations+ fixture - P2
cpg flows+ recipe + E2E script - P1b setter advisory
- P3 tiers as needed by false-negative reports
- P4 export / AST only if demanded
Estimate (rough): P0 small; P1 medium–large (lang-sensitive); P2 small if reuse slice; P3 medium; P4 medium.
15. Language readiness (Layer F)
Hybrid CPG quality is gated by language plugins. Layer F is required for every Tier 1 language (Java sets the bar). Full checklist: tier-1-language-support.md § Layer F.
| Status | Languages |
|---|---|
| Parity shipped | Java, C#, Go, C, C++, TypeScript, JavaScript, Python, Rust — fields/ctors/typed params + field_write golden mutation tests + field_write_locals merge |
Do not put full type checkers in plugins. Bound resolution: decl / param / field / import. Dynamic languages (JS/Python without annotations) may still return more Unresolved receivers — that is expected, not a pass to skip F1–F6 shapes.
16. Exit criteria for “hybrid CPG v1”
- L_repo / L_proc / L_query separation documented and enforced (no field-write edges in snapshot digest)
-
cpg mutations+cpg flowscover OrderDTO-style fixture loop - All Tier 1 languages meet Layer F1–F6 (Java-shaped) on fixtures
- Default discover perf unchanged (L_proc / field-write opt-in via
--with-cfg) - Agent recipe published; AGENTS.md updated
- Honesty limits documented (alias, reflection, unresolved receivers)