feat(cfg): call-site sites[] taint substrate for C-family (#2195 U6)

Extend the C/C++/C#/Java/Go harvests with the call-site sites[] taint
substrate (SiteRecord/SiteArgOccurrence), mirroring the TS shape so the
shared taint matcher consumes all languages uniformly. Extract the
grammar-agnostic site machinery into cfg/visitors/call-site-harvest.ts
(CallSiteFactAccumulator -- names no language); each harvest adds only its
per-grammar visitCall/walkChain over its call node (C/C++ call_expression,
C# invocation_expression, Java method_invocation, Go call_expression).

INERT BY DESIGN: no C-family taint model exists (registerBuiltinTaintModels
is TS/JS only), so getSourceSinkConfig returns undefined for these
languages and the harvested sites produce ZERO TAINTED edges -- the
positive source->sink->TAINTED path is deferred with the model authoring.

sites emitted only when non-empty; facts-only attachment, block/edge
topology unchanged (pre-existing topology + def/use tests byte-identical).
23 new substrate tests; 574 green across the cfg/taint/emit suites; gate
green; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 11:56:21 +00:00
parent fff2c1489a
commit b294a387a5
9 changed files with 1231 additions and 174 deletions

View file

@ -51,6 +51,14 @@
*/
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} so the harvester references one name for both
* the value (constructor) 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(['lambda_expression', 'function_definition']);
@ -91,6 +99,13 @@ export class CCppHarvester {
private readonly nearestScopeCache = new Map<number, Scope>();
/** >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 (#2195 U6). Registered by the declaration/assignment handlers
* BEFORE the value walk, consumed by {@link visitCall} when it reaches the
* node (mirrors the TS harvester's `resultDefTargets`).
*/
private readonly resultDefTargets = new Map<number, number[]>();
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
@ -404,8 +419,13 @@ export class CCppHarvester {
// `int x;` is not a def (it writes nothing at runtime), matching the
// TS bare-`var` rule. Pointer/array/member declarators are not scalar
// defs either — their inner identifiers stay uses.
if (name && value && declarator?.type === 'identifier') this.def(name, acc);
else if (declarator && declarator.type !== 'identifier') this.walkValue(declarator, acc);
if (name && value && declarator?.type === 'identifier') {
const snap = acc.defSnapshot();
this.def(name, acc);
this.registerResultDefs(value, acc.defsSince(snap));
} else if (declarator && declarator.type !== 'identifier') {
this.walkValue(declarator, acc);
}
if (value) this.walkValue(value, acc);
}
return;
@ -416,8 +436,12 @@ export class CCppHarvester {
if (left) {
const lv = this.unwrapLvalue(left);
if (lv.type === 'identifier') {
const snap = acc.defSnapshot();
this.def(lv, acc);
if (op !== '=') this.use(lv, acc); // compound assign reads too
// A plain `x = f(a)` attaches `resultDefs: [x]` to f's site; a
// compound `x += f(a)` does not (the prior value flows in too).
if (op === '=' && right) this.registerResultDefs(right, acc.defsSince(snap));
} else {
this.walkValue(lv, acc); // member/pointer/subscript target — uses only
}
@ -456,11 +480,20 @@ export class CCppHarvester {
if (alt) this.conditional(() => this.walkValue(alt, acc));
return;
}
case 'call_expression':
// #2195 U6: explicit case (previously default-descended) — same uses,
// plus a taint-site record. Defs/uses stay byte-identical.
this.visitCall(node, acc, 'call');
return;
case 'new_expression':
// C++ `new Foo(x)` — constructor call site (`type` field is the callee).
this.visitCall(node, acc, 'new');
return;
case 'field_expression': {
// `a.b` / `a->b` — value read of the chain root only; the field name is
// not a scalar binding. Mirrors the TS member-read use semantics.
const arg = node.childForFieldName('argument');
if (arg) this.walkValue(arg, acc);
// not a scalar binding. Mirrors the TS member-read use semantics, plus a
// member-read site for the innermost identifier-rooted access.
this.walkChain(node, acc, false);
return;
}
default:
@ -470,47 +503,145 @@ export class CCppHarvester {
}
}
}
}
/** Ordered, deduplicating def/use collector for one statement record. */
class FactAccumulator {
private readonly defs: number[] = [];
private readonly uses: number[] = [];
private readonly mayDefs: number[] = [];
private readonly defSeen = new Set<number>();
private readonly useSeen = new Set<number>();
private readonly mayDefSeen = new Set<number>();
// ── taint-site harvest (#2195 U6) ────────────────────────────────────────
constructor(private readonly line: number) {}
addDef(idx: number): void {
if (this.defSeen.has(idx)) return;
this.defSeen.add(idx);
this.defs.push(idx);
/**
* When `value`'s root (after stripping parens) is a call/new node, remember
* that its site should carry `resultDefs: defs` — consumed by
* {@link visitCall} once the value walk reaches the node.
*/
private registerResultDefs(value: SyntaxNode, defs: readonly number[]): void {
if (defs.length === 0) return;
const root = this.unwrapLvalue(value);
if (root.type === 'call_expression' || root.type === 'new_expression') {
this.resultDefTargets.set(root.id, [...defs]);
}
}
addMayDef(idx: number): void {
if (this.mayDefSeen.has(idx)) return;
this.mayDefSeen.add(idx);
this.mayDefs.push(idx);
/**
* Explicit call/new handler: records a call site (callee path, receiver,
* per-arg occurrence entries, result defs) while reproducing EXACTLY the uses
* the old default descent recorded — callee chain root + arguments. `new`
* sites read the `type` field as the callee; `call` sites the `function`
* field.
*/
private visitCall(node: SyntaxNode, acc: FactAccumulator, kind: 'call' | 'new'): void {
const calleeNode = node.childForFieldName(kind === 'new' ? 'type' : 'function');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite(kind);
acc.pushFrame(siteIdx);
let calleePath: string | undefined;
if (calleeNode) {
const callee = this.unwrapLvalue(calleeNode);
if (callee.type === 'identifier' || callee.type === 'type_identifier') {
// The callee NAME is a statement-level use but NOT a value occurrence in
// any enclosing argument (`exec(escape(x))` must not put `escape` into
// exec's arg 0). A `new Foo(...)` type identifier is a type, not a
// scalar binding — record neither a use nor an occurrence for it.
if (callee.type === 'identifier') acc.addUseWithoutOccurrence(this.resolve(callee));
calleePath = callee.text;
} else if (callee.type === 'field_expression') {
// skipFinalRead: the final access IS the callee, carried by the dotted
// path — recording it as a member read would double-count.
const chain = this.walkChain(callee, acc, true);
calleePath = chain.path;
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
} else if (callee.type === 'qualified_identifier') {
// `ns::g(...)` — a static dotted path, no scalar receiver binding.
calleePath = this.qualifiedPath(callee);
this.walkValue(callee, acc);
} else {
// Call-rooted chains, function-pointer expressions — the walk still
// records uses and nested sites.
this.walkValue(callee, acc);
}
if (calleePath !== undefined) acc.setSiteCallee(siteIdx, calleePath);
}
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
if (argsNode) {
let pos = 0;
for (let i = 0; i < argsNode.namedChildCount; i++) {
const arg = argsNode.namedChild(i);
if (!arg || arg.type === 'comment') continue;
acc.setFrameArg(pos);
this.walkValue(arg, acc);
pos++;
}
}
acc.popFrame();
}
addUse(idx: number): void {
if (this.useSeen.has(idx)) return;
this.useSeen.add(idx);
this.uses.push(idx);
/**
* Member chain walk shared by value position and callee position. Use-
* recording is identical to the old default descent (chain-root identifier
* once). Member-read sites: at most ONE per chain — the INNERMOST access —
* and only when the chain root is an identifier; `skipFinalRead` suppresses it
* when that access is the callee (carried by the dotted path instead).
*/
private walkChain(
node: SyntaxNode,
acc: FactAccumulator,
skipFinalRead: boolean,
): { path?: string; rootIdx?: number } {
// Collect field accesses outer→inner (unshift), then resolve the root.
const accesses: string[] = [];
let cur: SyntaxNode = this.unwrapLvalue(node);
for (;;) {
if (cur.type === 'field_expression') {
const field = cur.childForFieldName('field');
accesses.unshift(field?.text ?? '');
const obj = cur.childForFieldName('argument');
if (!obj) break;
cur = this.unwrapLvalue(obj);
} else {
break;
}
}
let rootIdx: number | undefined;
let rootSegment: string | undefined;
if (cur.type === 'identifier' || cur.type === 'field_identifier') {
rootIdx = this.resolve(cur);
acc.addUse(rootIdx);
rootSegment = cur.text;
} else {
this.walkValue(cur, acc); // call-rooted etc. — uses + nested sites
}
const innermost = accesses[0];
if (rootIdx !== undefined && innermost && !(skipFinalRead && accesses.length === 1)) {
acc.addMemberRead(rootIdx, innermost);
}
const path =
rootSegment !== undefined && accesses.every((a) => a !== '')
? [rootSegment, ...accesses].join('.')
: undefined;
return { path, rootIdx };
}
defCount(): number {
return this.defs.length + this.mayDefs.length;
}
finish(): StatementFacts {
return {
line: this.line,
defs: this.defs,
uses: this.uses,
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
};
/** Dotted path of a `ns::a::b` qualified_identifier (`::` folded to `.`). */
private qualifiedPath(node: SyntaxNode): string | undefined {
const segs: string[] = [];
let cur: SyntaxNode | null = node;
let hops = 16;
while (cur && hops-- > 0) {
if (cur.type === 'qualified_identifier') {
const scope = cur.childForFieldName('scope');
const name = cur.childForFieldName('name');
if (scope) segs.push(scope.text);
cur = name ?? null;
} else {
segs.push(cur.text);
break;
}
}
return segs.length ? segs.join('.') : undefined;
}
}
/**
* Ordered, deduplicating def/use + call-site collector for one statement record.
* The shared {@link CallSiteFactAccumulator} carries the def/use machinery the
* old local class had, plus the taint-site harvest (#2195 U6).
*/
const FactAccumulator = CallSiteFactAccumulator;

View file

@ -0,0 +1,277 @@
/**
* Shared call-site taint substrate for the C-family CFG harvesters (#2195 U6,
* plan R7 / KTD2) — the language-agnostic mechanism the C/C++, C#, Java and Go
* harvesters layer their grammar-specific call/member walks on top of.
*
* This file is PURE MECHANISM: it contains no tree-sitter node-type or field
* literals (each harvester supplies those when it drives `openCallSite` /
* `addMemberRead` / `setFrameArg`), so it names no language and carries nothing
* the grammar-literal CI gate needs to validate. It is the C-family analogue of
* the `FactAccumulator` site machinery in
* {@link import('./typescript-harvest.js')} — extracted into one place because
* the four C-family harvesters already share an identical def/use accumulator,
* and the site layer is identical across them too (only the per-grammar node
* shapes differ, and those live in each harvester's `walkValue`/`visitCall`).
*
* Produces the same {@link SiteRecord} shape the (future, deferred) shared
* taint matcher consumes uniformly across all languages: callee path, receiver,
* per-argument occurrence entries (with sanitizer-interposition via-tags),
* result defs, spread/template markers, and member reads. INERT BY DESIGN — no
* C-family source/sink/sanitizer model is registered today (`getSourceSinkConfig`
* returns undefined for every C-family language), so a harvest with no model
* produces ZERO TAINTED edges; this only emits the substrate the deferred model
* work will match against.
*
* Sites are emitted on {@link StatementFacts.sites} only when non-empty, exactly
* like the TS harvester — flag-off runs never harvest, and most fact-bearing
* statements carry no calls.
*
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SiteArgOccurrence, SiteRecord, StatementFacts } from '../types.js';
/** Mutable build-time view of a {@link SiteRecord}. */
interface MutableSite {
kind: SiteRecord['kind'];
parent?: [number, number];
callee?: string;
receiver?: number;
args?: SiteArgOccurrence[][];
resultDefs?: number[];
spread?: number;
template?: boolean;
requireArg?: string;
object?: number;
property?: string;
}
/**
* One open call/new site during the walk (mirrors the TS `SiteFrame`). `argIdx`
* is the argument position currently being walked, or -1 while outside any
* argument (callee walk) — occurrences recorded then do NOT land in this frame's
* args, but still fan out (via-tagged) to enclosing arg-active frames.
*/
interface SiteFrame {
siteIdx: number;
argIdx: number;
}
/**
* Ordered, deduplicating def/use collector for one statement record, PLUS the
* call-site harvest machinery (#2195 U6). A drop-in superset of the simple
* def/use accumulator the C-family harvesters used before the substrate landed
* — `addDef`/`addMayDef`/`addUse`/`defCount`/`useCount`/`finish` are unchanged,
* so harvesters that never open a site emit byte-identical facts (no `sites`
* key, since `finish` omits it when empty).
*/
export class CallSiteFactAccumulator {
private readonly defs: number[] = [];
private readonly uses: number[] = [];
private readonly mayDefs: number[] = [];
private readonly defSeen = new Set<number>();
private readonly useSeen = new Set<number>();
private readonly mayDefSeen = new Set<number>();
/** Taint sites recorded for this statement. */
private readonly sites: MutableSite[] = [];
/** Composite (object|property|parent) keys of recorded member-read sites — O(1) dedup. */
private readonly memberReadKeys = new Set<string>();
/** Stack of open call/new sites — the occurrence fan-out targets. */
private readonly frames: SiteFrame[] = [];
constructor(private readonly line: number) {}
addDef(idx: number): void {
if (this.defSeen.has(idx)) return;
this.defSeen.add(idx);
this.defs.push(idx);
}
/** A def that may not execute (conditional context) — gen without kill. */
addMayDef(idx: number): void {
if (this.mayDefSeen.has(idx)) return;
this.mayDefSeen.add(idx);
this.mayDefs.push(idx);
}
addUse(idx: number): void {
// Occurrence fan-out happens BEFORE the statement-level dedup: `exec(x, x)`
// records x at BOTH arg positions even though `uses` lists it once.
this.recordOccurrence(idx);
this.addUseWithoutOccurrence(idx);
}
/**
* Statement-level use that is NOT a value occurrence in any open site
* argument — bare callee names only (see each harvester's `visitCall`).
*/
addUseWithoutOccurrence(idx: number): void {
if (this.useSeen.has(idx)) return;
this.useSeen.add(idx);
this.uses.push(idx);
}
defCount(): number {
return this.defs.length + this.mayDefs.length;
}
useCount(): number {
return this.uses.length;
}
// ── site machinery (#2195 U6, mirrors the TS harvester) ──────────────────
/** `[defs.length, mayDefs.length]` marker for {@link defsSince}. */
defSnapshot(): readonly [number, number] {
return [this.defs.length, this.mayDefs.length];
}
/** Binding indices def'd (must- OR may-) since the snapshot was taken. */
defsSince(snap: readonly [number, number]): number[] {
return [...this.defs.slice(snap[0]), ...this.mayDefs.slice(snap[1])];
}
/** Open a call/new site; parent = innermost enclosing argument position. */
openCallSite(kind: 'call' | 'new'): number {
const site: MutableSite = { kind };
const parent = this.innermostArgPosition();
if (parent) site.parent = parent;
this.sites.push(site);
return this.sites.length - 1;
}
pushFrame(siteIdx: number): void {
this.frames.push({ siteIdx, argIdx: -1 });
}
popFrame(): void {
this.frames.pop();
}
/** Set the argument position the top frame is currently walking. */
setFrameArg(argIdx: number): void {
const top = this.frames[this.frames.length - 1];
if (top) top.argIdx = argIdx;
}
/**
* Run `fn` with all open arg frames temporarily detached (argIdx = -1), so
* identifier reads inside still record USES but do NOT fan occurrences into
* the enclosing sink-argument position (e.g. the non-value operands of a
* comma expression — only the final operand's value flows).
*/
suppressOccurrences(fn: () => void): void {
const saved = this.frames.map((f) => f.argIdx);
for (const f of this.frames) f.argIdx = -1;
try {
fn();
} finally {
this.frames.forEach((f, i) => {
f.argIdx = saved[i];
});
}
}
setSiteCallee(siteIdx: number, callee: string): void {
this.sites[siteIdx].callee = callee;
}
setSiteReceiver(siteIdx: number, receiver: number): void {
this.sites[siteIdx].receiver = receiver;
}
setSiteResultDefs(siteIdx: number, resultDefs: readonly number[]): void {
this.sites[siteIdx].resultDefs = [...resultDefs];
}
setSiteSpread(siteIdx: number, firstSpreadArg: number): void {
const site = this.sites[siteIdx];
if (site.spread === undefined) site.spread = firstSpreadArg;
}
/**
* Record a value-position member read. Exact duplicates within the statement
* (same object/property/parent position) dedup; reads at DIFFERENT argument
* positions stay distinct (`exec(req.body, req.body)` is two occurrences).
*/
addMemberRead(object: number, property: string): void {
const parent = this.innermostArgPosition();
const dedupKey = `${object}|${property}|${parent ? `${parent[0]}:${parent[1]}` : 'top'}`;
if (this.memberReadKeys.has(dedupKey)) return;
this.memberReadKeys.add(dedupKey);
const site: MutableSite = { kind: 'member-read' };
if (parent) site.parent = parent;
site.object = object;
site.property = property;
this.sites.push(site);
}
private innermostArgPosition(): [number, number] | undefined {
for (let i = this.frames.length - 1; i >= 0; i--) {
const f = this.frames[i];
if (f.argIdx >= 0) return [f.siteIdx, f.argIdx];
}
return undefined;
}
/**
* Fan a binding occurrence out to every arg-active open frame, via-tagged
* with the site of the IMMEDIATELY nested frame when one exists:
* `exec(escape(x))` puts a plain `x` in escape's arg 0 and `[x, escapeIdx]`
* in exec's arg 0 — the sanitizer-interposition substrate.
*/
private recordOccurrence(idx: number): void {
for (let i = this.frames.length - 1; i >= 0; i--) {
const f = this.frames[i];
if (f.argIdx < 0) continue;
const via = i + 1 < this.frames.length ? this.frames[i + 1].siteIdx : undefined;
this.pushArgEntry(f.siteIdx, f.argIdx, idx, via);
}
}
private pushArgEntry(
siteIdx: number,
argIdx: number,
bindingIdx: number,
via: number | undefined,
): void {
const site = this.sites[siteIdx];
const args = (site.args ??= []);
while (args.length <= argIdx) args.push([]);
const list = args[argIdx];
// Dedup exact (binding, via) pairs per position — `f(x + x)` is one entry;
// `f(x + g(x))` keeps the plain AND the via-tagged entry (distinct paths).
for (const e of list) {
const match =
typeof e === 'number'
? via === undefined && e === bindingIdx
: via !== undefined && e[0] === bindingIdx && e[1] === via;
if (match) return;
}
list.push(via === undefined ? bindingIdx : [bindingIdx, via]);
}
finish(): StatementFacts {
return {
line: this.line,
defs: this.defs,
uses: this.uses,
// Optional fields stay absent when empty — keeps the serialized
// side-channel payload lean (most statements have no may-defs / sites).
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
...(this.sites.length > 0 ? { sites: this.sites.map(finalizeSite) } : {}),
};
}
}
/** Trim trailing empty arg positions; drop `args` entirely when all-empty. */
const finalizeSite = (site: MutableSite): SiteRecord => {
const args = site.args;
if (args !== undefined) {
let end = args.length;
while (end > 0 && args[end - 1].length === 0) end--;
if (end === 0) delete site.args;
else if (end < args.length) site.args = args.slice(0, end);
}
return site as SiteRecord;
};

View file

@ -46,6 +46,13 @@
*/
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([
@ -89,6 +96,12 @@ export class CsharpHarvester {
private readonly nearestScopeCache = new Map<number, Scope>();
/** >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 (#2195 U6). Registered before the value walk, consumed by
* {@link visitCall} (mirrors the TS harvester's `resultDefTargets`).
*/
private readonly resultDefTargets = new Map<number, number[]>();
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
@ -390,7 +403,11 @@ export class CsharpHarvester {
const name = d.childForFieldName('name');
// The initializer (if any) is the LAST named child after `name`.
const init = this.declaratorInit(d);
if (name && init) this.def(name, acc);
if (name && init) {
const snap = acc.defSnapshot();
this.def(name, acc);
this.registerResultDefs(init, acc.defsSince(snap));
}
if (init) this.walkValue(init, acc);
}
}
@ -403,8 +420,10 @@ export class CsharpHarvester {
if (left) {
const lv = this.unwrapLvalue(left);
if (lv.type === 'identifier') {
const snap = acc.defSnapshot();
this.def(lv, acc);
if (op !== '=') this.use(lv, acc); // compound assign reads too
if (op === '=' && right) this.registerResultDefs(right, acc.defsSince(snap));
} else if (lv.type === 'tuple_expression') {
this.defTupleTargets(lv, acc); // `(a, b) = …` deconstruction
} else {
@ -452,11 +471,20 @@ export class CsharpHarvester {
if (alt) this.conditional(() => this.walkValue(alt, acc));
return;
}
case 'invocation_expression':
// #2195 U6: explicit case (previously default-descended) — same uses,
// plus a taint-site record. Defs/uses stay byte-identical.
this.visitCall(node, acc, 'call');
return;
case 'object_creation_expression':
// `new Foo(x)` — constructor call site (`type` field is the callee).
this.visitCall(node, acc, 'new');
return;
case 'member_access_expression': {
// `a.B` — value read of the chain root only; the member name is not a
// scalar binding. Mirrors the TS member-read use semantics.
const expr = node.childForFieldName('expression');
if (expr) this.walkValue(expr, acc);
// scalar binding. Mirrors the TS member-read use semantics, plus a
// member-read site for the innermost identifier-rooted access.
this.walkChain(node, acc, false);
return;
}
default:
@ -467,6 +495,133 @@ export class CsharpHarvester {
}
}
// ── taint-site harvest (#2195 U6) ────────────────────────────────────────
/**
* When `value`'s root (after stripping parens) is an invocation/creation
* node, remember that its site should carry `resultDefs: defs` — consumed by
* {@link visitCall} once the value walk reaches the node.
*/
private registerResultDefs(value: SyntaxNode, defs: readonly number[]): void {
if (defs.length === 0) return;
const root = this.unwrapLvalue(value);
if (root.type === 'invocation_expression' || root.type === 'object_creation_expression') {
this.resultDefTargets.set(root.id, [...defs]);
}
}
/**
* Explicit invocation / object-creation handler: records a call site (callee
* path, receiver, per-arg occurrence entries, result defs) while reproducing
* EXACTLY the uses the old default descent recorded. C# wraps each argument in
* an `argument` node; `new Foo(...)` reads the `type` field as the callee.
*/
private visitCall(node: SyntaxNode, acc: FactAccumulator, kind: 'call' | 'new'): void {
const calleeNode = node.childForFieldName(kind === 'new' ? 'type' : 'function');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite(kind);
acc.pushFrame(siteIdx);
let calleePath: string | undefined;
if (calleeNode) {
const callee = this.unwrapLvalue(calleeNode);
if (callee.type === 'identifier') {
// For a `new Foo(...)`, the `type` is a type name, not a scalar
// binding — record neither a use nor an occurrence for it; for a bare
// call the callee name is a statement-level use but not an occurrence.
if (kind === 'call') acc.addUseWithoutOccurrence(this.resolve(callee));
calleePath = callee.text;
} else if (callee.type === 'member_access_expression') {
// skipFinalRead: the final access IS the callee, carried by the path.
// A static dotted path (`System.Console.WriteLine(...)`) parses as a
// member_access_expression chain too, so this one branch covers both
// instance and static dotted callees.
const chain = this.walkChain(callee, acc, true);
calleePath = chain.path;
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
} else {
this.walkValue(callee, acc);
}
if (calleePath !== undefined) acc.setSiteCallee(siteIdx, calleePath);
}
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
if (argsNode) {
let pos = 0;
for (let i = 0; i < argsNode.namedChildCount; i++) {
const arg = argsNode.namedChild(i);
// C# wraps each value in an `argument` node; the value is the inner
// expression (a named argument `name: x` exposes the label through the
// `name` field — skip it so `x` is what flows).
if (!arg || arg.type === 'comment') continue;
acc.setFrameArg(pos);
const value = arg.type === 'argument' ? this.argumentValue(arg) : arg;
if (value) this.walkValue(value, acc);
pos++;
}
}
acc.popFrame();
}
/**
* The value expression inside an `argument` node. A named argument
* (`name: x`) carries the label on the `name` field and the value as a
* sibling; a positional argument is just the value. Returns the last named
* child that is not the `name`-field label (and skips `ref`/`out`/`in`
* modifier keywords, which are anonymous tokens, not named children).
*/
private argumentValue(arg: SyntaxNode): SyntaxNode | undefined {
const label = arg.childForFieldName('name');
for (let i = arg.namedChildCount - 1; i >= 0; i--) {
const c = arg.namedChild(i);
if (c && c.id !== label?.id) return c;
}
return undefined;
}
/**
* Member chain walk shared by value position and callee position. Records the
* chain-root identifier as a use (identical to the old default descent), plus
* at most ONE member-read site — the INNERMOST access — when the root is an
* identifier; `skipFinalRead` suppresses it when that access is the callee.
*/
private walkChain(
node: SyntaxNode,
acc: FactAccumulator,
skipFinalRead: boolean,
): { path?: string; rootIdx?: number } {
const accesses: string[] = [];
let cur: SyntaxNode = this.unwrapLvalue(node);
for (;;) {
if (cur.type === 'member_access_expression') {
const name = cur.childForFieldName('name');
accesses.unshift(name?.text ?? '');
const expr = cur.childForFieldName('expression');
if (!expr) break;
cur = this.unwrapLvalue(expr);
} else {
break;
}
}
let rootIdx: number | undefined;
let rootSegment: string | undefined;
if (cur.type === 'identifier') {
rootIdx = this.resolve(cur);
acc.addUse(rootIdx);
rootSegment = cur.text;
} else {
this.walkValue(cur, acc);
}
const innermost = accesses[0];
if (rootIdx !== undefined && innermost && !(skipFinalRead && accesses.length === 1)) {
acc.addMemberRead(rootIdx, innermost);
}
const path =
rootSegment !== undefined && accesses.every((a) => a !== '')
? [rootSegment, ...accesses].join('.')
: undefined;
return { path, rootIdx };
}
/** The initializer value of a `variable_declarator` — the named child after `name`. */
private declaratorInit(declarator: SyntaxNode): SyntaxNode | undefined {
const name = declarator.childForFieldName('name');
@ -499,45 +654,9 @@ export class CsharpHarvester {
}
}
/** Ordered, deduplicating def/use collector for one statement record. */
class FactAccumulator {
private readonly defs: number[] = [];
private readonly uses: number[] = [];
private readonly mayDefs: number[] = [];
private readonly defSeen = new Set<number>();
private readonly useSeen = new Set<number>();
private readonly mayDefSeen = new Set<number>();
constructor(private readonly line: number) {}
addDef(idx: number): void {
if (this.defSeen.has(idx)) return;
this.defSeen.add(idx);
this.defs.push(idx);
}
addMayDef(idx: number): void {
if (this.mayDefSeen.has(idx)) return;
this.mayDefSeen.add(idx);
this.mayDefs.push(idx);
}
addUse(idx: number): void {
if (this.useSeen.has(idx)) return;
this.useSeen.add(idx);
this.uses.push(idx);
}
defCount(): number {
return this.defs.length + this.mayDefs.length;
}
finish(): StatementFacts {
return {
line: this.line,
defs: this.defs,
uses: this.uses,
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
};
}
}
/**
* Ordered, deduplicating def/use + call-site collector for one statement record.
* The shared {@link CallSiteFactAccumulator} carries the def/use machinery the
* old local class had, plus the taint-site harvest (#2195 U6).
*/
const FactAccumulator = CallSiteFactAccumulator;

View file

@ -67,6 +67,13 @@
*/
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([
@ -109,6 +116,12 @@ export class GoHarvester {
private readonly nearestScopeCache = new Map<number, Scope>();
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
/**
* Call node id → bindings whose declaration/assignment VALUE is exactly that
* call (#2195 U6). Registered before the value walk, consumed by
* {@link visitCall} (mirrors the TS harvester's `resultDefTargets`).
*/
private readonly resultDefTargets = new Map<number, number[]>();
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
@ -474,6 +487,10 @@ export class GoHarvester {
case 'short_var_declaration': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
// Register result-defs BEFORE walking the value so the nested call site
// (reached during the value walk) carries them — single-target only
// (`x := f(a)`; a multi-target `a, b := f()` attaches nothing).
if (left && right) this.registerListResultDefs(left, right);
if (right) this.walkValue(right, acc);
if (left) {
for (let i = 0; i < left.namedChildCount; i++) {
@ -486,6 +503,7 @@ export class GoHarvester {
case 'var_declaration': {
for (const spec of this.varSpecs(node)) {
const value = spec.childForFieldName('value');
if (value && this.singleSpecName(spec)) this.registerResultDefs(value, [spec]);
if (value) this.walkValue(value, acc);
if (value) {
for (let i = 0; i < spec.namedChildCount; i++) {
@ -500,6 +518,9 @@ export class GoHarvester {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.text ?? '=';
// Plain `x = f(a)` attaches `resultDefs: [x]`; a compound `x += f(a)`
// does not (the prior value flows in too).
if (op === '=' && left && right) this.registerListResultDefs(left, right);
if (right) this.walkValue(right, acc);
if (left) this.defLeftList(left, acc, op !== '=');
return;
@ -528,11 +549,17 @@ export class GoHarvester {
}
return;
}
case 'call_expression':
// #2195 U6: explicit case (previously default-descended) — same uses,
// plus a taint-site record. Go has no `new` (constructor calls are plain
// `call_expression`s). Defs/uses stay byte-identical.
this.visitCall(node, acc);
return;
case 'selector_expression': {
// `a.b` — value read of the operand root only; the field name is not a
// scalar binding. Mirrors the TS member-read use semantics.
const operand = node.childForFieldName('operand');
if (operand) this.walkValue(operand, acc);
// scalar binding. Mirrors the TS member-read use semantics, plus a
// member-read site for the innermost identifier-rooted access.
this.walkChain(node, acc, false);
return;
}
default:
@ -542,47 +569,177 @@ export class GoHarvester {
}
}
}
}
/** Ordered, deduplicating def/use collector for one statement record. */
class FactAccumulator {
private readonly defs: number[] = [];
private readonly uses: number[] = [];
private readonly mayDefs: number[] = [];
private readonly defSeen = new Set<number>();
private readonly useSeen = new Set<number>();
private readonly mayDefSeen = new Set<number>();
// ── taint-site harvest (#2195 U6) ────────────────────────────────────────
constructor(private readonly line: number) {}
addDef(idx: number): void {
if (this.defSeen.has(idx)) return;
this.defSeen.add(idx);
this.defs.push(idx);
/** The single `var_spec` name when the spec declares exactly one name, else undefined. */
private singleSpecName(spec: SyntaxNode): SyntaxNode | undefined {
const names: SyntaxNode[] = [];
for (let i = 0; i < spec.namedChildCount; i++) {
const c = spec.namedChild(i);
if (c?.type === 'identifier') names.push(c);
}
return names.length === 1 ? names[0] : undefined;
}
addMayDef(idx: number): void {
if (this.mayDefSeen.has(idx)) return;
this.mayDefSeen.add(idx);
this.mayDefs.push(idx);
/**
* Register result-defs for a single-target LHS `expression_list` → RHS
* `expression_list` whose sole element is a call. `a, b := f()` (multi-target)
* and `x, y := f(), g()` attach nothing — the per-target mapping is ambiguous,
* matching the TS harvester's per-declarator restriction.
*/
private registerListResultDefs(left: SyntaxNode, right: SyntaxNode): void {
const leftNames = this.listIdentifiers(left);
const rightVals = this.listElements(right);
if (leftNames.length !== 1 || rightVals.length !== 1) return;
this.registerResultDefs(rightVals[0], [leftNames[0]]);
}
addUse(idx: number): void {
if (this.useSeen.has(idx)) return;
this.useSeen.add(idx);
this.uses.push(idx);
/** Identifier elements of an `expression_list` (`a, b` ⇒ [a, b]). */
private listIdentifiers(list: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
for (let i = 0; i < list.namedChildCount; i++) {
const c = list.namedChild(i);
if (c?.type === 'identifier') out.push(c);
}
return out;
}
defCount(): number {
return this.defs.length + this.mayDefs.length;
/** Named elements of an `expression_list` (or the node itself if not a list). */
private listElements(list: SyntaxNode): SyntaxNode[] {
if (list.type !== 'expression_list') return [list];
const out: SyntaxNode[] = [];
for (let i = 0; i < list.namedChildCount; i++) {
const c = list.namedChild(i);
if (c) out.push(c);
}
return out;
}
finish(): StatementFacts {
return {
line: this.line,
defs: this.defs,
uses: this.uses,
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
};
/**
* When `value`'s root (after stripping parens) is a call, remember its site
* should carry `resultDefs` — the binding indices of `targets` (def-position
* identifiers, resolved against the completed scope tree). Consumed by
* {@link visitCall} once the value walk reaches the node.
*/
private registerResultDefs(value: SyntaxNode, targets: readonly SyntaxNode[]): void {
const root = this.unwrapLvalue(value);
if (root.type !== 'call_expression') return;
const defs: number[] = [];
for (const target of targets) {
// A `var_spec` carries its name(s) as children; an identifier resolves
// directly. Skip the blank identifier (`_`), which binds nothing.
const names =
target.type === 'identifier' ? [target] : this.listIdentifiers(target);
for (const n of names) {
if (n.text === '_') continue;
defs.push(this.resolve(n));
}
}
if (defs.length > 0) this.resultDefTargets.set(root.id, defs);
}
/**
* Explicit `call_expression` handler. Records a call site (callee path,
* receiver, per-arg occurrence entries, result defs) while reproducing EXACTLY
* the uses the old default descent recorded (callee chain root + arguments).
*/
private visitCall(node: SyntaxNode, acc: FactAccumulator): void {
const calleeNode = node.childForFieldName('function');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite('call');
acc.pushFrame(siteIdx);
let calleePath: string | undefined;
if (calleeNode) {
const callee = this.unwrapLvalue(calleeNode);
if (callee.type === 'identifier') {
if (callee.text !== '_') acc.addUseWithoutOccurrence(this.resolve(callee));
calleePath = callee.text;
} else if (callee.type === 'selector_expression') {
// skipFinalRead: the final `.field` IS the callee, carried by the path.
const chain = this.walkChain(callee, acc, true);
calleePath = chain.path;
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
} else {
// Call-rooted chains, conversions (`T(x)`), parenthesized funcs — the
// walk still records uses and nested sites.
this.walkValue(callee, acc);
}
if (calleePath !== undefined) acc.setSiteCallee(siteIdx, calleePath);
}
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
if (argsNode) {
let pos = 0;
for (let i = 0; i < argsNode.namedChildCount; i++) {
const arg = argsNode.namedChild(i);
if (!arg || arg.type === 'comment') continue;
// `f(xs...)` — a variadic spread. Mark the first spread position so the
// matcher degrades soundly; the inner value still walks for occurrences.
if (arg.type === 'variadic_argument') {
acc.setFrameArg(pos);
acc.setSiteSpread(siteIdx, pos);
const inner = arg.namedChild(0);
if (inner) this.walkValue(inner, acc);
} else {
acc.setFrameArg(pos);
this.walkValue(arg, acc);
}
pos++;
}
}
acc.popFrame();
}
/**
* `selector_expression` chain walk shared by value position and callee
* position. Records the chain-root identifier as a use (identical to the old
* default descent) plus at most ONE member-read site — the INNERMOST access —
* when the root is an identifier; `skipFinalRead` suppresses it when that
* access is the callee (carried by the dotted path instead).
*/
private walkChain(
node: SyntaxNode,
acc: FactAccumulator,
skipFinalRead: boolean,
): { path?: string; rootIdx?: number } {
const accesses: string[] = [];
let cur: SyntaxNode = this.unwrapLvalue(node);
for (;;) {
if (cur.type === 'selector_expression') {
const field = cur.childForFieldName('field');
accesses.unshift(field?.text ?? '');
const operand = cur.childForFieldName('operand');
if (!operand) break;
cur = this.unwrapLvalue(operand);
} else {
break;
}
}
let rootIdx: number | undefined;
let rootSegment: string | undefined;
if (cur.type === 'identifier' && cur.text !== '_') {
rootIdx = this.resolve(cur);
acc.addUse(rootIdx);
rootSegment = cur.text;
} else {
this.walkValue(cur, acc);
}
const innermost = accesses[0];
if (rootIdx !== undefined && innermost && !(skipFinalRead && accesses.length === 1)) {
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 the
* old local class had, plus the taint-site harvest (#2195 U6).
*/
const FactAccumulator = CallSiteFactAccumulator;

View file

@ -43,6 +43,13 @@
*/
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([
@ -89,6 +96,12 @@ export class JavaHarvester {
private readonly nearestScopeCache = new Map<number, Scope>();
/** >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 (#2195 U6). Registered before the value walk, consumed by
* {@link visitCall} (mirrors the TS harvester's `resultDefTargets`).
*/
private readonly resultDefTargets = new Map<number, number[]>();
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
@ -384,7 +397,11 @@ export class JavaHarvester {
// Only an INITIALIZED declarator writes (`int x = e;`). A bare
// `int x;` is not a def (it writes nothing at runtime), matching the
// TS bare-`var` rule.
if (name && value) this.def(name, acc);
if (name && value) {
const snap = acc.defSnapshot();
this.def(name, acc);
this.registerResultDefs(value, acc.defsSince(snap));
}
if (value) this.walkValue(value, acc);
}
return;
@ -396,8 +413,10 @@ export class JavaHarvester {
if (left) {
const lv = this.unwrapLvalue(left);
if (lv.type === 'identifier') {
const snap = acc.defSnapshot();
this.def(lv, acc);
if (op !== '=') this.use(lv, acc); // compound assign reads too
if (op === '=' && right) this.registerResultDefs(right, acc.defsSince(snap));
} else {
this.walkValue(lv, acc); // field/array target — uses only
}
@ -439,11 +458,20 @@ export class JavaHarvester {
if (alt) this.conditional(() => this.walkValue(alt, acc));
return;
}
case 'method_invocation':
// #2195 U6: explicit case (previously default-descended) — same uses,
// plus a taint-site record. Defs/uses stay byte-identical.
this.visitCall(node, acc);
return;
case 'object_creation_expression':
// `new Foo(x)` — constructor call site (`type` field is the callee).
this.visitNew(node, acc);
return;
case 'field_access': {
// `a.b` — value read of the object root only; the field name is not a
// scalar binding. Mirrors the TS member-read use semantics.
const obj = node.childForFieldName('object');
if (obj) this.walkValue(obj, acc);
// scalar binding. Mirrors the TS member-read use semantics, plus a
// member-read site for the innermost identifier-rooted access.
this.walkChain(node, acc, false);
return;
}
default:
@ -454,6 +482,134 @@ export class JavaHarvester {
}
}
// ── taint-site harvest (#2195 U6) ────────────────────────────────────────
/**
* When `value`'s root (after stripping parens) is a method-invocation /
* 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.unwrapLvalue(value);
if (root.type === 'method_invocation' || root.type === 'object_creation_expression') {
this.resultDefTargets.set(root.id, [...defs]);
}
}
/**
* Explicit `method_invocation` handler. Unlike a member-chain callee, Java
* carries the method NAME on the `name` field and the receiver on a sibling
* `object` field (`db.query(x)` ⇒ object `db`, name `query`); a bare call
* (`exec(x)`) has no `object`. Reproduces EXACTLY the uses the old default
* descent recorded (object root + arguments) and adds the call site.
*/
private visitCall(node: SyntaxNode, acc: FactAccumulator): void {
const objectNode = node.childForFieldName('object');
const nameNode = node.childForFieldName('name');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite('call');
acc.pushFrame(siteIdx);
let receiverPath: string | undefined;
if (objectNode) {
// The receiver is a value read (object chain root) — record its uses and
// the member-read sites, and capture its dotted path + binding root.
const chain = this.walkChain(objectNode, acc, false);
receiverPath = chain.path;
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
}
if (nameNode) {
// The method NAME was a (synthetic) statement-level use under the old
// default descent — preserve it byte-identically, but never as a value
// occurrence in an enclosing argument (`exec(escape(x))` must not put the
// `escape` name into exec's arg 0).
acc.addUseWithoutOccurrence(this.resolve(nameNode));
const callee =
receiverPath !== undefined ? `${receiverPath}.${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 typeNode = node.childForFieldName('type');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite('new');
acc.pushFrame(siteIdx);
if (typeNode) {
// The type name is not a scalar binding — record it only as the callee
// path, never a use/occurrence (matches the type-position semantics).
acc.setSiteCallee(siteIdx, typeNode.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 `argument_list`, 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 || COMMENT_TYPES.has(arg.type)) continue;
acc.setFrameArg(pos);
this.walkValue(arg, acc);
pos++;
}
}
/**
* `field_access` chain walk shared by value position and the method-invocation
* receiver. Records the chain-root identifier as a use (identical to the old
* default descent) plus at most ONE member-read site — the INNERMOST access —
* when the root is an identifier; `skipFinalRead` suppresses it when that
* access is the callee (never the case for `field_access`, which is value-only).
*/
private walkChain(
node: SyntaxNode,
acc: FactAccumulator,
skipFinalRead: boolean,
): { path?: string; rootIdx?: number } {
const accesses: string[] = [];
let cur: SyntaxNode = this.unwrapLvalue(node);
for (;;) {
if (cur.type === 'field_access') {
const field = cur.childForFieldName('field');
accesses.unshift(field?.text ?? '');
const obj = cur.childForFieldName('object');
if (!obj) break;
cur = this.unwrapLvalue(obj);
} else {
break;
}
}
let rootIdx: number | undefined;
let rootSegment: string | undefined;
if (cur.type === 'identifier') {
rootIdx = this.resolve(cur);
acc.addUse(rootIdx);
rootSegment = cur.text;
} else if (cur.type === 'this' || cur.type === 'super') {
rootSegment = cur.text; // `this`/`super` are path segments, never bind
} else {
this.walkValue(cur, acc);
}
const innermost = accesses[0];
if (rootIdx !== undefined && innermost && !(skipFinalRead && accesses.length === 1)) {
acc.addMemberRead(rootIdx, innermost);
}
const path =
rootSegment !== undefined && accesses.every((a) => a !== '')
? [rootSegment, ...accesses].join('.')
: undefined;
return { path, rootIdx };
}
/** The operand identifier of an `update_expression` (`x++` / `--x`). */
private updateOperand(node: SyntaxNode): SyntaxNode | undefined {
for (let i = 0; i < node.namedChildCount; i++) {
@ -464,49 +620,9 @@ export class JavaHarvester {
}
}
/** Ordered, deduplicating def/use collector for one statement record. */
class FactAccumulator {
private readonly defs: number[] = [];
private readonly uses: number[] = [];
private readonly mayDefs: number[] = [];
private readonly defSeen = new Set<number>();
private readonly useSeen = new Set<number>();
private readonly mayDefSeen = new Set<number>();
constructor(private readonly line: number) {}
addDef(idx: number): void {
if (this.defSeen.has(idx)) return;
this.defSeen.add(idx);
this.defs.push(idx);
}
addMayDef(idx: number): void {
if (this.mayDefSeen.has(idx)) return;
this.mayDefSeen.add(idx);
this.mayDefs.push(idx);
}
addUse(idx: number): void {
if (this.useSeen.has(idx)) return;
this.useSeen.add(idx);
this.uses.push(idx);
}
defCount(): number {
return this.defs.length + this.mayDefs.length;
}
useCount(): number {
return this.uses.length;
}
finish(): StatementFacts {
return {
line: this.line,
defs: this.defs,
uses: this.uses,
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
};
}
}
/**
* Ordered, deduplicating def/use + call-site collector for one statement record.
* The shared {@link CallSiteFactAccumulator} carries the def/use machinery the
* old local class had, plus the taint-site harvest (#2195 U6).
*/
const FactAccumulator = CallSiteFactAccumulator;

View file

@ -5,7 +5,7 @@ import {
createCCfgVisitor,
createCppCfgVisitor,
} from '../../../src/core/ingestion/cfg/visitors/c-cpp.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import type { FunctionCfg, SiteRecord } from '../../../src/core/ingestion/cfg/types.js';
import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js';
// U2 — the C/C++ CfgVisitor, one hazard per test (KTD5: real-parser regression,
@ -60,6 +60,18 @@ function bindingIdx(cfg: FunctionCfg, name: string): number {
return i;
}
/** 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;
}
/** True iff at least one statement carries a (non-empty) `sites` array. */
function hasAnySites(cfg: FunctionCfg): boolean {
return cfg.blocks.some((b) => (b.statements ?? []).some((s) => (s.sites ?? []).length > 0));
}
describe('C CfgVisitor — structure', () => {
it('straight-line body: ENTRY → block → EXIT (seq)', () => {
const cfg = c.cfgOf(`void f() { a(); b(); c(); }`);
@ -303,3 +315,70 @@ describe('C++ CfgVisitor — lambdas are CFG-bearing functions', () => {
}
});
});
// U6 — call-site `sites[]` taint substrate. INERT BY DESIGN: no C-family taint
// model is registered, so these sites produce zero TAINTED edges; they only
// give the deferred per-language source/sink model something to match against.
// The assertions verify the substrate is harvested in the TS `SiteRecord` shape.
describe('C CfgVisitor — call-site sites[] substrate', () => {
it('a bare call records a `call` site with callee name + arg occurrence', () => {
const cfg = c.cfgOf(`void f(int cmd) { exec(cmd); }`);
const sites = allSites(cfg);
const exec = sites.find((s) => s.kind === 'call' && s.callee === 'exec');
expect(exec).toBeDefined();
// `cmd` (binding 0) occurs at argument position 0.
expect(exec?.args?.[0]).toContainEqual(bindingIdx(cfg, 'cmd'));
});
it('a method call (`db.query(x)`) records the receiver binding + dotted callee', () => {
const cfg = c.cfgOf(`void f(int x) { db.query(x); }`);
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'db.query');
expect(site).toBeDefined();
expect(site?.receiver).toBe(bindingIdx(cfg, 'db'));
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'x'));
});
it('a nested call (`exec(escape(x))`) via-tags the inner site (sanitizer substrate)', () => {
const cfg = c.cfgOf(`void f(int x) { exec(escape(x)); }`);
const sites = allSites(cfg);
const exec = sites.findIndex((s) => s.callee === 'exec');
const escape = sites.findIndex((s) => s.callee === 'escape');
expect(exec).toBeGreaterThanOrEqual(0);
expect(escape).toBeGreaterThanOrEqual(0);
const x = bindingIdx(cfg, 'x');
// escape's arg 0 carries a plain `x`; exec's arg 0 carries `[x, escapeSiteIdx]`.
expect(sites[escape].args?.[0]).toContainEqual(x);
expect(sites[exec].args?.[0]).toContainEqual([x, escape]);
});
it('a call assigned to a variable records resultDefs', () => {
const cfg = c.cfgOf(`void f(int a) { int y = load(a); }`);
const site = allSites(cfg).find((s) => s.callee === 'load');
expect(site?.resultDefs).toContain(bindingIdx(cfg, 'y'));
});
it('a CFG-only function (no calls) emits NO sites key (omit-when-empty)', () => {
const cfg = c.cfgOf(`void f(int a, int b) { int x = a + b; }`);
expect(hasAnySites(cfg)).toBe(false);
expect(allSites(cfg)).toHaveLength(0);
});
});
describe('C++ CfgVisitor — call-site sites[] substrate', () => {
it('a `new Foo(x)` records a `new` site with the constructor as callee', () => {
const cfg = cpp.cfgOf(`void f(int x) { auto p = new Foo(x); }`);
const site = allSites(cfg).find((s) => s.kind === 'new');
expect(site).toBeDefined();
expect(site?.callee).toBe('Foo');
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'x'));
// `auto p = new Foo(x)` attaches resultDefs of `p` to the new site.
expect(site?.resultDefs).toContain(bindingIdx(cfg, 'p'));
});
it('a `ns::g(z)` namespace call folds `::` into a dotted callee path', () => {
const cfg = cpp.cfgOf(`void f(int z) { ns::g(z); }`);
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'ns.g');
expect(site).toBeDefined();
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'z'));
});
});

View file

@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { createRequire } from 'node:module';
import { createCsharpCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/csharp.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import type { FunctionCfg, SiteRecord } from '../../../src/core/ingestion/cfg/types.js';
import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js';
// U3 — the C# CfgVisitor, one hazard per test (KTD5: real-parser regression,
@ -63,6 +63,15 @@ const hasUse = (cfg: FunctionCfg, idx: number): boolean =>
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;
}
const hasAnySites = (cfg: FunctionCfg): boolean =>
cfg.blocks.some((b) => (b.statements ?? []).some((s) => (s.sites ?? []).length > 0));
const wrap = (body: string): string => `class C { void M(${''}) { ${body} } }`;
describe('C# CfgVisitor — structure', () => {
@ -387,3 +396,50 @@ describe('C# CfgVisitor — does not throw on exotic shapes', () => {
}
});
});
// U6 — call-site `sites[]` taint substrate. INERT BY DESIGN: no C# taint model
// is registered, so these sites produce zero TAINTED edges; they only give the
// deferred per-language source/sink model something to match against.
describe('C# CfgVisitor — call-site sites[] substrate', () => {
const cfgOf = (body: string): FunctionCfg => cs.cfgOf(`class C { void f(int cmd, int x, int a) { ${body} } }`);
it('a bare invocation records a `call` site with callee name + arg occurrence', () => {
const cfg = cfgOf(`Exec(cmd);`);
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'Exec');
expect(site).toBeDefined();
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'cmd'));
});
it('a member invocation (`db.Query(x)`) records the receiver + dotted callee', () => {
const cfg = cfgOf(`db.Query(x);`);
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'db.Query');
expect(site).toBeDefined();
expect(site?.receiver).toBe(bindingIdx(cfg, 'db'));
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'x'));
});
it('`new Foo(x)` records a `new` site with the type as callee', () => {
const cfg = cfgOf(`var p = new Foo(x);`);
const site = allSites(cfg).find((s) => s.kind === 'new');
expect(site?.callee).toBe('Foo');
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'x'));
expect(site?.resultDefs).toContain(bindingIdx(cfg, 'p'));
});
it('a nested invocation via-tags the inner site (sanitizer substrate)', () => {
const cfg = cfgOf(`Exec(Escape(x));`);
const sites = allSites(cfg);
const exec = sites.findIndex((s) => s.callee === 'Exec');
const escape = sites.findIndex((s) => s.callee === 'Escape');
expect(exec).toBeGreaterThanOrEqual(0);
expect(escape).toBeGreaterThanOrEqual(0);
const x = bindingIdx(cfg, 'x');
expect(sites[escape].args?.[0]).toContainEqual(x);
expect(sites[exec].args?.[0]).toContainEqual([x, escape]);
});
it('a CFG-only function (no calls) emits NO sites key (omit-when-empty)', () => {
const cfg = cs.cfgOf(`class C { void f(int a, int b) { int x = a + b; } }`);
expect(hasAnySites(cfg)).toBe(false);
});
});

View file

@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { createRequire } from 'node:module';
import { createGoCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/go.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import type { FunctionCfg, SiteRecord } from '../../../src/core/ingestion/cfg/types.js';
import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js';
import { isExitReachableFromAllBlocks } from '../../../src/core/ingestion/cfg/post-dominators.js';
import { computeControlDependence } from '../../../src/core/ingestion/cfg/control-dependence.js';
@ -54,6 +54,15 @@ const hasUse = (cfg: FunctionCfg, idx: number): boolean =>
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;
}
const hasAnySites = (cfg: FunctionCfg): boolean =>
cfg.blocks.some((b) => (b.statements ?? []).some((s) => (s.sites ?? []).length > 0));
/** Wrap a Go function body in a minimal compilable package. */
const pkg = (src: string): string => `package main\n${src}\n`;
@ -424,3 +433,57 @@ describe('Go CfgVisitor — functionStartColumn', () => {
expect(cfgs[0].functionStartColumn).not.toBe(cfgs[1].functionStartColumn);
});
});
// U6 — call-site `sites[]` taint substrate. INERT BY DESIGN: no Go taint model
// is registered, so these sites produce zero TAINTED edges; they only give the
// deferred per-language source/sink model something to match against. Go has no
// `new` — constructor-style calls are plain `call_expression`s.
describe('Go CfgVisitor — call-site sites[] substrate', () => {
const cfgOf = (body: string): FunctionCfg =>
go.cfgOf(pkg(`func f(cmd, x, a int, xs []int) { ${body} }`));
it('a bare call records a `call` site with callee name + arg occurrence', () => {
const cfg = cfgOf(`exec(cmd)`);
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'exec');
expect(site).toBeDefined();
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'cmd'));
});
it('a selector call (`db.Query(x)`) records the receiver binding + dotted callee', () => {
const cfg = cfgOf(`db.Query(x)`);
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'db.Query');
expect(site).toBeDefined();
expect(site?.receiver).toBe(bindingIdx(cfg, 'db'));
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'x'));
});
it('a call assigned with `:=` records resultDefs', () => {
const cfg = cfgOf(`r := load(a); _ = r`);
const site = allSites(cfg).find((s) => s.callee === 'load');
expect(site?.resultDefs).toContain(bindingIdx(cfg, 'r'));
});
it('a variadic call (`g(xs...)`) marks the spread position', () => {
const cfg = cfgOf(`g(xs...)`);
const site = allSites(cfg).find((s) => s.callee === 'g');
expect(site?.spread).toBe(0);
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'xs'));
});
it('a nested call via-tags the inner site (sanitizer substrate)', () => {
const cfg = cfgOf(`exec(escape(x))`);
const sites = allSites(cfg);
const exec = sites.findIndex((s) => s.callee === 'exec');
const escape = sites.findIndex((s) => s.callee === 'escape');
expect(exec).toBeGreaterThanOrEqual(0);
expect(escape).toBeGreaterThanOrEqual(0);
const x = bindingIdx(cfg, 'x');
expect(sites[escape].args?.[0]).toContainEqual(x);
expect(sites[exec].args?.[0]).toContainEqual([x, escape]);
});
it('a CFG-only function (no calls) emits NO sites key (omit-when-empty)', () => {
const cfg = cfgOf(`x := a + a; _ = x`);
expect(hasAnySites(cfg)).toBe(false);
});
});

View file

@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { createRequire } from 'node:module';
import { createJavaCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/java.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import type { FunctionCfg, SiteRecord } from '../../../src/core/ingestion/cfg/types.js';
import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js';
// U4 — the Java CfgVisitor, one hazard per test (KTD5: real-parser regression,
@ -54,6 +54,15 @@ function bindingIdx(cfg: FunctionCfg, name: string): number {
return i;
}
/** 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;
}
const hasAnySites = (cfg: FunctionCfg): boolean =>
cfg.blocks.some((b) => (b.statements ?? []).some((s) => (s.sites ?? []).length > 0));
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 =>
@ -513,3 +522,53 @@ describe('Java CfgVisitor — does not throw on exotic shapes', () => {
}
});
});
// U6 — call-site `sites[]` taint substrate. INERT BY DESIGN: no Java taint model
// is registered, so these sites produce zero TAINTED edges; they only give the
// deferred per-language source/sink model something to match against.
describe('Java CfgVisitor — call-site sites[] substrate', () => {
const cfgOf = (body: string): FunctionCfg =>
java.cfgOf(`class C { void f(int cmd, int x, int a) { ${body} } }`);
it('a bare method call records a `call` site with callee + arg occurrence', () => {
const cfg = cfgOf(`exec(cmd);`);
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'exec');
expect(site).toBeDefined();
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'cmd'));
});
it('a receiver call (`db.query(x)`) records the receiver binding + dotted callee', () => {
// Java carries the method NAME on the `name` field and the receiver on the
// sibling `object` field — the substrate normalizes both to receiver+callee.
const cfg = cfgOf(`db.query(x);`);
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'db.query');
expect(site).toBeDefined();
expect(site?.receiver).toBe(bindingIdx(cfg, 'db'));
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'x'));
});
it('`new Foo(x)` records a `new` site with the type as callee', () => {
const cfg = cfgOf(`Object p = new Foo(x);`);
const site = allSites(cfg).find((s) => s.kind === 'new');
expect(site?.callee).toBe('Foo');
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'x'));
expect(site?.resultDefs).toContain(bindingIdx(cfg, 'p'));
});
it('a nested call via-tags the inner site (sanitizer substrate)', () => {
const cfg = cfgOf(`exec(escape(x));`);
const sites = allSites(cfg);
const exec = sites.findIndex((s) => s.callee === 'exec');
const escape = sites.findIndex((s) => s.callee === 'escape');
expect(exec).toBeGreaterThanOrEqual(0);
expect(escape).toBeGreaterThanOrEqual(0);
const x = bindingIdx(cfg, 'x');
expect(sites[escape].args?.[0]).toContainEqual(x);
expect(sites[exec].args?.[0]).toContainEqual([x, escape]);
});
it('a CFG-only function (no calls) emits NO sites key (omit-when-empty)', () => {
const cfg = java.cfgOf(`class C { void f(int a, int b) { int x = a + b; } }`);
expect(hasAnySites(cfg)).toBe(false);
});
});