fix(javascript): preserve source facts across graph projections

This commit is contained in:
morluto committed 2026-10-24 03:00:10 +08:00
1 parent dac544be20
commit c8f8ec656c
14 files changed
+369 -60

No files matched your search

@@ -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),
};