From 5e72cfe57a63a91861a3b849382345da9784e641 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 14 Jun 2026 12:46:39 +0000 Subject: [PATCH] feat(cfg): PHP CFG visitor + def/use harvest (#2195 U9) Add createPhpCfgVisitor + php-harvest: if/elseif/else (+ alt colon syntax), for/foreach/while/do-while, switch (fallthrough) + match (no fallthrough), try/catch/finally, break N/continue N (N-th enclosing loop), goto, return/throw. Wire into phpProvider. Every literal validated against tree-sitter-php (php_only) via the probe (for_statement initialize/condition/update; throw_expression not throw_statement; break/continue integer child). while(true) keeps EXIT reverse-reachable (production CDG probe: 3 edges; break 2 escapes the outer loop). 35 real-parser tests. Also repoint worker-roundtrip's "non-CFG language" gate test from Python (which now has a cfgVisitor) to COBOL (the permanent non-goal of the rollout) -- a stale assertion the Python commit invalidated. Full in-process sweep green (452 across 18 files). Gaps: match inline value, goto plain-block. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ingestion/cfg/visitors/php-harvest.ts | 662 ++++++++++++++ .../src/core/ingestion/cfg/visitors/php.ts | 837 ++++++++++++++++++ gitnexus/src/core/ingestion/languages/php.ts | 2 + .../integration/cfg/fixtures/php-hazards.php | 142 +++ .../integration/cfg/worker-roundtrip.test.ts | 8 +- gitnexus/test/unit/cfg/php-visitor.test.ts | 414 +++++++++ 6 files changed, 2062 insertions(+), 3 deletions(-) create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/php-harvest.ts create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/php.ts create mode 100644 gitnexus/test/integration/cfg/fixtures/php-hazards.php create mode 100644 gitnexus/test/unit/cfg/php-visitor.test.ts diff --git a/gitnexus/src/core/ingestion/cfg/visitors/php-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/php-harvest.ts new file mode 100644 index 000000000..40782f7ee --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/php-harvest.ts @@ -0,0 +1,662 @@ +/** + * PHP def/use harvester (PDG layer — brace-family CFG, closest to Java/C#). + * + * Runs in the parse worker next to the PHP CFG visitor, extracting per-statement + * variable definition/use facts that ride the side channel for the reaching-defs + * / CDG solvers. Output is the per-function binding table ({@link BindingEntry}[]) + * plus {@link StatementFacts} the visitor attaches to blocks as it walks. The + * call-site substrate ({@link CallSiteFactAccumulator}) is harvested too (it is + * INERT until a PHP source/sink model is registered). + * + * TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the Java / C# harvesters): + * the CFG walk is NOT source-order (`visitFor` builds the init block after the + * body, `visitDoWhile` the condition before the body), so resolving names against + * a scope stack populated *during* the walk would mis-resolve. Phase 1 pre-scans + * the whole function subtree once into a completed lexical scope tree; phase 2 + * resolves defs/uses against that finished tree from any walk order. + * + * PHP-SPECIFIC NOTE — PHP variables are FUNCTION-SCOPED (no block scope): a `$x` + * written inside an `if` body is the SAME variable as one written at the top + * level (unlike Java/C# block scoping). So the harvester declares EVERY assigned/ + * parameter/foreach/catch variable into the single function-root scope; there is + * no per-block shadowing. The grammar carries the leading `$` on `variable_name` + * text (`$x`), which we keep as the binding name (consistent and unambiguous). + * + * Every node type and field literal below was grammar-validated against + * tree-sitter-php (`php_only` export) via the introspection probe before use + * (mandatory pre-step). PHP shapes pre-empted (verified by a real parse): + * - functions: `function_definition`/`method_declaration` (fields + * `name`/`parameters`/`body`), `anonymous_function` (`parameters`/`body` plus + * an `anonymous_function_use_clause` capturing outer vars), `arrow_function` + * (`parameters`/`body`; body is an EXPRESSION). + * - parameters: `simple_parameter` / `variadic_parameter` / + * `property_promotion_parameter`, each with a `name` field (`variable_name` or + * a `by_ref` wrapping one); `simple_parameter` may carry `default_value`. + * - assignment: `assignment_expression` (`left`/`right`), + * `augmented_assignment_expression` (`left`/`operator`/`right`, def+use), + * `update_expression` (`argument`/`operator`, def+use). An lvalue may be a + * `variable_name`, a `list_literal` (`[$a,$b]` / `list($a,$b)` destructure), + * a `member_access_expression` (`$o->p` — a USE of the object, not a scalar + * def), or a `subscript_expression` (`$a[$i]` — same). + * - `foreach_statement`: the iterable + a value `variable_name`, OR a + * `pair` (`$k => $v`) binding both — NO field names (positional children). + * - `catch_clause` (`type`/`name`/`body`): `name` is the exception + * `variable_name`. + * - conditional contexts: `binary_expression` operator `&&`/`||`/`??`, + * `conditional_expression` (`condition`/`body`/`alternative`; short `?:` omits + * `body`), and switch/match case tests. + * + * v1 def-semantics scope: + * - assignment / augmented-assignment / update to a `variable_name` (or to a + * `list_literal` destructure target) — define (and, for augmented/update, use) + * the variable. + * - parameters, the `foreach` value/key variable, catch parameters, and + * `anonymous_function` `use (...)` captures (by-value AND by-ref). + * EXCLUDED, deliberately (TypeScript-CFA precedent, mirrored by Java): property / + * array-element writes (`$o->p = …`, `$a[$i] = …`) are NOT scalar defs — their + * variables are uses only. Nested-function (closure / arrow) bodies are opaque in + * BOTH directions. + * + * MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right + * operand of `&&` / `||` / `??` (`$a && ($x = f())`, `$c ?? ($c = load())`), a + * ternary arm, or a switch/match case test — is a may-def (gen without kill), so + * the not-taken path's prior def is not falsely killed. + * + * Identifiers with no in-function declaration (globals, statics, imported names) + * resolve to a SYNTHETIC module-level binding (`name@module`), applied + * identically by def and use harvesting. + * + * NOTE: nothing serialized here may carry a field named `nodeId` — the durable + * parsedfile-store reviver dedups objects keyed on that field name. + */ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import type { BindingEntry, StatementFacts } from '../types.js'; +import { CallSiteFactAccumulator } from './call-site-harvest.js'; + +/** + * The per-statement def/use + call-site collector, aliased to the shared + * {@link CallSiteFactAccumulator} (one name for the value and the type). + */ +type FactAccumulator = CallSiteFactAccumulator; + +/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */ +const NESTED_FUNCTION_TYPES = new Set([ + 'function_definition', + 'method_declaration', + 'anonymous_function', + 'arrow_function', +]); + +export class PhpHarvester { + private readonly bindings: BindingEntry[] = []; + /** PHP is function-scoped: one flat table, name → binding index. */ + private readonly table = new Map(); + private readonly synthetic = new Map(); + private readonly fnId: number; + /** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */ + private conditionalDepth = 0; + /** + * Call/new node id → bindings whose declarator/assignment VALUE is exactly + * that call. Registered before the value walk, consumed by {@link visitCall} / + * {@link visitNew} (mirrors the Java harvester's `resultDefTargets`). + */ + private readonly resultDefTargets = new Map(); + + constructor(private readonly fnNode: SyntaxNode) { + this.fnId = fnNode.id; + this.declareParams(fnNode); + this.declareUseClause(fnNode); + const body = this.bodyOf(fnNode); + if (body) this.prescan(body); + } + + /** The completed binding table — pass to `CfgBuilder.finish`. */ + bindingTable(): readonly BindingEntry[] { + return this.bindings; + } + + /** The function/closure body node (a `compound_statement`, or an expression). */ + private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined { + return fnNode.childForFieldName('body') ?? undefined; + } + + // ── phase 1: declaration pre-scan ──────────────────────────────────────── + + private declare(name: string, declNode: SyntaxNode, kind: BindingEntry['kind']): void { + if (!name || this.table.has(name)) return; + this.table.set(name, this.bindings.length); + this.bindings.push({ + name, + declLine: declNode.startPosition.row + 1, + declColumn: declNode.startPosition.column, + kind, + }); + } + + /** The `$name` text of a parameter's `name` field (a `variable_name` or `by_ref`). */ + private paramVarName(param: SyntaxNode): SyntaxNode | undefined { + const name = param.childForFieldName('name'); + if (!name) return undefined; + if (name.type === 'by_ref') { + return name.namedChildren.find((c) => c.type === 'variable_name'); + } + return name.type === 'variable_name' ? name : undefined; + } + + private declareParams(fnNode: SyntaxNode): void { + const params = fnNode.childForFieldName('parameters'); + if (!params) return; + for (let i = 0; i < params.namedChildCount; i++) { + const p = params.namedChild(i); + if (!p) continue; + if ( + p.type !== 'simple_parameter' && + p.type !== 'variadic_parameter' && + p.type !== 'property_promotion_parameter' + ) { + continue; + } + const varName = this.paramVarName(p); + if (varName) this.declare(varName.text, varName, 'param'); + } + } + + /** `anonymous_function ... use ($a, &$b)` — each captured var binds in the closure. */ + private declareUseClause(fnNode: SyntaxNode): void { + if (fnNode.type !== 'anonymous_function') return; + const clause = fnNode.namedChildren.find((c) => c.type === 'anonymous_function_use_clause'); + if (!clause) return; + for (const v of this.useClauseVars(clause)) this.declare(v.text, v, 'param'); + } + + /** The captured `variable_name`s of a `use (...)` clause (unwrapping `by_ref`). */ + private useClauseVars(clause: SyntaxNode): SyntaxNode[] { + const out: SyntaxNode[] = []; + for (let i = 0; i < clause.namedChildCount; i++) { + const c = clause.namedChild(i); + if (!c) continue; + if (c.type === 'variable_name') out.push(c); + else if (c.type === 'by_ref') { + const inner = c.namedChildren.find((x) => x.type === 'variable_name'); + if (inner) out.push(inner); + } + } + return out; + } + + /** + * Walk the function body once, declaring every assigned / foreach / catch + * variable into the FLAT function scope (PHP has no block scoping). Nested + * function/closure bodies are NOT descended (opaque). + */ + private prescan(node: SyntaxNode): void { + const t = node.type; + if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return; + + switch (t) { + case 'assignment_expression': { + const left = node.childForFieldName('left'); + if (left) this.declareLvalue(left); + break; + } + case 'augmented_assignment_expression': { + const left = node.childForFieldName('left'); + if (left && left.type === 'variable_name') this.declare(left.text, left, 'var'); + break; + } + case 'update_expression': { + const arg = node.childForFieldName('argument'); + if (arg && arg.type === 'variable_name') this.declare(arg.text, arg, 'var'); + break; + } + case 'foreach_statement': { + for (const v of this.foreachTargets(node)) this.declare(v.text, v, 'var'); + break; + } + case 'catch_clause': { + const name = node.childForFieldName('name'); + if (name && name.type === 'variable_name') this.declare(name.text, name, 'catch'); + break; + } + default: + break; + } + + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (c) this.prescan(c); + } + } + + /** + * Declare the variable(s) named by an assignment lvalue: a plain + * `variable_name`, or a `list_literal` destructure (`[$a,$b]` / `list($a,$b)`, + * possibly keyed `["x" => $e]`). Member / subscript targets bind nothing. + */ + private declareLvalue(left: SyntaxNode): void { + if (left.type === 'variable_name') { + this.declare(left.text, left, 'var'); + } else if (left.type === 'list_literal') { + for (const v of this.listTargets(left)) this.declare(v.text, v, 'var'); + } + } + + /** Every `variable_name` bound by a `list_literal` (including keyed entries). */ + private listTargets(list: SyntaxNode): SyntaxNode[] { + const out: SyntaxNode[] = []; + const walk = (n: SyntaxNode): void => { + if (n.type === 'variable_name') { + out.push(n); + return; + } + // Keyed (`"x" => $e`) entries and nested lists descend; non-variable keys + // (the string/int key) are not lvalues and carry no `variable_name`. + for (let i = 0; i < n.namedChildCount; i++) { + const c = n.namedChild(i); + if (c) walk(c); + } + }; + for (let i = 0; i < list.namedChildCount; i++) { + const c = list.namedChild(i); + if (c) walk(c); + } + return out; + } + + /** + * The bound variable(s) of a `foreach ($it as [$k =>] $v)`: the value (and key) + * `variable_name`s. The structure is positional — the FIRST named child is the + * iterable, then either a bare `variable_name` (value) or a `pair` ($k => $v). + */ + private foreachTargets(stmt: SyntaxNode): SyntaxNode[] { + const out: SyntaxNode[] = []; + // Skip the iterable (first named child); collect value / pair targets after. + for (let i = 1; i < stmt.namedChildCount; i++) { + const c = stmt.namedChild(i); + if (!c) continue; + if (c.type === 'variable_name') out.push(c); + else if (c.type === 'pair') { + for (let j = 0; j < c.namedChildCount; j++) { + const v = c.namedChild(j); + if (v?.type === 'variable_name') out.push(v); + } + } + // `body` (compound_statement / colon_block) is not a target — it has its + // own non-variable_name/non-pair type, so it is skipped here. + } + return out; + } + + // ── phase 2: per-statement fact extraction ─────────────────────────────── + + /** Def/use facts for one statement (or construct-header expression) node. */ + facts(node: SyntaxNode): StatementFacts { + const acc = new FactAccumulator(node.startPosition.row + 1); + this.walkValue(node, acc); + return acc.finish(); + } + + /** Facts for an expression whose WHOLE evaluation is conditional (case tests). */ + factsConditional(node: SyntaxNode): StatementFacts { + const acc = new FactAccumulator(node.startPosition.row + 1); + this.conditional(() => this.walkValue(node, acc)); + return acc.finish(); + } + + /** Facts for a `foreach ($it as [$k =>] $v)` head: targets bind, iterable used. */ + foreachHeadFacts(stmt: SyntaxNode): StatementFacts { + const acc = new FactAccumulator(stmt.startPosition.row + 1); + const iterable = stmt.namedChild(0); + if (iterable) this.walkValue(iterable, acc); + for (const v of this.foreachTargets(stmt)) this.def(v, acc); + return acc.finish(); + } + + /** ENTRY-block facts for the function's parameters (defs only). */ + paramFacts(): StatementFacts | undefined { + const acc = new FactAccumulator(this.fnNode.startPosition.row + 1); + const params = this.fnNode.childForFieldName('parameters'); + if (params) { + for (let i = 0; i < params.namedChildCount; i++) { + const p = params.namedChild(i); + if (!p) continue; + if ( + p.type !== 'simple_parameter' && + p.type !== 'variadic_parameter' && + p.type !== 'property_promotion_parameter' + ) { + continue; + } + const varName = this.paramVarName(p); + if (varName) this.def(varName, acc); + } + } + // A closure's `use (...)` captures are live on entry too — model as defs. + if (this.fnNode.type === 'anonymous_function') { + const clause = this.fnNode.namedChildren.find( + (c) => c.type === 'anonymous_function_use_clause', + ); + if (clause) for (const v of this.useClauseVars(clause)) this.def(v, acc); + } + return acc.defCount() ? acc.finish() : undefined; + } + + /** Def fact for a `catch (T $e)` parameter — prepend to the handler entry block. */ + catchParamFacts(catchClause: SyntaxNode): StatementFacts | undefined { + const name = catchClause.childForFieldName('name'); + if (!name || name.type !== 'variable_name') return undefined; + const acc = new FactAccumulator(catchClause.startPosition.row + 1); + this.def(name, acc); + return acc.defCount() ? acc.finish() : undefined; + } + + private resolve(nameNode: SyntaxNode): number { + const name = nameNode.text; + const idx = this.table.get(name); + if (idx !== undefined) return idx; + let syn = this.synthetic.get(name); + if (syn === undefined) { + syn = this.bindings.length; + this.synthetic.set(name, syn); + this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true }); + } + return syn; + } + + private def(nameNode: SyntaxNode, acc: FactAccumulator): void { + if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode)); + else acc.addDef(this.resolve(nameNode)); + } + + private use(nameNode: SyntaxNode, acc: FactAccumulator): void { + acc.addUse(this.resolve(nameNode)); + } + + /** Run `fn` with defs demoted to may-defs (conditionally-evaluated context). */ + private conditional(fn: () => void): void { + this.conditionalDepth++; + try { + fn(); + } finally { + this.conditionalDepth--; + } + } + + /** Strip parenthesized wrappers around an lvalue (`($x) = 1`). */ + private unwrapParen(node: SyntaxNode): SyntaxNode { + let n = node; + let hops = 8; + while (n.type === 'parenthesized_expression' && hops-- > 0) { + const inner = n.namedChildren.find((c) => c.type !== 'comment'); + if (!inner) break; + n = inner; + } + return n; + } + + /** Value-position walk: collect uses; route def positions to the lvalue handler. */ + private walkValue(node: SyntaxNode, acc: FactAccumulator): void { + const t = node.type; + if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) { + // Opaque nested function / closure — captured reads/writes are invisible. + return; + } + + switch (t) { + case 'variable_name': + this.use(node, acc); + return; + case 'assignment_expression': { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (left) { + const lv = this.unwrapParen(left); + if (lv.type === 'variable_name') { + const snap = acc.defSnapshot(); + this.def(lv, acc); + if (right) this.registerResultDefs(right, acc.defsSince(snap)); + } else if (lv.type === 'list_literal') { + // Destructure: every target binds; non-variable keys are uses. + for (const v of this.listTargets(lv)) this.def(v, acc); + } else { + this.walkValue(lv, acc); // member / subscript target — uses only + } + } + if (right) this.walkValue(right, acc); + return; + } + case 'augmented_assignment_expression': { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (left) { + const lv = this.unwrapParen(left); + if (lv.type === 'variable_name') { + this.def(lv, acc); + this.use(lv, acc); // compound assign reads too + } else { + this.walkValue(lv, acc); + } + } + if (right) this.walkValue(right, acc); + return; + } + case 'update_expression': { + const arg = node.childForFieldName('argument'); + const lv = arg ? this.unwrapParen(arg) : null; + if (lv?.type === 'variable_name') { + this.def(lv, acc); + this.use(lv, acc); + } else if (arg) { + this.walkValue(arg, acc); + } + return; + } + case 'binary_expression': { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + const op = node.childForFieldName('operator')?.text ?? ''; + if (left) this.walkValue(left, acc); + if (right) { + if (op === '&&' || op === '||' || op === '??' || op === 'and' || op === 'or') { + this.conditional(() => this.walkValue(right, acc)); + } else { + this.walkValue(right, acc); + } + } + return; + } + case 'conditional_expression': { + const cond = node.childForFieldName('condition'); + const body = node.childForFieldName('body'); + const alt = node.childForFieldName('alternative'); + if (cond) this.walkValue(cond, acc); + if (body) this.conditional(() => this.walkValue(body, acc)); + if (alt) this.conditional(() => this.walkValue(alt, acc)); + return; + } + case 'function_call_expression': + this.visitCall(node, acc, 'function'); + return; + case 'member_call_expression': + case 'nullsafe_member_call_expression': + this.visitCall(node, acc, 'member'); + return; + case 'scoped_call_expression': + this.visitCall(node, acc, 'scoped'); + return; + case 'object_creation_expression': + this.visitNew(node, acc); + return; + case 'member_access_expression': + case 'nullsafe_member_access_expression': { + // `$o->p` — value read of the object root only (the property name is not + // a scalar binding); record the innermost identifier-rooted member read. + this.walkChain(node, acc); + return; + } + default: + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (c) this.walkValue(c, acc); + } + } + } + + // ── taint-site harvest ─────────────────────────────────────────────────── + + /** + * When `value`'s root (after stripping parens) is a call / object-creation + * node, remember its site should carry `resultDefs: defs`. + */ + private registerResultDefs(value: SyntaxNode, defs: readonly number[]): void { + if (defs.length === 0) return; + const root = this.unwrapParen(value); + if ( + root.type === 'function_call_expression' || + root.type === 'member_call_expression' || + root.type === 'nullsafe_member_call_expression' || + root.type === 'scoped_call_expression' || + root.type === 'object_creation_expression' + ) { + this.resultDefTargets.set(root.id, [...defs]); + } + } + + /** + * Call-site handler for the three PHP call shapes: + * - `function`: `function_call_expression` (`function` field = name, no receiver) + * - `member`: `member_call_expression` (`object` receiver, `name` method) + * - `scoped`: `scoped_call_expression` (`scope` class, `name` method) + * Reproduces the same uses the default descent recorded plus the call site. + */ + private visitCall( + node: SyntaxNode, + acc: FactAccumulator, + shape: 'function' | 'member' | 'scoped', + ): void { + const argsNode = node.childForFieldName('arguments'); + const siteIdx = acc.openCallSite('call'); + acc.pushFrame(siteIdx); + + if (shape === 'function') { + const fnNode = node.childForFieldName('function'); + if (fnNode) { + if (fnNode.type === 'name' || fnNode.type === 'qualified_name') { + acc.setSiteCallee(siteIdx, fnNode.text); + } else { + // dynamic callee (`$fn()`, `($obj->cb)()`) — record uses, no static path + this.walkValue(fnNode, acc); + } + } + } else if (shape === 'member') { + const objectNode = node.childForFieldName('object'); + const nameNode = node.childForFieldName('name'); + let receiverPath: string | undefined; + if (objectNode) { + const chain = this.walkChain(objectNode, acc); + receiverPath = chain.path; + if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx); + } + if (nameNode && nameNode.type === 'name') { + const callee = + receiverPath !== undefined ? `${receiverPath}.${nameNode.text}` : nameNode.text; + acc.setSiteCallee(siteIdx, callee); + } + } else { + // scoped: `C::method(...)` — scope is a class name (not a binding). + const scopeNode = node.childForFieldName('scope'); + const nameNode = node.childForFieldName('name'); + const scopeText = + scopeNode && (scopeNode.type === 'name' || scopeNode.type === 'qualified_name') + ? scopeNode.text + : undefined; + if (nameNode && nameNode.type === 'name') { + const callee = scopeText !== undefined ? `${scopeText}.${nameNode.text}` : nameNode.text; + acc.setSiteCallee(siteIdx, callee); + } + } + + const resultDefs = this.resultDefTargets.get(node.id); + if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs); + this.walkArgs(argsNode, acc); + acc.popFrame(); + } + + /** Explicit `object_creation_expression` (`new Foo($x)`) handler. */ + private visitNew(node: SyntaxNode, acc: FactAccumulator): void { + const argsNode = node.childForFieldName('arguments'); + const siteIdx = acc.openCallSite('new'); + acc.pushFrame(siteIdx); + // The class name is the first `name`/`qualified_name` child (not a binding). + const className = node.namedChildren.find( + (c) => c.type === 'name' || c.type === 'qualified_name', + ); + if (className) acc.setSiteCallee(siteIdx, className.text.replace(/\s+/g, '')); + const resultDefs = this.resultDefTargets.get(node.id); + if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs); + this.walkArgs(argsNode, acc); + acc.popFrame(); + } + + /** Walk an `arguments` node, tagging each positional `argument` for occurrences. */ + private walkArgs(argsNode: SyntaxNode | null, acc: FactAccumulator): void { + if (!argsNode) return; + let pos = 0; + for (let i = 0; i < argsNode.namedChildCount; i++) { + const arg = argsNode.namedChild(i); + if (!arg || arg.type === 'comment') continue; + if (arg.type !== 'argument') { + // A spread (`...$xs`) or other non-`argument` child — still walk for uses. + this.walkValue(arg, acc); + continue; + } + acc.setFrameArg(pos); + this.walkValue(arg, acc); + pos++; + } + } + + /** + * Member-access chain walk shared by value position and a method-call receiver. + * Records the chain-root `variable_name` as a use plus at most ONE member-read + * site — the innermost access — when the root is a variable. + */ + private walkChain(node: SyntaxNode, acc: FactAccumulator): { path?: string; rootIdx?: number } { + const accesses: string[] = []; + let cur: SyntaxNode = this.unwrapParen(node); + for (;;) { + if (cur.type === 'member_access_expression' || cur.type === 'nullsafe_member_access_expression') { + const field = cur.childForFieldName('name'); + accesses.unshift(field?.text ?? ''); + const obj = cur.childForFieldName('object'); + if (!obj) break; + cur = this.unwrapParen(obj); + } else { + break; + } + } + let rootIdx: number | undefined; + let rootSegment: string | undefined; + if (cur.type === 'variable_name') { + rootIdx = this.resolve(cur); + acc.addUse(rootIdx); + rootSegment = cur.text; + } else { + this.walkValue(cur, acc); + } + const innermost = accesses[0]; + if (rootIdx !== undefined && innermost) acc.addMemberRead(rootIdx, innermost); + const path = + rootSegment !== undefined && accesses.every((a) => a !== '') + ? [rootSegment, ...accesses].join('.') + : undefined; + return { path, rootIdx }; + } +} + +/** + * Ordered, deduplicating def/use + call-site collector for one statement record. + * The shared {@link CallSiteFactAccumulator} carries the def/use machinery plus + * the taint-site harvest. + */ +const FactAccumulator = CallSiteFactAccumulator; diff --git a/gitnexus/src/core/ingestion/cfg/visitors/php.ts b/gitnexus/src/core/ingestion/cfg/visitors/php.ts new file mode 100644 index 000000000..c506464e8 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/php.ts @@ -0,0 +1,837 @@ +/** + * PHP CfgVisitor (PDG layer — brace-family, closest to Java/C#). + * + * Walks a PHP function / method / closure / arrow-function tree-sitter AST and + * drives the language-agnostic {@link CfgBuilder} to produce a serializable + * {@link FunctionCfg}, plus a def/use harvest ({@link PhpHarvester}) for the + * reaching-defs / CDG solvers. Structured like the Java / C# visitors — a + * `visit_` dispatch over the statement taxonomy, driving a + * per-function {@link ControlFlowContext} — because PHP shares their `finally` + * semantics (try/catch/finally) and C-style switch FALLTHROUGH. + * + * Every node type and field literal below was grammar-validated against + * tree-sitter-php (`php_only` export) via the introspection probe before use + * (mandatory pre-step). PHP shapes pre-empted (verified by a real parse): + * - functions: `function_definition` / `method_declaration` (fields + * `name`/`parameters`/`body`, body a `compound_statement`), + * `anonymous_function` (`parameters`/`body` + `anonymous_function_use_clause`), + * `arrow_function` (`parameters`/`body`; body is an EXPRESSION). + * - `if_statement` field `condition` (a `parenthesized_expression`), `body`, and + * zero-or-more `alternative` fields, each an `else_if_clause` + * (`condition`/`body`) or a trailing `else_clause` (`body`). PHP has no nested- + * `if` else chain — `elseif` is its own clause. The ALTERNATIVE colon syntax + * (`if … : … elseif … : … else: … endif;`) parses to the SAME node types with + * a `colon_block` body instead of `compound_statement`, so reading the `body` + * field handles both uniformly. + * - `for_statement` fields `initialize` / `condition` / `update` / `body` (NOT + * `init`/`incr`); `foreach_statement` field `body` plus POSITIONAL children: + * the iterable `variable_name`, then a value `variable_name` OR a `pair` + * (`$k => $v`); `while_statement` (`condition`/`body`); `do_statement` + * (`body`/`condition`). + * - `switch_statement` (`condition`/`body` = `switch_block`); the block holds + * `case_statement` (field `value`, body statements are siblings — FALLS + * THROUGH) and `default_statement`. `match_expression` (`condition`/`body` = + * `match_block`) is a value-position expression with NO fallthrough. + * - `try_statement` field `body`; `catch_clause` (`type`/`name`/`body`), + * `finally_clause` (`body`). + * - `return_statement`; `break_statement` / `continue_statement` carry an + * optional `integer` child (`break 2;` targets the 2nd enclosing loop/switch); + * `throw` is a `throw_expression` wrapped in an `expression_statement` (there + * is NO `throw_statement` node); `goto_statement` + `named_label_statement`. + * + * Edge-kind contract (matches the existing visitors — RD/CDG consume these): + * - if/elseif/else → `cond-true` / `cond-false` + * - loops (for / foreach / while / do-while) → `cond-true` / `loop-back` / + * `cond-false` + * - switch → `switch-case` / `fallthrough` (a `case` with no `break`/`return` + * falls through to the next case); `match` is left INLINE as a value + * (no fallthrough — see the limitations). + * - try/catch → `throw` (every protected-region block → the handler); a + * `finally` runs on normal AND exception exit, so a `return`/`break`/`continue` + * crossing it gets a `finally-*` completion edge. + * - return / throw / break / continue → the matching terminator kind; `break N` + * / `continue N` target the N-th enclosing loop/switch (not the nearest). + * - straight-line → `seq` + * + * Classic hazards, handled explicitly (mirrors the Java / TS visitors): + * - loops allocate a dedicated loop-exit block so `break` has a target before + * the loop's successor is known; `continue` targets the header / update. + * - `for (;;) {}` / `while (true) {}` still emit the structural `header → + * loopExit` `cond-false` escape edge so EXIT stays reverse-reachable from + * every block — the post-dominator / CDG pass silently emits zero CDG for the + * function otherwise. + * - `break N` / `continue N`: each loop/switch frame is pushed with a UNIQUE + * synthetic label, and an N-level jump resolves against the label of the N-th + * enclosing loop/switch frame — reusing the existing finalizer-threading + * machinery so a jump that crosses a `finally` still threads through it. + * - try/catch: conservative exceptional flow — EVERY block in the protected + * region edges to the handler (an exception may fire mid-block). + * + * Known limitations: + * - `match` is a value-position EXPRESSION (`$r = match($x) { … }`), kept INLINE + * inside its owning statement's block — its arms are not modeled as separate + * CFG blocks (the value flows to the assignment). Documented gap, mirroring the + * Java inline-value-switch handling. + * - context-manager-style suppression and PHP's exception-from-mid-call outside + * any `try` are not modeled (no edge), matching the other visitors. + * - `goto` / named labels are modeled as straight-line blocks (the label is a + * plain block; a `goto` does NOT create a jump edge — PHP `goto` is rare and + * intra-function only; an over-approximation here would harm precision more + * than the missing edge). Documented gap. + * - Def/use harvest scope: see `php-harvest.ts` — property / array-element + * writes are not scalar defs; nested-function (closure / arrow) bodies are + * opaque in both directions. + * + * Returns `undefined` (never throws) for an AST shape it cannot model, so a + * malformed function never drops the whole file's CFG group (R4). + */ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { CfgBuilder } from '../cfg-builder.js'; +import { + ControlFlowContext, + drainFinalizerPending, + wireJumpThroughFinalizers, +} from '../control-flow-context.js'; +import type { TraversalResult } from '../traversal-result.js'; +import type { CfgVisitor, FunctionCfg } from '../types.js'; +import { PhpHarvester } from './php-harvest.js'; + +/** PHP node types that own a CFG-bearing function body. */ +const PHP_FUNCTION_TYPES = new Set([ + 'function_definition', + 'method_declaration', + 'anonymous_function', + 'arrow_function', +]); + +/** Statement node types that break a basic block (everything else coalesces). */ +const CONTROL_FLOW_TYPES = new Set([ + 'if_statement', + 'for_statement', + 'foreach_statement', + 'while_statement', + 'do_statement', + 'switch_statement', + 'try_statement', + 'return_statement', + 'break_statement', + 'continue_statement', + 'goto_statement', + 'named_label_statement', + 'compound_statement', +]); + +const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1; +const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1; + +const isComment = (n: SyntaxNode): boolean => n.type === 'comment'; + +/** A statement sequence that produced no blocks (empty body) is "transparent". */ +type SeqResult = TraversalResult | null; + +/** + * Per-function PHP walk state. One instance per function so the + * {@link ControlFlowContext}, exception-handler stack, and the `break N` / + * `continue N` synthetic-label bookkeeping are scoped to that function and never + * leak across functions. + */ +class PhpCfgWalk { + private readonly cfc = new ControlFlowContext(); + /** Stack of exception-handler entry blocks (catch / finally) a `throw` jumps to. */ + private readonly handlers: number[] = []; + /** + * Synthetic labels of the active loop/switch frames, innermost LAST — so the + * N-th enclosing frame's label is `loopLabels[length - N]`. PHP's `break N` / + * `continue N` resolve against these (no source labels exist). + */ + private readonly loopLabels: string[] = []; + private labelSeq = 0; + + constructor( + private readonly builder: CfgBuilder, + private readonly harvest: PhpHarvester, + ) {} + + /** Statements of a body node, ignoring comments. */ + private statementsOf(block: SyntaxNode): SyntaxNode[] { + return block.namedChildren.filter((c) => !isComment(c)); + } + + /** The `body` block of a node (a `compound_statement` / `colon_block` / stmt). */ + private bodyBlockOf(node: SyntaxNode): SyntaxNode | undefined { + return node.childForFieldName('body') ?? undefined; + } + + /** Strip a `parenthesized_expression` wrapper (PHP `if`/`while` conditions). */ + private unwrapParen(node: SyntaxNode): SyntaxNode { + if (node.type === 'parenthesized_expression') { + const inner = node.namedChildren.find((c) => !isComment(c)); + if (inner) return inner; + } + return node; + } + + /** Visit a body that may be a block-ish container or a single statement. */ + private visitBody(node: SyntaxNode | undefined | null): SeqResult { + if (!node) return null; + if (node.type === 'compound_statement' || node.type === 'colon_block') { + return this.visitSeq(this.statementsOf(node)); + } + return this.visitStmt(node); + } + + /** Wire a sequence of statements, coalescing straight-line runs into blocks. */ + visitSeq(stmts: SyntaxNode[]): SeqResult { + let entry: number | undefined; + let dangling: number[] = []; + let openSimple: number | undefined; + + for (const stmt of stmts) { + // An `expression_statement` wrapping a bare `throw_expression` is a + // terminator (PHP has no `throw_statement` node), so it breaks the block. + const breaks = CONTROL_FLOW_TYPES.has(stmt.type) || this.isThrowStatement(stmt); + if (breaks) { + openSimple = undefined; // close any open straight-line block + const res = this.visitStmt(stmt); + if (res === null) continue; // transparent (empty nested block) + if (entry === undefined) entry = res.entry; + else this.builder.connect(dangling, res.entry, 'seq'); + dangling = [...res.exits]; + } else { + if (openSimple === undefined) { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + if (entry === undefined) entry = idx; + else this.builder.connect(dangling, idx, 'seq'); + openSimple = idx; + dangling = [idx]; + } else { + this.builder.extendBlock(openSimple, endLineOf(stmt), stmt.text, this.harvest.facts(stmt)); + } + } + } + + if (entry === undefined) return null; + return { entry, exits: dangling }; + } + + /** Dispatch one statement to its handler. Non-null except for empty blocks. */ + visitStmt(stmt: SyntaxNode): SeqResult { + if (this.isThrowStatement(stmt)) return this.visitThrow(stmt); + switch (stmt.type) { + case 'if_statement': + return this.visitIf(stmt); + case 'for_statement': + return this.visitFor(stmt); + case 'foreach_statement': + return this.visitForEach(stmt); + case 'while_statement': + return this.visitWhile(stmt); + case 'do_statement': + return this.visitDoWhile(stmt); + case 'switch_statement': + return this.visitSwitch(stmt); + case 'try_statement': + return this.visitTry(stmt); + case 'return_statement': + return this.visitReturn(stmt); + case 'break_statement': + return this.visitBreak(stmt); + case 'continue_statement': + return this.visitContinue(stmt); + case 'compound_statement': + case 'colon_block': + return this.visitSeq(this.statementsOf(stmt)); + case 'goto_statement': + case 'named_label_statement': + // `goto` / labels are modeled as straight-line blocks (no jump edge — see + // the visitor limitations); they still carry their text + facts. + return this.visitSimple(stmt); + default: + return this.visitSimple(stmt); + } + } + + /** True for an `expression_statement` whose value is a bare `throw_expression`. */ + private isThrowStatement(stmt: SyntaxNode): boolean { + if (stmt.type !== 'expression_statement') return false; + const inner = stmt.namedChildren.find((c) => !isComment(c)); + return inner?.type === 'throw_expression'; + } + + private visitSimple(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + return { entry: idx, exits: [idx] }; + } + + private visitReturn(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + // A return crosses EVERY active finally before EXIT. + wireJumpThroughFinalizers( + this.builder, + idx, + this.cfc.finalizersForReturn(), + this.builder.exitIndex, + 'return', + ); + return { entry: idx, exits: [] }; + } + + private visitThrow(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + this.builder.edge(idx, this.currentHandler(), 'throw'); + return { entry: idx, exits: [] }; + } + + private visitBreak(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + const label = this.jumpLabel(stmt); + const res = this.cfc.resolveBreak(label); + const { target, finalizers } = res ?? { + target: this.builder.exitIndex, + finalizers: this.cfc.finalizersForReturn(), + }; + wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'break'); + return { entry: idx, exits: [] }; + } + + private visitContinue(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + const label = this.jumpLabel(stmt); + const res = this.cfc.resolveContinue(label); + const { target, finalizers } = res ?? { + target: this.builder.exitIndex, + finalizers: this.cfc.finalizersForReturn(), + }; + wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'continue'); + return { entry: idx, exits: [] }; + } + + /** + * Resolve a `break N` / `continue N` to the SYNTHETIC label of the N-th + * enclosing loop/switch frame (innermost = 1). Returns undefined for a bare + * `break`/`continue` (no level), so the context resolves the nearest frame as + * usual. PHP counts BOTH loop AND switch frames for `break N` and `continue N` + * (a `switch` acts like a loop level for `continue`), which is exactly the set + * pushed onto {@link loopLabels} here — so one count serves both. + */ + private jumpLabel(stmt: SyntaxNode): string | undefined { + const level = this.jumpLevel(stmt); + if (level <= 1) return undefined; // bare break/continue → nearest frame + const n = this.loopLabels.length; + if (level > n) return undefined; // over-deep level → conservative fallback + return this.loopLabels[n - level]; + } + + /** The integer level of a `break N;` / `continue N;` (default 1). */ + private jumpLevel(stmt: SyntaxNode): number { + const intNode = stmt.namedChildren.find((c) => c.type === 'integer'); + if (!intNode) return 1; + const v = parseInt(intNode.text, 10); + return Number.isFinite(v) && v >= 1 ? v : 1; + } + + /** Push a fresh synthetic loop/switch label and return it. */ + private nextLabel(): string { + const label = `__php_lvl_${this.labelSeq++}`; + return label; + } + + /** + * `if cond: … elseif cond: … else: …`. PHP has NO nested-if else chain: the + * `if_statement` carries the condition + body plus zero-or-more `alternative` + * fields, each an `else_if_clause` (its own condition + body) or a trailing + * `else_clause`. The elif chain is threaded on the `cond-false` edge. Handles + * both brace bodies and the colon (`endif`) syntax uniformly (body field). + */ + private visitIf(stmt: SyntaxNode): TraversalResult { + const cond = this.condOf(stmt) ?? stmt; + const header = this.builder.newBlock( + startLineOf(stmt), + endLineOf(cond), + cond.text, + 'normal', + this.harvest.facts(cond), + ); + + const exits: number[] = []; + const thenRes = this.visitBody(stmt.childForFieldName('body')); + if (thenRes) { + this.builder.edge(header, thenRes.entry, 'cond-true'); + exits.push(...thenRes.exits); + } else { + exits.push(header); // empty then — true path falls through + } + + const alternatives = this.alternativesOf(stmt); + let falseFrom = header; + for (const alt of alternatives) { + if (alt.type === 'else_if_clause') { + const elifCondRaw = alt.childForFieldName('condition'); + const elifCond = elifCondRaw ? this.unwrapParen(elifCondRaw) : alt; + const elifHeader = this.builder.newBlock( + startLineOf(alt), + endLineOf(elifCond), + elifCond.text, + 'normal', + this.harvest.facts(elifCond), + ); + this.builder.edge(falseFrom, elifHeader, 'cond-false'); + const elifRes = this.visitBody(alt.childForFieldName('body')); + if (elifRes) { + this.builder.edge(elifHeader, elifRes.entry, 'cond-true'); + exits.push(...elifRes.exits); + } else { + exits.push(elifHeader); + } + falseFrom = elifHeader; + } else if (alt.type === 'else_clause') { + const elseRes = this.visitBody(alt.childForFieldName('body')); + if (elseRes) { + this.builder.edge(falseFrom, elseRes.entry, 'cond-false'); + exits.push(...elseRes.exits); + } else { + exits.push(falseFrom); + } + falseFrom = -1; // an else consumes the false path entirely + } + } + if (falseFrom >= 0) exits.push(falseFrom); // no trailing else → fall through + + return { entry: header, exits: [...new Set(exits)] }; + } + + /** The `alternative`-field children of an `if_statement`, in source order. */ + private alternativesOf(stmt: SyntaxNode): SyntaxNode[] { + const out: SyntaxNode[] = []; + for (let i = 0; i < stmt.childCount; i++) { + if (stmt.fieldNameForChild(i) === 'alternative') { + const c = stmt.child(i); + if (c) out.push(c); + } + } + return out; + } + + /** The (paren-unwrapped) condition expression of an if/while/do/switch. */ + private condOf(stmt: SyntaxNode): SyntaxNode | undefined { + const cond = stmt.childForFieldName('condition'); + return cond ? this.unwrapParen(cond) : undefined; + } + + private visitWhile(stmt: SyntaxNode): TraversalResult { + const label = this.nextLabel(); + const cond = this.condOf(stmt) ?? stmt; + const header = this.builder.newBlock( + startLineOf(stmt), + endLineOf(cond), + cond.text, + 'normal', + this.harvest.facts(cond), + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.loopLabels.push(label); + this.cfc.pushLoop(header, loopExit, [label]); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.cfc.pop(); + this.loopLabels.pop(); + + if (body) { + this.builder.edge(header, body.entry, 'cond-true'); + this.builder.connect(body.exits, header, 'loop-back'); + } else { + this.builder.edge(header, header, 'loop-back'); // empty body re-tests + } + // Always emit the structural exit edge — even `while (true)` keeps EXIT + // reverse-reachable for the post-dominator / CDG pass. + this.builder.edge(header, loopExit, 'cond-false'); + return { entry: header, exits: [loopExit] }; + } + + private visitDoWhile(stmt: SyntaxNode): TraversalResult { + const label = this.nextLabel(); + const cond = this.condOf(stmt) ?? stmt; + const condBlock = this.builder.newBlock( + startLineOf(cond), + endLineOf(cond), + cond.text, + 'normal', + this.harvest.facts(cond), + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.loopLabels.push(label); + this.cfc.pushLoop(condBlock, loopExit, [label]); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.cfc.pop(); + this.loopLabels.pop(); + + const backTarget = body ? body.entry : condBlock; + if (body) this.builder.connect(body.exits, condBlock, 'seq'); + this.builder.edge(condBlock, backTarget, 'loop-back'); // cond true → run body again + this.builder.edge(condBlock, loopExit, 'cond-false'); + return { entry: backTarget, exits: [loopExit] }; + } + + private visitFor(stmt: SyntaxNode): TraversalResult { + const label = this.nextLabel(); + const init = stmt.childForFieldName('initialize'); + const cond = stmt.childForFieldName('condition'); + const incr = stmt.childForFieldName('update'); + + const header = this.builder.newBlock( + startLineOf(stmt), + cond ? endLineOf(cond) : startLineOf(stmt), + cond ? cond.text : 'for(;;)', + 'normal', + cond ? this.harvest.facts(cond) : undefined, + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + let incrBlock = header; + if (incr) { + incrBlock = this.builder.newBlock( + startLineOf(incr), + endLineOf(incr), + incr.text, + 'normal', + this.harvest.facts(incr), + ); + this.builder.edge(incrBlock, header, 'loop-back'); + } + + this.loopLabels.push(label); + this.cfc.pushLoop(incrBlock, loopExit, [label]); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.cfc.pop(); + this.loopLabels.pop(); + + if (body) { + this.builder.edge(header, body.entry, 'cond-true'); + this.builder.connect(body.exits, incrBlock, incr ? 'seq' : 'loop-back'); + } else { + this.builder.edge(header, incrBlock, 'cond-true'); + if (!incr) this.builder.edge(header, header, 'loop-back'); + } + // Structural exit edge — `for (;;) {}` (no condition) still keeps EXIT + // reverse-reachable so CDG is not silently skipped for the function. + this.builder.edge(header, loopExit, 'cond-false'); + + let entry = header; + if (init) { + const initBlock = this.builder.newBlock( + startLineOf(init), + endLineOf(init), + init.text, + 'normal', + this.harvest.facts(init), + ); + this.builder.edge(initBlock, header, 'seq'); + entry = initBlock; + } + return { entry, exits: [loopExit] }; + } + + private visitForEach(stmt: SyntaxNode): TraversalResult { + const label = this.nextLabel(); + // Header text is SYNTHESIZED, so facts come from the iterable (use) + the + // loop target variable(s) (def) directly. + const header = this.builder.newBlock( + startLineOf(stmt), + startLineOf(stmt), + this.forEachHeaderText(stmt), + 'normal', + this.harvest.foreachHeadFacts(stmt), + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.loopLabels.push(label); + this.cfc.pushLoop(header, loopExit, [label]); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.cfc.pop(); + this.loopLabels.pop(); + + if (body) { + this.builder.edge(header, body.entry, 'cond-true'); + this.builder.connect(body.exits, header, 'loop-back'); + } else { + this.builder.edge(header, header, 'loop-back'); + } + this.builder.edge(header, loopExit, 'cond-false'); + return { entry: header, exits: [loopExit] }; + } + + private forEachHeaderText(stmt: SyntaxNode): string { + const first = stmt.namedChild(0); + return first ? `foreach(${first.text} as …)` : 'foreach(… as …)'; + } + + private visitSwitch(stmt: SyntaxNode): TraversalResult { + const label = this.nextLabel(); + const value = this.condOf(stmt) ?? stmt; + const dispatch = this.builder.newBlock( + startLineOf(stmt), + endLineOf(value), + value.text, + 'normal', + this.harvest.facts(value), + ); + const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.loopLabels.push(label); + this.cfc.pushSwitch(switchExit, [label]); + + const body = stmt.childForFieldName('body'); + // A `switch_block` holds `case_statement`s (field `value`, fall through) and a + // `default_statement`. + const groups = body + ? body.namedChildren.filter( + (c) => c.type === 'case_statement' || c.type === 'default_statement', + ) + : []; + + // Each case-test expression evaluates before its body runs — harvest its uses + // onto the dispatch block, CONDITIONALLY (a later case test only runs when + // earlier cases didn't match). + for (const g of groups) { + const test = this.caseTest(g); + if (test) this.builder.attachFacts(dispatch, this.harvest.factsConditional(test)); + } + + const groupResults = groups.map((g) => this.visitSeq(this.caseStatements(g))); + const hasDefault = groups.some((g) => g.type === 'default_statement'); + + const entryOf: number[] = new Array(groups.length); + let after = switchExit; + for (let i = groups.length - 1; i >= 0; i--) { + entryOf[i] = groupResults[i]?.entry ?? after; + after = entryOf[i]; + } + + for (let i = 0; i < groups.length; i++) { + this.builder.edge(dispatch, entryOf[i], 'switch-case'); + } + if (!hasDefault) this.builder.edge(dispatch, switchExit, 'switch-case'); // no-match path + + // C-style FALLTHROUGH: a case with no break/return falls through to the next. + for (let i = 0; i < groups.length; i++) { + const res = groupResults[i]; + if (!res) continue; + const fallTarget = i + 1 < groups.length ? entryOf[i + 1] : switchExit; + this.builder.connect(res.exits, fallTarget, 'fallthrough'); + } + + this.cfc.pop(); + this.loopLabels.pop(); + return { entry: dispatch, exits: [switchExit] }; + } + + /** A switch group's body statements (everything but its case-test value). */ + private caseStatements(group: SyntaxNode): SyntaxNode[] { + const value = group.childForFieldName('value'); + return group.namedChildren.filter((c) => c.id !== value?.id && !isComment(c)); + } + + /** The case-test value expression of a `case_statement` (default has none). */ + private caseTest(group: SyntaxNode): SyntaxNode | undefined { + return group.childForFieldName('value') ?? undefined; + } + + /** + * try / catch / finally. A `finally` runs on BOTH normal and exception exit — + * a `return`/`break`/`continue` crossing it threads through it (`finally-*` + * completion edges). + */ + private visitTry(stmt: SyntaxNode): SeqResult { + const bodyNode = stmt.childForFieldName('body'); + const catchClauses: SyntaxNode[] = []; + let finallyClause: SyntaxNode | undefined; + for (let i = 0; i < stmt.namedChildCount; i++) { + const c = stmt.namedChild(i); + if (c?.type === 'catch_clause') catchClauses.push(c); + else if (c?.type === 'finally_clause') finallyClause = c; + } + const finallyBody = finallyClause?.childForFieldName('body'); + + return this.buildProtected(bodyNode ?? null, catchClauses, finallyBody ?? null); + } + + /** + * Shared try/catch/finally builder (mirrors the Java visitor). `catchClauses` + * may be empty; `finallyBody` is the explicit finally's body (or null). + * + * Normal completion of try AND catch flows through the finally; a throw in the + * protected region routes to the handler; early exits crossing the finally + * thread through it (`finally-*` completion edges). + */ + private buildProtected( + bodyNode: SyntaxNode | null, + catchClauses: SyntaxNode[], + finallyBody: SyntaxNode | null, + ): SeqResult { + const finallyRes = finallyBody ? this.visitBody(finallyBody) : null; + const finFrame = finallyRes ? this.cfc.pushFinalizer(finallyRes.entry) : null; + const finalizerEntry = finallyRes?.entry; + + // Build each catch handler. + const catchEntries: number[] = []; + const catchExits: number[] = []; + let firstCatchEntry: number | undefined; + for (const clause of catchClauses) { + const clauseBody = clause.childForFieldName('body'); + if (finalizerEntry !== undefined) this.handlers.push(finalizerEntry); + let res: SeqResult = clauseBody ? this.visitBody(clauseBody) : null; + if (finalizerEntry !== undefined) this.handlers.pop(); + if (res === null) { + // Empty `catch {}` still catches — synthesize one block so exception flow + // lands somewhere and the post-try code stays reachable. + const idx = this.builder.newBlock(startLineOf(clause), endLineOf(clause), ''); + res = { entry: idx, exits: [idx] }; + } + const paramFacts = this.harvest.catchParamFacts(clause); + if (paramFacts) { + const paramBlock = this.builder.newBlock( + startLineOf(clause), + startLineOf(clause), + '', + 'normal', + paramFacts, + ); + this.builder.edge(paramBlock, res.entry, 'seq'); + res = { entry: paramBlock, exits: res.exits }; + } + catchEntries.push(res.entry); + catchExits.push(...res.exits); + if (firstCatchEntry === undefined) firstCatchEntry = res.entry; + } + + // Handler for the try body: first catch if present, else the finally, else + // the outer handler. + const tryHandler = firstCatchEntry ?? finalizerEntry ?? this.currentHandler(); + const protectedStart = this.builder.blockCount; + this.handlers.push(tryHandler); + const bodyRes = bodyNode ? this.visitBody(bodyNode) : null; + this.handlers.pop(); + + if (catchClauses.length > 0 || finalizerEntry !== undefined) { + for (let b = protectedStart; b < this.builder.blockCount; b++) { + this.builder.edge(b, tryHandler, 'throw'); + } + } + + // Pop the finalizer frame and drain its pending crossing-jump legs. + if (finFrame && finallyRes) { + this.cfc.pop(); + drainFinalizerPending(this.builder, finFrame, finallyRes.exits); + } + + const exits: number[] = []; + if (finalizerEntry !== undefined && finallyRes) { + if (bodyRes) this.builder.connect(bodyRes.exits, finalizerEntry, 'seq'); + for (const e of catchExits) this.builder.edge(e, finalizerEntry, 'seq'); + exits.push(...finallyRes.exits); + // No catch → an exception re-propagates out after the finally runs. + if (catchClauses.length === 0) { + this.builder.connect(finallyRes.exits, this.currentHandler(), 'throw'); + } + } else { + if (bodyRes) exits.push(...bodyRes.exits); + exits.push(...catchExits); + } + + const entry = bodyRes?.entry ?? finalizerEntry ?? catchEntries[0]; + if (entry === undefined) return null; + return { entry, exits: [...new Set(exits)] }; + } + + /** Nearest enclosing exception handler, or the function EXIT. */ + private currentHandler(): number { + return this.handlers.length ? this.handlers[this.handlers.length - 1] : this.builder.exitIndex; + } +} + +/** Build the CFG for one PHP function node, or `undefined` if not modelable. */ +function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined { + try { + if (!PHP_FUNCTION_TYPES.has(fnNode.type)) return undefined; + const startLine = startLineOf(fnNode); + const endLine = endLineOf(fnNode); + const startColumn = fnNode.startPosition.column; + + const body = fnNode.childForFieldName('body'); + if (!body) return undefined; // abstract / interface method — no body + + const builder = new CfgBuilder(filePath, startLine, endLine, startColumn); + const harvest = new PhpHarvester(fnNode); + + const paramFacts = harvest.paramFacts(); + if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts); + + if (fnNode.type === 'arrow_function' || body.type !== 'compound_statement') { + // `fn($x) => expr` — the body is an EXPRESSION (no block): one block whose + // value is returned. + const blk = builder.newBlock( + startLineOf(body), + endLineOf(body), + body.text, + 'normal', + harvest.facts(body), + ); + builder.edge(builder.entryIndex, blk, 'seq'); + builder.edge(blk, builder.exitIndex, 'return'); + return builder.finish(harvest.bindingTable()); + } + + const walk = new PhpCfgWalk(builder, harvest); + const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment')); + if (!res) { + builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body + return builder.finish(harvest.bindingTable()); + } + builder.edge(builder.entryIndex, res.entry, 'seq'); + builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT + return builder.finish(harvest.bindingTable()); + } catch (err) { + // Never throw out of buildFunctionCfg — a malformed AST shape must skip only + // this one function's CFG, never drop the whole file's language group (R4). + // eslint-disable-next-line no-console + console.warn(`[cfg] PHP buildFunctionCfg skipped a function in ${filePath}: ${String(err)}`); + return undefined; + } +} + +/** Whether a node is a PHP function this visitor builds a CFG for. */ +function isFunction(node: SyntaxNode): boolean { + return PHP_FUNCTION_TYPES.has(node.type); +} + +/** The PHP CFG visitor. */ +export function createPhpCfgVisitor(): CfgVisitor { + return { buildFunctionCfg, isFunction }; +} + +export { PHP_FUNCTION_TYPES }; diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index 9b3aabec6..c58db55ad 100644 --- a/gitnexus/src/core/ingestion/languages/php.ts +++ b/gitnexus/src/core/ingestion/languages/php.ts @@ -20,6 +20,7 @@ import { import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; import { phpClassConfig } from '../class-extractors/configs/php.js'; +import { createPhpCfgVisitor } from '../cfg/visitors/php.js'; import { defineLanguage, type AstFrameworkPatternConfig } from '../language-provider.js'; import { typeConfig as phpConfig } from '../type-extractors/php.js'; import { phpExportChecker } from '../export-detection.js'; @@ -297,6 +298,7 @@ export const phpProvider = defineLanguage({ builtInNames: BUILT_INS, // ── RFC #909 Ring 3: scope-based resolution hooks ────────────────────── emitScopeCaptures: emitPhpScopeCaptures, + cfgVisitor: createPhpCfgVisitor(), interpretImport: interpretPhpImport, interpretTypeBinding: interpretPhpTypeBinding, // LanguageProvider uses (def, callsite); phpArityCompatibility uses (def, callsite) — same. diff --git a/gitnexus/test/integration/cfg/fixtures/php-hazards.php b/gitnexus/test/integration/cfg/fixtures/php-hazards.php new file mode 100644 index 000000000..cf1868792 --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/php-hazards.php @@ -0,0 +1,142 @@ + 0) { + positive(); + } elseif ($x < 0) { + negative(); + } else { + zero(); + } + after(); +} + +function loops(array $arr, int $n): void +{ + for ($i = 0; $i < $n; $i++) { + forBody(); + } + foreach ($arr as $v) { + eachValue($v); + } + foreach ($arr as $k => $v) { + eachPair($k, $v); + } + while ($n > 0) { + whileBody(); + $n--; + } + do { + doBody(); + } while ($n < 10); +} + +function switchFallthrough(int $x): string +{ + switch ($x) { + case 1: + one(); + break; + case 2: + two(); + // falls through (no break) + case 3: + three(); + break; + default: + other(); + } + return done(); +} + +function matchValue(int $x): string +{ + $r = match ($x) { + 1, 2 => "low", + 3 => "mid", + default => "high", + }; + return $r; +} + +function tryCatchFinally(): void +{ + try { + risky(); + } catch (\TypeError | \ValueError $e) { + handleTyped($e); + } catch (\Exception $ex) { + handleOther($ex); + } finally { + cleanup(); + } + afterTry(); +} + +function breakTwo(): void +{ + while (true) { + for ($i = 0; ; $i++) { + if (cond()) { + break 2; + } + if (other()) { + continue 2; + } + innerBody(); + } + unreachableAfterInner(); + } + afterLoops(); +} + +function infiniteLoop(int $x): void +{ + while (true) { + if ($x) { + tick(); + } + } +} + +function returnThroughFinally(int $x): int +{ + try { + if ($x > 0) { + return earlyReturn(); + } + body(); + } finally { + releaseLock(); + } + return fallReturn(); +} + +function defsAndUses(array $data): int +{ + $x = $data; + $y = transform($x); + [$a, $b] = split($y); + list($c, $d) = pair($a); + return $b + $c + $d; +} + +function closureCapture(int $b): callable +{ + $c = make(); + return function (int $a) use ($b, &$c) { + return $a + $b + $c; + }; +} + +function arrowFn(int $n): callable +{ + return fn(int $a) => $a * $n; +} diff --git a/gitnexus/test/integration/cfg/worker-roundtrip.test.ts b/gitnexus/test/integration/cfg/worker-roundtrip.test.ts index e61112ee0..981a5b9ff 100644 --- a/gitnexus/test/integration/cfg/worker-roundtrip.test.ts +++ b/gitnexus/test/integration/cfg/worker-roundtrip.test.ts @@ -27,15 +27,17 @@ const tsVisitor = (): CfgVisitor => { return v; }; -describe('U3 — TS/JS provider exposes a cfgVisitor; others do not (worker gate)', () => { +describe('CFG provider gate — a cfgVisitor enables the worker CFG path', () => { it('TS and JS providers carry a cfgVisitor', () => { expect(getProvider(SupportedLanguages.TypeScript).cfgVisitor).toBeDefined(); expect(getProvider(SupportedLanguages.JavaScript).cfgVisitor).toBeDefined(); }); - it('a non-CFG language (Python) has no cfgVisitor ⇒ worker emits no cfgSideChannel', () => { + it('a non-CFG language (COBOL) has no cfgVisitor ⇒ worker emits no cfgSideChannel', () => { // `provider.cfgVisitor &&` short-circuits in the worker → no CFG, no field. - expect(getProvider(SupportedLanguages.Python).cfgVisitor).toBeUndefined(); + // COBOL is the deliberate non-goal of the PDG-language rollout (#2195) — + // every other supported language now carries a cfgVisitor. + expect(getProvider(SupportedLanguages.Cobol).cfgVisitor).toBeUndefined(); }); }); diff --git a/gitnexus/test/unit/cfg/php-visitor.test.ts b/gitnexus/test/unit/cfg/php-visitor.test.ts new file mode 100644 index 000000000..933725446 --- /dev/null +++ b/gitnexus/test/unit/cfg/php-visitor.test.ts @@ -0,0 +1,414 @@ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; +import { createPhpCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/php.js'; +import type { FunctionCfg, SiteRecord } from '../../../src/core/ingestion/cfg/types.js'; +import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js'; + +// The PHP CfgVisitor, one hazard per test (real-parser regression, NOT +// snapshot-pinning). Each fixture's distinctive statement text (a(), step(), +// handle($e), …) lets us locate the block for a region by text and assert the +// control-flow topology around it. tree-sitter-php's runtime grammar is the +// `php_only` export (matching parser-loader.ts). + +const phpGrammar = (createRequire(import.meta.url)('tree-sitter-php') as { php_only: unknown }) + .php_only as Parameters[0]; + +const php: CfgHarness = makeCfgHarness(phpGrammar, createPhpCfgVisitor(), 'fixture.php'); + +const wrap = (body: string): string => ` { + const b = cfg.blocks.find((bl) => bl.text.includes(substr)); + if (!b) throw new Error(`no block containing ${JSON.stringify(substr)}`); + return b.index; +}; + +const edgeKinds = (cfg: FunctionCfg): Set => new Set(cfg.edges.map((e) => e.kind)); + +function reaches(cfg: FunctionCfg, from: number, to: number): boolean { + const adj = new Map(); + for (const e of cfg.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to); + const seen = new Set([from]); + const stack = [from]; + while (stack.length) { + const n = stack.pop() as number; + if (n === to) return true; + for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), stack.push(nx)); + } + return seen.has(to); +} +const reachable = (cfg: FunctionCfg, idx: number): boolean => reaches(cfg, cfg.entryIndex, idx); + +/** Is EXIT reverse-reachable from every reachable block? (CDG soundness gate.) */ +function exitReachableFromAll(cfg: FunctionCfg): boolean { + for (const b of cfg.blocks) { + if (b.index === cfg.exitIndex) continue; + if (!reachable(cfg, b.index)) continue; // unreachable blocks exempt + if (!reaches(cfg, b.index, cfg.exitIndex)) return false; + } + return true; +} + +/** Resolve a binding by name → its index in the function's binding table. */ +function bindingIdx(cfg: FunctionCfg, name: string): number { + const i = (cfg.bindings ?? []).findIndex((b) => b.name === name); + if (i < 0) throw new Error(`no binding ${name}`); + return i; +} + +const hasDef = (cfg: FunctionCfg, idx: number): boolean => + cfg.blocks.some((bl) => bl.statements?.some((s) => s.defs.includes(idx))); +const hasUse = (cfg: FunctionCfg, idx: number): boolean => + cfg.blocks.some((bl) => bl.statements?.some((s) => s.uses.includes(idx))); +const hasMayDef = (cfg: FunctionCfg, idx: number): boolean => + cfg.blocks.some((bl) => bl.statements?.some((s) => (s.mayDefs ?? []).includes(idx))); + +/** Every taint `SiteRecord` harvested across the function's statements. */ +function allSites(cfg: FunctionCfg): SiteRecord[] { + const out: SiteRecord[] = []; + for (const b of cfg.blocks) for (const s of b.statements ?? []) out.push(...(s.sites ?? [])); + return out; +} + +describe('PHP CfgVisitor — structure', () => { + it('straight-line body: ENTRY → block → EXIT (seq)', () => { + const cfg = php.cfgOf(` b.kind === 'normal')).toHaveLength(1); + const body = block(cfg, 'a();'); + expect(cfg.edges).toContainEqual({ from: cfg.entryIndex, to: body, kind: 'seq' }); + expect(reaches(cfg, body, cfg.exitIndex)).toBe(true); + }); + + it('empty body: ENTRY → EXIT', () => { + const cfg = php.cfgOf(` { + const cfgs = php.cfgsOf( + ` $c * 2;`, + ); + // method m, the closure, and the arrow = 3 CFGs. + expect(cfgs.length).toBeGreaterThanOrEqual(3); + for (const cfg of cfgs) expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); + + it('arrow function: one block returns its expression value', () => { + const cfgs = php.cfgsOf(` $z * 2;`); + const arrow = cfgs.find((c) => c.blocks.some((b) => b.text === '$z * 2')); + expect(arrow).toBeDefined(); + const body = arrow!.blocks.find((b) => b.text === '$z * 2')!.index; + expect(arrow!.edges).toContainEqual({ from: body, to: arrow!.exitIndex, kind: 'return' }); + }); + + it('abstract method (no body) → graceful undefined, no throw', () => { + const root = php.parse(` createPhpCfgVisitor().buildFunctionCfg(fn, 'x.php')).not.toThrow(); + } + }); +}); + +describe('PHP CfgVisitor — branching', () => { + it('if / elseif / else → cond-true & cond-false; join reachable', () => { + const cfg = php.cfgOf( + wrap(`if ($x > 0) { pos(); } elseif ($x < 0) { neg(); } else { zero(); } after();`), + ); + const kinds = edgeKinds(cfg); + expect(kinds.has('cond-true')).toBe(true); + expect(kinds.has('cond-false')).toBe(true); + const after = block(cfg, 'after();'); + expect(reaches(cfg, block(cfg, 'pos();'), after)).toBe(true); + expect(reaches(cfg, block(cfg, 'neg();'), after)).toBe(true); + expect(reaches(cfg, block(cfg, 'zero();'), after)).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); + + it('alternative colon if/elseif/else (endif) is modeled like the brace form', () => { + const cfg = php.cfgOf( + wrap(`if ($x): pos(); elseif ($x): mid(); else: zero(); endif; after();`), + ); + const kinds = edgeKinds(cfg); + expect(kinds.has('cond-true')).toBe(true); + expect(kinds.has('cond-false')).toBe(true); + const after = block(cfg, 'after();'); + expect(reaches(cfg, block(cfg, 'pos();'), after)).toBe(true); + expect(reaches(cfg, block(cfg, 'zero();'), after)).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); +}); + +describe('PHP CfgVisitor — loops', () => { + it('for: cond-true / loop-back / cond-false; body loops back', () => { + const cfg = php.cfgOf(wrap(`for ($i = 0; $i < $x; $i++) { body(); } after();`)); + const kinds = edgeKinds(cfg); + expect(kinds.has('cond-true')).toBe(true); + expect(kinds.has('loop-back')).toBe(true); + expect(kinds.has('cond-false')).toBe(true); + expect(reaches(cfg, block(cfg, 'body();'), block(cfg, 'after();'))).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); + + it('foreach ($it as $v): loops with cond-true / loop-back / cond-false', () => { + const cfg = php.cfgOf(wrap(`foreach ($x as $v) { use1($v); } after();`)); + const kinds = edgeKinds(cfg); + expect(kinds.has('cond-true')).toBe(true); + expect(kinds.has('loop-back')).toBe(true); + expect(kinds.has('cond-false')).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); + + it('while: cond-true / loop-back / cond-false', () => { + const cfg = php.cfgOf(wrap(`while ($x > 0) { tick(); } after();`)); + const kinds = edgeKinds(cfg); + expect(kinds.has('cond-true')).toBe(true); + expect(kinds.has('loop-back')).toBe(true); + expect(kinds.has('cond-false')).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); + + it('do-while: body runs before the test (loop-back from the condition)', () => { + const cfg = php.cfgOf(wrap(`do { tick(); } while ($x < 3); after();`)); + const kinds = edgeKinds(cfg); + expect(kinds.has('loop-back')).toBe(true); + expect(kinds.has('cond-false')).toBe(true); + // The body is the loop entry — control reaches it before the condition. + const body = block(cfg, 'tick();'); + expect(reachable(cfg, body)).toBe(true); + expect(reaches(cfg, body, block(cfg, 'after();'))).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); + + it('while (true) {} keeps EXIT reverse-reachable (structural cond-false escape)', () => { + const cfg = php.cfgOf(wrap(`while (true) { if ($x) { g(); } }`)); + // The cond-false escape edge must exist so the post-dominator/CDG pass runs. + expect(edgeKinds(cfg).has('cond-false')).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); + + it('for (;;) {} (no condition) keeps EXIT reverse-reachable', () => { + const cfg = php.cfgOf(wrap(`for (;;) { step(); }`)); + expect(edgeKinds(cfg).has('cond-false')).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); +}); + +describe('PHP CfgVisitor — switch / match', () => { + it('switch: C-style FALLTHROUGH (a case with no break flows to the next)', () => { + const cfg = php.cfgOf( + wrap(`switch ($x) { case 1: a(); break; case 2: b(); case 3: c(); break; default: d(); } e();`), + ); + const kinds = edgeKinds(cfg); + expect(kinds.has('switch-case')).toBe(true); + expect(kinds.has('fallthrough')).toBe(true); + // case 2 (no break) falls through to case 3. + const c2 = block(cfg, 'b();'); + const c3 = block(cfg, 'c();'); + expect(cfg.edges).toContainEqual({ from: c2, to: c3, kind: 'fallthrough' }); + // case 1's break skips case 2's body, but the switch join is reachable. + expect(reaches(cfg, block(cfg, 'a();'), block(cfg, 'e();'))).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); + + it('switch without break: no switch-case edge is mislabeled fallthrough at the dispatch', () => { + const cfg = php.cfgOf(wrap(`switch ($x) { case 1: a(); } after();`)); + // No default → the no-match path reaches the join directly. + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + expect(reaches(cfg, block(cfg, 'a();'), block(cfg, 'after();'))).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); + + it('match is a value expression (no fallthrough), kept inline — value flows to the assign', () => { + const cfg = php.cfgOf(wrap(`$r = match ($x) { 1, 2 => "low", default => "high" }; return $r;`)); + // match arms are NOT separate dispatch blocks (documented inline-value gap). + expect(edgeKinds(cfg).has('fallthrough')).toBe(false); + expect(edgeKinds(cfg).has('switch-case')).toBe(false); + // The match value and the return both reach EXIT. + expect(reaches(cfg, block(cfg, 'match ($x)'), cfg.exitIndex)).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); +}); + +describe('PHP CfgVisitor — try / catch / finally', () => { + it('try/catch/finally: throw edges to the handler; normal flow crosses finally', () => { + const cfg = php.cfgOf( + wrap(`try { risky(); } catch (\\E $e) { handle($e); } finally { cleanup(); } after();`), + ); + expect(edgeKinds(cfg).has('throw')).toBe(true); + // Body and catch both reach the finally, then after(). + const fin = block(cfg, 'cleanup();'); + expect(reaches(cfg, block(cfg, 'risky();'), fin)).toBe(true); + expect(reaches(cfg, block(cfg, 'handle($e)'), fin)).toBe(true); + expect(reaches(cfg, fin, block(cfg, 'after();'))).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); + + it('multi-catch type list (TypeError | ValueError) catches and reaches the join', () => { + const cfg = php.cfgOf( + wrap(`try { risky(); } catch (\\TypeError | \\ValueError $e) { handle($e); } after();`), + ); + expect(edgeKinds(cfg).has('throw')).toBe(true); + expect(reaches(cfg, block(cfg, 'handle($e)'), block(cfg, 'after();'))).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); + + it('return inside try threads through finally (finally-return completion edge)', () => { + const cfg = php.cfgOf( + wrap(`try { if ($x) { return early(); } body(); } finally { release(); } return tail();`), + ); + expect(edgeKinds(cfg).has('finally-return')).toBe(true); + // The early return's first leg goes to the finally entry, not straight to EXIT. + const ret = block(cfg, 'return early()'); + const fin = block(cfg, 'release();'); + expect(reaches(cfg, ret, fin)).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); +}); + +describe('PHP CfgVisitor — break N / continue N', () => { + it('break 2 targets the 2nd enclosing loop (escapes both)', () => { + const cfg = php.cfgOf( + ` { + const cfg = php.cfgOf( + ` { + const cfg = php.cfgOf(wrap(`while ($x) { if ($x) { break; } step(); } after();`)); + expect(edgeKinds(cfg).has('break')).toBe(true); + expect(reaches(cfg, block(cfg, 'break;'), block(cfg, 'after();'))).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); +}); + +describe('PHP CfgVisitor — def/use harvest', () => { + it('$x = $a defines $x and uses $a', () => { + const cfg = php.cfgOf(` { + const cfg = php.cfgOf(` $v) defines both $k and $v', () => { + const cfg = php.cfgOf(` $v) { use1($k, $v); } }`); + expect(hasDef(cfg, bindingIdx(cfg, '$k'))).toBe(true); + expect(hasDef(cfg, bindingIdx(cfg, '$v'))).toBe(true); + expect(hasUse(cfg, bindingIdx(cfg, '$it'))).toBe(true); + }); + + it('catch (T $e) defines the exception variable', () => { + const cfg = php.cfgOf(` { + const cfg = php.cfgOf(` { + const cfg = php.cfgOf(`p = v) is NOT a scalar def — $o is a use only', () => { + // $o is a local (not a param) so the only def site that could exist is the + // member-write itself — which must NOT count as a scalar def. + const cfg = php.cfgOf(`prop = compute(); }`); + expect(hasUse(cfg, bindingIdx(cfg, '$o'))).toBe(true); + // The member-write defines no scalar binding for `prop` — it never appears + // as a binding name in the table. + expect((cfg.bindings ?? []).some((b) => b.name === 'prop' || b.name === '$prop')).toBe(false); + }); + + it('closure use ($b, &$c) captures bind in the closure body', () => { + const cfgs = php.cfgsOf(` (c.bindings ?? []).some((bd) => bd.name === '$a')); + expect(closure).toBeDefined(); + expect((closure!.bindings ?? []).some((bd) => bd.name === '$b')).toBe(true); + expect((closure!.bindings ?? []).some((bd) => bd.name === '$c')).toBe(true); + }); +}); + +describe('PHP CfgVisitor — taint-site substrate', () => { + it('records a call site with a callee path for a function call', () => { + const cfg = php.cfgOf(` s.kind === 'call' && s.callee === 'exec')).toBe(true); + }); + + it('records a member-call receiver + callee (db->query)', () => { + const cfg = php.cfgOf(`query($req); }`); + const sites = allSites(cfg); + const call = sites.find((s) => s.kind === 'call' && (s.callee ?? '').endsWith('query')); + expect(call).toBeDefined(); + expect(call!.receiver).toBe(bindingIdx(cfg, '$db')); + }); + + it('nested sanitizer call exec(escape($req)) is via-tagged for interposition', () => { + const cfg = php.cfgOf(` s.callee === 'exec')).toBe(true); + expect(sites.some((s) => s.callee === 'escape')).toBe(true); + }); +}); + +describe('PHP CfgVisitor — robustness', () => { + it('unmodeled / malformed body shape → graceful partial CFG, never throws', () => { + // Deeply nested + a syntax-error tail. The visitor must not throw out. + const root = php.parse(` createPhpCfgVisitor().buildFunctionCfg(fn, 'x.php')).not.toThrow(); + } + }); + + it('goto / named label are modeled as straight-line blocks (no crash)', () => { + const cfg = php.cfgOf(`