diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index 67f4615b01..a38cc30b81 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -27,6 +27,8 @@ export interface ElectronIpcPairingState { readonly transmission: ElectronIpcRecord; readonly candidates: readonly ElectronIpcRecord[]; readonly status: "paired" | "ambiguous" | "unpaired"; + /** An observed match that may have unseen competitors when coverage is incomplete. */ + readonly coverageLimitedCandidate: ElectronIpcRecord | null; } /** Flatten all per-file IPC facts into deterministic application records. */ @@ -55,6 +57,7 @@ export interface ElectronIpcContext { export const createElectronIpcContext = ( analysis: JavaScriptArtifactAnalysis, applicationOwner: (file: JavaScriptArtifactFile) => string | null, + coverageComplete = true, ): ElectronIpcContext => { const records = collectElectronIpcRecords(analysis); const owners = new Map( @@ -81,11 +84,20 @@ export const createElectronIpcContext = ( // Unknown ownership can conceal competing dispatch contexts. Different owners // do not prove runtime isolation, so retain ambiguity rather than absence. const uncertain = + !coverageComplete || owner == null || matching.some((handler) => owners.get(handler.key) == null); return { transmission, candidates, + coverageLimitedCandidate: + !coverageComplete && + owner != null && + candidates.length === 1 && + candidates[0]?.finding.receiver === undefined && + !matching.some((handler) => owners.get(handler.key) == null) + ? (candidates[0] ?? null) + : null, status: !uncertain && candidates.length === 1 && diff --git a/src/application/javascript/ElectronBoundaryGraphCallPaths.ts b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts index 0d792cce69..59558f0f3b 100644 --- a/src/application/javascript/ElectronBoundaryGraphCallPaths.ts +++ b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts @@ -1,5 +1,6 @@ import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js"; import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js"; +import { IPC_COVERAGE_LIMITED_PAIRING_BASIS } from "../../domain/javascript/javascriptApplicationGraphSchemas.js"; import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { electronCallPathId } from "./ElectronCallPathIdentity.js"; @@ -137,7 +138,21 @@ export const addElectronCallPaths = ( projected.push({ file, path, coverage, nodes }); } } - for (const { transmission, handler } of ipc.pairings) { + const wrapperCandidates = [ + ...ipc.pairings.map((pairing) => ({ ...pairing, coverageLimited: false })), + ...ipc.states.flatMap(({ transmission, coverageLimitedCandidate }) => + coverageLimitedCandidate === null + ? [] + : [ + { + transmission, + handler: coverageLimitedCandidate, + coverageLimited: true, + }, + ], + ), + ]; + for (const { transmission, handler, coverageLimited } of wrapperCandidates) { const sources = projected.filter( (p) => p.path.entry_kind === "bridge-member" && matches(p, transmission), ); @@ -160,15 +175,22 @@ export const addElectronCallPaths = ( operation: "join-electron-wrapper-paths", properties: { channel: transmission.finding.channel, - pairing_basis: "unique-exact-literal-channel", + pairing_basis: coverageLimited + ? IPC_COVERAGE_LIMITED_PAIRING_BASIS + : "unique-exact-literal-channel", resolution: "candidate", handler_path: to.file.path, bridge_conditions: from.path.conditions, handler_conditions: to.path.conditions, }, - limitations: [ - "The unique static channel match does not establish runtime registration, sender acceptance, or dispatch.", - ], + limitations: coverageLimited + ? [ + "Incomplete source coverage may hide competing handlers; uniqueness of this observed channel match cannot be established.", + "The match does not establish runtime registration, sender acceptance, or dispatch.", + ] + : [ + "The unique static channel match does not establish runtime registration, sender acceptance, or dispatch.", + ], }); } } diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.revocation.test.ts b/src/application/javascript/JavaScriptArtifactAnalysis.revocation.test.ts new file mode 100644 index 0000000000..0c84bf2ad2 --- /dev/null +++ b/src/application/javascript/JavaScriptArtifactAnalysis.revocation.test.ts @@ -0,0 +1,77 @@ +import { expect, it } from "vitest"; +import { analyzeAndAdoptJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js"; +import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js"; +import { + AnalysisCancelledError, + 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;" }, +}); + +it.each([false, true])( + "marks only the earlier omitted projection when a later source %s is cancelled", + async (cancelled) => { + const files = [source("first.js"), source("second.js")]; + const javascript = analyzeJavaScriptStaticSource("export const x = 1;"); + const error = new AnalysisResourceConstraintError( + "analyze_javascript_application", + "memory", + "earlier projection omitted", + {}, + {}, + ); + let call = 0; + const worker = { + analyze: async () => { + call++; + const value = { javascript, module: null, projection: null }; + if (call === 1) return ok(value); + const revokedStaticProjections = [{ sourceIndex: 0, error }]; + return cancelled + ? err({ + ...value, + error: new AnalysisCancelledError( + "analyze_javascript_application", + ), + revokedStaticProjections, + }) + : ok({ ...value, revokedStaticProjections }); + }, + }; + const outcome = await analyzeAndAdoptJavaScriptArtifactFiles( + { + files, + unavailable_relevant_files: [], + containers: [], + text_bytes_read: 40, + invalid_utf8_files: 0, + }, + worker, + createJavaScriptSemanticGraphProjection(), + async () => {}, + ); + expect(outcome.analysis.files[0]).toMatchObject({ + file: { path: "first.js" }, + javascript: { parse_status: "complete" }, + application_projection_failure: { + reason: expect.stringContaining("earlier projection omitted"), + }, + }); + expect(outcome.analysis.files[1]).not.toHaveProperty( + "application_projection_failure", + ); + expect(outcome.interruption === null).toBe(!cancelled); + }, +); diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index 648fa64565..d459e8f041 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -82,6 +82,7 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async ( readonly interruption: AnalysisError | null; }> => { const state = emptyArtifactAnalysis(); + const sourceIndices: number[] = []; let interruption: AnalysisError | null = null; const selectedFileCount = fileSet.files.length - (fileSet.source_selection?.unselected_files ?? 0); @@ -119,6 +120,22 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async ( onPhase === undefined ? undefined : (phase) => onPhase(file, phase), ); const facts = result.ok ? result.value : result.error; + for (const revoked of facts.revokedStaticProjections ?? []) { + const index = sourceIndices[revoked.sourceIndex]; + const previous = index === undefined ? undefined : state.files[index]; + if (previous === undefined) + throw new TypeError( + "Revoked application projection has no retained source", + ); + state.files[index!] = { + ...previous, + application_projection_failure: { + reason: revoked.error.message, + error: projectAnalysisError(revoked.error), + limits: revoked.error.reportedLimits, + }, + }; + } const applicationProjectionFailure = facts.applicationProjectionFailure; if (facts.projection !== null) projection.adoptFileProjection(facts.projection); @@ -139,6 +156,7 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async ( error: projectAnalysisError(error), limits, }; + sourceIndices.push(state.files.length); state.files.push({ file, javascript: facts.javascript, diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index dd0f58e97d..92f8a36f85 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -98,10 +98,14 @@ function* buildJavaScriptArtifactGraphInputSteps( const applicationOwnershipUnknownContainers = new Set(); const applicationModuleGapSources = new Map>(); const applicationModuleReferencesRetained = new Set(); + let ipcSourceGap = false; const recordModuleGap = (file: { path: string; container_sha256: string; + kind?: string; }) => { + if (file.kind === "javascript" || file.kind === undefined) + ipcSourceGap = true; const container = applicationContainerIdentity(fileSet, file); const sources = applicationModuleGapSources.get(container) ?? new Set(); @@ -123,11 +127,24 @@ function* buildJavaScriptArtifactGraphInputSteps( (!("text" in file) || !file.text.included) ) recordModuleGap(file); + // Unread HTML can conceal inline scripts with Electron IPC calls. + if (file.kind === "html" && (!("text" in file) || !file.text.included)) + ipcSourceGap = true; } } // Omitted module relationships can hide another launch owner. Do not expand // retained facts beyond the projection guard merely to recover ownership. for (const analyzed of analysis.files) { + if ( + analyzed.file.kind === "javascript" && + (analyzed.javascript === null || + analyzed.javascript.analysis_coverage === undefined || + analyzed.javascript.analysis_coverage.examination + .electron_call_paths_limited || + analyzed.javascript.analysis_coverage.examination + .semantic_resource_limits.length > 0) + ) + ipcSourceGap = true; if ( analyzed.application_projection_failure !== undefined || analyzed.analysis_failure !== undefined || @@ -198,8 +215,19 @@ function* buildJavaScriptArtifactGraphInputSteps( yield* addJavaScriptModuleRelationshipsSteps(projectionContext); const findingLimitations = yield* addJavaScriptStaticFindingsSteps(projectionContext); - const ipc = createElectronIpcContext(analysis, (file) => - applicationRootPath(projectionContext, file), + const coverage = graphCoverage(context); + // Graph-wide partial coverage can describe unrelated dynamic values or + // source maps. Qualify IPC pairing by omitted JavaScript sources instead: + // their hidden handlers could compete with an observed match. + const ipc = createElectronIpcContext( + projectionContext.analysis, + (file) => applicationRootPath(projectionContext, file), + snapshot.coverage.status === "complete" && + !ipcSourceGap && + analysis.html_inline_scripts.length === 0 && + !fileSet.unavailable_relevant_files.some( + ({ kind }) => kind === undefined, + ), ); addElectronBoundaries(projectionContext, ipc); yield; @@ -207,10 +235,9 @@ function* buildJavaScriptArtifactGraphInputSteps( yield; addJavaScriptSourceMapOriginals(projectionContext); yield; - const coverage = graphCoverage(context); return { electronSummary: summarizeElectronBoundaries( - analysis, + projectionContext.analysis, ipc, context.directoryPaths, ), diff --git a/src/domain/javascript/javascriptApplicationGraphSchemas.ts b/src/domain/javascript/javascriptApplicationGraphSchemas.ts index b274d6381f..7dc521194c 100644 --- a/src/domain/javascript/javascriptApplicationGraphSchemas.ts +++ b/src/domain/javascript/javascriptApplicationGraphSchemas.ts @@ -11,6 +11,10 @@ import { prefixedDigestSchema } from "../digests.js"; const boundedTextSchema = z.string().min(1); +/** An observed IPC channel match whose uniqueness is not established. */ +export const IPC_COVERAGE_LIMITED_PAIRING_BASIS = + "observed-literal-channel-incomplete-coverage"; + /** Node kinds admitted by JavaScript Application Graph. */ export const JAVASCRIPT_APPLICATION_NODE_KINDS = [ "package", diff --git a/src/domain/javascript/javascriptApplicationVersionItems.ts b/src/domain/javascript/javascriptApplicationVersionItems.ts index f94de042ce..034bda717c 100644 --- a/src/domain/javascript/javascriptApplicationVersionItems.ts +++ b/src/domain/javascript/javascriptApplicationVersionItems.ts @@ -6,6 +6,7 @@ import type { ApplicationEdge, ApplicationNode, } from "./javascriptApplicationGraphSchemas.js"; +import { IPC_COVERAGE_LIMITED_PAIRING_BASIS } from "./javascriptApplicationGraphSchemas.js"; import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js"; import { javaScriptFileOccurrenceSchema } from "./javascriptArtifactFiles.js"; import { applicationEndpointCorrespondence } from "./javascriptApplicationEndpointCorrespondence.js"; @@ -54,6 +55,7 @@ interface IndexedRelationship { readonly direction: "in" | "out"; readonly relation: ApplicationEdge["relation"]; readonly neighborId: string; + readonly coverageLimited: boolean; } interface RelationshipIndex { @@ -180,6 +182,7 @@ const matchedItem = ( ? "changed" : complete && !relationships.unresolved && + !relationships.coverageLimited && !endpointTargetUncertain && !sourceAnalysisUncertain ? "unchanged" @@ -218,6 +221,11 @@ const matchedItem = ( "Relationship target identity remains unresolved; equal node kinds do not establish cross-version alignment.", ] : []), + ...(relationships.coverageLimited + ? [ + "Incomplete source coverage leaves some candidate relationships unconfirmed.", + ] + : []), ], }; return itemWithId(semantic); @@ -608,16 +616,20 @@ const indexRelationships = ( else existing.push(relationship); }; for (const edge of graph.edges) { + const coverageLimited = + edge.properties.pairing_basis === IPC_COVERAGE_LIMITED_PAIRING_BASIS; add(edge.source_node_id, { direction: "out", relation: edge.relation, neighborId: edge.target_node_id, + coverageLimited, }); if (edge.target_node_id !== edge.source_node_id) add(edge.target_node_id, { direction: "in", relation: edge.relation, neighborId: edge.source_node_id, + coverageLimited, }); } return { @@ -630,6 +642,7 @@ const indexRelationships = ( interface RelationshipComparison { readonly different: boolean; readonly unresolved: boolean; + readonly coverageLimited: boolean; } const compareRelationships = ( @@ -640,41 +653,95 @@ const compareRelationships = ( const right = relationshipFacts(pair.right, "right", context); return { different: - left.paired !== right.paired || - left.unpairedShape !== right.unpairedShape, + unexplainedRelationshipDifference( + left.paired, + right.paired, + left.possiblePaired, + right.possiblePaired, + ) || + unexplainedRelationshipDifference( + left.unpairedShape, + right.unpairedShape, + left.possibleUnpairedShape, + right.possibleUnpairedShape, + ), unresolved: left.unresolved || right.unresolved, + coverageLimited: left.coverageLimited || right.coverageLimited, }; }; +/** A candidate can explain a missing counterpart but cannot prove a change. */ +const unexplainedRelationshipDifference = ( + left: readonly string[], + right: readonly string[], + leftPossible: readonly string[], + rightPossible: readonly string[], +): boolean => { + const counts = (values: readonly string[]) => { + const result = new Map(); + for (const value of values) result.set(value, (result.get(value) ?? 0) + 1); + return result; + }; + const leftCounts = counts(left); + const rightCounts = counts(right); + const leftCandidateCounts = counts(leftPossible); + const rightCandidateCounts = counts(rightPossible); + for (const key of new Set([...leftCounts.keys(), ...rightCounts.keys()])) { + const difference = (leftCounts.get(key) ?? 0) - (rightCounts.get(key) ?? 0); + if ( + difference > (rightCandidateCounts.get(key) ?? 0) || + -difference > (leftCandidateCounts.get(key) ?? 0) + ) + return true; + } + return false; +}; + const relationshipFacts = ( node: ApplicationNode, side: "left" | "right", context: ItemContext, ): { - readonly paired: string; - readonly unpairedShape: string; + readonly paired: readonly string[]; + readonly unpairedShape: readonly string[]; + readonly possiblePaired: readonly string[]; + readonly possibleUnpairedShape: readonly string[]; readonly unresolved: boolean; + readonly coverageLimited: boolean; } => { const { nodeById, adjacency } = side === "left" ? context.leftRelationships : context.rightRelationships; const paired = side === "left" ? context.pairByLeft : context.pairByRight; const matched: string[] = []; const unpaired: string[] = []; - for (const { direction, relation, neighborId } of adjacency.get( - node.node_id, - ) ?? []) { + const possibleMatched: string[] = []; + const possibleUnpaired: string[] = []; + let coverageLimited = false; + for (const { + direction, + relation, + neighborId, + coverageLimited: candidate, + } of adjacency.get(node.node_id) ?? []) { const mapped = paired.get(neighborId); - if (mapped !== undefined) - matched.push(`${direction}\0${relation}\0${mapped}`); - else - unpaired.push( + if (candidate) coverageLimited = true; + if (mapped !== undefined) { + (candidate ? possibleMatched : matched).push( + `${direction}\0${relation}\0${mapped}`, + ); + } else { + (candidate ? possibleUnpaired : unpaired).push( `${direction}\0${relation}\0${nodeById.get(neighborId)?.kind ?? "unknown"}`, ); + } } return { - paired: canonicalJson(matched.sort(compareUnicodeCodePoints)), - unpairedShape: canonicalJson(unpaired.sort(compareUnicodeCodePoints)), - unresolved: unpaired.length > 0, + paired: matched, + unpairedShape: unpaired, + possiblePaired: possibleMatched, + possibleUnpairedShape: possibleUnpaired, + unresolved: unpaired.length > 0 || possibleUnpaired.length > 0, + coverageLimited, }; }; diff --git a/src/domain/javascript/javascriptApplicationVersionRelocation.test.ts b/src/domain/javascript/javascriptApplicationVersionRelocation.test.ts index 75fac10dc5..76af18f2dd 100644 --- a/src/domain/javascript/javascriptApplicationVersionRelocation.test.ts +++ b/src/domain/javascript/javascriptApplicationVersionRelocation.test.ts @@ -9,6 +9,7 @@ import { createJavaScriptApplicationGraph, type JavaScriptApplicationGraph, } from "./javascriptApplicationGraph.js"; +import { IPC_COVERAGE_LIMITED_PAIRING_BASIS } from "./javascriptApplicationGraphSchemas.js"; import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js"; import { applicationVersionComparisonResultSchema } from "./javascriptApplicationVersionComparisonSchemas.js"; @@ -20,6 +21,9 @@ const graph = ( content?: string; permission?: string; relation?: "handles" | "invokes"; + candidate?: boolean; + candidateExtra?: boolean; + noEdge?: boolean; } = {}, ) => { const evidence = { @@ -72,15 +76,32 @@ const graph = ( schema: "JavaScriptApplicationGraph", root_node_ids: [module.node_id], nodes: [module, channel], - edges: [ - createJavaScriptApplicationEdge({ - source_node_id: module.node_id, - target_node_id: channel.node_id, - relation: options.relation ?? "handles", - properties: {}, - evidence, - }), - ], + edges: options.noEdge + ? [] + : [ + createJavaScriptApplicationEdge({ + source_node_id: module.node_id, + target_node_id: channel.node_id, + relation: options.relation ?? "handles", + properties: options.candidate + ? { pairing_basis: IPC_COVERAGE_LIMITED_PAIRING_BASIS } + : {}, + evidence, + }), + ...(options.candidateExtra + ? [ + createJavaScriptApplicationEdge({ + source_node_id: module.node_id, + target_node_id: channel.node_id, + relation: "handles" as const, + properties: { + pairing_basis: IPC_COVERAGE_LIMITED_PAIRING_BASIS, + }, + evidence, + }), + ] + : []), + ], coverage, limitations: coverage.status === "complete" @@ -114,6 +135,55 @@ const partialCoverage: JavaScriptApplicationGraph["coverage"] = { omitted_count: null, }; +it("does not report a candidate-only IPC edge as a definite relationship change", () => { + const result = compare( + graph("a".repeat(64), 10, partialCoverage, { candidate: true }), + graph("b".repeat(64), 50, partialCoverage, { + noEdge: true, + permission: "changed", + }), + ); + const channel = result.items.find((item) => item.node_kind === "ipc-channel"); + expect(channel?.status).toBe("changed"); + expect(channel?.dimensions).toContain("properties"); + expect(channel?.dimensions).not.toContain("relationships"); + expect(channel?.limitations.length).toBeGreaterThan(0); +}); + +it.each([false, true])( + "keeps a candidate and a definite equivalent uncertain when reversed: %s", + (reverse) => { + const candidate = graph("a".repeat(64), 10, partialCoverage, { + candidate: true, + }); + const definite = graph("b".repeat(64), 50, partialCoverage, { + permission: "changed", + }); + const result = reverse + ? compare(definite, candidate) + : compare(candidate, definite); + const channel = result.items.find( + (item) => item.node_kind === "ipc-channel", + ); + expect(channel?.status).toBe("changed"); + expect(channel?.dimensions).not.toContain("relationships"); + }, +); + +it("retains an independent definite relationship change beside candidate edges", () => { + const result = compare( + graph("a".repeat(64), 10, partialCoverage, { candidateExtra: true }), + graph("b".repeat(64), 50, partialCoverage, { + relation: "invokes", + candidateExtra: true, + permission: "changed", + }), + ); + const channel = result.items.find((item) => item.node_kind === "ipc-channel"); + expect(channel?.status).toBe("changed"); + expect(channel?.dimensions).toContain("relationships"); +}); + it("keeps relocated exact modules and inferred channels unchanged while retaining both coordinates", () => { const left = graph("a".repeat(64), 10, completeCoverage), right = graph("b".repeat(64), 50, completeCoverage); diff --git a/src/domain/javascript/javascriptSourceAnalysis.ts b/src/domain/javascript/javascriptSourceAnalysis.ts index c925199756..9a588c7f32 100644 --- a/src/domain/javascript/javascriptSourceAnalysis.ts +++ b/src/domain/javascript/javascriptSourceAnalysis.ts @@ -9,12 +9,20 @@ import type { JavaScriptArtifactFile } from "./javascriptArtifactFiles.js"; import type { JavaScriptStaticAnalysis } from "./javascriptStaticAnalysisTypes.js"; import type { JavaScriptModuleSemanticIr } from "./javascriptModuleSemanticIr.js"; +/** An earlier static projection displaced to retain later static observations. */ +export interface JavaScriptRevokedStaticProjection { + /** Zero-based source-analysis call index within this worker checkout. */ + readonly sourceIndex: number; + readonly error: AnalysisResourceConstraintError; +} + /** Portable facts collected from one inert source. */ export interface JavaScriptAnalyzedSource { readonly javascript: JavaScriptStaticAnalysis; readonly module: JavaScriptModuleSemanticIr | null; readonly projection: JavaScriptSemanticFileProjection | null; readonly applicationProjectionFailure?: AnalysisResourceConstraintError; + readonly revokedStaticProjections?: readonly JavaScriptRevokedStaticProjection[]; } /** Execution failure alongside facts already collected by the consumer. */ @@ -24,6 +32,7 @@ export interface JavaScriptSourceAnalysisFailure { readonly module: JavaScriptModuleSemanticIr | null; readonly projection: JavaScriptSemanticFileProjection | null; readonly applicationProjectionFailure?: AnalysisResourceConstraintError; + readonly revokedStaticProjections?: readonly JavaScriptRevokedStaticProjection[]; } /** Operational choices for source analysis, independent of transport framing. */ diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index 7b15baf7a5..f8436d297b 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -21,6 +21,7 @@ import type { JavaScriptAnalyzedSource, JavaScriptSourceAnalysisFailure, JavaScriptSourceAnalysisLimits, + JavaScriptRevokedStaticProjection, } from "../../domain/javascript/javascriptSourceAnalysis.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { @@ -119,6 +120,13 @@ export class JavaScriptAnalysisWorker { #phase = "startup"; #lastFailure: AnalysisError | undefined; #estimatedRetainedBytes = 0; + #staticProjectionReservations: { + sourceIndex: number; + path: string; + bytes: number; + }[] = []; + #revokedStaticProjections: JavaScriptRevokedStaticProjection[] = []; + #nextSourceIndex = 0; #applicationProjectionFailure: AnalysisResourceConstraintError | undefined; #parentCapacityExhausted = false; #sourcesRemaining: number | undefined; @@ -230,6 +238,9 @@ export class JavaScriptAnalysisWorker { ); this.#sourcesRemaining = sourceCount; this.#estimatedRetainedBytes = 0; + this.#staticProjectionReservations = []; + this.#revokedStaticProjections = []; + this.#nextSourceIndex = 0; this.#parentCapacityExhausted = false; this.#applicationProjectionFailure = undefined; this.#refreshResultBudget(); @@ -270,8 +281,10 @@ export class JavaScriptAnalysisWorker { let module: JavaScriptModuleSemanticIr | null = null; let projection: JavaScriptSemanticFileProjection | null = null; const id = randomUUID(); + const sourceIndex = this.#nextSourceIndex++; this.#applicationProjectionFailure = undefined; this.#parentCapacityExhausted = false; + this.#revokedStaticProjections = []; if (this.#sourcesRemaining !== undefined) this.#sourcesRemaining = Math.max(0, this.#sourcesRemaining - 1); let deadline: ProviderStartupDeadline | undefined; @@ -358,7 +371,12 @@ export class JavaScriptAnalysisWorker { OPERATION, "JavaScript worker emitted a duplicate static checkpoint", ); - javascript = await this.#readCheckpoint(id, file.path, response); + javascript = await this.#readCheckpoint( + id, + file.path, + sourceIndex, + response, + ); if (this.#parentCapacityExhausted) throw new AnalysisResourceConstraintError( OPERATION, @@ -519,7 +537,7 @@ export class JavaScriptAnalysisWorker { this.#runtime.rootPath !== undefined ) { try { - javascript = await this.#readCheckpoint(id, file.path); + javascript = await this.#readCheckpoint(id, file.path, sourceIndex); } catch (checkpointCause: unknown) { void checkpointCause; } @@ -573,15 +591,34 @@ export class JavaScriptAnalysisWorker { if (result.ok) this.#lastFailure = error; result = err({ error, javascript, module, projection }); } - if (this.#applicationProjectionFailure !== undefined) + if ( + this.#applicationProjectionFailure !== undefined || + this.#revokedStaticProjections.length > 0 + ) return result.ok ? ok({ ...result.value, - applicationProjectionFailure: this.#applicationProjectionFailure, + ...(this.#applicationProjectionFailure === undefined + ? {} + : { + applicationProjectionFailure: + this.#applicationProjectionFailure, + }), + ...(this.#revokedStaticProjections.length === 0 + ? {} + : { revokedStaticProjections: this.#revokedStaticProjections }), }) : err({ ...result.error, - applicationProjectionFailure: this.#applicationProjectionFailure, + ...(this.#applicationProjectionFailure === undefined + ? {} + : { + applicationProjectionFailure: + this.#applicationProjectionFailure, + }), + ...(this.#revokedStaticProjections.length === 0 + ? {} + : { revokedStaticProjections: this.#revokedStaticProjections }), }); return result; } @@ -709,6 +746,7 @@ export class JavaScriptAnalysisWorker { async #readCheckpoint( id: string, sourcePath: string, + sourceIndex: number, expected?: { readonly bytes: number; readonly sha256: string }, ): Promise { const rootPath = this.#runtime.rootPath; @@ -746,8 +784,52 @@ export class JavaScriptAnalysisWorker { const decoded = safeParseUtf8Json(checkpoint); if (!decoded.ok) throw decoded.cause; const facts = javaScriptWorkerStaticAnalysisSchema.parse(decoded.value); - const remaining = this.#remainingTotalResultBudget(); const retainedBytes = bytes * STATIC_CHECKPOINT_RETENTION_EXPANSION; + // Application relationships are materialized only after all source files + // have been examined. Reclaim their pending reservations to keep room for + // later static observations, without relaxing the actual retained bound. + const needed = + retainedBytes - (this.#resultBudgetBytes - this.#estimatedRetainedBytes); + const liveAvailable = + this.#remainingParentHeapBytes() - + 24 * JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES; + if (needed > 0 && retainedBytes <= liveAvailable) { + // Plan all revocations first so an insufficient set leaves the earlier + // relationships intact rather than sacrificing them for a failed read. + let start = this.#staticProjectionReservations.length; + let reclaimable = 0; + while (start > 0 && reclaimable < needed) { + start--; + reclaimable += this.#staticProjectionReservations[start]!.bytes; + } + if (reclaimable >= needed) { + const revoked = this.#staticProjectionReservations.splice(start); + for (const reservation of revoked) { + this.#estimatedRetainedBytes -= reservation.bytes; + this.#revokedStaticProjections.push({ + sourceIndex: reservation.sourceIndex, + error: new AnalysisResourceConstraintError( + OPERATION, + "memory", + `Expanded application relationships for ${reservation.path} were omitted to retain static facts for later sources`, + { parent_result_budget_bytes: this.#resultBudgetBytes }, + { + remediationAction: `Read the retained static facts or analyze fewer source files. ${JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION}`, + }, + ), + }); + } + } + } + // Use the same live-heap observation for the reclamation decision and + // admission so a second read cannot reject after revocations commit. + const remaining = Math.max( + 0, + Math.min( + this.#resultBudgetBytes - this.#estimatedRetainedBytes, + liveAvailable, + ), + ); this.#parentCapacityExhausted = retainedBytes > remaining; this.#estimatedRetainedBytes += retainedBytes; const expandedBytes = estimateJavaScriptStaticApplicationProjection( @@ -756,9 +838,14 @@ export class JavaScriptAnalysisWorker { sourcePath, ); const availableResultBudget = this.#remainingResultBudget(); - if (expandedBytes <= availableResultBudget) + if (expandedBytes <= availableResultBudget) { this.#estimatedRetainedBytes += expandedBytes; - else + this.#staticProjectionReservations.push({ + sourceIndex, + path: sourcePath, + bytes: expandedBytes, + }); + } else this.#applicationProjectionFailure = new AnalysisResourceConstraintError( OPERATION, "memory", diff --git a/tests/boundary/cli/electronWrappedCallPaths.test.ts b/tests/boundary/cli/electronWrappedCallPaths.test.ts index 7e87395ec1..cafbc548e8 100644 --- a/tests/boundary/cli/electronWrappedCallPaths.test.ts +++ b/tests/boundary/cli/electronWrappedCallPaths.test.ts @@ -866,6 +866,16 @@ cliTest( expect(joins.map((edge) => edge.properties.channel)).toEqual([ "shallow:run", ]); + expect(joins[0]?.properties).toMatchObject({ + pairing_basis: "observed-literal-channel-incomplete-coverage", + resolution: "candidate", + }); + expect(application.summary.ipc).toMatchObject({ + paired_renderer_transmissions: 0, + }); + expect( + application.summary.ipc.ambiguous_renderer_transmissions, + ).toBeGreaterThan(0); expect( graph.nodes .flatMap((node) => node.observations) @@ -878,6 +888,75 @@ cliTest( }, ); +cliTest( + "keeps a wrapper match provisional when a limited path can hide a competing handler", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-limited-handler-"); + let deepMember = + '() => { const register = (ipc) => ipc.on("shared:run", () => {}); register(e.ipcMain); }'; + for (let index = 0; index < 129; index += 1) + deepMember = `{ level${index}: ${deepMember} }`; + await writeFixtureFiles(root, { + "preload.js": `const e=require("electron"); + e.contextBridge.exposeInMainWorld("desktop", { + deep: ${deepMember}, + shallow: () => e.ipcRenderer.send("shared:run") + });`, + "main.js": `const e=require("electron"); + e.ipcMain.on("shared:run", event => event.sender.copy());`, + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + const { normalized_result: application } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(output.json); + expect(application.summary.ipc.paired_renderer_transmissions).toBe(0); + expect( + application.summary.ipc.ambiguous_renderer_transmissions, + ).toBeGreaterThan(0); + const channelEdges = application.graph.edges.filter( + (edge) => edge.properties.channel === "shared:run", + ); + expect( + channelEdges.some( + (edge) => + edge.evidence.extractor.operation === "pair-ipc-literal-channel", + ), + ).toBe(false); + const candidateJoin = channelEdges.find( + (edge) => + edge.evidence.extractor.operation === "join-electron-wrapper-paths", + ); + expect(candidateJoin?.properties).toMatchObject({ + pairing_basis: "observed-literal-channel-incomplete-coverage", + resolution: "candidate", + }); + expect(candidateJoin?.evidence.limitations?.length).toBeGreaterThan(0); + + // A known second handler is not merely a hidden-coverage uncertainty. + await writeFile( + join(root, "main.js"), + 'const e=require("electron"); e.ipcMain.on("shared:run", () => {}); e.ipcMain.on("shared:run", () => {});', + ); + const competing = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(competing.exitCode, competing.stderr).toBe(0); + const { normalized_result: competingApplication } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(competing.json); + expect( + competingApplication.graph.edges.some( + (edge) => + edge.evidence.extractor.operation === "join-electron-wrapper-paths" && + edge.properties.channel === "shared:run", + ), + ).toBe(false); + }, +); + cliTest( "preserves empty bridge member segments in nested paths", async ({ cli }) => { diff --git a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts index 43dce81474..0cb1f7f721 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -24,8 +24,10 @@ import { immutableArtifactGraph } from "../../../fixtures/javascriptArtifactGrap import { createJavaScriptSemanticGraphProjection } from "../../../../src/application/javascript/JavaScriptSemanticGraphBuilder.js"; import type { JavaScriptModuleSemanticIr } from "../../../../src/domain/javascript/javascriptModuleSemanticIr.js"; import type { JavaScriptStaticAnalysis } from "../../../../src/domain/javascript/javascriptStaticAnalysisTypes.js"; +import { analyzeJavaScriptStaticSource } from "../../../../src/domain/javascript/javascriptStaticAnalysis.js"; import type { JavaScriptArtifactFile } from "../../../../src/domain/javascript/javascriptArtifactFiles.js"; import { completeJavaScriptAnalysisSteps } from "../../../../src/application/javascript/JavaScriptAnalysisControl.js"; +import { buildImmutableJavaScriptArtifactGraphSteps } from "../../../../src/application/javascript/JavaScriptArtifactGraphBuilder.js"; import { writeJavaScriptAnalysisTransfer, readJavaScriptAnalysisTransfer, @@ -383,6 +385,421 @@ it("keeps imported loadFile ownership unknown across an application projection o } }); +it("keeps known IPC pairings when a different Electron value is dynamic", async () => { + const root = await createTestTempDirectory("rea-partial-ipc-"); + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ main: "main.js" }), + "main.js": + 'const { BrowserWindow, ipcMain } = require("electron"); new BrowserWindow({ webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true } }); ipcMain.handle("rea:ping", () => true);', + "preload.js": + 'const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("rea", { ping: () => ipcRenderer.invoke("rea:ping") });', + "hidden.js": "export const hidden = true;", + "unrelated.js.map": JSON.stringify({ + version: 3, + sources: [], + names: [], + mappings: "", + }), + }); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + const { analysis } = await analyzeArtifactFiles(files); + expect( + analysis.files.find(({ file }) => file.path === "main.js")?.javascript, + ).toMatchObject({ parse_status: "partial", parse_error_count: 0 }); + const { graph, electronSummary } = await completeJavaScriptAnalysisSteps( + buildImmutableJavaScriptArtifactGraphSteps(snapshot, files, analysis), + ); + expect(graph.coverage.status).toBe("partial"); + expect(electronSummary.ipc).toMatchObject({ + paired_renderer_transmissions: 1, + ambiguous_renderer_transmissions: 0, + }); + + // An unselected source map cannot hide an IPC handler. + const withoutMap = await readJavaScriptArtifactFiles( + reader, + snapshot, + undefined, + ["package.json", "main.js", "preload.js", "hidden.js"], + ); + const withoutMapAnalysis = (await analyzeArtifactFiles(withoutMap)) + .analysis; + const nonJsResult = await completeJavaScriptAnalysisSteps( + buildImmutableJavaScriptArtifactGraphSteps( + snapshot, + withoutMap, + withoutMapAnalysis, + ), + ); + expect(nonJsResult.graph.coverage.status).toBe("partial"); + expect(nonJsResult.electronSummary.ipc.paired_renderer_transmissions).toBe( + 1, + ); + + // A source outside the requested scope can hide a competing IPC handler, + // even when the observed sender and handler have a known package owner. + const selected = await readJavaScriptArtifactFiles( + reader, + snapshot, + undefined, + ["package.json", "main.js", "preload.js"], + ); + const selectedAnalysis = (await analyzeArtifactFiles(selected)).analysis; + const selectionResult = await completeJavaScriptAnalysisSteps( + buildImmutableJavaScriptArtifactGraphSteps( + snapshot, + selected, + selectedAnalysis, + ), + ); + expect(selectionResult.electronSummary.ipc).toMatchObject({ + paired_renderer_transmissions: 0, + ambiguous_renderer_transmissions: 1, + }); + + const hidden = files.files.find(({ path }) => path === "hidden.js"); + if (hidden === undefined) throw new Error("Missing hidden source fixture"); + const unavailable = { + ...files, + files: files.files.filter(({ path }) => path !== hidden.path), + unavailable_relevant_files: [ + ...files.unavailable_relevant_files, + { + path: hidden.path, + container_sha256: hidden.container_sha256, + sha256: hidden.sha256, + bytes: hidden.bytes, + inventory_artifact_id: hidden.inventory_artifact_id, + kind: hidden.kind, + }, + ], + }; + const unavailableAnalysis = { + ...analysis, + files: analysis.files.filter(({ file }) => file.path !== hidden.path), + }; + const unavailableResult = await completeJavaScriptAnalysisSteps( + buildImmutableJavaScriptArtifactGraphSteps( + snapshot, + unavailable, + unavailableAnalysis, + ), + ); + expect(unavailableResult.electronSummary.ipc).toMatchObject({ + paired_renderer_transmissions: 0, + ambiguous_renderer_transmissions: 1, + }); + } finally { + await reader.close(); + } +}); + +it("keeps IPC pairings uncertain when inline HTML scripts are not examined", async () => { + const root = await createTestTempDirectory("rea-inline-ipc-"); + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ main: "main.js" }), + "main.js": + 'const { ipcMain } = require("electron"); ipcMain.handle("rea:ping", () => true);', + "preload.js": + 'const { ipcRenderer } = require("electron"); ipcRenderer.invoke("rea:ping");', + "index.html": + '', + }); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + const { analysis } = await analyzeArtifactFiles(files); + expect(analysis.html_inline_scripts.length).toBeGreaterThan(0); + const result = await completeJavaScriptAnalysisSteps( + buildImmutableJavaScriptArtifactGraphSteps(snapshot, files, analysis), + ); + expect(result.electronSummary.ipc).toMatchObject({ + paired_renderer_transmissions: 0, + ambiguous_renderer_transmissions: 1, + }); + + const selected = await readJavaScriptArtifactFiles( + reader, + snapshot, + undefined, + ["package.json", "main.js", "preload.js"], + ); + const selectedAnalysis = (await analyzeArtifactFiles(selected)).analysis; + const selectedResult = await completeJavaScriptAnalysisSteps( + buildImmutableJavaScriptArtifactGraphSteps( + snapshot, + selected, + selectedAnalysis, + ), + ); + expect(selectedResult.electronSummary.ipc).toMatchObject({ + paired_renderer_transmissions: 0, + ambiguous_renderer_transmissions: 1, + }); + } finally { + await reader.close(); + } +}); + +it("keeps IPC pairings uncertain when source examination is incomplete", async () => { + const root = await createTestTempDirectory("rea-partial-ipc-examination-"); + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ main: "main.js" }), + "main.js": + 'const { ipcMain } = require("electron"); ipcMain.handle("rea:ping", () => true);', + "preload.js": + 'const { ipcRenderer } = require("electron"); ipcRenderer.invoke("rea:ping");', + "hidden.js": "export const hidden = true;", + }); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + const { analysis } = await analyzeArtifactFiles(files); + const complete = await completeJavaScriptAnalysisSteps( + buildImmutableJavaScriptArtifactGraphSteps(snapshot, files, analysis), + ); + expect(complete.electronSummary.ipc.paired_renderer_transmissions).toBe(1); + const hidden = files.files.find(({ path }) => path === "hidden.js"); + if (hidden === undefined) throw new Error("Missing hidden source fixture"); + const hiddenSource = analysis.files.find( + ({ file }) => file.path === hidden.path, + ); + if (hiddenSource?.javascript?.analysis_coverage === undefined) + throw new Error("Missing hidden source coverage"); + const hiddenStatic = hiddenSource.javascript; + const { analysis_coverage: _ignoredCoverage, ...historicalStatic } = + hiddenStatic; + const limitedStatic = { + ...hiddenStatic, + analysis_coverage: { + ...hiddenStatic.analysis_coverage!, + examination: { + ...hiddenStatic.analysis_coverage!.examination, + status: "limited" as const, + semantic_resource_limits: ["alias-selections" as const], + }, + }, + }; + const wrapperLimitedStatic = { + ...hiddenStatic, + analysis_coverage: { + ...hiddenStatic.analysis_coverage!, + examination: { + ...hiddenStatic.analysis_coverage!.examination, + status: "limited" as const, + electron_call_paths_limited: true, + }, + }, + }; + for (const javascript of [ + historicalStatic, + limitedStatic, + wrapperLimitedStatic, + ]) { + const partialAnalysis = { + ...analysis, + files: analysis.files.map((item) => + item.file.path === hidden.path ? { ...item, javascript } : item, + ), + }; + const partialResult = await completeJavaScriptAnalysisSteps( + buildImmutableJavaScriptArtifactGraphSteps( + snapshot, + files, + partialAnalysis, + ), + ); + expect(partialResult.electronSummary.ipc).toMatchObject({ + paired_renderer_transmissions: 0, + ambiguous_renderer_transmissions: 1, + }); + } + + const failedAnalyses = [ + { ...hiddenSource, javascript: null, semantic: null }, + { + ...hiddenSource, + analysis_failure: { + scope: { + operation: "analyze-javascript-source" as const, + authority: "ast-static-analysis" as const, + }, + status: "interrupted" as const, + reason: "fixture source analysis failed", + error: null, + limits: null, + }, + }, + { + ...hiddenSource, + javascript: { + ...hiddenStatic, + parse_status: "partial" as const, + parse_error_count: 1, + analysis_coverage: { + ...hiddenStatic.analysis_coverage!, + syntax: { status: "recovered" as const, error_count: 1 }, + }, + }, + }, + ]; + for (const failed of failedAnalyses) { + const partialAnalysis = { + ...analysis, + files: analysis.files.map((item) => + item.file.path === hidden.path ? failed : item, + ), + }; + const partialResult = await completeJavaScriptAnalysisSteps( + buildImmutableJavaScriptArtifactGraphSteps( + snapshot, + files, + partialAnalysis, + ), + ); + expect(partialResult.electronSummary.ipc).toMatchObject({ + paired_renderer_transmissions: 0, + ambiguous_renderer_transmissions: 1, + }); + } + + // An incomplete inventory may hide another JavaScript source altogether. + const incomplete = { + ...snapshot, + coverage: { ...snapshot.coverage, status: "unknown" as const }, + }; + const inventoryResult = await completeJavaScriptAnalysisSteps( + buildImmutableJavaScriptArtifactGraphSteps(incomplete, files, analysis), + ); + expect(inventoryResult.electronSummary.ipc).toMatchObject({ + paired_renderer_transmissions: 0, + ambiguous_renderer_transmissions: 1, + }); + } finally { + await reader.close(); + } +}); + +it("does not pair IPC when the Electron wrapper walk hits its depth limit", async () => { + const root = await createTestTempDirectory("rea-limited-electron-ipc-"); + const source = (depth: number) => { + const nested = + Array.from({ length: depth }, (_, index) => `level${index}:{`).join("") + + 'deep:()=>{const register=(ipc)=>ipc.handle("rea:ping",()=>false);register(e.ipcMain)}' + + "}".repeat(depth); + return `const e=require("electron");e.contextBridge.exposeInMainWorld("desktop",{${nested}});`; + }; + // Without the traversal limit, the same wrapper reveals a competing handler. + const shallow = analyzeJavaScriptStaticSource(source(2)); + expect(shallow.electron.ipc).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + side: "main", + operation: "handle", + channel: "rea:ping", + }), + ]), + ); + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ main: "main.js" }), + "main.js": + 'const { ipcMain } = require("electron"); ipcMain.handle("rea:ping", () => true);', + "preload.js": + 'const { ipcRenderer } = require("electron"); ipcRenderer.invoke("rea:ping");', + "limited.js": source(130), + }); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + const { analysis } = await analyzeArtifactFiles(files); + const limited = analysis.files.find( + ({ file }) => file.path === "limited.js", + ); + expect(limited?.javascript?.analysis_coverage?.examination).toMatchObject({ + electron_call_paths_limited: true, + semantic_resource_limits: [], + }); + expect(limited?.javascript?.electron.ipc).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ channel: "rea:ping" }), + ]), + ); + const { electronSummary } = await completeJavaScriptAnalysisSteps( + buildImmutableJavaScriptArtifactGraphSteps(snapshot, files, analysis), + ); + expect(electronSummary.ipc).toMatchObject({ + paired_renderer_transmissions: 0, + }); + expect( + electronSummary.ipc.ambiguous_renderer_transmissions, + ).toBeGreaterThanOrEqual(1); + } finally { + await reader.close(); + } +}); + +it("does not expand or pair IPC from a revoked application projection", async () => { + const root = await createTestTempDirectory("rea-revoked-ipc-"); + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ main: "main.cjs" }), + "main.cjs": + 'const { ipcMain } = require("electron"); ipcMain.handle("ping", () => 1);', + "preload.cjs": + 'const { ipcRenderer } = require("electron"); ipcRenderer.invoke("ping");', + }); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + const { analysis } = await analyzeArtifactFiles(files); + const handler = analysis.files.find(({ file }) => file.path === "main.cjs"); + expect(handler?.javascript?.electron.ipc).toHaveLength(1); + const omitted = { + ...analysis, + files: analysis.files.map((file) => + file.file.path === "main.cjs" + ? { + ...file, + application_projection_failure: { + reason: "fixture projection revoked", + error: null, + limits: null, + }, + } + : file, + ), + }; + const { graph, electronSummary } = await completeJavaScriptAnalysisSteps( + buildImmutableJavaScriptArtifactGraphSteps(snapshot, files, omitted), + ); + expect(graph.coverage.status).toBe("partial"); + expect(electronSummary.ipc).toMatchObject({ + operations: 1, + main_handlers: 0, + paired_renderer_transmissions: 0, + ambiguous_renderer_transmissions: 1, + unpaired_literal_renderer_transmissions: 0, + }); + expect( + graph.nodes.some(({ observations }) => + observations.some( + ({ properties }) => + properties.application_projection_failure !== undefined && + properties.static_analysis !== undefined, + ), + ), + ).toBe(true); + } finally { + await reader.close(); + } +}); + it("keeps a loadFile owner unknown when a sibling manifest is unavailable in the inventory projection", async () => { const root = await createTestTempDirectory("rea-unavailable-app-owner-"); await writeFixtureFiles(root, { diff --git a/tests/boundary/javascript/javascriptStaticProjectionReclamation.test.ts b/tests/boundary/javascript/javascriptStaticProjectionReclamation.test.ts new file mode 100644 index 0000000000..ef1c99e458 --- /dev/null +++ b/tests/boundary/javascript/javascriptStaticProjectionReclamation.test.ts @@ -0,0 +1,245 @@ +import { createHash } from "node:crypto"; +import { expect, it, vi } from "vitest"; + +const heap = vi.hoisted(() => ({ + remainingBytes: 200 * 1024 * 1024, + readings: [] as number[], +})); +vi.mock("node:v8", async (load) => { + const actual = await load(); + return { + ...actual, + getHeapStatistics: () => { + const current = actual.getHeapStatistics(); + return { + ...current, + heap_size_limit: + current.used_heap_size + + (heap.readings.shift() ?? heap.remainingBytes), + }; + }, + }; +}); + +// Bound the parent-side maximum checkpoint reservation for this controlled +// test. The child still emits actual, digest-verified complete checkpoints. +vi.mock( + "../../../src/domain/javascript/javascriptAnalysisResourceControls.js", + async (load) => { + const actual = + await load< + typeof import("../../../src/domain/javascript/javascriptAnalysisResourceControls.js") + >(); + return { ...actual, JAVASCRIPT_STATIC_FACT_BYTES: 256 * 1024 }; + }, +); + +import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js"; +import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js"; +import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; +const file = (path: string, count: number): JavaScriptArtifactFile => { + const text = Array.from( + { length: count }, + (_, i) => `localStorage.setItem('k${i}', 'v');`, + ).join("\n"); + return { + path, + container_sha256: "a".repeat(64), + sha256: createHash("sha256").update(text).digest("hex"), + bytes: Buffer.byteLength(text), + inventory_artifact_id: path, + kind: "javascript", + unpacked: false, + text: { included: true, value: text }, + }; +}; +it("uses one live heap observation for revocation and static admission", async () => { + const run = async (changeHeapAt?: number) => { + const worker = new JavaScriptAnalysisWorker( + { heapMb: 512, timeoutMs: 30_000 }, + undefined, + 8, + ); + const results = []; + try { + for (let i = 0; i < 8; i++) { + const index = i; + results.push( + await worker.analyze( + file(`${i}.js`, i === 0 ? 50 : 500), + { nodes: 1, unknowns: 1 }, + undefined, + async (phase) => { + if (phase === "static" && index === changeHeapAt) + heap.readings = [200, 200, 192, 192].map( + (mib) => mib * 1024 * 1024, + ); + }, + ), + ); + heap.readings = []; + } + return results; + } finally { + heap.readings = []; + await worker.close(); + } + }; + const control = await run(); + const at = control.findIndex((result) => + (result.ok ? result.value : result.error).revokedStaticProjections?.some( + ({ sourceIndex }) => sourceIndex === 0, + ), + ); + expect(at).toBeGreaterThan(0); + const changed = await run(at); + const observed = changed[at]; + if (observed === undefined) throw new Error("Missing changed observation"); + const detail = observed.ok ? observed.value : observed.error; + expect( + detail.revokedStaticProjections?.map(({ sourceIndex }) => sourceIndex), + ).toContain(0); + if ( + !observed.ok && + observed.error.error instanceof AnalysisResourceConstraintError + ) + expect( + observed.error.error.reportedLimits?.parent_result_budget_exhausted, + ).not.toBe(true); +}); + +it("reclaims prior unmaterialized projections only when a complete static checkpoint would otherwise exhaust the result budget", async () => { + const worker = new JavaScriptAnalysisWorker( + { heapMb: 512, timeoutMs: 30_000 }, + undefined, + 8, + ); + const results = []; + try { + for (let i = 0; i < 8; i++) + results.push( + await worker.analyze(file(`${i}.js`, i === 0 ? 50 : 500), { + nodes: 1, + unknowns: 1, + }), + ); + const details = results.map((r) => (r.ok ? r.value : r.error)); + expect( + details.every((d) => d.javascript?.parse_status === "complete"), + ).toBe(true); + expect( + details.some( + (d) => + "error" in d && + d.error instanceof AnalysisResourceConstraintError && + d.error.reportedLimits?.parent_result_budget_exhausted === true, + ), + ).toBe(false); + expect( + details + .flatMap((d) => d.revokedStaticProjections ?? []) + .map((d) => d.sourceIndex), + ).toContain(0); + } finally { + await worker.close(); + } +}); + +it("keeps earlier projections when reclaiming them cannot admit the checkpoint", async () => { + const worker = new JavaScriptAnalysisWorker( + { heapMb: 512, timeoutMs: 30_000 }, + undefined, + 5, + ); + try { + const results = []; + for (let i = 0; i < 5; i++) + results.push( + await worker.analyze(file(`${i}.js`, i === 0 ? 10 : 1000), { + nodes: 1, + unknowns: 1, + }), + ); + const details = results.map((result) => + result.ok ? result.value : result.error, + ); + expect(results[0]?.ok).toBe(true); + expect(details[0]?.applicationProjectionFailure).toBeUndefined(); + expect( + details.flatMap((detail) => detail.revokedStaticProjections ?? []), + ).toEqual([]); + expect(results.at(-1)?.ok).toBe(false); + const last = results.at(-1); + if (last === undefined || last.ok) + throw new Error("Expected capacity refusal"); + expect(last.error.error).toMatchObject({ + reportedLimits: { parent_result_budget_exhausted: true }, + }); + } finally { + await worker.close(); + } +}); + +it("resets projection reservations and source indices when an idle worker is reused", async () => { + const worker = new JavaScriptAnalysisWorker( + { heapMb: 512, timeoutMs: 30_000 }, + undefined, + 1, + ); + try { + const first = await worker.analyze(file("previous.js", 10), { + nodes: 1, + unknowns: 1, + }); + expect(first.ok).toBe(true); + expect(worker.prepareForAnotherAnalysis(8)).toBe(true); + const results = []; + for (let i = 0; i < 7; i++) + results.push( + await worker.analyze(file(`new-${i}.js`, i === 0 ? 50 : 500), { + nodes: 1, + unknowns: 1, + }), + ); + const revocations = results.flatMap( + (r) => (r.ok ? r.value : r.error).revokedStaticProjections ?? [], + ); + expect(revocations.map((entry) => entry.sourceIndex)).toEqual([0]); + expect(revocations[0]?.error.message).toContain("new-0.js"); + expect(revocations[0]?.error.message).not.toContain("previous.js"); + } finally { + await worker.close(); + } +}); + +it("does not discard earlier projections when live heap, not reservations, blocks admission", async () => { + const worker = new JavaScriptAnalysisWorker( + { heapMb: 512, timeoutMs: 30_000 }, + undefined, + 2, + ); + try { + const first = await worker.analyze(file("first.js", 10), { + nodes: 1, + unknowns: 1, + }); + expect(first.ok).toBe(true); + const second = await worker.analyze( + file("second.js", 1000), + { nodes: 1, unknowns: 1 }, + undefined, + async (phase) => { + if (phase === "static") heap.remainingBytes = 192 * 1024 * 1024; + }, + ); + expect(second.ok).toBe(false); + if (second.ok) throw new Error("Expected live heap refusal"); + expect(second.error.error).toMatchObject({ + reportedLimits: { parent_result_budget_exhausted: true }, + }); + expect(second.error.revokedStaticProjections).toBeUndefined(); + } finally { + heap.remainingBytes = 200 * 1024 * 1024; + await worker.close(); + } +});