GitNexus/gitnexus/test/unit/cfg-site-position.test.ts
Gergő Magyar 78b4077d8a
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
feat(impact): opt-in PDG-backed impact mode - statement + inter-procedural slicing, resolved-callee-id soundness, mutation-oracle validated (#2227)
2026-06-20 12:04:32 +01:00

124 lines
6.9 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import type { FunctionCfg, SiteRecord } from '../../src/core/ingestion/cfg/types.js';
import { cfgOf } from '../helpers/ts-cfg-harness.js';
import { allSites } from '../helpers/cfg-harness.js';
/**
* U1 (#2227 follow-up plan) — `SiteRecord.at` call-site anchor position.
*
* Each call/new `SiteRecord` is stamped with `at: [line (1-based), col
* (0-based)]`, recorded by the worker harvester at the call/new node where it
* reads the callee. A downstream unit joins each site to its resolved callee id
* by EXACT position, so `at` MUST be the SAME anchor the CALLS-edge resolution
* keys on (plan KTD7).
*
* ANCHOR ALIGNMENT (verified against the scope-extractor): the CALLS `atRange`
* is `nodeToCapture('@reference.call.*', node).range` where the `@reference.call`
* anchor is the WHOLE call/new EXPRESSION node (the callee identifier / member
* property is the `@reference.name` SUB-tag, excluded from the anchor by
* `anchorCaptureFor` + `KNOWN_SUB_TAGS`; `atRange: anchor.range` at
* scope-extractor.ts:1030). The harvester's `visitCall`/`visitNew` receives that
* exact `call_expression`/`new_expression` node and records its `startPosition`,
* so `at` == the CALLS atRange for every call shape:
* - bare call `foo(x)` → `at` == the bare callee/call-expr start.
* - member call `arr.map(x)` → `at` == the call-expr start, i.e. the receiver
* `arr`'s position (where `@reference.call.member`
* anchors), NOT the `.map` property token.
* - chained call `a.b.c(x)` → `at` == the outer call-expr start (`a`).
*
* Member-read sites carry no `at` (the resolved-id join only consumes call/new).
*/
/** The call/new sites of `cfg`, in (block, statement, site) order. */
function callSites(cfg: FunctionCfg): SiteRecord[] {
return allSites(cfg).filter((s) => s.kind === 'call' || s.kind === 'new');
}
/** The single call/new site whose dotted callee path is `callee` (throws otherwise). */
function siteByCallee(cfg: FunctionCfg, callee: string): SiteRecord {
const matches = callSites(cfg).filter((s) => s.callee === callee);
if (matches.length !== 1) {
throw new Error(`expected exactly 1 call site with callee ${callee}, got ${matches.length}`);
}
return matches[0];
}
describe('SiteRecord.at — call-site anchor position (U1)', () => {
it('a bare call `foo(x)` carries `at` of the callee/call-expr anchor', () => {
// `function f(x) { foo(x); }` — `foo(` starts at column 16 on line 1; for a
// bare call the call-expression start and the callee identifier coincide, so
// this is the anchor the CALLS `@reference.call.free` resolution keys on.
const cfg = cfgOf(`function f(x) { foo(x); }`);
expect(siteByCallee(cfg, 'foo')).toMatchObject({ kind: 'call', at: [1, 16] });
});
it('a member call `arr.map(...)` carries `at` of the resolved-reference anchor (the call-expr / receiver start)', () => {
// `function f(arr) { arr.map(x => x); }` — `arr.map(...)` is one
// `call_expression` starting at `arr` (column 18). The CALLS
// `@reference.call.member` anchor is that whole call_expression, so `at`
// records the receiver/call-expr start, NOT the `.map` property token (which
// is only the `@reference.name` sub-tag — plan KTD7).
const cfg = cfgOf(`function f(arr) { arr.map(x => x); }`);
expect(siteByCallee(cfg, 'arr.map')).toMatchObject({ kind: 'call', at: [1, 18] });
});
it('a chained call `a.b.c(x)` carries `at` of the outer call-expr start (the chain root)', () => {
// `a.b.c(x)` is one call_expression starting at the chain root `a` (column
// 19); the `@reference.call.member` anchor and the harvested `at` both land
// there — the outer call-expr start, not the `.c` property token.
const cfg = cfgOf(`function f(a, x) { a.b.c(x); }`);
expect(siteByCallee(cfg, 'a.b.c')).toMatchObject({ kind: 'call', at: [1, 19] });
});
it('an argument-position call on a LATER line carries that call`s own line, not the statement head line', () => {
// line 2: outer(
// line 3: inner(a)
// line 4: );
// The whole call statement begins on line 2, but the inner argument-position
// call begins on line 3 — its `SiteRecord.at` line MUST be 3 (the inner
// call's line). This is the core bug the position field fixes: a
// statement-line join would mis-attribute `inner`'s resolved id to line 2.
const cfg = cfgOf(`function f(a, b) {\n outer(\n inner(a)\n );\n}`);
const outer = siteByCallee(cfg, 'outer');
const inner = siteByCallee(cfg, 'inner');
expect(outer).toMatchObject({ kind: 'call', at: [2, 2] });
expect(inner).toMatchObject({ kind: 'call', at: [3, 4] });
// The inner call's `at` line is the INNER call's line, distinct from the
// statement head — assert the inequality unconditionally.
expect(inner.at?.[0]).toBe(3);
expect(inner.at?.[0]).not.toBe(outer.at?.[0]);
});
it('a `new` site carries `at` of the new-expression anchor', () => {
// `new User(x)` — `new` starts at column 16; the harvester records the
// new_expression start, matching `@reference.call.constructor`'s anchor.
const cfg = cfgOf(`function f(x) { const u = new User(x); return u; }`);
expect(siteByCallee(cfg, 'User')).toMatchObject({ kind: 'new', at: [1, 26] });
});
it('a nested closure `arr.map(x => foo(x))` records ONLY the outer `arr.map` site — `foo` is excluded', () => {
// The harvester does not record sites inside nested functions (types.ts:150),
// so `foo(x)` inside the arrow produces NO top-level SiteRecord here. The
// block's only call site is the outer `arr.map`, carrying its own `at`.
const cfg = cfgOf(`function f(arr) { arr.map(x => foo(x)); }`);
const sites = callSites(cfg);
// Exactly one call site, and it is the outer member call — not `foo`.
expect(sites).toHaveLength(1);
expect(sites.map((s) => s.callee)).toEqual(['arr.map']);
expect(siteByCallee(cfg, 'arr.map')).toMatchObject({ kind: 'call', at: [1, 18] });
// `foo` produces no site at this level (nested-fn exclusion preserved).
expect(sites.some((s) => s.callee === 'foo')).toBe(false);
});
it('member-read sites carry no `at` (the id join only consumes call/new)', () => {
// `sink(req.body)` — `req.body` is a value-position member-read site; only
// the `sink(...)` call site needs an anchor for the resolved-id join.
const cfg = cfgOf(`function f(req) { sink(req.body); }`);
const memberReads = allSites(cfg).filter((s) => s.kind === 'member-read');
expect(memberReads.length).toBeGreaterThan(0);
// No member-read site carries `at` (omit-when-absent for non-call sites).
expect(memberReads.every((s) => s.at === undefined)).toBe(true);
// The enclosing call site does carry one.
expect(siteByCallee(cfg, 'sink')).toMatchObject({ kind: 'call', at: [1, 18] });
});
});