fix(javascript): preserve semantic query identity and source coverage
This commit is contained in:
1 parent
34552ff0df
commit
cf5e4f6fa3
16 files changed
+362
-51
No files matched your search
@@ -158,6 +158,14 @@ module produced it. In particular:
|
|||||||
zero omissions;
|
zero omissions;
|
||||||
- complete coverage cannot be truncated or omit items.
|
- 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,
|
Unavailable values are represented explicitly rather than as missing fields,
|
||||||
so absence of evidence cannot silently become evidence of absence.
|
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
|
name or select IDs from the parent graph. Previously stored graphs keep their
|
||||||
original IDs and remain valid trace inputs.
|
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
|
## Version comparison
|
||||||
|
|
||||||
REA pairs entities only when a tier produces one unique candidate on each side.
|
REA pairs entities only when a tier produces one unique candidate on each side.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type {
|
|||||||
JavaScriptArtifactFile,
|
JavaScriptArtifactFile,
|
||||||
JavaScriptArtifactFileSet,
|
JavaScriptArtifactFileSet,
|
||||||
} from "../../domain/javascript/javascriptArtifactFiles.js";
|
} from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||||
|
import { javaScriptArtifactTextExclusion } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||||
import type {
|
import type {
|
||||||
AnalyzedJavaScriptArtifactFile,
|
AnalyzedJavaScriptArtifactFile,
|
||||||
JavaScriptArtifactAnalysisFailure,
|
JavaScriptArtifactAnalysisFailure,
|
||||||
@@ -159,7 +160,7 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async (
|
|||||||
let analysisStarted = false;
|
let analysisStarted = false;
|
||||||
try {
|
try {
|
||||||
await setImmediate();
|
await setImmediate();
|
||||||
if (classifyOutsideAnalysisScope(file, state, projection)) {
|
if (classifyUnavailableSource(file, state, projection)) {
|
||||||
if (signal?.aborted === true) {
|
if (signal?.aborted === true) {
|
||||||
interruption = new AnalysisCancelledError(
|
interruption = new AnalysisCancelledError(
|
||||||
"analyze_javascript_application",
|
"analyze_javascript_application",
|
||||||
@@ -320,7 +321,7 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async (
|
|||||||
>();
|
>();
|
||||||
for (const file of fileSet.files) {
|
for (const file of fileSet.files) {
|
||||||
if (analyzedPaths.has(file.path)) continue;
|
if (analyzedPaths.has(file.path)) continue;
|
||||||
if (classifyOutsideAnalysisScope(file, state, projection)) continue;
|
if (classifyUnavailableSource(file, state, projection)) continue;
|
||||||
const scope = javascriptArtifactAnalysisScopes[file.kind];
|
const scope = javascriptArtifactAnalysisScopes[file.kind];
|
||||||
let unavailable = scope === null ? undefined : failures.get(scope);
|
let unavailable = scope === null ? undefined : failures.get(scope);
|
||||||
if (scope !== null && unavailable === undefined) {
|
if (scope !== null && unavailable === undefined) {
|
||||||
@@ -404,7 +405,7 @@ function* analyzeArtifactFileSteps<
|
|||||||
context: ArtifactAnalysisContext<SemanticIr>,
|
context: ArtifactAnalysisContext<SemanticIr>,
|
||||||
): Generator<void, void> {
|
): Generator<void, void> {
|
||||||
const { state } = context;
|
const { state } = context;
|
||||||
if (classifyOutsideAnalysisScope(file, state)) return;
|
if (classifyUnavailableSource(file, state)) return;
|
||||||
addStructuredObservations(file, state);
|
addStructuredObservations(file, state);
|
||||||
if (file.kind === "html" && file.text.included) {
|
if (file.kind === "html" && file.text.included) {
|
||||||
const html = parseHtmlScripts(file.path, file.text.value);
|
const html = parseHtmlScripts(file.path, file.text.value);
|
||||||
@@ -460,22 +461,17 @@ function* analyzeArtifactFileSteps<
|
|||||||
if (analysis.parse_status === "failed") state.parseFailures += 1;
|
if (analysis.parse_status === "failed") state.parseFailures += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const classifyOutsideAnalysisScope = <
|
const classifyUnavailableSource = <
|
||||||
SemanticIr extends JavaScriptModuleSemanticIr,
|
SemanticIr extends JavaScriptModuleSemanticIr,
|
||||||
>(
|
>(
|
||||||
file: JavaScriptArtifactFile,
|
file: JavaScriptArtifactFile,
|
||||||
state: MutableArtifactAnalysis<SemanticIr>,
|
state: MutableArtifactAnalysis<SemanticIr>,
|
||||||
projection?: JavaScriptSemanticGraphProjection,
|
projection?: JavaScriptSemanticGraphProjection,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (file.text.included || file.text.reason !== "outside-analysis-scope")
|
const reason = javaScriptArtifactTextExclusion(file);
|
||||||
return false;
|
if (reason === null) return false;
|
||||||
state.files.push({ file, javascript: null, semantic: null });
|
state.files.push({ file, javascript: null, semantic: null });
|
||||||
projection?.recordUnavailableFile(
|
projection?.recordUnavailableFile(file, reason, null, "incomplete-module");
|
||||||
file,
|
|
||||||
"Source was not analyzed because it is outside the caller-selected source_paths.",
|
|
||||||
null,
|
|
||||||
"incomplete-module",
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ const graphCoverage = (context: JavaScriptArtifactGraphContext) => {
|
|||||||
javascript !== null && javascript.parse_status === "partial",
|
javascript !== null && javascript.parse_status === "partial",
|
||||||
);
|
);
|
||||||
const unknownGap =
|
const unknownGap =
|
||||||
|
context.snapshot.coverage.status !== "complete" ||
|
||||||
(context.fileSet.source_selection?.unselected_files ?? 0) > 0 ||
|
(context.fileSet.source_selection?.unselected_files ?? 0) > 0 ||
|
||||||
context.fileSet.unavailable_relevant_files.length > 0 ||
|
context.fileSet.unavailable_relevant_files.length > 0 ||
|
||||||
context.analysis.files.some(
|
context.analysis.files.some(
|
||||||
@@ -331,11 +332,12 @@ const graphLimitations = (
|
|||||||
);
|
);
|
||||||
return [
|
return [
|
||||||
...context.analysis.limitations,
|
...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(
|
...context.snapshot.integrity_contradictions.map(
|
||||||
({ logical_path: path }) =>
|
({ logical_path: path }) =>
|
||||||
`Artifact integrity metadata contradicts observed bytes at ${path}; the observed bytes are untrusted.`,
|
`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 { sourceRangesEqual as rangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
|
||||||
import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
|
import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
|
||||||
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.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 {
|
import {
|
||||||
projectSemanticEvents,
|
projectSemanticEvents,
|
||||||
projectSemanticTimers,
|
projectSemanticTimers,
|
||||||
@@ -156,16 +159,16 @@ export const buildJavaScriptSemanticGraph = ({
|
|||||||
analyzed.semantic.ir,
|
analyzed.semantic.ir,
|
||||||
analyzed.javascript?.electron.call_paths,
|
analyzed.javascript?.electron.call_paths,
|
||||||
);
|
);
|
||||||
else if (
|
else {
|
||||||
!analyzed.file.text.included &&
|
const reason = javaScriptArtifactTextExclusion(analyzed.file);
|
||||||
analyzed.file.text.reason === "outside-analysis-scope"
|
if (reason !== null)
|
||||||
)
|
projection.recordUnavailableFile(
|
||||||
projection.recordUnavailableFile(
|
analyzed.file,
|
||||||
analyzed.file,
|
reason,
|
||||||
"Source was not analyzed because it is outside the caller-selected source_paths.",
|
null,
|
||||||
null,
|
"incomplete-module",
|
||||||
"incomplete-module",
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
return projection.finish(rootArtifactSha256, applicationGraph);
|
return projection.finish(rootArtifactSha256, applicationGraph);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,8 +27,12 @@ export function* projectSemanticObjects(
|
|||||||
);
|
);
|
||||||
for (const [index, operation] of context.ir.objectOperations.entries()) {
|
for (const [index, operation] of context.ir.objectOperations.entries()) {
|
||||||
if (index % 64 === 0) yield;
|
if (index % 64 === 0) yield;
|
||||||
const occurrence = addObjectOccurrence(context, operation);
|
|
||||||
const target = objectTarget(context, values, operation);
|
const target = objectTarget(context, values, operation);
|
||||||
|
const occurrence = addObjectOccurrence(
|
||||||
|
context,
|
||||||
|
operation,
|
||||||
|
target.slotApplicability,
|
||||||
|
);
|
||||||
const ends = objectRelationEnds(context, operation, occurrence, target);
|
const ends = objectRelationEnds(context, operation, occurrence, target);
|
||||||
const resolved =
|
const resolved =
|
||||||
operation.resolution === "complete" &&
|
operation.resolution === "complete" &&
|
||||||
@@ -57,6 +61,7 @@ interface ObjectTarget {
|
|||||||
readonly slot: JavaScriptSemanticGraphNode | null;
|
readonly slot: JavaScriptSemanticGraphNode | null;
|
||||||
readonly presence: JavaScriptSemanticSlotPresence;
|
readonly presence: JavaScriptSemanticSlotPresence;
|
||||||
readonly receiverResolved: boolean;
|
readonly receiverResolved: boolean;
|
||||||
|
readonly slotApplicability: "resolved" | "unresolved";
|
||||||
}
|
}
|
||||||
|
|
||||||
const objectTarget = (
|
const objectTarget = (
|
||||||
@@ -82,6 +87,7 @@ const objectTarget = (
|
|||||||
slot: null,
|
slot: null,
|
||||||
presence: "unknown-coverage",
|
presence: "unknown-coverage",
|
||||||
receiverResolved: false,
|
receiverResolved: false,
|
||||||
|
slotApplicability: "unresolved",
|
||||||
};
|
};
|
||||||
const fact = semanticSlotAtPath(value, operation.propertyPath);
|
const fact = semanticSlotAtPath(value, operation.propertyPath);
|
||||||
if (operation.propertyPath.length === 0)
|
if (operation.propertyPath.length === 0)
|
||||||
@@ -90,6 +96,7 @@ const objectTarget = (
|
|||||||
slot: null,
|
slot: null,
|
||||||
presence: fact.presence,
|
presence: fact.presence,
|
||||||
receiverResolved: semanticContainer(value) !== null,
|
receiverResolved: semanticContainer(value) !== null,
|
||||||
|
slotApplicability: "unresolved",
|
||||||
};
|
};
|
||||||
const slot = semanticPropertySlot(
|
const slot = semanticPropertySlot(
|
||||||
context,
|
context,
|
||||||
@@ -104,6 +111,7 @@ const objectTarget = (
|
|||||||
return {
|
return {
|
||||||
objectNode: slot,
|
objectNode: slot,
|
||||||
slot,
|
slot,
|
||||||
|
slotApplicability: "resolved",
|
||||||
presence: fact.presence,
|
presence: fact.presence,
|
||||||
receiverResolved:
|
receiverResolved:
|
||||||
receiver.presence === "present" &&
|
receiver.presence === "present" &&
|
||||||
@@ -138,6 +146,7 @@ const objectRelationEnds = (
|
|||||||
const addObjectOccurrence = (
|
const addObjectOccurrence = (
|
||||||
context: SemanticFlowProjectionContext,
|
context: SemanticFlowProjectionContext,
|
||||||
operation: JavaScriptSemanticObjectOperation,
|
operation: JavaScriptSemanticObjectOperation,
|
||||||
|
slotApplicability: ObjectTarget["slotApplicability"],
|
||||||
): JavaScriptSemanticGraphNode | null =>
|
): JavaScriptSemanticGraphNode | null =>
|
||||||
retainSemanticGraphNode(context.state, context.file, {
|
retainSemanticGraphNode(context.state, context.file, {
|
||||||
kind: "expression",
|
kind: "expression",
|
||||||
@@ -156,6 +165,7 @@ const addObjectOccurrence = (
|
|||||||
operation_kind: operation.kind,
|
operation_kind: operation.kind,
|
||||||
property_path:
|
property_path:
|
||||||
operation.propertyPath === null ? null : [...operation.propertyPath],
|
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");
|
if (summary.value.kind !== "summary") throw new Error("expected summary");
|
||||||
expect(summary.value.summary).toMatchObject({
|
expect(summary.value.summary).toMatchObject({
|
||||||
format: "directory",
|
format: "directory",
|
||||||
limitation_count: 0,
|
limitation_count: summary.value.limitations.length,
|
||||||
});
|
});
|
||||||
expect(JSON.stringify(summary.value)).not.toMatch(/"graph"/);
|
expect(JSON.stringify(summary.value)).not.toMatch(/"graph"/);
|
||||||
expect(summary.value.summary).not.toHaveProperty("semantic_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;
|
if (!result.ok) throw result.error;
|
||||||
expect(result.value).toMatchObject({
|
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."],
|
limitations: ["Some semantic nodes could not be retained."],
|
||||||
unknowns: expect.arrayContaining([
|
unknowns: expect.arrayContaining([
|
||||||
"semantic_graph.coverage.status is partial",
|
"semantic_graph.coverage.status is partial",
|
||||||
@@ -1094,6 +1097,8 @@ it.each([63, 64, 65, 10_000])(
|
|||||||
] as const) {
|
] as const) {
|
||||||
const result = projectAnalysisView(duplicatedParent, view);
|
const result = projectAnalysisView(duplicatedParent, view);
|
||||||
if (!result.ok) throw result.error;
|
if (!result.ok) throw result.error;
|
||||||
|
if (result.value.kind === "summary")
|
||||||
|
expect(result.value.summary.limitation_count).toBe(count);
|
||||||
if (count <= 65) {
|
if (count <= 65) {
|
||||||
expect(result.value.limitations).toEqual(messages);
|
expect(result.value.limitations).toEqual(messages);
|
||||||
expect(result.value.unknowns).toEqual(
|
expect(result.value.unknowns).toEqual(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type AnalysisInputIssue,
|
type AnalysisInputIssue,
|
||||||
} from "../analysisErrorCore.js";
|
} from "../analysisErrorCore.js";
|
||||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||||
|
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||||
import { jsonObjectSchema, type JsonValue } from "../jsonValue.js";
|
import { jsonObjectSchema, type JsonValue } from "../jsonValue.js";
|
||||||
import type { JavaScriptApplicationAnalysisResult } from "../javascript/javascriptApplicationAnalysis.js";
|
import type { JavaScriptApplicationAnalysisResult } from "../javascript/javascriptApplicationAnalysis.js";
|
||||||
import type { ApplicationNode } from "../javascript/javascriptApplicationGraphSchemas.js";
|
import type { ApplicationNode } from "../javascript/javascriptApplicationGraphSchemas.js";
|
||||||
@@ -247,25 +248,32 @@ const parentFields = (
|
|||||||
...analysis.graph.limitations,
|
...analysis.graph.limitations,
|
||||||
...analysis.semantic_graph.limitations,
|
...analysis.semantic_graph.limitations,
|
||||||
];
|
];
|
||||||
|
const mergedLimitations = uniqueSorted([
|
||||||
|
...parent.limitations,
|
||||||
|
...limitations,
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
parent_evidence_id: parent.evidenceId,
|
limitationCount: mergedLimitations.length,
|
||||||
parent_operation: "analyze_javascript_application" as const,
|
fields: {
|
||||||
parent_digest: parent.evidenceId.slice(3),
|
parent_evidence_id: parent.evidenceId,
|
||||||
artifact: {
|
parent_operation: "analyze_javascript_application" as const,
|
||||||
path: analysis.input_path,
|
parent_digest: parent.evidenceId.slice(3),
|
||||||
sha256: analysis.root_artifact_sha256,
|
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 sourceCoverage = javascriptApplicationSourceCoverage(analysis);
|
||||||
const shared = parentFields(parent, analysis, sourceCoverage);
|
const { fields: shared, limitationCount } = parentFields(
|
||||||
|
parent,
|
||||||
|
analysis,
|
||||||
|
sourceCoverage,
|
||||||
|
);
|
||||||
if (view.kind === "javascript-facts") {
|
if (view.kind === "javascript-facts") {
|
||||||
const { rows, total } = projectJavaScriptFacts(analysis.graph.nodes, view);
|
const { rows, total } = projectJavaScriptFacts(analysis.graph.nodes, view);
|
||||||
return ok({
|
return ok({
|
||||||
@@ -409,7 +421,7 @@ export const projectJavaScriptApplicationView = (
|
|||||||
application_unknowns: sourceCoverage.application_unknown_count,
|
application_unknowns: sourceCoverage.application_unknown_count,
|
||||||
semantic_unknowns: sourceCoverage.semantic_unknown_count,
|
semantic_unknowns: sourceCoverage.semantic_unknown_count,
|
||||||
}),
|
}),
|
||||||
limitation_count: analysis.limitations.length,
|
limitation_count: limitationCount,
|
||||||
},
|
},
|
||||||
coverage: completeCoverage(moduleCount, moduleCount),
|
coverage: completeCoverage(moduleCount, moduleCount),
|
||||||
...shared,
|
...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
|
* One inventoried regular file not backed by analyzed source bytes. Its inventory
|
||||||
* occurrence remains available even when content is opaque or unavailable.
|
* occurrence remains available even when content is opaque or unavailable.
|
||||||
|
|||||||
@@ -313,6 +313,24 @@ function* checkCoverageSteps(
|
|||||||
path: ["coverage", "limits"],
|
path: ["coverage", "limits"],
|
||||||
message: "Truncated graph coverage must identify an effective limit",
|
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(
|
const relationsByFamily = new Map(
|
||||||
JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => [family, 0]),
|
JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => [family, 0]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,68 @@ import {
|
|||||||
import { queryJavaScriptSemanticGraph } from "./javascriptSemanticQuery.js";
|
import { queryJavaScriptSemanticGraph } from "./javascriptSemanticQuery.js";
|
||||||
|
|
||||||
const SHA = "a".repeat(64);
|
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 = (
|
const node = (
|
||||||
kind: (typeof JAVASCRIPT_SEMANTIC_NODE_KINDS)[number],
|
kind: (typeof JAVASCRIPT_SEMANTIC_NODE_KINDS)[number],
|
||||||
role: string,
|
role: string,
|
||||||
|
|||||||
@@ -48,8 +48,17 @@ export const resolveJavaScriptSemanticQuerySeeds = (
|
|||||||
.map(({ function_node_id }) => function_node_id)
|
.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
|
return graph.nodes
|
||||||
.filter((node) => seedMatches(node, input, fingerprintFunctions))
|
.filter((node) =>
|
||||||
|
seedMatches(node, input, fingerprintFunctions, unresolvedAccesses),
|
||||||
|
)
|
||||||
.map(({ node_id }) => node_id)
|
.map(({ node_id }) => node_id)
|
||||||
.sort(compareUnicodeCodePoints);
|
.sort(compareUnicodeCodePoints);
|
||||||
};
|
};
|
||||||
@@ -58,6 +67,7 @@ const seedMatches = (
|
|||||||
node: JavaScriptSemanticGraphNode,
|
node: JavaScriptSemanticGraphNode,
|
||||||
input: JavaScriptSemanticQueryInput,
|
input: JavaScriptSemanticQueryInput,
|
||||||
fingerprintFunctions: ReadonlySet<string>,
|
fingerprintFunctions: ReadonlySet<string>,
|
||||||
|
unresolvedAccesses: ReadonlySet<string>,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const seed = input.seed;
|
const seed = input.seed;
|
||||||
if (seed.kind === "semantic-node") return node.node_id === seed.node_id;
|
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 === "function") return fingerprintFunctions.has(node.node_id);
|
||||||
if (seed.kind === "literal")
|
if (seed.kind === "literal")
|
||||||
return node.kind === "literal" && node.properties.value === seed.value;
|
return node.kind === "literal" && node.properties.value === seed.value;
|
||||||
if (seed.kind === "property")
|
if (seed.kind === "property") {
|
||||||
return node.kind === "property-slot" && node.properties.name === seed.name;
|
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")
|
if (seed.kind === "endpoint")
|
||||||
return (
|
return (
|
||||||
(node.kind === "request" || node.kind === "response") &&
|
(node.kind === "request" || node.kind === "response") &&
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const javaScriptSemanticQuerySeedSchema = z.discriminatedUnion("kind", [
|
|||||||
}),
|
}),
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
kind: z.literal("event"),
|
kind: z.literal("event"),
|
||||||
name: z.string().min(1),
|
name: z.string(),
|
||||||
}),
|
}),
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
kind: z.literal("boundary-field"),
|
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 { parseEvidence } from "../../../src/domain/evidence.js";
|
||||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.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 { writeJavaScriptSourceSelectionFixture } from "../../fixtures/javascriptSourceSelection.js";
|
||||||
import {
|
import {
|
||||||
asarHeaderBytes,
|
asarHeaderBytes,
|
||||||
@@ -102,6 +103,24 @@ cliTest.for([
|
|||||||
);
|
);
|
||||||
expect(full.source_selection).toBeUndefined();
|
expect(full.source_selection).toBeUndefined();
|
||||||
expect(full.statistics.invalid_utf8_files).toBe(1);
|
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(full.statistics.parse_failures).toBeGreaterThan(0);
|
||||||
expect([
|
expect([
|
||||||
result.root_artifact_sha256,
|
result.root_artifact_sha256,
|
||||||
|
|||||||
Reference in new issue
Block a user