diff --git a/.claude/SCHEMA_DECISIONS.md b/.claude/SCHEMA_DECISIONS.md index 1cbf901..800ffd9 100644 --- a/.claude/SCHEMA_DECISIONS.md +++ b/.claude/SCHEMA_DECISIONS.md @@ -173,3 +173,13 @@ an unresolved read is `reason: "non-literal"` (key never closes on one literal) | P3 | **Markers `TSCanNode` / `JSCanNode`** on every `can:///` id, with a range index on `id` each | index anchors only; anchor label chosen from the id's own namespace | property indexes are label-scoped; `STARTS WITH` seeks only on a range index. Two markers because this analyzer emits two namespaces. `CanNode` stays until #95 | | P4 | **Empty application refused** (`applicationPrefixes` throws) | on any push: the diff itself is app-scoped | `STARTS WITH ''` matches the whole store | | P5 | **`SCHEMA_VERSION` stays 2.0.0** despite a removed property | supersedes #140's "MAJOR bump" goal | #144 / python #186: one version until every analyzer re-baselines together | + +## L4 port lattice ↔ statement ddg (2026-09-06, #81/#80 — python #115 parity) + +| # | Concept | Decision | Rationale | +|---|---|---|---| +| L1 | **Four binding classes**, all `prov: ["reaching-defs"]`: `formal_in:n → first-use`, `def stmt → actual_in:k`, `actual_out → callsite`, `stmt → @formal_out` | emitted in `emitL4` (`src/dataflow/attach.ts`); the last existed already | one class alone made the SDG two disconnected graphs: a walk could cross a call only on the return leg | +| L2 | `formal_in:n → use` is emitted **alongside** the L3 `@entry → use` edge, not instead | a param folds onto `@entry` below L4 (`l3()`); L3 consumers keep that | additive across levels (L3 ⊆ L4 gate); a walk entering via `param_in` no longer dead-ends | +| 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 | diff --git a/src/dataflow/attach.ts b/src/dataflow/attach.ts index 905d4fd..685bb98 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, FunctionGraphs, GraphNode, PdgEdge, ProgramGraphs } from "../schema/graphs"; import type { TSApplication, TSCallable, TSParamEdge } from "../schema"; import { globalOrdinal, stampBodyIds } from "../schema/ids"; @@ -141,18 +141,18 @@ 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). + const ddg = c.ddg as Array<{ src: string; dst: string; var?: string; prov: string[] }>; 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"], - }); - } + if (e.type !== "DDG") continue; + // return/global → EXIT ddg edges re-target @formal_out (syntactic routing; L4-placed vertex). + if (e.target === li.exitId) ddg.push({ src: l3(li, e.source), dst: "@formal_out", var: e.var, prov: ["reaching-defs"] }); + // #81 (python #115 parity): `formal_in:n → first-use` — the same dependence L3 carries as + // `@entry → stmt` (a param folds onto @entry below L4), re-sourced from the port so a walk that + // enters through param_in does not dead-end at formal_in. Both edges stay: L3 keeps @entry. + const n = li.paramN.get(e.source); + if (n !== undefined && e.target !== li.exitId) ddg.push({ src: `@formal_in:${n}`, dst: l3(li, e.target), var: e.var, prov: ["reaching-defs"] }); } - (c.ddg as Array<{ src: string; dst: string; var?: string; prov: string[] }>)?.sort?.(cmpDdg); + ddg.sort(cmpDdg); } // Cross-function SDG edges → param_in/param_out (app) + summary (callable) + actual vertices. @@ -172,6 +172,7 @@ function emitL4(root: TSApplication, pg: ProgramGraphs, info: Map | undefined; if (s) s.sort(cmpEdgeVar); + const d = li.callable.ddg as Array<{ src: string; dst: string; var?: string; prov?: string[] }> | undefined; + if (d) { + const uniq = dedupe(d, (e) => `${e.src}\0${e.dst}\0${e.var ?? ""}\0${(e.prov ?? []).join(",")}`); + d.length = 0; + d.push(...uniq.sort(cmpDdg)); + } } } +type DdgRow = { src: string; dst: string; var?: string; prov: string[] }; +function pushDdg(c: TSCallable, row: DdgRow): void { + ((c.ddg ??= []) as DdgRow[]).push(row); +} + +/** + * #81: `def stmt → actual_in:k` — argument binding at the call site. The intra-callable ddg says + * which variables reach the call statement L (`X → L`, var v); the call site's own argument text + * says which of them feeds argument k. Bound when v's head identifier appears as a whole word in + * `arguments[k]`. A param reaching L binds from its port (`@formal_in:n`), the way l3() would fold + * it onto @entry. Text matching is the ceiling here (no per-argument AST at this stage): a name + * that is both a local and a property (`f(o.v)` with a local `v`) over-binds; a value threaded only + * through a helper call inside the argument still binds, which is the reaching-defs reading. + */ +function bindDefsToActualIn(caller: LocalIds, fg: FunctionGraphs | undefined, callNode: number, L: string, k: number): void { + const args = argumentTextsAt(caller, fg, callNode); + const text = args?.[k]; + if (text === undefined) return; + for (const e of fg?.pdg?.edges ?? []) { + if (e.type !== "DDG" || e.target !== callNode || !e.var) continue; + const head = /^[A-Za-z_$][\w$]*/.exec(e.var)?.[0]; + if (!head || !new RegExp(`(^|[^\\w$])${head.replace(/\$/g, "\\$")}(?![\\w$])`).test(text)) continue; + const n = caller.paramN.get(e.source); + const src = n !== undefined ? `@formal_in:${n}` : caller.stmtLocal.get(e.source); + if (!src) continue; + pushDdg(caller.callable, { src, dst: `${L}/actual_in:${k}`, var: e.var, prov: ["reaching-defs"] }); + } +} + +/** The recorded call site (INTERNAL `call_sites`, still on the callable during this pass) that sits inside CFG node `callNode`. */ +function argumentTextsAt(caller: LocalIds, fg: FunctionGraphs | undefined, callNode: number): string[] | undefined { + const node = fg?.cfg?.nodes.find((x) => x.id === callNode); + const sites = (caller.callable as unknown as { call_sites?: Array<{ start_line: number; start_column: number; end_line: number; end_column: number; arguments: string[] }> }).call_sites ?? []; + if (!node) return undefined; + const inside = sites.filter((s) => + (s.start_line > node.start_line || (s.start_line === node.start_line && s.start_column >= node.start_column)) && + (s.end_line < node.end_line || (s.end_line === node.end_line && s.end_column <= node.end_column))); + // several calls in one statement (`f(g(x))`): the OUTERMOST is the one whose arguments PARAM_IN + // describes for this statement; pick the earliest start, longest span. + inside.sort((a, b) => a.start_line - b.start_line || a.start_column - b.start_column || (b.end_line - a.end_line) || (b.end_column - a.end_column)); + return inside[0]?.arguments; +} + // ---------------------------------------------------------------------------------------------- // entry point // ---------------------------------------------------------------------------------------------- diff --git a/test/l4-port-binding.test.ts b/test/l4-port-binding.test.ts new file mode 100644 index 0000000..5073521 --- /dev/null +++ b/test/l4-port-binding.test.ts @@ -0,0 +1,86 @@ +/** + * #81 / #80 (python #115 parity): the L4 port lattice is wired INTO the statement-level ddg. + * Before this, one of four binding classes existed (`stmt → @formal_out`), so an end-to-end + * flows_to walk could cross a call only on the return leg: a caller's definition never reached + * `actual_in`, `actual_out` never reached a use, and a walk entering through param_in dead-ended + * at `formal_in`. #81's acceptance: on `y = build(x)`, all four classes exist, `reaching-defs` + * tagged, endpoints present in `body`. + */ +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 { forEachCallable } from "../src/schema"; +import type { AnalysisOptions } from "../src/options"; +import type { TSApplication, TSCallable } from "../src/schema"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-l4port-")); +fs.mkdirSync(path.join(dir, "src")); +fs.writeFileSync(path.join(dir, "src", "a.ts"), [ + "export function build(x: number): number {", + " const t = x + 1;", // formal_in:0 → this statement (first use of x) + " return t;", // this statement → @formal_out + "}", + "export function main(): number {", + " const a = 1;", // def a + " const y = build(a);", // def a → /actual_in:0 ; /actual_out → L + " return y;", // L → this statement (existing intra edge) + "}", +].join("\n")); +fs.writeFileSync(path.join(dir, "tsconfig.json"), JSON.stringify({ compilerOptions: { target: "ES2020" }, include: ["src/**/*.ts"] })); +const opts = (analysisLevel: number) => ({ input: dir, appName: "pb", analysisLevel, eager: true, noBuild: true, emit: "json", + graphs: ["cfg", "dfg", "pdg", "sdg"], graphFieldDepth: 3, jobs: 1, skipTests: true, phantoms: true, entrypointRules: null }) as unknown as AnalysisOptions; +const rootOf = (r: { application: unknown }) => (r.application as { application: TSApplication }).application; +type Ddg = { src: string; dst: string; var?: string; prov?: string[] }; +function fn(root: TSApplication, name: string): TSCallable { let out: TSCallable | undefined; for (const m of Object.values(root.symbol_table)) forEachCallable(m, (c) => { if (c.name === name) out = c; }); if (!out) throw new Error(name); return out; } +const kindOf = (c: TSCallable, key: string) => c.body?.[key]?.kind ?? c.body?.[key.replace(/^@/, "")]?.kind; + +describe("L4 port lattice ↔ statement ddg (#81)", () => { + test("all four binding classes exist on y = build(x), reaching-defs tagged, endpoints in body", async () => { + const root = rootOf(await analyze(opts(4))); + const build = fn(root, "build"), main = fn(root, "main"); + const bd = (build.ddg ?? []) as Ddg[], md = (main.ddg ?? []) as Ddg[]; + const rd = (e: Ddg) => (e.prov ?? []).includes("reaching-defs"); + + // (1) formal_in:0 → first-use statement inside the callee + const fi = bd.filter((e) => e.src === "@formal_in:0" && rd(e)); + expect(fi.length).toBeGreaterThan(0); + for (const e of fi) { expect(kindOf(build, e.dst)).toBe("statement"); expect(e.var).toBe("x"); } + // (2) return statement → @formal_out (pre-existing class, still present) + expect(bd.some((e) => e.dst === "@formal_out" && rd(e) && kindOf(build, e.src) === "statement")).toBe(true); + + // the call statement and its ports + const L = Object.keys(main.body ?? {}).find((k) => main.body![k]!.kind === "actual_in")!.split("/")[0]!; + expect(main.body?.[`${L}/actual_in:0`]?.kind).toBe("actual_in"); + expect(main.body?.[`${L}/actual_out`]?.kind).toBe("actual_out"); + // (3) def a → /actual_in:0 — the caller's definition binds to the argument port + const din = md.filter((e) => e.dst === `${L}/actual_in:0` && rd(e)); + expect(din.map((e) => [kindOf(main, e.src), e.var])).toEqual([["statement", "a"]]); + // (4) /actual_out → L — the return value flows into the call statement + expect(md.some((e) => e.src === `${L}/actual_out` && e.dst === L && rd(e))).toBe(true); + // and the pre-existing intra edge carries it on to the use + expect(md.some((e) => e.src === L && e.var === "y")).toBe(true); + + // every endpoint of every ddg edge names a body node + for (const c of [build, main]) for (const e of c.ddg as Ddg[]) { expect(c.body?.[e.src], `${c.name} src ${e.src}`).toBeDefined(); expect(c.body?.[e.dst], `${c.name} dst ${e.dst}`).toBeDefined(); } + }); + + test("a parameter passed straight through binds from its own port: g(x) { return build(x) }", async () => { + fs.writeFileSync(path.join(dir, "src", "b.ts"), "import { build } from './a';\nexport function g(x: number): number { return build(x); }\n"); + const root = rootOf(await analyze(opts(4))); + const g = fn(root, "g"); + const L = Object.keys(g.body ?? {}).find((k) => g.body![k]!.kind === "actual_in")!.split("/")[0]!; + expect((g.ddg as Ddg[]).some((e) => e.src === "@formal_in:0" && e.dst === `${L}/actual_in:0` && e.var === "x")).toBe(true); + fs.rmSync(path.join(dir, "src", "b.ts")); + }); + + test("additive: the L3 ddg is a subset of the L4 ddg (monotonicity holds)", async () => { + const l3 = rootOf(await analyze(opts(3))), l4 = rootOf(await analyze(opts(4))); + for (const name of ["build", "main"]) { + const key = (e: Ddg) => `${e.src}|${e.dst}|${e.var ?? ""}`; + const s4 = new Set((fn(l4, name).ddg as Ddg[]).map(key)); + for (const e of fn(l3, name).ddg as Ddg[]) expect(s4.has(key(e)), `${name}: ${key(e)}`).toBe(true); + } + }); +});