mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(javascript): preserve source facts across graph projections
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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<string>,
|
||||
nativeEvidence: readonly Evidence[],
|
||||
): NativeHandoff[] => {
|
||||
let nodeById: Map<string, ApplicationNode> | 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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string>;
|
||||
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<string, Set<string>>();
|
||||
const byPathSuffix = new Map<string, Set<string>>();
|
||||
const byBasename = new Map<string, Set<string>>();
|
||||
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<string, CurrentProjection>();
|
||||
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<string>();
|
||||
const paths: CurrentPath[] = [];
|
||||
@@ -229,6 +315,7 @@ const projectCurrentNode = (node: ApplicationNode): CurrentProjection => {
|
||||
}
|
||||
return {
|
||||
node,
|
||||
representations: [node.node_id],
|
||||
digests,
|
||||
paths: uniquePaths(paths),
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user