diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 2ffad62061..17da6faa02 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -160,7 +160,10 @@ matching seeds and the complete graph reachable in the selected direction. The result contains the matching basis, complete reachable subgraph, terminal paths, authority summaries, and native handoffs. A handoff binds the exact native artifact digest and requested exports from the application -graph. Existing Hopper or Ghidra Evidence is linked only when its subject digest +graph. Trace direction selects the reachable addons; requested +exports for each selected addon come from the complete supplied graph, so an +outgoing trace does not discard an incoming export association. Existing Hopper +or Ghidra Evidence is linked only when its subject digest matches exactly. Otherwise the result recommends provider-neutral follow-up tools and reports `requires-provider-analysis`; it never starts or switches a provider implicitly. @@ -366,9 +369,16 @@ files; multiple canonical-equivalent candidates remain unknown. Each historical source file is classified as `unchanged`, `modified`, `removed`, `split`, `merged`, `duplicated`, or `unknown`. Multiple equally -scored path mappings form a static split inference. Multiple current -nodes with the exact historical digest are duplicated; multiple historical -paths with exact identity to one current node are merged. `removed` requires +scored path mappings form a static split inference. Multiple current file +occurrences with the exact historical digest are duplicated; multiple historical +paths with exact identity to one current occurrence are merged. Fresh file +observations carry their container digest, inventory path, and content digest. +Their asset, source-module, and export-binding representations form one candidate, +which retains `current_file_occurrence` and `representation_node_ids`. Equal bytes +at two inventory paths remain two occurrences even when they share an asset node. +Logical bundle modules and older observations without occurrence facts retain +their graph identities; matching does not infer common ownership from a label. +`removed` requires complete historical and application inventories. The comparison analyzes and returns all source files, candidate nodes, and matching signals represented in the supplied graphs. Partial inventories remain unknown where absence cannot @@ -484,7 +494,9 @@ Changes use JSON Pointer paths with `added`, `removed`, `changed`, or `unknown` status and a separate `presence` object (`present`, `absent`, or `unknown-coverage`) on each side. A name is `added` or `removed` when one paired shape proves presence and the other proves absence. Complete parent-property -coverage can establish absence even when field values stay unresolved. Identical literals are +coverage can establish absence even when field values stay unresolved. Proven +absence of an ancestor also establishes absence of its descendants. An observed +ancestor with unresolved contents keeps its descendants unknown. Identical literals are omitted. Unresolved values that remain on both sides stay `unknown` when the projections differ, and are omitted when presence is unchanged and the unknown projections match. Incomplete spreads and other partial parent coverage keep diff --git a/src/application/javascript/JavaScriptArtifactGraphContext.ts b/src/application/javascript/JavaScriptArtifactGraphContext.ts index b724d94747..e1798d7314 100644 --- a/src/application/javascript/JavaScriptArtifactGraphContext.ts +++ b/src/application/javascript/JavaScriptArtifactGraphContext.ts @@ -379,6 +379,13 @@ export const javascriptAnalysisCoverage = ( ? completeApplicationCoverage() : partialApplicationCoverage([], null); +/** Parsed inventory identity shared by whole-file graph representations. */ +export const fileOccurrenceIdentity = (file: JavaScriptArtifactFile) => ({ + container_sha256: file.container_sha256, + path: file.path, + sha256: file.sha256, +}); + /** Stable identity for a fact scoped to one exact artifact. */ export const artifactLocalIdentity = ( sha256: string, diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.ts index 68c2e18442..c7bd387d89 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.ts @@ -25,6 +25,7 @@ import { dependencyManifest, addUnavailableStaticParseScope, artifactFileNodeKind, + fileOccurrenceIdentity, artifactLocalIdentity, javascriptAnalysisCoverage, createElectronRoleNode, @@ -603,6 +604,7 @@ const createFileTarget = ( { label: file.path, properties: { + file_occurrence: fileOccurrenceIdentity(file), path: file.path, bytes: file.bytes, inventory_artifact_id: file.inventory_artifact_id, diff --git a/src/application/javascript/JavaScriptModuleRelationships.ts b/src/application/javascript/JavaScriptModuleRelationships.ts index 6676f6323c..05a4e786f6 100644 --- a/src/application/javascript/JavaScriptModuleRelationships.ts +++ b/src/application/javascript/JavaScriptModuleRelationships.ts @@ -23,6 +23,7 @@ import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptA import { addAstContainsEdge, artifactLocalIdentity, + fileOccurrenceIdentity, type JavaScriptArtifactGraphContext, type JavaScriptArtifactGraphCoverage, } from "./JavaScriptArtifactGraphContext.js"; @@ -106,6 +107,7 @@ export const addJavaScriptSourceModules = ( label: file.path, properties: { semantic_role: "source-module", + file_occurrence: fileOccurrenceIdentity(file), source_sha256: file.sha256, path: file.path, logical_module_key: file.path, @@ -202,6 +204,7 @@ const addExportRelationship = ( label: `${file.path}:${link.exportedName}`, properties: { semantic_role: "export-binding", + file_occurrence: fileOccurrenceIdentity(file), module_path: file.path, relationship_kind: link.kind, exported_name: link.exportedName, diff --git a/src/domain/javascript/javascriptArtifactFiles.ts b/src/domain/javascript/javascriptArtifactFiles.ts index a35c868798..faefbc3b85 100644 --- a/src/domain/javascript/javascriptArtifactFiles.ts +++ b/src/domain/javascript/javascriptArtifactFiles.ts @@ -1,6 +1,18 @@ +import { z } from "zod"; +import { digestSchema } from "../digests.js"; import type { JavaScriptSourceSelection } from "./javascriptSourceSelection.js"; import type { ArtifactOccurrence } from "../artifactGraph.js"; +/** One physical file occurrence, independent of its graph representations. */ +export const javaScriptFileOccurrenceSchema = z.strictObject({ + container_sha256: digestSchema, + path: z.string().min(1), + sha256: digestSchema, +}); +export type JavaScriptFileOccurrence = z.infer< + typeof javaScriptFileOccurrenceSchema +>; + /** Whether an inventoried path has a source extension read by the analyzer. */ export const isJavaScriptSourcePath = (path: string): boolean => /\.(?:cjs|cts|mjs|mts|js|jsx|ts|tsx)$/iu.test(path); diff --git a/src/domain/javascript/javascriptExportShapeVariants.ts b/src/domain/javascript/javascriptExportShapeVariants.ts index 2ba7a0c37b..be039b9614 100644 --- a/src/domain/javascript/javascriptExportShapeVariants.ts +++ b/src/domain/javascript/javascriptExportShapeVariants.ts @@ -312,10 +312,11 @@ const diffPair = ( return paths.flatMap((path) => { const leftField = fieldAtPath(left, leftFields, path); const rightField = fieldAtPath(right, rightFields, path); + const leftPresence = fieldPresence(left, leftField, path); + const rightPresence = fieldPresence(right, rightField, path); const status = fieldChangeStatus({ - leftShape: left, - rightShape: right, - path, + leftPresence, + rightPresence, leftField, rightField, }); @@ -326,11 +327,11 @@ const diffPair = ( path, discriminant, presence: { - left: fieldPresence(left, leftField, path), - right: fieldPresence(right, rightField, path), + left: leftPresence, + right: rightPresence, }, - left: fieldAvailability(leftField), - right: fieldAvailability(rightField), + left: fieldAvailability(leftField, leftPresence), + right: fieldAvailability(rightField, rightPresence), left_source_range: left.source_range, right_source_range: right.source_range, evidence_links: evidenceLinks, @@ -365,24 +366,20 @@ const fieldAtPath = ( }; interface FieldChangeInput { - readonly leftShape: Shape; - readonly rightShape: Shape; - readonly path: string; + readonly leftPresence: PropertyPresence; + readonly rightPresence: PropertyPresence; readonly leftField: Field | undefined; readonly rightField: Field | undefined; } const fieldChangeStatus = ({ - leftShape, - rightShape, - path, + leftPresence, + rightPresence, leftField, rightField, }: FieldChangeInput): | JavaScriptExportShapeComparisonChange["status"] | null => { - const leftPresence = fieldPresence(leftShape, leftField, path); - const rightPresence = fieldPresence(rightShape, rightField, path); if ( leftField !== undefined && rightField !== undefined && @@ -412,24 +409,39 @@ const fieldPresence = ( path: string, ): PropertyPresence => { if (field !== undefined) return field.presence; - return propertyCoverageComplete(shape, parentPointer(path)) - ? "absent" - : "unknown-coverage"; + for ( + let parent = parentPointer(path); + parent !== null; + parent = parentPointer(parent) + ) { + const coverage = shape.property_coverage.find( + (entry) => entry.path === parent, + ); + if (coverage?.status === "complete") return "absent"; + const ancestor = shape.fields.find((entry) => entry.path === parent); + if (ancestor !== undefined) + return ancestor.presence === "absent" ? "absent" : "unknown-coverage"; + if (coverage !== undefined) return "unknown-coverage"; + } + return "unknown-coverage"; }; -const propertyCoverageComplete = (shape: Shape, path: string | null): boolean => - path !== null && - shape.property_coverage.some( - (coverage) => coverage.path === path && coverage.status === "complete", - ); - const parentPointer = (path: string): string | null => { if (path === "") return null; const separator = path.lastIndexOf("/"); return separator < 0 ? null : path.slice(0, separator); }; -const fieldAvailability = (field: Field | undefined): ValueAvailability => { +const fieldAvailability = ( + field: Field | undefined, + presence: PropertyPresence, +): ValueAvailability => { + if (field === undefined && presence === "unknown-coverage") + return { + availability: "unknown", + reason: + "Property presence is not established by the retained parent coverage.", + }; if (field === undefined || field.presence === "absent") return { availability: "absent" }; if (field.state === "unknown") diff --git a/src/domain/javascript/javascriptFeatureTrace.ts b/src/domain/javascript/javascriptFeatureTrace.ts index d136bf784b..b643eb476e 100644 --- a/src/domain/javascript/javascriptFeatureTrace.ts +++ b/src/domain/javascript/javascriptFeatureTrace.ts @@ -52,8 +52,12 @@ export const traceApplicationFeature = ( const graph = traceGraph(input, seedMatches, traversal); const paths = terminalPaths(traversal, seedMatches); const nativeHandoffs = buildJavaScriptNativeHandoffs( - traversal.nodes, - traversal.edges, + input.graph, + new Set( + traversal.nodes + .filter(({ kind }) => kind === "native-addon") + .map(({ node_id }) => node_id), + ), input.nativeEvidence, ); const evidenceLinks = uniqueSorted([ diff --git a/src/domain/javascript/javascriptNativeHandoff.ts b/src/domain/javascript/javascriptNativeHandoff.ts index 00b7c699e0..2635b20088 100644 --- a/src/domain/javascript/javascriptNativeHandoff.ts +++ b/src/domain/javascript/javascriptNativeHandoff.ts @@ -4,19 +4,23 @@ import type { ApplicationEdge, ApplicationNode, } from "./javascriptApplicationGraphSchemas.js"; +import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js"; import type { ApplicationFeatureTraceResult } from "./javascriptFeatureTraceSchemas.js"; type NativeHandoff = ApplicationFeatureTraceResult["native_handoffs"][number]; /** Build provider-neutral addon targets and link exact-subject native Evidence. */ export const buildJavaScriptNativeHandoffs = ( - nodes: readonly ApplicationNode[], - edges: readonly ApplicationEdge[], + graph: JavaScriptApplicationGraph, + selectedNativeNodeIds: ReadonlySet, nativeEvidence: readonly Evidence[], ): NativeHandoff[] => { let nodeById: Map | undefined; - return nodes - .filter(({ kind }) => kind === "native-addon") + return graph.nodes + .filter( + ({ kind, node_id }) => + kind === "native-addon" && selectedNativeNodeIds.has(node_id), + ) .flatMap((node) => { // Observations may describe only a containing archive. A native target // requires the node owner's exact byte identity, independently of its @@ -27,9 +31,9 @@ export const buildJavaScriptNativeHandoffs = ( ({ subject }) => subject?.digest.sha256 === digest, ); nodeById ??= new Map( - nodes.map((candidate) => [candidate.node_id, candidate]), + graph.nodes.map((candidate) => [candidate.node_id, candidate]), ); - const exportNodes = nativeExportNodes(node, edges, nodeById); + const exportNodes = nativeExportNodes(node, graph.edges, nodeById); return [ { native_node_id: node.node_id, diff --git a/src/domain/javascript/sourceToBundleComparison.ts b/src/domain/javascript/sourceToBundleComparison.ts index 5a70c57f37..1a3de58760 100644 --- a/src/domain/javascript/sourceToBundleComparison.ts +++ b/src/domain/javascript/sourceToBundleComparison.ts @@ -19,6 +19,7 @@ import { historicalSourceFiles, scoreSourceToBundleCandidate, sourceBearingNodes, + sourceToBundleCandidateIdentity, type SourceToBundleCandidateIndex, } from "./sourceToBundleSignals.js"; @@ -61,7 +62,12 @@ export const compareSourceToBundle = ( ...projection.factors, }; const mappedCurrentIds = new Set( - mappings.items.flatMap(({ current_node_ids: nodeIds }) => nodeIds), + mappings.items.flatMap(({ current_node_ids: nodeIds, candidates }) => [ + ...nodeIds, + ...candidates + .filter((candidate) => nodeIds.includes(candidate.current_node_id)) + .flatMap((candidate) => candidate.representation_node_ids ?? []), + ]), ); const unmappedCurrentNodeIds = projection.currentNodes .map(({ node_id: nodeId }) => nodeId) @@ -131,8 +137,9 @@ const compareMappings = ( const classification = classifyMapping( scored, projection.absenceComplete, - (nodeId) => - source.sha256 !== null && candidateHasDigest(projection.index, nodeId), + (candidate) => + source.sha256 !== null && + candidateHasDigest(projection.index, candidate), ); const semantic = { source_path: source.path, @@ -183,7 +190,7 @@ const scoringModel = (): SourceToBundleComparisonResult["scoring"] => ({ const classifyMapping = ( candidates: readonly SourceToBundleCandidate[], absenceComplete: boolean, - digestComparable: (nodeId: string) => boolean, + digestComparable: (candidate: SourceToBundleCandidate) => boolean, ): ClassifiedMapping => { const exact = candidates.filter((candidate) => hasSignal(candidate, "exact-source-digest"), @@ -201,9 +208,11 @@ const classifyMapping = ( return { status: "duplicated", confidence: "exact", - currentNodeIds: exact.map(({ current_node_id: nodeId }) => nodeId), + currentNodeIds: uniqueSorted( + exact.map(({ current_node_id: nodeId }) => nodeId), + ), limitations: [ - "The same historical source digest occurs in multiple current graph nodes.", + "The same historical source digest occurs in multiple current source occurrences.", ], }; const pathCandidates = candidates.filter((candidate) => @@ -222,7 +231,7 @@ const classifyMapping = ( : pathCandidates.filter(({ score }) => score === topScore); if (topPathCandidates.length === 1) { const candidate = topPathCandidates[0]; - if (candidate !== undefined && digestComparable(candidate.current_node_id)) + if (candidate !== undefined && digestComparable(candidate)) return { status: "modified", confidence: candidate.confidence === "high" ? "high" : "medium", @@ -245,11 +254,11 @@ const classifyMapping = ( return { status: "split", confidence: "medium", - currentNodeIds: topPathCandidates.map( - ({ current_node_id: nodeId }) => nodeId, + currentNodeIds: uniqueSorted( + topPathCandidates.map(({ current_node_id: nodeId }) => nodeId), ), limitations: [ - "Multiple equally scored current nodes retain the historical source path; this is a static split inference, not runtime proof.", + "Multiple equally scored current source occurrences retain the historical source path; this is a static split inference, not runtime proof.", ], }; if (candidates.length > 0) @@ -293,8 +302,12 @@ const markMergedMappings = ( if (item.status !== "unchanged" || item.current_node_ids.length !== 1) continue; const nodeId = item.current_node_ids[0]; - if (nodeId === undefined) continue; - sourcesByNode.set(nodeId, [...(sourcesByNode.get(nodeId) ?? []), item]); + const candidate = item.candidates.find( + (candidate) => candidate.current_node_id === nodeId, + ); + if (candidate === undefined) continue; + const key = sourceToBundleCandidateIdentity(candidate); + sourcesByNode.set(key, [...(sourcesByNode.get(key) ?? []), item]); } const mergedSources = new Set( [...sourcesByNode.values()] @@ -311,7 +324,7 @@ const markMergedMappings = ( confidence: "exact" as const, limitations: uniqueSorted([ ...item.limitations, - "Multiple historical source paths have exact digest identity with the same current graph node.", + "Multiple historical source paths have exact digest identity with the same current source occurrence.", ]), }; return { ...semantic, mapping_id: mappingId(input, semantic) }; @@ -388,4 +401,7 @@ const compareCandidates = ( right: SourceToBundleCandidate, ): number => right.score - left.score || - compareUnicodeCodePoints(left.current_node_id, right.current_node_id); + compareUnicodeCodePoints( + sourceToBundleCandidateIdentity(left), + sourceToBundleCandidateIdentity(right), + ); diff --git a/src/domain/javascript/sourceToBundleComparisonSchemas.ts b/src/domain/javascript/sourceToBundleComparisonSchemas.ts index f69273b592..b8ccf7a630 100644 --- a/src/domain/javascript/sourceToBundleComparisonSchemas.ts +++ b/src/domain/javascript/sourceToBundleComparisonSchemas.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { javaScriptFileOccurrenceSchema } from "./javascriptArtifactFiles.js"; import { evidenceSchema } from "../evidence.js"; import { JAVASCRIPT_APPLICATION_NODE_KINDS } from "./javascriptApplicationGraphSchemas.js"; @@ -44,6 +45,8 @@ const sourceToBundleSignalSchema = z.strictObject({ }); const sourceToBundleCandidateSchema = z.strictObject({ + current_file_occurrence: javaScriptFileOccurrenceSchema.exactOptional(), + representation_node_ids: z.array(nodeIdSchema).min(1).exactOptional(), current_node_id: nodeIdSchema, current_node_kind: z.enum(JAVASCRIPT_APPLICATION_NODE_KINDS), score: z.int().min(1), diff --git a/src/domain/javascript/sourceToBundleSignals.ts b/src/domain/javascript/sourceToBundleSignals.ts index 62490bec86..2f8e3d04ee 100644 --- a/src/domain/javascript/sourceToBundleSignals.ts +++ b/src/domain/javascript/sourceToBundleSignals.ts @@ -11,6 +11,10 @@ import { type SourceToBundleCandidate, type SourceToBundleSignal, } from "./sourceToBundleComparisonSchemas.js"; +import { + javaScriptFileOccurrenceSchema, + type JavaScriptFileOccurrence, +} from "./javascriptArtifactFiles.js"; import { isDigest } from "../digests.js"; type SourceFile = Extract< @@ -32,6 +36,8 @@ interface CurrentPath { interface CurrentProjection { readonly node: ApplicationNode; + readonly occurrence?: JavaScriptFileOccurrence; + readonly representations: readonly string[]; readonly digests: ReadonlySet; readonly paths: readonly CurrentPath[]; } @@ -95,32 +101,32 @@ export const historicalSourceFiles = ( export const buildSourceToBundleCandidateIndex = ( nodes: readonly ApplicationNode[], ): SourceToBundleCandidateIndex => { - const projections = nodes.map(projectCurrentNode); + const projections = projectCurrentOccurrences(nodes); const byDigest = new Map>(); const byPathSuffix = new Map>(); const byBasename = new Map>(); for (const projection of projections) { for (const digest of projection.digests) - addIndexValue(byDigest, digest, projection.node.node_id); + addIndexValue(byDigest, digest, projectionKey(projection)); for (const path of projection.paths) { for (const suffix of path.allowSuffix ? pathSuffixes(path.value) : [path.value]) { - addIndexValue(byPathSuffix, suffix, projection.node.node_id); + addIndexValue(byPathSuffix, suffix, projectionKey(projection)); const canonical = suffix.normalize("NFC"); if (canonical !== suffix) - addIndexValue(byPathSuffix, canonical, projection.node.node_id); + addIndexValue(byPathSuffix, canonical, projectionKey(projection)); } addIndexValue( byBasename, posix.basename(path.value), - projection.node.node_id, + projectionKey(projection), ); } } return { nodes: new Map( - projections.map((projection) => [projection.node.node_id, projection]), + projections.map((projection) => [projectionKey(projection), projection]), ), byDigest, byPathSuffix, @@ -158,8 +164,10 @@ export const candidateIdsForSource = ( /** Whether an indexed candidate carries any content digest to compare. */ export const candidateHasDigest = ( index: SourceToBundleCandidateIndex, - nodeId: string, -): boolean => (index.nodes.get(nodeId)?.digests.size ?? 0) > 0; + candidate: SourceToBundleCandidate, +): boolean => + (index.nodes.get(sourceToBundleCandidateIdentity(candidate))?.digests.size ?? + 0) > 0; /** Score one indexed candidate from explicit, caller-visible signals. */ export const scoreSourceToBundleCandidate = ( @@ -173,6 +181,12 @@ export const scoreSourceToBundleCandidate = ( const score = signals.reduce((total, signal) => total + signal.weight, 0); if (score < 20) return null; return { + ...(projection.occurrence === undefined + ? {} + : { + current_file_occurrence: projection.occurrence, + representation_node_ids: [...projection.representations], + }), current_node_id: projection.node.node_id, current_node_kind: projection.node.kind, score, @@ -181,6 +195,78 @@ export const scoreSourceToBundleCandidate = ( }; }; +const fileOccurrenceKey = (occurrence: JavaScriptFileOccurrence): string => + compositeKey([ + "file-occurrence", + occurrence.container_sha256, + occurrence.path, + occurrence.sha256, + ]); +const projectionKey = (projection: CurrentProjection): string => + projection.occurrence === undefined + ? projection.node.node_id + : fileOccurrenceKey(projection.occurrence); + +/** Matching and multiplicity share the producing occurrence identity. */ +export const sourceToBundleCandidateIdentity = ( + candidate: SourceToBundleCandidate, +): string => + candidate.current_file_occurrence === undefined + ? candidate.current_node_id + : fileOccurrenceKey(candidate.current_file_occurrence); + +const projectCurrentOccurrences = ( + nodes: readonly ApplicationNode[], +): CurrentProjection[] => { + const groups = new Map(); + for (const node of nodes) { + const occurrences = new Map< + string, + { + occurrence: JavaScriptFileOccurrence; + observations: ApplicationNode["observations"][number][]; + } + >(); + for (const observation of node.observations) { + const parsed = javaScriptFileOccurrenceSchema.safeParse( + observation.properties.file_occurrence, + ); + if (!parsed.success) continue; + const key = fileOccurrenceKey(parsed.data); + const previous = occurrences.get(key); + if (previous === undefined) + occurrences.set(key, { + occurrence: parsed.data, + observations: [observation], + }); + else previous.observations.push(observation); + } + if (occurrences.size === 0) { + groups.set(node.node_id, projectCurrentNode(node)); + continue; + } + for (const [key, { occurrence, observations }] of occurrences) { + const projected = projectCurrentNode({ ...node, observations }); + const previous = groups.get(key); + groups.set(key, { + ...projected, + occurrence, + node: previous?.node ?? node, + representations: [ + ...new Set([...(previous?.representations ?? []), node.node_id]), + ].sort(compareUnicodeCodePoints), + digests: new Set([ + occurrence.sha256, + ...projected.digests, + ...(previous?.digests ?? []), + ]), + paths: uniquePaths([...projected.paths, ...(previous?.paths ?? [])]), + }); + } + } + return [...groups.values()]; +}; + const projectCurrentNode = (node: ApplicationNode): CurrentProjection => { const digests = new Set(); const paths: CurrentPath[] = []; @@ -229,6 +315,7 @@ const projectCurrentNode = (node: ApplicationNode): CurrentProjection => { } return { node, + representations: [node.node_id], digests, paths: uniquePaths(paths), }; diff --git a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts index d02db4b809..0c48a5ef06 100644 --- a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts +++ b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts @@ -48,6 +48,37 @@ afterEach(async () => { }); describe("JavaScript application workflows", () => { + it("preserves source-owned requested native members across trace directions", () => { + const graph = buildSyntheticJavaScriptApplicationGraph(); + const addon = graph.nodes.find(({ kind }) => kind === "native-addon"); + if (addon === undefined) throw new Error("Expected native fixture node"); + const traces = (["incoming", "outgoing", "both"] as const).map( + (direction) => + traceApplicationFeature({ + sourceEvidenceId: `ev_${"a".repeat(64)}`, + graph, + nativeEvidence: [], + seed: { + kind: "node-id", + value: addon.node_id, + match: "exact", + case_sensitive: true, + }, + direction, + }), + ); + for (const trace of traces) + expect(trace.native_handoffs).toMatchObject([ + { + native_node_id: addon.node_id, + requested_exports: ["openProject"], + export_node_ids: [expect.any(String)], + }, + ]); + expect(traces[0]!.native_handoffs).toEqual(traces[1]!.native_handoffs); + expect(traces[0]!.graph?.edges).not.toEqual(traces[1]!.graph?.edges); + }); + it("traces a contextBridge API through IPC into linked native evidence", () => { const graph = buildSyntheticJavaScriptApplicationGraph(); const nativeEvidence = createEvidence( diff --git a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts index f296c263a4..f9dc3b88d2 100644 --- a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts +++ b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts @@ -19,6 +19,50 @@ import { } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; describe("JavaScript export return-shape comparison", () => { + it("propagates proven ancestor absence to nested additions and removals", async () => { + const [left, right] = await analyzeSources({ + left: 'export default () => ({kind:"result"});', + right: 'export default () => ({kind:"result",nested:{total:1}});', + }); + const forward = compare(left, right), + reverse = compare(right, left); + expect(forward.summary).toEqual({ + added: 2, + removed: 0, + changed: 0, + unknown: 0, + }); + expect(reverse.summary).toEqual({ + added: 0, + removed: 2, + changed: 0, + unknown: 0, + }); + expect(forward.changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "/nested/total", + status: "added", + presence: { left: "absent", right: "present" }, + }), + ]), + ); + expect(forward.coverage.status).toBe("complete-within-inputs"); + const [unknown, present] = await analyzeSources({ + left: 'export default () => ({kind:"result",nested:unresolved});', + right: 'export default () => ({kind:"result",nested:{total:1}});', + }); + expect(compare(unknown, present).changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "/nested/total", + status: "unknown", + presence: { left: "unknown-coverage", right: "present" }, + }), + ]), + ); + }); + it("reports exactly the heading depth addition from source-owned parser fixtures", async () => { const root = await temporaryRoot(); const leftRoot = join(root, "left"); diff --git a/tests/boundary/filesystem/sourceToBundlePaths.test.ts b/tests/boundary/filesystem/sourceToBundlePaths.test.ts index 85181a7b79..cd79ce5012 100644 --- a/tests/boundary/filesystem/sourceToBundlePaths.test.ts +++ b/tests/boundary/filesystem/sourceToBundlePaths.test.ts @@ -16,6 +16,78 @@ import { import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; +it.each([ + { current: "export const value = 1;", status: "unchanged" }, + { current: "export const value = 2;", status: "modified" }, +])( + "counts a file's asset, module, and exports as one $status occurrence", + async ({ current: source, status }) => { + const root = await createTestTempDirectory("rea-source-representations-"); + const previous = join(root, "previous"); + const current = join(root, "current"); + await Promise.all([mkdir(previous), mkdir(current)]); + await Promise.all([ + writeFile(join(previous, "index.js"), "export const value = 1;"), + writeFile(join(current, "index.js"), source), + ]); + const { comparison } = await compareTrees(previous, current); + expect(comparison.items).toEqual([ + expect.objectContaining({ + source_path: "index.js", + status, + candidates: [ + expect.objectContaining({ + current_file_occurrence: expect.objectContaining({ + path: "index.js", + }), + representation_node_ids: expect.any(Array), + }), + ], + }), + ]); + expect( + comparison.items[0]?.candidates[0]?.representation_node_ids?.length, + ).toBeGreaterThan(1); + for (const id of comparison.items[0]?.candidates[0] + ?.representation_node_ids ?? []) + expect(comparison.unmapped_current_node_ids).not.toContain(id); + }, +); + +it("retains two physical occurrences when equal bytes share an asset node", async () => { + const root = await createTestTempDirectory("rea-source-occurrences-"); + const previous = join(root, "previous"); + const current = join(root, "current"); + await Promise.all([mkdir(previous), mkdir(current)]); + const source = "export const value = 1;"; + await Promise.all([ + writeFile(join(previous, "index.js"), source), + writeFile(join(current, "index.js"), source), + writeFile(join(current, "copy.js"), source), + ]); + const { comparison } = await compareTrees(previous, current); + expect(comparison.items).toEqual([ + expect.objectContaining({ + source_path: "index.js", + status: "duplicated", + candidates: expect.arrayContaining([ + expect.objectContaining({ + current_file_occurrence: expect.objectContaining({ + path: "index.js", + }), + }), + expect.objectContaining({ + current_file_occurrence: expect.objectContaining({ path: "copy.js" }), + }), + ]), + }), + ]); + expect(comparison.items[0]?.candidates).toHaveLength(2); + for (const candidate of comparison.items[0]?.candidates ?? []) + for (const id of candidate.representation_node_ids ?? []) + expect(comparison.unmapped_current_node_ids).not.toContain(id); +}); + it("keeps literal hash characters in historical/current filesystem paths", async () => { const root = await createTestTempDirectory("rea-source-path-"); const previous = join(root, "previous");