From b294a387a504cf649e3d3405c00a51e31dcac84a Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 14 Jun 2026 11:56:21 +0000 Subject: [PATCH] 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) --- .../ingestion/cfg/visitors/c-cpp-harvest.ts | 209 ++++++++++--- .../cfg/visitors/call-site-harvest.ts | 277 ++++++++++++++++++ .../ingestion/cfg/visitors/csharp-harvest.ts | 211 ++++++++++--- .../core/ingestion/cfg/visitors/go-harvest.ts | 227 +++++++++++--- .../ingestion/cfg/visitors/java-harvest.ts | 216 ++++++++++---- gitnexus/test/unit/cfg/c-cpp-visitor.test.ts | 81 ++++- gitnexus/test/unit/cfg/csharp-visitor.test.ts | 58 +++- gitnexus/test/unit/cfg/go-visitor.test.ts | 65 +++- gitnexus/test/unit/cfg/java-visitor.test.ts | 61 +++- 9 files changed, 1231 insertions(+), 174 deletions(-) create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/call-site-harvest.ts diff --git a/gitnexus/src/core/ingestion/cfg/visitors/c-cpp-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/c-cpp-harvest.ts index 3dfce8f24..ff1fa2f99 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/c-cpp-harvest.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/c-cpp-harvest.ts @@ -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(); /** >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(); 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(); - private readonly useSeen = new Set(); - private readonly mayDefSeen = new Set(); + // ── 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; diff --git a/gitnexus/src/core/ingestion/cfg/visitors/call-site-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/call-site-harvest.ts new file mode 100644 index 000000000..cb9e4eba9 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/call-site-harvest.ts @@ -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(); + private readonly useSeen = new Set(); + private readonly mayDefSeen = new Set(); + /** 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(); + /** 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; +}; diff --git a/gitnexus/src/core/ingestion/cfg/visitors/csharp-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/csharp-harvest.ts index 18016a7d1..01f0e0fdf 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/csharp-harvest.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/csharp-harvest.ts @@ -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(); /** >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(); 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(); - private readonly useSeen = new Set(); - private readonly mayDefSeen = new Set(); - - 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; diff --git a/gitnexus/src/core/ingestion/cfg/visitors/go-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/go-harvest.ts index 880b6c2db..107e73181 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/go-harvest.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/go-harvest.ts @@ -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(); /** >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(); 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(); - private readonly useSeen = new Set(); - private readonly mayDefSeen = new Set(); + // ── 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; diff --git a/gitnexus/src/core/ingestion/cfg/visitors/java-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/java-harvest.ts index bac4e3e0d..3e2ccd6b0 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/java-harvest.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/java-harvest.ts @@ -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(); /** >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(); 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(); - private readonly useSeen = new Set(); - private readonly mayDefSeen = new Set(); - - 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; diff --git a/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts b/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts index 1970f73aa..359171082 100644 --- a/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts +++ b/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts @@ -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')); + }); +}); diff --git a/gitnexus/test/unit/cfg/csharp-visitor.test.ts b/gitnexus/test/unit/cfg/csharp-visitor.test.ts index 9a952f2dd..7cdc96405 100644 --- a/gitnexus/test/unit/cfg/csharp-visitor.test.ts +++ b/gitnexus/test/unit/cfg/csharp-visitor.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/cfg/go-visitor.test.ts b/gitnexus/test/unit/cfg/go-visitor.test.ts index 9a217f447..8b0ba715b 100644 --- a/gitnexus/test/unit/cfg/go-visitor.test.ts +++ b/gitnexus/test/unit/cfg/go-visitor.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/cfg/java-visitor.test.ts b/gitnexus/test/unit/cfg/java-visitor.test.ts index 72d9dbb6c..c8be77dec 100644 --- a/gitnexus/test/unit/cfg/java-visitor.test.ts +++ b/gitnexus/test/unit/cfg/java-visitor.test.ts @@ -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); + }); +});