diff --git a/docs/guides/javascript/javascript-artifact-reconstruction.md b/docs/guides/javascript/javascript-artifact-reconstruction.md index 3963f8bb96..f568cc4153 100644 --- a/docs/guides/javascript/javascript-artifact-reconstruction.md +++ b/docs/guides/javascript/javascript-artifact-reconstruction.md @@ -86,6 +86,11 @@ properties and remains readable without migration. ## Select sources before analysis +Explicit `source_paths` retain the acquisition owner's requested order, including +selected dependency files. First-party priority applies only to unscoped analysis. +Recovered syntax or incomplete semantic examination keeps Electron coverage +partial even when useful windows or IPC facts were retained. + For a question about known entry sources, supply exact artifact-relative file paths in `source_paths`, or repeat `--source-path` on either CLI route: diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index 0f76b4e2b7..fd73320870 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -6,7 +6,10 @@ import { ELECTRON_REFERENCE_KINDS, } from "../../domain/javascript/javascriptElectronMemberWrites.js"; import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; -import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; +import type { + AnalyzedJavaScriptArtifactFile, + JavaScriptArtifactAnalysis, +} from "./JavaScriptArtifactAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { resolveArtifactPathByContext, @@ -128,7 +131,7 @@ export const createElectronIpcContext = ( }; /** - * Whether any analyzed source loads an Electron module. + * Whether one analyzed source loads an Electron module. * * `analysis_coverage.applicability.electron` is the domain owner of this * question: it already excludes a `type-import`, a `require.resolve` that only @@ -139,42 +142,51 @@ export const createElectronIpcContext = ( * Historical evidence may predate that field, so the retained references answer * the same question for those files. */ -export const electronModuleReferenced = ( - analysis: JavaScriptArtifactAnalysis, -): boolean => - analysis.files.some(({ javascript, semantic }) => { - const applicability = javascript?.analysis_coverage?.applicability.electron; - if (applicability !== undefined) return applicability === "observed"; - const links = semantic?.ir.moduleLinks; - if ( - links !== undefined && - links.some( - ({ specifier, kind, loaderAuthority }) => - kind !== "type-import" && - loaderAuthority !== "candidate" && - specifier !== null && - ELECTRON_MODULE.test(specifier), - ) - ) - return true; - return (javascript?.references ?? []).some( - ({ specifier, kind, operation, loader_authority }) => - operation === "load" && - loader_authority !== "candidate" && - ELECTRON_REFERENCE_KINDS.has(kind) && +const electronModuleReferencedBy = ({ + javascript, + semantic, +}: AnalyzedJavaScriptArtifactFile): boolean => { + const applicability = javascript?.analysis_coverage?.applicability.electron; + if (applicability !== undefined) return applicability === "observed"; + const links = semantic?.ir.moduleLinks; + if ( + links !== undefined && + links.some( + ({ specifier, kind, loaderAuthority }) => + kind !== "type-import" && + loaderAuthority !== "candidate" && specifier !== null && ELECTRON_MODULE.test(specifier), - ); - }); + ) + ) + return true; + return (javascript?.references ?? []).some( + ({ specifier, kind, operation, loader_authority }) => + operation === "load" && + loader_authority !== "candidate" && + ELECTRON_REFERENCE_KINDS.has(kind) && + specifier !== null && + ELECTRON_MODULE.test(specifier), + ); +}; -/** Every Electron boundary fact family the graph reports. */ +/** Whether any analyzed source loads an Electron module. */ +export const electronModuleReferenced = ( + analysis: JavaScriptArtifactAnalysis, +): boolean => analysis.files.some(electronModuleReferencedBy); + +/** + * Every Electron boundary fact family the graph reports. A rejected + * application projection keeps its static facts on the file node only, so + * those facts are not part of the reported boundary surface. + */ export const electronBoundaryFactCount = ( analysis: JavaScriptArtifactAnalysis, ): number => analysis.files.reduce( - (count, { javascript }) => + (count, { javascript, application_projection_failure }) => count + - (javascript === null + (javascript === null || application_projection_failure !== undefined ? 0 : javascript.electron.browser_windows.filter(provenElectronFinding) .length + @@ -219,18 +231,57 @@ export const hasCandidateElectronFindings = ( ); /** - * Whether the Electron boundary summary came back empty. `undefined` means no - * analyzed source names Electron, which is unknown rather than absent: unread - * sources and dynamic module names can hide its use. + * A completed static pass can still stop early, for example at the Electron + * wrapper expansion budget. Syntax recovery and examination facts jointly own that verdict. + */ +const electronExaminationLimited = ({ + javascript, +}: AnalyzedJavaScriptArtifactFile): boolean => { + if (javascript === null) return false; + const status = javascript.analysis_coverage?.examination.status; + const syntax = javascript.analysis_coverage?.syntax.status; + return ( + javascript.parse_status !== "complete" || + (syntax !== undefined && syntax !== "complete") || + (status !== undefined && status !== "complete") + ); +}; + +/** + * Electron-loading sources whose boundary facts may be missing from the + * report: their analysis was interrupted or not started, their application + * projection was rejected for the result budget, or their static examination + * stopped at a resource limit or retained only partial syntax/semantics. + */ +export const electronIncompleteSourceCount = ( + analysis: JavaScriptArtifactAnalysis, +): number => + analysis.files.filter( + (file) => + (file.analysis_failure !== undefined || + file.application_projection_failure !== undefined || + electronExaminationLimited(file)) && + electronModuleReferencedBy(file), + ).length; + +/** + * Whether the Electron boundary summary came back empty or incomplete. + * `undefined` means no analyzed source names Electron, which is unknown rather + * than absent: unread sources and dynamic module names can hide its use. + * `partial` means some facts were reported while other Electron-loading + * sources were not fully examined or reported, so missing windows, preloads, + * bridges, or IPC operations from those sources are unknown. */ export const electronSemanticsState = ( analysis: JavaScriptArtifactAnalysis, -): "recovered" | "empty" | undefined => +): "recovered" | "partial" | "empty" | undefined => electronModuleReferenced(analysis) === false ? undefined : electronBoundaryFactCount(analysis) === 0 ? "empty" - : "recovered"; + : electronIncompleteSourceCount(analysis) > 0 + ? "partial" + : "recovered"; /** Summarize static Electron findings without treating gaps as absence. */ export const summarizeElectronBoundaries = ( diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.order.test.ts b/src/application/javascript/JavaScriptArtifactAnalysis.order.test.ts new file mode 100644 index 0000000000..4692fcf0b7 --- /dev/null +++ b/src/application/javascript/JavaScriptArtifactAnalysis.order.test.ts @@ -0,0 +1,158 @@ +import { expect, it } from "vitest"; +import { + analyzeAndAdoptJavaScriptArtifactFiles, + javaScriptAnalysisOrder, +} from "./JavaScriptArtifactAnalysis.js"; +import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js"; +import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; +import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; +import { err, ok } from "../../domain/result.js"; + +const source = (path: string): JavaScriptArtifactFile => ({ + path, + container_sha256: "a".repeat(64), + sha256: "b".repeat(64), + bytes: 20, + inventory_artifact_id: path, + kind: "javascript", + unpacked: false, + text: { included: true, value: "export const x = 1;" }, +}); + +const manifest = (path: string): JavaScriptArtifactFile => ({ + ...source(path), + kind: "package-json", + text: { included: true, value: "{}" }, +}); + +// Lexical inventory order puts `node_modules/` before an electron-vite `out-*` +// directory, which is the shape that exhausted the result budget in #9321. +const inventory = [ + source("node_modules/a/index.js"), + manifest("node_modules/a/package.json"), + source("node_modules/b/lib/yaml.js"), + source("out-global-online/main/index.js"), + source("out-global-online/preload/index.js"), + manifest("package.json"), +]; + +it("offers application JavaScript before dependency JavaScript", () => { + expect(javaScriptAnalysisOrder(inventory).map(({ path }) => path)).toEqual([ + "node_modules/a/package.json", + "out-global-online/main/index.js", + "out-global-online/preload/index.js", + "package.json", + "node_modules/a/index.js", + "node_modules/b/lib/yaml.js", + ]); + // A package merely named like a dependency directory is not one. + expect( + javaScriptAnalysisOrder([ + source("node_modules.js"), + source("src/node_modules_shim.js"), + ]).map(({ path }) => path), + ).toEqual(["node_modules.js", "src/node_modules_shim.js"]); +}); + +it("spends a limited result budget on first-party sources and keeps inventory order", async () => { + const javascript = analyzeJavaScriptStaticSource("export const x = 1;"); + const analyzed: string[] = []; + // Admit two sources, then report the budget as spent, as the parent does + // once its remaining result capacity is reserved for static observations. + const worker = { + analyze: async (file: JavaScriptArtifactFile) => { + analyzed.push(file.path); + const value = { javascript, module: null, projection: null }; + if (analyzed.length <= 2) return ok(value); + return err({ + ...value, + error: new AnalysisResourceConstraintError( + "analyze_javascript_application", + "memory", + `semantic analysis of ${file.path} was not started`, + { semantic_result_budget_bytes: 0 }, + ), + }); + }, + }; + const outcome = await analyzeAndAdoptJavaScriptArtifactFiles( + { + files: inventory, + unavailable_relevant_files: [], + containers: [], + text_bytes_read: 120, + invalid_utf8_files: 0, + }, + worker, + createJavaScriptSemanticGraphProjection(), + async () => {}, + ); + expect(analyzed).toEqual([ + "out-global-online/main/index.js", + "out-global-online/preload/index.js", + "node_modules/a/index.js", + "node_modules/b/lib/yaml.js", + ]); + expect(outcome.analysis.files.map(({ file }) => file.path)).toEqual( + inventory.map(({ path }) => path), + ); + const failed = outcome.analysis.files + .filter(({ analysis_failure }) => analysis_failure !== undefined) + .map(({ file }) => file.path); + expect(failed).toEqual([ + "node_modules/a/index.js", + "node_modules/b/lib/yaml.js", + ]); + expect(outcome.interruption).toBeNull(); +}); + +it("spends capacity in the caller's requested order for explicitly selected sources", async () => { + const files = [ + source("node_modules/selected/index.js"), + source("src/main.js"), + ]; + const admitted: string[] = []; + const javascript = analyzeJavaScriptStaticSource("export const x = 1;"); + const outcome = await analyzeAndAdoptJavaScriptArtifactFiles( + { + files, + unavailable_relevant_files: [], + containers: [], + text_bytes_read: 40, + invalid_utf8_files: 0, + source_selection: { + requested_paths: files.map((file) => file.path), + unselected_files: 0, + }, + }, + { + analyze: async (file) => { + const value = { javascript, module: null, projection: null }; + admitted.push(file.path); + return admitted.length === 1 + ? ok(value) + : err({ + ...value, + error: new AnalysisResourceConstraintError( + "analyze_javascript_application", + "memory", + "result capacity exhausted", + { semantic_result_budget_bytes: 0 }, + ), + }); + }, + }, + createJavaScriptSemanticGraphProjection(), + async () => {}, + ); + expect(admitted).toEqual(files.map((file) => file.path)); + expect( + outcome.analysis.files.find(({ file }) => file.path === files[0]!.path) + ?.analysis_failure, + ).toBeUndefined(); + expect( + outcome.analysis.files.find(({ file }) => file.path === files[1]!.path) + ?.analysis_failure, + ).toBeDefined(); +}); diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index 4cd483b909..0af3bcdedb 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -97,7 +97,9 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async ( const selectedFileCount = fileSet.files.length - (fileSet.source_selection?.unselected_files ?? 0); let selectedFileIndex = 0; - for (const file of fileSet.files) { + for (const file of fileSet.source_selection === undefined + ? javaScriptAnalysisOrder(fileSet.files) + : fileSet.files) { let analysisStarted = false; try { await setImmediate(); @@ -308,12 +310,49 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async ( ); } } + restoreInventoryOrder(fileSet.files, state.files); return { analysis: finalizeArtifactAnalysis(state, preloadLimitations), interruption, }; }; +/** A source inside an installed dependency package, not the application. */ +const dependencySourcePath = (path: string): boolean => + path.split("/").slice(0, -1).includes("node_modules"); + +/** + * Every source in one analysis charges the same parent result budget, so the + * order sources are offered decides which ones keep semantic and application + * facts when that budget runs out. Inventory order is lexical, which lets a + * large `node_modules` tree spend the budget before first-party entrypoints, + * preloads, and Electron windows are examined. Offer application JavaScript + * first and dependency JavaScript last; metadata files keep their position. + */ +export const javaScriptAnalysisOrder = ( + files: readonly JavaScriptArtifactFile[], +): JavaScriptArtifactFile[] => { + const application: JavaScriptArtifactFile[] = []; + const dependencies: JavaScriptArtifactFile[] = []; + for (const file of files) { + if (file.kind === "javascript" && dependencySourcePath(file.path)) + dependencies.push(file); + else application.push(file); + } + return [...application, ...dependencies]; +}; + +/** Analysis order is an admission policy; reported files keep inventory order. */ +const restoreInventoryOrder = ( + inventory: readonly JavaScriptArtifactFile[], + analyzed: AnalyzedJavaScriptArtifactFile[], +): void => { + const positions = new Map(inventory.map((file, index) => [file, index])); + const position = ({ file }: AnalyzedJavaScriptArtifactFile): number => + positions.get(file) ?? inventory.length; + analyzed.sort((left, right) => position(left) - position(right)); +}; + const finalizeArtifactAnalysis = ( state: MutableArtifactAnalysis, preloadLimitations: readonly string[], diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index 81bde8333a..0aaa217388 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -1,5 +1,6 @@ import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { + electronIncompleteSourceCount, electronSemanticsState, createElectronIpcContext, hasCandidateElectronFindings, @@ -396,11 +397,16 @@ const graphLimitations = ( ? [ "Electron modules are referenced but no Electron boundary fact was recovered from inert syntax. Zero IPC operations and zero BrowserWindows here mean empty coverage, not evidence that the application has none.", ] - : electronSemantics === "recovered" + : electronSemantics === "partial" ? [ + `Electron boundary facts are partial: ${String(electronIncompleteSourceCount(context.analysis))} Electron-loading source(s) were not fully examined or reported because syntax was recovered, semantics remained unresolved, analysis was interrupted, or a resource limit was reached. BrowserWindows, preloads, bridges, and IPC operations from those sources are unknown, not absent; analyze fewer sources, such as first-party main and preload files selected with source_paths.`, "Electron relationships are derived from inert syntax; runtime registration, reachability, defaults, and enforcement remain unproven.", ] - : []), + : electronSemantics === "recovered" + ? [ + "Electron relationships are derived from inert syntax; runtime registration, reachability, defaults, and enforcement remain unproven.", + ] + : []), ...(ipc.some(({ finding }) => finding.channel === null) ? [ "Dynamic IPC channel expressions remain unresolved and are never paired by textual similarity.", diff --git a/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts b/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts index c48584df8b..c3c5f1677c 100644 --- a/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts +++ b/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts @@ -7,10 +7,13 @@ import { moduleResolutionModeForJavaScriptReference } from "../../domain/javascr import { analyzeParsedJavaScriptSemanticsSteps } from "../../domain/javascript/javascriptSemanticAnalysis.js"; import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js"; import { analyzeParsedJavaScriptStaticSourceSteps } from "../../domain/javascript/javascriptStaticAnalysis.js"; +import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import type { AnalyzedJavaScriptArtifactFile, JavaScriptArtifactAnalysis, } from "./JavaScriptArtifactAnalysisTypes.js"; +import { javascriptArtifactAnalysisScopes } from "./JavaScriptArtifactAnalysisTypes.js"; import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js"; import { buildImmutableJavaScriptArtifactGraphSteps as buildGraph } from "./JavaScriptArtifactGraphBuilder.js"; import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; @@ -86,10 +89,19 @@ const graphFor = async ( electronSemantics: string | undefined; }> => { const base = await analyzedEntry(source, path); - const entry = mutate === undefined ? base : mutate(base); + return graphForEntries([mutate === undefined ? base : mutate(base)]); +}; + +const graphForEntries = ( + entries: readonly AnalyzedJavaScriptArtifactFile[], +): { + limitations: readonly string[]; + electronSemantics: string | undefined; + browserWindows: number; +} => { const analysis: JavaScriptArtifactAnalysis = { jsonDocuments: new JavaScriptJsonDocuments(), - files: [entry], + files: [...entries], packages: [], json_modules: [], html_scripts: [], @@ -102,10 +114,10 @@ const graphFor = async ( limitations: [], }; const fileSet = { - files: [entry.file], + files: entries.map(({ file }) => file), unavailable_relevant_files: [], containers: [], - text_bytes_read: entry.file.bytes, + text_bytes_read: entries.reduce((total, { file }) => total + file.bytes, 0), invalid_utf8_files: 0, }; const steps = buildGraph(snapshot, fileSet, analysis); @@ -114,6 +126,7 @@ const graphFor = async ( return { limitations: next.value.graph.limitations, electronSemantics: next.value.electronSummary.electron_semantics, + browserWindows: next.value.electronSummary.browser_windows, }; }; @@ -344,3 +357,102 @@ it("classifies a bundle module by its loader, not by its name", async () => { ); expect(externalUnderKey.electronSemantics).toBe("recovered"); }); + +it("reports partial coverage when an Electron source was dropped for the result budget", async () => { + // #9321: IPC handlers from an admitted source were reported while the + // window source's projection was rejected, so zero windows is unknown. + const reported = await analyzedEntry( + `const electron = require("electron");\nelectron.ipcMain.handle("ping", () => "pong");\n`, + "out/main/ipc.cjs", + ); + const window = await analyzedEntry( + `const electron = require("electron");\nnew electron.BrowserWindow({ webPreferences: { contextIsolation: true } });\n`, + "out/main/window.cjs", + ); + expect(window.javascript?.electron.browser_windows).toHaveLength(1); + const complete = graphForEntries([reported, window]); + expect(complete.electronSemantics).toBe("recovered"); + expect(complete.browserWindows).toBe(1); + + const notStarted = new AnalysisResourceConstraintError( + "analyze_javascript_application", + "memory", + "semantic analysis of out/main/window.cjs was not started", + { semantic_result_budget_bytes: 0 }, + ); + const dropped: AnalyzedJavaScriptArtifactFile[] = [ + { + ...window, + application_projection_failure: { + reason: "result budget exhausted", + error: projectAnalysisError(notStarted), + limits: null, + }, + }, + { + ...window, + semantic: null, + analysis_failure: { + scope: javascriptArtifactAnalysisScopes.javascript, + status: "interrupted", + reason: `out/main/window.cjs: ${notStarted.message}`, + error: projectAnalysisError(notStarted), + limits: notStarted.reportedLimits, + }, + }, + ]; + for (const entry of dropped) { + const partial = graphForEntries([reported, entry]); + expect(partial.electronSemantics).toBe("partial"); + expect(partial.limitations).not.toContain(EMPTY_COVERAGE); + expect(partial.limitations).toContainEqual( + expect.stringContaining( + "Electron boundary facts are partial: 1 Electron-loading source(s)", + ), + ); + } +}); + +it("reports partial coverage when a successful static pass stopped at a limit", async () => { + // The wrapper expansion budget ends Electron call-path discovery without + // failing the worker, so no failure field marks the source as incomplete. + const { electronSemantics, limitations } = await graphFor( + `const electron = require("electron");\nelectron.ipcMain.handle("ping", () => "pong");\n`, + (entry) => { + const javascript = entry.javascript!; + const coverage = javascript.analysis_coverage!; + return { + ...entry, + javascript: { + ...javascript, + analysis_coverage: { + ...coverage, + examination: { + ...coverage.examination, + status: "limited", + electron_call_paths_limited: true, + }, + }, + }, + }; + }, + "main.cjs", + ); + expect(electronSemantics).toBe("partial"); + expect(limitations).toContainEqual( + expect.stringContaining( + "Electron boundary facts are partial: 1 Electron-loading source(s)", + ), + ); +}); + +it("keeps recovered Electron facts partial after syntax recovery", async () => { + const source = `import { BrowserWindow } from "electron"; new BrowserWindow({webPreferences: {sandbox: true}}); const duplicate = 1; const duplicate = 2;`; + const entry = await analyzedEntry(source, "main.mjs"); + expect(entry.javascript?.parse_status).toBe("partial"); + expect(entry.javascript?.electron.browser_windows.length).toBeGreaterThan(0); + expect(graphForEntries([entry])).toMatchObject({ + electronSemantics: "partial", + browserWindows: 1, + }); +}); diff --git a/src/domain/javascript/javascriptApplicationAnalysis.ts b/src/domain/javascript/javascriptApplicationAnalysis.ts index c54ce3d66d..d55e345c94 100644 --- a/src/domain/javascript/javascriptApplicationAnalysis.ts +++ b/src/domain/javascript/javascriptApplicationAnalysis.ts @@ -61,14 +61,17 @@ const electronBoundarySummarySchema = z.strictObject({ /** * Whether the Electron boundary surface was actually recovered. `empty` means * an analyzed source names Electron but no boundary fact was recovered, so the - * zeroes above are missing coverage and not evidence of absence. + * zeroes above are missing coverage and not evidence of absence. `partial` + * means some boundary facts were recovered while other Electron-loading + * sources were not fully examined or reported, for example after the result + * budget ran out; counts from those sources are unknown, not zero. * * Absent when no retained fact names an Electron module, or in evidence * produced before this field existed. Absence is unknown rather than proof * that the application has no Electron surface: unread sources, failed * analysis, and dynamic module names can hide one. */ - electron_semantics: z.enum(["recovered", "empty"]).exactOptional(), + electron_semantics: z.enum(["recovered", "partial", "empty"]).exactOptional(), }); const reconstructionStatisticsSchema = z.strictObject({