diff --git a/docs/design/limits-and-recovery.md b/docs/design/limits-and-recovery.md index 63134f404a..074f64d2b4 100644 --- a/docs/design/limits-and-recovery.md +++ b/docs/design/limits-and-recovery.md @@ -188,6 +188,7 @@ cannot partially apply. Clients coordinating writes can supply | `regex.matching_deadline_ms`, `regex.worker_heap_request_mib` | Next search / new regex worker | | `capture.decoder_old_generation_mib` | Next owned capture decoder | | `javascript.analysis_timeout_ms`, `javascript.max_heap_mb` | Next source analysis / new JavaScript worker | +| `javascript.member_mutation_alias_states` | Next JavaScript application, web bundle or reference-source analysis | | `wasm.command_timeout_ms` | Next WABT operation | | `native.matching_representation_bytes` | Next `match_native_functions` request | diff --git a/docs/design/rea-limit-inventory.md b/docs/design/rea-limit-inventory.md index f7635abb59..b9059f258b 100644 --- a/docs/design/rea-limit-inventory.md +++ b/docs/design/rea-limit-inventory.md @@ -96,21 +96,22 @@ floor, and maximum. The accepted operation snapshot supplies the value. force. An update outside the range is rejected. A hit names the setting and reports the current value, source, and range. A rejected Windows ChangeTime launch value omits `pending_restart`. -| Environment | Owner | Default | Floor | Maximum | Applies | Fields | -| ------------------------------------------ | ------------------------------------------- | ----------------------: | ------: | -----------: | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | -| `REA_BRIDGE_MAX_REQUEST_BYTES` | `src/config/bridgeMaxRequestBytes.ts` | 16777216 | 7464 | 536870887 | each bridge request, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | -| `REA_BRIDGE_MAX_REPLY_BYTES` | `src/config/bridgeMaxReplyBytes.ts` | 268435456 | 1048576 | 536870888 | each Hopper or Ghidra request, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | -| `REA_CAPTURE_PACKET_JSON_EXPANSION_BYTES` | `src/config/capturePacketJsonExpansion.ts` | 137438953472 | 1048576 | 137438953472 | each packet-capture reply before parsing, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | -| `REA_DEBUGGER_COMMAND_DEADLINE_MS` | `src/domain/analysisResourceLimits.ts` | 120000 | 1 | 2147483647 | each command, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | -| `REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES` | `src/domain/analysisResourceLimits.ts` | 16777216 | 1 | 89303718 | each command, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | -| `REA_HOPPER_CALLER_SCAN_BUDGET_MS` | `src/domain/analysisResourceLimits.ts` | 2000 | 1 | 2147483647 | each caller scan, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | -| `REA_JEB_MCP_TIMEOUT_MS` | `src/jeb/JebMcpConnection.ts` | 60000 (MCP SDK default) | 1 | 2147483647 | discovery and each tools/call, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | -| `REA_KUNA_PROJECT_TIMEOUT_MS` | `src/kuna/KunaConfiguration.ts` | 1800000 | 1 | 2147483647 | each project export and whole-program scan, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | -| `REA_KUNA_TIMEOUT_MS` | `src/kuna/KunaConfiguration.ts` | 120000 | 1 | 2147483647 | each function decompile, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | -| `REA_PWNTOOLS_MAX_INPUT_BYTES` | `src/native/pwntools/PwntoolsInputLimit.ts` | 33554432 | 1 | 1073741824 | each pwntools decode, unless the call passes `producer_limits.input_bytes`; otherwise the operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | -| `REA_WINDOWS_CHANGE_TIME_DRIFT_MS` | `src/config/windowsChangeTimeSettle.ts` | 2000 | 1 | 60000 | each ChangeTime observation, from the accepted operation snapshot | `update_mode: "runtime"`; invalid launch omits `pending_restart` | -| `REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS` | `src/config/windowsChangeTimeSettle.ts` | 25 | 1 | 1000 | each ChangeTime observation, from the accepted operation snapshot | `update_mode: "runtime"`; invalid launch omits `pending_restart` | -| `REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS` | `src/config/windowsChangeTimeSettle.ts` | 40 | 1 | 1000 | each ChangeTime observation, from the accepted operation snapshot | `update_mode: "runtime"`; invalid launch omits `pending_restart` | +| Environment | Owner | Default | Floor | Maximum | Applies | Fields | +| --------------------------------------------- | ------------------------------------------- | ----------------------: | ------: | -----------: | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | +| `REA_BRIDGE_MAX_REQUEST_BYTES` | `src/config/bridgeMaxRequestBytes.ts` | 16777216 | 7464 | 536870887 | each bridge request, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | +| `REA_BRIDGE_MAX_REPLY_BYTES` | `src/config/bridgeMaxReplyBytes.ts` | 268435456 | 1048576 | 536870888 | each Hopper or Ghidra request, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | +| `REA_CAPTURE_PACKET_JSON_EXPANSION_BYTES` | `src/config/capturePacketJsonExpansion.ts` | 137438953472 | 1048576 | 137438953472 | each packet-capture reply before parsing, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | +| `REA_DEBUGGER_COMMAND_DEADLINE_MS` | `src/domain/analysisResourceLimits.ts` | 120000 | 1 | 2147483647 | each command, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | +| `REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES` | `src/domain/analysisResourceLimits.ts` | 16777216 | 1 | 89303718 | each command, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | +| `REA_HOPPER_CALLER_SCAN_BUDGET_MS` | `src/domain/analysisResourceLimits.ts` | 2000 | 1 | 2147483647 | each caller scan, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | +| `REA_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES` | `src/domain/analysisResourceLimits.ts` | 524288 | 1 | 8388608 | each JavaScript source's member-mutation phase, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | +| `REA_JEB_MCP_TIMEOUT_MS` | `src/jeb/JebMcpConnection.ts` | 60000 (MCP SDK default) | 1 | 2147483647 | discovery and each tools/call, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | +| `REA_KUNA_PROJECT_TIMEOUT_MS` | `src/kuna/KunaConfiguration.ts` | 1800000 | 1 | 2147483647 | each project export and whole-program scan, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | +| `REA_KUNA_TIMEOUT_MS` | `src/kuna/KunaConfiguration.ts` | 120000 | 1 | 2147483647 | each function decompile, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | +| `REA_PWNTOOLS_MAX_INPUT_BYTES` | `src/native/pwntools/PwntoolsInputLimit.ts` | 33554432 | 1 | 1073741824 | each pwntools decode, unless the call passes `producer_limits.input_bytes`; otherwise the operation snapshot | `update_mode: "runtime"`, `pending_restart: false` | +| `REA_WINDOWS_CHANGE_TIME_DRIFT_MS` | `src/config/windowsChangeTimeSettle.ts` | 2000 | 1 | 60000 | each ChangeTime observation, from the accepted operation snapshot | `update_mode: "runtime"`; invalid launch omits `pending_restart` | +| `REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS` | `src/config/windowsChangeTimeSettle.ts` | 25 | 1 | 1000 | each ChangeTime observation, from the accepted operation snapshot | `update_mode: "runtime"`; invalid launch omits `pending_restart` | +| `REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS` | `src/config/windowsChangeTimeSettle.ts` | 40 | 1 | 1000 | each ChangeTime observation, from the accepted operation snapshot | `update_mode: "runtime"`; invalid launch omits `pending_restart` | ## Restart-only numeric limits diff --git a/docs/guides/javascript/javascript-artifact-reconstruction.md b/docs/guides/javascript/javascript-artifact-reconstruction.md index eb2c8d9d02..91eb76c4ba 100644 --- a/docs/guides/javascript/javascript-artifact-reconstruction.md +++ b/docs/guides/javascript/javascript-artifact-reconstruction.md @@ -831,13 +831,32 @@ caller can change the resource actually exhausted. Finite inline literal object spreads use iterative syntax traversal and no longer inherit a duplicate semantic-depth cut. -Member-mutation analysis also has a fixed work allowance of 524,288 alias -states per source. It is not a `semantic_precision` field. A source whose alias -histories exceed it is refused with a `resource_constraint` that names the -fixed work allowance, instead of exhausting the worker heap. Static facts the -worker had not yet reported are not kept, because a partial mutation index -would let later reads keep stale values. Raising `max_heap_mb`, -`analysis_timeout_ms` or `semantic_precision` does not admit the same source. +Member-mutation analysis also has a work allowance of 524,288 alias states +per source, selected by the `javascript.member_mutation_alias_states` setting +(1 to 8,388,608). Change it with `update_analysis_settings` on MCP, or with +`REA_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES` for CLI or future REA processes; +it applies to the next `analyze_javascript_application`, `analyze_web_bundle` +or `import-reference-source` operation. It is not a `semantic_precision` field. +A source whose alias histories exceed it is refused with a +`resource_constraint` that names the setting, its effective value and source, +instead of exhausting the worker heap. Each scheduled state retains about +1–2 KiB of worker heap until its query finishes, so a raised allowance usually +needs a larger `max_heap_mb` too. In `analyze_javascript_application`, a +source refused during the semantic phase keeps its completed static facts. One +refused during the static phase still counts as parsed and keeps its +syntax-level facts: parse status `partial`, syntax coverage, +source map directives and vendor markers. Its `analysis_coverage.examination` +is `not-examined` with `refused_phase: "member-mutations"` and an +`unexamined_reason`, and findings that read mutation facts are not reported, +because a partial mutation index would let later reads keep stale values; their +absence is not evidence of absence. In `analyze_web_bundle`, a refused script +still counts as parsed and keeps its parse status and text-level inferences +such as vendor and bundle-runtime markers; its routes, endpoints and edges are +not reported, and its `javascript_analysis` unknown carries the setting, +remediation and `refused_phase: "member-mutations"`. `import-reference-source` keeps a refused +file's declaration-level imports and records that its CommonJS require calls +were not examined. Raising `max_heap_mb`, `analysis_timeout_ms` or +`semantic_precision` alone does not admit the same source. Byte, entry, path, and graph-shape bounds are hard limits. The parse deadline is checked before and between bounded parsing and traversal phases; the synchronous diff --git a/scripts/verify/advertised-schemas/tools/inspect_analysis_settings.json b/scripts/verify/advertised-schemas/tools/inspect_analysis_settings.json index cba515db9d..fc17490efc 100644 --- a/scripts/verify/advertised-schemas/tools/inspect_analysis_settings.json +++ b/scripts/verify/advertised-schemas/tools/inspect_analysis_settings.json @@ -1,6 +1,6 @@ { "schemas": { - "input compact": "7020930a4af1f37c", + "input compact": "5ea37049dad60cdc", "output compact": "b3a429110120aff1" }, "patterns": {} diff --git a/scripts/verify/advertised-schemas/tools/update_analysis_settings.json b/scripts/verify/advertised-schemas/tools/update_analysis_settings.json index 0bc2f02d83..d1a23c1a60 100644 --- a/scripts/verify/advertised-schemas/tools/update_analysis_settings.json +++ b/scripts/verify/advertised-schemas/tools/update_analysis_settings.json @@ -1,6 +1,6 @@ { "schemas": { - "input compact": "2144cc02dc3d9c72", + "input compact": "c6bdd8000e43ec47", "output compact": "39446aa1012006cc" }, "patterns": {} diff --git a/src/application/ReferenceSourceImport.ts b/src/application/ReferenceSourceImport.ts index 7d901eb928..55faa1d910 100644 --- a/src/application/ReferenceSourceImport.ts +++ b/src/application/ReferenceSourceImport.ts @@ -207,7 +207,12 @@ export const importReferenceSource = async ( const read = readResult.value; const { entries, relationships, parseFailures, limitations } = - parseReferenceSourceEntries(read, exclusions, options.signal); + parseReferenceSourceEntries( + read, + exclusions, + options.signal, + options.memberMutationAllowance, + ); if (isAborted(options.signal)) return err(cancelled(options.signal, read)); diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 991c4aaa62..ddcd179f5f 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -1,3 +1,4 @@ +import type { JavaScriptMemberMutationAllowance } from "../domain/javascript/javascriptSemanticWorkAllowance.js"; import { err, ok, type Result } from "../domain/result.js"; import { createHash } from "node:crypto"; import { posix } from "node:path"; @@ -248,6 +249,7 @@ interface SpecifierContext { readonly packages: ReadonlyMap; readonly packageManifestPathLookup: ReferenceSourcePathLookup; readonly unobservedPath: (path: string) => boolean; + readonly memberMutationAllowance?: JavaScriptMemberMutationAllowance; } /** Earlier unobserved candidates must not be treated as absent. */ @@ -556,6 +558,7 @@ const appendFile = ( entry.bytes, language, packageTypeForFile(entry.path, context), + context.memberMutationAllowance, ); output.entries.push({ path: entry.path, @@ -647,6 +650,7 @@ export const parseReferenceSourceEntries = ( read: ReferenceSourceRead, exclusions: HistoricalSourceGraphInput["exclusions"], signal?: AbortSignal, + memberMutationAllowance?: JavaScriptMemberMutationAllowance, ): ParsedReferenceSourceEntries => { const output: ParsedReferenceSourceEntries = { entries: [], @@ -673,6 +677,9 @@ export const parseReferenceSourceEntries = ( unobservedPath, packages: packageInventory.packages, packageManifestPathLookup: packageInventory.pathLookup, + ...(memberMutationAllowance === undefined + ? {} + : { memberMutationAllowance }), }; for (const entry of read.entries) { if (signal?.aborted === true) break; diff --git a/src/application/ReferenceSourceImportTypes.ts b/src/application/ReferenceSourceImportTypes.ts index 06a74e8b1b..dfaeb75fee 100644 --- a/src/application/ReferenceSourceImportTypes.ts +++ b/src/application/ReferenceSourceImportTypes.ts @@ -1,3 +1,4 @@ +import type { JavaScriptMemberMutationAllowance } from "../domain/javascript/javascriptSemanticWorkAllowance.js"; import { AnalysisCapabilityUnavailableError, AnalysisInputError, @@ -134,6 +135,8 @@ export interface ReferenceSourceImportOptions { readonly importer?: string; readonly importerVersion?: string | null; readonly excludePaths?: readonly string[]; + /** Accepted member-mutation work allowance; absent means the default. */ + readonly memberMutationAllowance?: JavaScriptMemberMutationAllowance; } export const DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS = [ diff --git a/src/application/SetupRegistrationEnvironment.ts b/src/application/SetupRegistrationEnvironment.ts index 1974775a34..5a98eb9985 100644 --- a/src/application/SetupRegistrationEnvironment.ts +++ b/src/application/SetupRegistrationEnvironment.ts @@ -77,6 +77,7 @@ const SELECTED_REGISTRATION_SETTINGS = [ "REA_HOPPER_STARTUP_TIMEOUT_MS", "REA_JEB_MCP_URL", "REA_JEB_MCP_TIMEOUT_MS", + "REA_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES", "REA_MCP_INPUT_SCHEMA_PROFILE", "REA_MCP_TOOL_FAMILIES", "REA_MCP_OUTPUT_SCHEMA_PROFILE", diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index d7cee78469..36fa323e0b 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -5,6 +5,7 @@ import { parseAsarHeaderAdmission, } from "../../config/asarHeaderAdmission.js"; import { parseAsarListingAdmission } from "../../config/asarListingAdmission.js"; +import { resolveJavaScriptMemberMutationAllowance } from "../../config/javascriptMemberMutationAllowance.js"; import { z } from "zod"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; @@ -76,6 +77,11 @@ export const analyzeJavaScriptApplicationValidated = async ( ); if (!listingAdmission.ok) return listingAdmission; const overrides = options.settings?.().overrides; + const memberMutationAllowance = resolveJavaScriptMemberMutationAllowance( + options.environment, + overrides, + ); + if (!memberMutationAllowance.ok) return memberMutationAllowance; const header = overrides?.["asar.max_header_bytes"]; const listing = overrides?.["asar.max_listing_bytes"]; const acceptedAdmission = { @@ -106,6 +112,7 @@ export const analyzeJavaScriptApplicationValidated = async ( options.progress, acceptedAdmission, overrides, + memberMutationAllowance.value, ); const { electron_summary: summary, ...application } = reconstructed; await options.progress?.report({ diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index b084db4f44..f11dc4b527 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -1,3 +1,4 @@ +import type { JavaScriptMemberMutationAllowance } from "../../domain/javascript/javascriptSemanticWorkAllowance.js"; import { discoverJavaScriptPreloadPaths } from "./JavaScriptPreloadDiscovery.js"; import { artifactNameForClassification } from "../../domain/artifactNameSelection.js"; import type { AnalysisSettingValues } from "../../domain/analysisSettings.js"; @@ -91,6 +92,7 @@ export const reconstructJavaScriptArtifact = async ( progress?: ProgressReporter, asarHeaderAdmission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION, overrides?: Readonly>, + memberMutationAllowance?: JavaScriptMemberMutationAllowance, ): Promise => scope.run(async () => { const reportPhase = async ( @@ -196,6 +198,9 @@ export const reconstructJavaScriptArtifact = async ( ...(input.semantic_precision === undefined ? {} : { semanticPrecision: input.semantic_precision }), + ...(memberMutationAllowance === undefined + ? {} + : { memberMutationAllowance }), heapMb: heap.value, heapSource: heap.source, timeoutMs: timeout.value, diff --git a/src/artifacts/javascript/JavaScriptSourceAnalysis.ts b/src/artifacts/javascript/JavaScriptSourceAnalysis.ts index 2f890c866a..60beb5c902 100644 --- a/src/artifacts/javascript/JavaScriptSourceAnalysis.ts +++ b/src/artifacts/javascript/JavaScriptSourceAnalysis.ts @@ -1,3 +1,4 @@ +import { DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALLOWANCE } from "../../domain/javascript/javascriptSemanticWorkAllowance.js"; import { resolveJavaScriptSemanticPrecision } from "../../domain/javascript/javascriptSemanticPrecision.js"; import type { JavaScriptSourceAnalysisLimits, @@ -126,11 +127,24 @@ const registerJavaScriptSourceAnalysisClose = ( }); }; +/** + * Whether a parked worker was built for the same result-affecting choices. Its + * requests carry the limits it was built with, so every choice that changes a + * result belongs here, including the member-mutation allowance. + */ const sameLimits = ( left: JavaScriptSourceAnalysisLimits, right: JavaScriptSourceAnalysisLimits, ): boolean => { + const leftAllowance = + left.memberMutationAllowance ?? + DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALLOWANCE; + const rightAllowance = + right.memberMutationAllowance ?? + DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALLOWANCE; if ( + leftAllowance.aliasStates !== rightAllowance.aliasStates || + leftAllowance.source !== rightAllowance.source || left.heapMb !== right.heapMb || left.timeoutMs !== right.timeoutMs || left.heapSource !== right.heapSource || diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index 3edb965d73..17d3c6b203 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -1,3 +1,8 @@ +import type { ConfigurationError } from "../domain/configurationErrors.js"; +import { + DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALLOWANCE, + type JavaScriptMemberMutationAllowance, +} from "../domain/javascript/javascriptSemanticWorkAllowance.js"; import type { BrowserObservationPort } from "../application/BrowserObservationPort.js"; import type { ProviderIdentity, @@ -110,8 +115,24 @@ export class CdpBrowserProvider implements BrowserObservationPort { #closePromise: Promise | undefined; readonly #idleWaiters = new Set<() => void>(); - constructor(admission?: WebBundleAnalysisAdmission) { + readonly #memberMutationAllowance: () => Result< + JavaScriptMemberMutationAllowance, + ConfigurationError + >; + + /** + * `memberMutationAllowance` resolves the accepted operation's allowance at + * each bundle analysis; the default applies when the host selects none. + */ + constructor( + admission?: WebBundleAnalysisAdmission, + memberMutationAllowance: () => Result< + JavaScriptMemberMutationAllowance, + ConfigurationError + > = () => ok(DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALLOWANCE), + ) { this.#bundleAnalyzer = new IsolatedWebBundleAnalyzer(undefined, admission); + this.#memberMutationAllowance = memberMutationAllowance; } async close(): Promise { @@ -278,6 +299,9 @@ export class CdpBrowserProvider implements BrowserObservationPort { input: AnalyzeWebBundleInput, options: ExecutionOptions, ): Promise> { + // An invalid setting is refused before capture, never replaced by the default. + const memberMutationAllowance = this.#memberMutationAllowance(); + if (!memberMutationAllowance.ok) return memberMutationAllowance; const admission = await this.#bundleAnalyzer.reserve(); if (!admission.ok) return admission; let captured: CapturedPage | undefined; @@ -330,6 +354,7 @@ export class CdpBrowserProvider implements BrowserObservationPort { }, input, options.signal, + memberMutationAllowance.value, ); } catch (cause: unknown) { // Capture and analyzer work can still throw the exact caller reason. diff --git a/src/browser/IsolatedWebBundleAnalyzer.test.ts b/src/browser/IsolatedWebBundleAnalyzer.test.ts index 3e344bf86d..05595f4ada 100644 --- a/src/browser/IsolatedWebBundleAnalyzer.test.ts +++ b/src/browser/IsolatedWebBundleAnalyzer.test.ts @@ -15,6 +15,7 @@ import type { } from "../domain/webBundleAnalysis.js"; import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js"; import { analyzeCapturedWebScript } from "../domain/webBundleAnalyzer.js"; +import { unavailableWebScriptRecovery } from "../domain/webBundleScriptAnalysis.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { OwnedWorkerRuntime } from "../process/OwnedWorkerRuntime.js"; @@ -25,6 +26,7 @@ import { type WebBundleAnalysisWorkerPort, } from "./IsolatedWebBundleAnalyzer.js"; import { releaseOwnedCleanup } from "../../tests/support/cleanupRelease.js"; +import { javaScriptMemberMutationRefusal } from "../domain/javascript/javascriptSemanticWorkAllowance.js"; const origin = "https://app.example.test"; const noSourceMaps: WebBundleAnalysis["observations"]["source_maps"] = { @@ -85,6 +87,112 @@ it("preserves completed prefix and suffix facts when one script times out", asyn }); }); +it("keeps the selected allowance and its recovery for a refused script", async () => { + const captured = inspection(["fetch('/kept');", "fetch('/refused');"]); + const allowance = { aliasStates: 64, source: "runtime" } as const; + const refusal = javaScriptMemberMutationRefusal( + "analyze_web_bundle", + allowance, + ); + const received: unknown[] = []; + const analyzer = new IsolatedWebBundleAnalyzer(() => ({ + analyze: async (script, _timeout, _signal, _precision, selected) => { + received.push(selected); + return script.script_key === captured.scripts.items[1]?.script_key + ? err({ error: refusal, completed: null }) + : ok(analyzeCapturedWebScript(script)); + }, + close: async () => {}, + })); + expect((await analyzer.reserve()).ok).toBe(true); + const result = await analyzer.analyze( + captured, + noSourceMaps, + input, + undefined, + allowance, + ); + analyzer.releaseReservation(); + if (!result.ok) throw result.error; + expect(received).toEqual([allowance, allowance]); + expect(result.value.unknowns).toContainEqual({ + dimension: "javascript_analysis", + reason: expect.stringContaining("resource_constraint"), + affected_script_keys: [captured.scripts.items[1]?.script_key], + limit: refusal.limit, + remediation: refusal.remediationAction, + }); + expect(refusal.limit).toMatchObject({ + setting: "javascript.member_mutation_alias_states", + effective_value: 64, + source: "runtime", + }); +}); + +it("keeps a refused script's parse and text facts beside its refusal", async () => { + const refused = [ + "/* __webpack_require__ */ function probe(input, env) { var m; if (env.first) m = env.first.info;", + ...Array.from( + { length: 4 }, + (_, index) => + `if (/case${String(index)}/.test(input)) m = m[${String(index % 2)}];`, + ), + "m[1] = input.length; var c = new Map(); c.get('k'); fetch('/hidden'); return m; }", + ].join(" "); + const captured = inspection(["fetch('/kept');", refused]); + const allowance = { aliasStates: 8, source: "runtime" } as const; + const refusal = javaScriptMemberMutationRefusal( + "analyze_web_bundle", + allowance, + ); + const analyzer = new IsolatedWebBundleAnalyzer(() => ({ + analyze: async (script, _timeout, _signal, precision, selected) => { + const facts = analyzeCapturedWebScript(script, precision, selected); + // The worker parent states the refusal from the allowance it sent. + return ok( + facts.status === "unavailable" + ? { ...facts, ...unavailableWebScriptRecovery(refusal) } + : facts, + ); + }, + close: async () => {}, + })); + expect((await analyzer.reserve()).ok).toBe(true); + const result = await analyzer.analyze( + captured, + noSourceMaps, + input, + undefined, + allowance, + ); + analyzer.releaseReservation(); + if (!result.ok) throw result.error; + const refusedKey = captured.scripts.items[1]?.script_key; + expect(result.value.completeness).toMatchObject({ + status: "partial", + parsed_scripts: 2, + parse_failures: 0, + }); + expect(result.value.inferences).toContainEqual( + expect.objectContaining({ + kind: "bundle_runtime", + value: "webpack", + basis: [expect.objectContaining({ script_key: refusedKey })], + }), + ); + expect(result.value.observations.endpoints.map(({ value }) => value)).toEqual( + ["/kept"], + ); + expect(result.value.unknowns).toContainEqual({ + dimension: "javascript_analysis", + reason: expect.stringContaining("resource_constraint"), + affected_script_keys: [refusedKey], + limit: refusal.limit, + remediation: refusal.remediationAction, + refused_phase: "member-mutations", + }); +}); + it("marks the unstarted suffix when the aggregate deadline expires", async () => { const captured = inspection([ "fetch('/prefix');", diff --git a/src/browser/IsolatedWebBundleAnalyzer.ts b/src/browser/IsolatedWebBundleAnalyzer.ts index f4dd85c373..e1bce97fd0 100644 --- a/src/browser/IsolatedWebBundleAnalyzer.ts +++ b/src/browser/IsolatedWebBundleAnalyzer.ts @@ -1,3 +1,4 @@ +import type { JavaScriptMemberMutationAllowance } from "../domain/javascript/javascriptSemanticWorkAllowance.js"; import type { WebPageInspection } from "../domain/browserObservationSchemas.js"; import { DEFAULT_WEB_BUNDLE_HEAP_MB, @@ -9,7 +10,10 @@ import { } from "../domain/webBundleAnalysis.js"; import { buildCapturedWebBundleAnalysis } from "../domain/webBundleAnalyzerResult.js"; import { isIncludedScript } from "../domain/webBundleAnalyzerState.js"; -import type { WebBundleScriptAnalysis } from "../domain/webBundleScriptAnalysis.js"; +import { + unavailableWebScriptRecovery, + type WebBundleScriptAnalysis, +} from "../domain/webBundleScriptAnalysis.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCapabilityUnavailableError, @@ -143,6 +147,7 @@ export class IsolatedWebBundleAnalyzer { sourceMaps: WebBundleAnalysis["observations"]["source_maps"], input: AnalyzeWebBundleInput, signal?: AbortSignal, + memberMutationAllowance?: JavaScriptMemberMutationAllowance, ): Promise> { if (this.#closed) return Promise.resolve( @@ -170,6 +175,7 @@ export class IsolatedWebBundleAnalyzer { sourceMaps, input, signal, + memberMutationAllowance, ).finally(() => { this.#running = undefined; this.#controller = undefined; @@ -183,6 +189,7 @@ export class IsolatedWebBundleAnalyzer { sourceMaps: WebBundleAnalysis["observations"]["source_maps"], input: AnalyzeWebBundleInput, signal?: AbortSignal, + memberMutationAllowance?: JavaScriptMemberMutationAllowance, ): Promise> { const scripts = inspection.scripts.items.filter(isIncludedScript); const results: WebBundleScriptAnalysis[] = []; @@ -224,6 +231,7 @@ export class IsolatedWebBundleAnalyzer { input.analysis_timeout_ms ?? DEFAULT_WEB_BUNDLE_SCRIPT_TIMEOUT_MS, deadline.signal, input.semantic_precision, + memberMutationAllowance, ); if (analyzed.ok) results.push(analyzed.value); else { @@ -240,11 +248,7 @@ export class IsolatedWebBundleAnalyzer { : error instanceof AnalysisResourceConstraintError ? "resource_constraint" : "worker_failure", - detail: - error instanceof AnalysisResourceConstraintError && - error.reportedLimits !== null - ? `${error.userMessage ?? error.reason} Reported limits: ${JSON.stringify(error.reportedLimits)}` - : (error.userMessage ?? error.message), + ...unavailableWebScriptRecovery(error), }, ); if (error.cleanupIncomplete) { diff --git a/src/browser/WebBundleAnalysisWorker.test.ts b/src/browser/WebBundleAnalysisWorker.test.ts index 4b2f61a2d1..517c86def0 100644 --- a/src/browser/WebBundleAnalysisWorker.test.ts +++ b/src/browser/WebBundleAnalysisWorker.test.ts @@ -38,8 +38,8 @@ import { } from "./WebBundleAnalysisWorker.js"; import { WEB_BUNDLE_WORKER_LIMITS } from "./WebBundleAnalysisWorkerProtocol.js"; import { - JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT, + javaScriptMemberMutationRefusal, } from "../domain/javascript/javascriptSemanticWorkAllowance.js"; import { releaseOwnedCleanup } from "../../tests/support/cleanupRelease.js"; @@ -70,7 +70,7 @@ const sourceWorkerProgram = ( ): string => ` const { serveWebBundleAnalysisWorker } = await import(${JSON.stringify(new URL("./WebBundleAnalysisWorkerServer.ts", import.meta.url).href)}); const { analyzeCapturedWebScript } = await import(${JSON.stringify(new URL("../domain/webBundleAnalyzer.ts", import.meta.url).href)}); - const analyze = (script) => { + const analyze = (script, precision, allowance) => { if (${JSON.stringify(hangMarker)} !== undefined && script.source.artifact.text === ${JSON.stringify(hangMarker)}) { for (;;) {} } @@ -80,7 +80,7 @@ const sourceWorkerProgram = ( } if (${JSON.stringify(stderrMarker)} !== undefined) process.stderr.write(${JSON.stringify(stderrMarker)} + "\\n"); - return analyzeCapturedWebScript(script); + return analyzeCapturedWebScript(script, precision, allowance); }; await serveWebBundleAnalysisWorker(analyze); `; @@ -174,7 +174,7 @@ const protocolWorkerProgram = ( ? `send({ kind: "failure", id: request.id, reason: "resource", message: "bounded result capacity" }); setInterval(() => {}, 1_000);` : failure === "work-refusal" - ? `send({ kind: "failure", id: request.id, reason: "resource", limits: ${JSON.stringify({ [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE })}, message: "work allowance" }); + ? `send({ kind: "failure", id: request.id, reason: "resource", limits: { ${JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT}: request.member_mutation_allowance.alias_states, sent_source: request.member_mutation_allowance.source }, message: "work allowance" }); setInterval(() => {}, 1_000);` : failure === "final-frame-held-pipe" ? `const frame = JSON.stringify({ kind: "failure", id: request.id, reason: "output", message: "final frame from descendant" }); @@ -455,6 +455,60 @@ it("retains failed helper cleanup without caller cancellation", async () => { } }); +it("keeps a refused script's syntax facts and states the allowance the host sent", async () => { + // Four conditional self-selections before one member write (#8294); the + // Map lookup makes the analyzer index member mutations. + const source = [ + "/* __webpack_require__ */ function probe(input, env) { var m; if (env.first) m = env.first.info;", + ...Array.from( + { length: 4 }, + (_, index) => + `if (/case${String(index)}/.test(input)) m = m[${String(index % 2)}];`, + ), + "m[1] = input.length; var c = new Map(); c.get('k'); fetch('/refused'); return m; }", + ].join(" "); + const script = makeScript(source); + const allowance = { aliasStates: 8, source: "runtime" } as const; + const worker = new WebBundleAnalysisWorker( + WEB_BUNDLE_WORKER_LIMITS.heapMb, + launcherFor({ kind: "source" }), + ); + try { + const result = await worker.analyze( + script, + 10_000, + undefined, + undefined, + allowance, + ); + if (!result.ok) throw result.error.error; + const refusal = javaScriptMemberMutationRefusal( + "analyze_web_bundle", + allowance, + ); + expect(result.value).toEqual({ + status: "unavailable", + script_key: script.script_key, + reason: "resource_constraint", + detail: expect.stringContaining("more than 8 alias states"), + limit: refusal.limit, + remediation: refusal.remediationAction, + partial: { + refused_phase: "member-mutations", + parse_status: "parsed", + inferences: [ + expect.objectContaining({ + kind: "bundle_runtime", + value: "webpack", + }), + ], + }, + }); + } finally { + await worker.close(); + } +}); + it("reuses one real owned worker for completed facts from consecutive scripts", async () => { const children: ProviderProcessHandle[] = []; const worker = new WebBundleAnalysisWorker( @@ -834,28 +888,36 @@ it("does not let a failure frame for another job replace cancellation", async () } }); -it("names the fixed work allowance a worker refusal reports", async () => { +it("sends the accepted member-mutation allowance and names its setting on refusal", async () => { const worker = new WebBundleAnalysisWorker( 128, launcherFor({ kind: "protocol", failure: "work-refusal" }), ); try { const error = failureOf( - await worker.analyze(makeScript("fetch('/work');"), 5_000), + await worker.analyze( + makeScript("fetch('/work');"), + 5_000, + undefined, + undefined, + { aliasStates: 2048, source: "setting" }, + ), ); if (!(error instanceof AnalysisResourceConstraintError)) throw new Error("Expected a worker resource constraint failure"); expect(error).toMatchObject({ resource: "cpu", retryable: false, - fixedAllowance: { - kind: "work", - limit: JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, + limit: { + setting: "javascript.member_mutation_alias_states", + effective_value: 2048, + source: "setting", }, }); + expect(error.fixedAllowance).toBeUndefined(); expect(error.reportedLimits).toMatchObject({ - [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: - JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, + [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: 2048, + sent_source: "setting", }); } finally { await worker.close(); diff --git a/src/browser/WebBundleAnalysisWorker.ts b/src/browser/WebBundleAnalysisWorker.ts index 4d211bbc1b..e914339be9 100644 --- a/src/browser/WebBundleAnalysisWorker.ts +++ b/src/browser/WebBundleAnalysisWorker.ts @@ -1,5 +1,11 @@ import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; -import { javaScriptSemanticWorkAllowance } from "../domain/javascript/javascriptSemanticWorkAllowance.js"; +import { + DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALLOWANCE, + javaScriptMemberMutationAllowanceField, + javaScriptMemberMutationRefusal, + reportsJavaScriptMemberMutationRefusal, + type JavaScriptMemberMutationAllowance, +} from "../domain/javascript/javascriptSemanticWorkAllowance.js"; import { retainProcessInspectionCleanup, processInspectionCancellationError, @@ -53,6 +59,7 @@ import { javaScriptRuntime } from "../domain/runtimeVersion.js"; import { err, ok, type Result } from "../domain/result.js"; import type { IncludedScript } from "../domain/webBundleAnalyzerState.js"; import { + unavailableWebScriptRecovery, webBundleScriptAnalysisSchema, type WebBundleScriptAnalysis, } from "../domain/webBundleScriptAnalysis.js"; @@ -90,24 +97,34 @@ import { const OPERATION = "analyze_web_bundle"; /** - * A worker resource refusal. A reported fixed work allowance is deterministic - * for the same script; any other refusal is measured memory exhaustion. + * A worker resource refusal. The member-mutation allowance is deterministic + * for the same script and names its setting; any other refusal is measured + * memory exhaustion. */ const webBundleResourceFailure = ( frame: Extract, - resultBudgetBytes: number, + job: WebBundleActiveJob, capturedOutput?: AnalysisErrorOptions["capturedOutput"], ): AnalysisResourceConstraintError => { - const limits = { ...frame.limits, result_budget_bytes: resultBudgetBytes }; - const fixed = javaScriptSemanticWorkAllowance(frame.limits); + const limits = { + ...frame.limits, + result_budget_bytes: job.resultBudgetBytes, + }; + if (reportsJavaScriptMemberMutationRefusal(frame.limits)) + return javaScriptMemberMutationRefusal( + OPERATION, + job.memberMutationAllowance, + limits, + capturedOutput, + ); return new AnalysisResourceConstraintError( OPERATION, - fixed === undefined ? "memory" : "cpu", + "memory", frame.message, limits, { ...(capturedOutput === undefined ? {} : { capturedOutput }), - ...(fixed ?? { measuredExhaustion: true }), + measuredExhaustion: true, }, ); }; @@ -140,7 +157,7 @@ type WebBundleObservedFailure = WebBundleWorkerMessage, { readonly kind: "failure" } >; - readonly resultBudgetBytes: number; + readonly job: WebBundleActiveJob; } | { readonly kind: "exit"; @@ -153,6 +170,7 @@ type WebBundleObservedFailure = type WebBundleActiveJob = { readonly id: string; readonly resultBudgetBytes: number; + readonly memberMutationAllowance: JavaScriptMemberMutationAllowance; }; export interface WebBundleScriptAnalysisFailure { @@ -311,6 +329,7 @@ export class WebBundleAnalysisWorker { timeoutMs: number, signal?: AbortSignal, precision?: JavaScriptSemanticPrecisionInput, + memberMutationAllowance: JavaScriptMemberMutationAllowance = DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALLOWANCE, ): Promise> { let deadline: ProviderStartupDeadline | undefined; const id = randomUUID(); @@ -385,6 +404,9 @@ export class WebBundleAnalysisWorker { const request = webBundleWorkerRequestSchema.parse({ kind: "analyze", ...(precision === undefined ? {} : { semantic_precision: precision }), + member_mutation_allowance: javaScriptMemberMutationAllowanceField( + memberMutationAllowance, + ), id, source, result_budget_bytes: resultBudget, @@ -393,7 +415,12 @@ export class WebBundleAnalysisWorker { const stdin = supervisor.launch.process.stdin; if (stdin === null || stdin === undefined) throw new TypeError("Web analyzer request stream is unavailable"); - this.#activeJob = { id, resultBudgetBytes: resultBudget }; + const job: WebBundleActiveJob = { + id, + resultBudgetBytes: resultBudget, + memberMutationAllowance, + }; + this.#activeJob = job; this.#activeTerminalResponseObserved = false; await new Promise((resolve, reject) => stdin.write(frame, (error) => @@ -411,7 +438,7 @@ export class WebBundleAnalysisWorker { } while (response.kind === "phase"); if (response.kind === "failure") { if (response.reason === "resource") - throw webBundleResourceFailure(response, resultBudget); + throw webBundleResourceFailure(response, job); throw new AnalysisOutputError( OPERATION, `Web script analysis failed (${response.reason}): ${response.message}`, @@ -451,7 +478,12 @@ export class WebBundleAnalysisWorker { artifactFailure = true; throw cause; }); - const facts = this.#validateCompletedResult(stored, descriptor, script); + const facts = this.#validateCompletedResult( + stored, + descriptor, + script, + memberMutationAllowance, + ); this.#retainedResultBytes += descriptor.bytes; completed = facts; result = ok(facts); @@ -464,6 +496,7 @@ export class WebBundleAnalysisWorker { snapshot, resultDescriptor, script, + memberMutationAllowance, ); this.#retainedResultBytes += resultDescriptor.bytes; } catch { @@ -803,7 +836,7 @@ export class WebBundleAnalysisWorker { this.#latchFailure({ kind: "frame", frame, - resultBudgetBytes: this.#activeJob.resultBudgetBytes, + job: this.#activeJob, }); } @@ -837,11 +870,7 @@ export class WebBundleAnalysisWorker { ); case "frame": return failure.frame.reason === "resource" - ? webBundleResourceFailure( - failure.frame, - failure.resultBudgetBytes, - capturedOutput, - ) + ? webBundleResourceFailure(failure.frame, failure.job, capturedOutput) : new AnalysisOutputError( OPERATION, `Web script analysis failed (${failure.frame.reason}): ${failure.frame.message}`, @@ -908,6 +937,7 @@ export class WebBundleAnalysisWorker { stored: StableArtifactSnapshot, descriptor: WebBundleResultDescriptor, script: IncludedScript, + memberMutationAllowance: JavaScriptMemberMutationAllowance, ): WebBundleScriptAnalysis { if ( stored.bytes.length !== descriptor.bytes || @@ -920,12 +950,27 @@ export class WebBundleAnalysisWorker { const parsed = safeParseUtf8Json(stored.bytes); if (!parsed.ok) throw parsed.cause; const facts = webBundleScriptAnalysisSchema.parse(parsed.value); - if (facts.status !== "analyzed" || facts.script_key !== script.script_key) + if (facts.script_key !== script.script_key) throw new AnalysisOutputError( OPERATION, "Web analyzer facts name another script", ); - return facts; + if (facts.status === "analyzed") return facts; + if (facts.partial === undefined) + throw new AnalysisOutputError( + OPERATION, + "Web analyzer returned an unavailable result without partial facts", + ); + // The refusal and its recovery come from the allowance this host sent. + return { + status: "unavailable", + script_key: facts.script_key, + reason: "resource_constraint", + ...unavailableWebScriptRecovery( + javaScriptMemberMutationRefusal(OPERATION, memberMutationAllowance), + ), + partial: facts.partial, + }; } #reset(): void { diff --git a/src/browser/WebBundleAnalysisWorkerProtocol.ts b/src/browser/WebBundleAnalysisWorkerProtocol.ts index b4765e55e6..11df94370c 100644 --- a/src/browser/WebBundleAnalysisWorkerProtocol.ts +++ b/src/browser/WebBundleAnalysisWorkerProtocol.ts @@ -1,3 +1,4 @@ +import { javaScriptMemberMutationAllowanceSchema } from "../domain/javascript/javascriptSemanticWorkAllowance.js"; import { javaScriptSemanticPrecisionSchema } from "../domain/javascript/javascriptSemanticPrecision.js"; import { z } from "zod"; import { JAVASCRIPT_STATIC_FACT_BYTES } from "../domain/javascript/javascriptAnalysisResourceControls.js"; @@ -38,6 +39,7 @@ export const includedWebScriptSchema = browserScriptSchema export const webBundleWorkerRequestSchema = z.strictObject({ kind: z.literal("analyze"), semantic_precision: javaScriptSemanticPrecisionSchema.exactOptional(), + member_mutation_allowance: javaScriptMemberMutationAllowanceSchema, id: z.uuid(), source: z.strictObject({ bytes: z.number().int().min(0).max(WEB_BUNDLE_WORKER_LIMITS.sourceBytes), diff --git a/src/browser/WebBundleAnalysisWorkerServer.test.ts b/src/browser/WebBundleAnalysisWorkerServer.test.ts index 00f747e08e..fe159e5751 100644 --- a/src/browser/WebBundleAnalysisWorkerServer.test.ts +++ b/src/browser/WebBundleAnalysisWorkerServer.test.ts @@ -59,6 +59,9 @@ const writeSource = async ( }); }; +/** The parent always sends its accepted allowance with an analysis request. */ +const DEFAULT_ALLOWANCE = { alias_states: 524_288, source: "default" } as const; + const startServer = async ( root: string, analyzer = "analyzeCapturedWebScript", @@ -145,7 +148,7 @@ const closeServer = async ( } }; -it("reports a fixed work refusal as a resource failure with its limit", async () => { +it("refuses with the allowance the parent sent and reports its limit", async () => { const root = await mkdtemp(join(tmpdir(), "rea-web-worker-work-")); let worker: ChildProcessWithoutNullStreams | undefined; onTestFinished(async () => { @@ -154,7 +157,7 @@ it("reports a fixed work refusal as a resource failure with its limit", async () }); const server = await startServer( root, - "() => new JavaScriptSemanticWorkMeter(0).charge()", + "(_script, _precision, allowance) => new JavaScriptSemanticWorkMeter(allowance).charge(2)", ); worker = server.process; expect(await server.next()).toMatchObject({ kind: "ready" }); @@ -163,6 +166,7 @@ it("reports a fixed work refusal as a resource failure with its limit", async () await writeSource(root, id, input.bytes); await send(worker, { kind: "analyze", + member_mutation_allowance: { alias_states: 1, source: "runtime" }, id, source: input.descriptor, result_budget_bytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes, @@ -172,8 +176,8 @@ it("reports a fixed work refusal as a resource failure with its limit", async () kind: "failure", id, reason: "resource", - limits: { [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: 0 }, - message: expect.stringMatching(/more than 0 alias states/u), + limits: { [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: 1 }, + message: expect.stringMatching(/more than 1 alias states/u), }); }); @@ -198,6 +202,7 @@ it("serves split and coalesced LF/CRLF requests as separate jobs", async () => { const ids = [randomUUID(), randomUUID()]; const requests = ids.map((id) => ({ kind: "analyze", + member_mutation_allowance: DEFAULT_ALLOWANCE, id, source: input.descriptor, result_budget_bytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes, @@ -230,6 +235,7 @@ it.each([ await writeSource(server.root, id, input.bytes); const request = JSON.stringify({ kind: "analyze", + member_mutation_allowance: DEFAULT_ALLOWANCE, id, source: input.descriptor, result_budget_bytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes, @@ -271,6 +277,7 @@ it("rejects an oversized unterminated frame at EOF without starting analysis", a await writeSource(server.root, id, input.bytes); const request = JSON.stringify({ kind: "analyze", + member_mutation_allowance: DEFAULT_ALLOWANCE, id, source: input.descriptor, result_budget_bytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes, @@ -312,6 +319,7 @@ it("bounds and binds per-script worker jobs, then reuses one worker for the next await writeSource(root, randomUUID(), input.bytes); await send(worker, { kind: "analyze", + member_mutation_allowance: DEFAULT_ALLOWANCE, id: missingId, source: input.descriptor, result_budget_bytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes, @@ -327,6 +335,7 @@ it("bounds and binds per-script worker jobs, then reuses one worker for the next await writeSource(root, digestId, input.bytes); await send(worker, { kind: "analyze", + member_mutation_allowance: DEFAULT_ALLOWANCE, id: digestId, source: { ...input.descriptor, sha256: "0".repeat(64) }, result_budget_bytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes, @@ -341,6 +350,7 @@ it("bounds and binds per-script worker jobs, then reuses one worker for the next await writeSource(root, budgetId, input.bytes); await send(worker, { kind: "analyze", + member_mutation_allowance: DEFAULT_ALLOWANCE, id: budgetId, source: input.descriptor, result_budget_bytes: 1, @@ -362,6 +372,7 @@ it("bounds and binds per-script worker jobs, then reuses one worker for the next await writeSource(root, nextId, nextInput.bytes); await send(worker, { kind: "analyze", + member_mutation_allowance: DEFAULT_ALLOWANCE, id: nextId, source: nextInput.descriptor, result_budget_bytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes, @@ -403,6 +414,7 @@ it("bounds and binds per-script worker jobs, then reuses one worker for the next await writeSource(root, routesId, routesInput.bytes); await send(worker, { kind: "analyze", + member_mutation_allowance: DEFAULT_ALLOWANCE, id: routesId, source: routesInput.descriptor, result_budget_bytes: offered, diff --git a/src/browser/WebBundleAnalysisWorkerServer.ts b/src/browser/WebBundleAnalysisWorkerServer.ts index fc04811377..7ad879fd44 100644 --- a/src/browser/WebBundleAnalysisWorkerServer.ts +++ b/src/browser/WebBundleAnalysisWorkerServer.ts @@ -1,3 +1,7 @@ +import { + javaScriptMemberMutationAllowanceFromField, + type JavaScriptMemberMutationAllowance, +} from "../domain/javascript/javascriptSemanticWorkAllowance.js"; import type { JavaScriptSemanticPrecisionInput } from "../domain/javascript/javascriptSemanticPrecision.js"; import { fitJavaScriptRouteSourceFacts } from "../domain/javascript/javascriptRouteResultBudget.js"; import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; @@ -31,7 +35,8 @@ import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js" export interface WebBundleScriptAnalyzer { ( script: IncludedScript, - precision?: JavaScriptSemanticPrecisionInput, + precision: JavaScriptSemanticPrecisionInput | undefined, + memberMutationAllowance: JavaScriptMemberMutationAllowance, ): WebBundleScriptAnalysis; } @@ -134,7 +139,13 @@ const analyzeJob = async ( let result: WebBundleScriptAnalysis; try { result = webBundleScriptAnalysisSchema.parse( - analyze(script, request.semantic_precision), + analyze( + script, + request.semantic_precision, + javaScriptMemberMutationAllowanceFromField( + request.member_mutation_allowance, + ), + ), ); } catch (cause) { if (cause instanceof AnalysisResourceConstraintError) throw cause; @@ -142,11 +153,13 @@ const analyzeJob = async ( `Web bundle analyzer returned an invalid result: ${cause instanceof Error ? cause.message : String(cause)}`, ); } - if (result.status !== "analyzed") + // A refused script crosses with its partial facts; the parent states the + // refusal from the allowance it sent. + if (result.status !== "analyzed" && result.partial === undefined) throw new WebBundleWorkerOutputError( "Web bundle analyzer did not produce a completed per-script result", ); - if (result.route_facts != null) { + if (result.status === "analyzed" && result.route_facts != null) { const { route_facts, ...existing } = result; const available = request.result_budget_bytes - diff --git a/src/cli/browserCommands.ts b/src/cli/browserCommands.ts index a15d3aa750..517f659db9 100644 --- a/src/cli/browserCommands.ts +++ b/src/cli/browserCommands.ts @@ -48,10 +48,11 @@ import { cliProgressReporter } from "./cliProgress.js"; export const registerBrowserCommands = ( cli: ReturnType, logger: Logger, + environment: Readonly>, ): void => { registerTargetList(cli, logger); registerPageInspection(cli, logger); - registerBundleAnalysis(cli, logger); + registerBundleAnalysis(cli, logger, environment); registerObservationSession(cli, logger); }; @@ -168,6 +169,7 @@ const registerPageInspection = ( const registerBundleAnalysis = ( cli: ReturnType, logger: Logger, + environment: Readonly>, ): void => { cli.command(CLI_COMMANDS.analyzeWebBundle, { description: @@ -224,7 +226,10 @@ const registerBundleAnalysis = ( signal, ); if (precision !== undefined && !precision.ok) return precision.error; - const provider = createBrowserObservationProvider(); + const provider = createBrowserObservationProvider( + undefined, + environment, + ); const input = { cdp_endpoint: args.endpoint, allowed_origins: options.allowedOrigins, diff --git a/src/cli/cliCommandRegistrations.ts b/src/cli/cliCommandRegistrations.ts index a8039b2dbe..e240ddaa2e 100644 --- a/src/cli/cliCommandRegistrations.ts +++ b/src/cli/cliCommandRegistrations.ts @@ -258,7 +258,11 @@ export const CLI_COMMAND_REGISTRATIONS = { browser: plain( () => import("./browserCommands.js"), (module, context) => - module.registerBrowserCommands(context.cli, context.logger), + module.registerBrowserCommands( + context.cli, + context.logger, + context.environment, + ), ), webRuntime: plain( () => import("./webRuntimeCommands.js"), diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index 070c4d3187..1f55ea4d1b 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -1,4 +1,5 @@ import { contractNamed } from "./contractCliDescription.js"; +import { resolveJavaScriptMemberMutationAllowance } from "../config/javascriptMemberMutationAllowance.js"; import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { cliDecimalField } from "./options.js"; @@ -333,10 +334,18 @@ const registerReferenceSourceCommand = ( error: "Import failed", ...projectAnalysisError(config.error), }; + const memberMutationAllowance = + resolveJavaScriptMemberMutationAllowance(environment); + if (!memberMutationAllowance.ok) + return { + error: "Import failed", + ...projectAnalysisError(memberMutationAllowance.error), + }; return withCommandCancellation((signal) => runReferenceSourceImport( { root: args.root, + memberMutationAllowance: memberMutationAllowance.value, caller: "rea-cli", policy: config.value.referenceSourcePolicy, importer: PRODUCT_IDENTITY.packageName, diff --git a/src/composition/browserObservation.ts b/src/composition/browserObservation.ts index 9fa6d09e53..41de05395a 100644 --- a/src/composition/browserObservation.ts +++ b/src/composition/browserObservation.ts @@ -1,8 +1,22 @@ import type { BrowserObservationPort } from "../application/BrowserObservationPort.js"; import { CdpBrowserProvider } from "../browser/CdpBrowserProvider.js"; import type { WebBundleAnalysisAdmission } from "../browser/IsolatedWebBundleAnalyzer.js"; +import { resolveJavaScriptMemberMutationAllowance } from "../config/javascriptMemberMutationAllowance.js"; +import type { AnalysisSettingsReader } from "../domain/analysisSettings.js"; -/** Construct a fresh provider without opening a target or acquiring an engine. */ +/** + * Construct a fresh provider without opening a target or acquiring an engine. + * Bundle analysis reads the member-mutation allowance from the accepted + * operation's settings and the launch environment when it starts. + */ export const createBrowserObservationProvider = ( admission?: WebBundleAnalysisAdmission, -): BrowserObservationPort => new CdpBrowserProvider(admission); + environment?: Readonly>, + settings?: AnalysisSettingsReader, +): BrowserObservationPort => + new CdpBrowserProvider(admission, () => + resolveJavaScriptMemberMutationAllowance( + environment, + settings?.().overrides, + ), + ); diff --git a/src/composition/javascriptAnalysisWorkerProcess.ts b/src/composition/javascriptAnalysisWorkerProcess.ts index 7ddf97fe57..b1c399d0ed 100644 --- a/src/composition/javascriptAnalysisWorkerProcess.ts +++ b/src/composition/javascriptAnalysisWorkerProcess.ts @@ -1,6 +1,11 @@ import { moduleResolutionModeForJavaScriptReference } from "../domain/javascript/javascriptModuleResolutionMode.js"; import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js"; -import { analyzeParsedJavaScriptStaticSourceSteps } from "../domain/javascript/javascriptStaticAnalysis.js"; +import { + analyzeParsedJavaScriptStaticSourceSteps, + memberMutationRefusedJavaScriptStaticAnalysis, +} from "../domain/javascript/javascriptStaticAnalysis.js"; +import type { JavaScriptStaticAnalysis } from "../domain/javascript/javascriptStaticAnalysisTypes.js"; +import { isJavaScriptMemberMutationRefusal } from "../domain/javascript/javascriptSemanticWorkAllowance.js"; import { analyzeParsedJavaScriptSemanticsSteps } from "../domain/javascript/javascriptSemanticAnalysis.js"; import { failedJavaScriptStaticAnalysis } from "../domain/javascript/javascriptStaticAnalysisHelpers.js"; import { projectJavaScriptModuleSemantics } from "../domain/javascript/javascriptModuleSemanticIr.js"; @@ -23,6 +28,7 @@ const analyze: JavaScriptSourceWorkerAnalyzer = async ( packageType, purpose, precision, + memberMutationAllowance, ) => { if (!file.text.included) throw new TypeError("JavaScript worker requires decoded source text"); @@ -33,13 +39,29 @@ const analyze: JavaScriptSourceWorkerAnalyzer = async ( return null; } await callbacks.phase("static"); - const facts = await completeJavaScriptAnalysisSteps( - analyzeParsedJavaScriptStaticSourceSteps( - file.text.value, - parsed, - precision, - ), - ); + let facts: JavaScriptStaticAnalysis; + try { + facts = await completeJavaScriptAnalysisSteps( + analyzeParsedJavaScriptStaticSourceSteps( + file.text.value, + parsed, + precision, + memberMutationAllowance, + ), + ); + } catch (cause: unknown) { + // The semantic phase indexes the same mutations under the same + // allowance, so it is not started; the syntax facts cross first. + if (purpose === "analysis" && isJavaScriptMemberMutationRefusal(cause)) + await callbacks.checkpoint( + memberMutationRefusedJavaScriptStaticAnalysis( + file.text.value, + parsed, + cause, + ), + ); + throw cause; + } if (purpose === "preload-discovery") { // Only declarations cross this preliminary pass. The normal pass owns // full static/semantic facts and their result reservations. @@ -75,6 +97,7 @@ const analyze: JavaScriptSourceWorkerAnalyzer = async ( moduleResolutionModeForJavaScriptReference(file.path, "static-import"), capacity.nodes, precision, + memberMutationAllowance, ), ); parsed = null; diff --git a/src/composition/optionalObservationProviders.ts b/src/composition/optionalObservationProviders.ts index 9b3ee00512..12ecdd1163 100644 --- a/src/composition/optionalObservationProviders.ts +++ b/src/composition/optionalObservationProviders.ts @@ -41,7 +41,11 @@ export const createOptionalObservationFactories = ( const { WebBundleAnalysisAdmission: Admission } = await import("../browser/IsolatedWebBundleAnalyzer.js"); webBundleAdmission ??= new Admission(); - return browser.createBrowserObservationProvider(webBundleAdmission); + return browser.createBrowserObservationProvider( + webBundleAdmission, + environment, + settings, + ); }, browserScenarioCapture: async () => (await import("./browserScenario.js")).createBrowserScenarioProvider( diff --git a/src/config/analysisSettings.ts b/src/config/analysisSettings.ts index b3bd9cfbd6..b2e2e529ab 100644 --- a/src/config/analysisSettings.ts +++ b/src/config/analysisSettings.ts @@ -21,6 +21,7 @@ import { regexWorkerHeapRequestMiBSchema } from "./regexWorkerHeapRequest.js"; import { captureDecoderOldGenerationMiBSchema } from "./captureDecoderHeap.js"; import { capturePacketJsonExpansionBytesSchema } from "./capturePacketJsonExpansion.js"; import { parseNativeMatchingAllowance } from "./nativeMatchingAllowance.js"; +import { javaScriptMemberMutationAliasStatesSchema } from "./javascriptMemberMutationAllowance.js"; import { bridgeMaxRequestBytesSchema } from "./bridgeMaxRequestBytes.js"; import { bridgeMaxReplyBytesSchema } from "./bridgeMaxReplyBytes.js"; import { @@ -51,6 +52,8 @@ const environmentSchemas: Readonly>> = captureDecoderOldGenerationMiBSchema, REA_CAPTURE_PACKET_JSON_EXPANSION_BYTES: capturePacketJsonExpansionBytesSchema, + REA_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES: + javaScriptMemberMutationAliasStatesSchema, REA_BRIDGE_MAX_REQUEST_BYTES: bridgeMaxRequestBytesSchema, REA_BRIDGE_MAX_REPLY_BYTES: bridgeMaxReplyBytesSchema, REA_DEBUGGER_COMMAND_DEADLINE_MS: debuggerCommandDeadlineMsSchema, diff --git a/src/config/javascriptMemberMutationAllowance.test.ts b/src/config/javascriptMemberMutationAllowance.test.ts new file mode 100644 index 0000000000..02475590a5 --- /dev/null +++ b/src/config/javascriptMemberMutationAllowance.test.ts @@ -0,0 +1,75 @@ +import { expect, it } from "vitest"; + +import { AnalysisSettings } from "../application/AnalysisSettings.js"; +import { + DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, +} from "../domain/analysisResourceLimits.js"; +import { resolveJavaScriptMemberMutationAllowance } from "./javascriptMemberMutationAllowance.js"; + +const SEED = "REA_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES"; + +it("selects the runtime override, then the launch seed, then the default", () => { + expect(resolveJavaScriptMemberMutationAllowance({})).toEqual({ + ok: true, + value: { + aliasStates: DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + source: "default", + }, + }); + expect(resolveJavaScriptMemberMutationAllowance({ [SEED]: "1024" })).toEqual({ + ok: true, + value: { aliasStates: 1024, source: "setting" }, + }); + expect( + resolveJavaScriptMemberMutationAllowance( + { [SEED]: "not-a-number" }, + { "javascript.member_mutation_alias_states": 2048 }, + ), + ).toEqual({ ok: true, value: { aliasStates: 2048, source: "runtime" } }); +}); + +it("refuses a malformed or out-of-range launch seed instead of the default", () => { + for (const value of [ + "0", + "1.5", + "-1", + String(MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES + 1), + ]) { + const resolved = resolveJavaScriptMemberMutationAllowance({ + [SEED]: value, + }); + expect(resolved.ok, value).toBe(false); + if (!resolved.ok) + expect(resolved.error.settings).toEqual([ + expect.objectContaining({ setting: SEED }), + ]); + } +}); + +it("accepts runtime updates only within the validated range", () => { + const store = new AnalysisSettings({}); + for (const value of [0, MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES + 1]) + expect( + store.update({ + changes: [ + { + setting: "javascript.member_mutation_alias_states", + action: "set", + value, + }, + ], + }).ok, + ).toBe(false); + expect( + store.update({ + changes: [ + { + setting: "javascript.member_mutation_alias_states", + action: "set", + value: MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + }, + ], + }).ok, + ).toBe(true); +}); diff --git a/src/config/javascriptMemberMutationAllowance.ts b/src/config/javascriptMemberMutationAllowance.ts new file mode 100644 index 0000000000..1f444d57b3 --- /dev/null +++ b/src/config/javascriptMemberMutationAllowance.ts @@ -0,0 +1,57 @@ +import { + DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, +} from "../domain/analysisResourceLimits.js"; +import type { AnalysisSettingValues } from "../domain/analysisSettings.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { + JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES_ENVIRONMENT, + type JavaScriptMemberMutationAllowance, +} from "../domain/javascript/javascriptSemanticWorkAllowance.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { positiveDecimalIntegerSchema } from "./positiveDecimalInteger.js"; + +const constraint = `${JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES_ENVIRONMENT} must be a decimal integer from 1 to ${String(MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES)} alias states; the default is ${String(DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES)}.`; + +/** Optional decimal count for the JavaScript member-mutation work allowance. */ +export const javaScriptMemberMutationAliasStatesSchema = + positiveDecimalIntegerSchema( + 1, + MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + constraint, + ); + +/** + * Resolve the allowance for one accepted operation: a runtime override wins, + * then the launch seed, then the default. A malformed seed is refused before + * analysis, never replaced by the default. + */ +export const resolveJavaScriptMemberMutationAllowance = ( + environment: Readonly> | undefined, + overrides?: Readonly>, +): Result => { + const override = overrides?.["javascript.member_mutation_alias_states"]; + if (override !== undefined) + return ok({ aliasStates: override, source: "runtime" }); + const value = + environment?.[JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES_ENVIRONMENT]; + if (value === undefined) + return ok({ + aliasStates: DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + source: "default", + }); + const parsed = javaScriptMemberMutationAliasStatesSchema.safeParse(value); + return parsed.success + ? ok({ aliasStates: parsed.data, source: "setting" }) + : err( + new ConfigurationError(constraint, { + remediationAction: `On a running MCP session, call update_analysis_settings with ${JSON.stringify({ changes: [{ setting: "javascript.member_mutation_alias_states", action: "set", value: DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES }] })}, then retry. For CLI or future REA processes, correct ${JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES_ENVIRONMENT} in the launch environment.`, + settings: [ + { + setting: JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES_ENVIRONMENT, + constraint, + }, + ], + }), + ); +}; diff --git a/src/domain/analysisResourceLimits.ts b/src/domain/analysisResourceLimits.ts index afbdb5af83..175d84e0c6 100644 --- a/src/domain/analysisResourceLimits.ts +++ b/src/domain/analysisResourceLimits.ts @@ -29,6 +29,20 @@ export const MAXIMUM_GHIDRA_VALUE_FLOW_RECORDS = 2_147_483_647; /** Ghidra 12.1.4 shifts MiB by 20 into a signed Java int response capacity. */ export const MAXIMUM_GHIDRA_DECOMPILE_PAYLOAD_MIB = 2_047; +/** + * Alias states one JavaScript source's member-mutation phase may schedule by + * default. Each state is retained until its query finishes, at roughly + * 1–2 KiB, so the default stays well below the default 1 GiB worker heap. + * Published bundles up to 395 KB schedule at most about 123,000 states. + */ +export const DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES = 524_288; + +/** + * At about 2 KiB per retained state this is 16 GiB, the largest JavaScript + * worker heap `max_heap_mb` admits; a larger allowance could only exhaust it. + */ +export const MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES = 8_388_608; + /** Native function-matching representation admitted when the setting is unset. */ export const DEFAULT_NATIVE_MATCHING_REPRESENTATION_BYTES = 96 * 1024 * 1024; diff --git a/src/domain/analysisSettingDefinitions.ts b/src/domain/analysisSettingDefinitions.ts index fa5a6546c3..15a09b107d 100644 --- a/src/domain/analysisSettingDefinitions.ts +++ b/src/domain/analysisSettingDefinitions.ts @@ -33,6 +33,8 @@ import { DEFAULT_JADX_OPERATION_TIMEOUT_MS, DEFAULT_NATIVE_MATCHING_REPRESENTATION_BYTES, MAXIMUM_NATIVE_MATCHING_REPRESENTATION_BYTES, + DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, DEFAULT_BRIDGE_MAX_REQUEST_BYTES, MINIMUM_BRIDGE_MAX_REQUEST_BYTES, MAXIMUM_BRIDGE_MAX_REQUEST_BYTES, @@ -402,6 +404,18 @@ export const ANALYSIS_SETTINGS = { operations: ["analyze_javascript_application"], parameter: "max_heap_mb", }, + "javascript.member_mutation_alias_states": { + updateMode: "runtime", + environment: "REA_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES", + defaultValue: DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + minimum: 1, + maximum: MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + unit: "count", + appliesAt: "next_operation", + provider: "rea-javascript-application", + relatedProviders: ["web-bundle-static-analysis"], + operations: ["analyze_javascript_application", "analyze_web_bundle"], + }, "wasm.command_timeout_ms": { updateMode: "runtime", ...timer, @@ -594,6 +608,23 @@ export const analysisSettingIds = Object.keys( ANALYSIS_SETTINGS, ) as AnalysisSettingId[]; export const analysisSettingIdSchema = z.enum(analysisSettingIds); + +/** Limit facts a result carries when it records a refusal as a value. */ +export const analysisSettingLimitSchema = z.strictObject({ + setting: analysisSettingIdSchema, + effective_value: z.number().nullable(), + unit: z.enum(["bytes", "ms", "seconds", "MiB", "count"]), + minimum: z.number(), + maximum: z.number(), + source: z.enum(["default", "setting", "runtime", "request"]).exactOptional(), + applies_at: z.enum(["next_operation", "next_worker", "next_startup"]), + agent_editable: z.literal(true), + scope: z.literal("rea-runtime"), + update_mode: z.enum(["runtime", "restart", "parameter"]), + pending_restart: z.literal(false), + required_value: z.number().exactOptional(), + parameter: z.string().exactOptional(), +}) satisfies z.ZodType; export const analysisSettingDefinition = ( id: AnalysisSettingId, ): SettingDefinition => ANALYSIS_SETTINGS[id]; diff --git a/src/domain/analysisSettings.ts b/src/domain/analysisSettings.ts index a1a4a95331..a8258da2d1 100644 --- a/src/domain/analysisSettings.ts +++ b/src/domain/analysisSettings.ts @@ -16,6 +16,7 @@ export interface AnalysisSettingValues { readonly "capture.packet_json_expansion_bytes": number; readonly "javascript.analysis_timeout_ms": number; readonly "javascript.max_heap_mb": number; + readonly "javascript.member_mutation_alias_states": number; readonly "wasm.command_timeout_ms": number; readonly "native.matching_representation_bytes": number; readonly "bridge.max_request_bytes": number; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 7f5d6339a7..eff53b5a19 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -81,6 +81,10 @@ import { semanticBundledRequireOriginCandidate, } from "./javascriptBundledRequireOrigin.js"; import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js"; +import { + DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALLOWANCE, + type JavaScriptMemberMutationAllowance, +} from "./javascriptSemanticWorkAllowance.js"; import { semanticBindingAtRead, semanticEffectTiming, @@ -188,11 +192,17 @@ interface AddBindingInput { export const analyzeJavaScriptSemantics = ( source: string, precision?: JavaScriptSemanticPrecisionInput, + memberMutationAllowance?: JavaScriptMemberMutationAllowance, ): JavaScriptSemanticIr => { const file = parseJavaScriptSource(source); return file === null ? failedJavaScriptSemanticIr() - : analyzeParsedJavaScriptSemantics(file, "import", precision); + : analyzeParsedJavaScriptSemantics( + file, + "import", + precision, + memberMutationAllowance, + ); }; /** Recover lexical references, optionally for one name, without evaluating values or provenance. */ @@ -414,6 +424,7 @@ export const analyzeParsedJavaScriptSemantics = ( file: ParsedJavaScriptSource, staticImportResolutionMode: JavaScriptModuleResolutionMode = "import", precision?: JavaScriptSemanticPrecisionInput, + memberMutationAllowance?: JavaScriptMemberMutationAllowance, ): JavaScriptSemanticIr => completeSemanticSteps( analyzeParsedJavaScriptSemanticsSteps( @@ -421,6 +432,7 @@ export const analyzeParsedJavaScriptSemantics = ( staticImportResolutionMode, undefined, precision, + memberMutationAllowance, ), ); @@ -435,11 +447,13 @@ export function* analyzeParsedJavaScriptSemanticsSteps( staticImportResolutionMode: JavaScriptModuleResolutionMode = "import", expressionNodeBudget?: number, precision?: JavaScriptSemanticPrecisionInput, + memberMutationAllowance?: JavaScriptMemberMutationAllowance, ): Generator { const state = createState( file.program, staticImportResolutionMode, precision, + memberMutationAllowance, ); yield* collectDefinitionsSteps(file.program, state); yield* indexSemanticAmbientWritesSteps(state); @@ -543,8 +557,14 @@ export function* indexJavaScriptBindingsWithReceiversSteps( program: t.Program, hostContext?: "commonjs-wrapper", precision?: JavaScriptSemanticPrecisionInput, + memberMutationAllowance?: JavaScriptMemberMutationAllowance, ): Generator { - const state = createState(program, "import", precision); + const state = createState( + program, + "import", + precision, + memberMutationAllowance, + ); if (hostContext === "commonjs-wrapper") { const scope = currentSemanticScope(state.scopes); const binding: JavaScriptSemanticBindingState = { @@ -1200,6 +1220,7 @@ const createState = ( program: t.Program, staticImportResolutionMode: JavaScriptModuleResolutionMode = "import", precision?: JavaScriptSemanticPrecisionInput, + memberMutationAllowance: JavaScriptMemberMutationAllowance = DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALLOWANCE, ): JavaScriptSemanticAnalysisState => { const root: JavaScriptSemanticScopeState = { scopeId: semanticScopeId("program", program), @@ -1211,6 +1232,7 @@ const createState = ( }; return { precision: resolveJavaScriptSemanticPrecision(precision), + memberMutationAllowance, arrayIterationUnknown: false, memberMutationFactsComplete: false, program, diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 702c575664..765ae7372e 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -518,7 +518,7 @@ export function* collectSemanticMemberMutationsSteps( >(); // Alias states are retained until their query finishes; bound the phase // before a pathological alias history exhausts the worker heap. - const work = new JavaScriptSemanticWorkMeter(); + const work = new JavaScriptSemanticWorkMeter(state.memberMutationAllowance); // The binding pass already retained callable nodes. Ordinary data-only code // must not pay an alias walk for every member read or assignment. const accessorKeys = { diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index a7d2c008c4..81bc675bae 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -1,4 +1,5 @@ import type { JavaScriptSemanticPrecision } from "./javascriptSemanticPrecision.js"; +import type { JavaScriptMemberMutationAllowance } from "./javascriptSemanticWorkAllowance.js"; import type { SemanticPropertyReadSource } from "./javascriptSemanticIterationSources.js"; import * as t from "@babel/types"; import { @@ -151,6 +152,8 @@ export type SemanticPrototypeOwner = (typeof semanticPrototypeOwners)[number]; /** Shared state for semantic collection and evaluation. */ export interface JavaScriptSemanticAnalysisState { readonly precision: JavaScriptSemanticPrecision; + /** Alias states the member-mutation phase may schedule for this source. */ + readonly memberMutationAllowance: JavaScriptMemberMutationAllowance; arrayIterationUnknown: boolean; /** Receiver isolation requires the completed member-effect inventory. */ memberMutationFactsComplete: boolean; diff --git a/src/domain/javascript/javascriptSemanticWorkAllowance.test.ts b/src/domain/javascript/javascriptSemanticWorkAllowance.test.ts index dae0583a06..e35cdbffac 100644 --- a/src/domain/javascript/javascriptSemanticWorkAllowance.test.ts +++ b/src/domain/javascript/javascriptSemanticWorkAllowance.test.ts @@ -1,9 +1,13 @@ import { expect, it } from "vitest"; import { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; +import { + DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, +} from "../analysisResourceLimits.js"; import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; import { - JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT, + javaScriptMemberMutationRefusal, } from "./javascriptSemanticWorkAllowance.js"; /** @@ -22,32 +26,81 @@ const selections = (count: number): string => "m[1] = input.length; return m; } module.exports = probe;", ].join(" "); -it("refuses member-mutation work beyond the fixed allowance", () => { - let refusal: unknown; +const refusalOf = (run: () => unknown): AnalysisResourceConstraintError => { try { - analyzeJavaScriptSemantics(selections(8)); + run(); } catch (cause) { - refusal = cause; + if (cause instanceof AnalysisResourceConstraintError) return cause; + throw cause; } - if (!(refusal instanceof AnalysisResourceConstraintError)) - throw new Error("Expected a work allowance refusal", { cause: refusal }); + throw new Error("Expected a work allowance refusal"); +}; + +it("refuses member-mutation work beyond the default allowance and names its setting", () => { + const refusal = refusalOf(() => analyzeJavaScriptSemantics(selections(8))); expect(refusal).toMatchObject({ resource: "cpu", retryable: false, reportedLimits: { [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: - JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, + DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, }, - fixedAllowance: { - kind: "work", - limit: JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, - unit: "alias states", + limit: { + setting: "javascript.member_mutation_alias_states", + effective_value: DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + source: "default", + unit: "count", + maximum: MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + update_mode: "runtime", }, }); + expect(refusal.fixedAllowance).toBeUndefined(); +}); + +it("applies the selected allowance and reports the selected value", () => { + const source = selections(4); + expect(analyzeJavaScriptSemantics(source).coverage.status).toBe("complete"); + const refusal = refusalOf(() => + analyzeJavaScriptSemantics(source, undefined, { + aliasStates: 8, + source: "runtime", + }), + ); + expect(refusal.limit).toMatchObject({ + setting: "javascript.member_mutation_alias_states", + effective_value: 8, + source: "runtime", + }); + expect(refusal.reportedLimits).toMatchObject({ + [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: 8, + }); }); -it("completes the same shape within the allowance", () => { - expect(analyzeJavaScriptSemantics(selections(4)).coverage.status).toBe( - "complete", +it("does not advise raising an allowance already at its maximum", () => { + const below = javaScriptMemberMutationRefusal( + "analyze_javascript_semantics", + { + aliasStates: MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES - 1, + source: "setting", + }, ); + const capped = javaScriptMemberMutationRefusal( + "analyze_javascript_semantics", + { + aliasStates: MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + source: "runtime", + }, + ); + // Only an allowance below the maximum names the tools that raise it. + for (const tool of [ + "update_analysis_settings", + "REA_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES", + ]) { + expect(below.remediationAction).toContain(tool); + expect(capped.remediationAction).not.toContain(tool); + } + expect(capped.limit).toMatchObject({ + effective_value: MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + maximum: MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + }); }); diff --git a/src/domain/javascript/javascriptSemanticWorkAllowance.ts b/src/domain/javascript/javascriptSemanticWorkAllowance.ts index a5bd7a09f5..0aa5b4428b 100644 --- a/src/domain/javascript/javascriptSemanticWorkAllowance.ts +++ b/src/domain/javascript/javascriptSemanticWorkAllowance.ts @@ -1,60 +1,140 @@ +import { z } from "zod"; +import type { AnalysisCapturedOutput } from "../analysisErrorBase.js"; import { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; import { - fixedAllowanceOptions, - type FixedAllowance, -} from "../fixedAllowanceRemediation.js"; + DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, +} from "../analysisResourceLimits.js"; +import { analysisSettingLimit } from "../analysisSettingDefinitions.js"; +import type { AnalysisSettingLimit } from "../analysisSettings.js"; -/** - * Alias states one source's member-mutation phase may schedule. Each state is - * retained until its query finishes, at roughly 1–2 KiB, so the allowance stays - * well below the default 1 GiB worker heap. Published bundles up to 395 KB - * schedule at most about 123,000 states. - */ -export const JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE = 524_288; +/** Launch seed for CLI and future REA processes. */ +export const JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES_ENVIRONMENT = + "REA_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES"; /** Reported-limit key that names this allowance across worker boundaries. */ export const JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT = "javascript_member_mutation_alias_state_limit"; -const ALLOWANCE_NAME = "The JavaScript member-mutation work allowance"; +/** + * The analysis phase a work allowance refused. Facts that depend on it were + * not examined; syntax facts gathered before it remain. + */ +export const javaScriptRefusedPhaseSchema = z.literal("member-mutations"); -/** Fixed allowance facts for a refusal rebuilt from reported limits. */ -export const javaScriptSemanticWorkAllowance = ( - limits: Readonly> | null | undefined, -): - | { readonly retryable: false; readonly fixedAllowance: FixedAllowance } - | undefined => { - const limit = limits?.[JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]; - return typeof limit === "number" - ? fixedAllowanceOptions(ALLOWANCE_NAME, "work", { - limit, - unit: "alias states", - }) - : undefined; +/** The effective allowance for one operation and where it came from. */ +export interface JavaScriptMemberMutationAllowance { + readonly aliasStates: number; + readonly source: NonNullable; +} + +export const DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALLOWANCE: JavaScriptMemberMutationAllowance = + { + aliasStates: DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + source: "default", + }; + +/** Worker request field: the parent's resolved allowance for one source. */ +export const javaScriptMemberMutationAllowanceSchema = z.strictObject({ + alias_states: z + .int() + .min(1) + .max(MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES), + source: z.enum(["default", "setting", "runtime", "request"]), +}); +type JavaScriptMemberMutationAllowanceField = z.output< + typeof javaScriptMemberMutationAllowanceSchema +>; + +/** Encode an allowance for a worker request. */ +export const javaScriptMemberMutationAllowanceField = ( + allowance: JavaScriptMemberMutationAllowance, +): JavaScriptMemberMutationAllowanceField => ({ + alias_states: allowance.aliasStates, + source: allowance.source, +}); + +/** Decode the allowance a worker request carries. */ +export const javaScriptMemberMutationAllowanceFromField = ( + field: JavaScriptMemberMutationAllowanceField, +): JavaScriptMemberMutationAllowance => ({ + aliasStates: field.alias_states, + source: field.source, +}); + +const recovery = (allowance: JavaScriptMemberMutationAllowance): string => { + const current = `The effective javascript.member_mutation_alias_states value is ${String(allowance.aliasStates)} alias states (source ${allowance.source}; default ${String(DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES)}, maximum ${String(MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES)}).`; + const change = + allowance.aliasStates >= MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES + ? "It is already at the maximum; do not increase it further. Select another complete source within the allowance." + : `Raise it with update_analysis_settings on MCP, or ${JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES_ENVIRONMENT} for CLI or future REA processes, then retry. Each scheduled state retains about 1–2 KiB of worker heap until its query finishes, so raise max_heap_mb with it (about 2 MiB per 1,024 states).`; + return `${current} ${change} max_heap_mb, analysis_timeout_ms and semantic_precision alone do not admit the same source. Repeating the unchanged request fails the same way.`; }; /** - * Work meter for one analysis phase. Charging past the fixed allowance refuses - * the phase before its retained state can exhaust the worker heap; a partial + * The refusal for a source whose member-mutation phase exceeded the allowance. + * Worker parents rebuild it from the allowance they sent, so every adapter + * reports the same setting, value and recovery. + */ +export const javaScriptMemberMutationRefusal = ( + operation: string, + allowance: JavaScriptMemberMutationAllowance, + limits: Readonly> = {}, + capturedOutput?: AnalysisCapturedOutput, +): AnalysisResourceConstraintError => { + const remediationAction = recovery(allowance); + return new AnalysisResourceConstraintError( + operation, + "cpu", + `JavaScript member-mutation analysis scheduled more than ${String(allowance.aliasStates)} alias states for one source`, + { + ...limits, + [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: allowance.aliasStates, + }, + { + retryable: false, + remediationAction, + limit: analysisSettingLimit( + "javascript.member_mutation_alias_states", + allowance.aliasStates, + allowance.source, + ), + ...(capturedOutput === undefined ? {} : { capturedOutput }), + }, + ); +}; + +/** Whether a thrown value is this allowance's refusal. */ +export const isJavaScriptMemberMutationRefusal = ( + cause: unknown, +): cause is AnalysisResourceConstraintError => + cause instanceof AnalysisResourceConstraintError && + reportsJavaScriptMemberMutationRefusal(cause.reportedLimits); + +/** Whether a worker reported this allowance as the limit it reached. */ +export const reportsJavaScriptMemberMutationRefusal = ( + limits: Readonly> | null | undefined, +): boolean => + typeof limits?.[JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT] === "number"; + +/** + * Work meter for one analysis phase. Charging past the allowance refuses the + * phase before its retained state can exhaust the worker heap; a partial * mutation index would let later reads keep stale values. */ export class JavaScriptSemanticWorkMeter { #used = 0; constructor( - readonly allowance: number = JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, + readonly allowance: JavaScriptMemberMutationAllowance = DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALLOWANCE, ) {} charge(units = 1): void { this.#used += units; - if (this.#used <= this.allowance) return; - const limits = { [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: this.allowance }; - throw new AnalysisResourceConstraintError( + if (this.#used <= this.allowance.aliasStates) return; + throw javaScriptMemberMutationRefusal( "analyze_javascript_semantics", - "cpu", - `JavaScript member-mutation analysis scheduled more than ${String(this.allowance)} alias states for one source`, - limits, - javaScriptSemanticWorkAllowance(limits), + this.allowance, ); } } diff --git a/src/domain/javascript/javascriptSourceAnalysis.ts b/src/domain/javascript/javascriptSourceAnalysis.ts index b31c110c0b..5bd8fc0536 100644 --- a/src/domain/javascript/javascriptSourceAnalysis.ts +++ b/src/domain/javascript/javascriptSourceAnalysis.ts @@ -1,3 +1,4 @@ +import type { JavaScriptMemberMutationAllowance } from "./javascriptSemanticWorkAllowance.js"; import type { JavaScriptSemanticPrecisionInput } from "./javascriptSemanticPrecision.js"; import type { JavaScriptSemanticProjectionCapacity, @@ -40,6 +41,8 @@ export interface JavaScriptSourceAnalysisFailure { /** Operational choices for source analysis, independent of transport framing. */ export interface JavaScriptSourceAnalysisLimits { readonly semanticPrecision?: JavaScriptSemanticPrecisionInput; + /** Accepted member-mutation work allowance; absent means the default. */ + readonly memberMutationAllowance?: JavaScriptMemberMutationAllowance; readonly heapMb: number; /** Where `heapMb` was selected. Absent when the caller did not record it. */ readonly heapSource?: diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index d2cbcafbf9..aba14a9e2d 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -1,3 +1,6 @@ +import type { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; +import { failedJavaScriptStaticAnalysis } from "./javascriptStaticAnalysisHelpers.js"; +import type { JavaScriptMemberMutationAllowance } from "./javascriptSemanticWorkAllowance.js"; import { JAVASCRIPT_SEMANTIC_PRECISION_DEFAULTS, type JavaScriptSemanticPrecisionInput, @@ -81,11 +84,61 @@ import { type JavaScriptStaticCoverage, } from "./javascriptStaticCoverage.js"; +/** + * Syntax-level facts for a parsed source whose member-mutation phase was + * refused. Every finding that reads mutation facts is unexamined, so its + * absence here is not evidence of absence; parser, comment and text facts + * remain, marked with the refused phase. + */ +export const memberMutationRefusedJavaScriptStaticAnalysis = ( + source: string, + file: ParsedJavaScriptSource, + refusal: AnalysisResourceConstraintError, +): JavaScriptStaticAnalysis => { + const parserErrors = file.errors.length; + const accumulator = createJavaScriptAnalysisAccumulator(source.length); + addSourceMapDirectives(source, file.comments ?? [], accumulator); + const reason = `The member-mutation phase was refused (${refusal.reason}); findings that depend on member-mutation facts were not examined.`; + const coverage: JavaScriptStaticCoverage = { + syntax: + parserErrors === 0 + ? { status: "complete", error_count: 0 } + : { status: "recovered", error_count: parserErrors }, + semantics: { unknown_findings: null, uncertainty: [] }, + examination: { + status: "not-examined", + semantic_resource_limits: [], + electron_call_paths_limited: false, + refused_phase: "member-mutations", + }, + applicability: { electron: "unknown" }, + unexamined_reason: reason, + }; + return { + ...failedJavaScriptStaticAnalysis(), + parse_status: javaScriptStaticParseStatus(coverage), + analysis_coverage: coverage, + parse_error_count: parserErrors, + source_map_urls: accumulator.sourceMaps, + vendors: detectVendors(source), + limitations: [ + `${reason} Absence of findings is not evidence of absence.`, + ...(parserErrors > 0 + ? [ + "The parser recovered from syntax errors; affected bindings are partial.", + ] + : []), + "JavaScript syntax was parsed as data and was never evaluated.", + ], + }; +}; + /** Static analysis that yields during its source traversal; results are unchanged. */ export function* analyzeParsedJavaScriptStaticSourceSteps( source: string, file: ParsedJavaScriptSource, precision?: JavaScriptSemanticPrecisionInput, + memberMutationAllowance?: JavaScriptMemberMutationAllowance, ): Generator { const accumulator = createJavaScriptAnalysisAccumulator(source.length); // One definitions pass serves receiver classification and binding lookup. @@ -93,6 +146,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( file.program, undefined, precision, + memberMutationAllowance, ); const { bindings, receivers } = index; yield* index.indexAmbientWritesSteps(); diff --git a/src/domain/javascript/javascriptStaticCoverage.ts b/src/domain/javascript/javascriptStaticCoverage.ts index 61e6c63310..839557a23d 100644 --- a/src/domain/javascript/javascriptStaticCoverage.ts +++ b/src/domain/javascript/javascriptStaticCoverage.ts @@ -1,6 +1,7 @@ import { javaScriptSemanticPrecisionSchema } from "./javascriptSemanticPrecision.js"; import { z } from "zod"; import { javaScriptSemanticResourceLimitSchema } from "./javascriptSemanticValueTypes.js"; +import { javaScriptRefusedPhaseSchema } from "./javascriptSemanticWorkAllowance.js"; /** Independent observations from one inert static pass; no runtime completeness claim. */ export const javaScriptStaticCoverageSchema = z.strictObject({ @@ -34,6 +35,8 @@ export const javaScriptStaticCoverageSchema = z.strictObject({ semantic_resource_limits: z.array(javaScriptSemanticResourceLimitSchema), semantic_precision: javaScriptSemanticPrecisionSchema.optional(), electron_call_paths_limited: z.boolean(), + /** The analysis phase whose work allowance refused this source. */ + refused_phase: javaScriptRefusedPhaseSchema.exactOptional(), }), applicability: z.strictObject({ electron: z.enum(["observed", "unknown"]) }), unexamined_reason: z.string().min(1).exactOptional(), @@ -51,6 +54,7 @@ export const javaScriptStaticParseStatus = ( coverage.syntax.status === "not-examined" ? "failed" : coverage.syntax.status === "recovered" || + coverage.examination.status === "not-examined" || (coverage.semantics.unknown_findings ?? 0) > 0 || coverage.examination.semantic_resource_limits.includes( "alias-selections", diff --git a/src/domain/referenceSourceImportParsing.test.ts b/src/domain/referenceSourceImportParsing.test.ts new file mode 100644 index 0000000000..4983440770 --- /dev/null +++ b/src/domain/referenceSourceImportParsing.test.ts @@ -0,0 +1,59 @@ +import { expect, it } from "vitest"; + +import { javaScriptMemberMutationRefusal } from "./javascript/javascriptSemanticWorkAllowance.js"; +import { parseReferenceSourceImports } from "./referenceSourceImportParsing.js"; + +/** Conditional self-selections before one member write (#8294). */ +const SOURCE = [ + "function probe(input, env) { var m; if (env.first) m = env.first.info;", + ...Array.from( + { length: 4 }, + (_, index) => + `if (/case${String(index)}/.test(input)) m = m[${String(index % 2)}];`, + ), + "m[1] = input.length; return m; }", + "module.exports = probe;", + 'const other = require.call(null, "./other");', + 'import("./lazy");', +].join("\n"); + +it("keeps declaration-level imports when the member-mutation phase is refused", () => { + const admitted = parseReferenceSourceImports( + "probe.js", + Buffer.from(SOURCE), + "JavaScript", + "commonjs", + ); + expect(admitted.relationships.map(({ to, kind }) => [kind, to])).toEqual( + expect.arrayContaining([ + ["requires", "./other"], + ["imports", "./lazy"], + ]), + ); + const refused = parseReferenceSourceImports( + "probe.js", + Buffer.from(SOURCE), + "JavaScript", + "commonjs", + { aliasStates: 1, source: "setting" }, + ); + expect(refused.relationships.map(({ to, kind }) => [kind, to])).toEqual([ + ["imports", "./lazy"], + ]); + expect(refused.parse_failures).toEqual([]); + // The limitation carries the owning formatter's recovery for this file. + const owner = javaScriptMemberMutationRefusal( + "analyze_javascript_semantics", + { + aliasStates: 1, + source: "setting", + }, + ); + expect( + refused.limitations.filter( + (limitation) => + limitation.includes("probe.js") && + limitation.includes(owner.remediationAction ?? ""), + ), + ).toHaveLength(1); +}); diff --git a/src/domain/referenceSourceImportParsing.ts b/src/domain/referenceSourceImportParsing.ts index d5c17a68a6..814be060b3 100644 --- a/src/domain/referenceSourceImportParsing.ts +++ b/src/domain/referenceSourceImportParsing.ts @@ -1,3 +1,7 @@ +import { + isJavaScriptMemberMutationRefusal, + type JavaScriptMemberMutationAllowance, +} from "./javascript/javascriptSemanticWorkAllowance.js"; import type { File, Node } from "@babel/types"; import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js"; import { readExactJavaScriptLiteral } from "./javascript/javascriptAstValues.js"; @@ -280,12 +284,35 @@ const parseWithBabel = ( } }; +/** + * CommonJS require calls need the member-mutation index to prove the loader. + * When that phase is refused, the file keeps its declaration-level imports and + * records which requests were not examined instead of failing the import. + */ +const requireCallsOrRefusal = ( + ast: File, + path: string, + limitations: string[], + memberMutationAllowance: JavaScriptMemberMutationAllowance | undefined, +): ReturnType => { + try { + return referenceSourceRequireCalls(ast, path, memberMutationAllowance); + } catch (cause: unknown) { + if (!isJavaScriptMemberMutationRefusal(cause)) throw cause; + limitations.push( + `CommonJS require calls in ${path} were not examined because the member-mutation phase was refused (${cause.reason}). ${cause.remediationAction ?? ""}`.trimEnd(), + ); + return new Map(); + } +}; + /** Parse ESM/CommonJS imports and dynamic imports from source bytes. */ export const parseReferenceSourceImports = ( path: string, bytes: Uint8Array, language: string | null, packageType?: JavaScriptPackageType, + memberMutationAllowance?: JavaScriptMemberMutationAllowance, ): ReferenceSourceImportParseResult => { if (language !== null && !PARSEABLE_REFERENCE_SOURCE_LANGUAGES.has(language)) return { relationships: [], parse_failures: [], limitations: [] }; @@ -338,7 +365,7 @@ export const parseReferenceSourceImports = ( path, relationships, limitations, - referenceSourceRequireCalls(ast, path), + requireCallsOrRefusal(ast, path, limitations, memberMutationAllowance), ); return { diff --git a/src/domain/referenceSourceRequireBindings.ts b/src/domain/referenceSourceRequireBindings.ts index 3c81617003..5165b1dd5e 100644 --- a/src/domain/referenceSourceRequireBindings.ts +++ b/src/domain/referenceSourceRequireBindings.ts @@ -1,3 +1,4 @@ +import type { JavaScriptMemberMutationAllowance } from "./javascript/javascriptSemanticWorkAllowance.js"; import * as t from "@babel/types"; import { posix } from "node:path"; @@ -47,6 +48,7 @@ export type ReferenceSourceRequireInvocation = export const referenceSourceRequireCalls = ( file: Pick, path: string, + memberMutationAllowance?: JavaScriptMemberMutationAllowance, ): ReadonlyMap< ReferenceSourceRequireInvocation, ReferenceSourceRequireCall @@ -58,6 +60,8 @@ export const referenceSourceRequireCalls = ( indexJavaScriptBindingsWithReceiversSteps( file.program, runtimeContext === "module" ? undefined : "commonjs-wrapper", + undefined, + memberMutationAllowance, ), ); const factoryBindings: ReadonlySet = new Set(); diff --git a/src/domain/webBundleAnalysis.ts b/src/domain/webBundleAnalysis.ts index ca6a1ac541..f1d5a64729 100644 --- a/src/domain/webBundleAnalysis.ts +++ b/src/domain/webBundleAnalysis.ts @@ -1,5 +1,7 @@ import { MAX_NODE_TIMER_DELAY_MS } from "./analysisResourceLimits.js"; +import { analysisSettingLimitSchema } from "./analysisSettingDefinitions.js"; import { javaScriptSemanticPrecisionSchema } from "./javascript/javascriptSemanticPrecision.js"; +import { javaScriptRefusedPhaseSchema } from "./javascript/javascriptSemanticWorkAllowance.js"; import { fetchedSourceMapLimitsSchema } from "./sourceMapLimits.js"; import { z } from "zod"; import { browserExclusionSchema } from "./browserCompleteness.js"; @@ -519,6 +521,15 @@ export const webBundleAnalysisSchema = z.object({ dimension: z.string(), reason: z.string(), affected_script_keys: z.array(z.string()), + /** The setting that bounded these scripts, and how to change it. */ + limit: analysisSettingLimitSchema.exactOptional(), + remediation: z.string().exactOptional(), + /** + * The phase a work allowance refused for these scripts. Their parse + * status and text inferences remain; findings that read the phase's + * facts were not examined. + */ + refused_phase: javaScriptRefusedPhaseSchema.exactOptional(), }), ), completeness: z.object({ diff --git a/src/domain/webBundleAnalyzer.ts b/src/domain/webBundleAnalyzer.ts index 07c8b4931e..bd7f7ce74e 100644 --- a/src/domain/webBundleAnalyzer.ts +++ b/src/domain/webBundleAnalyzer.ts @@ -1,5 +1,12 @@ +import { + isJavaScriptMemberMutationRefusal, + type JavaScriptMemberMutationAllowance, +} from "./javascript/javascriptSemanticWorkAllowance.js"; import type { JavaScriptSemanticPrecisionInput } from "./javascript/javascriptSemanticPrecision.js"; -import { analyzeScript } from "./webBundleAnalyzerInspection.js"; +import { + analyzeScript, + scriptTextInferences, +} from "./webBundleAnalyzerInspection.js"; import { createWebBundleAnalysisAccumulator, type IncludedScript, @@ -9,13 +16,41 @@ import { type WebBundleScriptAnalysis, } from "./webBundleScriptAnalysis.js"; -/** Analyze one selected captured script without executing its source. */ +/** + * Analyze one selected captured script without executing its source. A script + * whose member-mutation phase is refused keeps its parse status and text + * inferences; the caller states the refusal and its recovery. + */ export const analyzeCapturedWebScript = ( script: IncludedScript, precision?: JavaScriptSemanticPrecisionInput, + memberMutationAllowance?: JavaScriptMemberMutationAllowance, ): WebBundleScriptAnalysis => { const accumulator = createWebBundleAnalysisAccumulator(); - const routeFacts = analyzeScript(script, accumulator, precision); + let routeFacts: ReturnType; + try { + routeFacts = analyzeScript( + script, + accumulator, + precision, + memberMutationAllowance, + ); + } catch (cause: unknown) { + if (!isJavaScriptMemberMutationRefusal(cause)) throw cause; + // Findings gathered before the refusal may have read an incomplete + // mutation index, so only facts that never read it are kept. + return webBundleScriptAnalysisSchema.parse({ + status: "unavailable", + script_key: script.script_key, + reason: "resource_constraint", + partial: { + refused_phase: "member-mutations", + parse_status: + accumulator.recoveredScripts.length > 0 ? "recovered" : "parsed", + inferences: scriptTextInferences(script), + }, + }); + } const failed = accumulator.failedScripts.length > 0; const recovered = accumulator.recoveredScripts.length > 0; return webBundleScriptAnalysisSchema.parse({ diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index 776b68662b..54b64fcffd 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -1,12 +1,14 @@ +import type { JavaScriptMemberMutationAllowance } from "./javascript/javascriptSemanticWorkAllowance.js"; import type { JavaScriptSemanticPrecisionInput } from "./javascript/javascriptSemanticPrecision.js"; import { isBrowserNetworkRequest, createXhrOpenRequestQuery, } from "./javascript/javascriptNetworkRequests.js"; import type { JavaScriptSemanticAnalysisState } from "./javascript/javascriptSemanticState.js"; -import type { - AnalysisAccumulator, - IncludedScript, +import { + createWebBundleAnalysisAccumulator, + type AnalysisAccumulator, + type IncludedScript, } from "./webBundleAnalyzerState.js"; import * as t from "@babel/types"; import { JavaScriptRouteScan } from "./javascript/javascriptRouteScan.js"; @@ -55,6 +57,7 @@ export const analyzeScript = ( script: IncludedScript, accumulator: AnalysisAccumulator, precision?: JavaScriptSemanticPrecisionInput, + memberMutationAllowance?: JavaScriptMemberMutationAllowance, ): JavaScriptRouteSourceFacts | null => { const file = parseJavaScriptSource(script.source.artifact.text); if (file === null) { @@ -70,6 +73,7 @@ export const analyzeScript = ( file.program, undefined, precision, + memberMutationAllowance, ), ); completeSemanticSteps(index.indexAmbientWritesSteps()); @@ -436,6 +440,15 @@ const addWebMcpDeclaration = ( ); }; +/** Inferences read from script text alone, without syntax or semantic facts. */ +export const scriptTextInferences = ( + script: IncludedScript, +): AnalysisAccumulator["inferences"] => { + const accumulator = createWebBundleAnalysisAccumulator(); + detectVendorFingerprints(script, accumulator); + return accumulator.inferences; +}; + const detectVendorFingerprints = ( script: IncludedScript, accumulator: AnalysisAccumulator, diff --git a/src/domain/webBundleAnalyzerResult.ts b/src/domain/webBundleAnalyzerResult.ts index 39d2bb74a6..f5361bf297 100644 --- a/src/domain/webBundleAnalyzerResult.ts +++ b/src/domain/webBundleAnalyzerResult.ts @@ -59,16 +59,7 @@ export const buildCapturedWebBundleAnalysis = ( { readonly status: "unavailable" } > => result.status === "unavailable", ); - const accumulator = mergeScriptResults( - orderedResults.filter( - ( - result, - ): result is Extract< - WebBundleScriptAnalysis, - { readonly status: "analyzed" } - > => result.status === "analyzed", - ), - ); + const accumulator = mergeScriptResults(orderedResults); const attemptedScripts = orderedResults.filter( (result) => !(result.status === "unavailable" && result.reason === "not_started"), @@ -203,13 +194,19 @@ export const buildCapturedWebBundleAnalysis = ( }; const mergeScriptResults = ( - results: readonly Extract< - WebBundleScriptAnalysis, - { readonly status: "analyzed" } - >[], + results: readonly WebBundleScriptAnalysis[], ): AnalysisAccumulator => { const accumulator = createWebBundleAnalysisAccumulator(); for (const result of results) { + if (result.status === "unavailable") { + // A refused script still parsed; its text inferences remain. + if (result.partial === undefined) continue; + accumulator.parsedScripts += 1; + if (result.partial.parse_status === "recovered") + accumulator.recoveredScripts.push(result.script_key); + appendAll(accumulator.inferences, result.partial.inferences); + continue; + } accumulator.visitedNodes += result.visited_ast_nodes; if (result.parse_status === "failed") { accumulator.failedScripts.push(result.script_key); @@ -325,10 +322,22 @@ const buildUnknowns = (input: UnknownsInput): WebBundleAnalysis["unknowns"] => [ ...(input.unavailableAnalyses.length === 0 ? [] : groupUnavailableAnalyses(input.unavailableAnalyses).map( - ({ reason, detail, affectedScriptKeys }) => ({ + ({ + reason, + detail, + limit, + remediation, + refusedPhase, + affectedScriptKeys, + }) => ({ dimension: "javascript_analysis" as const, reason: analysisUnavailableReason(reason, detail), affected_script_keys: affectedScriptKeys, + ...(limit === undefined ? {} : { limit }), + ...(remediation === undefined ? {} : { remediation }), + ...(refusedPhase === undefined + ? {} + : { refused_phase: refusedPhase }), }), )), ...(input.widenedScripts.length === 0 @@ -399,29 +408,42 @@ const groupUnavailableAnalyses = ( ): { readonly reason: (typeof unavailable)[number]["reason"]; readonly detail: string | undefined; + readonly limit: (typeof unavailable)[number]["limit"]; + readonly remediation: string | undefined; + readonly refusedPhase: + | NonNullable<(typeof unavailable)[number]["partial"]>["refused_phase"] + | undefined; readonly affectedScriptKeys: string[]; }[] => { + // Scripts share an unknown only when their structured recovery agrees too. const groups = new Map< - (typeof unavailable)[number]["reason"], - Map - >(); - for (const { reason, detail, script_key } of unavailable) { - let byDetail = groups.get(reason); - if (byDetail === undefined) { - byDetail = new Map(); - groups.set(reason, byDetail); + string, + { + readonly entry: (typeof unavailable)[number]; + readonly affectedScriptKeys: string[]; } - const keys = byDetail.get(detail) ?? []; - keys.push(script_key); - byDetail.set(detail, keys); + >(); + for (const entry of unavailable) { + const key = JSON.stringify([ + entry.reason, + entry.detail ?? null, + entry.limit ?? null, + entry.remediation ?? null, + entry.partial?.refused_phase ?? null, + ]); + const group = groups.get(key); + if (group === undefined) + groups.set(key, { entry, affectedScriptKeys: [entry.script_key] }); + else group.affectedScriptKeys.push(entry.script_key); } - return [...groups].flatMap(([reason, byDetail]) => - [...byDetail].map(([detail, affectedScriptKeys]) => ({ - reason, - detail, - affectedScriptKeys, - })), - ); + return [...groups.values()].map(({ entry, affectedScriptKeys }) => ({ + reason: entry.reason, + detail: entry.detail, + limit: entry.limit, + remediation: entry.remediation, + refusedPhase: entry.partial?.refused_phase, + affectedScriptKeys, + })); }; const analysisUnavailableReason = ( diff --git a/src/domain/webBundleScriptAnalysis.ts b/src/domain/webBundleScriptAnalysis.ts index 59d4117799..c58db6756b 100644 --- a/src/domain/webBundleScriptAnalysis.ts +++ b/src/domain/webBundleScriptAnalysis.ts @@ -1,7 +1,11 @@ import { z } from "zod"; import { webBundleAnalysisSchema } from "./webBundleAnalysis.js"; +import { analysisSettingLimitSchema } from "./analysisSettingDefinitions.js"; import { javaScriptRouteSourceFactsSchema } from "./javascript/javascriptRouteFacts.js"; +import { javaScriptRefusedPhaseSchema } from "./javascript/javascriptSemanticWorkAllowance.js"; +import type { AnalysisError } from "./analysisErrorBase.js"; +import { AnalysisResourceConstraintError } from "./analysisErrorCore.js"; const observationsSchema = z.strictObject({ edges: webBundleAnalysisSchema.shape.observations.shape.chunks.shape.edges, @@ -11,6 +15,16 @@ const observationsSchema = z.strictObject({ webBundleAnalysisSchema.shape.observations.shape.webmcp_declarations, }); +/** + * Syntax-level facts for a parsed script whose named phase was refused. + * Findings that read that phase's facts were not examined. + */ +const refusedScriptFactsSchema = z.strictObject({ + refused_phase: javaScriptRefusedPhaseSchema, + parse_status: z.enum(["parsed", "recovered"]), + inferences: webBundleAnalysisSchema.shape.inferences, +}); + /** Serializable result for one captured script, suitable for worker transport. */ export const webBundleScriptAnalysisSchema = z .discriminatedUnion("status", [ @@ -35,11 +49,14 @@ export const webBundleScriptAnalysisSchema = z "not_started", ]), detail: z.string().optional(), + /** The setting that bounded this script, when a setting refused it. */ + limit: analysisSettingLimitSchema.exactOptional(), + remediation: z.string().exactOptional(), + /** Facts gathered before a work allowance refused a later phase. */ + partial: refusedScriptFactsSchema.exactOptional(), }), ]) .superRefine((result, context) => { - if (result.status !== "analyzed") return; - const checkScriptKey = ( scriptKey: string, path: (string | number)[], @@ -51,6 +68,33 @@ export const webBundleScriptAnalysisSchema = z message: "Per-script analysis evidence must name its owning script", }); }; + const checkInferences = ( + inferences: z.output["inferences"], + path: (string | number)[], + ): void => + inferences.forEach((inference, inferenceIndex) => + inference.basis.forEach((basis, basisIndex) => + checkScriptKey(basis.script_key, [ + ...path, + inferenceIndex, + "basis", + basisIndex, + "script_key", + ]), + ), + ); + + if (result.status === "unavailable") { + if (result.partial === undefined) return; + if (result.reason !== "resource_constraint") + context.addIssue({ + code: "custom", + path: ["partial"], + message: "Only a refused script carries partial facts", + }); + checkInferences(result.partial.inferences, ["partial", "inferences"]); + return; + } result.observations.edges.forEach((edge, index) => { checkScriptKey(edge.from_script_key, [ @@ -86,17 +130,7 @@ export const webBundleScriptAnalysisSchema = z "script_key", ]), ); - result.inferences.forEach((inference, inferenceIndex) => - inference.basis.forEach((basis, basisIndex) => - checkScriptKey(basis.script_key, [ - "inferences", - inferenceIndex, - "basis", - basisIndex, - "script_key", - ]), - ), - ); + checkInferences(result.inferences, ["inferences"]); if (result.parse_status === "failed") { const hasFindings = @@ -122,3 +156,32 @@ export const webBundleScriptAnalysisSchema = z export type WebBundleScriptAnalysis = z.infer< typeof webBundleScriptAnalysisSchema >; + +type UnavailableWebScriptAnalysis = Extract< + WebBundleScriptAnalysis, + { readonly status: "unavailable" } +>; + +/** + * The detail and structured recovery an unavailable script states for the + * error that stopped its analysis. Only a registered setting's limit is kept. + */ +export const unavailableWebScriptRecovery = ( + error: AnalysisError, +): Pick => ({ + detail: + error instanceof AnalysisResourceConstraintError && + error.reportedLimits !== null + ? `${error.userMessage ?? error.reason} Reported limits: ${JSON.stringify(error.reportedLimits)}` + : (error.userMessage ?? error.message), + ...(error instanceof AnalysisResourceConstraintError && + error.limit !== undefined && + "setting" in error.limit + ? { + limit: error.limit, + ...(error.remediationAction === undefined + ? {} + : { remediation: error.remediationAction }), + } + : {}), +}); diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index a5e7a89746..89d76c3d31 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -1,4 +1,9 @@ -import { javaScriptSemanticWorkAllowance } from "../../domain/javascript/javascriptSemanticWorkAllowance.js"; +import { + DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALLOWANCE, + javaScriptMemberMutationAllowanceField, + javaScriptMemberMutationRefusal, + reportsJavaScriptMemberMutationRefusal, +} from "../../domain/javascript/javascriptSemanticWorkAllowance.js"; import { MAX_NODE_TIMER_DELAY_MS } from "../../domain/analysisResourceLimits.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; @@ -376,6 +381,10 @@ export class JavaScriptAnalysisWorker { ...(this.limits.semanticPrecision === undefined ? {} : { semantic_precision: this.limits.semanticPrecision }), + member_mutation_allowance: javaScriptMemberMutationAllowanceField( + this.limits.memberMutationAllowance ?? + DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALLOWANCE, + ), text_sha256: createHash("sha256").update(file.text.value).digest("hex"), file: metadata, package_type: packageType, @@ -1324,16 +1333,21 @@ export class JavaScriptAnalysisWorker { (parentBudget ? JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION : undefined); - // A fixed work allowance refuses the same source again; worker limits - // and other remediation do not raise it. - const fixed = javaScriptSemanticWorkAllowance(reported); + // The member-mutation allowance names its own setting; worker heap + // and other remediation do not admit the same source. + if (reportsJavaScriptMemberMutationRefusal(reported)) + return javaScriptMemberMutationRefusal( + OPERATION, + this.limits.memberMutationAllowance ?? + DEFAULT_JAVASCRIPT_MEMBER_MUTATION_ALLOWANCE, + limits, + ); return new AnalysisResourceConstraintError( OPERATION, response.resource ?? "memory", response.message, limits, - fixed ?? - (remediationAction === undefined ? undefined : { remediationAction }), + remediationAction === undefined ? undefined : { remediationAction }, ); } return new AnalysisOutputError( diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts index 70945ea7da..7c3f59eaa9 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts @@ -1,3 +1,4 @@ +import { javaScriptMemberMutationAllowanceSchema } from "../../domain/javascript/javascriptSemanticWorkAllowance.js"; import { javaScriptSemanticPrecisionSchema } from "../../domain/javascript/javascriptSemanticPrecision.js"; import { z } from "zod"; import { ANALYSIS_RESOURCE_KINDS } from "../../domain/analysisErrorCore.js"; @@ -27,6 +28,7 @@ export const javaScriptCheckpointCeilingRemediation = ( export const javaScriptWorkerRequestSchema = z.strictObject({ id: z.uuid(), semantic_precision: javaScriptSemanticPrecisionSchema.optional(), + member_mutation_allowance: javaScriptMemberMutationAllowanceSchema, unknown_budget: z.int().min(0).max(JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM), node_budget: z.int().min(0).max(JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM), text_sha256: digestSchema, diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerServer.test.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerServer.test.ts index 7c763d83a0..8f087b27fc 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerServer.test.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerServer.test.ts @@ -83,6 +83,7 @@ const startServer = async () => { }, package_type: "unknown", purpose: "analysis", + member_mutation_allowance: { alias_states: 524_288, source: "default" }, }); }, close: async () => { diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts index a49dade03a..c904d3f6e2 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts @@ -1,3 +1,7 @@ +import { + javaScriptMemberMutationAllowanceFromField, + type JavaScriptMemberMutationAllowance, +} from "../../domain/javascript/javascriptSemanticWorkAllowance.js"; import type { JavaScriptSemanticPrecisionInput } from "../../domain/javascript/javascriptSemanticPrecision.js"; import { fitJavaScriptRouteSourceFacts } from "../../domain/javascript/javascriptRouteResultBudget.js"; import { jsonValueByteLength } from "../../domain/jsonValueByteLength.js"; @@ -47,7 +51,8 @@ export interface JavaScriptSourceWorkerAnalyzer { }, packageType: JavaScriptPackageType, purpose: "analysis" | "preload-discovery", - precision?: JavaScriptSemanticPrecisionInput, + precision: JavaScriptSemanticPrecisionInput | undefined, + memberMutationAllowance: JavaScriptMemberMutationAllowance, ): Promise<{ readonly module: JavaScriptModuleSemanticIr; readonly projection: JavaScriptSemanticFileProjection; @@ -194,6 +199,9 @@ const analyzeJob = async ( request.package_type, request.purpose, request.semantic_precision, + javaScriptMemberMutationAllowanceFromField( + request.member_mutation_allowance, + ), ); await send({ kind: "phase", id: request.id, phase: "transfer" }); const descriptor = diff --git a/tests/boundary/javascript/javascriptMemberMutationAllowance.test.ts b/tests/boundary/javascript/javascriptMemberMutationAllowance.test.ts new file mode 100644 index 0000000000..4fb47ff8af --- /dev/null +++ b/tests/boundary/javascript/javascriptMemberMutationAllowance.test.ts @@ -0,0 +1,152 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES } from "../../../src/domain/analysisResourceLimits.js"; +import { javaScriptMemberMutationRefusal } from "../../../src/domain/javascript/javascriptSemanticWorkAllowance.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; + +/** Four conditional self-selections before one member write (#8294). */ +const SOURCE = [ + "function probe(input, env) { var m; if (env.first) m = env.first.info;", + ...Array.from( + { length: 4 }, + (_, index) => + `if (/case${String(index)}/.test(input)) m = m[${String(index % 2)}];`, + ), + "m[1] = input.length; return m; } module.exports = probe;", +].join(" "); + +const createApplication = async (): Promise => { + const root = await createTestTempDirectory("rea-member-mutation-setting-"); + const app = join(root, "app"); + await mkdir(app); + await writeFile(join(app, "main.js"), `${SOURCE}\n`); + await writeFile( + join(app, "package.json"), + JSON.stringify({ name: "fixture", main: "main.js" }), + ); + return app; +}; + +/** The per-source analysis failure the application graph retains. */ +const sourceFailure = (value: unknown): Record | undefined => { + if (value === null || typeof value !== "object") return undefined; + const record = value as Record; + if ( + typeof record.analysis_failure === "object" && + record.analysis_failure !== null + ) + return record.analysis_failure as Record; + for (const child of Object.values(record)) { + const found = sourceFailure(child); + if (found !== undefined) return found; + } + return undefined; +}; + +/** The static coverage of the one source whose member-mutation phase was refused. */ +const refusedCoverage = (value: unknown): unknown => { + if (value === null || typeof value !== "object") return undefined; + const record = value as Record; + const coverage = record.analysis_coverage as + | { readonly examination?: { readonly refused_phase?: string } } + | undefined; + if (coverage?.examination?.refused_phase !== undefined) return record; + for (const child of Object.values(record)) { + const found = refusedCoverage(child); + if (found !== undefined) return found; + } + return undefined; +}; + +it("applies the selected member-mutation allowance and reports it on refusal", async () => { + const app = await createApplication(); + const admitted = await analyzeJavaScriptApplication({ input_path: app }); + if (!admitted.ok) throw admitted.error; + expect(sourceFailure(admitted.value)).toBeUndefined(); + const cases = [ + { + options: { + environment: { REA_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES: "8" }, + }, + source: "setting", + }, + { + // The accepted runtime override wins over the launch seed. + options: { + environment: { REA_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES: "16" }, + settings: () => ({ + revision: 1, + overrides: { "javascript.member_mutation_alias_states": 8 }, + }), + }, + source: "runtime", + }, + ] as const; + for (const { options, source } of cases) { + const refused = await analyzeJavaScriptApplication( + { input_path: app }, + options, + ); + if (!refused.ok) throw refused.error; + // Structured limit facts, and the recovery the owning formatter gives. + const owner = javaScriptMemberMutationRefusal( + "analyze_javascript_application", + { aliasStates: 8, source }, + ); + expect(sourceFailure(refused.value), source).toMatchObject({ + code: "resource_constraint", + retryable: false, + details: { + limit: { + setting: "javascript.member_mutation_alias_states", + effective_value: 8, + source, + unit: "count", + minimum: 1, + maximum: MAXIMUM_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES, + update_mode: "runtime", + }, + }, + remediation: { action: owner.remediationAction }, + }); + // The refused source keeps its syntax-level facts, marked with the phase. + expect(refusedCoverage(refused.value), source).toMatchObject({ + path: "main.js", + parse_status: "partial", + analysis_coverage: { + syntax: { status: "complete", error_count: 0 }, + examination: { + status: "not-examined", + refused_phase: "member-mutations", + }, + }, + }); + expect( + ( + refused.value.normalized_result as { + readonly statistics: { readonly parsed_javascript_files: number }; + } + ).statistics.parsed_javascript_files, + ).toBe(1); + } + const raised = await analyzeJavaScriptApplication( + { input_path: app }, + { environment: { REA_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES: "4096" } }, + ); + if (!raised.ok) throw raised.error; + expect(sourceFailure(raised.value)).toBeUndefined(); + expect(refusedCoverage(raised.value)).toBeUndefined(); +}); + +it("refuses a malformed launch seed before analysis", async () => { + const app = await createApplication(); + const refused = await analyzeJavaScriptApplication( + { input_path: app }, + { environment: { REA_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES: "0" } }, + ); + if (refused.ok) throw new Error("A malformed seed was admitted"); + expect(refused.error._tag).toBe("ConfigurationError"); +}); diff --git a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts index a0a47517e2..d68d0b0699 100644 --- a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts +++ b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts @@ -15,10 +15,7 @@ import type { OwnedProcessGroup } from "../../../src/process/ProcessOwnership.js import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js"; -import { - JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, - JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT, -} from "../../../src/domain/javascript/javascriptSemanticWorkAllowance.js"; +import { JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT } from "../../../src/domain/javascript/javascriptSemanticWorkAllowance.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { javaScriptWorkerMemoryLimitBytes } from "../../../src/process/JavaScriptWorkerMemoryLimit.js"; @@ -115,6 +112,66 @@ it("reuses one real owned analyzer while retaining distinct source identities an } }); +it("applies a changed member-mutation allowance to the next analysis on the same scope", async () => { + const scope = new ArtifactResourceScope(); + // Four conditional self-selections before one member write (#8294). + const file = source( + "allowance.js", + [ + "function probe(input, env) { var m; if (env.first) m = env.first.info;", + ...Array.from( + { length: 4 }, + (_, index) => + `if (/case${String(index)}/.test(input)) m = m[${String(index % 2)}];`, + ), + "m[1] = input.length; return m; } module.exports = probe;", + ].join(" "), + ); + try { + for (const allowance of [ + undefined, + { aliasStates: 8, source: "runtime" } as const, + undefined, + ]) { + const worker = await createJavaScriptSourceAnalysis( + { + heapMb: 128, + timeoutMs: 30_000, + ...(allowance === undefined + ? {} + : { memberMutationAllowance: allowance }), + }, + 1, + { scope }, + ); + try { + const result = await worker.analyze(file, { + nodes: 100, + unknowns: 100, + }); + if (allowance === undefined) { + if (!result.ok) throw result.error.error; + continue; + } + expect(result.ok).toBe(false); + if (result.ok) + throw new Error("Expected the lowered allowance to refuse"); + expect(result.error.error).toMatchObject({ + limit: { + setting: "javascript.member_mutation_alias_states", + effective_value: 8, + source: "runtime", + }, + }); + } finally { + await worker.close(); + } + } + } finally { + await scope.close(); + } +}); + it("recovers finite primitive alternatives after changing precision on the same scope", async () => { const scope = new ArtifactResourceScope(); const expression = Array.from( @@ -760,16 +817,26 @@ it("reports the worker heap limit only when the runtime enforces V8 heap flags", } }); -it("names the fixed work allowance a worker refusal reports", async () => { - const worker = resourceFailureWorker( - 'process.versions.bun === undefined ? require("node:v8").getHeapStatistics().heap_size_limit : null', +it("sends the accepted member-mutation allowance and names its setting on refusal", async () => { + // The fixture reports the allowance the parent sent, as the real worker does. + const worker = new JavaScriptAnalysisWorker( { - resource: "cpu", - limits: { - [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: - JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, - }, + heapMb: 128, + timeoutMs: 30_000, + memberMutationAllowance: { aliasStates: 4096, source: "runtime" }, }, + (request) => + spawnOwnedProviderProcess({ + ...request, + arguments: [ + "-e", + `process.stdout.write(JSON.stringify({ kind: "ready", heap_limit_bytes: process.versions.bun === undefined ? require("node:v8").getHeapStatistics().heap_size_limit : null }) + "\\n"); +require("node:readline").createInterface({ input: process.stdin }).once("line", (line) => { + const request = JSON.parse(line); + process.stdout.write(JSON.stringify({ kind: "failure", id: request.id, reason: "resource", resource: "cpu", limits: { ${JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT}: request.member_mutation_allowance.alias_states, sent_source: request.member_mutation_allowance.source }, message: "fixture" }) + "\\n"); +});`, + ], + }), ); try { const result = await worker.analyze( @@ -783,13 +850,18 @@ it("names the fixed work allowance a worker refusal reports", async () => { expect(error).toMatchObject({ resource: "cpu", retryable: false, - remediationAction: undefined, - fixedAllowance: { - kind: "work", - limit: JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, - unit: "alias states", + reportedLimits: { + [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: 4096, + sent_source: "runtime", + }, + limit: { + setting: "javascript.member_mutation_alias_states", + effective_value: 4096, + source: "runtime", }, }); + expect(error.fixedAllowance).toBeUndefined(); + expect(error.remediationAction).toContain("update_analysis_settings"); } finally { await worker.close(); }