diff --git a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts index 899a1267d..78fea9845 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts @@ -192,6 +192,51 @@ describe("JavaScript semantic analysis: calls 2", () => { ).not.toBe(left.functionFingerprints[0]?.components.literalSetSha256); }); + it("keeps fingerprint relations ordered and effects scoped to their owner", () => { + const ordered = analyzeJavaScriptSemantics(` + function first() {} + function second(one, two) {} + function ordered() { first(); second(1, 2); } + function timerOwner() { setTimeout(work, 10); } + function networkOwner() { fetch('/data'); } + function noEffects() { return 1; } + `); + const reversed = analyzeJavaScriptSemantics(` + function first() {} + function second(one, two) {} + function ordered() { second(1, 2); first(); } + function timerOwner() { setTimeout(work, 10); } + function networkOwner() { fetch('/data'); } + function noEffects() { return 1; } + `); + const fingerprint = (ir: typeof ordered, name: string) => + ir.functionFingerprints.find((value) => + ir.callables.some( + (callable) => + callable.callableId === value.callableId && callable.name === name, + ), + ); + + expect( + fingerprint(ordered, "ordered")?.components.relationShapeSha256, + ).not.toBe( + fingerprint(reversed, "ordered")?.components.relationShapeSha256, + ); + expect(fingerprint(ordered, "timerOwner")?.components.effects).toContain( + "timer", + ); + expect(fingerprint(ordered, "networkOwner")?.components.effects).toContain( + "network", + ); + expect(fingerprint(ordered, "noEffects")?.components.effects).toEqual([]); + expect( + fingerprint(ordered, "timerOwner")?.components.effects, + ).not.toContain("network"); + expect( + fingerprint(ordered, "networkOwner")?.components.effects, + ).not.toContain("timer"); + }); + it("retains ambiguous local callees and explicit dynamic frontiers", () => { const ir = analyzeJavaScriptSemantics(` function left(value) { return value; } diff --git a/src/domain/javascript/javascriptSemanticFingerprints.ts b/src/domain/javascript/javascriptSemanticFingerprints.ts index 260f8bd9c..2e3336741 100644 --- a/src/domain/javascript/javascriptSemanticFingerprints.ts +++ b/src/domain/javascript/javascriptSemanticFingerprints.ts @@ -50,30 +50,36 @@ export const collectJavaScriptSemanticFingerprints = ( resourceOperations, parserPartial, } = input; + const callSitesByCaller = groupByOwner( + calls.callSites, + ({ callerCallableId }) => callerCallableId, + ); + const captureCountByCallable = countByOwner( + calls.closureCaptures, + ({ callableId }) => callableId, + ); + const promiseEffectsByOwner = groupByOwner(promises, ownerId); + const eventEffectsByOwner = groupByOwner(events, ownerId); + const timerEffectsByOwner = groupByOwner(timers, ownerId); + const childProcessEffectsByOwner = groupByOwner( + [...childProcessSpawns, ...childProcessInteractions], + ownerId, + ); + const networkEffectsByOwner = groupByOwner(requestOperations, ownerId); + const resourceEffectsByOwner = groupByOwner(resourceOperations, ownerId); return callables.flatMap((callable) => { if (callable.kind === "class") return []; const node = state.callableNodesById.get(callable.callableId); if (node === undefined) return []; const syntax = fingerprintSyntax(node, callable.callableId); - const promiseEffects = promises.filter( - ({ ownerCallableId }) => ownerCallableId === callable.callableId, - ); - const eventEffects = events.filter( - ({ ownerCallableId }) => ownerCallableId === callable.callableId, - ); - const timerEffects = timers.filter( - ({ ownerCallableId }) => ownerCallableId === callable.callableId, - ); - const childProcessEffects = [ - ...childProcessSpawns, - ...childProcessInteractions, - ].filter(({ ownerCallableId }) => ownerCallableId === callable.callableId); - const networkEffects = requestOperations.filter( - ({ ownerCallableId }) => ownerCallableId === callable.callableId, - ); - const resourceEffects = resourceOperations.filter( - ({ ownerCallableId }) => ownerCallableId === callable.callableId, - ); + const promiseEffects = promiseEffectsByOwner.get(callable.callableId) ?? []; + const eventEffects = eventEffectsByOwner.get(callable.callableId) ?? []; + const timerEffects = timerEffectsByOwner.get(callable.callableId) ?? []; + const childProcessEffects = + childProcessEffectsByOwner.get(callable.callableId) ?? []; + const networkEffects = networkEffectsByOwner.get(callable.callableId) ?? []; + const resourceEffects = + resourceEffectsByOwner.get(callable.callableId) ?? []; return [ { callableId: callable.callableId, @@ -86,7 +92,12 @@ export const collectJavaScriptSemanticFingerprints = ( normalizedAstSha256: digest(syntax.normalizedTokens), controlFlowSha256: digest(syntax.controlFlowTokens), relationShapeSha256: digest( - relationShape(callable, calls, promiseEffects), + relationShape( + callable, + callSitesByCaller.get(callable.callableId) ?? [], + captureCountByCallable.get(callable.callableId) ?? 0, + promiseEffects, + ), ), literalSetSha256: digest( [...new Set(syntax.literals)].sort(compareCodePoints), @@ -114,6 +125,36 @@ export const collectJavaScriptSemanticFingerprints = ( }); }; +const ownerId = ( + value: T, +): string | null => value.ownerCallableId; + +const groupByOwner = ( + values: readonly T[], + getOwner: (value: T) => K, +): Map => { + const grouped = new Map(); + for (const value of values) { + const owner = getOwner(value); + const ownedValues = grouped.get(owner); + if (ownedValues === undefined) grouped.set(owner, [value]); + else ownedValues.push(value); + } + return grouped; +}; + +const countByOwner = ( + values: readonly T[], + getOwner: (value: T) => K, +): Map => { + const counts = new Map(); + for (const value of values) { + const owner = getOwner(value); + counts.set(owner, (counts.get(owner) ?? 0) + 1); + } + return counts; +}; + interface FingerprintSyntax { readonly normalizedTokens: readonly string[]; readonly controlFlowTokens: readonly string[]; @@ -229,21 +270,16 @@ const semanticLiteral = (node: t.Node): string | null => { const relationShape = ( callable: JavaScriptSemanticCallable, - calls: JavaScriptSemanticCallAnalysis, + callSites: JavaScriptSemanticCallAnalysis["callSites"], + captureCount: number, promises: readonly JavaScriptSemanticPromiseOperation[], ): string[] => [ `returns:${String(callable.returnSites.length)}:${callable.returnCoverage.status}`, - ...calls.callSites - .filter(({ callerCallableId }) => callerCallableId === callable.callableId) - .map( - ({ kind, resolution, arguments: args, calleeCallableIds }) => - `call:${kind}:${resolution}:${String(args.length)}:${String(calleeCallableIds.length)}`, - ), - `captures:${String( - calls.closureCaptures.filter( - ({ callableId }) => callableId === callable.callableId, - ).length, - )}`, + ...callSites.map( + ({ kind, resolution, arguments: args, calleeCallableIds }) => + `call:${kind}:${resolution}:${String(args.length)}:${String(calleeCallableIds.length)}`, + ), + `captures:${String(captureCount)}`, ...promises.map( ({ kind, method, ownership, sourceResolution }) => `promise:${kind}:${method}:${ownership}:${sourceResolution}`,