fix(javascript): preserve exact native export facts (#10059)

fix(javascript): preserve exact native export names and unknown identity
This commit is contained in:
morluto authored and GitHub committed 2026-11-11 17:14:24 +08:00
1 parent 4073cfc932
commit ba4fb6a806
5 files changed
+157 -42

No files matched your search

@@ -255,7 +255,11 @@ const buildNativeRuntimeNodes = (): FixtureNode[] => [
key: "openProject",
},
"openProject",
{ calling_convention: "napi" },
{
calling_convention: "napi",
specifier: "native/synthetic.node",
members: ["openProject"],
},
artifactEvidence(
APPLICATION_GRAPH_DIGESTS.nativeAddon,
"native/synthetic.node",
@@ -144,7 +144,7 @@ const nativeHandoffSchema = z.strictObject({
artifact_sha256: digestSchema,
artifact_path: z.string().min(1).nullable(),
export_node_ids: z.array(nodeIdSchema),
requested_exports: z.array(boundedTextSchema),
requested_exports: z.array(z.string()),
status: z.enum(["evidence-linked", "requires-provider-analysis"]),
providers: z.array(providerSchema),
evidence_ids: z.array(evidenceIdSchema),
@@ -1,6 +1,16 @@
import { expect, it } from "vitest";
import { createJavaScriptApplicationNode } from "./javascriptApplicationGraph.js";
import { artifactEvidence } from "./javascriptApplicationGraph.fixture.js";
import {
createJavaScriptApplicationEdge,
createJavaScriptApplicationGraph,
createJavaScriptApplicationNode,
} from "./javascriptApplicationGraph.js";
import {
artifactEvidence,
completeCoverage,
} from "./javascriptApplicationGraph.fixture.js";
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
import { traceApplicationFeature } from "./javascriptFeatureTrace.js";
import type { ApplicationFeatureSeed } from "./javascriptFeatureTraceSchemas.js";
import {
nativeExportCorrespondenceKey,
nativeExportObservationFacts,
@@ -28,8 +38,8 @@ const node = (
})),
});
it("preserves legacy labels and members while distinguishing omitted and empty requests", () => {
const legacy = node([
it("preserves structured members while distinguishing omitted and empty requests", () => {
const membersOnly = node([
{
label: "open",
properties: { specifier: "./addon.node", members: ["open"] },
@@ -41,17 +51,17 @@ it("preserves legacy labels and members while distinguishing omitted and empty r
properties: { specifier: "./addon.node", requested_members: [] },
},
]);
expect(nativeExportObservationFacts(legacy.observations[0]!)).toMatchObject({
expect(
nativeExportObservationFacts(membersOnly.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(
expect(nativeExportCorrespondenceKey(membersOnly)).not.toEqual(
nativeExportCorrespondenceKey(empty),
);
});
@@ -67,24 +77,29 @@ it("considers later conflicting observations and retains malformed values withou
};
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],
for (const requested of [["open", 42], null, "invalid", {}]) {
const malformed = node([
{
label: "display summary",
properties: {
specifier: "./addon.node",
requested_members: requested,
},
},
},
]);
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);
]);
const before = JSON.stringify(malformed);
expect(
nativeExportObservationFacts(malformed.observations[0]!),
).toMatchObject({
valid: false,
requested: Array.isArray(requested) ? ["open"] : [],
});
expect(nativeExportCorrespondenceKey(malformed)).toBeNull();
expect(JSON.stringify(malformed)).toBe(before);
}
});
it("preserves label-only fallback without treating display labels as structured members", () => {
it("leaves missing structured identity unknown regardless of display labels", () => {
for (const requested_members of [["open"], ["close"], []])
expect(
nativeExportCorrespondenceKey(
@@ -93,7 +108,7 @@ it("preserves label-only fallback without treating display labels as structured
).toBeNull();
expect(
nativeExportCorrespondenceKey(node([{ label: "open", properties: {} }])),
).not.toBeNull();
).toBeNull();
const first = node([
{
label: "summary one",
@@ -110,3 +125,112 @@ it("preserves label-only fallback without treating display labels as structured
nativeExportCorrespondenceKey(second),
);
});
const traceRequestedExport = (
requested: ReturnType<typeof node>,
seed: ApplicationFeatureSeed,
) => {
const addon = createJavaScriptApplicationNode({
kind: "native-addon",
identity: {
strategy: "content-digest",
stability: "global-exact",
sha256: "b".repeat(64),
},
observations: [
{
label: "addon.node",
properties: {},
evidence: artifactEvidence("b".repeat(64), "addon.node"),
},
],
});
return traceApplicationFeature({
sourceEvidenceId: `ev_${"c".repeat(64)}`,
graph: createJavaScriptApplicationGraph({
schema: "JavaScriptApplicationGraph",
nodes: [addon, requested],
root_node_ids: [addon.node_id],
edges: [
createJavaScriptApplicationEdge({
source_node_id: addon.node_id,
target_node_id: requested.node_id,
relation: "exposes",
properties: {},
evidence: artifactEvidence("b".repeat(64), "addon.node"),
}),
],
coverage: completeCoverage,
limitations: [],
}),
nativeEvidence: [],
seed,
direction: "both",
});
};
it("retains an empty JavaScript-requested native member in a feature handoff", () => {
const analyzed = analyzeCommonJsStaticFixture(
'const empty = require("./addon.node")[""]; empty();',
);
const binding = analyzed.electron.native_addon_bindings[0];
if (binding === undefined) throw new Error("Expected native addon binding");
expect(binding.members).toEqual([""]);
const requested = node([
{
label: "requested member summary",
properties: {
specifier: binding.specifier,
requested_members: [...binding.members],
},
},
]);
const trace = traceRequestedExport(requested, {
kind: "native-export",
value: "",
match: "exact",
case_sensitive: true,
});
expect(trace.seed_matches).toMatchObject([{ node_id: requested.node_id }]);
expect(trace.native_handoffs).toMatchObject([
{
artifact_sha256: "b".repeat(64),
requested_exports: [""],
status: "requires-provider-analysis",
},
]);
});
const handoffCases: {
properties: Record<string, JsonValue>;
expected: string[];
}[] = [
{ properties: {}, expected: [] },
{ properties: { requested_members: null }, expected: [] },
{ properties: { requested_members: "invalid" }, expected: [] },
{ properties: { requested_members: {} }, expected: [] },
{ properties: { requested_members: ["open", 42] }, expected: ["open"] },
];
it.each(handoffCases)(
"uses only structured native names in a handoff: $properties",
({ properties, expected }) => {
const requested = node([
{
label: "display summary, never an export name",
properties: { specifier: "./addon.node", ...properties },
},
]);
const before = JSON.stringify(requested);
const trace = traceRequestedExport(requested, {
kind: "node-id",
value: requested.node_id,
match: "exact",
case_sensitive: true,
});
expect(trace.native_handoffs).toMatchObject([
{ requested_exports: expected, status: "requires-provider-analysis" },
]);
expect(JSON.stringify(requested)).toBe(before);
},
);
@@ -6,7 +6,7 @@ import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js";
type Observation = ApplicationNode["observations"][number];
/** Read member facts without changing historical observations or their identifiers. */
/** Read source-owned member facts without interpreting display labels. */
export const nativeExportObservationFacts = (observation: Observation) => {
const requested = observation.properties.requested_members;
const members = observation.properties.members;
@@ -29,9 +29,6 @@ export const nativeExportObservationFacts = (observation: Observation) => {
(!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,
};
};
@@ -43,12 +40,7 @@ export const nativeExportCorrespondenceKey = (
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]);
if (typeof specifier !== "string" || specifier === "") return null;
const flag = (name: string) => {
const value = observation.properties[name];
return typeof value === "boolean" ? value : null;
@@ -61,7 +53,6 @@ export const nativeExportCorrespondenceKey = (
facts.membersPresent
? ["members", [...facts.members].sort(compareUnicodeCodePoints)]
: ["members-unknown"],
facts.label,
flag("namespace_access"),
flag("dynamic_member_access"),
]);
@@ -106,11 +106,7 @@ const requestedExports = (nodes: readonly ApplicationNode[]): string[] => {
const values = nodes.flatMap((node) =>
node.observations.flatMap((observation) => {
const facts = nativeExportObservationFacts(observation);
return [
...(facts.label === null ? [] : [facts.label]),
...facts.requested,
...facts.members,
];
return [...facts.requested, ...facts.members];
}),
);
return [...new Set(values)].sort(compareUnicodeCodePoints);