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
10 changes: 10 additions & 0 deletions .claude/SCHEMA_DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,13 @@ an unresolved read is `reason: "non-literal"` (key never closes on one literal)
| P3 | **Markers `TSCanNode` / `JSCanNode`** on every `can://<lang>/` 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 |
78 changes: 66 additions & 12 deletions src/dataflow/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -141,18 +141,18 @@ function emitL4(root: TSApplication, pg: ProgramGraphs, info: Map<string, LocalI
for (const [nodeId, n] of li.paramN) c.body[`@formal_in:${n}`] = { kind: "formal_in", of: li.paramName.get(nodeId) };
if (li.exitId >= 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.
Expand All @@ -172,6 +172,7 @@ function emitL4(root: TSApplication, pg: ProgramGraphs, info: Map<string, LocalI
const n = callee.paramN.get(e.target.node);
if (n === undefined) continue;
root.param_in.push({ src: fq(caller.canId, ain), dst: fq(callee.canId, `@formal_in:${n}`) });
bindDefsToActualIn(caller, pg.functions[e.source.signature], e.source.node, L, i);
} else if (callee) {
// global read: rides in at the callee entry, carrying the global path.
root.param_in.push({ src: fq(caller.canId, L), dst: fq(callee.canId, "@entry"), var: e.var });
Expand All @@ -186,6 +187,9 @@ function emitL4(root: TSApplication, pg: ProgramGraphs, info: Map<string, LocalI
const aout = `${L}/actual_out`;
caller.callable.body[aout] = { kind: "actual_out", of: "$ret", parent: L };
root.param_out.push({ src: fq(callee.canId, "@formal_out"), dst: fq(caller.canId, aout) });
// #81: `actual_out → callsite` — the return value flows into the statement that made the
// call; the caller's existing `L → use` edges carry it onward. Python's class exactly.
pushDdg(caller.callable, { src: aout, dst: L, var: "$ret", prov: ["reaching-defs"] });
} else {
// global write: flows back from the callee formal-out to the caller's callsite.
root.param_out.push({ src: fq(callee.canId, "@formal_out"), dst: fq(caller.canId, L), var: e.var });
Expand All @@ -210,15 +214,65 @@ function emitL4(root: TSApplication, pg: ProgramGraphs, info: Map<string, LocalI
}
}

// Determinism: sort the application-scope lists + each callable's summary.
// Determinism: sort the application-scope lists + each callable's summary and ddg (deduped:
// the binding classes above can be reached from more than one sdg edge).
root.param_in.sort(cmpEdgeVar);
root.param_out.sort(cmpEdgeVar);
for (const li of info.values()) {
const s = li.callable.summary as Array<{ src: string; dst: string; var?: string }> | 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
// ----------------------------------------------------------------------------------------------
Expand Down
86 changes: 86 additions & 0 deletions test/l4-port-binding.test.ts
Original file line number Diff line number Diff line change
@@ -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 → <L>/actual_in:0 ; <L>/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 → <L>/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) <L>/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);
}
});
});
Loading