fix(javascript): preserve native export facts in comparisons

This commit is contained in:
morluto committed 2026-10-27 07:40:55 +08:00
1 parent c74059d89f
commit 046c4ce353
5 files changed
+295 -40

No files matched your search

@@ -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>): ApplicationNode[] =>
[...nodes].sort((left, right) =>
compareUnicodeCodePoints(left.node_id, right.node_id),
@@ -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<string, JsonValue>;
}[],
) =>
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),
);
});
@@ -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;
};
@@ -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"] =>
@@ -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-");