From 0f53849160d48026d885aa90948020eeaaa867f1 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 16 Nov 2026 00:05:14 +0800 Subject: [PATCH] feat(javascript): make the member-mutation work allowance a runtime setting (#11181) * feat(javascript): make the member-mutation work allowance a runtime setting The member-mutation alias-state allowance from #11144 was a fixed constant, so a refused source could not be admitted without a code change. Register javascript.member_mutation_alias_states (default 524288, 1 to 8388608 alias states) with the launch seed REA_JAVASCRIPT_MEMBER_MUTATION_ALIAS_STATES. The application, web bundle and reference-source boundaries resolve it from the accepted operation snapshot and launch environment, refuse a malformed seed before analysis, and send it to the workers with each source. A refusal names the setting, its effective value, source and range, and the recovery; worker parents rebuild it from the allowance they sent. Refs #8294 * fix(javascript): keep the setting and recovery of a refused web script A web bundle script refused by the member-mutation allowance kept only its reason and reported limits, so the public unknown lost the setting, value, source and recovery. Carry the refusal's structured limit and remediation on the per-script result and its javascript_analysis unknown, grouping scripts only when that recovery agrees. Assert structured limit facts and the owning formatter's recovery instead of fixed wording. * fix(browser): keep only registered setting limits on a refused web script * feat(javascript): keep syntax facts for a source whose member-mutation phase is refused (#11187) * feat(javascript): keep syntax facts for a source whose member-mutation phase is refused A source refused by the member-mutation work allowance during static analysis lost every fact, so the application graph counted it as skipped. Reference-source import failed outright on such a file. Checkpoint the parser, comment and text facts the static owner can still state, with parse status partial, examination not-examined and refused_phase member-mutations, before reporting the refusal; the semantic phase is not started because it indexes the same mutations. Reference-source import keeps the file's declaration-level imports and records that its CommonJS require calls were not examined. Refs #8294 * test(javascript): compare the reference-source refusal with its owning formatter * feat(javascript): keep a refused web script's parse and text facts A captured script whose member-mutation phase was refused lost every fact in analyze_web_bundle, so it was neither counted as parsed nor kept its vendor markers. The analyzer now returns the script as unavailable with partial facts: refused phase, parse status and the inferences read from script text alone. Findings gathered before the refusal are dropped, because they may have read an incomplete mutation index. The worker parent states the refusal and its setting recovery from the allowance it sent, through the same helper the isolated analyzer uses for every unavailable script. The bundle counts the script as parsed, keeps its text inferences, and its javascript_analysis unknown carries refused_phase. Refs #8294 * chore(mcp): record the member-mutation setting in the advertised summary * fix(javascript): rebuild a parked source worker when the member-mutation allowance changes A worker parked on a resource scope was reused when heap, timeout and precision matched, but its requests carry the allowance it was built with. After update_analysis_settings accepted a new allowance, the next analysis on that scope kept the old one. The reuse check now compares the allowance's value and source, so a changed setting gets a worker built with it. The web bundle worker already takes the allowance per script. --- docs/design/limits-and-recovery.md | 1 + docs/design/rea-limit-inventory.md | 31 ++-- .../javascript-artifact-reconstruction.md | 33 +++- .../tools/inspect_analysis_settings.json | 2 +- .../tools/update_analysis_settings.json | 2 +- src/application/ReferenceSourceImport.ts | 7 +- .../ReferenceSourceImportEntries.ts | 7 + src/application/ReferenceSourceImportTypes.ts | 3 + .../SetupRegistrationEnvironment.ts | 1 + .../JavaScriptApplicationService.ts | 7 + .../JavaScriptArtifactReconstruction.ts | 5 + .../javascript/JavaScriptSourceAnalysis.ts | 14 ++ src/browser/CdpBrowserProvider.ts | 27 +++- src/browser/IsolatedWebBundleAnalyzer.test.ts | 108 +++++++++++++ src/browser/IsolatedWebBundleAnalyzer.ts | 16 +- src/browser/WebBundleAnalysisWorker.test.ts | 84 ++++++++-- src/browser/WebBundleAnalysisWorker.ts | 85 +++++++--- .../WebBundleAnalysisWorkerProtocol.ts | 2 + .../WebBundleAnalysisWorkerServer.test.ts | 20 ++- src/browser/WebBundleAnalysisWorkerServer.ts | 21 ++- src/cli/browserCommands.ts | 9 +- src/cli/cliCommandRegistrations.ts | 6 +- src/cli/utilityCommands.ts | 9 ++ src/composition/browserObservation.ts | 18 ++- .../javascriptAnalysisWorkerProcess.ts | 39 ++++- .../optionalObservationProviders.ts | 6 +- src/config/analysisSettings.ts | 3 + .../javascriptMemberMutationAllowance.test.ts | 75 +++++++++ .../javascriptMemberMutationAllowance.ts | 57 +++++++ src/domain/analysisResourceLimits.ts | 14 ++ src/domain/analysisSettingDefinitions.ts | 31 ++++ src/domain/analysisSettings.ts | 1 + .../javascript/javascriptSemanticAnalysis.ts | 26 ++- .../javascriptSemanticMemberMutations.ts | 2 +- .../javascript/javascriptSemanticState.ts | 3 + .../javascriptSemanticWorkAllowance.test.ts | 83 ++++++++-- .../javascriptSemanticWorkAllowance.ts | 148 +++++++++++++---- .../javascript/javascriptSourceAnalysis.ts | 3 + .../javascript/javascriptStaticAnalysis.ts | 54 +++++++ .../javascript/javascriptStaticCoverage.ts | 4 + .../referenceSourceImportParsing.test.ts | 59 +++++++ src/domain/referenceSourceImportParsing.ts | 29 +++- src/domain/referenceSourceRequireBindings.ts | 4 + src/domain/webBundleAnalysis.ts | 11 ++ src/domain/webBundleAnalyzer.ts | 41 ++++- src/domain/webBundleAnalyzerInspection.ts | 19 ++- src/domain/webBundleAnalyzerResult.ts | 88 ++++++---- src/domain/webBundleScriptAnalysis.ts | 89 ++++++++-- .../analysis/JavaScriptAnalysisWorker.ts | 26 ++- .../JavaScriptAnalysisWorkerProtocol.ts | 2 + .../JavaScriptAnalysisWorkerServer.test.ts | 1 + .../JavaScriptAnalysisWorkerServer.ts | 10 +- .../javascriptMemberMutationAllowance.test.ts | 152 ++++++++++++++++++ .../javascriptSourceAnalysisWorker.test.ts | 106 ++++++++++-- 54 files changed, 1490 insertions(+), 214 deletions(-) create mode 100644 src/config/javascriptMemberMutationAllowance.test.ts create mode 100644 src/config/javascriptMemberMutationAllowance.ts create mode 100644 src/domain/referenceSourceImportParsing.test.ts create mode 100644 tests/boundary/javascript/javascriptMemberMutationAllowance.test.ts 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(); }