diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index ebbc665981..6475f90793 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -195,6 +195,27 @@ For supplied passive browser or Electron observations, use [runtime reconciliation](javascript-runtime-reconciliation.md). It compares existing Evidence without rerunning static analysis or contacting CDP. +Each newly analyzed JavaScript file retains `analysis_coverage` in its graph +observation. `syntax` distinguishes complete parsing, recovered syntax errors, +failed parsing and unexamined source. `semantics` records the number of observed +unknown findings and typed uncertainty, including returned-reference widening +and unavailable type-only compiler options. `examination` reports static semantic +resource limits and Electron wrapper expansion limits independently. A completed +pass does not establish complete runtime semantics. Route examination and result +capacity remain in the separate route inventory. + +Electron applicability is `observed` when a static Electron module reference is +retained, and otherwise `unknown`: a source without such a reference does not +prove that the application is inapplicable. Sources excluded by `source_paths` +retain `not-examined` with a reason and null semantic counts. Historical Evidence +without this facet remains unmeasured. The existing `parse_status` remains its +conservative overall summary; finer facts do not relabel earlier partial results. + +Summary delivery counts these facts under `summary.coverage.static_sources`. +Read exact per-file measurements with `inspect_analysis_view`, selecting +`kind: "javascript-facts"` and `property_keys: ["analysis_coverage"]`, or inspect +the module item. Both views use retained Evidence and preserve source provenance. + ## ASAR integrity ASAR inventory checks Electron integrity metadata for embedded archive entries diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts index 90e2008497..8af1cc5f1a 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts @@ -130,6 +130,11 @@ describe("skipped JavaScript parse reporting", () => { const context = contextFor(file, analysisFor([skippedEntry(file)])); addJavaScriptArtifactFiles(context); expect(fileObservation(context).parse_status).toBe("skipped"); + expect(fileObservation(context).analysis_coverage).toMatchObject({ + syntax: { status: "not-examined", error_count: null }, + semantics: { unknown_findings: null }, + examination: { status: "not-examined" }, + }); }); it("keeps parse_status null for a JavaScript file never attempted", () => { @@ -157,6 +162,21 @@ describe("skipped JavaScript parse reporting", () => { const context = contextFor(file, analysisFor([entry])); addJavaScriptArtifactFiles(context); expect(fileObservation(context).parse_status).toBe(javascript.parse_status); + expect(fileObservation(context).analysis_coverage).toEqual( + javascript.analysis_coverage, + ); + }); + + it("does not reconstruct coverage from a historical overall summary", () => { + const file = skippedFile(); + const { analysis_coverage: _coverage, ...javascript } = + analyzeJavaScriptStaticSource(file.text.included ? file.text.value : ""); + const context = contextFor( + file, + analysisFor([{ file, javascript, semantic: null }]), + ); + addJavaScriptArtifactFiles(context); + expect(fileObservation(context)).not.toHaveProperty("analysis_coverage"); }); }); diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.ts index 24355cee98..086f0531d2 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.ts @@ -7,16 +7,19 @@ import { partialApplicationCoverage, } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js"; import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; -import { javascriptArtifactAnalysisScopes } from "./JavaScriptArtifactAnalysisTypes.js"; +import { + javascriptArtifactAnalysisScopes, + isSkippedJavaScriptAnalysis, +} from "./JavaScriptArtifactAnalysisTypes.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; import { freezeJsonSnapshot } from "../../domain/immutableJson.js"; import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; +import { unexaminedJavaScriptStaticCoverage } from "../../domain/javascript/javascriptStaticCoverage.js"; import type { JavaScriptArtifactContainer, JavaScriptArtifactFile, } from "../../domain/javascript/javascriptArtifactFiles.js"; import type { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js"; -import { isSkippedJavaScriptAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; import { addArtifactContainsEdge, applicationManifestPath, @@ -648,6 +651,21 @@ const createFileTarget = ( text_status: file.text.included ? "included" : file.text.reason, parse_status: javascript?.parse_status ?? (skipped ? "skipped" : null), + ...(javascript?.analysis_coverage !== undefined + ? { analysis_coverage: javascript.analysis_coverage } + : javascript === null && file.kind === "javascript" + ? { + analysis_coverage: unexaminedJavaScriptStaticCoverage( + !file.text.included + ? file.text.reason === "outside-analysis-scope" + ? "Source is outside the caller-selected source_paths." + : `JavaScript text was unavailable: ${file.text.reason}.` + : skipped + ? "JavaScript analysis did not complete." + : "JavaScript source analysis was not performed.", + ), + } + : {}), json_parse_status: json?.status ?? null, json_top_level_keys: json?.top_level_keys ?? [], omitted_json_top_level_keys: diff --git a/src/application/javascript/JavaScriptWrappedCallPathCancellation.test.ts b/src/application/javascript/JavaScriptWrappedCallPathCancellation.test.ts new file mode 100644 index 0000000000..b13fbf0dfe --- /dev/null +++ b/src/application/javascript/JavaScriptWrappedCallPathCancellation.test.ts @@ -0,0 +1,61 @@ +import { expect, it } from "vitest"; +import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js"; +import { collectElectronWrappedCallPathsSteps } from "../../domain/javascript/electronWrappedCallPaths.js"; +import { + indexJavaScriptBindingsWithReceiversSteps, + indexJavaScriptElectronIdentitySteps, +} from "../../domain/javascript/javascriptSemanticAnalysis.js"; +import { completeSemanticSteps } from "../../domain/javascript/javascriptSemanticTraversal.js"; +import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js"; +import { createJavaScriptAnalysisAccumulator } from "../../domain/javascript/javascriptStaticAnalysisState.js"; + +it("cancels actual call-path work while its first seed indexes a long block", async () => { + const source = `const e = require("electron"); { + e.ipcMain.on("first", event => event.sender.copy()); + ${"0;".repeat(2048)} + }`; + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected parsed source"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + const identity = completeSemanticSteps( + indexJavaScriptElectronIdentitySteps(parsed.program, index.bindings), + ); + const accumulator = createJavaScriptAnalysisAccumulator(source.length); + const work = collectElectronWrappedCallPathsSteps( + parsed.program, + index.bindings, + source, + accumulator, + identity, + ); + // The first candidate has not yet been expanded: this checkpoint is inside + // its block indexing, before the walker's completed batch is yielded. + expect(work.next()).toEqual({ done: false, value: undefined }); + expect(accumulator.ipc).toEqual([]); + const controller = new AbortController(); + const reason = { request: "cancel prefix indexing" }; + controller.abort(reason); + await expect( + completeJavaScriptAnalysisSteps(work, controller.signal), + ).rejects.toMatchObject({ reason: "cancelled", cause: reason }); + expect(work.next()).toEqual({ done: true, value: undefined }); + expect(accumulator.ipc).toEqual([]); + expect(accumulator.electronCallPathLimitReached).toBeUndefined(); + + // A new analysis owns a new index and can still complete the same source. + const completed = createJavaScriptAnalysisAccumulator(source.length); + expect( + completeSemanticSteps( + collectElectronWrappedCallPathsSteps( + parsed.program, + index.bindings, + source, + completed, + identity, + ), + ), + ).toContainEqual(expect.objectContaining({ entry: "first", conditions: [] })); + expect(completed.electronCallPathLimitReached).toBeUndefined(); +}); diff --git a/src/domain/analysisView/javascriptApplicationCoverage.ts b/src/domain/analysisView/javascriptApplicationCoverage.ts index 2aec678f20..f4e29dfe36 100644 --- a/src/domain/analysisView/javascriptApplicationCoverage.ts +++ b/src/domain/analysisView/javascriptApplicationCoverage.ts @@ -3,6 +3,7 @@ import { applicationCoverageSchema } from "../javascript/javascriptApplicationEv import type { JavaScriptApplicationAnalysisResult } from "../javascript/javascriptApplicationAnalysis.js"; import { javaScriptSemanticGraphInputSchema } from "../javascript/javascriptSemanticGraphSchemas.js"; import { javaScriptSourceSelectionSchema } from "../javascript/javascriptSourceSelection.js"; +import { javaScriptStaticCoverageSchema } from "../javascript/javascriptStaticCoverage.js"; /** Source completeness is independent of exhausting a selected fact page. */ export const javascriptApplicationSourceCoverageSchema = z.strictObject({ @@ -37,3 +38,39 @@ export const javascriptApplicationSourceCoverage = ( semantic_unknown_count: analysis.semantic_graph.unknowns.length, }; }; + +/** Count only source-file observations; historical missing facts remain unmeasured. */ +export const javascriptStaticSourceCoverageSummary = ( + analysis: JavaScriptApplicationAnalysisResult, +) => { + const summary = { + sources: 0, + unmeasured_sources: 0, + syntax: { complete: 0, recovered: 0, failed: 0, "not-examined": 0 }, + semantics: { unknown_sources: 0, unexamined_sources: 0 }, + examination: { complete: 0, limited: 0, "not-examined": 0 }, + electron_applicability: { observed: 0, unknown: 0 }, + }; + for (const node of analysis.graph.nodes) { + for (const observation of node.observations) { + if (observation.properties.file_kind !== "javascript") continue; + summary.sources += 1; + const parsed = javaScriptStaticCoverageSchema.safeParse( + observation.properties.analysis_coverage, + ); + if (!parsed.success) { + summary.unmeasured_sources += 1; + continue; + } + const coverage = parsed.data; + summary.syntax[coverage.syntax.status] += 1; + summary.examination[coverage.examination.status] += 1; + summary.electron_applicability[coverage.applicability.electron] += 1; + if (coverage.semantics.unknown_findings === null) + summary.semantics.unexamined_sources += 1; + else if (coverage.semantics.uncertainty.length > 0) + summary.semantics.unknown_sources += 1; + } + } + return summary; +}; diff --git a/src/domain/analysisView/javascriptApplicationView.ts b/src/domain/analysisView/javascriptApplicationView.ts index eb38371aff..b63898d0b8 100644 --- a/src/domain/analysisView/javascriptApplicationView.ts +++ b/src/domain/analysisView/javascriptApplicationView.ts @@ -11,6 +11,7 @@ import { err, ok, type Result } from "../result.js"; import type { JavaScriptModuleReference } from "./javascriptModuleReferences.js"; import { javascriptApplicationSourceCoverage, + javascriptStaticSourceCoverageSummary, type JavaScriptApplicationSourceCoverage, } from "./javascriptApplicationCoverage.js"; import { projectJavaScriptFacts } from "./javascriptFactsView.js"; @@ -406,6 +407,7 @@ export const projectJavaScriptApplicationView = ( statistics: jsonObjectSchema.parse(analysis.statistics), electron: jsonObjectSchema.parse(analysis.summary), coverage: jsonObjectSchema.parse({ + static_sources: javascriptStaticSourceCoverageSummary(analysis), application: analysis.graph.coverage, semantic: { ...analysis.semantic_graph.coverage, diff --git a/src/domain/javascript/electronCallPathBlockPrefixes.test.ts b/src/domain/javascript/electronCallPathBlockPrefixes.test.ts new file mode 100644 index 0000000000..d2ef3db168 --- /dev/null +++ b/src/domain/javascript/electronCallPathBlockPrefixes.test.ts @@ -0,0 +1,78 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; +import { ElectronCallPathBlockPrefixes } from "./electronCallPathBlockPrefixes.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; + +it("indexes a real flat block once, retaining contextual guards in source order", () => { + const parsed = parseJavaScriptSource(`{ + ${"0;".repeat(2048)} + if (uncertain) ordinary(); + if (flag) throw 0; + electron.send("tail"); + }`); + const block = parsed?.program.body[0]; + if (!t.isBlockStatement(block)) throw new Error("Expected parsed block"); + const first = block.body[0]; + const tail = block.body.at(-1); + if (first === undefined || tail === undefined) + throw new Error("Expected block statements"); + let examined = 0; + const prefixes = new ElectronCallPathBlockPrefixes(() => { + examined += 1; + return true; + }); + const steps = prefixes.before(block, tail); + expect(steps.next()).toEqual({ done: false, value: undefined }); + expect(examined).toBe(1024); + const prefix = completeSemanticSteps(steps); + expect(examined).toBe(block.body.length); + expect(prefix).toMatchObject({ + before: 2050, + entries: [ + { + position: 2048, + fact: { + kind: "branch", + test: { type: "Identifier", name: "uncertain" }, + consequentExits: false, + alternateExits: false, + }, + }, + { + position: 2049, + fact: { + kind: "branch", + test: { type: "Identifier", name: "flag" }, + consequentExits: true, + alternateExits: false, + }, + }, + ], + }); + for (const statement of block.body) { + const earlier = completeSemanticSteps(prefixes.before(block, statement)); + expect(earlier?.entries).toBe(prefix?.entries); + } + expect(examined).toBe(block.body.length); + expect(completeSemanticSteps(prefixes.before(block, first))?.before).toBe(0); +}); + +it("does not publish an incomplete block index when its work budget ends", () => { + const parsed = parseJavaScriptSource( + `{ 0; 1; if (stop) throw 0; send("tail"); }`, + ); + const block = parsed?.program.body[0]; + if (!t.isBlockStatement(block)) throw new Error("Expected parsed block"); + const tail = block.body.at(-1); + if (tail === undefined) throw new Error("Expected terminal statement"); + let remaining = 2; + const prefixes = new ElectronCallPathBlockPrefixes(() => --remaining >= 0); + expect(completeSemanticSteps(prefixes.before(block, tail))).toBeUndefined(); + remaining = block.body.length; + expect(completeSemanticSteps(prefixes.before(block, tail))).toMatchObject({ + before: 3, + entries: [{ position: 2, fact: { kind: "branch" } }], + }); + expect(remaining).toBe(0); +}); diff --git a/src/domain/javascript/electronCallPathBlockPrefixes.ts b/src/domain/javascript/electronCallPathBlockPrefixes.ts new file mode 100644 index 0000000000..de0f655d0e --- /dev/null +++ b/src/domain/javascript/electronCallPathBlockPrefixes.ts @@ -0,0 +1,85 @@ +import * as t from "@babel/types"; +import { symbolicDefiniteExit } from "./javascriptSymbolicControlFlow.js"; + +/** Syntax alone can establish an exit, but never a branch's contextual truth. */ +export type ElectronStatementFallthrough = + | { readonly kind: "exit" } + | { + readonly kind: "branch"; + readonly test: t.Expression; + readonly consequentExits: boolean; + readonly alternateExits: boolean; + }; + +interface PrefixEntry { + readonly position: number; + readonly fact: ElectronStatementFallthrough; +} + +interface BlockIndex { + readonly positions: WeakMap; + readonly entries: readonly PrefixEntry[]; +} + +/** + * One collector's immutable syntax facts. Positions replace repeated body + * searches; ordinary fallthrough statements need no replay. Conditional tests + * remain present even when neither branch exits, since evaluating them belongs + * to the invocation's symbolic environment and can establish unknown coverage. + */ +export class ElectronCallPathBlockPrefixes { + readonly #blocks = new WeakMap(); + readonly #facts = new WeakMap< + t.Statement, + ElectronStatementFallthrough | null + >(); + + constructor(private readonly admitVisit: () => boolean) {} + + statement(statement: t.Statement): ElectronStatementFallthrough | null { + const cached = this.#facts.get(statement); + if (cached !== undefined) return cached; + const fact: ElectronStatementFallthrough | null = symbolicDefiniteExit( + statement, + ) + ? { kind: "exit" } + : t.isIfStatement(statement) + ? { + kind: "branch", + test: statement.test, + consequentExits: symbolicDefiniteExit(statement.consequent), + alternateExits: symbolicDefiniteExit(statement.alternate), + } + : null; + this.#facts.set(statement, fact); + return fact; + } + + *before( + block: t.BlockStatement, + child: t.Node, + ): Generator< + void, + | { readonly entries: readonly PrefixEntry[]; readonly before: number } + | undefined + > { + let index = this.#blocks.get(block); + if (index === undefined) { + const positions = new WeakMap(); + const entries: PrefixEntry[] = []; + for (const [position, statement] of block.body.entries()) { + if (!this.admitVisit()) return undefined; + positions.set(statement, position); + const fact = this.statement(statement); + if (fact !== null) entries.push({ position, fact }); + if ((position + 1) % 1024 === 0) yield; + } + index = { positions, entries }; + this.#blocks.set(block, index); + } + const before = index.positions.get(child); + return before === undefined + ? undefined + : { entries: index.entries, before }; + } +} diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index c5a6d3de25..6d479c0da0 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -1,6 +1,100 @@ import { describe, expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +it("preserves every direct observation in a long flat sibling block", () => { + const channels = Array.from({ length: 256 }, (_, index) => `flat:${index}`); + const result = analyzeJavaScriptStaticSource(`const e=require("electron"); { + ${channels.map((channel) => `e.ipcRenderer.send("${channel}");`).join("\n")} + }`); + expect(result.electron.ipc.map(({ channel }) => channel)).toEqual(channels); + expect(result.electron.call_paths ?? []).toEqual([]); + expect(result.parse_status).toBe("complete"); +}); + +it("keeps sibling guards, definite exits and nested shadowing separate", () => { + const result = analyzeJavaScriptStaticSource(`const e=require("electron"); { + if (stop) throw new Error("stop"); + e.ipcMain.on("guarded", event => event.sender.copy()); + { + const e = {ipcMain: {on() {}}}; + e.ipcMain.on("shadowed", event => event.sender.copy()); + } + if (true) throw new Error("end"); + e.ipcMain.on("unreachable", event => event.sender.copy()); + }`); + expect( + result.electron.call_paths?.map( + ({ entry, terminal, steps, conditions }) => ({ + entry, + terminal, + terminalStart: steps.at(-1)?.location.start, + conditions: conditions.map(({ expression }) => expression), + }), + ), + ).toEqual([ + { + entry: "guarded", + terminal: "electron.ipcMain.on", + terminalStart: { line: 3, column: 4 }, + conditions: ["!(stop)"], + }, + { + entry: "guarded", + terminal: "ipc-event.sender.copy", + terminalStart: { line: 3, column: 37 }, + conditions: ["!(stop)"], + }, + ]); + // Raw syntax after a definite exit remains an observation, not a reachable + // callback path. The shadowed ordinary object provides no Electron identity. + expect(result.electron.ipc.map(({ channel }) => channel)).toEqual([ + "guarded", + "unreachable", + ]); +}); + +it("evaluates shared return syntax separately for each invocation environment", () => { + const result = analyzeJavaScriptStaticSource(`const e=require("electron"); + function install(channel, enabled) { + if (!enabled) return; + e.ipcMain.on(channel, event => event.sender.copy()); + } + install("enabled", true); + install("disabled", false); + install("uncertain", externalFlag); + `); + expect( + result.electron.call_paths?.map( + ({ entry, terminal, steps, conditions }) => ({ + entry, + terminal, + invocationStart: steps[0]?.location.start, + conditions: conditions.map(({ expression }) => expression), + }), + ), + ).toEqual([ + { + entry: "enabled", + terminal: "ipc-event.sender.copy", + invocationStart: { line: 6, column: 4 }, + conditions: [], + }, + { + entry: "uncertain", + terminal: "electron.ipcMain.on", + invocationStart: { line: 8, column: 4 }, + conditions: ["!(!enabled)"], + }, + { + entry: "uncertain", + terminal: "ipc-event.sender.copy", + invocationStart: { line: 8, column: 4 }, + conditions: ["!(!enabled)"], + }, + ]); + expect(result.parse_status).toBe("partial"); +}); + const bundle = (entry: string) => `(() => { const modules = { 2(module) { module.exports = require("electron"); }, diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts index b0718328af..26456d87dd 100644 --- a/src/domain/javascript/electronWrappedCallPaths.ts +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -33,17 +33,22 @@ import { boundedExpression, contextBridgeMemberPathDepth, } from "./electronStaticAnalysisValues.js"; -import { argumentNode } from "./javascriptStaticAnalysisHelpers.js"; +import { + argumentNode, + range, + calleeName, +} from "./javascriptStaticAnalysisHelpers.js"; import { childNodes, traverseJavaScriptValueAst, + traverseJavaScriptValueAstSteps, } from "./javascriptSemanticTraversal.js"; -import { range, calleeName } from "./javascriptStaticAnalysisHelpers.js"; import { ipcDescriptor } from "./electronStaticAnalysisIpc.js"; +import { symbolicLogicalRight } from "./javascriptSymbolicControlFlow.js"; import { - symbolicDefiniteExit, - symbolicLogicalRight, -} from "./javascriptSymbolicControlFlow.js"; + ElectronCallPathBlockPrefixes, + type ElectronStatementFallthrough, +} from "./electronCallPathBlockPrefixes.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; const callLabel = (node: t.Node): string => @@ -227,16 +232,19 @@ interface PathContext { } /** Substitutes inert wrapper syntax; every emitted path remains a candidate. */ -export const collectElectronWrappedCallPaths = ( +export function* collectElectronWrappedCallPathsSteps( program: t.Program, state: JavaScriptSemanticAnalysisState, source: string, accumulator: JavaScriptAnalysisAccumulator, identity: JavaScriptElectronIdentity, -): readonly ElectronCallPath[] => { +): Generator { // Bounds repeated interprocedural expansion independently of bundle bytes. // Exhaustion is reported as unknown coverage, never as a negative finding. let remainingVisits = 1_000_000; + const prefixes = new ElectronCallPathBlockPrefixes( + () => --remainingVisits >= 0, + ); const resolver = new JavaScriptSymbolicBindings(state, program, identity); const paths: ElectronCallPath[] = []; const seen = new Set(); @@ -282,28 +290,26 @@ export const collectElectronWrappedCallPaths = ( }; }; const sequentialFallthrough = ( - statement: t.Statement, + fact: ElectronStatementFallthrough | null, env: SymbolicEnvironment, context: PathContext, ): { readonly continues: boolean; readonly context: PathContext } => { - if (symbolicDefiniteExit(statement)) return { continues: false, context }; - if (!t.isIfStatement(statement)) return { continues: true, context }; - const truth = symbolicTruth(resolver.evaluate(statement.test, env)); + if (fact === null) return { continues: true, context }; + if (fact.kind === "exit") return { continues: false, context }; + const truth = symbolicTruth(resolver.evaluate(fact.test, env)); if (truth !== undefined) - return symbolicDefiniteExit( - truth ? statement.consequent : statement.alternate, - ) + return (truth ? fact.consequentExits : fact.alternateExits) ? { continues: false, context } : { continues: true, context }; - if (symbolicDefiniteExit(statement.consequent)) + if (fact.consequentExits) return { continues: true, - context: condition(context, statement.test, false), + context: condition(context, fact.test, false), }; - if (symbolicDefiniteExit(statement.alternate)) + if (fact.alternateExits) return { continues: true, - context: condition(context, statement.test), + context: condition(context, fact.test), }; return { continues: true, context }; }; @@ -1148,7 +1154,11 @@ export const collectElectronWrappedCallPaths = ( let next = context; for (const statement of node.body) { walk(statement, env, next); - const fallthrough = sequentialFallthrough(statement, env, next); + const fallthrough = sequentialFallthrough( + prefixes.statement(statement), + env, + next, + ); if (!fallthrough.continues) break; next = fallthrough.context; } @@ -1194,70 +1204,98 @@ export const collectElectronWrappedCallPaths = ( call(node, env, context); for (const child of childNodes(node)) walk(child, env, context); } - traverseJavaScriptValueAst(program, state.erasedSyntaxNodes, { - enter: (node) => { - if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) - return; - const isolatedSeed = t.isStringLiteral(node.arguments[0]) - ? undefined - : isolatedBridgeCall(node); - if (!t.isStringLiteral(node.arguments[0]) && isolatedSeed === undefined) - return; - const env = isolatedSeed?.env ?? resolver.environmentAt(node); - let context: PathContext = { - entryNode: node, - calls: [], - entry: "", - entryKind: null, - channel: null, - steps: [], - conditions: [], - active: new Set(), - }; - for ( - let child: t.Node = node, parent = state.parentsByNode.get(child); - parent && !t.isFunction(parent); - child = parent, parent = state.parentsByNode.get(child) - ) { - if (t.isIfStatement(parent) || t.isConditionalExpression(parent)) { - if (child === parent.test) continue; - const truth = symbolicTruth(resolver.evaluate(parent.test, env)); - const positive = child === parent.consequent; - if (truth !== undefined && truth !== positive) return; - if (truth === undefined) - context = condition(context, parent.test, positive); - } else if (t.isLogicalExpression(parent) && child === parent.right) { - const right = symbolicLogicalRight( - parent.operator, - resolver.evaluate(parent.left, env), + let prefixVisits = 0; + function* seed( + node: t.CallExpression | t.OptionalCallExpression, + ): Generator { + if (remainingVisits < 0) return; + const isolatedSeed = t.isStringLiteral(node.arguments[0]) + ? undefined + : isolatedBridgeCall(node); + if (!t.isStringLiteral(node.arguments[0]) && isolatedSeed === undefined) + return; + const env = isolatedSeed?.env ?? resolver.environmentAt(node); + let context: PathContext = { + entryNode: node, + calls: [], + entry: "", + entryKind: null, + channel: null, + steps: [], + conditions: [], + active: new Set(), + }; + for ( + let child: t.Node = node, parent = state.parentsByNode.get(child); + parent && !t.isFunction(parent); + child = parent, parent = state.parentsByNode.get(child) + ) { + if (--remainingVisits < 0) return; + if (++prefixVisits % 1024 === 0) yield; + if (t.isIfStatement(parent) || t.isConditionalExpression(parent)) { + if (child === parent.test) continue; + const truth = symbolicTruth(resolver.evaluate(parent.test, env)); + const positive = child === parent.consequent; + if (truth !== undefined && truth !== positive) return; + if (truth === undefined) + context = condition(context, parent.test, positive); + } else if (t.isLogicalExpression(parent) && child === parent.right) { + const right = symbolicLogicalRight( + parent.operator, + resolver.evaluate(parent.left, env), + ); + if (right === false) return; + if (right === undefined) + context = condition( + context, + parent.left, + parent.operator === "??" ? "nullish" : parent.operator === "&&", ); - if (right === false) return; - if (right === undefined) - context = condition( - context, - parent.left, - parent.operator === "??" ? "nullish" : parent.operator === "&&", - ); - } else if (t.isBlockStatement(parent)) { - const index = parent.body.findIndex( - (statement) => statement === child, - ); - if (index >= 0) { - for (const statement of parent.body.slice(0, index)) { - const fallthrough = sequentialFallthrough( - statement, - env, - context, - ); - if (!fallthrough.continues) return; - context = fallthrough.context; - } + } else if (t.isBlockStatement(parent)) { + const prefix = yield* prefixes.before(parent, child); + if (remainingVisits < 0) return; + if (prefix !== undefined) { + for (const entry of prefix.entries) { + if (entry.position >= prefix.before) break; + if (--remainingVisits < 0) return; + if (++prefixVisits % 1024 === 0) yield; + const fallthrough = sequentialFallthrough(entry.fact, env, context); + if (!fallthrough.continues) return; + context = fallthrough.context; } } } - call(node, env, context, isolatedSeed?.callee); + } + call(node, env, context, isolatedSeed?.callee); + } + // The shared walker supplies one bounded batch at a time. Prefix work must + // run outside its synchronous callback so cancellation can interrupt a large + // block index or a genuinely long sequence of contextual branch facts. + const seeds: (t.CallExpression | t.OptionalCallExpression)[] = []; + const traversal = traverseJavaScriptValueAstSteps( + program, + state.erasedSyntaxNodes, + { + enter: (node) => { + if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) + seeds.push(node); + }, }, - }); + ); + try { + for (;;) { + const batch = traversal.next(); + for (const node of seeds) { + yield* seed(node); + if (remainingVisits < 0) break; + } + seeds.length = 0; + if (batch.done || remainingVisits < 0) break; + yield; + } + } finally { + traversal.return(); + } recordBridgeExposures(); if (remainingVisits < 0 || resolver.truncated) { accumulator.unknownFindings += 1; @@ -1265,4 +1303,4 @@ export const collectElectronWrappedCallPaths = ( } if (resolver.unresolved) accumulator.unknownFindings += 1; return paths; -}; +} diff --git a/src/domain/javascript/javascriptReturnedReferenceCoverage.test.ts b/src/domain/javascript/javascriptReturnedReferenceCoverage.test.ts index b6131c0996..64f9394bf7 100644 --- a/src/domain/javascript/javascriptReturnedReferenceCoverage.test.ts +++ b/src/domain/javascript/javascriptReturnedReferenceCoverage.test.ts @@ -21,6 +21,9 @@ it("preserves possible ambient effects and discloses projection widening in sour expect(ir.limitations).toContain(RETURNED_REFERENCE_WIDENING_LIMITATION); const facts = analyzeJavaScriptStaticSource(source); expect(facts.limitations).toContain(RETURNED_REFERENCE_WIDENING_LIMITATION); + expect(facts.analysis_coverage?.semantics.uncertainty).toContain( + "returned-reference-widening", + ); expect(facts.endpoints).toEqual( expect.arrayContaining([ expect.objectContaining({ value: "/preserved", mechanism: "call:fetch" }), diff --git a/src/domain/javascript/javascriptSemanticValueTypes.ts b/src/domain/javascript/javascriptSemanticValueTypes.ts index 41a698d4c6..70c1e12656 100644 --- a/src/domain/javascript/javascriptSemanticValueTypes.ts +++ b/src/domain/javascript/javascriptSemanticValueTypes.ts @@ -1,13 +1,21 @@ +import { z } from "zod"; + /** Primitive values admitted into the constant lattice. */ export type JavaScriptSemanticPrimitive = string | number | boolean | null; /** Resource bound that prevented an exact semantic value. */ -export type JavaScriptSemanticResourceLimit = - | "primitive-candidates" - | "primitive-bytes" - | "expression-depth" - | "getter-expansion" - | "alias-selections"; +export const javaScriptSemanticResourceLimitSchema = z.enum([ + "primitive-candidates", + "primitive-bytes", + "expression-depth", + "getter-expansion", + "alias-selections", +]); + +/** Resource-limit classifications shared by static facts and semantic values. */ +export type JavaScriptSemanticResourceLimit = z.infer< + typeof javaScriptSemanticResourceLimitSchema +>; type JavaScriptSemanticObjectValue = { readonly status: "object"; diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 2ecf74191e..0bb911ceb2 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -3,7 +3,7 @@ import * as t from "@babel/types"; import { JavaScriptRouteScan } from "./javascriptRouteScan.js"; import type { JavaScriptRouteSourceFacts } from "./javascriptRouteFacts.js"; import { RETURNED_REFERENCE_WIDENING_LIMITATION } from "./javascriptSemanticCoverage.js"; -import { collectElectronWrappedCallPaths } from "./electronWrappedCallPaths.js"; +import { collectElectronWrappedCallPathsSteps } from "./electronWrappedCallPaths.js"; import { ELECTRON_IDENTITY_LIMITATION, ELECTRON_MODULE, @@ -76,6 +76,10 @@ import { isTypeOnlyImportDeclaration, semanticImportEqualsSpecifier, } from "./javascriptModuleDeclaration.js"; +import { + javaScriptStaticParseStatus, + type JavaScriptStaticCoverage, +} from "./javascriptStaticCoverage.js"; /** Parse one JavaScript artifact and recover static structure only. */ export const analyzeJavaScriptStaticSource = ( @@ -154,7 +158,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( finishPartialNetworkEndpoints(findings, bindings, index); finishSqliteStorageCalls(findings, bindings, index); } - accumulator.electronCallPaths = collectElectronWrappedCallPaths( + accumulator.electronCallPaths = yield* collectElectronWrappedCallPathsSteps( file.program, bindings, source, @@ -191,6 +195,43 @@ const finalizeStaticAnalysis = ( routes: JavaScriptRouteSourceFacts, ): JavaScriptStaticAnalysis => { const parserErrors = file.errors.length; + const typeOnlySideEffect = hasUncertainTypeOnlyModuleSideEffect(file.program); + const electronObserved = accumulator.references.some( + ({ value }) => + value.specifier !== null && ELECTRON_MODULE.test(value.specifier), + ); + const resourceLimits = [...bindings.valueResourceLimits].sort(); + const coverage: JavaScriptStaticCoverage = { + syntax: + parserErrors === 0 + ? { status: "complete", error_count: 0 } + : { status: "recovered", error_count: parserErrors }, + semantics: { + unknown_findings: accumulator.unknownFindings, + uncertainty: [ + ...(accumulator.unknownFindings > 0 + ? ["dynamic-findings" as const] + : []), + ...(bindings.returnedReferenceWidenings.size > 0 + ? ["returned-reference-widening" as const] + : []), + ...(typeOnlySideEffect + ? ["type-only-module-side-effect" as const] + : []), + ], + }, + examination: { + status: + resourceLimits.length > 0 || + accumulator.electronCallPathLimitReached === true + ? "limited" + : "complete", + semantic_resource_limits: resourceLimits, + electron_call_paths_limited: + accumulator.electronCallPathLimitReached === true, + }, + applicability: { electron: electronObserved ? "observed" : "unknown" }, + }; const limitations = [ ...(bindings.valueResourceLimits.has("alias-selections") ? [semanticResourceLimitReason("alias-selections")] @@ -218,26 +259,17 @@ const finalizeStaticAnalysis = ( : [ "One or more static keys, expressions, or Electron boundary values were dynamic and remain unknown.", ]), - ...(hasUncertainTypeOnlyModuleSideEffect(file.program) + ...(typeOnlySideEffect ? [ "Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.", ] : []), - ...(accumulator.references.some( - ({ value }) => - value.specifier !== null && ELECTRON_MODULE.test(value.specifier), - ) - ? [ELECTRON_IDENTITY_LIMITATION] - : []), + ...(electronObserved ? [ELECTRON_IDENTITY_LIMITATION] : []), "JavaScript syntax was parsed as data and was never evaluated.", ]; return { - parse_status: - parserErrors > 0 || - accumulator.unknownFindings > 0 || - bindings.valueResourceLimits.has("alias-selections") - ? "partial" - : "complete", + parse_status: javaScriptStaticParseStatus(coverage), + analysis_coverage: coverage, parse_error_count: parserErrors, visited_ast_nodes: accumulator.visitedNodes, references: finalizeLocatedFindings( diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 34bdfc2f25..df4f39e096 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -23,10 +23,17 @@ import { compareUnicodeCodePoints, compositeKey, } from "../unicodeCodePointOrder.js"; +import { unexaminedJavaScriptStaticCoverage } from "./javascriptStaticCoverage.js"; /** Explicit result for source text that Babel cannot parse. */ export const failedJavaScriptStaticAnalysis = (): JavaScriptStaticAnalysis => ({ parse_status: "failed", + analysis_coverage: { + ...unexaminedJavaScriptStaticCoverage( + "JavaScript source could not be parsed.", + ), + syntax: { status: "failed", error_count: null }, + }, parse_error_count: 1, visited_ast_nodes: 0, references: [], diff --git a/src/domain/javascript/javascriptStaticAnalysisTypes.ts b/src/domain/javascript/javascriptStaticAnalysisTypes.ts index 843670a138..4a49d611fe 100644 --- a/src/domain/javascript/javascriptStaticAnalysisTypes.ts +++ b/src/domain/javascript/javascriptStaticAnalysisTypes.ts @@ -1,5 +1,6 @@ import type { ElectronStaticFindings } from "./electronStaticAnalysisTypes.js"; import type { JavaScriptRouteSourceFacts } from "./javascriptRouteFacts.js"; +import type { JavaScriptStaticCoverage } from "./javascriptStaticCoverage.js"; /** Token semantics shared by bundle producers and worker admission. */ export const JAVASCRIPT_AST_FINGERPRINT_ALGORITHM = "babel-ast-v3"; @@ -124,7 +125,10 @@ export interface JavaScriptRolePath { /** Deterministic AST-only analysis of one JavaScript source file. */ export interface JavaScriptStaticAnalysis { + /** Established overall summary; syntax alone is reported in analysis_coverage. */ readonly parse_status: "complete" | "partial" | "failed"; + /** Historical facts omit this measurement; consumers must not infer it. */ + readonly analysis_coverage?: JavaScriptStaticCoverage; readonly parse_error_count: number; readonly visited_ast_nodes: number; readonly references: readonly JavaScriptStaticReference[]; diff --git a/src/domain/javascript/javascriptStaticCoverage.test.ts b/src/domain/javascript/javascriptStaticCoverage.test.ts new file mode 100644 index 0000000000..2a6df5ae80 --- /dev/null +++ b/src/domain/javascript/javascriptStaticCoverage.test.ts @@ -0,0 +1,106 @@ +import { expect, it } from "vitest"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { + javaScriptStaticParseStatus, + unexaminedJavaScriptStaticCoverage, +} from "./javascriptStaticCoverage.js"; +import { aliasSelectionBudgetSource } from "../../../tests/fixtures/javascriptAliasSelectionBudget.js"; + +it("separates dynamic semantic uncertainty from completed syntax and examination", () => { + const result = analyzeJavaScriptStaticSource( + "const {ipcMain}=require('electron'); ipcMain.on(channel, () => {});", + ); + expect(result.parse_status).toBe("partial"); + expect(result.analysis_coverage).toMatchObject({ + syntax: { status: "complete", error_count: 0 }, + semantics: { unknown_findings: 1, uncertainty: ["dynamic-findings"] }, + examination: { + status: "complete", + semantic_resource_limits: [], + electron_call_paths_limited: false, + }, + applicability: { electron: "observed" }, + }); + expect(result.electron.ipc).toMatchObject([ + { channel: null, channel_expression: "channel" }, + ]); +}); + +it("distinguishes recovered syntax from unparseable source without inventing an error total", () => { + const recovered = analyzeJavaScriptStaticSource("let value; let value;"); + expect(recovered.parse_status).toBe("partial"); + expect(recovered.analysis_coverage?.syntax).toEqual({ + status: "recovered", + error_count: recovered.parse_error_count, + }); + const failed = analyzeJavaScriptStaticSource("const value = ;"); + expect(failed.parse_status).toBe("failed"); + expect(failed.analysis_coverage).toMatchObject({ + syntax: { status: "failed", error_count: null }, + semantics: { unknown_findings: null }, + examination: { status: "not-examined" }, + }); +}); + +it("keeps framework applicability unknown without positive Electron evidence", () => { + const ordinary = analyzeJavaScriptStaticSource("export const value = 1;"); + expect(ordinary.parse_status).toBe("complete"); + expect(ordinary.analysis_coverage?.applicability.electron).toBe("unknown"); + const electron = analyzeJavaScriptStaticSource( + "const {ipcMain}=require('electron');", + ); + expect(electron.analysis_coverage?.applicability.electron).toBe("observed"); +}); + +it("reports compiler-dependent module side effects independently of complete syntax", () => { + const result = analyzeJavaScriptStaticSource( + "import { type Config } from './config';", + ); + expect(result.analysis_coverage?.syntax).toEqual({ + status: "complete", + error_count: 0, + }); + expect(result.analysis_coverage?.semantics.uncertainty).toContain( + "type-only-module-side-effect", + ); +}); + +it("retains typed examination limits alongside successfully parsed source", () => { + const result = analyzeJavaScriptStaticSource(aliasSelectionBudgetSource()); + expect(result.parse_status).toBe("partial"); + expect(result.analysis_coverage).toMatchObject({ + syntax: { status: "complete", error_count: 0 }, + examination: { + status: "limited", + semantic_resource_limits: ["alias-selections"], + }, + }); +}); + +it("preserves the legacy summary responsibility without relabeling independent limitations", () => { + const base = analyzeJavaScriptStaticSource( + "export const value = 1;", + ).analysis_coverage!; + expect( + javaScriptStaticParseStatus({ + ...base, + semantics: { + unknown_findings: 0, + uncertainty: ["returned-reference-widening"], + }, + examination: { + ...base.examination, + status: "limited", + electron_call_paths_limited: true, + }, + }), + ).toBe("complete"); + expect( + unexaminedJavaScriptStaticCoverage("Caller selected another source."), + ).toMatchObject({ + syntax: { status: "not-examined", error_count: null }, + semantics: { unknown_findings: null }, + examination: { status: "not-examined" }, + unexamined_reason: "Caller selected another source.", + }); +}); diff --git a/src/domain/javascript/javascriptStaticCoverage.ts b/src/domain/javascript/javascriptStaticCoverage.ts new file mode 100644 index 0000000000..b216b647e1 --- /dev/null +++ b/src/domain/javascript/javascriptStaticCoverage.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import { javaScriptSemanticResourceLimitSchema } from "./javascriptSemanticValueTypes.js"; + +/** Independent observations from one inert static pass; no runtime completeness claim. */ +export const javaScriptStaticCoverageSchema = z.strictObject({ + syntax: z.discriminatedUnion("status", [ + z.strictObject({ + status: z.literal("complete"), + error_count: z.literal(0), + }), + z.strictObject({ + status: z.literal("recovered"), + error_count: z.int().positive(), + }), + z.strictObject({ status: z.literal("failed"), error_count: z.null() }), + z.strictObject({ + status: z.literal("not-examined"), + error_count: z.null(), + }), + ]), + semantics: z.strictObject({ + unknown_findings: z.int().nonnegative().nullable(), + uncertainty: z.array( + z.enum([ + "dynamic-findings", + "returned-reference-widening", + "type-only-module-side-effect", + ]), + ), + }), + examination: z.strictObject({ + status: z.enum(["complete", "limited", "not-examined"]), + semantic_resource_limits: z.array(javaScriptSemanticResourceLimitSchema), + electron_call_paths_limited: z.boolean(), + }), + applicability: z.strictObject({ electron: z.enum(["observed", "unknown"]) }), + unexamined_reason: z.string().min(1).exactOptional(), +}); + +export type JavaScriptStaticCoverage = z.infer< + typeof javaScriptStaticCoverageSchema +>; + +/** Preserve the established conservative summary, independently of finer facts. */ +export const javaScriptStaticParseStatus = ( + coverage: JavaScriptStaticCoverage, +): "complete" | "partial" | "failed" => + coverage.syntax.status === "failed" || + coverage.syntax.status === "not-examined" + ? "failed" + : coverage.syntax.status === "recovered" || + (coverage.semantics.unknown_findings ?? 0) > 0 || + coverage.examination.semantic_resource_limits.includes( + "alias-selections", + ) + ? "partial" + : "complete"; + +/** Unperformed work has no measured syntax, finding count or framework applicability. */ +export const unexaminedJavaScriptStaticCoverage = ( + reason: string, +): JavaScriptStaticCoverage => ({ + syntax: { status: "not-examined", error_count: null }, + semantics: { unknown_findings: null, uncertainty: [] }, + examination: { + status: "not-examined", + semantic_resource_limits: [], + electron_call_paths_limited: false, + }, + applicability: { electron: "unknown" }, + unexamined_reason: reason, +}); diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts index 13a528c2f6..5ae00bb48f 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts @@ -10,6 +10,28 @@ import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSe import { getterExpansionSource } from "../../../tests/fixtures/javascriptGetterExpansion.js"; import { aliasSelectionBudgetSource } from "../../../tests/fixtures/javascriptAliasSelectionBudget.js"; +it("preserves independent static coverage and historical unmeasured facts", () => { + const facts = analyzeJavaScriptStaticSource("require(moduleName);"); + expect( + javaScriptWorkerStaticAnalysisSchema.parse( + JSON.parse(JSON.stringify(facts)), + ), + ).toEqual(facts); + const { analysis_coverage: _coverage, ...historical } = facts; + expect(javaScriptWorkerStaticAnalysisSchema.parse(historical)).toEqual( + historical, + ); + expect( + javaScriptWorkerStaticAnalysisSchema.safeParse({ + ...facts, + analysis_coverage: { + ...facts.analysis_coverage, + syntax: { status: "complete", error_count: -1 }, + }, + }).success, + ).toBe(false); +}); + it("transfers bounded alias selection coverage across the worker boundary", () => { const source = aliasSelectionBudgetSource(); const facts = analyzeJavaScriptStaticSource(source); diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 88825153c6..e06022c2f0 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -1,20 +1,22 @@ -import { javaScriptSemanticUnknownOmissionsSchema } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; -import { z } from "zod"; -import { digestSchema } from "../../domain/digests.js"; -import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; -import { javaScriptRouteSourceFactsSchema } from "../../domain/javascript/javascriptRouteFacts.js"; -import { JAVASCRIPT_AST_FINGERPRINT_ALGORITHM } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; -import type { - JavaScriptSemanticProperty, - JavaScriptSemanticValue, -} from "../../domain/javascript/javascriptSemanticValueTypes.js"; import { + javaScriptSemanticUnknownOmissionsSchema, javaScriptSemanticNodeSchema, javaScriptSemanticRelationSchema, javaScriptSemanticUnknownSchema, javaScriptSemanticFingerprintSchema, javaScriptSemanticEvidenceContextSchema, } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; +import { z } from "zod"; +import { digestSchema } from "../../domain/digests.js"; +import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; +import { javaScriptRouteSourceFactsSchema } from "../../domain/javascript/javascriptRouteFacts.js"; +import { javaScriptStaticCoverageSchema } from "../../domain/javascript/javascriptStaticCoverage.js"; +import { JAVASCRIPT_AST_FINGERPRINT_ALGORITHM } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; +import type { + JavaScriptSemanticProperty, + JavaScriptSemanticValue, +} from "../../domain/javascript/javascriptSemanticValueTypes.js"; +import { javaScriptSemanticResourceLimitSchema } from "../../domain/javascript/javascriptSemanticValueTypes.js"; const range = javaScriptSemanticNodeSchema.shape.identity.shape.source_range.unwrap(); @@ -319,6 +321,7 @@ const referenceShape = { export const javaScriptWorkerStaticAnalysisSchema = z .strictObject({ parse_status: z.enum(["complete", "partial", "failed"]), + analysis_coverage: javaScriptStaticCoverageSchema.exactOptional(), routes: javaScriptRouteSourceFactsSchema.exactOptional(), parse_error_count: count, visited_ast_nodes: count, @@ -433,13 +436,6 @@ export const javaScriptWorkerStaticAnalysisSchema = z }, })) satisfies z.ZodType; -const resourceLimit = z.enum([ - "primitive-candidates", - "primitive-bytes", - "expression-depth", - "getter-expansion", - "alias-selections", -]); const primitive = z.union([z.string(), z.number(), z.boolean(), z.null()]); const property: z.ZodType = z.lazy(() => z.strictObject({ @@ -487,7 +483,7 @@ const semanticValue: z.ZodType = z.lazy(() => z.strictObject({ status: z.enum(["unknown", "ambiguous", "cycle"]), reason: z.string(), - resourceLimit: resourceLimit.optional(), + resourceLimit: javaScriptSemanticResourceLimitSchema.optional(), }), ]) .transform((value) => { @@ -544,19 +540,19 @@ const coverage = z z.strictObject({ status: z.literal("complete"), omittedCount: z.literal(0), - resourceLimits: z.array(resourceLimit).optional(), + resourceLimits: z.array(javaScriptSemanticResourceLimitSchema).optional(), widenedReturnedReferences: z.array(range).optional(), }), z.strictObject({ status: z.literal("partial"), omittedCount: z.union([z.literal(0), z.null()]), - resourceLimits: z.array(resourceLimit).optional(), + resourceLimits: z.array(javaScriptSemanticResourceLimitSchema).optional(), widenedReturnedReferences: z.array(range).optional(), }), z.strictObject({ status: z.literal("failed"), omittedCount: z.null(), - resourceLimits: z.array(resourceLimit).optional(), + resourceLimits: z.array(javaScriptSemanticResourceLimitSchema).optional(), widenedReturnedReferences: z.array(range).optional(), }), ]) diff --git a/tests/acceptance/applications/javascriptSourceSelectionCli.test.ts b/tests/acceptance/applications/javascriptSourceSelectionCli.test.ts index 1a8eb73ba1..e152f300c0 100644 --- a/tests/acceptance/applications/javascriptSourceSelectionCli.test.ts +++ b/tests/acceptance/applications/javascriptSourceSelectionCli.test.ts @@ -92,6 +92,15 @@ cliTest.for([ expect(properties?.parse_status).toBe( path === "main.mjs" ? "complete" : null, ); + if (/\.(?:mjs|js)$/u.test(path)) + expect(properties?.analysis_coverage).toMatchObject({ + syntax: { + status: requested.includes(path) ? "complete" : "not-examined", + }, + examination: { + status: requested.includes(path) ? "complete" : "not-examined", + }, + }); expect(Buffer.compare(await readFile(join(directory, path)), bytes)).toBe( 0, ); diff --git a/tests/boundary/mcp/javascriptFactsViewMcp.test.ts b/tests/boundary/mcp/javascriptFactsViewMcp.test.ts index 3316cd1e3c..5c46881f11 100644 --- a/tests/boundary/mcp/javascriptFactsViewMcp.test.ts +++ b/tests/boundary/mcp/javascriptFactsViewMcp.test.ts @@ -13,8 +13,11 @@ import { analysisViewJavaScriptEvidence, analysisViewBindJavaScriptGraphs, } from "../../fixtures/analysisView.js"; -import { parseEvidence } from "../../../src/domain/evidence.js"; -import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { + analysisViewResultSchema, + type AnalysisViewResult, +} from "../../../src/domain/analysisView/analysisView.js"; import { createJavaScriptApplicationGraph, createJavaScriptApplicationNode, @@ -46,6 +49,57 @@ const facts = async ( return { evidence, page, wire }; }; +/** Summary counts and both Evidence lookup paths preserve selected/excluded source facts. */ +const expectSelectedSourceCoverage = async ( + harness: Awaited>, + parent: Evidence, + summary: Extract< + AnalysisViewResult, + { kind: "summary"; parent_operation: "analyze_javascript_application" } + >, +): Promise => { + expect(summary.summary.coverage).toMatchObject({ + static_sources: { + sources: 2, + unmeasured_sources: 0, + syntax: { complete: 1, "not-examined": 1 }, + examination: { complete: 1, "not-examined": 1 }, + electron_applicability: { observed: 0, unknown: 2 }, + }, + }); + const query = { + kind: "javascript-facts", + where: { paths: ["a.js", "b.js"], kinds: ["javascript-asset"] }, + property_keys: ["analysis_coverage"], + limit: 64, + }; + const retained = await facts( + harness, + { kind: "retained-evidence", evidence_id: parent.evidence_id }, + query, + ); + const portable = await facts( + harness, + { kind: "inline", evidence: parent }, + query, + ); + expect(portable.evidence).toEqual(retained.evidence); + expect( + retained.page.rows.map(({ properties }) => properties.analysis_coverage), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + syntax: { status: "complete", error_count: 0 }, + }), + expect.objectContaining({ + syntax: { status: "not-examined", error_count: null }, + unexamined_reason: + "Source is outside the caller-selected source_paths.", + }), + ]), + ); +}; + it("pages selected real filesystem observations with shared identities and portable/retained parity", async () => { const root = await createTestTempDirectory("rea-retained-file-facts-"); await Promise.all( @@ -68,9 +122,15 @@ it("pages selected real filesystem observations with shared identities and porta const summary = analysisViewResultSchema.parse( parseEvidence(preparation.structuredContent).normalized_result, ); + if ( + summary.kind !== "summary" || + summary.parent_operation !== "analyze_javascript_application" + ) + throw new Error("Expected JavaScript summary view"); const parent = harness.session.evidenceById(summary.parent_evidence_id); if (parent === undefined) throw new Error("Missing retained parent"); const source = { kind: "retained-evidence", evidence_id: parent.evidence_id }; + await expectSelectedSourceCoverage(harness, parent, summary); const query = { kind: "javascript-facts", where: { paths: ["first.node", "second.node", "package.json", "missing"] },