diff --git a/src/dataflow/attach.ts b/src/dataflow/attach.ts index 11f59b0..2b6b280 100644 --- a/src/dataflow/attach.ts +++ b/src/dataflow/attach.ts @@ -17,7 +17,7 @@ * param→contracted out of the L3 CFG; '@formal_in:N' at L4, statement→'line:col'. */ -import type { CfgEdge, GraphNode, PdgEdge, ProgramGraphs } from "../schema/graphs"; +import type { CfgEdge, GraphNode, GraphSelector, PdgEdge, ProgramGraphs } from "../schema/graphs"; import type { TSApplication, TSCallable, TSParamEdge } from "../schema"; interface LocalIds { @@ -80,7 +80,14 @@ function spanOf(n: GraphNode): { start: [number, number]; end: [number, number]; // L3 — body statements + cfg/cdg/ddg on the callable (bare local ids) // ---------------------------------------------------------------------------------------------- -function emitL3(li: LocalIds, nodes: GraphNode[], cfgEdges: CfgEdge[] | undefined, pdgEdges: PdgEdge[] | undefined): void { +function emitL3( + li: LocalIds, + nodes: GraphNode[], + cfgEdges: CfgEdge[] | undefined, + pdgEdges: PdgEdge[] | undefined, + emitCdg: boolean, + emitDdg: boolean, +): void { const c = li.callable; // Grow body with entry/exit + statement nodes (params are contracted out; call nodes from L1 win). for (const n of nodes) { @@ -105,11 +112,11 @@ function emitL3(li: LocalIds, nodes: GraphNode[], cfgEdges: CfgEdge[] | undefine const cdg: Array<{ src: string; dst: string }> = []; const ddg: Array<{ src: string; dst: string; var?: string; prov: string[] }> = []; for (const e of pdgEdges) { - if (e.type === "CDG") { + if (e.type === "CDG" && emitCdg) { const src = l3(li, e.source); const dst = l3(li, e.target); if (src !== dst) cdg.push({ src, dst }); - } else if (e.type === "DDG") { + } else if (e.type === "DDG" && emitDdg) { if (e.target === li.exitId) continue; // formal-out routing → deferred to L4 (→ @formal_out) // prov = the def-use METHOD: `solveDefUse` computes forward may-reaching-definitions over // k-limited access paths with a flow-insensitive copy/field-alias substrate (defuse.ts). It @@ -122,8 +129,8 @@ function emitL3(li: LocalIds, nodes: GraphNode[], cfgEdges: CfgEdge[] | undefine ddg.push({ src: l3(li, e.source), dst: l3(li, e.target), var: e.var, prov: ["reaching-defs"] }); } } - c.cdg = dedupe(cdg, (e) => `${e.src}\0${e.dst}`).sort(cmp2); - c.ddg = dedupe(ddg, (e) => `${e.src}\0${e.dst}\0${e.var ?? ""}`).sort(cmpDdg); + if (emitCdg) c.cdg = dedupe(cdg, (e) => `${e.src}\0${e.dst}`).sort(cmp2); + if (emitDdg) c.ddg = dedupe(ddg, (e) => `${e.src}\0${e.dst}\0${e.var ?? ""}`).sort(cmpDdg); } } @@ -131,8 +138,8 @@ function emitL3(li: LocalIds, nodes: GraphNode[], cfgEdges: CfgEdge[] | undefine // L4 — synthetic vertices + summary (callable) + param_in/param_out (application) // ---------------------------------------------------------------------------------------------- -function emitL4(root: TSApplication, pg: ProgramGraphs, info: Map): void { - // Formal vertices + the deferred formal-out-routing ddg edges, per callable. +function emitL4(root: TSApplication, pg: ProgramGraphs, info: Map, emitDdg: boolean): void { + // Formal vertices + the deferred formal-out-routing DDG edges, per callable. for (const [sig, fg] of Object.entries(pg.functions)) { const li = info.get(sig); if (!li) continue; @@ -140,18 +147,20 @@ function emitL4(root: TSApplication, pg: ProgramGraphs, info: Map= 0) c.body["@formal_out"] = { kind: "formal_out", of: "$ret" }; if (!c.summary) c.summary = []; - // return/global → EXIT ddg edges re-target @formal_out (syntactic routing; L4-placed vertex). - for (const e of fg.pdg?.edges ?? []) { - if (e.type === "DDG" && e.target === li.exitId) { - (c.ddg as Array<{ src: string; dst: string; var?: string; prov: string[] }>).push({ - src: l3(li, e.source), - dst: "@formal_out", - var: e.var, - prov: ["reaching-defs"], - }); + // Return/global → EXIT DDG edges re-target @formal_out (syntactic routing; L4-placed vertex). + if (emitDdg) { + for (const e of fg.pdg?.edges ?? []) { + if (e.type === "DDG" && e.target === li.exitId) { + c.ddg?.push({ + src: l3(li, e.source), + dst: "@formal_out", + var: e.var, + prov: ["reaching-defs"], + }); + } } + c.ddg?.sort(cmpDdg); } - (c.ddg as Array<{ src: string; dst: string; var?: string; prov: string[] }>)?.sort?.(cmpDdg); } // Cross-function SDG edges → param_in/param_out (app) + summary (callable) + actual vertices. @@ -232,8 +241,9 @@ export function applyDataflow( idBySig: Map, callableBySig: Map, level: number, + selectors: readonly GraphSelector[], ): void { - if (level < 3) return; + if (level < 3 || selectors.length === 0) return; const info = new Map(); for (const [sig, fg] of Object.entries(pg.functions)) { @@ -243,13 +253,16 @@ export function applyDataflow( info.set(sig, buildLocalIds(canId, callable, fg.cfg.nodes)); } + const wantCfg = selectors.includes("cfg"); + const wantCdg = selectors.includes("pdg"); + const wantDdg = wantCdg || selectors.includes("dfg"); for (const [sig, fg] of Object.entries(pg.functions)) { const li = info.get(sig); if (!li) continue; - emitL3(li, fg.cfg?.nodes ?? [], fg.cfg?.edges, fg.pdg?.edges); + emitL3(li, fg.cfg?.nodes ?? [], wantCfg ? fg.cfg?.edges : undefined, fg.pdg?.edges, wantCdg, wantDdg); } - if (level >= 4) emitL4(root, pg, info); + if (level >= 4 && selectors.includes("sdg")) emitL4(root, pg, info, wantDdg); } // ---------------------------------------------------------------------------------------------- diff --git a/src/dataflow/index.ts b/src/dataflow/index.ts index 51ded96..546e49e 100644 --- a/src/dataflow/index.ts +++ b/src/dataflow/index.ts @@ -204,21 +204,14 @@ export async function buildProgramGraphs( const { summaries, ddg, sccCount, largest } = await composeWavefront(datas, callSites, extraction.pool, log); log.debug(`program graphs: ${sccCount} SCCs, largest ${largest}`); - // Emission per --graphs selector. - const wantCfg = opts.graphs.includes("cfg"); - const wantPdg = opts.graphs.includes("pdg"); - const wantDfg = opts.graphs.includes("dfg"); - const wantSdg = opts.graphs.includes("sdg"); - + // Build the complete compute IR. Output selectors are applied only when this substrate is + // attached to the analysis tree; DDG, SDG, and body-node identity all depend on CFG nodes. const functions: Record = {}; for (const [sig, data] of [...datas.entries()].sort(([a], [b]) => a.localeCompare(b))) { - const fg: FunctionGraphs = {}; - if (wantCfg) fg.cfg = { nodes: data.nodes, edges: data.edges }; - if (wantPdg || wantDfg) { - const edges: PdgEdge[] = []; - if (wantPdg) edges.push(...data.cdg); - edges.push(...(ddg.get(sig) ?? [])); - fg.pdg = { + const edges: PdgEdge[] = [...data.cdg, ...(ddg.get(sig) ?? [])]; + functions[sig] = { + cfg: { nodes: data.nodes, edges: data.edges }, + pdg: { edges: edges.sort( (a, b) => a.source - b.source || @@ -226,12 +219,11 @@ export async function buildProgramGraphs( a.type.localeCompare(b.type) || (a.var ?? "").localeCompare(b.var ?? ""), ), - }; - } - functions[sig] = fg; + }, + }; } - const sdg_edges = wantSdg ? assembleSdg(datas, callSites, summaries) : []; + const sdg_edges = assembleSdg(datas, callSites, summaries); persistSummaries(opts, symbol_table, callables, summaries, log); diff --git a/src/schema/emit.ts b/src/schema/emit.ts index 6a38883..c36fd6e 100644 --- a/src/schema/emit.ts +++ b/src/schema/emit.ts @@ -135,7 +135,7 @@ export function finalizeAnalysis( // L3/L4 — grow body{} + cfg/cdg/ddg/summary on callables and param_in/param_out on the app. let k_limit: number | undefined; if (level >= 3 && pg) { - applyDataflow(root, pg, idBySig, callableBySig, level); + applyDataflow(root, pg, idBySig, callableBySig, level, opts.graphs); k_limit = pg.k_limit; } diff --git a/src/schema/graphs.ts b/src/schema/graphs.ts index 27e02a8..09f8d96 100644 --- a/src/schema/graphs.ts +++ b/src/schema/graphs.ts @@ -1,7 +1,7 @@ /** - * The level-3 `program_graphs` contract — CFG / PDG (CDG+DDG) / SDG, per the cross-language - * dataflow-graphs spec. Emitted as an optional top-level section of analysis.json, only at - * `-a 3`, scoped by `--graphs`. + * The level-3 `program_graphs` compute IR — complete CFG / PDG (CDG+DDG) / SDG data per the + * cross-language dataflow-graphs spec. `--graphs` is applied only when this IR is attached to the + * analysis tree, so selecting DFG or SDG never removes the CFG node substrate those graphs need. * * Node identity is the invariant that makes everything joinable: every node is keyed by * `(signature, node_id)` where `signature` is the SAME signatureOf() key used by symbol_table diff --git a/test/dataflow.test.ts b/test/dataflow.test.ts index 7e5a686..1cd0040 100644 --- a/test/dataflow.test.ts +++ b/test/dataflow.test.ts @@ -8,14 +8,14 @@ import { describe, expect, test } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { analyze } from "../src/core"; +import { analyze, type AnalysisResult } from "../src/core"; import { backwardSlice } from "../src/dataflow"; import type { AnalysisOptions } from "../src/options"; -import type { CfgEdge, FunctionCfg, ProgramGraphs, SdgEdge } from "../src/schema"; +import { forEachCallable, type CfgEdge, type FunctionCfg, type GraphSelector, type ProgramGraphs, type SdgEdge, type TSCallable } from "../src/schema"; const FIXTURE = path.resolve(import.meta.dir, "fixtures/dataflow-app"); -function options(level: 1 | 2 | 3, cacheDir: string, jobs: number): AnalysisOptions { +function options(level: 1 | 2 | 3, cacheDir: string, jobs: number, graphs: GraphSelector[]): AnalysisOptions { return { input: FIXTURE, output: null, @@ -26,7 +26,7 @@ function options(level: 1 | 2 | 3, cacheDir: string, jobs: number): AnalysisOpti neo4jPassword: "", neo4jDatabase: null, analysisLevel: level, - graphs: ["cfg", "dfg", "pdg", "sdg"], + graphs, graphFieldDepth: 3, jobs, targetFiles: null, @@ -39,16 +39,31 @@ function options(level: 1 | 2 | 3, cacheDir: string, jobs: number): AnalysisOpti }; } -async function run(level: 1 | 2 | 3, jobs = 1): Promise>> { - // returns the full AnalysisResult — the program-graph IR rides on it, not on the tree +async function run( + level: 1 | 2 | 3, + jobs = 1, + graphs: GraphSelector[] = ["cfg", "dfg", "pdg", "sdg"], +): Promise { + // Returns the full AnalysisResult — the program-graph IR rides on it, not on the tree. const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-dataflow-test-")); try { - return await analyze(options(level, cacheDir, jobs)); + return await analyze(options(level, cacheDir, jobs, graphs)); } finally { fs.rmSync(cacheDir, { recursive: true, force: true }); } } +function callableOf(result: AnalysisResult, signature: string): TSCallable { + let found: TSCallable | undefined; + for (const mod of Object.values(result.internal.symbol_table)) { + forEachCallable(mod, (callable) => { + if (callable.signature === signature) found = callable; + }); + } + if (!found) throw new Error(`no callable for ${signature}`); + return found; +} + const pg = (await run(3)).program_graphs as ProgramGraphs; const cfgOf = (sig: string): FunctionCfg => { @@ -386,6 +401,22 @@ describe("determinism and gating", () => { expect(pg.schema_version).toBe("1.0.0"); expect(pg.k_limit).toBe(3); }); + + test("--graphs selects attached fields without deleting compute dependencies", async () => { + const dfg = await run(3, 1, ["dfg"]); + const dfgCallable = callableOf(dfg, "src/flow.sumTo"); + expect(dfg.program_graphs?.functions["src/flow.sumTo"]?.cfg?.nodes.length).toBeGreaterThan(0); + expect(dfgCallable.body["@entry"]).toBeDefined(); + expect(dfgCallable.cfg).toBeUndefined(); + expect(dfgCallable.cdg).toBeUndefined(); + expect(dfgCallable.ddg?.length).toBeGreaterThan(0); + + const cfg = await run(3, 1, ["cfg"]); + const cfgCallable = callableOf(cfg, "src/flow.sumTo"); + expect(cfgCallable.cfg?.length).toBeGreaterThan(0); + expect(cfgCallable.cdg).toBeUndefined(); + expect(cfgCallable.ddg).toBeUndefined(); + }); }); // ------------------------------------------------------------------------------------------------