From 0ef938f4c5faa48a0ec4ef4574390eb6f05cfce6 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:43:14 +0800 Subject: [PATCH] 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> --- docs/javascript-artifact-reconstruction.md | 26 ++- ...vascriptApplicationVersionMatching.test.ts | 30 +++- .../javascriptAstFingerprint.test.ts | 168 ++++++++++++++++-- .../javascript/javascriptAstFingerprint.ts | 49 ++++- .../javascriptStaticAnalysisTypes.ts | 2 +- .../JavaScriptAnalysisWorkerSchemas.test.ts | 57 +++--- .../applicationVersionMatchingCli.test.ts | 145 +++++++++++++++ .../javascriptArtifactReconstruction.test.ts | 2 +- 8 files changed, 421 insertions(+), 58 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index c7504b0567..9fe784b66f 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -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. diff --git a/src/domain/javascript/javascriptApplicationVersionMatching.test.ts b/src/domain/javascript/javascriptApplicationVersionMatching.test.ts index 9299d4184d..247f44f00a 100644 --- a/src/domain/javascript/javascriptApplicationVersionMatching.test.ts +++ b/src/domain/javascript/javascriptApplicationVersionMatching.test.ts @@ -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"); diff --git a/src/domain/javascript/javascriptAstFingerprint.test.ts b/src/domain/javascript/javascriptAstFingerprint.test.ts index 4a12ac8e3c..64fbac2a46 100644 --- a/src/domain/javascript/javascriptAstFingerprint.test.ts +++ b/src/domain/javascript/javascriptAstFingerprint.test.ts @@ -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( diff --git a/src/domain/javascript/javascriptAstFingerprint.ts b/src/domain/javascript/javascriptAstFingerprint.ts index 1dee83fa05..89a781640f 100644 --- a/src/domain/javascript/javascriptAstFingerprint.ts +++ b/src/domain/javascript/javascriptAstFingerprint.ts @@ -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; }; diff --git a/src/domain/javascript/javascriptStaticAnalysisTypes.ts b/src/domain/javascript/javascriptStaticAnalysisTypes.ts index 887277950a..f12327d557 100644 --- a/src/domain/javascript/javascriptStaticAnalysisTypes.ts +++ b/src/domain/javascript/javascriptStaticAnalysisTypes.ts @@ -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 = diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts index 6a461031da..4dab222d4f 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts @@ -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);`); diff --git a/tests/acceptance/applications/applicationVersionMatchingCli.test.ts b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts index 17cc61eaab..9d1704446b 100644 --- a/tests/acceptance/applications/applicationVersionMatchingCli.test.ts +++ b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts @@ -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 }) => { diff --git a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts index 11ad11cfac..e247cb60c7 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -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", }), }), ]),