fix(javascript): preserve source facts across graph projections

This commit is contained in:
morluto
2026-10-24 02:59:20 +08:00
parent dac544be20
commit c8f8ec656c
14 changed files with 369 additions and 60 deletions
+17 -5
View File
@@ -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),
+95 -8
View File
@@ -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");