Files
rea/src/domain/javascript/javascriptSemanticFingerprints.ts
T

289 lines
10 KiB
TypeScript

import { createHash } from "node:crypto";
import * as t from "@babel/types";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import type { JavaScriptSemanticCallAnalysis } from "./javascriptSemanticCalls.js";
import type {
JavaScriptSemanticCallable,
JavaScriptSemanticChildProcessInteraction,
JavaScriptSemanticChildProcessSpawn,
JavaScriptSemanticEventOperation,
JavaScriptSemanticFunctionFingerprint,
JavaScriptSemanticPromiseOperation,
JavaScriptSemanticRequestOperation,
JavaScriptSemanticResourceOperation,
JavaScriptSemanticTimerOperation,
} from "./javascriptSemanticIr.js";
import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js";
import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js";
import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js";
interface SemanticFingerprintCollectionInput {
readonly state: JavaScriptSemanticAnalysisState;
readonly callables: readonly JavaScriptSemanticCallable[];
readonly calls: JavaScriptSemanticCallAnalysis;
readonly promises: readonly JavaScriptSemanticPromiseOperation[];
readonly events: readonly JavaScriptSemanticEventOperation[];
readonly timers: readonly JavaScriptSemanticTimerOperation[];
readonly childProcessSpawns: readonly JavaScriptSemanticChildProcessSpawn[];
readonly childProcessInteractions: readonly JavaScriptSemanticChildProcessInteraction[];
readonly requestOperations: readonly JavaScriptSemanticRequestOperation[];
readonly resourceOperations: readonly JavaScriptSemanticResourceOperation[];
readonly parserPartial: boolean;
}
/** Build bounded rename- and formatting-resistant components per callable, yielding between callables. */
export function* collectJavaScriptSemanticFingerprintsSteps(
input: SemanticFingerprintCollectionInput,
): Generator<void, JavaScriptSemanticFunctionFingerprint[]> {
const {
state,
callables,
calls,
promises,
events,
timers,
childProcessSpawns,
childProcessInteractions,
requestOperations,
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);
const fingerprints: JavaScriptSemanticFunctionFingerprint[] = [];
for (const callable of callables) {
if (callable.kind === "class") continue;
const node = state.callableNodesById.get(callable.callableId);
if (node === undefined) continue;
yield;
const syntax = yield* fingerprintSyntaxSteps(node);
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) ?? [];
fingerprints.push({
callableId: callable.callableId,
status:
parserPartial || callable.returnCoverage.status !== "complete"
? "partial"
: "complete",
components: {
parameterArity: callableParameterArity(node),
normalizedAstSha256: digest(syntax.normalizedTokens),
controlFlowSha256: digest(syntax.controlFlowTokens),
relationShapeSha256: digest(
relationShape(
callable,
callSitesByCaller.get(callable.callableId) ?? [],
captureCountByCallable.get(callable.callableId) ?? 0,
promiseEffects,
),
),
literalSetSha256: digest(
[...new Set(syntax.literals)].sort(compareUnicodeCodePoints),
),
effects: [
...(isAsyncCallable(node) ? (["async"] as const) : []),
...(childProcessEffects.length > 0
? (["child-process"] as const)
: []),
...(eventEffects.length > 0 ? (["event"] as const) : []),
...(networkEffects.length > 0 ? (["network"] as const) : []),
...(promiseEffects.length > 0 ? (["promise"] as const) : []),
...(resourceEffects.length > 0 ? (["resource"] as const) : []),
...(timerEffects.length > 0 ? (["timer"] as const) : []),
],
},
limitations: [
"The fingerprint is a static candidate and does not prove behavioral equivalence.",
...(parserPartial
? ["Incomplete semantic recovery makes this fingerprint partial."]
: []),
],
});
}
return fingerprints;
}
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[];
readonly literals: readonly string[];
}
function* fingerprintSyntaxSteps(
root: t.Node,
): Generator<void, FingerprintSyntax> {
const normalizedTokens: string[] = [];
const controlFlowTokens: string[] = [];
const literals: string[] = [];
yield* traverseJavaScriptAstSteps(root, {
skip: (node) => semanticCallableIdForNode(node) !== null,
enter: (node, parent) => {
normalizedTokens.push(normalizedNodeToken(node, parent));
const control = controlFlowToken(node);
if (control !== null) controlFlowTokens.push(control);
const literal = semanticLiteral(node);
if (literal !== null) literals.push(literal);
},
});
return { normalizedTokens, controlFlowTokens, literals };
}
const normalizedNodeToken = (node: t.Node, parent: t.Node | null): string => {
if (t.isIdentifier(node))
return propertyIdentity(node, parent)
? `property:${node.name}`
: "identifier";
if (t.isStringLiteral(node))
return propertyIdentity(node, parent)
? `property:${node.value}`
: "string-literal";
const attributes = [
primitiveAttribute(node, "operator"),
primitiveAttribute(node, "kind"),
primitiveAttribute(node, "computed"),
primitiveAttribute(node, "optional"),
primitiveAttribute(node, "async"),
primitiveAttribute(node, "generator"),
].filter((value): value is string => value !== null);
return [node.type, ...attributes].join(":");
};
const primitiveAttribute = (node: t.Node, key: string): string | null => {
const value: unknown = Reflect.get(node, key);
return typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
? `${key}=${String(value)}`
: null;
};
const propertyIdentity = (node: t.Node, parent: t.Node | null): boolean => {
if (parent === null) return false;
if (
(t.isMemberExpression(parent) || t.isOptionalMemberExpression(parent)) &&
parent.property === node
)
return !parent.computed || t.isStringLiteral(node);
if (
(t.isObjectProperty(parent) ||
t.isObjectMethod(parent) ||
t.isClassMethod(parent) ||
t.isClassProperty(parent)) &&
parent.key === node
)
return !parent.computed || t.isStringLiteral(node);
return false;
};
const controlFlowToken = (node: t.Node): string | null => {
if (
!t.isIfStatement(node) &&
!t.isConditionalExpression(node) &&
!t.isSwitchStatement(node) &&
!t.isSwitchCase(node) &&
!t.isLoop(node) &&
!t.isTryStatement(node) &&
!t.isCatchClause(node) &&
!t.isReturnStatement(node) &&
!t.isThrowStatement(node) &&
!t.isAwaitExpression(node) &&
!t.isYieldExpression(node) &&
!t.isLogicalExpression(node)
)
return null;
const operator = primitiveAttribute(node, "operator");
return operator === null ? node.type : `${node.type}:${operator}`;
};
const semanticLiteral = (node: t.Node): string | null => {
if (t.isStringLiteral(node)) return `string:${node.value}`;
if (t.isNumericLiteral(node)) return `number:${String(node.value)}`;
if (t.isBooleanLiteral(node)) return `boolean:${String(node.value)}`;
if (t.isNullLiteral(node)) return "null";
if (t.isBigIntLiteral(node)) return `bigint:${node.value}`;
if (t.isRegExpLiteral(node)) return `regexp:${node.pattern}/${node.flags}`;
if (t.isTemplateElement(node))
return `template:${node.value.cooked ?? node.value.raw}`;
return null;
};
const relationShape = (
callable: JavaScriptSemanticCallable,
callSites: JavaScriptSemanticCallAnalysis["callSites"],
captureCount: number,
promises: readonly JavaScriptSemanticPromiseOperation[],
): string[] => [
`returns:${String(callable.returnSites.length)}:${callable.returnCoverage.status}`,
...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}`,
),
];
const callableParameterArity = (node: t.Node): number =>
t.isFunction(node) ? node.params.length : 0;
const isAsyncCallable = (node: t.Node): boolean =>
t.isFunction(node) && node.async;
const digest = (value: readonly string[]): string =>
createHash("sha256").update(JSON.stringify(value)).digest("hex");