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"] =>