Files
rea/src/domain/javascriptRuntimeReconciliationMatching.ts
T
N0zoM1z0 84e4361c09 fix(inspector): preserve lossy Node discovery locations (#542)
Keep explicitly selected Node Inspector targets observable when discovery
cannot verify their file location. Resolve literal POSIX discovery paths
without URI decoding, and retain lossy Windows or underscore paths as
unresolved metadata. Validate scriptParsed file URLs independently.

Preserve unknown target identity through runtime reconciliation, regenerate
contracts, and verify real CLI/MCP behavior on Linux and Windows.

Closes #530

Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
2026-10-05 16:55:42 +08:00

434 lines
13 KiB
TypeScript

import {
compareCodePoints,
createJavaScriptApplicationEdge,
type ApplicationEdge,
type ApplicationGraphEvidence,
} from "./javascriptApplicationGraph.js";
import {
digestCanonical,
type ParsedStaticLayer,
} from "./javascriptRuntimeReconciliationParsing.js";
import type { JavaScriptRuntimeReconciliationItem } from "./javascriptRuntimeReconciliationSchemas.js";
import type { RuntimeReconciliationEntity } from "./javascriptRuntimeReconciliationRuntime.js";
import {
mappedRuntimePaths,
type MappedRuntimePath,
type StaticRuntimeCandidate,
} from "./javascriptRuntimeStaticCandidates.js";
export interface RuntimeMatchingProjection {
readonly items: readonly JavaScriptRuntimeReconciliationItem[];
readonly edges: readonly ApplicationEdge[];
readonly omittedItems: number;
}
/** Match runtime entities conservatively and emit only inferred observed_as edges. */
export const reconcileRuntimeEntities = (input: {
readonly entities: readonly RuntimeReconciliationEntity[];
readonly candidates: readonly StaticRuntimeCandidate[];
readonly layers: readonly ParsedStaticLayer[];
}): RuntimeMatchingProjection => {
const index = indexCandidates(input.candidates);
const evaluated = input.entities.map((entity) =>
reconcileEntity(entity, index, input.layers),
);
const retained = evaluated.sort((left, right) =>
compareCodePoints(
left.item.reconciliation_id,
right.item.reconciliation_id,
),
);
return {
items: retained.map(({ item }) => item),
edges: retained.flatMap(({ edge }) => (edge === null ? [] : [edge])),
omittedItems: 0,
};
};
interface EvaluatedMatch {
readonly item: JavaScriptRuntimeReconciliationItem;
readonly edge: ApplicationEdge | null;
}
interface CandidateIndex {
readonly byCategoryDigest: ReadonlyMap<
string,
readonly StaticRuntimeCandidate[]
>;
readonly byLayerCategoryPath: ReadonlyMap<
string,
readonly StaticRuntimeCandidate[]
>;
}
const reconcileEntity = (
entity: RuntimeReconciliationEntity,
index: CandidateIndex,
layers: readonly ParsedStaticLayer[],
): EvaluatedMatch => {
if (entity.location.kind === "unresolved")
return unresolved(entity, {
status: "unknown",
basis: "none",
confidence: "unknown",
reason: "runtime-location-unresolved",
candidates: [],
});
const locations = candidatesWithLocation(entity, index, layers);
if (entity.sourceSha256 !== null) {
const content =
index.byCategoryDigest.get(
categoryDigestKey(entity.staticKind, entity.sourceSha256),
) ?? [];
const locatedKeys = new Set(
locations.map(({ candidate }) => candidateKey(candidate)),
);
const combined = content.filter((candidate) =>
locatedKeys.has(candidateKey(candidate)),
);
const combinedCandidate = combined.at(0);
if (combined.length === 1 && combinedCandidate !== undefined)
return matched(entity, combinedCandidate, {
basis: "content-and-location",
confidence: "high",
reason: "content-and-location-match",
});
const contentCandidate = content.at(0);
if (content.length === 1 && contentCandidate !== undefined) {
const candidate = contentCandidate;
const digestBasis = candidate.digests.find(
({ sha256 }) => sha256 === entity.sourceSha256,
)?.basis;
return matched(entity, candidate, {
basis: digestBasis ?? "content-sha256",
confidence: "high",
reason: "unique-content-match",
});
}
if (content.length > 1)
return unresolved(entity, {
status: "ambiguous",
basis: "none",
confidence: "unknown",
reason: "ambiguous-static-candidates",
candidates: content,
});
if (locations.length > 0)
return unresolved(entity, {
status: "unmatched",
basis: strongestLocationBasis(locations),
confidence: "high",
reason: "captured-content-disagrees-with-static-location",
candidates: locations.map(({ candidate }) => candidate),
});
return unresolved(entity, {
status: "unmatched",
basis: "none",
confidence: "high",
reason: "no-static-content-match",
candidates: [],
});
}
const location = locations.at(0);
if (locations.length === 1 && location !== undefined) {
const located = location;
return matched(entity, located.candidate, {
basis: located.mapping.basis,
confidence: "medium",
reason: "unique-location-match",
});
}
if (locations.length > 1)
return unresolved(entity, {
status: "ambiguous",
basis: strongestLocationBasis(locations),
confidence: "unknown",
reason: "ambiguous-static-candidates",
candidates: locations.map(({ candidate }) => candidate),
});
const mapped = layers.some(
(layer) => mappedRuntimePaths(layer, entity).length > 0,
);
return unresolved(entity, {
status: mapped ? "unmatched" : "unknown",
basis: "none",
confidence: "unknown",
reason: mapped
? "no-static-content-match"
: "no-authorized-location-mapping",
candidates: [],
});
};
interface LocatedCandidate {
readonly candidate: StaticRuntimeCandidate;
readonly mapping: MappedRuntimePath;
}
const candidatesWithLocation = (
entity: RuntimeReconciliationEntity,
index: CandidateIndex,
layers: readonly ParsedStaticLayer[],
): LocatedCandidate[] => {
const located = new Map<string, LocatedCandidate>();
for (const layer of layers)
for (const mapping of mappedRuntimePaths(layer, entity))
for (const candidate of index.byLayerCategoryPath.get(
layerCategoryPathKey(layer.layerId, entity.staticKind, mapping.path),
) ?? []) {
const key = candidateKey(candidate);
if (!located.has(key)) located.set(key, { candidate, mapping });
}
return [...located.values()].sort((left, right) =>
compareCodePoints(
candidateKey(left.candidate),
candidateKey(right.candidate),
),
);
};
const indexCandidates = (
candidates: readonly StaticRuntimeCandidate[],
): CandidateIndex => {
const byCategoryDigest = new Map<string, StaticRuntimeCandidate[]>();
const byLayerCategoryPath = new Map<string, StaticRuntimeCandidate[]>();
for (const candidate of candidates) {
for (const { sha256 } of candidate.digests)
appendIndex(
byCategoryDigest,
categoryDigestKey(candidate.category, sha256),
candidate,
);
for (const path of candidate.paths)
appendIndex(
byLayerCategoryPath,
layerCategoryPathKey(candidate.layer.layerId, candidate.category, path),
candidate,
);
}
return { byCategoryDigest, byLayerCategoryPath };
};
const appendIndex = (
index: Map<string, StaticRuntimeCandidate[]>,
key: string,
candidate: StaticRuntimeCandidate,
): void => {
const values = index.get(key);
if (values === undefined) index.set(key, [candidate]);
else values.push(candidate);
};
const categoryDigestKey = (category: string, digest: string): string =>
`${category}\0${digest}`;
const layerCategoryPathKey = (
layerId: string,
category: string,
path: string,
): string => `${layerId}\0${category}\0${path}`;
const matched = (
entity: RuntimeReconciliationEntity,
candidate: StaticRuntimeCandidate,
input: Pick<ItemInput, "basis" | "confidence" | "reason">,
): EvaluatedMatch => {
const item = createItem(entity, {
layer: candidate.layer,
staticNodeId: candidate.node.node_id,
status: "matched",
basis: input.basis,
confidence: input.confidence,
reason: input.reason,
candidates: [candidate],
});
return {
item,
edge: createJavaScriptApplicationEdge({
source_node_id: candidate.node.node_id,
target_node_id: entity.node.node_id,
relation: "observed_as",
properties: {
reconciliation_id: item.reconciliation_id,
basis: input.basis,
static_layer_id: candidate.layer.layerId,
},
evidence: reconciliationEvidence(
entity,
candidate.layer,
input.basis,
input.confidence,
),
}),
};
};
const unresolved = (
entity: RuntimeReconciliationEntity,
input: Pick<ItemInput, "basis" | "confidence" | "reason" | "candidates"> & {
readonly status: Exclude<
JavaScriptRuntimeReconciliationItem["status"],
"matched"
>;
},
): EvaluatedMatch => ({
item: createItem(entity, {
layer: null,
staticNodeId: null,
status: input.status,
basis: input.basis,
confidence: input.confidence,
reason: input.reason,
candidates: input.candidates,
}),
edge: null,
});
type ItemInput = {
readonly basis: JavaScriptRuntimeReconciliationItem["basis"];
readonly confidence: JavaScriptRuntimeReconciliationItem["confidence"];
readonly reason: JavaScriptRuntimeReconciliationItem["reason"];
readonly candidates: readonly StaticRuntimeCandidate[];
} & (
| {
readonly layer: ParsedStaticLayer;
readonly staticNodeId: string;
readonly status: "matched";
}
| {
readonly layer: null;
readonly staticNodeId: null;
readonly status: Exclude<
JavaScriptRuntimeReconciliationItem["status"],
"matched"
>;
}
);
const createItem = (
entity: RuntimeReconciliationEntity,
input: ItemInput,
): JavaScriptRuntimeReconciliationItem => {
const candidateReferences = uniqueCandidateReferences(input.candidates);
const staticMatch =
input.status === "matched"
? {
static_layer_id: input.layer.layerId,
static_node_id: input.staticNodeId,
status: input.status,
}
: {
static_layer_id: null,
static_node_id: null,
status: input.status,
};
const semantic = {
entity_kind: entity.kind,
runtime_evidence_id: entity.capture.evidence.evidence_id,
runtime_node_id: entity.node.node_id,
...staticMatch,
basis: input.basis,
confidence: input.confidence,
reason: input.reason,
candidate_static_count: candidateReferences.count,
candidate_static_nodes: candidateReferences.items,
} satisfies Omit<JavaScriptRuntimeReconciliationItem, "reconciliation_id">;
return {
reconciliation_id: `jrr_item_${digestCanonical(semantic)}`,
...semantic,
};
};
const reconciliationEvidence = (
entity: RuntimeReconciliationEntity,
layer: ParsedStaticLayer,
basis: JavaScriptRuntimeReconciliationItem["basis"],
confidence: JavaScriptRuntimeReconciliationItem["confidence"],
): ApplicationGraphEvidence => {
const observation = entity.node.observations.at(0);
if (observation === undefined)
throw new TypeError("Runtime reconciliation entity has no observation");
return {
authority: "cross-layer-reconciliation",
state: "inferred",
confidence: confidence === "medium" ? "medium" : "high",
artifact:
entity.sourceSha256 === null
? {
available: false,
reason: "not-observed",
detail: "Runtime source bytes were not captured for this mapping.",
}
: {
available: true,
artifact_id: `art_${entity.sourceSha256}`,
sha256: entity.sourceSha256,
},
location: observation.evidence.location,
extractor: {
name: "rea-javascript-runtime-reconciliation",
version: "1",
operation: "reconcile_javascript_runtime",
executable_sha256: null,
},
coverage:
layer.graph.coverage.status === "complete" &&
entity.capture.scriptsCompleteWithinScope
? { status: "complete", truncated: false, omitted_count: 0, limits: [] }
: {
status: "partial",
truncated: false,
omitted_count: null,
limits: [],
},
limitations: [
basis === "content-sha256" ||
basis === "module-source-sha256" ||
basis === "content-and-location"
? "Equal content digests establish byte identity, not execution causality."
: "Location correspondence is inferred from an artifact root or caller-declared mapping.",
"Passive script parsing or target presence does not prove feature execution.",
],
evidence_ids: [
layer.evidence.evidence_id,
entity.capture.evidence.evidence_id,
].sort(),
};
};
const strongestLocationBasis = (
values: readonly LocatedCandidate[],
): JavaScriptRuntimeReconciliationItem["basis"] => {
const bases = new Set(values.map(({ mapping }) => mapping.basis));
if (bases.has("artifact-path")) return "artifact-path";
if (bases.has("operator-file-mapping")) return "operator-file-mapping";
return "operator-url-mapping";
};
const candidateKey = (candidate: StaticRuntimeCandidate): string =>
`${candidate.layer.layerId}:${candidate.node.node_id}`;
const uniqueCandidateReferences = (
candidates: readonly StaticRuntimeCandidate[],
): {
readonly count: number;
readonly items: JavaScriptRuntimeReconciliationItem["candidate_static_nodes"];
} => {
const seen = new Set<string>();
let count = 0;
const items: JavaScriptRuntimeReconciliationItem["candidate_static_nodes"] =
[];
for (const { layer, node } of candidates) {
const key = `${layer.layerId}:${node.node_id}`;
if (seen.has(key)) continue;
seen.add(key);
count += 1;
items.push({
static_layer_id: layer.layerId,
static_node_id: node.node_id,
});
}
return { count, items };
};