diff --git a/src/domain/javascript/javascriptApplicationGraph.fixture.ts b/src/domain/javascript/javascriptApplicationGraph.fixture.ts index 2a61db1869..eada605c37 100644 --- a/src/domain/javascript/javascriptApplicationGraph.fixture.ts +++ b/src/domain/javascript/javascriptApplicationGraph.fixture.ts @@ -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", diff --git a/src/domain/javascript/javascriptFeatureTraceSchemas.ts b/src/domain/javascript/javascriptFeatureTraceSchemas.ts index cfef64739a..46fcbcb73d 100644 --- a/src/domain/javascript/javascriptFeatureTraceSchemas.ts +++ b/src/domain/javascript/javascriptFeatureTraceSchemas.ts @@ -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), diff --git a/src/domain/javascript/javascriptNativeExportFacts.test.ts b/src/domain/javascript/javascriptNativeExportFacts.test.ts index 4914905815..9f56f79493 100644 --- a/src/domain/javascript/javascriptNativeExportFacts.test.ts +++ b/src/domain/javascript/javascriptNativeExportFacts.test.ts @@ -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, + 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; + 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); + }, +); diff --git a/src/domain/javascript/javascriptNativeExportFacts.ts b/src/domain/javascript/javascriptNativeExportFacts.ts index 1e756cb79d..92d2db8c9b 100644 --- a/src/domain/javascript/javascriptNativeExportFacts.ts +++ b/src/domain/javascript/javascriptNativeExportFacts.ts @@ -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"), ]); diff --git a/src/domain/javascript/javascriptNativeHandoff.ts b/src/domain/javascript/javascriptNativeHandoff.ts index 075ca71062..e5fb31ff33 100644 --- a/src/domain/javascript/javascriptNativeHandoff.ts +++ b/src/domain/javascript/javascriptNativeHandoff.ts @@ -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);