perf(javascript): index semantic fingerprint relations (#942)

This commit is contained in:
morluto 2026-10-07 19:52:29 +08:00 committed by GitHub
parent ac3bae3cb9
commit c92a344d47
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 113 additions and 32 deletions

View File

@ -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; }

View File

@ -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 = <T extends { readonly ownerCallableId: string | null }>(
value: T,
): string | null => value.ownerCallableId;
const groupByOwner = <T, K extends string | null>(
values: readonly T[],
getOwner: (value: T) => K,
): Map<K, T[]> => {
const grouped = new Map<K, T[]>();
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 = <T, K extends string | null>(
values: readonly T[],
getOwner: (value: T) => K,
): Map<K, number> => {
const counts = new Map<K, number>();
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}`,