mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(javascript): preserve mapped runtime digest disagreements
Fixes #3316 Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
5 files changed
+316
-84
No files matched your search
@@ -91,17 +91,20 @@ nodes. REA evaluates compatible static candidates conservatively:
|
||||
|
||||
1. A captured-source SHA-256 plus one matching mapped location yields
|
||||
a content-and-location match.
|
||||
2. One unique exact content digest can match even when no location mapping is
|
||||
available. Whole-asset digests and reconstructed module-factory source
|
||||
2. When no static candidate exists at a mapped location, one unique exact
|
||||
content digest can match. Whole-asset digests and reconstructed module-factory source
|
||||
digests remain distinguishable in the reported basis.
|
||||
3. Without captured bytes, one unique mapped artifact path can produce a
|
||||
medium-confidence location match.
|
||||
4. Multiple candidates remain `ambiguous`; no `observed_as` edge is emitted.
|
||||
With captured source and mapped candidates, only mapped content matches
|
||||
qualify; unrelated content matches elsewhere are excluded.
|
||||
`candidate_static_count` reports the full qualified layer/node count, and
|
||||
`candidate_static_nodes` includes every unique candidate reference.
|
||||
5. If captured bytes disagree with the static candidate at the mapped path,
|
||||
the result is an explicit mismatch. A path never overrides a digest
|
||||
disagreement.
|
||||
the result is an explicit mismatch. An equal digest elsewhere cannot hide
|
||||
that disagreement. If a mapped candidate's digest is unavailable and no
|
||||
mapped content match is known, the comparison remains `unknown`.
|
||||
6. Missing authorization mappings and incomplete or truncated input coverage
|
||||
remain unknown or truncated rather than becoming absence claims.
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ const classifyLayer = (
|
||||
const nodes = layer.graph.nodes.filter(isLoadStateNode);
|
||||
const direct = new Map<string, string[]>();
|
||||
const ambiguous = new Set<string>();
|
||||
const incomplete = new Set<string>();
|
||||
for (const match of matches)
|
||||
if (
|
||||
match.status === "matched" &&
|
||||
@@ -54,10 +55,12 @@ const classifyLayer = (
|
||||
match.static_node_id !== null
|
||||
)
|
||||
append(direct, match.static_node_id, match.runtime_node_id);
|
||||
else if (match.status === "ambiguous")
|
||||
else if (match.status === "ambiguous" || match.status === "unknown") {
|
||||
const uncertain = match.status === "ambiguous" ? ambiguous : incomplete;
|
||||
for (const candidate of match.candidate_static_nodes)
|
||||
if (candidate.static_layer_id === layer.layerId)
|
||||
ambiguous.add(candidate.static_node_id);
|
||||
uncertain.add(candidate.static_node_id);
|
||||
}
|
||||
const resident = residentNodes(layer, new Set(direct.keys()));
|
||||
const runtimeScope = createStaticRuntimeScope(layer, runtimeEntities);
|
||||
const scopedEntities = runtimeEntities.filter(
|
||||
@@ -82,11 +85,13 @@ const classifyLayer = (
|
||||
runtimeNodeIds: [],
|
||||
reason: "containing-asset-was-loaded",
|
||||
});
|
||||
if (ambiguous.has(node.node_id))
|
||||
if (incomplete.has(node.node_id) || ambiguous.has(node.node_id))
|
||||
return state(layer, node, {
|
||||
status: "unknown",
|
||||
runtimeNodeIds: [],
|
||||
reason: "ambiguous-runtime-candidate",
|
||||
reason: incomplete.has(node.node_id)
|
||||
? "static-or-runtime-coverage-incomplete"
|
||||
: "ambiguous-runtime-candidate",
|
||||
});
|
||||
const nodeWithinScope = staticNodeWithinRuntimeScope(node, runtimeScope);
|
||||
if (layerComplete && nodeWithinScope)
|
||||
|
||||
@@ -78,60 +78,13 @@ const reconcileEntity = (
|
||||
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)),
|
||||
if (entity.sourceSha256 !== null)
|
||||
return reconcileCapturedSource(
|
||||
entity,
|
||||
entity.sourceSha256,
|
||||
locations,
|
||||
index,
|
||||
);
|
||||
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) {
|
||||
@@ -169,6 +122,69 @@ interface LocatedCandidate {
|
||||
readonly mapping: MappedRuntimePath;
|
||||
}
|
||||
|
||||
const reconcileCapturedSource = (
|
||||
entity: RuntimeReconciliationEntity,
|
||||
sourceSha256: string,
|
||||
locations: readonly LocatedCandidate[],
|
||||
index: CandidateIndex,
|
||||
): EvaluatedMatch => {
|
||||
// A known mapped candidate owns the comparison. Unrelated equal bytes cannot
|
||||
// repair a disagreement or establish the content of a candidate without a digest.
|
||||
const content =
|
||||
locations.length > 0
|
||||
? locations
|
||||
.map(({ candidate }) => candidate)
|
||||
.filter(({ digests }) =>
|
||||
digests.some(({ sha256 }) => sha256 === sourceSha256),
|
||||
)
|
||||
: (index.byCategoryDigest.get(
|
||||
categoryDigestKey(entity.staticKind, sourceSha256),
|
||||
) ?? []);
|
||||
const candidate = content.at(0);
|
||||
if (content.length === 1 && candidate !== undefined)
|
||||
return matched(entity, candidate, {
|
||||
basis:
|
||||
locations.length > 0
|
||||
? "content-and-location"
|
||||
: (candidate.digests.find(({ sha256 }) => sha256 === sourceSha256)
|
||||
?.basis ?? "content-sha256"),
|
||||
confidence: "high",
|
||||
reason:
|
||||
locations.length > 0
|
||||
? "content-and-location-match"
|
||||
: "unique-content-match",
|
||||
});
|
||||
if (content.length > 1)
|
||||
return unresolved(entity, {
|
||||
status: "ambiguous",
|
||||
basis: locations.length > 0 ? "content-and-location" : "none",
|
||||
confidence: "unknown",
|
||||
reason: "ambiguous-static-candidates",
|
||||
candidates: content,
|
||||
});
|
||||
if (locations.length > 0) {
|
||||
const digestUnavailable = locations.some(
|
||||
({ candidate: { digests } }) => digests.length === 0,
|
||||
);
|
||||
return unresolved(entity, {
|
||||
status: digestUnavailable ? "unknown" : "unmatched",
|
||||
basis: strongestLocationBasis(locations),
|
||||
confidence: digestUnavailable ? "unknown" : "high",
|
||||
reason: digestUnavailable
|
||||
? "static-or-runtime-coverage-incomplete"
|
||||
: "captured-content-disagrees-with-static-location",
|
||||
candidates: locations.map(({ candidate: located }) => located),
|
||||
});
|
||||
}
|
||||
return unresolved(entity, {
|
||||
status: "unmatched",
|
||||
basis: "none",
|
||||
confidence: "high",
|
||||
reason: "no-static-content-match",
|
||||
candidates: [],
|
||||
});
|
||||
};
|
||||
|
||||
const candidatesWithLocation = (
|
||||
entity: RuntimeReconciliationEntity,
|
||||
index: CandidateIndex,
|
||||
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseRuntimeReconciliationInput,
|
||||
reconcileJavaScriptRuntime,
|
||||
} from "../../../../src/domain/javascript/javascriptRuntimeReconciliation.js";
|
||||
import { reconcileRuntimeEntities } from "../../../../src/domain/javascript/javascriptRuntimeReconciliationMatching.js";
|
||||
import { classifyStaticLoadStates } from "../../../../src/domain/javascript/javascriptRuntimeLoadState.js";
|
||||
import { projectRuntimeCaptures } from "../../../../src/domain/javascript/javascriptRuntimeReconciliationRuntime.js";
|
||||
import { reconcileJavaScriptRuntimeInputSchema } from "../../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js";
|
||||
import { collectStaticRuntimeCandidates } from "../../../../src/domain/javascript/javascriptRuntimeStaticCandidates.js";
|
||||
import {
|
||||
SOURCE,
|
||||
analyzeFixture,
|
||||
applicationFixture,
|
||||
electronRuntimeEvidence,
|
||||
} from "../../../fixtures/javascriptRuntimeReconciliation.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const CAPTURED = "export const observed = 'different';\n";
|
||||
|
||||
const collisionInput = async (
|
||||
alternatives: number,
|
||||
explicitMapping: boolean,
|
||||
) => {
|
||||
const root = await applicationFixture();
|
||||
if (alternatives > 0) await writeFile(join(root, "other.js"), CAPTURED);
|
||||
const live = explicitMapping
|
||||
? await createTestTempDirectory("rea-runtime-content-live-")
|
||||
: root;
|
||||
const extra = await createTestTempDirectory("rea-runtime-content-extra-");
|
||||
await writeFile(join(extra, "app.js"), CAPTURED);
|
||||
return parseRuntimeReconciliationInput(
|
||||
reconcileJavaScriptRuntimeInputSchema.parse({
|
||||
static_layers: [
|
||||
{
|
||||
role: "application",
|
||||
analysis: await analyzeFixture(root),
|
||||
runtime_mappings: explicitMapping
|
||||
? [{ kind: "file-root", root: live, artifact_prefix: "" }]
|
||||
: [],
|
||||
},
|
||||
...(alternatives > 1
|
||||
? [{ role: "assets", analysis: await analyzeFixture(extra) }]
|
||||
: []),
|
||||
],
|
||||
runtime_observations: [
|
||||
electronRuntimeEvidence(live, CAPTURED, { includeWorker: false }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
it.each([
|
||||
[0, false],
|
||||
[1, false],
|
||||
[2, false],
|
||||
[0, true],
|
||||
[1, true],
|
||||
[2, true],
|
||||
] as const)(
|
||||
"preserves mapped disagreement with %i other content matches (explicit mapping: %s)",
|
||||
async (alternatives, explicitMapping) => {
|
||||
const parsed = await collisionInput(alternatives, explicitMapping);
|
||||
const result = reconcileJavaScriptRuntime(parsed);
|
||||
const script = result.reconciliations.find(
|
||||
({ entity_kind }) => entity_kind === "script",
|
||||
);
|
||||
expect(script).toMatchObject({
|
||||
status: "unmatched",
|
||||
basis: explicitMapping ? "operator-file-mapping" : "artifact-path",
|
||||
confidence: "high",
|
||||
reason: "captured-content-disagrees-with-static-location",
|
||||
static_layer_id: null,
|
||||
static_node_id: null,
|
||||
candidate_static_count: 1,
|
||||
});
|
||||
const candidate = script?.candidate_static_nodes[0];
|
||||
const selected = result.graph.nodes.find(
|
||||
({ node_id }) => node_id === candidate?.static_node_id,
|
||||
);
|
||||
expect(selected?.observations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({ path: "app.js" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
result.graph.edges.some(
|
||||
(edge) =>
|
||||
edge.relation === "observed_as" &&
|
||||
edge.target_node_id === script?.runtime_node_id,
|
||||
),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("restricts content-and-location ambiguity to mapped candidates", async () => {
|
||||
const application = await applicationFixture();
|
||||
const cache = await applicationFixture();
|
||||
const other = await applicationFixture();
|
||||
const live = await createTestTempDirectory("rea-runtime-content-mapped-");
|
||||
const analysis = await analyzeFixture(application);
|
||||
const mapping = [{ kind: "file-root", root: live, artifact_prefix: "" }];
|
||||
const parsed = parseRuntimeReconciliationInput(
|
||||
reconcileJavaScriptRuntimeInputSchema.parse({
|
||||
static_layers: [
|
||||
{ role: "application", analysis, runtime_mappings: mapping },
|
||||
{
|
||||
role: "cache",
|
||||
analysis: await analyzeFixture(cache),
|
||||
runtime_mappings: mapping,
|
||||
},
|
||||
{ role: "assets", analysis: await analyzeFixture(other) },
|
||||
],
|
||||
runtime_observations: [
|
||||
electronRuntimeEvidence(live, SOURCE, { includeWorker: false }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const result = reconcileJavaScriptRuntime(parsed);
|
||||
const script = result.reconciliations.find(
|
||||
({ entity_kind }) => entity_kind === "script",
|
||||
);
|
||||
expect(script).toMatchObject({
|
||||
status: "ambiguous",
|
||||
reason: "ambiguous-static-candidates",
|
||||
candidate_static_count: 2,
|
||||
});
|
||||
const unmapped = result.static_layers.find(({ role }) => role === "assets");
|
||||
expect(
|
||||
script?.candidate_static_nodes.map(
|
||||
({ static_layer_id }) => static_layer_id,
|
||||
),
|
||||
).not.toContain(unmapped?.layer_id);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"retains digest-only matching without a mapped candidate (mapping: %s)",
|
||||
async (mapped) => {
|
||||
const root = await applicationFixture();
|
||||
await writeFile(join(root, "other.js"), CAPTURED);
|
||||
const live = await createTestTempDirectory(
|
||||
"rea-runtime-content-unlocated-",
|
||||
);
|
||||
const result = reconcileJavaScriptRuntime(
|
||||
parseRuntimeReconciliationInput(
|
||||
reconcileJavaScriptRuntimeInputSchema.parse({
|
||||
static_layers: [
|
||||
{
|
||||
role: "application",
|
||||
analysis: await analyzeFixture(root),
|
||||
runtime_mappings: mapped
|
||||
? [{ kind: "file-root", root: live, artifact_prefix: "" }]
|
||||
: [],
|
||||
},
|
||||
],
|
||||
runtime_observations: [
|
||||
electronRuntimeEvidence(live, CAPTURED, {
|
||||
scriptFile: "absent.js",
|
||||
includeWorker: false,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
result.reconciliations.find(
|
||||
({ entity_kind }) => entity_kind === "script",
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "matched",
|
||||
basis: "content-sha256",
|
||||
confidence: "high",
|
||||
reason: "unique-content-match",
|
||||
candidate_static_count: 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps unavailable mapped digests unknown instead of borrowing unrelated matches", async () => {
|
||||
const parsed = await collisionInput(1, true);
|
||||
const candidates = collectStaticRuntimeCandidates(parsed.layers).map(
|
||||
(candidate) =>
|
||||
candidate.paths.includes("app.js") && candidate.category === "javascript"
|
||||
? { ...candidate, digests: [] }
|
||||
: candidate,
|
||||
);
|
||||
// Model the matcher's supported missing-digest boundary; do not falsify Evidence.
|
||||
const runtime = projectRuntimeCaptures(parsed.captures);
|
||||
const result = reconcileRuntimeEntities({
|
||||
entities: runtime.entities,
|
||||
candidates,
|
||||
layers: parsed.layers,
|
||||
});
|
||||
expect(
|
||||
result.items.find(({ entity_kind }) => entity_kind === "script"),
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
basis: "operator-file-mapping",
|
||||
confidence: "unknown",
|
||||
reason: "static-or-runtime-coverage-incomplete",
|
||||
candidate_static_count: 1,
|
||||
});
|
||||
const script = result.items.find(
|
||||
({ entity_kind }) => entity_kind === "script",
|
||||
);
|
||||
const candidate = script?.candidate_static_nodes[0];
|
||||
const states = classifyStaticLoadStates(
|
||||
parsed.layers,
|
||||
runtime.entities,
|
||||
result.items,
|
||||
{
|
||||
reconciliationComplete: true,
|
||||
},
|
||||
);
|
||||
expect(
|
||||
states.states.find(
|
||||
(state) =>
|
||||
state.static_layer_id === candidate?.static_layer_id &&
|
||||
state.static_node_id === candidate?.static_node_id,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
reason: "static-or-runtime-coverage-incomplete",
|
||||
});
|
||||
});
|
||||
-23
@@ -224,29 +224,6 @@ it("keeps static load states unknown when one scoped capture omits scripts", asy
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a captured digest disagreement instead of accepting a path match", async () => {
|
||||
const fixture = await applicationFixture();
|
||||
const staticEvidence = await analyzeFixture(fixture);
|
||||
const runtimeEvidence = electronRuntimeEvidence(
|
||||
fixture,
|
||||
"export const observed = 'different';\n",
|
||||
);
|
||||
|
||||
const result = reconcileInput({
|
||||
static_layers: [{ role: "application", analysis: staticEvidence }],
|
||||
runtime_observations: [runtimeEvidence],
|
||||
});
|
||||
const script = result.reconciliations.find(
|
||||
({ entity_kind: kind }) => kind === "script",
|
||||
);
|
||||
|
||||
expect(script).toMatchObject({
|
||||
status: "unmatched",
|
||||
reason: "captured-content-disagrees-with-static-location",
|
||||
});
|
||||
expect(script?.candidate_static_nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reconciles active Electron as a partial target-only runtime capture", async () => {
|
||||
const fixture = await applicationFixture();
|
||||
const staticEvidence = await analyzeFixture(fixture);
|
||||
|
||||
Reference in new issue
Block a user