fix(javascript): version syntax-aware structural fingerprints

Retain bigint/directive values, assignment operators, fixed public keys and
execution-affecting syntax flags in v3. Preserve local/private binding renames
and isolate structural matching by algorithm version. Remove the redundant
test grouping and document structural matching as heuristic evidence.

Validated AST, worker admission, archived-version matching, reconstruction and
public CLI comparisons. Auto-accessors are tested at the AST boundary because
the production JavaScript parser does not enable their Babel plugin.
Refs #3952, #4020.

Co-authored-by: ShitK <1724059440@qq.com>
This commit is contained in:
morluto
2026-10-16 05:43:14 +08:00
parent c0d550b231
commit 0ef938f4c5
8 changed files with 421 additions and 58 deletions
+19 -7
View File
@@ -311,13 +311,25 @@ suffix. Utility calls such as `useUtils().invoice.list.fetch()`, `useQueries`,
and links whose callee was renamed by a minifier are not recognized.
Each recovered bundle module retains the exact factory-source digest. A complete
bounded AST also receives a `babel-ast-v2` structural fingerprint that ignores
ordinary identifier names while retaining syntax, literals, operators, object
keys, and member properties. Computed member identifiers retain their access
syntax without treating a local variable's spelling as a fixed property; template
literal contents remain part of the fingerprint. Version comparison keeps the
algorithm in its matching key, so retained `babel-ast-v1` fingerprints cannot match
these new fingerprints. Re-analyze both snapshots to compare their new structure.
bounded AST also receives a `babel-ast-v3` structural fingerprint. It ignores
ordinary identifier spellings while retaining literal and directive values,
operators, fixed property names, and selected syntax flags such as computed
keys, declaration kind, function async/generator state, update-expression
prefix, method kind, and class static state. Local names used in computed keys
remain rename-resistant; private member names are also ignored while their
method kind and static state remain represented.
This fingerprint is structural evidence for candidate matching, not proof that
two modules behave equivalently. Its scope follows the AST facts listed above;
unrepresented syntax can still collide. The exact source digest remains
available to show byte-level change, and comparison reports content changes even
when structural matching succeeds.
Version comparison includes the algorithm in its structural matching key.
Retained `babel-ast-v1` results can still match other v1 results, and v2 results
can still match v2 results, but v1/v2 results cannot structurally match v3
results. Other matching tiers remain available across algorithm versions.
Re-analyze both snapshots to compare using the v3 rules.
If fingerprint construction reaches its structural
bound, the fingerprint is unavailable and its status is `truncated`; a bounded
prefix is never presented as a complete stable fingerprint.
@@ -14,7 +14,12 @@ import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVer
const ARTIFACT_SHA = "a".repeat(64);
const moduleNode = (name: string, source: string, structure?: string) =>
const moduleNode = (
name: string,
source: string,
structure?: string,
algorithm = "fixture",
) =>
fixtureNode(
"javascript-module",
{
@@ -30,7 +35,7 @@ const moduleNode = (name: string, source: string, structure?: string) =>
? {}
: {
structural_fingerprint_sha256: structure.repeat(64),
structural_fingerprint_algorithm: "fixture",
structural_fingerprint_algorithm: algorithm,
}),
},
artifactEvidence(ARTIFACT_SHA, `${name}.js`),
@@ -66,6 +71,27 @@ const compare = (
});
describe("application version matching", () => {
it.each([
["babel-ast-v1", "babel-ast-v1"],
["babel-ast-v2", "babel-ast-v2"],
["babel-ast-v3", "babel-ast-v3"],
["babel-ast-v1", "babel-ast-v2"],
["babel-ast-v1", "babel-ast-v3"],
["babel-ast-v2", "babel-ast-v3"],
])(
"compares %s with %s without mixing fingerprint algorithms",
(leftAlgorithm, rightAlgorithm) => {
const left = moduleNode("before", "1", "a", leftAlgorithm);
const right = moduleNode("after", "2", "a", rightAlgorithm);
const result = compare(graph([left]), graph([right]));
const sameAlgorithm = leftAlgorithm === rightAlgorithm;
expect(result.matching.structural_fingerprint).toBe(
sameAlgorithm ? 1 : 0,
);
expect(result.matching.unmatched).toBe(sameAlgorithm ? 0 : 2);
},
);
it("does not revisit a source group that becomes unique after identity matching", () => {
const shared = moduleNode("shared", "1");
const leftOnly = moduleNode("left", "1");
@@ -1,21 +1,159 @@
import { parse } from "@babel/parser";
import { describe, expect, it } from "vitest";
import { expect, it } from "vitest";
import { fingerprintJavaScriptAst } from "./javascriptAstFingerprint.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
describe("JavaScript AST fingerprints", () => {
it("includes complete literal values in the fingerprint", () => {
const prefix = "x".repeat(1_024);
const left = parse(`const value = ${JSON.stringify(`${prefix}left`)};`);
const right = parse(`const value = ${JSON.stringify(`${prefix}right`)};`);
expect(fingerprintJavaScriptAst(left)).not.toBe(
fingerprintJavaScriptAst(right),
);
});
it.each([
["bigint values", "const value = 1n;", "const value = 2n;"],
[
"bigint object keys",
"const value = { 1n: 0 };",
"const value = { 2n: 0 };",
],
[
"empty fixed object keys",
'const value = { "": 0 };',
'const value = { "x": 0 };',
],
["directives", '"use strict";', '"use asm";'],
["assignment operators", "value = 1;", "value += 1;"],
["public class method names", "class A { foo() {} }", "class A { bar() {} }"],
["public class field names", "class A { foo = 1; }", "class A { bar = 1; }"],
["update expression prefix", "value++", "++value"],
["declaration kind", "var value = 1;", "const value = 1;"],
[
"function async flag",
"function read() { return 1; }",
"async function read() { return 1; }",
],
["function generator flag", "function* read() {}", "function read() {}"],
["class method kind", "class A { read() {} }", "class A { get read() {} }"],
[
"class method static flag",
"class A { read() {} }",
"class A { static read() {} }",
],
[
"class field static flag",
"class A { value = 1; }",
"class A { static value = 1; }",
],
[
"private method static flag",
"class A { #read() {} }",
"class A { static #read() {} }",
],
[
"private field static flag",
"class A { #value = 1; }",
"class A { static #value = 1; }",
],
[
"object method kind",
"const value = { read() {} };",
"const value = { get read() {} };",
],
])("distinguishes %s", (_name, left, right) => {
expect(fingerprintJavaScriptAst(parse(left))).not.toBe(
fingerprintJavaScriptAst(parse(right)),
);
});
it("includes the fixed key of a Babel class auto-accessor", () => {
const before = parse("class A { accessor foo = 1; }", {
plugins: ["decoratorAutoAccessors"],
});
const after = parse("class A { accessor bar = 1; }", {
plugins: ["decoratorAutoAccessors"],
});
expect(fingerprintJavaScriptAst(before)).not.toBe(
fingerprintJavaScriptAst(after),
);
});
it("retains public auto-accessor static and computed flags", () => {
const parseAutoAccessor = (source: string) =>
parse(source, { plugins: ["decoratorAutoAccessors"] });
expect(
fingerprintJavaScriptAst(
parseAutoAccessor("class A { accessor value = 1; }"),
),
).not.toBe(
fingerprintJavaScriptAst(
parseAutoAccessor("class A { static accessor value = 1; }"),
),
);
expect(
fingerprintJavaScriptAst(
parseAutoAccessor("class A { accessor [key] = 1; }"),
),
).not.toBe(
fingerprintJavaScriptAst(
parseAutoAccessor("class A { accessor key = 1; }"),
),
);
});
it.each(["() {}", "= 1;"])(
"preserves computed class key semantics for %s",
(member) => {
const fingerprint = (source: string) =>
fingerprintJavaScriptAst(parse(source));
expect(
fingerprint(`const key = selectKey(); class A { [key]${member} }`),
).toBe(
fingerprint(
`const selector = selectKey(); class B { [selector]${member} }`,
),
);
expect(fingerprint(`class A { ["foo"]${member} }`)).not.toBe(
fingerprint(`class A { foo${member} }`),
);
},
);
it("keeps computed auto-accessor binding renames compatible", () => {
const before = parse(
"const key = selectKey(); class A { accessor [key] = 1; }",
{ plugins: ["decoratorAutoAccessors"] },
);
const after = parse(
"const selector = selectKey(); class B { accessor [selector] = 1; }",
{ plugins: ["decoratorAutoAccessors"] },
);
expect(fingerprintJavaScriptAst(before)).toBe(
fingerprintJavaScriptAst(after),
);
});
it("keeps private class member renames compatible", () => {
const fingerprint = (source: string) =>
fingerprintJavaScriptAst(parse(source));
expect(fingerprint("class A { #read() {} }")).toBe(
fingerprint("class A { #renamed() {} }"),
);
expect(fingerprint("class A { #value = 1; }")).toBe(
fingerprint("class A { #renamed = 1; }"),
);
const fingerprintAutoAccessor = (source: string) =>
fingerprintJavaScriptAst(
parse(source, { plugins: ["decoratorAutoAccessors"] }),
);
expect(fingerprintAutoAccessor("class A { accessor #value = 1; }")).toBe(
fingerprintAutoAccessor("class A { accessor #renamed = 1; }"),
);
});
it("includes complete literal values in the fingerprint", () => {
const prefix = "x".repeat(1_024);
const left = parse(`const value = ${JSON.stringify(`${prefix}left`)};`);
const right = parse(`const value = ${JSON.stringify(`${prefix}right`)};`);
expect(fingerprintJavaScriptAst(left)).not.toBe(
fingerprintJavaScriptAst(right),
);
});
it.each(["obj[key]", "obj?.[key]"])(
"ignores a local computed key binding in %s",
(expression) => {
@@ -44,6 +182,12 @@ it("distinguishes computed reads from fixed named members", () => {
);
});
it("distinguishes computed object keys from fixed object keys", () => {
expect(fingerprintJavaScriptAst(parse("({ key: value })"))).not.toBe(
fingerprintJavaScriptAst(parse("({ [key]: value })")),
);
});
it.each([
["obj[`left`]", "obj[`right`]"],
["obj[``]", "obj[`right`]"],
@@ -67,7 +211,7 @@ it("keeps renamed computed-key bundle modules structurally matchable", () => {
.bundler_registrations[0]?.modules[0];
expect(before).toBeDefined();
expect(after).toBeDefined();
expect(before?.structural_fingerprint_algorithm).toBe("babel-ast-v2");
expect(before?.structural_fingerprint_algorithm).toBe("babel-ast-v3");
expect(before?.source_sha256).not.toBe(after?.source_sha256);
expect(before?.structural_fingerprint_sha256).toMatch(/^[a-f0-9]{64}$/u);
expect(before?.structural_fingerprint_sha256).toBe(
@@ -6,7 +6,6 @@ import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import {
propertyName,
runtimeFunctionParameters,
semanticStaticPropertyKey,
} from "./javascriptAstValues.js";
@@ -78,6 +77,8 @@ export const collectJavaScriptExports = (
const semanticTokens = (node: t.Node): string[] => {
const tokens = [`node:${node.type}`];
if (t.isStringLiteral(node)) tokens.push(`string:${node.value}`);
else if (t.isBigIntLiteral(node)) tokens.push(`bigint:${String(node.value)}`);
else if (t.isDirectiveLiteral(node)) tokens.push(`directive:${node.value}`);
else if (t.isNumericLiteral(node))
tokens.push(`number:${String(node.value)}`);
else if (t.isBooleanLiteral(node))
@@ -86,21 +87,51 @@ const semanticTokens = (node: t.Node): string[] => {
tokens.push(`regexp:${node.pattern}/${node.flags}`);
else if (t.isTemplateElement(node))
tokens.push(`template:${JSON.stringify(node.value)}`);
else if (t.isBinaryExpression(node) || t.isLogicalExpression(node))
tokens.push(`operator:${node.operator}`);
else if (t.isUnaryExpression(node) || t.isUpdateExpression(node))
else if (
t.isBinaryExpression(node) ||
t.isLogicalExpression(node) ||
t.isUnaryExpression(node) ||
t.isUpdateExpression(node) ||
t.isAssignmentExpression(node)
)
tokens.push(`operator:${node.operator}`);
if (t.isUpdateExpression(node)) tokens.push(`prefix:${String(node.prefix)}`);
if (t.isVariableDeclaration(node)) tokens.push(`kind:${node.kind}`);
if (t.isFunction(node)) {
tokens.push(`async:${String(node.async)}`);
tokens.push(`generator:${String(node.generator)}`);
}
if (t.isObjectMethod(node)) tokens.push(`kind:${node.kind}`);
if (t.isClassMethod(node) || t.isClassPrivateMethod(node)) {
tokens.push(`kind:${node.kind}`);
tokens.push(`static:${String(node.static)}`);
}
if (
t.isClassProperty(node) ||
t.isClassPrivateProperty(node) ||
t.isClassAccessorProperty(node)
)
tokens.push(`static:${String(node.static)}`);
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
// A computed identifier names a local binding, not a fixed member.
tokens.push(`computed:${String(node.computed)}`);
if (!node.computed) {
const property = propertyName(node.property);
if (property !== "") tokens.push(`property:${property}`);
const property = semanticStaticPropertyKey(node.property, false);
if (property !== null) tokens.push(`property:${property}`);
}
}
if ((t.isObjectProperty(node) || t.isObjectMethod(node)) && !node.computed) {
const key = propertyName(node.key);
if (key !== "") tokens.push(`key:${key}`);
if (
t.isObjectProperty(node) ||
t.isObjectMethod(node) ||
t.isClassMethod(node) ||
t.isClassProperty(node) ||
t.isClassAccessorProperty(node)
) {
tokens.push(`computed:${String(node.computed)}`);
if (!node.computed) {
const key = semanticStaticPropertyKey(node.key, false);
if (key !== null) tokens.push(`key:${key}`);
}
}
return tokens;
};
@@ -1,7 +1,7 @@
import type { ElectronStaticFindings } from "./electronStaticAnalysisTypes.js";
/** Token semantics shared by bundle producers and worker admission. */
export const JAVASCRIPT_AST_FINGERPRINT_ALGORITHM = "babel-ast-v2";
export const JAVASCRIPT_AST_FINGERPRINT_ALGORITHM = "babel-ast-v3";
/** Static syntax context retained for later artifact path resolution. */
export type JavaScriptStaticPathContext =
@@ -29,34 +29,39 @@ it("retains candidate endpoint interpretation across the worker boundary", () =>
).toBe(false);
});
it("versions fingerprints through the worker schema and rejects v1", () => {
const facts =
analyzeJavaScriptStaticSource(`globalThis.webpackChunkApp.push([[1], {
it.each(["babel-ast-v1", "babel-ast-v2"])(
"versions fingerprints through the worker schema and rejects %s",
(oldAlgorithm) => {
const facts =
analyzeJavaScriptStaticSource(`globalThis.webpackChunkApp.push([[1], {
17(module, exports) { const key = selectKey(); exports.read = value[key]; }
}]);`);
expect(facts.bundler_registrations[0]?.modules[0]).toMatchObject({
structural_fingerprint_algorithm: "babel-ast-v2",
structural_fingerprint_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
});
expect(
javaScriptWorkerStaticAnalysisSchema.safeParse(
JSON.parse(JSON.stringify(facts)) as unknown,
).success,
).toBe(true);
const old = {
...facts,
bundler_registrations: facts.bundler_registrations.map((registration) => ({
...registration,
modules: registration.modules.map((module) => ({
...module,
structural_fingerprint_algorithm: "babel-ast-v1",
})),
})),
};
expect(javaScriptWorkerStaticAnalysisSchema.safeParse(old).success).toBe(
false,
);
});
expect(facts.bundler_registrations[0]?.modules[0]).toMatchObject({
structural_fingerprint_algorithm: "babel-ast-v3",
structural_fingerprint_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
});
expect(
javaScriptWorkerStaticAnalysisSchema.safeParse(
JSON.parse(JSON.stringify(facts)) as unknown,
).success,
).toBe(true);
const old = {
...facts,
bundler_registrations: facts.bundler_registrations.map(
(registration) => ({
...registration,
modules: registration.modules.map((module) => ({
...module,
structural_fingerprint_algorithm: oldAlgorithm,
})),
}),
),
};
expect(javaScriptWorkerStaticAnalysisSchema.safeParse(old).success).toBe(
false,
);
},
);
it("accepts candidate endpoint interpretations from static analysis", () => {
const facts = analyzeJavaScriptStaticSource(`http.request("POST", target);`);
@@ -9,6 +9,151 @@ import { applicationVersionComparisonResultSchema } from "../../../src/domain/ja
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"uses syntax-aware bundle fingerprints for changed syntax and renamed bindings",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-fingerprint-version-");
const pairs = [
["bigint", "exports.v = 1n;", "exports.v = 2n;"],
["bigint-key", "exports.v = { 1n: 1 };", "exports.v = { 2n: 1 };"],
["directive", '"use strict";', '"use asm";'],
["assignment", "exports.v = 1;", "exports.v += 1;"],
["string", 'exports.v = "left";', 'exports.v = "right";'],
["class-method", "class A { foo() {} }", "class A { bar() {} }"],
["class-field", "class A { foo = 1; }", "class A { bar = 1; }"],
[
"rename",
"const key = selectKey(); exports.read = value[key];",
"const selector = selectKey(); exports.read = value[selector];",
],
[
"rename-class-method",
"const key = selectKey(); class A { [key]() {} }",
"const selector = selectKey(); class B { [selector]() {} }",
],
[
"rename-class-field",
"const key = selectKey(); class A { [key] = 1; }",
"const selector = selectKey(); class B { [selector] = 1; }",
],
] as const;
const versions = [];
for (const side of [1, 2] as const) {
const application = join(root, String(side));
await mkdir(application);
for (const [name, left, right] of pairs)
await writeFile(
join(application, `${name}.js`),
`globalThis.webpackChunkApp.push([[1], {17(module, exports) {${side === 1 ? left : right}}}]);`,
);
const response = await cli.run({
arguments: ["analyze-javascript-application", application, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(response.exitCode, response.stderr).toBe(0);
versions.push(parseEvidence(response.json));
}
const [left, right] = versions;
if (left === undefined || right === undefined)
throw new Error("Missing bundle version Evidence");
const analyses = versions.map(({ normalized_result }) =>
javascriptApplicationAnalysisResultSchema.parse(normalized_result),
);
const modules = analyses.map(({ graph }) => {
expect(graph.coverage.status).toBe("complete");
return new Map(
graph.nodes.flatMap((node) =>
node.observations.flatMap(({ properties, evidence }) =>
typeof properties.structural_fingerprint_sha256 === "string"
? [[properties.path, { node, properties, evidence }] as const]
: [],
),
),
);
});
const inputPath = join(root, "comparison.json");
await writeFile(inputPath, JSON.stringify({ left, right }));
const response = await cli.run({
arguments: ["compare-application-versions", inputPath, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(response.exitCode, response.stderr).toBe(0);
const result = applicationVersionComparisonResultSchema.parse(
parseEvidence(response.json).normalized_result,
);
expect(result.coverage.status).toBe("complete-within-inputs");
for (const [name] of pairs) {
const isRename = name.startsWith("rename");
const a = modules[0]?.get(`${name}.js`);
const b = modules[1]?.get(`${name}.js`);
if (a === undefined || b === undefined)
throw new Error(`Missing ${name} bundle module`);
expect(a.evidence.coverage.status).toBe("complete");
expect(b.evidence.coverage.status).toBe("complete");
expect(a.properties.structural_fingerprint_algorithm).toBe(
"babel-ast-v3",
);
expect(b.properties.structural_fingerprint_algorithm).toBe(
"babel-ast-v3",
);
expect(a.properties.source_sha256).not.toBe(b.properties.source_sha256);
expect(
a.properties.structural_fingerprint_sha256 ===
b.properties.structural_fingerprint_sha256,
name,
).toBe(isRename);
const item = result.items.find(
({ left_node_id }) => left_node_id === a.node.node_id,
);
expect(item).toMatchObject(
isRename
? {
right_node_id: b.node.node_id,
status: "changed",
match: {
status: "matched",
basis: "structural-fingerprint",
confidence: "medium",
},
}
: {
right_node_id: null,
status: "removed",
match: { status: "unmatched" },
},
);
if (!isRename)
expect(
result.items.find(
({ right_node_id }) => right_node_id === b.node.node_id,
),
).toMatchObject({
left_node_id: null,
status: "added",
match: { status: "unmatched" },
});
const asset = analyses[0]?.graph.nodes.find(
({ kind, observations }) =>
kind === "javascript-asset" &&
observations.some(
({ properties }) => properties.path === `${name}.js`,
),
);
expect(asset).toBeDefined();
expect(
result.items.find(
({ left_node_id }) => left_node_id === asset?.node_id,
),
).toMatchObject({
status: "changed",
dimensions: expect.arrayContaining(["content"]),
match: { status: "matched", basis: "semantic-key" },
});
}
expect(result.matching.structural_fingerprint).toBe(3);
},
);
cliTest(
"pairs repeated factory bodies by identity when application versions retain a shared bundle",
async ({ cli }) => {
@@ -431,7 +431,7 @@ it("reconstructs package, Electron roles, Webpack/Rspack modules, and cross-laye
expect.objectContaining({
properties: expect.objectContaining({
module_key: "1",
structural_fingerprint_algorithm: "babel-ast-v2",
structural_fingerprint_algorithm: "babel-ast-v3",
}),
}),
]),