diff --git a/packages/mcp/src/handlers/context-handlers.ts b/packages/mcp/src/handlers/context-handlers.ts index f9537134..d3d08f4b 100644 --- a/packages/mcp/src/handlers/context-handlers.ts +++ b/packages/mcp/src/handlers/context-handlers.ts @@ -155,7 +155,12 @@ function formatCallsForDisplay(calls: CallInfo[]): string[] { ? ` -> ${c.target?.name} (${c.target?.file}:${c.target?.line})` : ' (unresolved)'; const prefix = c.type === 'METHOD_CALL' ? `${c.object}.` : ''; - lines.push(` - ${prefix}${c.name}()${target}`); + // R2: annotate non-precise resolutions so the reader sees the sound-superset + // fan-out (the precise default stays silent to avoid noise). + const prec = c.precision && c.precision.precision !== 'precise' + ? ` [${c.precision.precision}: ${c.precision.candidateCount} candidates]` + : ''; + lines.push(` - ${prefix}${c.name}()${target}${prec}`); } // Transitive calls (grouped by depth) diff --git a/packages/mcp/src/handlers/dataflow-handlers.ts b/packages/mcp/src/handlers/dataflow-handlers.ts index 2d5d54dc..7b28ea0b 100644 --- a/packages/mcp/src/handlers/dataflow-handlers.ts +++ b/packages/mcp/src/handlers/dataflow-handlers.ts @@ -477,7 +477,31 @@ export async function handleTraceEffects(args: TraceEffectsArgs): Promise 0) { + lines.push(`### ⚠ Suspected-unsound resolutions (${result.suspected_unsound.length})`); + for (const u of result.suspected_unsound) { + lines.push( + ` ${u.call_name} → ${u.resolved_to_name} (wrong: receiver '${u.receiver_module}' collapsed; ` + + `presents ${u.presented_effects.join(', ') || 'PURE'})`, + ); } lines.push(''); } diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index fd7de4b1..bd168da5 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -223,6 +223,26 @@ export { findCallsInFunction, findContainingFunction, traceValues, aggregateValu export { traceDataflow, traceForwardBFS, traceBackwardBFS, makeDataflowIndexCache } from './queries/index.js'; export { traceCallChain } from './queries/index.js'; export { traceEffects } from './queries/index.js'; +// Resolution-precision marker (RFD R1) + consumers (R4 soundness check). +export { + classifyResolution, + auditResolutionPrecision, + receiverExternalImport, + soundnessOf, + checkResolutionSoundness, +} from './queries/index.js'; +export type { + ResolutionPrecision, + ResolutionMarker, + ResolutionBasis, + ClassifyContext, + MarkedResolution, + UnsoundCase, + ResolutionPrecisionAudit, + SoundnessVerdict, + ResolutionSoundnessViolation, + ResolutionSoundnessReport, +} from './queries/index.js'; export { getShape } from './queries/index.js'; export type { ShapeResult, ShapeMember, ClassIndex } from './queries/index.js'; export { buildNodeContext, getNodeDisplayName, formatEdgeMetadata, STRUCTURAL_EDGE_TYPES } from './queries/index.js'; @@ -252,6 +272,7 @@ export type { BoundaryCrossing, LeafSource, TraceEffectsOptions, + UnsoundResolutionLeaf, } from './queries/index.js'; // Notation — DSL rendering engine diff --git a/packages/util/src/queries/findCallsInFunction.ts b/packages/util/src/queries/findCallsInFunction.ts index 3361523a..7fada22d 100644 --- a/packages/util/src/queries/findCallsInFunction.ts +++ b/packages/util/src/queries/findCallsInFunction.ts @@ -33,6 +33,8 @@ */ import type { CallInfo, FindCallsOptions } from './types.js'; +import { classifyResolution } from './resolutionPrecision.js'; +import type { DataflowNode, DataflowEdge } from './traceDataflow.js'; /** * Graph backend interface (minimal surface) @@ -50,7 +52,7 @@ interface GraphBackend { getOutgoingEdges( nodeId: string, edgeTypes: string[] | null - ): Promise>; + ): Promise }>>; } /** Normalize node type field (RFDB uses nodeType, some backends use type) */ @@ -187,8 +189,10 @@ async function buildCallInfo( const isRemote = callsEdges.length > 0 && callsEdges[0].type === 'CALLS_REMOTE'; let target = undefined; + let precision: CallInfo['precision'] = undefined; if (isResolved) { - const targetNode = await backend.getNode(callsEdges[0].dst); + const resolvedEdge = callsEdges[0]; + const targetNode = await backend.getNode(resolvedEdge.dst); if (targetNode) { target = { id: targetNode.id, @@ -196,6 +200,18 @@ async function buildCallInfo( file: targetNode.file, line: targetNode.line, }; + + // R2: precision marker. find_calls has no queryNodes surface, so the + // receiver-import walk is skipped (receiverIsExternalImport=false) — this + // path reports precise / heuristic-superset from fan-out + resolvedVia, + // never suspected-unsound (reserved for the full-walk audit/traceEffects). + precision = classifyResolution({ + call: { id: callNode.id, type: getNodeType(callNode), name: callNode.name } as DataflowNode, + edge: { src: callNode.id, dst: resolvedEdge.dst, type: resolvedEdge.type, metadata: resolvedEdge.metadata } as DataflowEdge, + target: { id: targetNode.id, type: getNodeType(targetNode), name: targetNode.name } as DataflowNode, + candidateCount: callsEdges.length, + receiverIsExternalImport: false, + }); } } @@ -210,6 +226,7 @@ async function buildCallInfo( line: callNode.line, depth, remote: isRemote, + precision, }; } diff --git a/packages/util/src/queries/index.ts b/packages/util/src/queries/index.ts index 4a48b9a1..d62fb56e 100644 --- a/packages/util/src/queries/index.ts +++ b/packages/util/src/queries/index.ts @@ -47,8 +47,14 @@ export type { DataflowIndexCache, } from './traceDataflow.js'; export { traceEffects } from './traceEffects.js'; -export type { TraceEffectsResult, BoundaryCrossing, LeafSource, TraceEffectsOptions } from './traceEffects.js'; -export { classifyResolution, auditResolutionPrecision } from './resolutionPrecision.js'; +export type { TraceEffectsResult, BoundaryCrossing, LeafSource, TraceEffectsOptions, UnsoundResolutionLeaf } from './traceEffects.js'; +export { + classifyResolution, + auditResolutionPrecision, + receiverExternalImport, + soundnessOf, + checkResolutionSoundness, +} from './resolutionPrecision.js'; export type { ResolutionPrecision, ResolutionMarker, @@ -57,4 +63,7 @@ export type { MarkedResolution, UnsoundCase, ResolutionPrecisionAudit, + SoundnessVerdict, + ResolutionSoundnessViolation, + ResolutionSoundnessReport, } from './resolutionPrecision.js'; diff --git a/packages/util/src/queries/resolutionPrecision.ts b/packages/util/src/queries/resolutionPrecision.ts index f4cfa3ec..05da35bc 100644 --- a/packages/util/src/queries/resolutionPrecision.ts +++ b/packages/util/src/queries/resolutionPrecision.ts @@ -200,7 +200,7 @@ function isReceiverCollapsed(call: DataflowNode, target: DataflowNode): boolean // null. Kept here so the marker is self-contained for the spike; the production // merge would share the one copy in traceEffects. -async function receiverExternalImport( +export async function receiverExternalImport( backend: DataflowBackend, callId: string, ): Promise { @@ -343,3 +343,106 @@ function readEffects(node: DataflowNode | null): string[] { } return []; } + +// ── R4: soundness taxonomy + util-layer invariant check ─────── +// +// The three precision classes carry a SOUNDNESS verdict the ≤1-target +// "is-a-function" guarantee cannot express: +// +// precise → SOUND (the resolution names the one real callee). +// heuristic-superset → SOUND (acceptable-but-noisy: the real target IS in +// the candidate set; the resolver over- +// approximated but dropped nothing). +// suspected-unsound → UNSOUND (a DEFECT: the real target was dropped and a +// single WRONG callee presented in its place — +// axios.get → ecma GLOBAL::get. This passes the +// ≤1-target guarantee while lying about the +// callee, so it needs an explicit hard flag). +// +// `checkResolutionSoundness` is the explicit hard-flag: given a backend it +// returns every unsound resolution as a VIOLATION. It is a plain function +// (NOT a guarantees.yaml rule — that surface is vm-owned), so the unsound +// class is caught at the util layer even though it survives the structural +// guarantee. + +/** Soundness verdict for a precision class (R4 taxonomy). */ +export type SoundnessVerdict = 'sound' | 'sound-superset' | 'unsound'; + +/** Map a precision class to its soundness verdict. */ +export function soundnessOf(precision: ResolutionPrecision): SoundnessVerdict { + switch (precision) { + case 'precise': + return 'sound'; + case 'heuristic-superset': + return 'sound-superset'; + case 'suspected-unsound': + return 'unsound'; + } +} + +/** A single unsound resolution surfaced as a hard violation. */ +export interface ResolutionSoundnessViolation { + callId: string; + callName: string; + /** The (wrong) single resolved target. */ + resolvedTo: string; + resolvedToName: string; + /** The collapsed-away receiver's module specifier (e.g. "axios"). */ + receiverModule: string; + /** Effects the wrong target masks (e.g. ["PURE"]). */ + presentedEffects: string[]; + basis: ResolutionBasis; + /** Always 'unsound' here — kept explicit so callers can branch on verdict. */ + verdict: SoundnessVerdict; +} + +export interface ResolutionSoundnessReport { + /** True iff at least one unsound resolution was found. */ + hasUnsound: boolean; + /** Every unsound resolution as a hard violation. */ + violations: ResolutionSoundnessViolation[]; + /** Tally by soundness verdict across all resolution edges. */ + bySoundness: Record; +} + +/** + * R4 invariant check: flag every UNSOUND resolution (suspected-unsound class) + * as a hard violation. Sound and sound-superset resolutions are tallied but + * never reported as violations. + * + * This is the util-layer counterpart to the ≤1-target "is-a-function" + * guarantee: that guarantee passes on the axios.get→GLOBAL::get defect (it IS + * one function); THIS check fails it. + */ +export async function checkResolutionSoundness( + backend: DataflowBackend, +): Promise { + const audit = await auditResolutionPrecision(backend); + + const bySoundness: Record = { + sound: 0, + 'sound-superset': 0, + unsound: 0, + }; + for (const r of audit.resolutions) { + bySoundness[soundnessOf(r.marker.precision)] += 1; + } + + const violations: ResolutionSoundnessViolation[] = []; + for (const r of audit.resolutions) { + if (r.marker.precision !== 'suspected-unsound') continue; + const u = audit.suspectedUnsound.find(c => c.callId === r.callId && c.resolvedTo === r.targetId); + violations.push({ + callId: r.callId, + callName: r.callName, + resolvedTo: r.targetId, + resolvedToName: r.targetName, + receiverModule: u?.receiverModule ?? '', + presentedEffects: u?.presentedEffects ?? [], + basis: r.marker.basis, + verdict: 'unsound', + }); + } + + return { hasUnsound: violations.length > 0, violations, bySoundness }; +} diff --git a/packages/util/src/queries/traceEffects.ts b/packages/util/src/queries/traceEffects.ts index 3fb92792..801a5c38 100644 --- a/packages/util/src/queries/traceEffects.ts +++ b/packages/util/src/queries/traceEffects.ts @@ -11,12 +11,17 @@ * @module queries/traceEffects */ -import type { DataflowBackend, DataflowNode } from './traceDataflow.js'; +import type { DataflowBackend, DataflowNode, DataflowEdge } from './traceDataflow.js'; import type { EffectsLookup } from '../manifest/effects-lookup.js'; import { parseCallTarget } from '../manifest/effects-lookup.js'; import type { EffectType } from '../manifest/types.js'; import { isValidEffect } from '../manifest/types.js'; import { findCallsInFunction } from './findCallsInFunction.js'; +import { + classifyResolution, + receiverExternalImport, + type ResolutionMarker, +} from './resolutionPrecision.js'; // ── Public types ────────────────────────────────────────────── @@ -29,6 +34,15 @@ export interface TraceEffectsResult { boundary_crossings: BoundaryCrossing[]; /** Where effects originate (external calls) */ leaf_sources: LeafSource[]; + /** + * R5: suspected-unsound resolutions encountered while tracing — a member + * call whose receiver is a real non-relative import but that resolved to a + * PURE/empty ecma-GLOBAL (the axios.get→GLOBAL::get suffix-collapse defect). + * The effects PART A recovers via effects-db; this list is the explicit + * PRECISION mark on top, so a consumer SEES "unsound resolution" instead of + * silently trusting the wrong PURE global. + */ + suspected_unsound: UnsoundResolutionLeaf[]; /** True if traversal was truncated by depth limit */ max_depth_reached: boolean; /** Configured depth limit */ @@ -36,6 +50,24 @@ export interface TraceEffectsResult { nodes_visited: number; } +/** + * R5: a suspected-unsound resolution surfaced from a trace. + */ +export interface UnsoundResolutionLeaf { + /** The originating CALL node id. */ + call_id: string; + /** The dotted call name, e.g. "axios.get". */ + call_name: string; + /** The (wrong) resolved target id, e.g. "GLOBAL::get". */ + resolved_to: string; + /** The (wrong) resolved target name, e.g. "get". */ + resolved_to_name: string; + /** The collapsed-away receiver module specifier, e.g. "axios". */ + receiver_module: string; + /** Effects the wrong global presents (what the defect masks), e.g. ["PURE"]. */ + presented_effects: string[]; +} + export interface BoundaryCrossing { from_file: string; to_file: string; @@ -55,6 +87,13 @@ export interface LeafSource { effects: EffectType[]; depth: number; file?: string; + /** + * R2: resolution-precision marker for the CALL→target edge that produced + * this leaf, when one was classifiable (i.e. the leaf came from a resolved + * CALL with a known fan-out). Additive: undefined for leaves where no + * resolution edge was classified (e.g. unresolved-call effects-db lookups). + */ + precision?: ResolutionMarker; } export interface TraceEffectsOptions { @@ -68,6 +107,16 @@ const FUNCTION_TYPES = new Set(['FUNCTION', 'METHOD', 'CONSTRUCTOR', 'LAMBDA']); // ── Helpers ─────────────────────────────────────────────────── +/** Read a leaf target's `effects` in either array or comma-string shape (R5). */ +function readLeafEffects(node: DataflowNode): string[] { + const raw = (node as Record).effects; + if (Array.isArray(raw)) return raw.map(String); + if (typeof raw === 'string' && raw.length > 0) { + return raw.split(',').map(s => s.trim()).filter(s => s.length > 0); + } + return []; +} + /** Extract direct metadata effects from a graph node (async / throw). */ function extractDirectEffects(node: DataflowNode): Set { const effects = new Set(); @@ -167,6 +216,22 @@ interface DfsResult { leafSources: LeafSource[]; boundaryCrossings: BoundaryCrossing[]; maxDepthReached: boolean; + /** R5: suspected-unsound resolutions found below this node. */ + suspectedUnsound: UnsoundResolutionLeaf[]; +} + +/** + * R5: context for classifying the resolution edge that reaches a target. + * Carried alongside the target so processTarget can attach a precision marker + * to the leaf and detect the suspected-unsound importedDefault.method() defect. + */ +interface ResolutionContext { + /** The originating CALL node (receiver-bearing), if known. */ + callNode: DataflowNode; + /** The resolution edge (CALLS / CALLS_REMOTE) that reached the target. */ + edge: DataflowEdge; + /** Total resolved targets for this CALL (fan-out). */ + candidateCount: number; } // ── Main entry ──────────────────────────────────────────────── @@ -216,6 +281,7 @@ export async function traceEffects( transitive, boundary_crossings: dfs.boundaryCrossings, leaf_sources: dfs.leafSources, + suspected_unsound: dfs.suspectedUnsound, max_depth_reached: dfs.maxDepthReached, max_depth: maxDepth, nodes_visited: visited.size, @@ -237,6 +303,7 @@ async function dfsCollectEffects( const effects = new Set(); const leafSources: LeafSource[] = []; const boundaryCrossings: BoundaryCrossing[] = []; + const suspectedUnsound: UnsoundResolutionLeaf[] = []; let maxDepthReached = false; // Collect node's own metadata effects @@ -260,9 +327,26 @@ async function dfsCollectEffects( if (!targetNode) continue; processedTargets.add(targetNode.id); + + // R5/R2: build the resolution context from the CALL node's own resolution + // edges so we can classify precision and detect the suspected-unsound + // importedDefault.method()→ecma-GLOBAL defect. The CALL node carries the + // receiver (lost once we descend into the function), so this is the only + // place the receiver walk is meaningful. + const callNode = await backend.getNode(call.id); + let resolution: ResolutionContext | undefined; + if (callNode) { + const resEdges = await backend.getOutgoingEdges(call.id, CALL_EDGE_TYPES); + const edge = resEdges.find(e => e.dst === targetNode.id); + if (edge) { + resolution = { callNode, edge, candidateCount: resEdges.length }; + } + } + const result = await processTarget( backend, node, targetNode, effectsLookup, visited, depth, maxDepth, effects, leafSources, boundaryCrossings, + suspectedUnsound, resolution, { callId: call.id, callName: call.name }, ); if (result.maxDepthReached) maxDepthReached = true; @@ -306,14 +390,24 @@ async function dfsCollectEffects( if (!targetNode) continue; processedTargets.add(targetNode.id); + // R5/R2: direct-edge layout — the edge source IS the (function-or-call) + // node. receiverExternalImport(node.id) only resolves a receiver when node + // is a member-call with a PROPERTY_ACCESS chain, so genuine FUNCTION→FUNCTION + // edges classify as precise and never mis-flag. + const resolution: ResolutionContext = { + callNode: node, + edge, + candidateCount: directCallEdges.length, + }; const result = await processTarget( backend, node, targetNode, effectsLookup, visited, depth, maxDepth, effects, leafSources, boundaryCrossings, + suspectedUnsound, resolution, ); if (result.maxDepthReached) maxDepthReached = true; } - return { effects, leafSources, boundaryCrossings, maxDepthReached }; + return { effects, leafSources, boundaryCrossings, maxDepthReached, suspectedUnsound }; } /** @@ -353,6 +447,8 @@ async function processTarget( effects: Set, leafSources: LeafSource[], boundaryCrossings: BoundaryCrossing[], + suspectedUnsound: UnsoundResolutionLeaf[], + resolution?: ResolutionContext, /** * Context about the originating CALL node, present only when the target was * discovered via findCallsInFunction (Strategy 1). Used to reconcile a @@ -363,6 +459,32 @@ async function processTarget( ): Promise<{ maxDepthReached: boolean }> { let maxDepthReached = false; + // R5/R2: classify the resolution edge that reached this leaf target. Only + // meaningful for non-recursive leaf targets (external/global); FUNCTION + // recursion below ignores it. The receiver walk runs once per resolved call. + let leafMarker: ResolutionMarker | undefined; + if (resolution && !FUNCTION_TYPES.has(targetNode.type)) { + const receiverModule = await receiverExternalImport(backend, resolution.callNode.id); + const marker = classifyResolution({ + call: resolution.callNode, + edge: resolution.edge, + target: targetNode, + candidateCount: resolution.candidateCount, + receiverIsExternalImport: receiverModule !== null, + }); + leafMarker = marker; + if (marker.precision === 'suspected-unsound') { + suspectedUnsound.push({ + call_id: resolution.callNode.id, + call_name: typeof resolution.callNode.name === 'string' ? resolution.callNode.name : '', + resolved_to: targetNode.id, + resolved_to_name: typeof targetNode.name === 'string' ? targetNode.name : '', + receiver_module: receiverModule ?? '', + presented_effects: readLeafEffects(targetNode), + }); + } + } + // ── FUNCTION / METHOD / CONSTRUCTOR / LAMBDA → recurse ── if (FUNCTION_TYPES.has(targetNode.type)) { // Record boundary crossing when caller and callee are in different files @@ -389,6 +511,7 @@ async function processTarget( for (const e of childResult.effects) effects.add(e); leafSources.push(...childResult.leafSources); boundaryCrossings.push(...childResult.boundaryCrossings); + suspectedUnsound.push(...childResult.suspectedUnsound); if (childResult.maxDepthReached) maxDepthReached = true; // Backfill boundary crossing effects @@ -441,6 +564,7 @@ async function processTarget( effects: leafEffects, depth, file: targetNode.file, + precision: leafMarker, }); return { maxDepthReached }; } @@ -475,6 +599,7 @@ async function processTarget( effects: leafEffects, depth, file: targetNode.file, + precision: leafMarker, }); return { maxDepthReached }; } @@ -492,6 +617,7 @@ async function processTarget( effects: leafEffects, depth, file: targetNode.file, + precision: leafMarker, }); } else { effects.add('UNKNOWN'); @@ -501,6 +627,7 @@ async function processTarget( effects: ['UNKNOWN'], depth, file: targetNode.file, + precision: leafMarker, }); } } else { @@ -511,6 +638,7 @@ async function processTarget( effects: ['UNKNOWN'], depth, file: targetNode.file, + precision: leafMarker, }); } return { maxDepthReached }; @@ -534,6 +662,7 @@ async function processTarget( effects: leafEffects, depth, file: targetNode.file, + precision: leafMarker, }); } else { effects.add('UNKNOWN'); @@ -543,6 +672,7 @@ async function processTarget( effects: ['UNKNOWN'], depth, file: targetNode.file, + precision: leafMarker, }); } return { maxDepthReached }; @@ -597,6 +727,7 @@ async function processTarget( for (const e of childResult.effects) effects.add(e); leafSources.push(...childResult.leafSources); boundaryCrossings.push(...childResult.boundaryCrossings); + suspectedUnsound.push(...childResult.suspectedUnsound); if (childResult.maxDepthReached) maxDepthReached = true; // Backfill boundary crossing effects @@ -625,6 +756,7 @@ async function processTarget( effects: ['UNKNOWN'], depth, file: targetNode.file, + precision: leafMarker, }); return { maxDepthReached }; } @@ -640,6 +772,7 @@ async function processTarget( effects: leafEffects, depth, file: targetNode.file, + precision: leafMarker, }); } else { effects.add('UNKNOWN'); @@ -649,6 +782,7 @@ async function processTarget( effects: ['UNKNOWN'], depth, file: targetNode.file, + precision: leafMarker, }); } diff --git a/packages/util/src/queries/types.ts b/packages/util/src/queries/types.ts index 3415ac34..437c8a3c 100644 --- a/packages/util/src/queries/types.ts +++ b/packages/util/src/queries/types.ts @@ -7,6 +7,8 @@ * @module queries/types */ +import type { ResolutionMarker } from './resolutionPrecision.js'; + /** * Information about a function/method call found in code */ @@ -36,6 +38,18 @@ export interface CallInfo { depth?: number; /** Whether this call crosses a process/language boundary (CALLS_REMOTE) */ remote?: boolean; + /** + * R2: resolution-precision marker for this call's resolved target — the + * soundness class (precise / heuristic-superset / suspected-unsound) plus + * candidateCount. Additive; present only when the call was resolved. + * + * NOTE: find_calls classifies WITHOUT the receiver-import walk (it has no + * queryNodes surface), so it reports `precise` / `heuristic-superset` from + * fan-out + resolvedVia, but never `suspected-unsound` — that nuance is + * reserved for traceEffects / auditResolutionPrecision, which run the full + * receiver walk. A caller wanting the unsound verdict should use those. + */ + precision?: ResolutionMarker; } /** diff --git a/test/unit/ResolutionPrecisionConsumers.test.js b/test/unit/ResolutionPrecisionConsumers.test.js new file mode 100644 index 00000000..081fd707 --- /dev/null +++ b/test/unit/ResolutionPrecisionConsumers.test.js @@ -0,0 +1,222 @@ +/** + * Resolution-precision marker CONSUMERS (RFD R2 / R4 / R5). + * + * Stacks on the R1 primitive (resolutionPrecision.ts). Three coupled pieces, + * proven against in-memory FixtureStorageView-style backends (~0.0s, no + * rfdb-server process) whose node/edge shape mirrors the LIVE /tmp/sep-test + * graph: + * + * R5 traceEffects surfaces a suspected-unsound MARK for the + * axios.get → ecma-GLOBAL::get suffix-collapse defect, AND attaches a + * per-leaf precision marker — while still recovering the IO via effects-db + * (PART A is complementary). + * R2 the precision marker is threaded into find_calls (CallInfo.precision) + * and the traceEffects result shape (per-leaf + suspected_unsound). + * R4 checkResolutionSoundness HARD-FLAGS the unsound resolution as a + * violation — caught even though it passes the ≤1-target guarantee — and + * maps the three classes onto the soundness taxonomy. + */ + +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { + traceEffects, + EffectsLookup, + findCallsInFunction, + checkResolutionSoundness, + soundnessOf, +} from '@grafema/util'; +import { join } from 'node:path'; + +const EFFECTS_DB_PATH = join(import.meta.dirname, '..', '..', 'effects-db'); + +// ── In-memory backend (mirrors trace-effects.test.js style) ── + +function makeBackend(nodes, edges) { + const byId = new Map(nodes.map(n => [n.id, n])); + return { + async getNode(id) { return byId.get(id) ?? null; }, + async *queryNodes(filter) { + for (const n of nodes) { + if (filter.type && n.type !== filter.type) continue; + if (filter.name && n.name !== filter.name) continue; + if (filter.file && n.file !== filter.file) continue; + yield n; + } + }, + async getOutgoingEdges(id, types) { + return edges.filter(e => e.src === id && (!types || types.includes(e.type))); + }, + async getIncomingEdges(id, types) { + return edges.filter(e => e.dst === id && (!types || types.includes(e.type))); + }, + }; +} + +// ── Fixture: fetchUser() { axios.get(...); JSON.parse(...); readFile(...); } ── +// +// fetchUser → (AWAITS) → CALL axios.get → CALLS → GLOBAL::get (PURE, runtime-globals) +// + receiver chain to non-relative import "axios" +// → (CALLS) → CALL JSON.parse → CALLS → GLOBAL::JSON.parse (THROW, receiver preserved) +// → (CALLS) → CALL readFile → CALLS → {GLOBAL::readFile, EXT:fs.readFile, IB:readFile} (fan-out 3) + +const RV_GLOBAL = { resolvedVia: 'runtime-globals', globalCategory: 'ecmascript' }; + +const NODES = [ + { id: 'FN:fetchUser', type: 'FUNCTION', name: 'fetchUser', file: 'src/api.ts', line: 1 }, + + // member-call CALLs (receiver-bearing) + { id: 'CALL:axios.get', type: 'CALL', name: 'axios.get', file: 'src/api.ts', line: 2 }, + { id: 'CALL:JSON.parse', type: 'CALL', name: 'JSON.parse', file: 'src/api.ts', line: 3 }, + { id: 'CALL:readFile', type: 'CALL', name: 'readFile', file: 'src/api.ts', line: 4 }, + + // resolved targets + { id: 'GLOBAL::get', type: 'GLOBAL_DEFINITION', name: 'get', effects: 'PURE' }, + { id: 'GLOBAL::JSON.parse', type: 'GLOBAL_DEFINITION', name: 'JSON.parse', effects: 'THROW' }, + { id: 'GLOBAL::readFile', type: 'GLOBAL_DEFINITION', name: 'readFile', effects: 'IO' }, + { id: 'EXT:fs.readFile', type: 'EXTERNAL_FUNCTION', name: 'readFile' }, + { id: 'IB:readFile', type: 'IMPORT_BINDING', name: 'readFile', source: 'fs/promises', importedName: 'readFile' }, + + // axios.get receiver chain (CALL → PROPERTY_ACCESS → REFERENCE → IMPORT_BINDING "axios") + { id: 'PA:axios.get', type: 'PROPERTY_ACCESS', name: 'get' }, + { id: 'REF:axios', type: 'REFERENCE', name: 'axios' }, + { id: 'IB:axios', type: 'IMPORT_BINDING', name: 'axios', source: 'axios', importedName: 'default' }, +]; + +const EDGES = [ + // function → its three calls (direct semantic edges; Layout B) + { src: 'FN:fetchUser', dst: 'CALL:axios.get', type: 'AWAITS' }, + { src: 'FN:fetchUser', dst: 'CALL:JSON.parse', type: 'CALLS' }, + { src: 'FN:fetchUser', dst: 'CALL:readFile', type: 'AWAITS' }, + + // axios.get → GLOBAL::get (the defect) + receiver chain + { src: 'CALL:axios.get', dst: 'GLOBAL::get', type: 'CALLS', metadata: RV_GLOBAL }, + { src: 'CALL:axios.get', dst: 'PA:axios.get', type: 'DERIVES_FROM', metadata: { kind: 'callee' } }, + { src: 'PA:axios.get', dst: 'REF:axios', type: 'READS_FROM', metadata: { kind: 'receiver' } }, + { src: 'REF:axios', dst: 'IB:axios', type: 'READS_FROM', metadata: {} }, + + // JSON.parse → GLOBAL::JSON.parse (genuine ecma-global, receiver preserved, NO import binding) + { src: 'CALL:JSON.parse', dst: 'GLOBAL::JSON.parse', type: 'CALLS', metadata: RV_GLOBAL }, + + // readFile → 3 targets (fan-out superset) + { src: 'CALL:readFile', dst: 'GLOBAL::readFile', type: 'CALLS', metadata: RV_GLOBAL }, + { src: 'CALL:readFile', dst: 'EXT:fs.readFile', type: 'CALLS', metadata: { resolvedVia: 'builtins' } }, + { src: 'CALL:readFile', dst: 'IB:readFile', type: 'CALLS', metadata: { _source: 'analyzer' } }, +]; + +describe('R5 — live suspected-unsound detection in traceEffects', () => { + it('marks axios.get → GLOBAL::get as suspected-unsound while still recovering effects', async () => { + const effectsLookup = EffectsLookup.load(EFFECTS_DB_PATH); + const backend = makeBackend(NODES, EDGES); + + const result = await traceEffects(backend, 'FN:fetchUser', effectsLookup); + assert.ok(result, 'non-null result'); + + // R5: exactly one suspected-unsound resolution, and it is axios.get. + assert.equal(result.suspected_unsound.length, 1, 'one suspected-unsound'); + const u = result.suspected_unsound[0]; + assert.equal(u.call_name, 'axios.get'); + assert.equal(u.resolved_to, 'GLOBAL::get'); + assert.equal(u.resolved_to_name, 'get'); + assert.equal(u.receiver_module, 'axios'); + assert.deepEqual(u.presented_effects, ['PURE']); + + // The axios.get leaf carries the suspected-unsound precision marker. + const axiosLeaf = result.leaf_sources.find(l => l.id === 'GLOBAL::get'); + assert.ok(axiosLeaf, 'axios.get leaf present'); + assert.equal(axiosLeaf.precision?.precision, 'suspected-unsound'); + assert.equal(axiosLeaf.precision?.basis, 'imported-default-method-to-ecma-global'); + }); + + it('does NOT mis-flag the genuine ecma-global JSON.parse', async () => { + const effectsLookup = EffectsLookup.load(EFFECTS_DB_PATH); + const backend = makeBackend(NODES, EDGES); + const result = await traceEffects(backend, 'FN:fetchUser', effectsLookup); + + const jsonLeaf = result.leaf_sources.find(l => l.id === 'GLOBAL::JSON.parse'); + assert.ok(jsonLeaf, 'JSON.parse leaf present'); + assert.equal(jsonLeaf.precision?.precision, 'precise'); + // JSON.parse is not in the suspected-unsound list. + assert.ok(!result.suspected_unsound.some(u => u.call_name === 'JSON.parse')); + }); + + it('marks the readFile fan-out as heuristic-superset (sound, noisy)', async () => { + const effectsLookup = EffectsLookup.load(EFFECTS_DB_PATH); + const backend = makeBackend(NODES, EDGES); + const result = await traceEffects(backend, 'FN:fetchUser', effectsLookup); + + const supersetLeaves = result.leaf_sources.filter( + l => l.precision?.precision === 'heuristic-superset', + ); + assert.ok(supersetLeaves.length >= 1, 'at least one superset leaf'); + for (const l of supersetLeaves) { + assert.equal(l.precision.candidateCount, 3); + assert.equal(l.precision.basis, 'multiple-candidates'); + } + }); +}); + +describe('R2 — precision marker in find_calls (CallInfo.precision)', () => { + it('threads precision onto each resolved call; superset on fan-out, precise otherwise', async () => { + const backend = makeBackend(NODES, EDGES); + const calls = await findCallsInFunction(backend, 'FN:fetchUser', { transitive: false }); + + const byName = Object.fromEntries(calls.map(c => [c.name, c])); + + // JSON.parse: single concrete target → precise. + assert.equal(byName['JSON.parse'].precision?.precision, 'precise'); + assert.equal(byName['JSON.parse'].precision?.candidateCount, 1); + + // readFile: fan-out 3 → heuristic-superset. + assert.equal(byName['readFile'].precision?.precision, 'heuristic-superset'); + assert.equal(byName['readFile'].precision?.candidateCount, 3); + + // axios.get: find_calls skips the receiver walk → NOT suspected-unsound here + // (it reports precise from a single non-superset target; the unsound nuance + // is reserved for traceEffects / the audit, which run the full walk). + assert.equal(byName['axios.get'].precision?.precision, 'precise'); + assert.notEqual(byName['axios.get'].precision?.precision, 'suspected-unsound'); + }); +}); + +describe('R4 — soundness taxonomy + util-layer invariant check', () => { + it('soundnessOf maps the three classes onto the verdict', () => { + assert.equal(soundnessOf('precise'), 'sound'); + assert.equal(soundnessOf('heuristic-superset'), 'sound-superset'); + assert.equal(soundnessOf('suspected-unsound'), 'unsound'); + }); + + it('checkResolutionSoundness HARD-FLAGS the axios.get unsound resolution', async () => { + const backend = makeBackend(NODES, EDGES); + const report = await checkResolutionSoundness(backend); + + assert.equal(report.hasUnsound, true, 'has an unsound violation'); + assert.equal(report.violations.length, 1, 'exactly one violation'); + const v = report.violations[0]; + assert.equal(v.callName, 'axios.get'); + assert.equal(v.resolvedTo, 'GLOBAL::get'); + assert.equal(v.receiverModule, 'axios'); + assert.equal(v.verdict, 'unsound'); + assert.equal(v.basis, 'imported-default-method-to-ecma-global'); + assert.deepEqual(v.presentedEffects, ['PURE']); + + // Taxonomy tally: 1 unsound (axios.get→GLOBAL), 3 sound-superset (readFile + // fan-out), 1 sound (JSON.parse). + assert.equal(report.bySoundness.unsound, 1); + assert.equal(report.bySoundness['sound-superset'], 3); + assert.equal(report.bySoundness.sound, 1); + }); + + it('a clean graph (no defect) reports no violations', async () => { + // Drop the axios.get defect and its receiver chain → only JSON.parse + readFile. + const cleanNodes = NODES.filter(n => !n.id.includes('axios')); + const cleanEdges = EDGES.filter( + e => !e.src.includes('axios') && !e.dst.includes('axios') + && !(e.src === 'PA:axios.get') && !(e.dst === 'PA:axios.get'), + ); + const backend = makeBackend(cleanNodes, cleanEdges); + const report = await checkResolutionSoundness(backend); + assert.equal(report.hasUnsound, false); + assert.equal(report.violations.length, 0); + }); +});