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
@@ -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);
});