mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(javascript): preserve semantic query identity and source coverage
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user