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) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 12:46:39 +00:00
parent 640d326a01
commit 5e72cfe57a
6 changed files with 2062 additions and 3 deletions

View file

@ -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<string, number>();
private readonly synthetic = new Map<string, number>();
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<number, number[]>();
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;

View file

@ -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_<node_type>` 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<SyntaxNode> {
return { buildFunctionCfg, isFunction };
}
export { PHP_FUNCTION_TYPES };

View file

@ -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.

View file

@ -0,0 +1,142 @@
<?php
// PHP CFG hazard fixture for the PDG layer — one construct per function, with
// distinctive call text so a test can locate the block for a region. Mirrors the
// other languages' cfg hazard fixtures (java-hazards / python-hazards).
namespace App\Cfg;
function ifElifElse(int $x): void
{
if ($x > 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;
}

View file

@ -27,15 +27,17 @@ const tsVisitor = (): CfgVisitor<SyntaxNode> => {
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();
});
});

View file

@ -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<typeof makeCfgHarness>[0];
const php: CfgHarness = makeCfgHarness(phpGrammar, createPhpCfgVisitor(), 'fixture.php');
const wrap = (body: string): string => `<?php function f($x) { ${body} }`;
const block = (cfg: FunctionCfg, substr: string): number => {
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<string> => new Set(cfg.edges.map((e) => e.kind));
function reaches(cfg: FunctionCfg, from: number, to: number): boolean {
const adj = new Map<number, number[]>();
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(`<?php function f() { a(); b(); c(); }`);
expect(cfg.blocks.filter((b) => 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(`<?php function f() {}`);
expect(cfg.blocks).toHaveLength(2);
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
});
it('method, anonymous function, and arrow function are CFG-bearing', () => {
const cfgs = php.cfgsOf(
`<?php class C { public function m($a) { return $a; } }
$clo = function ($b) { return $b; };
$arr = fn ($c) => $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(`<?php $g = fn ($z) => $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(`<?php abstract class C { abstract public function m(); }`);
const fns = php.collectFunctions(root);
for (const fn of fns) {
expect(() => 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(
`<?php function f() {
while (true) {
for ($i = 0; ; $i++) {
if (cond()) { break 2; }
inner();
}
afterInner();
}
afterOuter();
}`,
);
expect(edgeKinds(cfg).has('break')).toBe(true);
const brk = block(cfg, 'break 2');
// break 2 escapes the OUTER loop → reaches afterOuter(), NOT afterInner().
expect(reaches(cfg, brk, block(cfg, 'afterOuter();'))).toBe(true);
expect(reaches(cfg, brk, block(cfg, 'afterInner();'))).toBe(false);
});
it('continue 2 targets the 2nd enclosing loop header', () => {
const cfg = php.cfgOf(
`<?php function f() {
while (cond1()) {
for ($i = 0; $i < 3; $i++) {
if (cond2()) { continue 2; }
inner();
}
}
done();
}`,
);
expect(edgeKinds(cfg).has('continue')).toBe(true);
expect(exitReachableFromAll(cfg)).toBe(true);
});
it('bare break targets the nearest loop', () => {
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(`<?php function f($a) { $x = $a; }`);
expect(hasDef(cfg, bindingIdx(cfg, '$x'))).toBe(true);
expect(hasUse(cfg, bindingIdx(cfg, '$a'))).toBe(true);
});
it('[$a, $b] = f() and list($c, $d) = g() define all targets', () => {
const cfg = php.cfgOf(`<?php function f() { [$a, $b] = h(); list($c, $d) = g(); }`);
for (const name of ['$a', '$b', '$c', '$d']) {
expect(hasDef(cfg, bindingIdx(cfg, name))).toBe(true);
}
});
it('foreach ($it as $k => $v) defines both $k and $v', () => {
const cfg = php.cfgOf(`<?php function f($it) { foreach ($it as $k => $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(`<?php function f() { try { r(); } catch (\\E $e) { log($e); } }`);
expect(hasDef(cfg, bindingIdx(cfg, '$e'))).toBe(true);
});
it('parameters (incl. default, variadic, by-ref) are ENTRY defs', () => {
const cfg = php.cfgOf(`<?php function f($a, $b = 1, &$c, ...$rest) { use1($a); }`);
for (const name of ['$a', '$b', '$c', '$rest']) {
expect(hasDef(cfg, bindingIdx(cfg, name))).toBe(true);
}
});
it('conditional def (right of &&) is a may-def, not a must-def', () => {
const cfg = php.cfgOf(`<?php function f($a) { $r = $a && ($x = load()); }`);
const x = bindingIdx(cfg, '$x');
expect(hasMayDef(cfg, x)).toBe(true);
expect(hasDef(cfg, x)).toBe(false);
});
it('property write ($o->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(`<?php function f() { $o = make(); $o->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(`<?php function outer($b) { return function ($a) use ($b, &$c) { return $a + $b + $c; }; }`);
const closure = cfgs.find((c) => (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(`<?php function f($req) { exec($req); }`);
const sites = allSites(cfg);
expect(sites.some((s) => s.kind === 'call' && s.callee === 'exec')).toBe(true);
});
it('records a member-call receiver + callee (db->query)', () => {
const cfg = php.cfgOf(`<?php function f($req) { $db->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(`<?php function f($req) { exec(escape($req)); }`);
const sites = allSites(cfg);
expect(sites.some((s) => 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(`<?php function f($x) { if ($x) { while (true) { @@@ } } }`);
const fns = php.collectFunctions(root);
for (const fn of fns) {
expect(() => createPhpCfgVisitor().buildFunctionCfg(fn, 'x.php')).not.toThrow();
}
});
it('goto / named label are modeled as straight-line blocks (no crash)', () => {
const cfg = php.cfgOf(`<?php function f() { start(); goto end; end: done(); }`);
expect(reachable(cfg, block(cfg, 'done()'))).toBe(true);
expect(exitReachableFromAll(cfg)).toBe(true);
});
});