mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
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:
parent
640d326a01
commit
5e72cfe57a
6 changed files with 2062 additions and 3 deletions
662
gitnexus/src/core/ingestion/cfg/visitors/php-harvest.ts
Normal file
662
gitnexus/src/core/ingestion/cfg/visitors/php-harvest.ts
Normal 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;
|
||||
837
gitnexus/src/core/ingestion/cfg/visitors/php.ts
Normal file
837
gitnexus/src/core/ingestion/cfg/visitors/php.ts
Normal 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 };
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
142
gitnexus/test/integration/cfg/fixtures/php-hazards.php
Normal file
142
gitnexus/test/integration/cfg/fixtures/php-hazards.php
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
414
gitnexus/test/unit/cfg/php-visitor.test.ts
Normal file
414
gitnexus/test/unit/cfg/php-visitor.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue