diff --git a/src/domain/javascript/javascriptApplicationVersionKeys.ts b/src/domain/javascript/javascriptApplicationVersionKeys.ts index 5d8cd379f7..634b81b2cc 100644 --- a/src/domain/javascript/javascriptApplicationVersionKeys.ts +++ b/src/domain/javascript/javascriptApplicationVersionKeys.ts @@ -1,3 +1,4 @@ +import { nativeExportCorrespondenceKey } from "./javascriptNativeExportFacts.js"; import { canonicalJson } from "../comparisonSemantics.js"; import { compareUnicodeCodePoints, @@ -271,21 +272,7 @@ const semanticValue = ( exactString(properties.api_key) ?? nonEmptyString(properties.api_name) ); - if (node.kind === "native-export") - return ( - joined( - properties.specifier, - compositeKey([ - stringList(properties.requested_members), - typeof properties.namespace_access === "boolean" - ? properties.namespace_access - : null, - typeof properties.dynamic_member_access === "boolean" - ? properties.dynamic_member_access - : null, - ]), - ) ?? firstLabel(node) - ); + if (node.kind === "native-export") return nativeExportCorrespondenceKey(node); if ( [ "electron-main", @@ -334,13 +321,6 @@ const nonEmptyString = (value: unknown): string | null => const exactString = (value: unknown): string | null => typeof value === "string" ? value : null; -const stringList = (value: unknown): string[] => - Array.isArray(value) - ? value - .filter((item): item is string => typeof item === "string") - .sort(compareUnicodeCodePoints) - : []; - const sortedNodes = (nodes: Iterable): ApplicationNode[] => [...nodes].sort((left, right) => compareUnicodeCodePoints(left.node_id, right.node_id), diff --git a/src/domain/javascript/javascriptNativeExportFacts.test.ts b/src/domain/javascript/javascriptNativeExportFacts.test.ts new file mode 100644 index 0000000000..4914905815 --- /dev/null +++ b/src/domain/javascript/javascriptNativeExportFacts.test.ts @@ -0,0 +1,112 @@ +import { expect, it } from "vitest"; +import { createJavaScriptApplicationNode } from "./javascriptApplicationGraph.js"; +import { artifactEvidence } from "./javascriptApplicationGraph.fixture.js"; +import { + nativeExportCorrespondenceKey, + nativeExportObservationFacts, +} from "./javascriptNativeExportFacts.js"; +import type { JsonValue } from "../jsonValue.js"; + +const node = ( + observations: readonly { + label: string; + properties: Record; + }[], +) => + createJavaScriptApplicationNode({ + kind: "native-export", + identity: { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: "a".repeat(64), + namespace: "native-export-fixture", + key: "addon.node", + }, + observations: observations.map((observation) => ({ + ...observation, + evidence: artifactEvidence("a".repeat(64), "main.js"), + })), + }); + +it("preserves legacy labels and members while distinguishing omitted and empty requests", () => { + const legacy = node([ + { + label: "open", + properties: { specifier: "./addon.node", members: ["open"] }, + }, + ]); + const empty = node([ + { + label: "open", + properties: { specifier: "./addon.node", requested_members: [] }, + }, + ]); + expect(nativeExportObservationFacts(legacy.observations[0]!)).toMatchObject({ + requestedPresent: false, + members: ["open"], + label: "open", + }); + expect(nativeExportObservationFacts(empty.observations[0]!)).toMatchObject({ + requestedPresent: true, + requested: [], + label: null, + }); + expect(nativeExportCorrespondenceKey(legacy)).not.toEqual( + nativeExportCorrespondenceKey(empty), + ); +}); + +it("considers later conflicting observations and retains malformed values without inventing identity", () => { + const first = { + label: "summary", + properties: { specifier: "./addon.node", requested_members: ["open"] }, + }; + const second = { + label: "summary", + properties: { specifier: "./addon.node", requested_members: ["close"] }, + }; + expect(nativeExportCorrespondenceKey(node([first]))).not.toBeNull(); + expect(nativeExportCorrespondenceKey(node([first, second]))).toBeNull(); + const malformed = node([ + { + label: "legacy", + properties: { + specifier: "./addon.node", + requested_members: ["open", 42], + }, + }, + ]); + const before = JSON.stringify(malformed); + expect( + nativeExportObservationFacts(malformed.observations[0]!), + ).toMatchObject({ valid: false, requested: ["open"], label: null }); + expect(nativeExportCorrespondenceKey(malformed)).toBeNull(); + expect(JSON.stringify(malformed)).toBe(before); +}); + +it("preserves label-only fallback without treating display labels as structured members", () => { + for (const requested_members of [["open"], ["close"], []]) + expect( + nativeExportCorrespondenceKey( + node([{ label: "same summary", properties: { requested_members } }]), + ), + ).toBeNull(); + expect( + nativeExportCorrespondenceKey(node([{ label: "open", properties: {} }])), + ).not.toBeNull(); + const first = node([ + { + label: "summary one", + properties: { specifier: "./addon.node", requested_members: ["open"] }, + }, + ]); + const second = node([ + { + label: "summary two", + properties: { specifier: "./addon.node", requested_members: ["open"] }, + }, + ]); + expect(nativeExportCorrespondenceKey(first)).toEqual( + nativeExportCorrespondenceKey(second), + ); +}); diff --git a/src/domain/javascript/javascriptNativeExportFacts.ts b/src/domain/javascript/javascriptNativeExportFacts.ts new file mode 100644 index 0000000000..1e756cb79d --- /dev/null +++ b/src/domain/javascript/javascriptNativeExportFacts.ts @@ -0,0 +1,72 @@ +import { + compositeKey, + compareUnicodeCodePoints, +} from "../unicodeCodePointOrder.js"; +import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js"; + +type Observation = ApplicationNode["observations"][number]; + +/** Read member facts without changing historical observations or their identifiers. */ +export const nativeExportObservationFacts = (observation: Observation) => { + const requested = observation.properties.requested_members; + const members = observation.properties.members; + const strings = (value: unknown): string[] => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; + const requestedPresent = Object.hasOwn( + observation.properties, + "requested_members", + ); + const membersPresent = Object.hasOwn(observation.properties, "members"); + const validList = (value: unknown): boolean => + Array.isArray(value) && value.every((item) => typeof item === "string"); + return { + requestedPresent, + membersPresent, + valid: + (!requestedPresent || validList(requested)) && + (!membersPresent || validList(members)), + requested: strings(requested), + members: strings(members), + // Labels were the historical fallback, but current labels summarize names + // or namespace access and must not become requested native symbols. + label: Array.isArray(requested) ? null : observation.label, + }; +}; + +/** Correspondence requires every observation to describe the same binding facts. */ +export const nativeExportCorrespondenceKey = ( + node: ApplicationNode, +): string | null => { + const keys = node.observations.map((observation) => { + const facts = nativeExportObservationFacts(observation); + if (!facts.valid) return null; + const specifier = observation.properties.specifier; + if (typeof specifier !== "string" || specifier === "") + return observation.label === null || + facts.requestedPresent || + facts.membersPresent + ? null + : compositeKey(["label", observation.label]); + const flag = (name: string) => { + const value = observation.properties[name]; + return typeof value === "boolean" ? value : null; + }; + return compositeKey([ + specifier, + facts.requestedPresent + ? ["requested", [...facts.requested].sort(compareUnicodeCodePoints)] + : ["requested-unknown"], + facts.membersPresent + ? ["members", [...facts.members].sort(compareUnicodeCodePoints)] + : ["members-unknown"], + facts.label, + flag("namespace_access"), + flag("dynamic_member_access"), + ]); + }); + if (keys.some((key) => key === null)) return null; + const unique = new Set(keys); + return unique.size === 1 ? keys[0]! : null; +}; diff --git a/src/domain/javascript/javascriptNativeHandoff.ts b/src/domain/javascript/javascriptNativeHandoff.ts index 2635b20088..075ca71062 100644 --- a/src/domain/javascript/javascriptNativeHandoff.ts +++ b/src/domain/javascript/javascriptNativeHandoff.ts @@ -7,6 +7,8 @@ import type { import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js"; import type { ApplicationFeatureTraceResult } from "./javascriptFeatureTraceSchemas.js"; +import { nativeExportObservationFacts } from "./javascriptNativeExportFacts.js"; + type NativeHandoff = ApplicationFeatureTraceResult["native_handoffs"][number]; /** Build provider-neutral addon targets and link exact-subject native Evidence. */ @@ -102,25 +104,18 @@ const nativeExportNodes = ( const requestedExports = (nodes: readonly ApplicationNode[]): string[] => { const values = nodes.flatMap((node) => - node.observations.flatMap((observation) => [ - // Display labels can summarize several names or namespace access. - // Only legacy observations without structured member facts need them. - ...(observation.label === null || - Array.isArray(observation.properties.requested_members) - ? [] - : [observation.label]), - ...stringArray(observation.properties.requested_members), - ...stringArray(observation.properties.members), - ]), + node.observations.flatMap((observation) => { + const facts = nativeExportObservationFacts(observation); + return [ + ...(facts.label === null ? [] : [facts.label]), + ...facts.requested, + ...facts.members, + ]; + }), ); return [...new Set(values)].sort(compareUnicodeCodePoints); }; -const stringArray = (value: unknown): string[] => - Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : []; - const uniqueProviders = ( evidence: readonly Evidence[], ): NativeHandoff["providers"] => diff --git a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts index 3a442708bd..db8129fffa 100644 --- a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts +++ b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts @@ -1,5 +1,5 @@ -import { rm } from "node:fs/promises"; -import { relative, resolve } from "node:path"; +import { rm, writeFile } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -23,7 +23,11 @@ import { import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; import { electronPageInspectionSchema } from "../../../src/domain/javascript/electronObservation.js"; import { compareJavaScriptApplicationVersions } from "../../../src/domain/javascript/javascriptApplicationVersionComparison.js"; -import { createJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; +import { createJavaScriptSemanticGraph } from "../../../src/domain/javascript/javascriptSemanticGraph.js"; +import { + createJavaScriptApplicationNode, + createJavaScriptApplicationGraph, +} from "../../../src/domain/javascript/javascriptApplicationGraph.js"; import { traceApplicationFeature } from "../../../src/domain/javascript/javascriptFeatureTrace.js"; import { applicationFeatureTraceResultSchema } from "../../../src/domain/javascript/javascriptFeatureTraceSchemas.js"; import { @@ -218,6 +222,98 @@ describe("JavaScript application comparison workflows", () => { }); }); +describe("historical native comparison projections", () => { + it("keeps historical native member omission distinct through authenticated comparison", async () => { + const root = await createTestTempDirectory( + "rea-historical-native-members-", + ); + temporary.push(root); + await writeFile( + join(root, "main.js"), + 'const { open } = require("./addon.node"); open();', + ); + await writeFile(join(root, "addon.node"), "inert fixture; never executed"); + const source = await analyzeFixture(root); + const original = javascriptApplicationAnalysisResultSchema.parse( + source.normalized_result, + ); + const binding = original.graph.nodes.find( + ({ kind }) => kind === "native-export", + ); + if (binding === undefined || source.subject === null) + throw new Error("Missing fixture binding"); + const historical = (name: string, explicitEmpty = false) => { + const artifact = binding.observations[0]!.evidence.artifact; + if (!artifact.available) + throw new Error("Missing binding source artifact"); + const node = createJavaScriptApplicationNode({ + kind: binding.kind, + identity: { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: artifact.sha256, + namespace: "historical-native-binding-fixture", + key: `${name}-${String(explicitEmpty)}`, + }, + observations: binding.observations.map((observation) => { + const { requested_members: _requested, ...properties } = + observation.properties; + return { + label: name, + properties: { + ...properties, + ...(explicitEmpty + ? { requested_members: [] } + : { members: [name] }), + }, + evidence: observation.evidence, + }; + }), + }); + const { graph_id: _graphId, ...graphContent } = original.graph; + const graph = createJavaScriptApplicationGraph({ + ...graphContent, + nodes: [...original.graph.nodes, node], + }); + const { graph_id: _semanticId, ...semanticContent } = + original.semantic_graph; + const semantic_graph = createJavaScriptSemanticGraph({ + ...semanticContent, + application_graph_id: graph.graph_id, + }); + return createEvidence( + { + path: source.subject!.local_path, + sha256: source.subject!.digest.sha256, + format: source.subject!.format, + }, + source.provider, + { + predicateType: source.predicate_type, + operation: source.operation, + parameters: source.parameters, + authority: source.authority, + confidence: source.confidence, + result: { ...original, graph, semantic_graph }, + }, + ); + }; + const left = historical("open"); + const before = JSON.stringify(left); + for (const right of [historical("close"), historical("open", true)]) { + const compared = compareApplicationVersionsEvidence({ left, right }); + if (!compared.ok) throw compared.error; + const bindings = compared.value.result.items.filter( + ({ node_kind, match }) => + node_kind === "native-export" && match.status === "unmatched", + ); + expect(bindings).toHaveLength(2); + expect(compared.value.sources).toEqual([left, right]); + } + expect(JSON.stringify(left)).toBe(before); + }); +}); + describe("complete comparison projections", () => { it("matches rechunked modules by exact source and minified modules by structural fingerprint", async () => { const root = await createTestTempDirectory("rea-application-versions-");