fix(javascript): preserve semantic query identity and source coverage

This commit is contained in:
morluto
2026-10-24 02:52:47 +08:00
parent 34552ff0df
commit cf5e4f6fa3
16 changed files with 362 additions and 51 deletions
+8
View File
@@ -158,6 +158,14 @@ module produced it. In particular:
zero omissions;
- complete coverage cannot be truncated or omit items.
Semantic graph validation enforces the same truncation coupling. Unknown
omission extents remain `null`; positive omitted unknown occurrences can explain
truncation even when omitted node and relation counts are both zero. Ordinary
source unavailability, including invalid UTF-8, can make coverage partial without
resource truncation. JavaScript summary `limitation_count` counts the full,
deduplicated union of parent, application and semantic caveats, including messages
whose delivery is omitted by the view's byte budget.
Unavailable values are represented explicitly rather than as missing fields,
so absence of evidence cannot silently become evidence of absence.
+8
View File
@@ -300,6 +300,14 @@ change hashed event IDs and longer event/listener labels; query the preserved
name or select IDs from the parent graph. Previously stored graphs keep their
original IDs and remain valid trace inputs.
Empty string event names remain exact identities and can be selected with an
event seed whose `name` is `""`. A property seed selects the named slots and
retained accesses whose producer established that no slot was resolved. Those
accesses keep their source locations and unknowns; a missing retained relation
does not prove that an access is slotless. New access nodes record
`property_slot_resolution` independently of whether capacity permitted retaining
the resolved slot node or its relations.
## Version comparison
REA pairs entities only when a tier produces one unique candidate on each side.
@@ -25,6 +25,7 @@ import type {
JavaScriptArtifactFile,
JavaScriptArtifactFileSet,
} from "../../domain/javascript/javascriptArtifactFiles.js";
import { javaScriptArtifactTextExclusion } from "../../domain/javascript/javascriptArtifactFiles.js";
import type {
AnalyzedJavaScriptArtifactFile,
JavaScriptArtifactAnalysisFailure,
@@ -159,7 +160,7 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async (
let analysisStarted = false;
try {
await setImmediate();
if (classifyOutsideAnalysisScope(file, state, projection)) {
if (classifyUnavailableSource(file, state, projection)) {
if (signal?.aborted === true) {
interruption = new AnalysisCancelledError(
"analyze_javascript_application",
@@ -320,7 +321,7 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async (
>();
for (const file of fileSet.files) {
if (analyzedPaths.has(file.path)) continue;
if (classifyOutsideAnalysisScope(file, state, projection)) continue;
if (classifyUnavailableSource(file, state, projection)) continue;
const scope = javascriptArtifactAnalysisScopes[file.kind];
let unavailable = scope === null ? undefined : failures.get(scope);
if (scope !== null && unavailable === undefined) {
@@ -404,7 +405,7 @@ function* analyzeArtifactFileSteps<
context: ArtifactAnalysisContext<SemanticIr>,
): Generator<void, void> {
const { state } = context;
if (classifyOutsideAnalysisScope(file, state)) return;
if (classifyUnavailableSource(file, state)) return;
addStructuredObservations(file, state);
if (file.kind === "html" && file.text.included) {
const html = parseHtmlScripts(file.path, file.text.value);
@@ -460,22 +461,17 @@ function* analyzeArtifactFileSteps<
if (analysis.parse_status === "failed") state.parseFailures += 1;
}
const classifyOutsideAnalysisScope = <
const classifyUnavailableSource = <
SemanticIr extends JavaScriptModuleSemanticIr,
>(
file: JavaScriptArtifactFile,
state: MutableArtifactAnalysis<SemanticIr>,
projection?: JavaScriptSemanticGraphProjection,
): boolean => {
if (file.text.included || file.text.reason !== "outside-analysis-scope")
return false;
const reason = javaScriptArtifactTextExclusion(file);
if (reason === null) return false;
state.files.push({ file, javascript: null, semantic: null });
projection?.recordUnavailableFile(
file,
"Source was not analyzed because it is outside the caller-selected source_paths.",
null,
"incomplete-module",
);
projection?.recordUnavailableFile(file, reason, null, "incomplete-module");
return true;
};
@@ -240,6 +240,7 @@ const graphCoverage = (context: JavaScriptArtifactGraphContext) => {
javascript !== null && javascript.parse_status === "partial",
);
const unknownGap =
context.snapshot.coverage.status !== "complete" ||
(context.fileSet.source_selection?.unselected_files ?? 0) > 0 ||
context.fileSet.unavailable_relevant_files.length > 0 ||
context.analysis.files.some(
@@ -331,11 +332,12 @@ const graphLimitations = (
);
return [
...context.analysis.limitations,
...(context.fileSet.source_selection === undefined
...((context.fileSet.source_selection?.unselected_files ?? 0) === 0
? []
: [
`Caller-selected source analysis leaves ${String(context.fileSet.source_selection.unselected_files)} inventoried files outside analysis scope. Inventory identity covers the complete artifact; relationships through unselected sources remain unknown.`,
`Caller-selected source analysis leaves ${String(context.fileSet.source_selection?.unselected_files)} inventoried files outside analysis scope. Inventory identity covers the complete artifact; relationships through unselected sources remain unknown.`,
]),
...context.snapshot.limitations,
...context.snapshot.integrity_contradictions.map(
({ logical_path: path }) =>
`Artifact integrity metadata contradicts observed bytes at ${path}; the observed bytes are untrusted.`,
@@ -19,7 +19,10 @@ import type {
import { sourceRangesEqual as rangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
javaScriptArtifactTextExclusion,
type JavaScriptArtifactFile,
} from "../../domain/javascript/javascriptArtifactFiles.js";
import {
projectSemanticEvents,
projectSemanticTimers,
@@ -156,16 +159,16 @@ export const buildJavaScriptSemanticGraph = ({
analyzed.semantic.ir,
analyzed.javascript?.electron.call_paths,
);
else if (
!analyzed.file.text.included &&
analyzed.file.text.reason === "outside-analysis-scope"
)
projection.recordUnavailableFile(
analyzed.file,
"Source was not analyzed because it is outside the caller-selected source_paths.",
null,
"incomplete-module",
);
else {
const reason = javaScriptArtifactTextExclusion(analyzed.file);
if (reason !== null)
projection.recordUnavailableFile(
analyzed.file,
reason,
null,
"incomplete-module",
);
}
}
return projection.finish(rootArtifactSha256, applicationGraph);
};
@@ -27,8 +27,12 @@ export function* projectSemanticObjects(
);
for (const [index, operation] of context.ir.objectOperations.entries()) {
if (index % 64 === 0) yield;
const occurrence = addObjectOccurrence(context, operation);
const target = objectTarget(context, values, operation);
const occurrence = addObjectOccurrence(
context,
operation,
target.slotApplicability,
);
const ends = objectRelationEnds(context, operation, occurrence, target);
const resolved =
operation.resolution === "complete" &&
@@ -57,6 +61,7 @@ interface ObjectTarget {
readonly slot: JavaScriptSemanticGraphNode | null;
readonly presence: JavaScriptSemanticSlotPresence;
readonly receiverResolved: boolean;
readonly slotApplicability: "resolved" | "unresolved";
}
const objectTarget = (
@@ -82,6 +87,7 @@ const objectTarget = (
slot: null,
presence: "unknown-coverage",
receiverResolved: false,
slotApplicability: "unresolved",
};
const fact = semanticSlotAtPath(value, operation.propertyPath);
if (operation.propertyPath.length === 0)
@@ -90,6 +96,7 @@ const objectTarget = (
slot: null,
presence: fact.presence,
receiverResolved: semanticContainer(value) !== null,
slotApplicability: "unresolved",
};
const slot = semanticPropertySlot(
context,
@@ -104,6 +111,7 @@ const objectTarget = (
return {
objectNode: slot,
slot,
slotApplicability: "resolved",
presence: fact.presence,
receiverResolved:
receiver.presence === "present" &&
@@ -138,6 +146,7 @@ const objectRelationEnds = (
const addObjectOccurrence = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticObjectOperation,
slotApplicability: ObjectTarget["slotApplicability"],
): JavaScriptSemanticGraphNode | null =>
retainSemanticGraphNode(context.state, context.file, {
kind: "expression",
@@ -156,6 +165,7 @@ const addObjectOccurrence = (
operation_kind: operation.kind,
property_path:
operation.propertyPath === null ? null : [...operation.propertyPath],
property_slot_resolution: slotApplicability,
},
});
@@ -0,0 +1,72 @@
import { expect, it } from "vitest";
import { graphForJavaScript } from "../../../tests/fixtures/javascriptSemanticGraph.js";
import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js";
it.each([
"function read() { return this.speed; }",
"class Game { read() { return this.config.speed; } }",
])(
"matches retained slotless property accesses and their unknowns: %s",
(source) => {
const graph = graphForJavaScript(source);
const accesses = graph.nodes.filter(
({ kind, properties }) =>
kind === "expression" &&
properties.property_slot_resolution === "unresolved" &&
Array.isArray(properties.property_path) &&
properties.property_path.at(-1) === "speed",
);
expect(accesses.length).toBeGreaterThan(0);
const trace = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "property", name: "speed" },
direction: "forward-influence",
});
expect(trace.seed_node_ids).toEqual(
accesses.map(({ node_id }) => node_id).sort(),
);
const relevant = graph.unknowns.filter(({ node_id }) =>
accesses.some((node) => node.node_id === node_id),
);
expect(relevant.length).toBeGreaterThan(0);
expect(trace.unknowns.map(({ unknown_id }) => unknown_id)).toEqual(
expect.arrayContaining(relevant.map(({ unknown_id }) => unknown_id)),
);
expect(
queryJavaScriptSemanticGraph(graph, {
seed: { kind: "property", name: "absent" },
direction: "forward-influence",
}).seed_node_ids,
).toEqual([]);
},
);
it("keeps resolved property seeds on their slot without duplicating access occurrences", () => {
const graph = graphForJavaScript(
"const state = { speed: 1 }; const result = state.speed;",
);
const trace = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "property", name: "speed" },
direction: "backward-provenance",
});
expect(trace.seed_node_ids).toHaveLength(1);
expect(
graph.nodes.find(({ node_id }) => node_id === trace.seed_node_ids[0])?.kind,
).toBe("property-slot");
});
it("queries an empty event name as an exact retained event identity", () => {
const graph = graphForJavaScript(
'const { EventEmitter } = require("node:events"); const bus = new EventEmitter(); bus.on("", function emptyListener() {}); bus.on("data", function dataListener() {});',
);
const trace = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "event", name: "" },
direction: "forward-influence",
});
expect(trace.seed_node_ids.length).toBeGreaterThan(0);
expect(
trace.nodes
.filter(({ node_id }) => trace.seed_node_ids.includes(node_id))
.every(({ properties }) => properties.event_name === ""),
).toBe(true);
});
+7 -2
View File
@@ -485,7 +485,7 @@ it("projects JavaScript summary and preserves selected module observations", ()
if (summary.value.kind !== "summary") throw new Error("expected summary");
expect(summary.value.summary).toMatchObject({
format: "directory",
limitation_count: 0,
limitation_count: summary.value.limitations.length,
});
expect(JSON.stringify(summary.value)).not.toMatch(/"graph"/);
expect(summary.value.summary).not.toHaveProperty("semantic_graph");
@@ -1056,7 +1056,10 @@ it("retains semantic coverage and limitations in a summary", () => {
);
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
summary: { coverage: { semantic: { status: "partial", truncated: true } } },
summary: {
limitation_count: 1,
coverage: { semantic: { status: "partial", truncated: true } },
},
limitations: ["Some semantic nodes could not be retained."],
unknowns: expect.arrayContaining([
"semantic_graph.coverage.status is partial",
@@ -1094,6 +1097,8 @@ it.each([63, 64, 65, 10_000])(
] as const) {
const result = projectAnalysisView(duplicatedParent, view);
if (!result.ok) throw result.error;
if (result.value.kind === "summary")
expect(result.value.summary.limitation_count).toBe(count);
if (count <= 65) {
expect(result.value.limitations).toEqual(messages);
expect(result.value.unknowns).toEqual(
@@ -3,6 +3,7 @@ import {
type AnalysisInputIssue,
} from "../analysisErrorCore.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import { jsonObjectSchema, type JsonValue } from "../jsonValue.js";
import type { JavaScriptApplicationAnalysisResult } from "../javascript/javascriptApplicationAnalysis.js";
import type { ApplicationNode } from "../javascript/javascriptApplicationGraphSchemas.js";
@@ -247,25 +248,32 @@ const parentFields = (
...analysis.graph.limitations,
...analysis.semantic_graph.limitations,
];
const mergedLimitations = uniqueSorted([
...parent.limitations,
...limitations,
]);
return {
parent_evidence_id: parent.evidenceId,
parent_operation: "analyze_javascript_application" as const,
parent_digest: parent.evidenceId.slice(3),
artifact: {
path: analysis.input_path,
sha256: analysis.root_artifact_sha256,
limitationCount: mergedLimitations.length,
fields: {
parent_evidence_id: parent.evidenceId,
parent_operation: "analyze_javascript_application" as const,
parent_digest: parent.evidenceId.slice(3),
artifact: {
path: analysis.input_path,
sha256: analysis.root_artifact_sha256,
},
limitations: boundedViewMessages(
mergedLimitations,
parent.evidenceId,
"limitations",
),
unknowns: boundedViewMessages(
limitations,
parent.evidenceId,
"unknowns",
javascriptCoverageUnknowns(sourceCoverage),
),
},
limitations: boundedViewMessages(
[...parent.limitations, ...limitations],
parent.evidenceId,
"limitations",
),
unknowns: boundedViewMessages(
limitations,
parent.evidenceId,
"unknowns",
javascriptCoverageUnknowns(sourceCoverage),
),
};
};
@@ -350,7 +358,11 @@ export const projectJavaScriptApplicationView = (
),
);
const sourceCoverage = javascriptApplicationSourceCoverage(analysis);
const shared = parentFields(parent, analysis, sourceCoverage);
const { fields: shared, limitationCount } = parentFields(
parent,
analysis,
sourceCoverage,
);
if (view.kind === "javascript-facts") {
const { rows, total } = projectJavaScriptFacts(analysis.graph.nodes, view);
return ok({
@@ -409,7 +421,7 @@ export const projectJavaScriptApplicationView = (
application_unknowns: sourceCoverage.application_unknown_count,
semantic_unknowns: sourceCoverage.semantic_unknown_count,
}),
limitation_count: analysis.limitations.length,
limitation_count: limitationCount,
},
coverage: completeCoverage(moduleCount, moduleCount),
...shared,
@@ -34,6 +34,18 @@ export interface JavaScriptArtifactFile {
};
}
/** Producer text exclusions shared by full and incremental source projections. */
export const javaScriptArtifactTextExclusion = (
file: JavaScriptArtifactFile,
): string | null => {
if (file.text.included || file.text.reason === "not-applicable") return null;
if (file.text.reason === "invalid-utf8" && file.kind !== "javascript")
return null;
return file.text.reason === "outside-analysis-scope"
? "Source was not analyzed because it is outside the caller-selected source_paths."
: "Source bytes are not valid UTF-8; JavaScript semantics were not analyzed.";
};
/**
* One inventoried regular file not backed by analyzed source bytes. Its inventory
* occurrence remains available even when content is opaque or unavailable.
@@ -313,6 +313,24 @@ function* checkCoverageSteps(
path: ["coverage", "limits"],
message: "Truncated graph coverage must identify an effective limit",
});
if (graph.coverage.truncated && graph.coverage.status !== "partial")
context.addIssue({
code: "custom",
path: ["coverage", "status"],
message: "Truncated semantic graph coverage must be partial",
});
if (
graph.coverage.truncated &&
graph.coverage.omitted_nodes === 0 &&
graph.coverage.omitted_relations === 0 &&
omittedUnknowns.length === 0
)
context.addIssue({
code: "custom",
path: ["coverage"],
message:
"Truncated semantic graph coverage must acknowledge omitted facts",
});
const relationsByFamily = new Map(
JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => [family, 0]),
);
@@ -37,6 +37,68 @@ import {
import { queryJavaScriptSemanticGraph } from "./javascriptSemanticQuery.js";
const SHA = "a".repeat(64);
it.each(["unknown", "unavailable", "partial"] as const)(
"rejects contradictory truncated %s graph coverage",
(status) => {
const input = semanticFixtureInput();
expect(() =>
createJavaScriptSemanticGraph({
...input,
coverage: {
...input.coverage,
status,
truncated: true,
limits: [{ name: "nodes", value: 1, unit: "items" }],
},
}),
).toThrow(/Truncated semantic graph coverage/u);
},
);
it("accepts truncated partial coverage with unknown omission extents", () => {
const input = semanticFixtureInput();
expect(
createJavaScriptSemanticGraph({
...input,
coverage: {
...input.coverage,
status: "partial",
truncated: true,
omitted_nodes: null,
omitted_relations: null,
limits: [{ name: "nodes", value: 1, unit: "items" }],
},
}).coverage,
).toMatchObject({ status: "partial", truncated: true });
});
it("accepts omitted unknown occurrences as the reason for semantic truncation", () => {
const input = semanticFixtureInput();
const family = input.coverage.families[0]!.family;
const graph = createJavaScriptSemanticGraph({
...input,
coverage: {
...input.coverage,
status: "partial",
truncated: true,
omitted_unknown_occurrences: [
{ family, reason: "resource-limit", occurrences: 1 },
],
limits: [{ name: "unknowns", value: 1, unit: "items" }],
families: input.coverage.families.map((coverage) =>
coverage.family === family
? { ...coverage, status: "partial" }
: coverage,
),
},
});
expect(graph.coverage).toMatchObject({
truncated: true,
omitted_nodes: 0,
omitted_relations: 0,
});
});
const node = (
kind: (typeof JAVASCRIPT_SEMANTIC_NODE_KINDS)[number],
role: string,
@@ -48,8 +48,17 @@ export const resolveJavaScriptSemanticQuerySeeds = (
.map(({ function_node_id }) => function_node_id)
: [],
);
const unresolvedAccesses = new Set(
input.seed.kind === "property"
? graph.unknowns.flatMap(({ family, node_id }) =>
family === "object-flow" && node_id !== null ? [node_id] : [],
)
: [],
);
return graph.nodes
.filter((node) => seedMatches(node, input, fingerprintFunctions))
.filter((node) =>
seedMatches(node, input, fingerprintFunctions, unresolvedAccesses),
)
.map(({ node_id }) => node_id)
.sort(compareUnicodeCodePoints);
};
@@ -58,6 +67,7 @@ const seedMatches = (
node: JavaScriptSemanticGraphNode,
input: JavaScriptSemanticQueryInput,
fingerprintFunctions: ReadonlySet<string>,
unresolvedAccesses: ReadonlySet<string>,
): boolean => {
const seed = input.seed;
if (seed.kind === "semantic-node") return node.node_id === seed.node_id;
@@ -66,8 +76,20 @@ const seedMatches = (
if (seed.kind === "function") return fingerprintFunctions.has(node.node_id);
if (seed.kind === "literal")
return node.kind === "literal" && node.properties.value === seed.value;
if (seed.kind === "property")
return node.kind === "property-slot" && node.properties.name === seed.name;
if (seed.kind === "property") {
if (node.kind === "property-slot")
return node.properties.name === seed.name;
if (node.kind !== "expression") return false;
const path = node.properties.property_path;
// Older retained graphs did not record slot resolution. Their existing
// source-bound unknown establishes an unresolved access without guessing
// from an absent or resource-omitted relation.
const slotless =
node.properties.property_slot_resolution === "unresolved" ||
(node.properties.property_slot_resolution === undefined &&
unresolvedAccesses.has(node.node_id));
return slotless && Array.isArray(path) && path.at(-1) === seed.name;
}
if (seed.kind === "endpoint")
return (
(node.kind === "request" || node.kind === "response") &&
@@ -49,7 +49,7 @@ export const javaScriptSemanticQuerySeedSchema = z.discriminatedUnion("kind", [
}),
z.strictObject({
kind: z.literal("event"),
name: z.string().min(1),
name: z.string(),
}),
z.strictObject({
kind: z.literal("boundary-field"),
@@ -0,0 +1,62 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { javaScriptSemanticTraceResultSchema } from "../../../src/domain/javascript/javascriptSemanticTraceSchemas.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"preserves exact empty events and unresolved property queries through public Evidence",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-graph-query-coverage-");
await writeFile(
join(root, "main.cjs"),
'const { EventEmitter } = require("node:events"); const bus = new EventEmitter(); bus.on("", function emptyListener() {}); function read() { return this.speed; }',
);
await writeFile(join(root, "other.js"), "export const answer = 42;");
const response = await cli.run({
arguments: [
"analyze-javascript-application",
root,
"--source-path",
"main.cjs",
"--source-path",
"other.js",
"--format",
"json",
],
});
expect(response.exitCode, response.stderr).toBe(0);
const evidence = parseEvidence(response.json);
const result = javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
);
expect(result.source_selection?.unselected_files).toBe(0);
expect(
result.graph.limitations.some((text) =>
text.includes("relationships through unselected sources"),
),
).toBe(false);
const call = await cli.sharedMcpCall?.();
if (call === undefined) throw new Error("Expected shared MCP session");
for (const seed of [
{ kind: "event", name: "" },
{ kind: "property", name: "speed" },
] as const) {
const traced = await call("trace_javascript_semantics", {
application: evidence,
query: { seed, direction: "forward-influence" },
});
expect(traced.isError, JSON.stringify(traced.content)).not.toBe(true);
const trace = javaScriptSemanticTraceResultSchema.parse(
parseEvidence(traced.structuredContent).normalized_result,
);
expect(trace.summary.total_seed_matches).toBeGreaterThan(0);
if (seed.kind === "property")
expect(trace.summary.relevant_unknowns).toBeGreaterThan(0);
}
},
);
@@ -6,6 +6,7 @@ import { expect } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { JAVASCRIPT_SEMANTIC_RELATION_FAMILIES } from "../../../src/domain/javascript/javascriptSemanticGraphSchemas.js";
import { writeJavaScriptSourceSelectionFixture } from "../../fixtures/javascriptSourceSelection.js";
import {
asarHeaderBytes,
@@ -102,6 +103,24 @@ cliTest.for([
);
expect(full.source_selection).toBeUndefined();
expect(full.statistics.invalid_utf8_files).toBe(1);
const unavailableModule = full.semantic_graph.nodes.find(
({ kind, identity }) =>
kind === "module" && identity.module_path === "invalid.mjs",
);
expect(unavailableModule).toBeDefined();
expect(
full.semantic_graph.unknowns.filter(
({ node_id, detail }) =>
node_id === unavailableModule?.node_id &&
detail.includes("not valid UTF-8"),
),
).toHaveLength(JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.length);
expect(full.semantic_graph.coverage).toMatchObject({
status: "partial",
truncated: false,
omitted_nodes: null,
omitted_relations: null,
});
expect(full.statistics.parse_failures).toBeGreaterThan(0);
expect([
result.root_artifact_sha256,