diff --git a/docs/javascript-application-graph.md b/docs/javascript-application-graph.md index 8aeecebcac..e3c8dacff8 100644 --- a/docs/javascript-application-graph.md +++ b/docs/javascript-application-graph.md @@ -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. diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index fcb3c3c547..2ffad62061 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -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. diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index 85b1d379ad..fa2f207ea3 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -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, ): Generator { 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, 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; }; diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index d4ec631045..d3e6132059 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -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.`, diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index 8e284ef259..0eeaa843ec 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -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); }; diff --git a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts index 722937ed1e..ad5f25cb66 100644 --- a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts @@ -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, }, }); diff --git a/src/application/javascript/JavaScriptSemanticGraphSeedQueries.test.ts b/src/application/javascript/JavaScriptSemanticGraphSeedQueries.test.ts new file mode 100644 index 0000000000..dd10e2b279 --- /dev/null +++ b/src/application/javascript/JavaScriptSemanticGraphSeedQueries.test.ts @@ -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); +}); diff --git a/src/domain/analysisView/analysisView.test.ts b/src/domain/analysisView/analysisView.test.ts index a530452abf..bb509a300c 100644 --- a/src/domain/analysisView/analysisView.test.ts +++ b/src/domain/analysisView/analysisView.test.ts @@ -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( diff --git a/src/domain/analysisView/javascriptApplicationView.ts b/src/domain/analysisView/javascriptApplicationView.ts index 25e75b01c1..912c93c9ae 100644 --- a/src/domain/analysisView/javascriptApplicationView.ts +++ b/src/domain/analysisView/javascriptApplicationView.ts @@ -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, diff --git a/src/domain/javascript/javascriptArtifactFiles.ts b/src/domain/javascript/javascriptArtifactFiles.ts index c7013bc331..a35c868798 100644 --- a/src/domain/javascript/javascriptArtifactFiles.ts +++ b/src/domain/javascript/javascriptArtifactFiles.ts @@ -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. diff --git a/src/domain/javascript/javascriptSemanticGraph.ts b/src/domain/javascript/javascriptSemanticGraph.ts index 15029f6b7b..9900bfca16 100644 --- a/src/domain/javascript/javascriptSemanticGraph.ts +++ b/src/domain/javascript/javascriptSemanticGraph.ts @@ -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]), ); diff --git a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts index b08104c76c..60b80e5a18 100644 --- a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts +++ b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts @@ -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, diff --git a/src/domain/javascript/javascriptSemanticQueryIdentity.ts b/src/domain/javascript/javascriptSemanticQueryIdentity.ts index 0bb738bad0..4d9bf374a1 100644 --- a/src/domain/javascript/javascriptSemanticQueryIdentity.ts +++ b/src/domain/javascript/javascriptSemanticQueryIdentity.ts @@ -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, + unresolvedAccesses: ReadonlySet, ): 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") && diff --git a/src/domain/javascript/javascriptSemanticQuerySchemas.ts b/src/domain/javascript/javascriptSemanticQuerySchemas.ts index e85d3c9ec6..7c6f8172de 100644 --- a/src/domain/javascript/javascriptSemanticQuerySchemas.ts +++ b/src/domain/javascript/javascriptSemanticQuerySchemas.ts @@ -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"), diff --git a/tests/acceptance/applications/javascriptGraphQueryCoverage.test.ts b/tests/acceptance/applications/javascriptGraphQueryCoverage.test.ts new file mode 100644 index 0000000000..edc7d38b18 --- /dev/null +++ b/tests/acceptance/applications/javascriptGraphQueryCoverage.test.ts @@ -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); + } + }, +); diff --git a/tests/acceptance/applications/javascriptSourceSelectionCli.test.ts b/tests/acceptance/applications/javascriptSourceSelectionCli.test.ts index de9abcf76d..212bfa0a1f 100644 --- a/tests/acceptance/applications/javascriptSourceSelectionCli.test.ts +++ b/tests/acceptance/applications/javascriptSourceSelectionCli.test.ts @@ -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,