Compare commits

..
Author SHA1 Message Date
Cursor Agent 5fb8939a28 fix(javascript): keep Object.defineProperty identified at its own call
The call that escapes Object was treated as a prior replacement of
defineProperty, so a descriptor value never replaced the exported sender
and bundled IPC paths were dropped.
2026-11-11 02:35:53 +00:00
Cursor Agent f366d21446 chore: merge main into codex/audit-javascript-semantics 2026-11-11 02:26:35 +00:00
Cursor Agent 881aec416a fix(javascript): keep source accessors known after a copied value escapes
An unknown computed getter makes accessor lookup consider every property.
A spread copy already records a value escape, and that escape was treated
as an unknown getter on the source, so the next read escaped the whole
object. Accessor lookup still fails closed for unknown writes and calls.
2026-11-11 01:18:58 +00:00
Cursor Agent f8d17481e8 test(javascript): include the request link in endpoint traces
Response consumers now also link the originating request to the response, so an endpoint trace reports both that association and the response's json consumer.
2026-11-10 23:34:45 +00:00
morluto 199b006cc3 fix(javascript): retain semantic graph evidence and coverage 2026-11-10 17:32:25 -05:00
morluto db1db1a965 fix(javascript): preserve bundler identities and helper bindings 2026-11-10 17:32:25 -05:00
morluto bf7f518e2d fix(javascript): preserve semantic facts across writes and implicit effects 2026-11-10 17:32:25 -05:00
57 changed files with 2056 additions and 1613 deletions

No files matched your search

-5
View File
@@ -358,11 +358,6 @@ export const documentationSections = [
source: "design/limits-and-recovery.md",
route: "/design/limits-and-recovery",
},
{
text: "REA limit inventory",
source: "design/rea-limit-inventory.md",
route: "/design/rea-limit-inventory",
},
{
text: "MCP tool design",
source: "development/tool-design.md",
-1
View File
@@ -111,7 +111,6 @@ product pages. The VitePress documentation source is this `docs/` tree.
## Development and verification
- [Settings and recovery](design/limits-and-recovery.md)
- [REA limit inventory](design/rea-limit-inventory.md)
- [Flutter budget calibration](verification/flutter-budget-calibration.md)
- [Source-map budget calibration](verification/source-map-budget-calibration.md)
- [Source-location delivery qualification](verification/source-location-delivery.md)
+1 -3
View File
@@ -5,9 +5,7 @@ who owns each value, when an update takes effect, how providers enforce it, and
how to extend the system. The public tool contract is in
[MCP contracts](../reference/mcp-contracts.md#editable-analysis-settings); recorded runtime
checks and their limitations are in the
[verification report](../verification/runtime-analysis-settings.md). The
[limit inventory](rea-limit-inventory.md) lists every numeric `REA_` limit, which
of them are runtime settings, and which stay restart-only.
[verification report](../verification/runtime-analysis-settings.md).
## Problem and design boundary
-225
View File
@@ -1,225 +0,0 @@
# REA limit inventory
Audit for [#9881](https://github.com/morluto/rea/issues/9881). Counts below are
from this revision of `src`. A numeric `REA_` limit is a name under `src` (tests
excluded) that carries a byte, time, count, heap, budget, deadline, or maximum.
`scripts/verify/rea-numeric-limits.mjs` fails the static checks when one of those
names is neither an environment seed of
[`ANALYSIS_SETTINGS`](https://github.com/morluto/rea/blob/main/src/domain/analysisSettingDefinitions.ts) nor an
entry in [`numericLimitRegistry.ts`](https://github.com/morluto/rea/blob/main/src/domain/numericLimitRegistry.ts)
with a reason.
`effect` is not unified yet. The restart column is the structured field a
failure uses today. Prose that only says to restart REA is marked `prose`.
`mcp_server_restart_required` is a server-identity alignment state in
`src/serverIdentity.ts`, not a setting flag.
Three numeric limits are absent from this tree and are being moved into the
runtime system on their own PRs. This audit does not change them:
| Name | Tracking |
| -------------------------------------------- | -------- |
| `REA_NTFS_MFT_MAX_FILE_BYTES` | #9868 |
| `REA_REGF_HIVE_MAX_FILE_BYTES` | #9865 |
| `REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES` | #9836 |
When those names land in `src`, the static check requires them to be analysis
settings. They are not on the restart-only list.
## Counts
| Category | Count |
| --------------------------------------------------------------------- | ---------------------------------------------------------------------------: |
| Runtime analysis settings | 17 (14 have an `REA_` seed; 3 are per-call parameters only) |
| Per-call numeric `REA_` limits still read from the launch environment | 11 |
| Restart-only numeric `REA_` limits | 15 |
| Numeric `REA_` names discovered in `src` | 40 |
| Non-numeric `REA_` settings kept restart-only | 53 caller settings, plus process-protocol names that are not caller settings |
| Hardcoded ceilings left for follow-up | grouped below; none converted in the inventory change |
## Runtime analysis settings
Already adjustable with `update_analysis_settings`. Each call reads the snapshot
accepted for that operation. Launch source is `default` or `setting`; a runtime
override is `source: "runtime"`. The structured restart field is
`rea_restart_required: false`. `applies_at` is the finer point (`next_operation`,
`next_worker`, or `next_startup`).
| Setting | Environment seed | Owner | Default | Floor | Maximum | Read |
| -------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------- | ----------------------------------------- | ---------------: | -------------------------------------: | --------------------- |
| `asar.max_header_bytes` | `REA_ASAR_MAX_HEADER_BYTES` | `src/domain/analysisResourceLimits.ts` | 16777216 | 1 | 268435456 | per operation |
| `asar.max_listing_bytes` | `REA_ASAR_MAX_LISTING_BYTES` | `src/domain/analysisResourceLimits.ts` | 67108864 | 1 | `Number.MAX_SAFE_INTEGER` | per operation |
| `ghidra.value_flow_max_operations` | `REA_GHIDRA_VALUE_FLOW_MAX_OPERATIONS` | `src/domain/analysisResourceLimits.ts` | 3000 | 1 | 2147483647 | per operation |
| `ghidra.value_flow_max_inputs_per_operation` | `REA_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION` | `src/domain/analysisResourceLimits.ts` | 64 | 1 | 2147483647 | per operation |
| `ghidra.value_flow_max_edges` | `REA_GHIDRA_VALUE_FLOW_MAX_EDGES` | `src/domain/analysisResourceLimits.ts` | 12000 | 1 | 2147483647 | per operation |
| `ghidra.decompile_max_payload_mib` | `REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB` | `src/domain/analysisResourceLimits.ts` | unset (`null`, unbounded payload policy) | 1 | 2047 | per operation |
| `ghidra.startup_timeout_ms` | `REA_GHIDRA_STARTUP_TIMEOUT_MS` | `src/domain/analysisResourceLimits.ts` | 330000 | 1 | 2147483647 | next provider startup |
| `hopper.startup_timeout_ms` | `REA_HOPPER_STARTUP_TIMEOUT_MS` | `src/domain/analysisResourceLimits.ts` | 120000 | 1 | 2147483647 | next provider startup |
| `ghidra.decompile_timeout_seconds` | `REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS` | `src/domain/analysisResourceLimits.ts` | unset (`null`, decompile stays unbounded) | 1 | `MAX_GHIDRA_DECOMPILE_TIMEOUT_SECONDS` | per operation |
| `jadx.operation_timeout_ms` | `REA_JADX_OPERATION_TIMEOUT_MS` | `src/domain/analysisResourceLimits.ts` | 120000 | 1 | 2147483647 | per operation |
| `regex.matching_deadline_ms` | `REA_REGEX_MATCHING_DEADLINE_MS` | `src/domain/analysisResourceLimits.ts` | 5000 | 1 | 2147483647 | per operation |
| `regex.worker_heap_request_mib` | `REA_REGEX_WORKER_HEAP_REQUEST_MIB` | `src/domain/analysisResourceLimits.ts` | 128 | 7 | 16384 | next worker |
| `capture.decoder_old_generation_mib` | `REA_CAPTURE_DECODER_OLD_GENERATION_MIB` | `src/domain/analysisResourceLimits.ts` | 192 | semi-space floor | 16384 | next worker |
| `javascript.analysis_timeout_ms` | none (`analysis_timeout_ms`) | `src/domain/javascript/javascriptAnalysisResourceControls.ts` | owner default | 1 | 2147483647 | per operation |
| `javascript.max_heap_mb` | none (`max_heap_mb`) | `src/domain/javascript/javascriptAnalysisResourceControls.ts` | owner default | owner floor | owner maximum | next worker |
| `wasm.command_timeout_ms` | none (`command_timeout_ms`) | `src/domain/wasm/wasmArtifact.ts` | owner default | 1 | owner maximum | per operation |
| `native.matching_representation_bytes` | `REA_NATIVE_MATCHING_REPRESENTATION_BYTES` | `src/domain/analysisResourceLimits.ts` | 100663296 | 1 | 1073741824 | per operation |
An out-of-range `update_analysis_settings` value is rejected by the setting's
schema. Invalid launch seeds that the global environment parser accepts as
provider-local stay inspectable with `source: "invalid"`.
## Per-call limits still outside the runtime system
These are checked when the work runs. They keep the `REA_` variable, default,
floor, and maximum, and the migration reads the accepted snapshot instead of a
value copied at process start. Until that lands they are `policy: "per-call"` in
the registry so the static check still requires a reason.
| Environment | Owner | Default | Floor | Maximum | Read today | Restart field |
| ------------------------------------------ | ------------------------------------------- | ----------------------: | ----: | ---------: | ------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `REA_BRIDGE_MAX_REQUEST_BYTES` | `src/config/bridgeMaxRequestBytes.ts` | 16777216 | 7464 | 536870887 | each bridge request, from the launch value | prose |
| `REA_DEBUGGER_COMMAND_DEADLINE_MS` | `src/domain/analysisResourceLimits.ts` | 120000 | 1 | 2147483647 | copied when the debugger manager is constructed; applied to each command | `restart_required: true` |
| `REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES` | `src/domain/analysisResourceLimits.ts` | 16777216 | 1 | 89303718 | copied when the debugger manager is constructed; applied to each command | `restart_required: true` |
| `REA_HOPPER_CALLER_SCAN_BUDGET_MS` | `src/domain/analysisResourceLimits.ts` | 2000 | 1 | 2147483647 | each caller-scan operation, from config resolved at launch | prose |
| `REA_JEB_MCP_TIMEOUT_MS` | `src/jeb/JebMcpConnection.ts` | 60000 (MCP SDK default) | 1 | 2147483647 | captured when the JEB connection is created; applied to discovery and each tools/call | prose |
| `REA_KUNA_PROJECT_TIMEOUT_MS` | `src/kuna/KunaConfiguration.ts` | 1800000 | 1 | 2147483647 | project export and whole-program scans, from the launch configuration | prose |
| `REA_KUNA_TIMEOUT_MS` | `src/kuna/KunaConfiguration.ts` | 120000 | 1 | 2147483647 | each function decompile, from the launch configuration | prose |
| `REA_PWNTOOLS_MAX_INPUT_BYTES` | `src/native/pwntools/PwntoolsInputLimit.ts` | 33554432 | 1 | 1073741824 | each pwntools decode, unless the call passes `producer_limits.input_bytes` | prose |
| `REA_WINDOWS_CHANGE_TIME_DRIFT_MS` | `src/config/windowsChangeTimeSettle.ts` | 2000 | 1 | 60000 | each ChangeTime observation | `restartRequired: true` on an invalid launch value |
| `REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS` | `src/config/windowsChangeTimeSettle.ts` | 25 | 1 | 1000 | each ChangeTime observation | `restartRequired: true` on an invalid launch value |
| `REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS` | `src/config/windowsChangeTimeSettle.ts` | 40 | 1 | 1000 | each ChangeTime observation | `restartRequired: true` on an invalid launch value |
## Restart-only numeric limits
These stay out of `update_analysis_settings`.
| Environment | Owner | Default | Floor | Maximum | Read | Why restart-only |
| ----------------------------------- | -------------------------------------- | ------------------: | -------: | ------------------------: | ------------------------------------------- | ------------------------------------------------------------------------------------- |
| `REA_DEBUGGER_MAX_PENDING_COMMANDS` | `src/domain/analysisResourceLimits.ts` | 16 | 1 | 256 | debugger manager construction | Queue capacity of the manager created at startup. Field: `restart_required: true`. |
| `REA_DEBUGGER_MAX_SESSIONS` | `src/domain/analysisResourceLimits.ts` | 8 | 1 | 64 | debugger manager construction | Session-pool size of the manager created at startup. Field: `restart_required: true`. |
| `REA_HOPPER_DEMO_DIALOG_TIMEOUT_MS` | `src/domain/hopperStartupFailure.ts` | 10000 | 1 | 2147483647 | Linux private-display adapter start | The helper reads the process environment. Prose restart. |
| `REA_JADX_ACTIVE_PROCESSOR_COUNT` | `src/android/JadxConfiguration.ts` | unset (JVM default) | 1 | 2147483647 | JADX JVM launch | `-XX:ActiveProcessorCount` is fixed for that JVM. Prose on invalid values. |
| `REA_JADX_HEAP_MIB` | `src/android/JadxConfiguration.ts` | unset (JVM default) | 1 | `Number.MAX_SAFE_INTEGER` | JADX JVM launch | `-Xmx` is fixed for that JVM. Prose on invalid values. |
| `REA_KUNA_CACHE_MAX_BYTES` | `src/kuna/KunaConfiguration.ts` | 4294967296 | 1048576 | `Number.MAX_SAFE_INTEGER` | Kuna cache construction | The cache is sized once for the provider. Prose restart. |
| `REA_KUNA_JOBS` | `src/kuna/KunaConfiguration.ts` | 1 | 1 | 32 | Kuna run-slot construction | Worker-pool width for the provider. Prose restart. |
| `REA_MCP_DOCTOR_DEADLINE_MS` | `src/config/mcpStartupBudgets.ts` | 30000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_MCP_FIRST_CATALOG_BUDGET_MS` | `src/config/mcpStartupBudgets.ts` | 20000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_MCP_INITIALIZE_BUDGET_MS` | `src/config/mcpStartupBudgets.ts` | 15000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_MCP_MAX_INPUT_ELEMENTS` | `src/config/mcpInputBudget.ts` | 1024 | 1 | `Number.MAX_SAFE_INTEGER` | MCP server creation | Argument admission for the server process. Prose restart. |
| `REA_MCP_MAX_RESPONSE_BYTES` | `src/config/mcpResponseBudget.ts` | 10485760 when unset | 10485760 | `Number.MAX_SAFE_INTEGER` | MCP server creation | Stdio receive buffer and the paired client buffer. Prose restart. |
| `REA_MCP_TOOLS_LIST_BUDGET_MS` | `src/config/mcpStartupBudgets.ts` | 5000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_RECONNECT_TIMEOUT_SECONDS` | `src/hopper/BridgeLauncher.ts` | 30 | 30 | 30 | written into the Hopper bridge helper | Not a caller setting. It is `HOPPER_SHUTDOWN_TIMEOUT_MS / 1000`. |
| `REA_SWIFTC_COMPILE_TIMEOUT_MS` | `src/config/providerStartupTimeout.ts` | 120000 | 1 | 2147483647 | Darwin ownership-helper reader construction | Captured at process start for that helper compile. Prose restart. |
## Restart-only non-numeric settings
No floor or maximum. They are read from the launch environment (startup). None
of them use `rea_restart_required`, `restart_required`, or `restartRequired`;
recovery text tells the caller to change the launch environment. They stay
restart-only because they choose a command, a binary, a filesystem root, the
browser sandbox, secret patterns, the provider, or the shape of the MCP server
or logger.
| Name | Owner | Why |
| ------------------------------------ | -------------------------------------------------- | ------------------------------------------- |
| `REA_ADB_PATH` | `src/adb/AdbConfiguration.ts` | Caller-selected executable path. |
| `REA_AGENT_ENV_COMMAND` | `src/application/DesktopBuildDoctor.ts` | Caller-selected command. |
| `REA_ANALYSIS_PROVIDER` | `src/config/environment.ts` | Provider choice for the process. |
| `REA_APKTOOL_COMMAND` | `src/apktool/ApktoolConfiguration.ts` | Caller-selected command. |
| `REA_BINWALK_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_BROWSER_EXECUTABLE` | `src/application/CapabilityInventory.ts` | Caller-selected browser binary. |
| `REA_BROWSER_NO_SANDBOX` | `src/application/SetupRegistrationEnvironment.ts` | Browser sandbox policy. |
| `REA_CAPINFOS_COMMAND` | `src/pcap/PcapConfiguration.ts` | Caller-selected command. |
| `REA_CUTTER_BRIDGE_DIR` | `src/application/SetupRegistrationEnvironment.ts` | Filesystem root for the Cutter bridge. |
| `REA_DARWIN_MODULE_CACHE_PATH` | `src/application/SetupRegistrationEnvironment.ts` | Filesystem path for the Swift module cache. |
| `REA_DARWIN_SWIFTC_PATH` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected compiler path. |
| `REA_DCONF_COMMAND` | `src/desktop/DconfDatabaseReader.ts` | Caller-selected command. |
| `REA_DOCKER_COMMAND` | `src/application/DesktopBuildDoctor.ts` | Caller-selected command. |
| `REA_ELECTRON_CAPTURE_ARGV` | `src/browser/PlaywrightElectronActiveProvider.ts` | Caller-selected process arguments. |
| `REA_EVM_PRLIMIT_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_FIRMWARE_PRLIMIT_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_GDB_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected debugger binary. |
| `REA_GHIDRA_CHECKPOINT_DIR` | `src/config/environment.ts` | Filesystem root. |
| `REA_GHIDRA_COMPILER_SPEC_ID` | `src/config/environment.ts` | Provider analysis choice. |
| `REA_GHIDRA_DISABLED_ANALYZERS` | `src/config/environment.ts` | Provider analysis choice. |
| `REA_GHIDRA_LANGUAGE_ID` | `src/config/environment.ts` | Provider analysis choice. |
| `REA_GHIDRA_NATIVEAOT_JAR` | `src/config/environment.ts` | Caller-selected binary path. |
| `REA_GHIDRA_PROGRAM_NAME` | `src/config/environment.ts` | Provider project selection. |
| `REA_GHIDRA_PROJECT_PATH` | `src/config/environment.ts` | Filesystem root. |
| `REA_GHIDRA_SEED_FILE` | `src/config/environment.ts` | Filesystem path. |
| `REA_IDA_MCP_CONFIG` | `src/config/environment.ts` | Filesystem path of the IDA MCP config. |
| `REA_ILSPY_CMD_PATH` | `src/config/environment.ts` | Caller-selected executable path. |
| `REA_JADX_MCP_JAR` | `src/android/JadxConfiguration.ts` | Caller-selected binary path. |
| `REA_JAVASCRIPT_PRLIMIT_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_JEB_MCP_URL` | `src/config/environment.ts` | Provider endpoint. |
| `REA_KUNA_CACHE_DIR` | `src/kuna/KunaConfiguration.ts` | Filesystem root. |
| `REA_KUNA_COMMAND` | `src/kuna/KunaConfiguration.ts` | Caller-selected executable path. |
| `REA_LOG_LEVEL` | `src/config/environment.ts` | Logger configuration for the process. |
| `REA_MANAGED_LINUX_HOPPER_LAUNCHER` | `src/application/HopperLauncherDiscovery.ts` | Caller-selected launcher path. |
| `REA_MCP_INPUT_SCHEMA_PROFILE` | `src/config/environment.ts` | MCP server schema shape. |
| `REA_MCP_OUTPUT_SCHEMA_PROFILE` | `src/config/environment.ts` | MCP server schema shape. |
| `REA_MCP_TOOL_CATALOG_PROFILE` | `src/config/environment.ts` | MCP server catalog shape. |
| `REA_MCP_TOOL_FAMILIES` | `src/config/environment.ts` | Which tools the MCP server registers. |
| `REA_MITMDUMP_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_OBJDUMP_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_PWNDBG_GDB` | `src/native/pwndbg/PwndbgCoreContext.ts` | Caller-selected debugger binary. |
| `REA_PWNDBG_GDBINIT` | `src/native/pwndbg/PwndbgCoreContext.ts` | Caller-selected file path. |
| `REA_PWNDBG_VENV_PATH` | `src/native/pwndbg/PwndbgCoreContext.ts` | Filesystem root. |
| `REA_PWNTOOLS_PYTHON` | `src/native/pwntools/PwntoolsDecoder.ts` | Caller-selected interpreter path. |
| `REA_REFERENCE_SECRET_PATTERNS_JSON` | `src/config/environment.ts` | Secret-pattern policy. |
| `REA_RIZIN_COMMAND` | `src/rizin/RizinDebugSessionManager.ts` | Caller-selected debugger binary. |
| `REA_SIGROK_COMMAND` | `src/signals/SigrokRecordingProvider.ts` | Caller-selected command. |
| `REA_SIGROK_PRLIMIT_COMMAND` | `src/signals/SigrokRecordingProvider.ts` | Caller-selected command. |
| `REA_TSHARK_COMMAND` | `src/pcap/PcapConfiguration.ts` | Caller-selected command. |
| `REA_TSHARK_PRLIMIT_COMMAND` | `src/pcap/PcapNativeBound.ts` | Caller-selected command. |
| `REA_UNBLOB_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_WABT_BIN_DIRECTORY` | `src/wasm/WabtArtifactProvider.ts` | Filesystem root of the WABT tools. |
| `REA_WAKARU_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_WORKFLOW_PROVIDER` | `src/application/DirectAnalysis.ts` | Provider choice. |
| `REA_XPROP_COMMAND` | `src/application/desktop/DesktopCaptureFailure.ts` | Caller-selected command. |
Names that look like `REA_` but are process protocol, not caller settings:
`REA_BUILD_COMMIT`, `REA_BUILD_COMPUTER_REA_ARCHIVE`, `REA_INSTALL_SCRIPT`,
`REA_OWNS_PROCESS_LIFETIME`, `REA_PAYLOAD`, `REA_PROCESS_RUN_ID`, `REA_RUN_ID`,
`REA_SOCKET`, `REA_STATE_FORMAT`, `REA_TARGET_PATH`, `REA_TOKEN`, bridge markers
such as `REA_COMMAND_COMPLETE_`, and the `ERR_REA_WINDOWS_NATIVE` code.
`GHIDRA_INSTALL_DIR`, `JAVA_HOME`, `HOPPER_LAUNCHER_PATH`, `HOPPER_TARGET_PATH`,
`HOPPER_TARGET_KIND`, and `HOPPER_LOADER_ARGS_JSON` are launch configuration
without the `REA_` prefix. Paths and the provider choice among them stay
restart-only for the same reason as the table above.
## Hardcoded ceilings left for follow-up
These are checked per call and tell the caller this release has no runtime
override. They are not `REA_` settings, so the numeric-limit check does not
list them. Each family has its own owner. Turning one into a setting needs that
owner's default, floor, maximum, and an error that carries the setting id,
value, source, and range. Format and protocol ceilings that the
[limits guide](limits-and-recovery.md) already calls fixed stay fixed. In
particular, JSON nesting stops at `MAX_JSON_DEPTH` (1000) and the debugger
control-reply budget stays separate from
`REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES`.
| Family | Examples | Follow-up |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| Native structural input ceilings | PE resources, imports, load config, certificates, exports, initializers, binary hardening, Windows shortcuts | One setting per ceiling, or a shared input budget only where the owners match. |
| Native plist | `MAX_NATIVE_PLIST_BYTES`, decode bytes, nesting depth | Separate from JSON depth. |
| SQLite inspection | concurrent inspections, input bytes, diagnostic bytes | Pool width stays restart-only if it sizes the worker set; byte budgets can become settings. |
| Producer command allowances | fixed command deadline and retained output where the text says there is no runtime override | Distinct from per-call `producer_limits`. |
| Hopper caller-scan retention and page allowances | `HOPPER_CALLER_SCAN_RETENTION_BYTES`, `HOPPER_CALLER_PAGE_ALLOWANCE_BYTES` | Explicitly not raised by the scan time budget. |
| Rizin frame protocol | `MAX_FRAME_BYTES`, queued frames | Protocol bounds, not caller budgets. |
| Debugger detach and control replies | `DETACH_TIMEOUT_MS`, `DEBUGGER_CONTROL_REPLY_BYTES` | Ownership deadlines, not the shared command deadline. |
| Web network capture nesting | `max_nesting_depth` already has a per-call parameter | The fixed depth maximum is the follow-up, not the parameter. |
## Static check
`npm run lint` runs `scripts/verify/rea-numeric-limits.mjs` after
dependencies are installed. `scripts/verify/architecture-guards.mjs` runs the
same check when `typescript6` is already installed; the pre-install CI step
cannot. The check scans `src` excluding tests,
keeps names that are numeric limits, and requires each name to be an
`environment` on an analysis setting or a registry entry with a reason.
`policy: "per-call"` is temporary. The migration that moves the limit deletes
the entry and rejects any new `per-call` policy so the durable exceptions are
only `restart-only`.
+1 -1
View File
@@ -148,7 +148,7 @@
"build:termux:run": "npm run compile:termux && npm run artifacts:skills",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"lint": "node scripts/ci/static-check-report.mjs lint && npm run verify:module-boundaries && node scripts/verify/rea-numeric-limits.mjs",
"lint": "node scripts/ci/static-check-report.mjs lint && npm run verify:module-boundaries",
"lint:details": "oxlint && npm run verify:module-boundaries",
"lint:fix": "oxlint --fix && npm run verify:module-boundaries",
"knip": "knip --cache",
-21
View File
@@ -1,5 +1,3 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, readdir } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
@@ -195,23 +193,4 @@ if (failures.length > 0) {
console.error(`Architecture guards failed:\n\n${failures.join("\n\n")}\n`);
process.exit(1);
}
// CI runs this file before `npm ci`. The numeric-limit parser needs
// typescript6, so that check waits until dependencies exist. `npm run lint`
// runs it after install.
const typescript6 = new URL(
"../../node_modules/typescript6/package.json",
import.meta.url,
);
if (!existsSync(typescript6)) {
console.log(
"Numeric REA_ limit check waits until dependencies are installed.",
);
} else {
const numericLimits = spawnSync(
process.execPath,
[fileURLToPath(new URL("./rea-numeric-limits.mjs", import.meta.url))],
{ stdio: "inherit" },
);
if (numericLimits.status !== 0) process.exit(numericLimits.status ?? 1);
}
console.log(`Architecture guards passed across ${files.length} source files.`);
-23
View File
@@ -1,23 +0,0 @@
/** Relative path of the registry. Its text is a declaration, not a producer. */
export const NUMERIC_LIMIT_DECLARATION: "src/domain/numericLimitRegistry.ts";
/** Limits that must become analysis settings and cannot be registry exceptions. */
export const REQUIRED_RUNTIME_LIMITS: readonly [
"REA_NTFS_MFT_MAX_FILE_BYTES",
"REA_REGF_HIVE_MAX_FILE_BYTES",
"REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES",
];
/** True for a numeric `REA_` limit, including the documented endpoint-candidate count. */
export function isNumericLimit(name: string): boolean;
/** Producer paths for numeric limits. Registry text, comments, and allowlists are omitted. */
export function discoverNumericLimitUses(
files: readonly { readonly path: string; readonly source: string }[],
): Map<string, string>;
/** Why a tracked limit is not an analysis setting, when it is present. */
export function requiredRuntimeLimitFailure(
name: string,
presence: { readonly inRuntime: boolean; readonly inRegistry: boolean },
): string | undefined;
-319
View File
@@ -1,319 +0,0 @@
import { readFile, readdir } from "node:fs/promises";
import { join, relative, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import ts from "typescript6";
// Every numeric REA_ limit under src must be an analysis setting or a
// documented exception with a reason. restart-only is durable. per-call is a
// launch-captured limit whose runtime migration deletes the registry entry.
// The three limits moving on #9868, #9865, and #9836 are not in this tree;
// once their names appear here they must join ANALYSIS_SETTINGS.
const root = fileURLToPath(new URL("../../", import.meta.url));
const definitionsPath = join(root, "src/domain/analysisSettingDefinitions.ts");
const registryPath = join(root, "src/domain/numericLimitRegistry.ts");
const listFiles = async (dir) => {
const output = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist") continue;
output.push(...(await listFiles(path)));
} else if (
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".fixture.ts")
)
output.push(path);
}
return output;
};
/**
* Count-shaped limits whose names do not use a byte, time, or maximum suffix.
* `REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES` is the runtime setting on #9836.
*/
const DOCUMENTED_COUNT_LIMITS = new Set([
"REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES",
]);
/** Registry text is a declaration, not a producer that uses the limit. */
export const NUMERIC_LIMIT_DECLARATION = "src/domain/numericLimitRegistry.ts";
/**
* These limits are moving onto runtime analysis settings on their own PRs.
* A producer or a registry entry cannot satisfy that requirement.
*/
export const REQUIRED_RUNTIME_LIMITS = [
"REA_NTFS_MFT_MAX_FILE_BYTES",
"REA_REGF_HIVE_MAX_FILE_BYTES",
"REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES",
];
const unwrapExpression = (node) => {
let current = node;
while (
ts.isAsExpression(current) ||
ts.isSatisfiesExpression(current) ||
ts.isParenthesizedExpression(current) ||
ts.isTypeAssertionExpression(current) ||
ts.isNonNullExpression(current)
)
current = current.expression;
return current;
};
const literalText = (node) =>
ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)
? node.text
: undefined;
/** Same-file const strings and `setting` fields, so `environment[name]` still resolves. */
const stringBindings = (sourceFile) => {
const bindings = new Map();
const visit = (node) => {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.initializer !== undefined
) {
const initializer = unwrapExpression(node.initializer);
const literal = literalText(initializer);
if (literal !== undefined) bindings.set(node.name.text, literal);
if (ts.isObjectLiteralExpression(initializer)) {
for (const property of initializer.properties) {
if (
!ts.isPropertyAssignment(property) ||
(!ts.isIdentifier(property.name) &&
!ts.isStringLiteral(property.name))
)
continue;
const value = literalText(unwrapExpression(property.initializer));
if (value !== undefined)
bindings.set(`${node.name.text}.${property.name.text}`, value);
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return bindings;
};
const boundLimitName = (node, bindings) => {
const expression = unwrapExpression(node);
const literal = literalText(expression);
if (literal !== undefined) return literal;
if (ts.isIdentifier(expression)) return bindings.get(expression.text);
if (
ts.isPropertyAccessExpression(expression) &&
ts.isIdentifier(expression.expression) &&
(ts.isIdentifier(expression.name) || ts.isStringLiteral(expression.name))
)
return bindings.get(
`${expression.expression.text}.${expression.name.text}`,
);
return undefined;
};
export const isNumericLimit = (name) =>
DOCUMENTED_COUNT_LIMITS.has(name) ||
/(?:_BYTES|_MS|_MIB|_SECONDS|_POLLS|_JOBS|_ELEMENTS|_COUNT|_SESSIONS|_COMMANDS)$/u.test(
name,
) ||
/_MAX_/u.test(name) ||
/_(?:TIMEOUT|DEADLINE|BUDGET)(?:_|$)/u.test(name) ||
/_HEAP(?:_|$)/u.test(name);
const limitName = (text) =>
text !== undefined && /^REA_[A-Z0-9_]+$/u.test(text) && isNumericLimit(text)
? text
: undefined;
const remember = (discovered, path, name) => {
const limit = limitName(name);
if (limit === undefined || discovered.has(limit)) return;
discovered.set(limit, path);
};
/**
* Producers are syntax, not text. A read is a property or element access,
* including a const string used as the element, a string argument to a call,
* an object-binding key (rename and default included), an `in` check, or an
* object key that a spread can copy. Comments and array literals are not reads.
*/
export const discoverNumericLimitUses = (files) => {
const discovered = new Map();
for (const file of files) {
const relativePath = file.path.split(sep).join("/");
if (relativePath === NUMERIC_LIMIT_DECLARATION) continue;
const sourceFile = ts.createSourceFile(
relativePath,
file.source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const bindings = stringBindings(sourceFile);
const visit = (node) => {
if (
ts.isPropertyAccessExpression(node) ||
ts.isPropertyAccessChain?.(node)
)
remember(discovered, relativePath, node.name.text);
if (ts.isElementAccessExpression(node))
remember(
discovered,
relativePath,
boundLimitName(node.argumentExpression, bindings),
);
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
for (const argument of node.arguments ?? [])
remember(
discovered,
relativePath,
boundLimitName(argument, bindings),
);
}
if (
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.InKeyword
)
remember(discovered, relativePath, boundLimitName(node.left, bindings));
if (
ts.isBindingElement(node) &&
node.dotDotDotToken === undefined &&
ts.isObjectBindingPattern(node.parent)
) {
const key = node.propertyName ?? node.name;
if (
ts.isIdentifier(key) ||
ts.isStringLiteral(key) ||
ts.isNumericLiteral(key)
)
remember(discovered, relativePath, key.text);
}
if (
(ts.isPropertyAssignment(node) ||
ts.isShorthandPropertyAssignment(node)) &&
(ts.isIdentifier(node.name) ||
ts.isStringLiteral(node.name) ||
ts.isNumericLiteral(node.name))
)
remember(discovered, relativePath, node.name.text);
if (ts.isVariableDeclaration(node) && node.initializer !== undefined)
remember(
discovered,
relativePath,
literalText(unwrapExpression(node.initializer)),
);
if (ts.isPropertyAssignment(node))
remember(
discovered,
relativePath,
literalText(unwrapExpression(node.initializer)),
);
if (
ts.isTemplateExpression(node) ||
ts.isNoSubstitutionTemplateLiteral(node)
) {
const chunks = ts.isNoSubstitutionTemplateLiteral(node)
? [node.text]
: [
node.head.text,
...node.templateSpans.map((span) => span.literal.text),
];
for (const chunk of chunks)
for (const token of chunk.match(/REA_[A-Z0-9_]+/gu) ?? [])
remember(discovered, relativePath, token);
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
}
return discovered;
};
/** Tracked limits join ANALYSIS_SETTINGS. The exception registry cannot classify them. */
export const requiredRuntimeLimitFailure = (
name,
{ inRuntime, inRegistry },
) => {
if (!REQUIRED_RUNTIME_LIMITS.includes(name)) return undefined;
if (inRuntime && !inRegistry) return undefined;
return `${name} must join ANALYSIS_SETTINGS; numericLimitRegistry cannot classify it`;
};
const namesIn = (source, pattern) =>
new Set([...source.matchAll(pattern)].map((match) => match[1]));
const direct =
process.argv[1] !== undefined &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
if (direct) {
const failures = [];
const definitions = await readFile(definitionsPath, "utf8");
const registry = await readFile(registryPath, "utf8");
const runtime = namesIn(definitions, /environment:\s*"(REA_[A-Z0-9_]+)"/gu);
const entries = [
...registry.matchAll(
/name:\s*"(REA_[A-Z0-9_]+)"\s*,\s*policy:\s*"(restart-only|per-call)"\s*,\s*read:\s*"(startup|per-call)"\s*,\s*reason:\s*"((?:\\.|[^"\\])*)"/gu,
),
];
const listed = new Map();
for (const match of entries) {
const [, name, policy, read, reason] = match;
if (listed.has(name)) failures.push(`${name} is listed twice`);
listed.set(name, { policy, read, reason });
if (reason.trim().length < 40)
failures.push(`${name} needs a reason that states why`);
if (runtime.has(name))
failures.push(
`${name} is both a runtime setting and on the exception list`,
);
if (policy === "per-call" && read !== "per-call")
failures.push(`${name} is per-call policy but is not read per call`);
if (policy === "restart-only" && read !== "startup")
failures.push(`${name} is restart-only but is not read at startup`);
}
const discovered = discoverNumericLimitUses(
await Promise.all(
(await listFiles(join(root, "src"))).map(async (path) => ({
path: relative(root, path),
source: await readFile(path, "utf8"),
})),
),
);
for (const [name, file] of [...discovered].sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
)) {
if (!runtime.has(name) && !listed.has(name))
failures.push(
`${name} in ${file} is neither an analysis setting nor docs/design/rea-limit-inventory.md via src/domain/numericLimitRegistry.ts`,
);
}
for (const name of listed.keys()) {
if (!discovered.has(name))
failures.push(`${name} is registered but no longer appears in src`);
}
const present = new Set([...discovered.keys(), ...listed.keys(), ...runtime]);
for (const name of REQUIRED_RUNTIME_LIMITS) {
if (!present.has(name)) continue;
const failure = requiredRuntimeLimitFailure(name, {
inRuntime: runtime.has(name),
inRegistry: listed.has(name),
});
if (failure !== undefined) failures.push(failure);
}
if (failures.length > 0) {
console.error(`Numeric REA_ limit check failed:\n${failures.join("\n")}`);
process.exit(1);
}
console.log(
`Numeric REA_ limits: ${runtime.size} runtime, ${[...listed.values()].filter((entry) => entry.policy === "restart-only").length} restart-only, ${[...listed.values()].filter((entry) => entry.policy === "per-call").length} per-call pending migration, ${discovered.size} discovered.`,
);
}
@@ -10,6 +10,7 @@ import {
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
inferredSemanticEvidenceAt,
unknownSemanticEvidence,
builtinSemanticNode,
builtinSemanticRelation,
@@ -39,6 +40,7 @@ export const projectSemanticEvents = (
source: eventEmitterNode(context, operation),
target: event,
relation,
evidence: inferredSemanticEvidenceAt(context.file, operation.location),
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
@@ -48,6 +50,7 @@ export const projectSemanticEvents = (
source: event,
target: listener,
relation,
evidence: inferredSemanticEvidenceAt(context.file, operation.location),
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
@@ -480,10 +480,8 @@ export const createJavaScriptSemanticGraphProjection = (
limit,
);
}
state.fileNodeBudget = Math.min(
fileNodeCeiling,
Math.max(0, nodeCeiling - state.nodes.size),
);
const remainingTreeBudget = Math.max(0, nodeCeiling - state.nodes.size);
state.fileNodeBudget = Math.min(fileNodeCeiling, remainingTreeBudget);
const module = retainNode(state, file, {
kind: "module",
roleKey: "module",
@@ -493,6 +491,14 @@ export const createJavaScriptSemanticGraphProjection = (
});
state.fileNodeBudget = null;
if (module !== null) state.roots.add(module.node_id);
else {
truncatedFiles += 1;
noteSemanticCeilingReached(
nodeCeilingsReached,
remainingTreeBudget,
fileNodeCeiling,
);
}
const evidence = {
...observedSemanticEvidence(file, null),
authority: "unknown" as const,
@@ -10,6 +10,7 @@ import {
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
inferredSemanticEvidenceAt,
builtinSemanticNode,
builtinSemanticRelation,
unknownSemanticEvidence,
@@ -135,7 +136,7 @@ export const projectSemanticRequests = (
if (request === undefined) continue;
if (operation.kind === "request")
projectRequestConstruction(context, operation, request);
else projectResponseConsumer(context, operation, request);
else projectResponseConsumer(context, operation, request, requestNodes);
if (operation.resolution !== "complete")
addDataUnknown({
context,
@@ -224,16 +225,31 @@ const projectResponseConsumer = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticRequestOperation,
response: JavaScriptSemanticGraphNode,
requestNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
const authority = builtinSemanticRelation(
context.file,
operation,
operation.resolution === "complete" ? "resolved" : "candidate",
);
const evidence =
authority.evidence ??
inferredSemanticEvidenceAt(context.file, operation.location);
for (const requestId of operation.linkedRequestIds)
addSemanticGraphRelation(context.state, {
source: requestNodes.get(requestId),
target: response,
relation: "consumed-by",
...authority,
evidence,
properties: { method: operation.method },
});
addSemanticGraphRelation(context.state, {
source: response,
target: context.callSiteAt(operation.location),
relation: "consumed-by",
...builtinSemanticRelation(
context.file,
operation,
operation.resolution === "complete" ? "resolved" : "candidate",
),
...authority,
evidence,
properties: { method: operation.method },
});
};
@@ -14,7 +14,10 @@ import {
addSemanticGraphRelation,
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import {
inferredSemanticEvidenceAt,
unknownSemanticEvidence,
} from "./JavaScriptSemanticGraphEvidence.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
import { semanticPropertySlot } from "./JavaScriptSemanticGraphValueProjection.js";
@@ -47,6 +50,7 @@ export function* projectSemanticObjects(
...ends,
relation: OBJECT_RELATIONS[operation.kind],
resolution: resolved ? "resolved" : "candidate",
evidence: inferredSemanticEvidenceAt(context.file, operation.location),
});
if (!resolved)
addObjectUnknown(context, operation, target.slot ?? occurrence, target);
@@ -340,3 +340,37 @@ it("bounds reads within unmodeled nested operator operands", () => {
);
expect(trace.status).not.toBe("found");
});
it.each([1, 2])(
"reports unavailable-module node omission only when total capacity %s rejects it",
(totalNodes) => {
const projection = createJavaScriptSemanticGraphProjection({
totalNodes,
fileNodes: 10,
});
const retained = source("retained.js", "");
projection.projectFile(retained, analyzeJavaScriptSemantics(""));
projection.recordUnavailableFile(
source("unavailable.js"),
"Source unavailable",
null,
"incomplete-module",
);
const graph = projection.finish(digest, application);
expect(graph.nodes).toHaveLength(totalNodes);
expect(graph.unknowns).toHaveLength(12);
expect(graph.coverage).toMatchObject({
status: "partial",
truncated: totalNodes === 1,
omitted_nodes: null,
});
if (totalNodes === 1) {
expect(graph.coverage.limits).toContainEqual({
name: "max_semantic_nodes",
value: 1,
unit: "items",
});
expect(graph.limitations.join("\n")).toContain("max_semantic_nodes is 1");
} else expect(graph.coverage.limits).toEqual([]);
},
);
@@ -650,6 +650,32 @@ it("connects exposed closures to IPC sends and text clipboard calls", () => {
expect(ipcPath?.steps.at(-1)).toBeDefined();
expect(ipcFinding?.location).toEqual(ipcPath?.steps.at(-1)?.location);
});
it("uses a defineProperty value that replaces an exported sender", () => {
const result = analyzeJavaScriptStaticSource(`(() => {
const modules = {
2(module) { module.exports = require("electron"); },
9(module,exports,load) {
const electron=load(2),helper=load(5);
electron.contextBridge.exposeInMainWorld("desktop", {copy:helper.sender("defined:copy")});
},
5(module,exports,load) {
const electron=load(2);
exports.sender=function(){return ()=>{};};
Object.defineProperty(exports,"sender",{value:function(channel){return (...args)=>send(channel,...args);}});
function send(channel,...args){electron.ipcRenderer.send(channel,...args);}
}
};
function load(id) { const m={exports:{}}; modules[id].call(m.exports,m,m.exports,load); return m.exports; }
load(9);
})();`);
expect(result.electron.call_paths).toContainEqual(
expect.objectContaining({
entry: "desktop.copy",
channel: "defined:copy",
terminal: "electron.ipcRenderer.send",
}),
);
});
it("resolves a helper factory listed after its bundle consumer", () => {
const result = analyzeJavaScriptStaticSource(`(() => {
const modules = {
@@ -1,7 +1,11 @@
import * as t from "@babel/types";
import type { javascriptBootstrapAt } from "./javascriptBootstrap.js";
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
import {
semanticStaticPropertyKey,
semanticWriteSiteTarget,
semanticWriteTargets,
} from "./javascriptAstValues.js";
import { semanticIdentifierRole } from "./javascriptSemanticProjection.js";
import {
resolveSemanticBindingState,
@@ -285,8 +289,13 @@ const moduleObjectContained = (
state: JavaScriptSemanticAnalysisState,
): boolean => {
let contained = true;
const writtenTargets = new Set<t.Node>();
traverseJavaScriptValueAst(loader.body, state.erasedSyntaxNodes, {
enter: (node, parent, readAncestors) => {
const target = semanticWriteSiteTarget(node);
if (target !== null)
for (const leaf of semanticWriteTargets(target))
writtenTargets.add(leaf);
if (!contained || !t.isIdentifier(node) || parent === null) return;
const ancestors = readAncestors();
if (semanticIdentifierRole(node, parent, () => ancestors) === null)
@@ -294,7 +303,7 @@ const moduleObjectContained = (
if (bindingOf(state, node) !== moduleId) return;
contained =
!ancestors.some((ancestor) => t.isFunction(ancestor)) &&
moduleUse(node, [parent, ancestors.at(-2)], dispatch);
moduleUse(node, parent, dispatch, writtenTargets.has(parent));
},
});
return contained;
@@ -302,21 +311,18 @@ const moduleObjectContained = (
const moduleUse = (
node: t.Identifier,
[parent, grandparent]: readonly [t.Node, t.Node | undefined],
parent: t.Node,
dispatch: t.CallExpression,
written: boolean,
): boolean => {
if (parent === dispatch) return dispatch.arguments.includes(node);
if (!t.isMemberExpression(parent) || parent.object !== node) return false;
const key = semanticStaticPropertyKey(parent.property, parent.computed);
const written =
(t.isAssignmentExpression(grandparent) && grandparent.left === parent) ||
t.isUpdateExpression(grandparent) ||
t.isUnaryExpression(grandparent, { operator: "delete" });
if (written) return key !== null && key !== "exports";
if (key !== "exports") return false;
// `module.exports` passed to the factory, or read after the dispatch ran.
return (
grandparent === dispatch ||
dispatch.arguments.some((argument) => argument === parent) ||
(parent.start ?? -1) > (dispatch.end ?? Number.POSITIVE_INFINITY)
);
};
@@ -10,9 +10,54 @@ import {
completeSemanticSteps,
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import { createGlobalReferenceReader } from "./javascriptSemanticAmbientWrites.js";
import {
createGlobalPathReader,
createGlobalReferenceReader,
} from "./javascriptSemanticAmbientWrites.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
it.each([
[
"await value; Object.prototype.then=null; await value;",
["unmodified", "null"],
],
[
"Object.prototype.then=function(resolve){resolve()}; await value;",
["function"],
],
["if(flag) Object.prototype.then=null; await value;", ["unknown"]],
["mutate(Object.prototype); await value;", ["unknown"]],
["await value; mutate(Object.prototype);", ["unmodified"]],
["Array.prototype.then=null; await value;", ["unmodified"]],
] as const)(
"reads intrinsic property facts at implicit await sites: %s",
(source, expected) => {
const parsed = parseJavaScriptSource(source, "app.mjs");
if (parsed === null) throw new Error("Expected implicit read fixture");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
const read = createGlobalPathReader(state, ["Object", "prototype", "then"]);
const statuses: string[] = [];
traverseJavaScriptAst(parsed.program, {
enter(node) {
if (!t.isAwaitExpression(node)) return;
const fact = read(node);
statuses.push(
fact.status === "replacement"
? t.isNullLiteral(fact.value)
? "null"
: t.isFunction(fact.value)
? "function"
: "other"
: fact.status,
);
},
});
expect(statuses).toEqual(expected);
},
);
it("keeps wrapper module aliases distinct from global-object module properties", () => {
const parsed = parseJavaScriptSource(
`
@@ -306,6 +351,11 @@ it.each([
"Object.defineProperty=()=>{};inspect(globalThis.Object.defineProperty);",
["replacement"],
],
[
"an escaped global owner",
"mutate(Object);inspect(Object.defineProperty);",
["unknown"],
],
[
"a namespaced global",
"inspect(globalThis.Object.defineProperty);globalThis.Object.defineProperty=()=>{};inspect(globalThis.Object.defineProperty);",
@@ -902,37 +902,92 @@ export const createGlobalReferenceReader = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
): ((callee: t.Node) => GlobalReferenceRead) => {
const selected = ["global", ...path];
let generation = -1;
const byNamespace = new Map<
string | null,
typeof state.ambientWriteEffects
>();
const read = createGlobalPathRead(state, path);
return (callee) => {
const namespace = globalReferenceNamespace(callee, state, path);
if (namespace === undefined) return { status: "unrelated" };
return namespace === undefined
? { status: "unrelated" }
: read(callee, namespace);
};
};
/** Read an implicit intrinsic property at its actual source site, without inventing a callee. */
export const createGlobalPathReader = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
): ((site: t.Node) => GlobalReferenceRead) => {
const read = createGlobalPathRead(state, path);
return (site) => read(site, null);
};
const createGlobalPathRead = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
): ((site: t.Node, namespace: string | null) => GlobalReferenceRead) => {
const selected = ["global", ...path];
let writesGeneration = -1;
let escapesGeneration = -1;
const byNamespace = new Map<
string | null,
{
writes: typeof state.ambientWriteEffects;
escapes: typeof state.ambientEscapeEffects;
}
>();
return (site, namespace) => {
const covered = (paths: typeof state.ambientWrites): boolean =>
paths.covers(selected) ||
(namespace !== null && paths.covers(["global", namespace]));
const writesCovered = covered(state.ambientWrites);
const escapesCovered = covered(state.ambientEscapes);
if (!writesCovered && !escapesCovered) return { status: "unmodified" };
if (
!state.ambientWrites.covers(selected) &&
(namespace === null || !state.ambientWrites.covers(["global", namespace]))
)
return { status: "unmodified" };
if (generation !== state.ambientWriteEffects.length) {
generation = state.ambientWriteEffects.length;
writesGeneration !== state.ambientWriteEffects.length ||
escapesGeneration !== state.ambientEscapeEffects.length
) {
writesGeneration = state.ambientWriteEffects.length;
escapesGeneration = state.ambientEscapeEffects.length;
byNamespace.clear();
}
let effects = byNamespace.get(namespace);
if (effects === undefined) {
effects = state.ambientWriteEffects.filter(
(effect) =>
ambientEffectCovers(effect, selected) ||
(namespace !== null &&
ambientEffectCovers(effect, ["global", namespace])),
);
const matches = (
effect: (typeof state.ambientWriteEffects)[number],
): boolean =>
ambientEffectCovers(effect, selected) ||
(namespace !== null &&
ambientEffectCovers(effect, ["global", namespace]));
effects = {
writes: state.ambientWriteEffects.filter(matches),
escapes: state.ambientEscapeEffects.filter(matches),
};
byNamespace.set(namespace, effects);
}
if (effects.length === 0) return { status: "unknown" };
const active = effects.filter(
(effect) => semanticEffectTiming(callee, effect.node, state) !== "after",
// The call that escapes a receiver contains the callee being identified.
// That escape cannot have replaced the callee before the call selects it.
const escapePrecedesRead = effects.escapes.some((effect) => {
const escape = effect.node;
if (
escape !== undefined &&
escape !== site &&
typeof escape.start === "number" &&
typeof escape.end === "number" &&
typeof site.start === "number" &&
typeof site.end === "number" &&
site.start >= escape.start &&
site.end <= escape.end
)
return false;
return semanticEffectTiming(site, escape, state) !== "after";
});
if (
(escapesCovered && effects.escapes.length === 0) ||
escapePrecedesRead ||
(writesCovered && effects.writes.length === 0)
)
return { status: "unknown" };
const active = effects.writes.filter(
(effect) => semanticEffectTiming(site, effect.node, state) !== "after",
);
if (active.length === 0) return { status: "unmodified" };
if (
@@ -943,47 +998,57 @@ export const createGlobalReferenceReader = (
)
)
return { status: "unknown" };
let replacement: t.AssignmentExpression | undefined;
for (const effect of active) {
const write = effect.node;
const writeNamespace = t.isAssignmentExpression(write)
? globalReferenceNamespace(write.left, state, path)
: undefined;
if (
t.isAssignmentExpression(write, { operator: "=" }) &&
writeNamespace !== undefined &&
(writeNamespace === null ||
!state.ambientWrites.covers(["global", writeNamespace])) &&
semanticDefiniteEffectPrecedesReference(
write,
callee,
state.parentsByNode,
) &&
(replacement === undefined ||
semanticDefiniteEffectPrecedesReference(
replacement,
write,
state.parentsByNode,
))
)
replacement = write;
}
if (replacement === undefined) return { status: "unknown" };
const confirmed = replacement;
if (
active.some(
(effect) =>
effect.node !== confirmed &&
semanticEffectTiming(confirmed, effect.node, state) !== "before" &&
(effect.node === undefined ||
!semanticDefiniteEffectPrecedesReference(
effect.node,
confirmed,
state.parentsByNode,
)),
)
)
return { status: "unknown" };
return { status: "replacement", value: confirmed.right };
return globalPathReplacement(state, path, site, active);
};
};
/** Select a definite replacement only when every competing write is proven earlier. */
const globalPathReplacement = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
site: t.Node,
effects: typeof state.ambientWriteEffects,
): GlobalReferenceRead => {
let replacement: t.AssignmentExpression | undefined;
for (const effect of effects) {
const write = effect.node;
const writeNamespace = t.isAssignmentExpression(write)
? globalReferenceNamespace(write.left, state, path)
: undefined;
if (
t.isAssignmentExpression(write, { operator: "=" }) &&
writeNamespace !== undefined &&
(writeNamespace === null ||
!state.ambientWrites.covers(["global", writeNamespace])) &&
semanticDefiniteEffectPrecedesReference(
write,
site,
state.parentsByNode,
) &&
(replacement === undefined ||
semanticDefiniteEffectPrecedesReference(
replacement,
write,
state.parentsByNode,
))
)
replacement = write;
}
if (replacement === undefined) return { status: "unknown" };
const confirmed = replacement;
if (
effects.some(
(effect) =>
effect.node !== confirmed &&
semanticEffectTiming(confirmed, effect.node, state) !== "before" &&
(effect.node === undefined ||
!semanticDefiniteEffectPrecedesReference(
effect.node,
confirmed,
state.parentsByNode,
)),
)
)
return { status: "unknown" };
return { status: "replacement", value: confirmed.right };
};
@@ -249,3 +249,20 @@ it("does not initialize a class self name during computed-key evaluation", () =>
expect(ir.callSites[0]).toBeDefined();
expect(ir.callSites[0]?.resolution).not.toBe("exact");
});
it.each(["__proto__", '"__proto__"', "['__proto__']"])(
"distinguishes prototype setters from own data keys in loader-write scoping: %s",
(key) => {
const ir = analyze(`
with ({require: local, [Symbol.unscopables]: {${key}: {require: true}}}) {
require = replacement;
}
const selected = require("node:fs");
`);
const selected = onlyBinding(ir, "selected");
if (key.startsWith("[")) {
// A computed key is an own data property, so it supplies no inherited exclusion.
expect(selected.provenance.status).toBe("module");
} else expect(selected.provenance.status).not.toBe("module");
},
);
@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import { runInNewContext } from "node:vm";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js";
import {
onlyBinding,
onlyCallable,
} from "./javascriptSemanticAnalysis.fixture.js";
const resultValue = (body: string) =>
onlyCallable(
@@ -9,6 +13,128 @@ const resultValue = (body: string) =>
"result",
).returnSites[0]?.value;
it.each([
[
"own getter",
"const value = { count: 1, get then() { this.count = 2; } };",
2,
],
[
"own method",
"const value = { count: 1, then(resolve) { this.count = 2; resolve(); } };",
2,
],
[
"function-valued slot",
"const value = { count: 1, then: function(resolve) { this.count = 2; resolve(); } };",
2,
],
[
"getter returning a method",
"const value = { count: 1, get then() { return function(resolve) { this.count = 2; resolve(); }; } };",
2,
],
[
"inherited getter",
"const proto = { get then() { this.count = 2; } }; const value = { __proto__: proto, count: 1 };",
2,
],
[
"inherited method",
"const proto = { then(resolve) { this.count = 2; resolve(); } }; const value = { __proto__: proto, count: 1 };",
2,
],
[
"class method",
"class Value { count = 1; then(resolve) { this.count = 2; resolve(); } } const value = new Value();",
2,
],
[
"stored method",
"const value = { count: 1 }; value.then = function(resolve) { this.count = 2; resolve(); };",
2,
],
[
"method stored on a copied shared child",
"const source = { child: { count: 1 } }; const box = { ...source }; source.child.then = function(resolve) { this.count = 2; resolve(); }; const value = box.child;",
2,
],
[
"copied method retained after source replacement",
"const source = { count: 1, then(resolve) { this.count = 2; resolve(); } }; const value = { ...source }; source.then = null;",
2,
],
["inert object", "const value = { count: 1 };", 1],
["noncallable then", "const value = { count: 1, then: null };", 1],
[
"noncallable getter result",
"const value = { count: 1, get then() { return 3; } };",
1,
],
[
"own noncallable shadow",
"const proto = { get then() { this.count = 2; } }; const value = { __proto__: proto, count: 1, then: null };",
1,
],
[
"method replaced before await",
"const value = { count: 1, then(resolve) { this.count = 2; resolve(); } }; value.then = null;",
1,
],
] as const)(
"accounts for thenable effects at await: %s",
async (_kind, declaration, expected) => {
const body = `${declaration} await value; return value.count;`;
expect(await runInNewContext(`(async () => { ${body} })()`)).toBe(expected);
const ir = analyzeJavaScriptSemantics(
`export async function result() { ${body} }`,
);
const value = onlyCallable(ir, "result").returnSites[0]?.value;
if (expected === 2) expect(value?.status).toBe("unknown");
else expect(value).toEqual({ status: "literal", value: 1 });
},
);
it("keeps a then slot captured before a later top-level source write", async () => {
const body = `
const source = { count: 1 };
const value = { ...source };
source.then = function(resolve) { this.count = 2; resolve(); };
await value;
const observed = value.count;
`;
expect(
await runInNewContext(`(async () => { ${body} return observed; })()`),
).toBe(1);
expect(
onlyBinding(analyzeJavaScriptSemantics(body), "observed").value,
).toEqual({ status: "literal", value: 1 });
});
it.each([
"const value = { count: 1, then: external };",
"const value = { count: 1, then: null }; value.then = external;",
])("keeps an externally supplied then callable possible: %s", async (slot) => {
const body = `${slot} await value; return value.count;`;
for (const external of [
null,
function (this: { count: number }, resolve: () => void) {
this.count = 2;
resolve();
},
]) {
expect(
await runInNewContext(`(async () => { ${body} })()`, { external }),
).toBe(external === null ? 1 : 2);
}
const ir = analyzeJavaScriptSemantics(
`export async function result(external) { ${body} }`,
);
expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe(
"unknown",
);
});
it.each([
["field = shared;", "declare field: { mode: string };", "new Derived()"],
[
@@ -41,6 +167,83 @@ it.each([
},
);
it("retains then invocation effects through a getter-invoked async callable", async () => {
const body = `
const value = {
count: 1,
get start() { (async () => { await this; })(); return null; },
then(resolve) { this.count = 2; resolve(); },
};
await value.start;
return value.count;
`;
expect(await runInNewContext(`(async () => { ${body} })()`)).toBe(2);
const ir = analyzeJavaScriptSemantics(
`export async function result() { ${body} }`,
);
expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe(
"unknown",
);
});
it.each([
[
"Object.prototype.then = function(resolve) { this.count = 2; resolve(); }; const value = { count: 1 }; await value; return value.count;",
2,
],
[
"Array.prototype.then = function(resolve) { this[0] = 2; resolve(); }; const value = [1]; await value; return value[0];",
2,
],
[
"Object.prototype.then = function(resolve) { this.count = 2; resolve(); }; const value = { count: 1, then: null }; await value; return value.count;",
1,
],
[
"Object.prototype.then = function(resolve) { this.count = 2; resolve(); }; const value = { __proto__: null, count: 1 }; await value; return value.count;",
1,
],
[
"Array.prototype.then = function(resolve) { this[0] = 2; resolve(); }; const value = [1]; value.then = null; await value; return value[0];",
1,
],
[
"Object.prototype.then = function(resolve) { this[0] = 2; resolve(); }; const value = [1]; await value; return value[0];",
2,
],
[
"Object.prototype.then = function(resolve) { this[0] = 2; resolve(); }; Array.prototype.then = null; const value = [1]; await value; return value[0];",
1,
],
[
"Object.prototype.then = null; const value = { count: 1 }; await value; return value.count;",
1,
],
[
"Object.defineProperty(Object.prototype, 'then', { get() { this.count = 2; } }); const value = { count: 1 }; await value; return value.count;",
2,
],
[
"Array.prototype.__proto__ = { then(resolve) { this[0] = 2; resolve(); } }; const value = [1]; await value; return value[0];",
2,
],
[
"Object.prototype.then = function(resolve) { this[0] = 2; resolve(); }; Array.prototype.__proto__ = null; const value = [1]; await value; return value[0];",
1,
],
] as const)(
"retains source-written prototype then invocation: %s",
async (body, expected) => {
expect(await runInNewContext(`(async () => { ${body} })()`)).toBe(expected);
const ir = analyzeJavaScriptSemantics(
`export async function result() { ${body} }`,
);
const value = onlyCallable(ir, "result").returnSites[0]?.value;
if (expected === 2) expect(value?.status).toBe("unknown");
else expect(value).toEqual({ status: "literal", value: 1 });
},
);
it.each(["field;", "field = { mode: 'other' };"])(
"preserves a real derived field's shadowing: %s",
(field) => {
@@ -269,3 +269,18 @@ describe("JavaScript semantic analysis: configuration value escapes", () => {
expect(configurationReads(body)).toEqual([]);
});
});
it.each([
["if (flag) child = spawn('tool');", "partial"],
["child = spawn('tool');", "complete"],
] as const)(
"keeps child-process incoming parameter uncertainty after %s",
(assignment, resolution) => {
const ir = analyzeJavaScriptSemantics(
`import { spawn } from 'node:child_process'; function stop(child, flag) { ${assignment} child.kill(); }`,
);
expect(ir.childProcessInteractions).toEqual([
expect.objectContaining({ kind: "signal", resolution }),
]);
},
);
@@ -28,6 +28,13 @@ const eventsFor = (call: string) =>
}));
it.each([
["a missing on listener", `e.on("x")`],
["a missing once listener", `e.once("x")`],
["a missing addListener listener", `e.addListener("x")`],
["a missing prependListener listener", `e.prependListener("x")`],
["a missing prependOnceListener listener", `e.prependOnceListener("x")`],
["a missing removeListener listener", `e.removeListener("x")`],
["a missing off listener", `e.off("x")`],
["a numeric listener", `e.on("x", 1)`],
["a string listener", `e.on("x", "fn")`],
["a template-literal listener", 'e.on("x", `fn`)'],
@@ -22,6 +22,54 @@ const releases = (source: string) =>
resolution,
}));
it.each([
["net", "connect", "-0"],
["net", "createConnection", "-0.0"],
["tls", "connect", "-0"],
["net", "connect", "typeof 1.5"],
["net", "connect", "~(-1.5)"],
["tls", "connect", "void 0, { port: 443 }"],
] as const)(
"retains %s.%s(%s) and its socket release when the unary result is valid",
(module, method, argument) => {
expect(
operations(
`import socket from "node:${module}"; const handle = socket.${method}(${argument}); handle.destroy();`,
).map(({ kind, method, resolution }) => ({ kind, method, resolution })),
).toEqual([
{ kind: "acquire", method, resolution: "complete" },
{ kind: "release", method: "destroy", resolution: "complete" },
]);
},
);
it.each([
["connect", "void 0"],
["createConnection", "void value"],
["connect", "undefined"],
["connect", "void 0, { port: 443 }"],
] as const)(
"emits no net.%s acquisition or release for a missing first argument: %s",
(method, argument) => {
expect(
operations(
`import net from 'node:net'; const handle = net.${method}(${argument}); handle.destroy();`,
),
).toEqual([]);
},
);
it("retains unknown net argument positions and shadowed undefined values", () => {
for (const body of [
"function use(undefined) { const handle=net.connect(undefined);handle.destroy(); }",
"const handle=net.connect(...args, void 0);handle.destroy();",
])
expect(operations(`import net from 'node:net';${body}`)).toEqual([
expect.objectContaining({ kind: "acquire", method: "connect" }),
expect.objectContaining({ kind: "release", method: "destroy" }),
]);
});
const commonJsPromise = `const { open } = require("node:fs/promises"); const h = open("input.txt"); h.close();`;
it("keeps conditional release facts when the loader is unknown", () => {
@@ -198,3 +246,17 @@ it("keeps an awaited handle release uncertain after an opaque receiver write", (
),
).toEqual([{ method: "close", linked: 1, resolution: "partial" }]);
});
it.each([
["if (flag) handle = createReadStream('input.txt');", "partial"],
["handle = createReadStream('input.txt');", "complete"],
] as const)(
"preserves incoming parameter uncertainty before release: %s",
(assignment, resolution) => {
expect(
releases(
`import { createReadStream } from 'node:fs'; function release(handle, flag) { ${assignment} handle.destroy(); }`,
),
).toEqual([{ method: "destroy", linked: 1, resolution }]);
},
);
@@ -16,7 +16,8 @@ import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
* net.connect("") -> THREW ERR_SOCKET_BAD_PORT
* net.connect("/tmp/x.sock") -> Socket created (Unix socket path)
* net.connect({ port: 80 }) -> Socket created
* net.connect(undefined) / {} / [] -> THREW ERR_MISSING_ARGS (undecided here)
* net.connect() / undefined / void 0 -> THREW ERR_MISSING_ARGS
* net.connect({}) / [] -> THREW ERR_MISSING_ARGS (undecided here)
*/
const socketAcquisitionsFor = (call: string) =>
analyzeJavaScriptSemantics(
@@ -34,6 +35,10 @@ it.each([
["a boolean port", `net.connect(true)`],
["a null port", `net.connect(null)`],
["an empty path", `net.connect("")`],
["an omitted argument", `net.connect()`],
["an explicit undefined port", `net.connect(undefined)`],
["a void port", `net.connect(void 0)`],
["an omitted createConnection argument", `net.createConnection()`],
["a tls port above the range", `tls.connect(65536)`],
["a tls negative port", `tls.connect(-1)`],
["a tls NaN port", `tls.connect(NaN)`],
@@ -49,8 +54,12 @@ it.each([
["the highest port", `net.connect(65535)`, "connect"],
["a Unix socket path", `net.connect("/tmp/x.sock")`, "connect"],
["an options object", `net.connect({ port: 80 })`, "connect"],
["an omitted argument", `net.connect()`, "connect"],
["an explicit undefined port", `net.connect(undefined)`, "connect"],
["a computed argument", `net.connect(p)`, "connect"],
[
"a TLS port supplied by later options",
`tls.connect(void 0, { port: 443 })`,
"connect",
],
["a tls port", `tls.connect(80)`, "connect"],
["a createConnection port", `net.createConnection(80)`, "createConnection"],
] as const)("keeps the socket acquisition for %s", (_name, call, method) => {
@@ -16,7 +16,6 @@ import {
resolveSemanticBindingState,
semanticResolutionBlocked,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js";
@@ -160,7 +159,10 @@ export const collectJavaScriptSemanticAsyncEffects = (
*
* A class expression passes Node's own `typeof` check, so it is kept.
*/
const rejectsEventListener = (listener: t.Node): boolean => {
export const rejectsSemanticEventListener = (
listener: t.Node | undefined,
): boolean => {
if (listener === undefined) return true;
if (t.isSpreadElement(listener)) return false;
const value = unwrapJavaScriptExpression(listener).node;
if (t.isFunctionExpression(value) || t.isArrowFunctionExpression(value))
@@ -204,9 +206,9 @@ const collectEvent = (
// provably the listener: `e.on(...["x", fn], 1)` registers `fn` and ignores the
// trailing `1`.
if (
EVENT_LISTENER_METHODS.includes(method) &&
!t.isSpreadElement(eventArgument) &&
listenerArgument !== undefined &&
rejectsEventListener(listenerArgument)
rejectsSemanticEventListener(listenerArgument)
)
return;
const emitter = emitterIdentity(member.object, state);
@@ -7,6 +7,25 @@ const analyze = (source: string) =>
`import { spawn } from 'node:child_process';\n${source}`,
);
it.each(
["on", "once", "addListener"].flatMap((method) =>
["", ", 1", ", null", ", {}", ", void 0"].map((listener) => ({
method,
listener,
})),
),
)(
"does not invent a child $method registration with rejected listener $listener",
({ method, listener }) => {
const ir = analyze(
`const child = spawn('worker'); child.${method}('exit'${listener});`,
);
expect(ir.childProcessSpawns).toHaveLength(1);
expect(ir.childProcessInteractions).toEqual([]);
expect(ir.eventOperations).toEqual([]);
},
);
it("keeps interactions before replacement resolved and later ownership uncertain", () => {
const ir = analyze(`
let child = spawn('old');
@@ -7,6 +7,7 @@ import type {
JavaScriptSemanticChildProcessSpawn,
} from "./javascriptSemanticIr.js";
import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js";
import { rejectsSemanticEventListener } from "./javascriptSemanticAsyncEffects.js";
import {
createSemanticExpressionShapeResolver,
optionsAfterOptionalArgv,
@@ -225,6 +226,11 @@ const childInteraction = (
const method = semanticStaticPropertyName(member.property, member.computed);
const classification = classifyInteraction(method, node.arguments[0]);
if (classification === null) return null;
if (
classification.kind === "listener" &&
rejectsSemanticEventListener(node.arguments[1])
)
return null;
const binding = objectBinding(member.object, state);
const { candidates: linked, complete } = resolveChildCandidates(
member.object,
@@ -30,6 +30,8 @@ export interface SemanticPropertyReadSource
extends IterationSourceValue, SourceGuards {
readonly mutation: t.Node;
readonly lookup: "prototype" | "own";
/** The language protocol invokes a callable value read from this slot. */
readonly invoke?: true;
}
interface PatternSource extends IterationSourceValue {
@@ -337,6 +339,7 @@ const semanticSourceFacts = (
t.isAssignmentExpression(node) ||
t.isForOfStatement(node) ||
t.isSpreadElement(node) ||
t.isAwaitExpression(node) ||
t.isYieldExpression(node) ||
t.isCatchClause(node) ||
t.isFunction(node)
@@ -372,11 +375,15 @@ const semanticSourceFacts = (
mutation: node,
lookup: "own",
});
} else if (
t.isYieldExpression(node) &&
node.delegate &&
node.argument != null
)
} else if (t.isAwaitExpression(node))
propertyReads.push({
node: node.argument,
projection: ["then"],
mutation: node,
lookup: "prototype",
invoke: true,
});
else if (t.isYieldExpression(node) && node.delegate && node.argument != null)
add(sources, { node: node.argument, projection: [] }, node, {});
else if (
t.isCatchClause(node) &&
@@ -2,6 +2,7 @@ import * as t from "@babel/types";
import { semanticKnownSymbolKey } from "./javascriptSemanticPropertyKeys.js";
import {
createAmbientPathResolver,
createGlobalPathReader,
retainSemanticAmbientEffect,
} from "./javascriptSemanticAmbientWrites.js";
import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js";
@@ -37,6 +38,7 @@ import {
import { semanticContainer } from "./javascriptSemanticSlots.js";
import {
semanticProjectedSourceFactsAtRead,
semanticCapturedSelectionsAfterPropertyRead,
semanticShiftCapturedSelections,
semanticNormalizeCapturedSelections,
type SemanticCapturedSelection,
@@ -49,6 +51,7 @@ import {
semanticClassAccessorReferences,
semanticObjectPropertyReferences,
semanticObjectAccessorReferences,
semanticNativePrototypeChain,
type JavaScriptSemanticObjectReference,
} from "./javascriptSemanticObjectReferences.js";
import {
@@ -122,27 +125,55 @@ const arrayPrototypeIterationMayChange = (path: PropertyPath): boolean => {
/** Preserve uncertainty from property writes and references exposed to calls, yielding during traversal and fixpoint iteration. */
/** Accessor selection uses the same slots, prototype shadowing and array indices as aliases. */
const localAccessorMethods = (
/** Local methods use the same slots, prototype shadowing and array indices as aliases. */
const localPropertyMethods = (
root: t.Node,
path: PropertyPath,
accessor: "get" | "set",
accessor: "get" | "set" | "invoke",
state: JavaScriptSemanticAnalysisState,
keyMayExist: (
key: PropertyPath[number] | undefined,
kind: "get" | "set",
) => boolean,
lookup: "prototype" | "own" = "prototype",
): readonly (t.ObjectMethod | t.ClassMethod)[] | undefined => {
if (!keyMayExist(path.at(-1), accessor)) return [];
const methods = new Set<t.ObjectMethod | t.ClassMethod>();
const pending = [{ node: root, path }];
): readonly t.Function[] | undefined => {
if (accessor !== "invoke" && !keyMayExist(path.at(-1), accessor)) return [];
const methods = new Set<t.Function>();
const pending: (MutationSelectionContext & {
node: t.Node;
path: PropertyPath;
})[] = [{ node: root, path, memberReadAt: root }];
const examined = new WeakMap<t.Node, Set<string>>();
const nativeReaders = new Map<
string,
ReturnType<typeof createGlobalPathReader>
>();
const nativeRead = (
prototype: "Object" | "Array",
key: string,
at: t.Node,
) => {
const identity = JSON.stringify([prototype, key]);
let read = nativeReaders.get(identity);
if (read === undefined) {
read = createGlobalPathReader(state, [prototype, "prototype", key]);
nativeReaders.set(identity, read);
}
return read(at);
};
let selections = 0;
for (let cursor = 0; cursor < pending.length; cursor++) {
const current = pending[cursor]!;
const node = unwrapJavaScriptExpression(current.node).node;
const key = JSON.stringify(current.path);
const key = JSON.stringify([
current.path,
current.capturedSelections?.map(({ at, depth, ownEnumerable }) => [
at.start,
at.end,
depth,
ownEnumerable,
]),
]);
const paths = examined.get(node) ?? new Set<string>();
if (paths.has(key)) continue;
paths.add(key);
@@ -152,7 +183,10 @@ const localAccessorMethods = (
return undefined;
}
if (
(t.isObjectMethod(node) || t.isClassMethod(node)) &&
t.isFunction(node) &&
(accessor === "invoke" ||
t.isObjectMethod(node) ||
t.isClassMethod(node)) &&
current.path.length === 0
) {
methods.add(node);
@@ -160,15 +194,42 @@ const localAccessorMethods = (
}
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
if (binding === undefined) continue;
const selected = semanticBindingAtRead(binding, node, state).binding;
for (const source of [
...selected.initializers,
...selected.referenceInitializers,
])
if (binding === undefined) {
if (accessor === "invoke") return undefined;
continue;
}
if (
accessor === "invoke" &&
!semanticBindingAtRead(binding, node, state).hasDefiniteInitializer
)
return undefined;
const selected = semanticProjectedSourceFactsAtRead(
binding,
node,
current.path,
state,
true,
current.memberReadAt,
current.capturedSelections,
false,
// A spread copy escapes the copied values. That must not make the
// source's own accessors unknown, or the next read escapes the
// receiver itself.
accessor === "invoke",
);
if (selected === undefined) return undefined;
for (const source of selected.sources)
pending.push({
node: source.node,
path: [...source.projection, ...current.path],
path: source.path,
memberReadAt: current.memberReadAt,
capturedSelections: semanticNormalizeCapturedSelections([
...(semanticShiftCapturedSelections(
current.capturedSelections,
source.path.length - current.path.length,
) ?? []),
...(source.capturedSelections ?? []),
]),
});
} else if (
t.isMemberExpression(node) ||
@@ -180,15 +241,57 @@ const localAccessorMethods = (
semanticStaticPropertyKey(node.property, node.computed),
...current.path,
],
memberReadAt: current.memberReadAt,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
1,
),
});
} else if (
current.path.length > 0 &&
(t.isObjectExpression(node) || t.isArrayExpression(node))
) {
const [selected, ...remaining] = current.path;
if (
accessor === "invoke" &&
lookup === "prototype" &&
!current.capturedSelections?.some(
(capture) => capture.depth === 1 && capture.ownEnumerable,
)
) {
for (const prototype of semanticNativePrototypeChain(node, selected!)) {
if (selected === null || typeof selected === "object")
return undefined;
const at = current.memberReadAt ?? root;
const member = nativeRead(prototype, String(selected), at);
if (member.status === "unknown" || member.status === "unrelated")
return undefined;
if (member.status === "replacement") {
pending.push({
node: member.value,
path: remaining,
memberReadAt: at,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
-1,
),
});
break;
}
// A changed native link selects its retained source rather than the
// default next prototype. Null naturally ends that lookup.
const link = nativeRead(prototype, "__proto__", at);
if (link.status === "unknown" || link.status === "unrelated")
return undefined;
if (link.status === "replacement") {
pending.push({ ...current, node: link.value });
break;
}
}
}
const references = t.isArrayExpression(node)
? semanticArrayPropertyReferences(node, [selected!])
: remaining.length === 0
: remaining.length === 0 && accessor !== "invoke"
? semanticObjectAccessorReferences(
node,
selected!,
@@ -197,11 +300,24 @@ const localAccessorMethods = (
lookup,
)
: semanticObjectPropertyReferences(node, [selected!], state);
for (const source of references)
for (const source of references) {
if (
source.inherited &&
current.capturedSelections?.some(
(capture) => capture.depth === 1 && capture.ownEnumerable,
)
)
continue;
pending.push({
node: source.node,
path: [...source.path, ...remaining],
memberReadAt: current.memberReadAt,
capturedSelections: semanticCapturedSelectionsAfterPropertyRead(
current.capturedSelections,
source,
),
});
}
} else if (
(t.isClass(node) || t.isNewExpression(node)) &&
current.path.length > 0
@@ -218,23 +334,39 @@ const localAccessorMethods = (
const references =
remaining.length > 0
? semanticClassPropertyReferences(selection, [selected!], state)
: lookup === "own"
? []
: semanticClassAccessorReferences(
selection,
selected!,
state,
accessor,
);
: accessor === "invoke"
? semanticClassPropertyReferences(selection, [selected!], state)
: lookup === "own"
? []
: semanticClassAccessorReferences(
selection,
selected!,
state,
accessor,
);
for (const source of references)
pending.push({
node: source.node,
path: [...source.path, ...remaining],
memberReadAt: current.memberReadAt,
capturedSelections: semanticCapturedSelectionsAfterPropertyRead(
current.capturedSelections,
source,
),
});
}
} else {
for (const source of branchReferenceValues(node, current.path))
pending.push(source);
const references = branchReferenceValues(node, current.path);
if (accessor === "invoke" && references.length === 0) {
const value = evaluateSemanticExpression(
node,
state,
current.memberReadAt,
);
if (!["literal", "union", "object", "array"].includes(value.status))
return undefined;
}
for (const source of references) pending.push({ ...current, ...source });
}
}
return [...methods];
@@ -247,6 +379,7 @@ type AccessorEffectOperand =
readonly node: t.Node;
readonly path: PropertyPath;
readonly lookup: "prototype" | "own";
readonly invoke?: true;
readonly guards?: SemanticPropertyReadSource;
}
| {
@@ -333,6 +466,7 @@ const accessorEffectOperands = (
node: source.node,
path: source.projection,
lookup: source.lookup,
...(source.invoke ? { invoke: true } : {}),
guards: source,
});
if (
@@ -383,13 +517,14 @@ export function* collectSemanticMemberMutationsSteps(
);
};
const returnedReferences = createSemanticReturnedReferences(state);
const getterEffects = (
const propertyReadEffects = (
root: t.Node,
path: PropertyPath,
lookup: "prototype" | "own" = "prototype",
invoke = false,
): readonly JavaScriptSemanticObjectReference[] => {
if (!accessorKeyMayExist(path.at(-1), "get")) return [];
const pending = [{ node: root, path, lookup }];
if (!invoke && !accessorKeyMayExist(path.at(-1), "get")) return [];
const pending = [{ node: root, path, lookup, invoke }];
const examined = new Set<string>();
const effects: JavaScriptSemanticObjectReference[] = [];
for (let cursor = 0; cursor < pending.length; cursor++) {
@@ -397,12 +532,14 @@ export function* collectSemanticMemberMutationsSteps(
node: selectedRoot,
path: selected,
lookup: selectedLookup,
invoke: selectedInvoke,
} = pending[cursor]!;
const identity = JSON.stringify([
selectedRoot.start,
selectedRoot.end,
selected,
selectedLookup,
selectedInvoke,
]);
if (examined.has(identity)) continue;
examined.add(identity);
@@ -410,7 +547,20 @@ export function* collectSemanticMemberMutationsSteps(
state.valueResourceLimits.add("alias-selections");
return [{ node: root, path: [] }];
}
const methods = localAccessorMethods(
const receiver = selected.slice(0, -1);
if (
selectedInvoke &&
(localPropertyMethods(
selectedRoot,
selected,
"invoke",
state,
accessorKeyMayExist,
selectedLookup,
)?.length ?? 1) > 0
)
effects.push({ node: selectedRoot, path: receiver });
const methods = localPropertyMethods(
selectedRoot,
selected,
"get",
@@ -418,12 +568,12 @@ export function* collectSemanticMemberMutationsSteps(
accessorKeyMayExist,
selectedLookup,
);
const receiver = selected.slice(0, -1);
if (methods === undefined) {
effects.push({ node: selectedRoot, path: receiver });
continue;
}
for (const method of methods) {
if (!t.isObjectMethod(method) && !t.isClassMethod(method)) continue;
const callables: t.Function[] = [method];
const visitedCallables = new Set<t.Function>();
for (let index = 0; index < callables.length; index++) {
@@ -441,7 +591,8 @@ export function* collectSemanticMemberMutationsSteps(
}
if (
operand.kind === "read" &&
(!accessorKeyMayExist(operand.path.at(-1), "get") ||
((!operand.invoke &&
!accessorKeyMayExist(operand.path.at(-1), "get")) ||
(operand.guards !== undefined &&
semanticReferenceSourcesInactive(operand.guards, state)))
)
@@ -465,6 +616,7 @@ export function* collectSemanticMemberMutationsSteps(
node: targetRoot,
path: [...target, ...operand.path],
lookup: operand.lookup,
invoke: operand.invoke === true,
});
}
}
@@ -1114,7 +1266,7 @@ export function* collectSemanticMemberMutationsSteps(
if (
origins.initializers.some(
(source) =>
(localAccessorMethods(
(localPropertyMethods(
source.node,
[...source.projection, ...selectedPath],
"set",
@@ -1596,7 +1748,7 @@ export function* collectSemanticMemberMutationsSteps(
source.kind === "reference-store" &&
source.target !== undefined &&
!state.escapingReferenceStores.has(source.target) &&
(localAccessorMethods(
(localPropertyMethods(
source.target.object,
[
semanticStaticPropertyKey(
@@ -1658,17 +1810,18 @@ export function* collectSemanticMemberMutationsSteps(
}
for (const source of semanticPropertyReadSources(node, parent, state)) {
if (semanticReferenceSourcesInactive(source, state)) continue;
for (const effect of getterEffects(
for (const effect of propertyReadEffects(
source.node,
source.projection,
source.lookup,
source.invoke === true,
))
markEscaped(effect.node, source.mutation, effect.path);
}
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
// The write owner classifies direct and destructuring stores once.
if (!writeOnlyMembers.has(node))
for (const effect of getterEffects(node.object, [
for (const effect of propertyReadEffects(node.object, [
semanticStaticPropertyKey(node.property, node.computed),
]))
markEscaped(effect.node, node, effect.path);
@@ -4,6 +4,7 @@ import { expect, it } from "vitest";
import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js";
import {
semanticClassPropertyReferences,
semanticNativePrototypeChain,
semanticObjectPropertyReferences,
} from "./javascriptSemanticObjectReferences.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
@@ -36,6 +37,37 @@ const spelled = (
],
);
it.each([
["{}", "then", ["Object"]],
["{then:null}", "then", []],
["{then}", "then", []],
["{...{then:null}}", "then", []],
["{...unknown}", "then", ["Object"]],
["{__proto__:null}", "then", []],
["{__proto__:{}}", "then", []],
["{__proto__:0}", "then", ["Object"]],
["{__proto__:unknown}", "then", ["Object"]],
['{["__proto__"]:null}', "then", ["Object"]],
["[]", "then", ["Array", "Object"]],
["[]", "length", []],
["[value]", "0", []],
["[,value]", "0", ["Array", "Object"]],
["[value,...unknown]", "0", []],
["[...unknown,value]", "0", ["Array", "Object"]],
] as const)(
"retains possible native prototype lookup for %s[%s]",
(value, key, expected) => {
const parsed = parseJavaScriptSource(`const o=${value};`, "prototype.mjs");
const declaration = parsed?.program.body[0];
if (!t.isVariableDeclaration(declaration))
throw new Error("Expected declaration");
const node = declaration.declarations[0]?.init;
if (!t.isObjectExpression(node) && !t.isArrayExpression(node))
throw new Error("Expected literal");
expect(semanticNativePrototypeChain(node, key)).toEqual(expected);
},
);
it("resolves exact keys with source-order overrides, spreads and accessors", () => {
const fixture = objectAndState(
`const o = { a: 1, ...base, b: 2, a: 3, [dynamic]: 4, __proto__: proto, ...{ b: 5 }, get c() { return 6; }, c: 7 };`,
@@ -54,14 +86,13 @@ it("resolves exact keys with source-order overrides, spreads and accessors", ()
["4", "[]"],
["base", '["missing"]'],
]);
// A wildcard key still scans every property.
// A wildcard keeps possible slots, excluding data replaced by a known spread.
expect(spelled(fixture, null)).toEqual([
["7", "[]"],
["{ b: 5 }", '[{"excludedKeys":["c"]}]'],
["proto", "[null]"],
["4", "[]"],
["3", "[]"],
["2", "[]"],
["base", '[{"excludedKeys":["a","b","c"]}]'],
]);
// Numeric keys use the same exact spelling as string keys.
@@ -70,6 +101,112 @@ it("resolves exact keys with source-order overrides, spreads and accessors", ()
).toEqual([['"one"', "[]"]]);
});
it.each([1, "1"])(
"preserves inherited numeric property selection %j and own shadowing",
(key) => {
expect(
spelled(objectAndState("const o = { __proto__: prototype };"), key),
).toEqual([["prototype", JSON.stringify([String(key)])]]);
expect(
spelled(
objectAndState('const o = { __proto__: prototype, 1: "own" };'),
key,
),
).toEqual([['"own"', "[]"]]);
},
);
it.each([
[
"static",
"static method(){return 1;} method(){return 2;}",
"static method(){return 1;}",
],
[
"instance",
"static method(){return 1;} method(){return 2;}",
"method(){return 2;}",
],
[
"prototype",
"static method(){return 1;} method(){return 2;}",
"method(){return 2;}",
],
["static", "static method(){return 1;} static set method(value){}", null],
["static", "static method=child; static method(){return 1;}", "child"],
[
"static",
"static get method(){return child;} static method(){return 1;}",
"static method(){return 1;}",
],
[
"static",
"static method(){return 1;} static get method(){return child;}",
"child",
],
[
"static",
"static get method(){return child;} static method(){return 1;} static set method(value){}",
null,
],
] as const)(
"selects method-valued %s descriptors with runtime precedence: %s",
(kind, body, expected) => {
const source = `class Box { ${body} }`;
const parsed = parseJavaScriptSource(source, "methods.mjs");
if (parsed === null) throw new Error("Expected a parsed class");
const owner = parsed.program.body[0];
if (!t.isClassDeclaration(owner)) throw new Error("Expected a class");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
expect(
semanticClassPropertyReferences({ owner, kind }, ["method"], state).map(
({ node }) => source.slice(node.start ?? 0, node.end ?? 0),
),
).toEqual(expected === null ? [] : [expected]);
},
);
it.each(["method(){return 1;}", "...{method: child}"])(
"keeps an intervening object data definition between accessor pairs: %s",
(definition) => {
expect(
spelled(
objectAndState(
`const o = { get method(){return child;} , ${definition}, set method(value){} };`,
),
"method",
),
).toEqual([]);
},
);
it.each(["static", "instance"] as const)(
"retains inherited %s field ownership for enumerable copies",
(kind) => {
const source = `class Base { ${kind === "static" ? "static" : ""} method=child; } class Box extends Base {}`;
const parsed = parseJavaScriptSource(source, "fields.mjs");
if (parsed === null) throw new Error("Expected a parsed class");
const owner = parsed.program.body[1];
if (!t.isClassDeclaration(owner)) throw new Error("Expected derived class");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
const references = semanticClassPropertyReferences(
{ owner, kind, fieldsOnly: true },
["method"],
state,
);
expect(
references.map(({ node }) =>
source.slice(node.start ?? 0, node.end ?? 0),
),
).toEqual(["child"]);
expect(references[0]?.inherited).toBe(kind === "static" ? true : undefined);
},
);
it("preserves references through a small static getter permutation", () => {
const getters = Array.from(
{ length: 5 },
@@ -154,96 +154,73 @@ const classPropertyDescriptors = (
initializedBefore?: t.ClassProperty,
accessor: "get" | "set" = "get",
) => {
const isStatic = kind === "static";
const selectedAccessors: t.ClassMethod[] = [];
const fields: t.ClassProperty[] = [];
const indexedMembers: {
readonly member: t.ClassMethod | t.ClassProperty;
readonly index: number;
readonly key: string | null;
}[] = [];
const methods: t.ClassMethod[] = [];
const accessors: t.ClassMethod[] = [];
const definitions: (t.ClassMethod | t.ClassProperty)[] = [];
const before =
initializedBefore === undefined
? -1
: klass.body.body.indexOf(initializedBefore);
klass.body.body.forEach((member, index) => {
for (const [index, member] of klass.body.body.entries()) {
if (
(t.isClassMethod(member) || t.isClassProperty(member)) &&
propertyName !== null &&
member.computed &&
semanticKnownSymbolKey(member.key, state)
(!t.isClassMethod(member) &&
!(kind === "static" && isRuntimeJavaScriptClassProperty(member))) ||
member.static !== (kind === "static") ||
(before >= 0 &&
isRuntimeJavaScriptClassProperty(member) &&
index >= before) ||
(propertyName !== null &&
member.computed &&
semanticKnownSymbolKey(member.key, state))
)
return;
if (
before >= 0 &&
index >= before &&
isRuntimeJavaScriptClassProperty(member)
)
return;
if (
(t.isClassMethod(member) ||
(kind === "static" && isRuntimeJavaScriptClassProperty(member))) &&
member.static === isStatic
)
indexedMembers.push({
member,
index,
key: semanticStaticPropertyKey(member.key, member.computed),
});
});
const matchingMembers =
propertyName === null
? []
: indexedMembers.filter(({ key }) => key === propertyName);
const uncertainAccessors = indexedMembers.filter(
(entry): entry is typeof entry & { member: t.ClassMethod } => {
const { member, key } = entry;
return (
t.isClassMethod(member) &&
member.kind === accessor &&
(propertyName === null || (member.computed && key === null))
);
},
);
const getters = matchingMembers.filter(
(entry): entry is typeof entry & { member: t.ClassMethod } =>
t.isClassMethod(entry.member) && entry.member.kind === accessor,
);
const getter = getters.at(-1);
const getterIndex = getter?.index ?? -1;
const laterDataMember = matchingMembers.some(
({ member, index }) =>
t.isClassProperty(member) ||
(t.isClassMethod(member) &&
member.kind === "method" &&
index > getterIndex),
);
const seenFields = new Set<string>();
for (const { member, key } of [...indexedMembers].reverse()) {
if (!t.isClassProperty(member)) continue;
if (key !== null && seenFields.has(key)) continue;
if (key !== null) seenFields.add(key);
if (propertyName === null || key === null || key === propertyName)
fields.push(member);
continue;
definitions.push(member);
}
if (getters.length > 0 && !laterDataMember && getter !== undefined)
selectedAccessors.push(getter.member);
for (const { member, index } of uncertainAccessors)
// Methods/accessors are installed before static fields execute, even when a
// field appears earlier in source. Instance fields are owned separately.
const ordered = [
...definitions.filter((member) => t.isClassMethod(member)),
...definitions.filter((member) => t.isClassProperty(member)),
];
const data = new Set<string>();
const getterNames = new Set<string>();
const setterNames = new Set<string>();
let inherits = true;
for (const member of ordered.reverse()) {
const key = semanticStaticPropertyKey(member.key, member.computed);
if (propertyName !== null && key !== null && key !== propertyName) continue;
if (propertyName !== null && key === propertyName) inherits = false;
const descriptor =
t.isClassMethod(member) &&
(member.kind === "get" || member.kind === "set")
? member.kind
: "data";
const selected = key ?? propertyName;
if (
!matchingMembers.some(
({ member: matching, index: matchingIndex }) =>
(t.isClassProperty(matching) ||
(t.isClassMethod(matching) &&
(matching.kind === "method" || matching.kind === accessor))) &&
matchingIndex > index,
)
)
selectedAccessors.push(member);
return {
accessors: selectedAccessors,
fields,
inherits: matchingMembers.length === 0,
};
selected !== null &&
(data.has(selected) ||
(descriptor === "data"
? getterNames.has(selected) || setterNames.has(selected)
: (descriptor === "get" ? getterNames : setterNames).has(selected)))
) {
// A later accessor replaced this data descriptor, but the data definition
// still prevents an earlier accessor from pairing across it.
if (descriptor === "data" && key !== null) data.add(key);
continue;
}
if (t.isClassProperty(member)) fields.push(member);
else if (member.kind === "method") methods.push(member);
else if (member.kind === accessor) accessors.push(member);
if (key !== null)
(descriptor === "data"
? data
: descriptor === "get"
? getterNames
: setterNames
).add(key);
}
return { accessors, fields, methods, inherits };
};
export const semanticClassPropertyReferences = (
@@ -345,9 +322,21 @@ class ClassReferenceWalk {
? descriptors.fields
: [])
if (field.value != null)
this.#references.push({ node: field.value, path: this.#path });
this.#references.push({
node: field.value,
path: this.#path,
...(this.lookup.kind === "static" && klass !== this.lookup.owner
? { inherited: true }
: {}),
});
for (const getter of this.lookup.fieldsOnly ? [] : descriptors.accessors)
this.#getter(klass, getter);
if (this.context.accessor === undefined && !this.lookup.fieldsOnly)
for (const method of descriptors.methods) {
const name = semanticStaticPropertyKey(method.key, method.computed);
if (name !== null && this.#fields.has(name)) continue;
this.#references.push({ node: method, path: this.#path });
}
// Only a definite own descriptor can suppress possible superclass results.
if (descriptors.inherits && klass.superClass != null)
for (const base of semanticClassCandidates(klass.superClass, this.state))
@@ -559,6 +548,18 @@ const expandGetterResult = (
type ObjectLiteralMember = t.ObjectExpression["properties"][number];
/** Only this colon-form definition selects an object literal's prototype. */
const isObjectPrototypeSetter = (
property: ObjectLiteralMember,
name = t.isSpreadElement(property)
? null
: semanticStaticPropertyKey(property.key, property.computed),
): property is t.ObjectProperty =>
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__";
/** Property positions grouped by whether an exact key can reach them. */
interface ObjectPropertyIndex {
/** Spreads, computed keys and `__proto__` data entries reach every key. */
@@ -579,13 +580,7 @@ const objectPropertyIndex = (node: t.ObjectExpression): ObjectPropertyIndex => {
const name = t.isSpreadElement(property)
? null
: semanticStaticPropertyKey(property.key, property.computed);
if (
name === null ||
(t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__")
) {
if (name === null || isObjectPrototypeSetter(property, name)) {
keyIndependent.push(index);
return;
}
@@ -671,7 +666,9 @@ class ObjectReferenceWalk {
readonly accessor?: "get" | "set",
readonly accessorLookup: "prototype" | "own" = "prototype",
) {
[this.#key, ...this.#remaining] = path;
const [key, ...remaining] = path;
this.#key = typeof key === "number" ? String(key) : key;
this.#remaining = remaining;
}
#spreadKey(): PropertyPath[number] {
@@ -691,38 +688,29 @@ class ObjectReferenceWalk {
#spread(property: t.SpreadElement): void {
const selected = this.#key ?? null;
if (typeof selected !== "object" && this.#overwritten.has(String(selected)))
return;
// Spread creates data properties; the source descriptor is not installed.
if (this.accessor === undefined)
if (
this.accessor === undefined &&
(typeof selected === "object" || !this.#overwritten.has(String(selected)))
)
this.#references.push({
node: property.argument,
path: [this.#spreadKey(), ...this.#remaining],
capturedSelectionAt: property.argument,
ownEnumerable: true,
});
if (
selected !== null &&
typeof selected !== "object" &&
t.isObjectExpression(property.argument) &&
objectExpressionDefinesKey(property.argument, String(selected))
) {
this.#overwritten.add(String(selected));
this.#data.add(String(selected));
}
if (t.isObjectExpression(property.argument))
for (const name of objectExpressionDefinedKeys(property.argument)) {
this.#overwritten.add(name);
this.#data.add(name);
}
}
#prototype(
property: t.ObjectMethod | t.ObjectProperty,
name: string | null,
): boolean {
if (
!t.isObjectProperty(property) ||
property.computed ||
property.shorthand ||
name !== "__proto__"
)
return false;
if (!isObjectPrototypeSetter(property, name)) return false;
const selected = this.#key ?? null;
if (this.accessor !== undefined && this.accessorLookup === "own")
return true;
@@ -731,7 +719,7 @@ class ObjectReferenceWalk {
(selected === null ||
typeof selected === "object" ||
(typeof selected === "string" &&
!objectExpressionDefinesKey(this.node, selected)))
!objectExpressionDefinedKeys(this.node).has(selected)))
)
this.#references.push({
node: property.value,
@@ -819,8 +807,10 @@ class ObjectReferenceWalk {
? this.#data.has(name) || this.#accessors.has(name)
: this.#data.has(name) ||
(kind === "get" ? this.#getters : this.#setters).has(name))
)
) {
if (kind === "data") this.#data.add(name);
return;
}
this.#read(property, name);
if (name !== null) this.#record(name, kind);
}
@@ -839,12 +829,12 @@ const objectPropertyReferences = (
): readonly JavaScriptSemanticObjectReference[] =>
new ObjectReferenceWalk(node, path, query).run();
const objectExpressionDefinesKey = (
const objectExpressionDefinedKeys = (
root: t.ObjectExpression,
key: string,
): boolean => {
): ReadonlySet<string> => {
const pending: t.ObjectExpression[] = [root];
const seen = new Set<t.ObjectExpression>();
const names = new Set<string>();
for (let cursor = 0; cursor < pending.length; cursor++) {
const object = pending[cursor];
if (object === undefined || seen.has(object)) continue;
@@ -856,19 +846,11 @@ const objectExpressionDefinesKey = (
continue;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
if (
name === key &&
!(
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__"
)
)
return true;
if (name !== null && !isObjectPrototypeSetter(property, name))
names.add(name);
}
}
return false;
return names;
};
export const semanticArrayPropertyReferences = (
@@ -924,3 +906,44 @@ export const semanticArrayPropertyReferences = (
}
return references;
};
/** Possible native fallback after own slots; explicit prototype references are walked separately. */
export const semanticNativePrototypeChain = (
node: t.ObjectExpression | t.ArrayExpression,
key: PropertyPath[number],
): readonly ("Object" | "Array")[] => {
const exact =
key !== null && typeof key !== "object" ? String(key) : undefined;
if (t.isArrayExpression(node)) {
if (exact === "length") return [];
if (exact !== undefined) {
const sources = semanticArrayPropertyReferences(node, [exact]);
if (
sources.length > 0 &&
sources.every((source) => source.capturedSelectionAt === undefined)
)
return [];
}
return ["Array", "Object"];
}
if (exact !== undefined && objectExpressionDefinedKeys(node).has(exact))
return [];
for (const property of node.properties) {
if (!isObjectPrototypeSetter(property)) continue;
const prototype = unwrapJavaScriptExpression(property.value).node;
// Null removes the prototype. Objects select the explicitly retained
// chain, while primitive values are ignored and unknowns may be primitive.
if (
t.isNullLiteral(prototype) ||
t.isObjectExpression(prototype) ||
t.isArrayExpression(prototype) ||
t.isFunctionExpression(prototype) ||
t.isArrowFunctionExpression(prototype) ||
t.isClassExpression(prototype) ||
t.isRegExpLiteral(prototype) ||
t.isNewExpression(prototype)
)
return [];
}
return ["Object"];
};
@@ -190,6 +190,7 @@ export function semanticProjectedSourceFactsAtRead(
memberReadAt?: t.Node,
capturedSelections?: readonly SemanticCapturedSelection[],
retainEligibility?: boolean,
valueEscapesHideSources?: boolean,
):
| {
readonly sources: readonly (Omit<ProjectedSource, "path"> & {
@@ -219,6 +220,9 @@ const projectedSourceFactsAtRead = (
memberReadAt: t.Node = reference,
capturedSelections: readonly SemanticCapturedSelection[] = [],
retainEligibility = false,
// Accessor lookup reads source syntax. A prior value escape does not hide
// that syntax; unknown writes and invocations still fail closed.
valueEscapesHideSources = true,
):
| {
readonly sources: readonly ProjectedSource[];
@@ -365,6 +369,7 @@ const projectedSourceFactsAtRead = (
(replacement.assignedSources?.[0]?.target?.start ?? -1)));
if (
includeUnknown &&
valueEscapesHideSources &&
semanticSomeEffectPathCandidate(
binding.escapedPaths,
path,
@@ -2,6 +2,40 @@ import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
"load('./dep.cjs')",
"load.call(null,'./dep.cjs')",
"load.apply(null,['./dep.cjs'])",
"Reflect.apply(load,null,['./dep.cjs'])",
])("retains a createRequire filename through %s", (call) => {
const result = analyzeJavaScriptStaticSource(
`import {createRequire} from 'node:module';const load=createRequire(new URL('./sub/entry.cjs',import.meta.url));${call};`,
);
expect(result.references.filter(({ kind }) => kind === "require")).toEqual([
expect.objectContaining({
specifier: "./dep.cjs",
resolution_base_path: "./sub/entry.cjs",
}),
]);
});
it.each([
"load.call(null,'./dep.cjs')",
"Reflect.apply(load,null,['./dep.cjs'])",
])("keeps mixed createRequire bases unknown through %s", (call) => {
const result = analyzeJavaScriptStaticSource(
`import {createRequire} from 'node:module';const load=createRequire(flag?import.meta.url:new URL('./sub/entry.cjs',import.meta.url));${call};`,
);
expect(result.references.filter(({ kind }) => kind === "require")).toEqual([
expect.objectContaining({ specifier: null }),
]);
expect(
result.references.every(
(reference) => !("resolution_base_path" in reference),
),
).toBe(true);
});
it.each([
['const h={require:module.require};h.require("./shared.cjs");', true],
[
@@ -30,6 +30,8 @@ import {
import {
semanticStaticPropertyKey,
semanticMemberChain,
semanticWriteSiteTarget,
semanticWriteTargets,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js";
@@ -71,30 +73,33 @@ export type {
const possibleCommonJsDelegateWriteStates =
new WeakSet<JavaScriptSemanticAnalysisState>();
/** Admission and indexing share the same direct, projected and iteration leaves. */
function* commonJsDelegateWriteTargets(
node: t.Node,
): Generator<t.MemberExpression | t.OptionalMemberExpression> {
const write = semanticWriteSiteTarget(node);
if (write === null) return;
for (const target of semanticWriteTargets(write)) {
if (!t.isMemberExpression(target) && !t.isOptionalMemberExpression(target))
continue;
const key = semanticStaticPropertyKey(target.property, target.computed);
if (key === "require" || (key === null && target.computed)) yield target;
}
}
/** Source eligibility only; the shared write owner still proves receiver identity. */
export const observePossibleCommonJsDelegateWrite = (
node: t.Node,
state: JavaScriptSemanticAnalysisState,
): void => {
if (possibleCommonJsDelegateWriteStates.has(state)) return;
const target = t.isAssignmentExpression(node)
? node.left
: t.isUpdateExpression(node) ||
t.isUnaryExpression(node, { operator: "delete" })
? node.argument
: undefined;
if (
semanticIterationSources(node, state.parentsByNode.get(node) ?? null).some(
(source) => source.kind === "reference-store",
)
)
possibleCommonJsDelegateWriteStates.add(state);
if (
(t.isMemberExpression(target) || t.isOptionalMemberExpression(target)) &&
["require", null].includes(
semanticStaticPropertyKey(target.property, target.computed),
)
)
if (!commonJsDelegateWriteTargets(node).next().done)
possibleCommonJsDelegateWriteStates.add(state);
else if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
possibleCommonJsDelegateWriteStates.add(state);
@@ -886,6 +891,9 @@ export const semanticRequireCallAt = (
}
const interop = loaderInvocationInterop(state);
if (!t.isNewExpression(node)) {
// Invocation normalization retains the selected path identity. Keep its
// filename alongside that path rather than selecting the outer call again.
const sourceRelativePaths = new Map<readonly string[], string>();
const invocation = interop.intrinsicInvocation(node, (callee) => {
const loader = requireLoaderValueAt(
callee,
@@ -912,22 +920,21 @@ export const semanticRequireCallAt = (
state,
factoryBindingIds,
);
if (selected !== undefined)
return [
if (selected !== undefined) {
const path = [
"require-loader",
selected.loader,
selected.sourceRelative ? "relative" : "unknown",
...(selected.loaderAuthority === "candidate" ? ["candidate"] : []),
];
if (selected.sourceRelativePath !== undefined)
sourceRelativePaths.set(path, selected.sourceRelativePath);
return path;
}
return interop.intrinsicPath(callee);
});
if (invocation?.path[0] === "require-loader") {
const selected = selectedRequireSourcesAt(
node.callee,
node,
state,
factoryBindingIds,
);
const sourceRelativePath = sourceRelativePaths.get(invocation.path);
return {
operation: "load",
loader:
@@ -942,9 +949,9 @@ export const semanticRequireCallAt = (
: {}),
sourceRelative:
invocation.kind === "known" && invocation.path[2] === "relative",
...(selected?.sourceRelativePath === undefined
? {}
: { sourceRelativePath: selected.sourceRelativePath }),
...(invocation.kind === "known" && sourceRelativePath !== undefined
? { sourceRelativePath }
: {}),
};
}
}
@@ -1556,20 +1563,8 @@ const writesModuleRequire = (
};
traverseJavaScriptValueAst(state.program, state.erasedSyntaxNodes, {
enter: (node) => {
const target = t.isAssignmentExpression(node)
? node.left
: t.isUpdateExpression(node) ||
t.isUnaryExpression(node, { operator: "delete" })
? node.argument
: undefined;
if (
t.isMemberExpression(target) ||
t.isOptionalMemberExpression(target)
) {
const key = semanticStaticPropertyKey(target.property, target.computed);
if (key === "require" || (key === null && target.computed))
record(node, target.object);
}
for (const target of commonJsDelegateWriteTargets(node))
record(node, target.object);
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
const invocation = interop.intrinsicInvocation(node);
const owner =
@@ -1583,42 +1578,21 @@ const writesModuleRequire = (
const key = interop.staticString(args?.[1]);
if (key === "require" || key === undefined)
record(node, args?.[0] ?? t.nullLiteral());
} else if (owner === "Reflect" && method === "set") {
const key = interop.staticString(args?.[1]);
if (key === "require" || key === undefined)
record(node, args?.[3] ?? args?.[0] ?? t.nullLiteral());
} else if (owner === "Object" && method === "defineProperties") {
const descriptors = args?.[1];
if (
!t.isObjectExpression(descriptors) ||
descriptors.properties.some(
(property) =>
t.isSpreadElement(property) ||
(t.isObjectProperty(property) &&
semanticStaticPropertyKey(property.key, property.computed) ===
"require"),
)
)
if (objectMayProvideRequire(descriptors))
record(node, args?.[0] ?? t.nullLiteral());
} else if (owner === "Object" && method === "assign") {
const target = args?.[0];
if (target !== undefined) {
const sourceHasRequire = args
?.slice(1)
.some(
(arg) =>
t.isObjectExpression(arg) &&
arg.properties.some(
(property) =>
t.isObjectProperty(property) &&
semanticStaticPropertyKey(
property.key,
property.computed,
) === "require",
),
);
if (
sourceHasRequire ||
args?.slice(1).some((arg) => !t.isObjectExpression(arg))
)
record(node, target);
}
if (
target !== undefined &&
args?.slice(1).some(objectMayProvideRequire)
)
record(node, target);
}
}
},
@@ -1627,6 +1601,15 @@ const writesModuleRequire = (
return writes;
};
/** Methods, spreads and unknown computed keys can all supply an own delegate. */
const objectMayProvideRequire = (node: t.Node | undefined): boolean =>
!t.isObjectExpression(node) ||
node.properties.some((property) => {
if (t.isSpreadElement(property)) return true;
const key = semanticStaticPropertyKey(property.key, property.computed);
return key === "require" || key === null;
});
/** Whether the original global process object can still own effects at this source site. */
export const semanticOriginalGlobalProcessAt = (
node: t.Node,
@@ -78,7 +78,11 @@ export const collectJavaScriptSemanticResources = (
const acquisitions = collectAcquisitions(program, context).filter(
(candidate) =>
!socketPortInFirstArgument(candidate) ||
(!rejectsSocketPortLiteral(candidate.node, context) &&
(!rejectsSocketLiteralArguments(
candidate.node,
candidate.specifier,
context,
) &&
!rejectsTlsProtocolVersionLiteral(
candidate.node,
candidate.specifier,
@@ -447,6 +451,9 @@ const collectReleases = (
resolution:
!opaqueReceiver &&
linked.length === 1 &&
(binding.kind !== "parameter" ||
semanticBindingAtRead(binding, member.object, context.state)
.hasDefiniteInitializer) &&
holdsOnlyAcquisitions(binding, linked, context.state)
? "complete"
: "partial",
@@ -573,16 +580,25 @@ const resourceCandidateId = (candidate: ResourceCandidate): string =>
* readable non-port literal is decided. `""` is a path with no length, which
* Node also rejects.
*
* `undefined` and an empty object throw `ERR_MISSING_ARGS`, but those are a
* missing option rather than a bad port, so they stay undecided here.
* `net` also rejects an omitted or undefined port, before it returns a socket.
* TLS merges later options, so an undefined first argument alone cannot decide
* its admission. Object arguments and spread positions remain undecided here.
*/
const rejectsSocketPortLiteral = (
const rejectsSocketLiteralArguments = (
node: t.CallExpression | t.OptionalCallExpression,
specifier: string,
context: DataEffectTraversalContext,
): boolean => {
const port = node.arguments[0];
if (port === undefined || t.isSpreadElement(port)) return false;
if (port === undefined) return specifier === "net";
if (t.isSpreadElement(port)) return false;
const value = unwrapJavaScriptExpression(port).node;
if (
specifier === "net" &&
(t.isUnaryExpression(value, { operator: "void" }) ||
isUnmodifiedGlobalReference(value, context.state, "undefined"))
)
return true;
if (t.isStringLiteral(value)) return value.value.length === 0;
if (
t.isNullLiteral(value) ||
@@ -601,13 +617,14 @@ const rejectsSocketPortLiteral = (
isUnmodifiedGlobalReference(value, context.state, value.name)
)
return true;
// `-1` and `-1.5` are unary expressions over numeric literals: always out of
// range because a port is never negative.
// Numeric unary operators are judged by their result: `-0` is port zero,
// and `~(-1.5)` is zero too. `typeof` instead yields a nonempty IPC path.
if (t.isUnaryExpression(value)) {
const operand = unwrapJavaScriptExpression(value.argument).node;
if (value.operator === "!" || value.operator === "delete") return true;
const number = unaryOperandNumber(value, context, 0);
return (
t.isNumericLiteral(operand) &&
(value.operator === "-" || !Number.isInteger(operand.value))
number !== undefined &&
(!Number.isInteger(number) || number < 0 || number > 65535)
);
}
return false;
@@ -205,3 +205,21 @@ it("widens a cycle that rewrites the path head while increasing its depth", () =
returnedExpression(parsed.program, "target"),
);
});
it("retains both finite class-prototype callable selections", () => {
const { parsed, state } = indexed(`
class C { first(){return firstResult} second(){return secondResult} }
const selected=flag?C.prototype.first:C.prototype.second;
selected();
`);
const [read] = callReads(parsed.program, "selected");
if (read === undefined)
throw new Error("Missing selected prototype method call");
const result = createSemanticReturnedReferences(state)(read);
expect(result.precision).toBe("exact");
expect(
result.references
.map(({ node }) => (t.isIdentifier(node) ? node.name : null))
.sort(),
).toEqual(["firstResult", "secondResult"]);
});
@@ -24,8 +24,13 @@ import {
semanticKnownArrayIterationAt,
semanticSelectedReferenceSlot,
} from "./javascriptSemanticReferenceSources.js";
import { semanticArrayPropertyReferences } from "./javascriptSemanticObjectReferences.js";
import {
semanticArrayPropertyReferences,
semanticObjectPropertyReferences,
semanticClassPropertyReferences,
} from "./javascriptSemanticObjectReferences.js";
import {
semanticCapturedSelectionsAfterPropertyRead,
semanticNormalizeCapturedSelections,
semanticProjectedSourcesAtRead,
semanticShiftCapturedSelections,
@@ -62,6 +67,7 @@ interface Candidate {
| readonly SemanticCapturedSelection[]
| undefined;
readonly invocation?: JavaScriptSourceInvocationContext;
readonly classKind?: "instance" | "static";
}
export interface SemanticReturnedReferenceResult {
@@ -74,22 +80,6 @@ export interface SemanticReturnedReferenceResult {
readonly precision: "exact" | "widened";
}
interface ObjectTargetIndex {
readonly propertyByName: ReadonlyMap<
string,
t.ObjectMethod | t.ObjectProperty
>;
readonly trailingUnknownsByProperty: ReadonlyMap<
t.ObjectMethod | t.ObjectProperty,
ObjectTargetUnknown
>;
}
interface ObjectTargetUnknown {
readonly node: t.ObjectMethod | t.ObjectProperty | t.SpreadElement;
readonly previous: ObjectTargetUnknown | undefined;
}
/** Resolve local result references conservatively, without claiming call execution. */
export const createSemanticReturnedReferences = (
state: JavaScriptSemanticAnalysisState,
@@ -115,7 +105,6 @@ export const createSemanticReturnedReferences = (
}[];
}
>();
const objectTargets = new WeakMap<t.ObjectExpression, ObjectTargetIndex>();
return (callee, invocation) => {
const existing = results.get(callee);
if (
@@ -141,7 +130,7 @@ export const createSemanticReturnedReferences = (
// shared diamonds and reassignment cycles converge to the graph fixed point.
const projectionDepths = new WeakMap<
t.Node,
Map<string, number | undefined>
Map<string, { depth: number | undefined }>
>();
const expanded = new WeakMap<t.Node, Set<string>>();
let additionalContexts = 0;
@@ -190,6 +179,7 @@ export const createSemanticReturnedReferences = (
readAt?: t.Node,
capturedSelections?: readonly SemanticCapturedSelection[],
invocationContext?: JavaScriptSourceInvocationContext,
classKind?: Candidate["classKind"],
): void => {
const source = unwrapJavaScriptExpression(node).node;
if (contextsWidened) {
@@ -213,20 +203,25 @@ export const createSemanticReturnedReferences = (
readAt?.start ?? -1,
readAt?.end ?? -1,
invocationKey(),
classKind,
]);
const depths = projectionDepths.get(source) ?? new Map();
projectionDepths.set(source, depths);
if (!depths.has(readKey)) {
depths.set(readKey, path?.length);
} else {
const previous = depths.get(readKey);
if (previous === undefined || previous !== path?.length) {
if (previous !== undefined) result.precision = "widened";
depths.set(readKey, undefined);
path = undefined;
}
let context = depths.get(readKey);
if (context === undefined) {
context = { depth: path?.length };
depths.set(readKey, context);
} else if (
context.depth === undefined ||
context.depth !== path?.length
) {
if (context.depth !== undefined) result.precision = "widened";
context.depth = undefined;
path = undefined;
}
if (path !== undefined) path = selections.normalize(source, path);
// Selector joins share the transition's read/invocation/receiver context.
// Joining only by syntax would cross static and instance member names.
if (path !== undefined) path = selections.normalize(context, path);
capturedSelections =
path === undefined
? undefined
@@ -279,9 +274,10 @@ export const createSemanticReturnedReferences = (
readAt?.start ?? -1,
readAt?.end ?? -1,
null,
classKind,
]);
if (sourceGenericInvocation) result.unresolvedInvocation = true;
depths.set(readKey, undefined);
depths.set(readKey, { depth: undefined });
key = expansionKey();
if (paths.has(key)) return;
}
@@ -289,6 +285,7 @@ export const createSemanticReturnedReferences = (
expanded.set(source, paths);
work.push({
node: source,
...(classKind === undefined ? {} : { classKind }),
path,
...(readAt === undefined ? {} : { readAt }),
...(capturedSelections === undefined ? {} : { capturedSelections }),
@@ -382,6 +379,7 @@ export const createSemanticReturnedReferences = (
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
}
continue;
@@ -505,9 +503,38 @@ export const createSemanticReturnedReferences = (
result.unresolvedInvocation = true;
}
}
} else if (t.isObjectExpression(node))
selectObjectTargets(node, current, pending, objectTargets);
else if (t.isArrayExpression(node)) {
} else if (t.isObjectExpression(node)) {
const [key, ...suffix] = current.path ?? [];
for (const choice of key === undefined
? [undefined]
: semanticAliasSelectionChoices(key))
for (const source of semanticObjectPropertyReferences(
node,
choice === undefined ? [] : [choice],
state,
)) {
if (source.widenedGetterResult) result.precision = "widened";
if (
source.inherited &&
current.capturedSelections?.some(
(capture) => capture.depth === 1 && capture.ownEnumerable,
)
)
continue;
pending.push({
...current,
node: source.node,
path:
current.path === undefined || source.widenedGetterResult
? undefined
: [...source.path, ...suffix],
capturedSelections: semanticCapturedSelectionsAfterPropertyRead(
current.capturedSelections,
source,
),
});
}
} else if (t.isArrayExpression(node)) {
const [key, ...path] = current.path ?? [];
if (current.path !== undefined && key !== undefined) {
for (const choice of semanticAliasSelectionChoices(key))
@@ -552,6 +579,7 @@ export const createSemanticReturnedReferences = (
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
}
continue;
@@ -590,63 +618,83 @@ export const createSemanticReturnedReferences = (
});
});
} else if (t.isNewExpression(node))
pending.push({ ...current, node: node.callee });
pending.push({ ...current, node: node.callee, classKind: "instance" });
else if (t.isClass(node)) {
const [key, ...path] = current.path ?? [];
if (current.path !== undefined && key !== undefined) {
for (const choice of semanticAliasSelectionChoices(key)) {
const prototype =
current.classKind !== "instance" && choice === "prototype";
if (prototype && path.length === 0) continue;
const ownEnumerable = current.capturedSelections?.some(
(capture) =>
capture.depth === (prototype ? 2 : 1) && capture.ownEnumerable,
);
const member = prototype ? path[0] : choice;
if (member === undefined) continue;
for (const selected of semanticAliasSelectionChoices(member))
for (const source of semanticClassPropertyReferences(
{
owner: node,
kind: prototype
? "prototype"
: (current.classKind ?? "static"),
...(ownEnumerable ? { fieldsOnly: true as const } : {}),
},
[selected],
state,
)) {
if (source.inherited && ownEnumerable) continue;
if (source.widenedGetterResult) result.precision = "widened";
pending.push({
...current,
node: source.node,
path: source.widenedGetterResult
? undefined
: [...source.path, ...(prototype ? path.slice(1) : path)],
classKind: "static",
capturedSelections:
semanticCapturedSelectionsAfterPropertyRead(
prototype
? semanticShiftCapturedSelections(
current.capturedSelections,
-1,
)
: current.capturedSelections,
source,
),
});
}
}
for (const child of pending)
enqueue(
child.node,
child.path,
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
continue;
}
// Unknown projections retain every possible callable descendant. At
// zero depth only construction contributes a source-local result.
for (const member of node.body.body) {
if (t.isClassMethod(member)) {
if (
((current.path === undefined || current.path.length === 0) &&
member.kind === "constructor") ||
current.path === undefined ||
(key !== undefined &&
semanticAliasSelectionChoices(key).some((choice) => {
const name = semanticStaticPropertyKey(
member.key,
member.computed,
);
return (
name === null ||
semanticPropertyPathKeyMatches(choice, name)
);
}))
)
pending.push({
...current,
node: member,
path: current.path === undefined ? undefined : path,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
-1,
),
});
} else if (
t.isClassProperty(member) &&
member.value !== null &&
member.value !== undefined &&
(key !== undefined || current.path === undefined) &&
(current.path === undefined ||
(key !== undefined &&
semanticAliasSelectionChoices(key).some((choice) => {
const name = semanticStaticPropertyKey(
member.key,
member.computed,
);
return (
name === null ||
semanticPropertyPathKeyMatches(choice, name)
);
})))
if (
t.isClassMethod(member) &&
(current.path === undefined || member.kind === "constructor")
)
pending.push({
...current,
node: member.value,
path: current.path === undefined ? undefined : path,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
-1,
),
node: member,
path: current.path === undefined ? undefined : [],
});
else if (
current.path === undefined &&
t.isClassProperty(member) &&
member.value != null
)
pending.push({ ...current, node: member.value, path: undefined });
}
if (node.superClass !== null && node.superClass !== undefined)
pending.push({ ...current, node: node.superClass });
@@ -660,6 +708,7 @@ export const createSemanticReturnedReferences = (
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
}
}
@@ -699,139 +748,3 @@ export const createSemanticReturnedReferences = (
return result;
};
};
const selectObjectTargets = (
node: t.ObjectExpression,
item: Candidate,
pending: Candidate[],
indexes: WeakMap<t.ObjectExpression, ObjectTargetIndex>,
): void => {
const [key, ...path] = item.path ?? [];
const childPath = item.path === undefined ? undefined : path;
if (key !== null && typeof key === "object" && "alternatives" in key) {
for (const choice of semanticAliasSelectionChoices(key))
selectObjectTargets(
node,
{ ...item, path: [choice, ...path] },
pending,
indexes,
);
return;
}
if (key !== null && key !== undefined && typeof key !== "object") {
const index = objectTargetIndex(node, indexes);
const property = index.propertyByName.get(String(key));
if (property !== undefined) {
const unknowns: ObjectTargetUnknown[] = [];
for (
let unknown = index.trailingUnknownsByProperty.get(property);
unknown !== undefined;
unknown = unknown.previous
)
unknowns.push(unknown);
for (const { node: unknown } of unknowns.reverse())
pending.push({
...item,
node: t.isSpreadElement(unknown)
? unknown.argument
: t.isObjectProperty(unknown)
? unknown.value
: unknown,
...(t.isSpreadElement(unknown) ? {} : { path: childPath }),
capturedSelections: t.isSpreadElement(unknown)
? [
...(item.capturedSelections ?? []),
{ at: unknown.argument, depth: 1, ownEnumerable: true },
]
: (semanticShiftCapturedSelections(item.capturedSelections, -1) ??
[]),
});
pending.push({
...item,
node: t.isObjectProperty(property) ? property.value : property,
path: childPath,
capturedSelections:
semanticShiftCapturedSelections(item.capturedSelections, -1) ?? [],
});
return;
}
}
let replaced = false;
for (const property of [...node.properties].reverse()) {
if (replaced) break;
if (t.isSpreadElement(property)) {
pending.push({
...item,
node: property.argument,
capturedSelections: [
...(item.capturedSelections ?? []),
{ at: property.argument, depth: 1, ownEnumerable: true },
],
});
continue;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
if (
key !== null &&
key !== undefined &&
name !== null &&
!semanticPropertyPathKeyMatches(key, name)
)
continue;
if (t.isObjectProperty(property))
pending.push({
...item,
node: property.value,
path: childPath,
capturedSelections:
semanticShiftCapturedSelections(item.capturedSelections, -1) ?? [],
});
else
pending.push({
...item,
node: property,
path: childPath,
capturedSelections:
semanticShiftCapturedSelections(item.capturedSelections, -1) ?? [],
});
if (
key !== null &&
key !== undefined &&
typeof key !== "object" &&
name === String(key)
)
replaced = true;
}
};
const objectTargetIndex = (
node: t.ObjectExpression,
indexes: WeakMap<t.ObjectExpression, ObjectTargetIndex>,
): ObjectTargetIndex => {
const existing = indexes.get(node);
if (existing !== undefined) return existing;
const propertyByName = new Map<string, t.ObjectMethod | t.ObjectProperty>();
const trailingUnknownsByProperty = new Map<
t.ObjectMethod | t.ObjectProperty,
ObjectTargetUnknown
>();
let trailingUnknown: ObjectTargetUnknown | undefined;
for (const property of [...node.properties].reverse()) {
if (t.isSpreadElement(property)) {
trailingUnknown = { node: property, previous: trailingUnknown };
continue;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
if (name === null) {
trailingUnknown = { node: property, previous: trailingUnknown };
continue;
}
if (propertyByName.has(name)) continue;
propertyByName.set(name, property);
if (trailingUnknown !== undefined)
trailingUnknownsByProperty.set(property, trailingUnknown);
}
const created = { propertyByName, trailingUnknownsByProperty };
indexes.set(node, created);
return created;
};
@@ -155,3 +155,31 @@ it.each([
}
},
);
it("discloses getter-result widening when selecting returned callable targets", () => {
const parsed = parseJavaScriptSource(
"const box={get item(){return {make:()=>result}}};box.item.make();",
);
if (parsed === null) throw new Error("Expected getter fixture");
const steps = indexJavaScriptBindingsSteps(parsed.program, {
getter_expansions: 1,
});
let step = steps.next();
while (!step.done) step = steps.next();
let call: t.CallExpression | undefined;
t.traverseFast(parsed.program, (node) => {
if (t.isCallExpression(node)) call = node;
});
if (call === undefined)
throw new Error("Expected getter-produced callable invocation");
const result = createSemanticReturnedReferences(step.value)(
call.callee,
call,
);
expect(result.precision).toBe("widened");
expect(result.references).toContainEqual({
node: expect.objectContaining({ type: "Identifier", name: "result" }),
projection: [],
});
expect(step.value.returnedReferenceWidenings.has(call.callee)).toBe(true);
});
@@ -81,3 +81,29 @@ it.each([
);
},
);
it.each([
["const box={...{__proto__:{make:()=>module}}};box.make()", false],
["const box={...{get make(){return ()=>module}}};box.make()", true],
["class C{static make(){return module}};const box={...C};box.make()", false],
["class C{make(){return module}};const box={...new C()};box.make()", false],
[
"class Base{static make=()=>module};class C extends Base{};const box={...C};box.make()",
false,
],
["class C{static make=()=>module};const box={...C};box.make()", true],
["class C{make=()=>module};const box={...new C()};box.make()", true],
] as const)(
"copies only own enumerable callable values: %s",
(expression, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${expression}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -201,3 +201,82 @@ it("refreshes cached returned targets after the shared member index settles", ()
}),
]);
});
it.each([
["const box={set make(value){return module}};box.make()", false],
["const box={get make(){return module}};box.make()", false],
["const box={get make(){return ()=>module}};box.make()", true],
[
"const box={get make(){return ()=>module},set make(value){}};box.make()",
true,
],
["const box={get make(){return ()=>module},make:()=>({})};box.make()", false],
["const box={__proto__:{make:()=>module}};box.make()", true],
["const box={__proto__:{make:()=>module},make:()=>({})};box.make()", false],
] as const)(
"uses property-read ownership before invoking a returned callable: %s",
(expression, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${expression}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each([
["class C{make(){return module}};C.make()", false],
["class C{static make(){return module}};new C().make()", false],
["class C{static make(){return module}};C.make()", true],
["class C{make(){return module}};new C().make()", true],
["class C{make(){return module}};C.prototype.make()", true],
["class C{get make(){return ()=>module}};new C().make()", true],
["class C{set make(value){return module}};new C().make()", false],
[
"class Base{static make(){return module}};class C extends Base{};C.make()",
true,
],
[
"class Base{make(){return module}};class C extends Base{make=()=>({})};new C().make()",
false,
],
] as const)(
"preserves the class property receiver before invoking its value: %s",
(expression, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${expression}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each([
["C.first:new C().second", false],
["C.second:new C().first", true],
] as const)(
"keeps static and instance selector alternatives correlated: %s",
(selection, expected) => {
const analysis = analyzeJavaScriptStaticSource(`
class C {
static first(){return {}} first(){return module}
static second(){return module} second(){return {}}
}
const selected=flag?${selection};selected().require('./shared.cjs');
`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -535,7 +535,7 @@ const semanticWithPrototypeKind = (
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
t.isIdentifier(property.key, { name: "__proto__" })
semanticStaticPropertyKey(property.key, false) === "__proto__"
)
return t.isNullLiteral(unwrapJavaScriptExpression(property.value).node)
? "null"
@@ -3,7 +3,58 @@ import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { topLevelBinding } from "./javascriptSemanticAnalysis.fixture.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
import {
analyzeCommonJsStaticFixture,
analyzeCommonJsSemanticFixture,
} from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
it.each([
"Object.assign(module,{require(){return {}}});",
"Object.assign(module,{...replacement});",
"Object.defineProperties(module,{[key]:{value:()=>({})}});",
'Reflect.set(module,"require",()=>({}));',
'Reflect.set({},"require",()=>({}),module);',
"({x:module.require}={x:replacement});",
"[module.require]=[replacement];",
"for(module.require of [replacement]){}",
])("retains uncertainty after a possible delegate replacement: %s", (write) => {
const analysis = analyzeCommonJsStaticFixture(`${write}require("electron");`);
expect(analysis.references).not.toContainEqual(
expect.objectContaining({ kind: "require", specifier: "electron" }),
);
expect(analysis.references).toContainEqual(
expect.objectContaining({ kind: "require", specifier: null }),
);
});
it.each([
"Object.assign(module,{other(){}});",
"Object.defineProperties(module,{other:{value:()=>({})}});",
"Object.assign({},{require(){}});",
'Reflect.set(module,"other",()=>({}));',
'Reflect.set(module,"require",()=>({}),{});',
"({x:module.other}={x:replacement});",
"[module.other]=[replacement];",
"for(module.other of [replacement]){}",
])("preserves the host loader after an unrelated write: %s", (write) => {
const analysis = analyzeCommonJsStaticFixture(`${write}require("electron");`);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: "electron" }),
]);
});
it.each([
"({x:module.require}={x:replacement});",
"[module.require]=[replacement];",
"for(module.require of [replacement]){}",
])("preserves a builtin captured before its delegate write: %s", (write) => {
const analysis = analyzeCommonJsSemanticFixture(
`const fs=require('node:fs');${write}fs.readFileSync('kept');`,
);
expect(analysis.configurationOperations.map(({ key }) => key)).toEqual([
"kept",
]);
});
it.each([
'var require;require("./before.cjs");',
@@ -76,3 +76,44 @@ it("recognizes a bundler runtime through nested member names", () => {
modules: [expect.objectContaining({ exports: ["ok"] })],
});
});
it.each(["1 as const", "1 satisfies number"])(
"keeps numeric bundle identities through erased syntax: %s",
(entry) => {
const analysis = analyzeJavaScriptStaticSource(`
globalThis.webpackChunkApp.push([[(${entry})], {
1: function(module, exports, req) { req((2 as const)); req.e((3 satisfies number)); },
2: function() {}
}, function(req) { req((${entry})); }]);
`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.bundler_registrations).toMatchObject([
{
chunk_keys: ["1"],
unknown_chunk_keys: 0,
entry_module_keys: ["1"],
unknown_entry_module_keys: 0,
async_chunk_keys: ["3"],
unknown_async_chunk_keys: 0,
},
]);
expect(analysis.references).toContainEqual(
expect.objectContaining({
specifier: "2",
loader: "factory-loader",
module_key: "1",
}),
);
},
);
it.each(["0, {}", "[], modules"])(
"does not grant factory-loader authority to a rejected registration: %s",
(entry) => {
const analysis = analyzeJavaScriptStaticSource(`
globalThis.webpackChunkApp.push([${entry}, function(req) { req("electron"); }]);
`);
expect(analysis.bundler_registrations).toEqual([]);
expect(analysis.references).toEqual([]);
},
);
@@ -140,7 +140,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
accumulator,
vitePreloadDependencyTable: indexVitePreloadDependencyTable(
file.program,
bindings.erasedSyntaxNodes,
bindings,
),
openReceiverFacts: receivers.openReceivers,
electronBindings: electronIdentity.bindings,
@@ -48,12 +48,12 @@ export const inspectBundlerRegistration = (
if (runtime === undefined || !t.isArrayExpression(entry)) return;
const chunkIds = entry.elements[0];
const table = entry.elements[1];
if (!t.isArrayExpression(chunkIds) || !t.isObjectExpression(table)) return;
const runtimeValue = runtimeMetadata(
entry.elements[2],
bindings,
accumulator,
);
if (!t.isArrayExpression(chunkIds) || !t.isObjectExpression(table)) return;
const recovered = recoverBundlerModules(source, table, accumulator, {
runtime,
bindings,
@@ -43,6 +43,7 @@ import type {
import { VITE_OUTPUT_DEPENDENCY_PREFIX } from "./javascriptStaticAnalysisTypes.js";
import {
resolveSemanticBindingState,
semanticBindingLookupAt,
semanticStableInitializer,
type JavaScriptBindingIndex,
type JavaScriptSemanticAnalysisState,
@@ -411,7 +412,7 @@ const chunkLoadSpecifier = (
const vitePreloadDependencySpecifiers = (
node: JavaScriptCallLike,
name: string,
table: readonly (string | undefined)[] | null,
table: FindingContext["vitePreloadDependencyTable"],
bindings: JavaScriptSemanticAnalysisState,
): readonly (string | undefined)[] => {
if (
@@ -420,10 +421,18 @@ const vitePreloadDependencySpecifiers = (
!t.isArrayExpression(node.arguments[0])
)
return [];
const callee = unwrapJavaScriptExpression(node.callee).node;
const lookup = semanticBindingLookupAt(bindings, callee, name);
if (
(lookup.kind !== "binding" && lookup.kind !== "uncertain-binding") ||
semanticStableInitializer(lookup.binding, bindings)?.node !== table.helper
)
return [];
const indexes = node.arguments[0].elements;
// Explicit cache overrides change which table the helper reads. Retain the
// call slots as unknown unless every override is statically undefined.
if (
lookup.kind !== "binding" ||
!node.arguments
.slice(1)
.every((argument) => isExplicitDefaultArgument(argument, bindings))
@@ -433,7 +442,7 @@ const vitePreloadDependencySpecifiers = (
if (element === null || !t.isNumericLiteral(element)) return undefined;
const index = element.value;
return Number.isSafeInteger(index) && index >= 0
? viteDependencySpecifier(table[index])
? viteDependencySpecifier(table.dependencies[index])
: undefined;
});
};
@@ -473,8 +482,9 @@ type ViteMapDepsFunction =
/** Index the canonical Vite helper's cache initializer from parsed syntax. */
export const indexVitePreloadDependencyTable = (
program: t.Program,
erasedSyntaxNodes: WeakSet<t.Node>,
): readonly (string | undefined)[] | null => {
bindings: JavaScriptSemanticAnalysisState,
): FindingContext["vitePreloadDependencyTable"] => {
const { erasedSyntaxNodes } = bindings;
const helpers: ViteMapDepsFunction[] = [];
traverseJavaScriptValueAst(program, erasedSyntaxNodes, {
enter: (node) => {
@@ -497,7 +507,7 @@ export const indexVitePreloadDependencyTable = (
const mapIdentifier = t.isAssignmentPattern(mapParameter)
? mapParameter.left
: mapParameter;
if (!t.isIdentifier(mapIdentifier)) return [];
if (!t.isIdentifier(mapIdentifier)) return { helper, dependencies: [] };
const tables: t.ArrayExpression[] = [];
traverseJavaScriptValueAst(helper, erasedSyntaxNodes, {
skip: (node) => t.isFunction(node),
@@ -515,9 +525,9 @@ export const indexVitePreloadDependencyTable = (
tables.push(node.right);
},
});
if (tables.length !== 1) return [];
if (tables.length !== 1) return { helper, dependencies: [] };
const table = tables[0];
if (table === undefined) return [];
if (table === undefined) return { helper, dependencies: [] };
// A spread contributes an unknown number of entries, so every later slot
// has an unknown index; leave those out instead of shifting them.
const spread = table.elements.findIndex((element) =>
@@ -540,6 +550,14 @@ export const indexVitePreloadDependencyTable = (
semanticIdentifierRole(node, parent, readAncestors) === null
)
return;
const lookup = semanticBindingLookupAt(bindings, node, node.name);
if (
lookup.kind === "ambient" ||
lookup.kind === "captured" ||
(lookup.kind === "binding" &&
semanticStableInitializer(lookup.binding, bindings)?.node !== helper)
)
return;
if (
(t.isCallExpression(parent) || t.isOptionalCallExpression(parent)) &&
parent.callee === node
@@ -548,7 +566,12 @@ export const indexVitePreloadDependencyTable = (
escapedOrMutated = true;
},
});
return escapedOrMutated ? dependencies.map(() => undefined) : dependencies;
return {
helper,
dependencies: escapedOrMutated
? dependencies.map(() => undefined)
: dependencies,
};
};
const requestConstructorUrl = (
@@ -769,8 +769,11 @@ export const stringValue = (
/** Return a string or numeric argument literal. */
export const argumentValue = (
node: t.Node | null | undefined,
): string | undefined =>
t.isNumericLiteral(node) ? String(node.value) : stringValue(node);
): string | undefined => {
if (node === null || node === undefined) return undefined;
const value = unwrapJavaScriptExpression(node).node;
return t.isNumericLiteral(value) ? String(value.value) : stringValue(value);
};
/** Prefix a static argument value when one is available. */
export const prefixedArgument = (
@@ -133,7 +133,10 @@ export interface JavaScriptFindingContext {
readonly expressionShapes: JavaScriptExpressionShapeResolver;
readonly source: string;
readonly accumulator: JavaScriptAnalysisAccumulator;
readonly vitePreloadDependencyTable: readonly (string | undefined)[] | null;
readonly vitePreloadDependencyTable: {
readonly helper: t.Node;
readonly dependencies: readonly (string | undefined)[];
} | null;
readonly openReceiverFacts?: ReadonlyMap<number, JavaScriptOpenReceiverFact>;
/** Proven Electron export path segments by root offset. */
readonly electronBindings?: ReadonlyMap<number, readonly string[]>;
@@ -1,6 +1,7 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
const windowPrefix =
"import {BrowserWindow} from 'electron'; import {fileURLToPath} from 'node:url';";
@@ -152,3 +153,52 @@ it("keeps a reused renderer receiver uncertain after its native method call", ()
"independent.html",
]);
});
it.each([
["untouched exports", "", 1],
[
"object-pattern exports write",
"({value: module.exports} = replacement);",
0,
],
["array-pattern exports write", "[module.exports] = replacement;", 0],
["loop-target exports write", "for (module.exports of replacement) {}", 0],
] as const)(
"requires intact bootstrap exports after %s",
(_name, write, expected) => {
const source = `(() => {
const table = {
external(module, exports, load) { module.exports = require('electron'); },
entry(module, exports, load) { new (load('external').BrowserWindow)(); }
};
function load(key) {
const module = {exports: {}};
table[key](module, module.exports, load);
${write}
return module.exports;
}
load('entry');
})();`;
const observed: string[] = [];
const windowClass = (identity: string) =>
class {
constructor() {
observed.push(identity);
}
};
const local = { BrowserWindow: windowClass("replacement") };
runInNewContext(source, {
replacement: Object.assign([local], { value: local }),
require: (specifier: string) => {
expect(specifier).toBe("electron");
return { BrowserWindow: windowClass("electron") };
},
});
expect(observed).toEqual([expected === 1 ? "electron" : "replacement"]);
expect(
analyzeCommonJsStaticFixture(source).electron.browser_windows,
).toHaveLength(expected);
},
);
import { runInNewContext } from "node:vm";
@@ -7,6 +7,7 @@ import {
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import { JavaScriptSymbolicBindings } from "./javascriptSymbolicBindings.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import {
symbolicTruth,
symbolicVariants,
@@ -1076,3 +1077,94 @@ it("retains each external selection guard once across deep member reads", () =>
});
expect(resolver.truncated).toBe(false);
});
it.each([
[
"module property after replacement",
'module.exports={api:null};module.exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"module property overwritten after replacement",
'module.exports={api:require("electron")};module.exports.api=null;',
{ kind: "literal", value: null },
],
[
"detached exports property",
'module.exports={api:null};exports.api=require("electron");',
{ kind: "literal", value: null },
],
[
"rebound exports parameter",
'module.exports={api:null};exports={api:require("electron")};',
{ kind: "literal", value: null },
],
[
"identity-preserving module write",
'module.exports=module.exports;exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"identity-preserving original exports write",
'module.exports=exports;exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"reconnected exports parameter",
'module.exports={api:null};exports=module.exports;exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"rebound module parameter",
'module={exports:{}};module.exports.api=require("electron");',
{ kind: "unknown", reason: "ambiguous export writes" },
],
] as const)("retains namespace write order for %s", (_name, body, expected) => {
const value = inspectOne(`(() => {
const modules={
1(module,exports){${body}},
2(module,exports,load){inspect(load(1).api);}
};
function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;}
load(2);
})();`);
expect(value).toEqual(expected);
});
it("lists members added to the public module object after its replacement", () => {
const { resolver, values } = inspectFixture(`(() => {
const modules={
1(module,exports){module.exports={first:1};module.exports.later=2;exports.detached=3;},
2(module,exports,load){inspect(load(1));}
};
function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;}
load(2);
})();`);
expect(values).toHaveLength(1);
expect(resolver.members(values[0]!)).toEqual(["first", "later"]);
});
it("derives bridge paths from the latest public module member writes", () => {
const result = analyzeJavaScriptStaticSource(`(() => {
const modules={
1(module,exports){
const e=require("electron");
module.exports={action:()=>e.ipcRenderer.send("stale")};
module.exports.action=()=>e.ipcRenderer.send("current");
exports.detached=()=>e.ipcRenderer.send("detached");
},
2(module,exports,load){
const api=load(1);
require("electron").contextBridge.exposeInMainWorld("desktop",api);
}
};
function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;}
load(2);
})();`);
expect(
result.electron.call_paths?.map(({ entry, channel }) => ({
entry,
channel,
})),
).toEqual([{ entry: "desktop.action", channel: "current" }]);
});
@@ -52,6 +52,9 @@ interface PropertyWrite {
readonly path: readonly string[];
readonly value: t.Node;
readonly site: t.Node;
readonly exportReceiver?: "module" | "exports";
readonly exportValueReceiver?: "module" | "exports";
readonly receiverUncertain?: true;
}
interface SymbolicReturnFlow extends SymbolicMetadata {
readonly values: readonly SymbolicValue[];
@@ -198,6 +201,12 @@ export class JavaScriptSymbolicBindings {
}
},
});
for (const writes of this.#writes.values())
writes.sort(
(left, right) =>
(left.site.end ?? Infinity) - (right.site.end ?? Infinity) ||
(right.site.start ?? 0) - (left.site.start ?? 0),
);
for (const table of this.#bootstrapTables)
this.#invalidateBootstrapTable(table.tableAliases, table.modules);
}
@@ -396,19 +405,19 @@ export class JavaScriptSymbolicBindings {
this.unresolved = true;
return symbolicUnknown("module export read boundary is unavailable");
}
const replacement = available.findLast(
(write) => write.path.length === 0,
const write = available.findLast(
(write) =>
write.path.length === 0 ||
(write.path.length === 1 && write.path[0] === key),
);
if (replacement)
if (write?.receiverUncertain)
return symbolicUnknown("uncertain export receiver");
if (write?.path.length === 0)
return this.property(
this.evaluate(replacement.value, value.environment),
this.evaluate(write.value, value.environment),
key,
read,
);
const writes = available.filter(
(w) => w.path.length === 1 && w.path[0] === key,
);
const write = writes.at(-1);
return write
? this.evaluate(write.value, value.environment)
: symbolicUnknown("unresolved module export");
@@ -509,19 +518,23 @@ export class JavaScriptSymbolicBindings {
if (value.kind === "namespace") {
const available = this.#namespaceWrites(value);
if (available === undefined) return [];
const replacement = available.findLast(
const replacementIndex = available.findLastIndex(
(write) => write.path.length === 0,
);
return replacement
? this.members(
this.evaluate(replacement.value, value.environment),
active,
)
: [
...new Set(
available.flatMap((w) => (w.path.length === 1 ? w.path : [])),
),
];
const replacement = available[replacementIndex];
return [
...new Set([
...(replacement === undefined
? []
: this.members(
this.evaluate(replacement.value, value.environment),
active,
)),
...available
.slice(replacementIndex + 1)
.flatMap((write) => (write.path.length === 1 ? write.path : [])),
]),
];
}
if (value.kind !== "object") return [];
return [
@@ -563,6 +576,7 @@ export class JavaScriptSymbolicBindings {
if (this.#mutated.has(value.bindingId)) return 1;
const available = this.#namespaceWrites(value);
if (available === undefined) return 1;
if (available.some((write) => write.receiverUncertain)) return 1;
const replacement = available.findLast(
(write) => write.path.length === 0,
);
@@ -1043,7 +1057,9 @@ export class JavaScriptSymbolicBindings {
if (boundary !== undefined && sourcePoint == null) return undefined;
if (completedAt !== undefined && completedAt.end == null) return undefined;
if (sourcePoint == null && completedAt === undefined)
return read === undefined ? writes : undefined;
return read === undefined
? this.#publicNamespaceWrites(writes)
: undefined;
// An owned snapshot cannot read beyond either its capture or this exit.
const point = Math.min(
sourcePoint ?? Infinity,
@@ -1069,17 +1085,84 @@ export class JavaScriptSymbolicBindings {
}
}
}
return writes.filter(
(write) =>
write.site.end != null &&
write.site.end <= point &&
(changed.size === 0 ||
(write.path.length > 0
? !changed.has(write.path[0]!)
: selectedKey !== undefined && !changed.has(selectedKey))),
return this.#publicNamespaceWrites(
writes.filter(
(write) =>
write.site.end != null &&
write.site.end <= point &&
(changed.size === 0 ||
(write.path.length > 0
? !changed.has(write.path[0]!)
: selectedKey !== undefined && !changed.has(selectedKey))),
),
);
}
/** Keep publication separate from writes through a detached or rebound alias. */
#publicNamespaceWrites(
writes: readonly PropertyWrite[],
): readonly PropertyWrite[] {
let relation: "connected" | "detached" | "uncertain" = "connected";
const published: PropertyWrite[] = [];
for (const write of writes) {
if (write.path.length === 0 && write.exportReceiver === "exports") {
if (write.exportValueReceiver === "module") relation = "connected";
else if (write.exportValueReceiver !== "exports")
relation = "uncertain";
continue;
}
if (write.path.length === 0 && write.exportReceiver === "module") {
if (
write.exportValueReceiver === "module" ||
(write.exportValueReceiver === "exports" && relation === "connected")
)
continue;
relation =
write.exportValueReceiver === "exports"
? "connected"
: relation === "uncertain"
? "uncertain"
: "detached";
}
if (write.exportReceiver === "exports") {
if (relation === "detached") continue;
if (relation === "uncertain") {
published.push({ ...write, receiverUncertain: true });
continue;
}
}
published.push(write);
}
return published;
}
#exportValueReceiver(
input: t.Node,
alias: string,
): "module" | "exports" | undefined {
const expression = unwrapJavaScriptExpression(input).node;
const node = t.isAssignmentExpression(expression, { operator: "=" })
? expression.left
: expression;
const root = t.isMemberExpression(node) ? node.object : node;
if (!t.isIdentifier(root)) return undefined;
const binding = resolveSemanticBindingState(this.state, root, root.name);
if (
binding === undefined ||
this.#exportAliases.get(binding.bindingId) !== alias
)
return undefined;
if (t.isIdentifier(node))
return this.#moduleBindings.has(binding.bindingId)
? undefined
: "exports";
return t.isMemberExpression(node) &&
this.#moduleBindings.has(binding.bindingId) &&
semanticStaticPropertyKey(node.property, node.computed) === "exports"
? "module"
: undefined;
}
#completeNamespace(
value: SymbolicNamespace,
point: t.Node,
@@ -1548,6 +1631,14 @@ export class JavaScriptSymbolicBindings {
const binding = resolveSemanticBindingState(this.state, root, root.name);
if (!binding) return;
const alias = this.#exportAliases.get(binding.bindingId);
if (
alias &&
this.#moduleBindings.has(binding.bindingId) &&
path.length === 0
) {
this.#mutated.add(alias);
return;
}
if (this.#moduleBindings.has(binding.bindingId) && path[0] !== "exports")
return;
const key = alias ?? binding.bindingId;
@@ -1577,7 +1668,25 @@ export class JavaScriptSymbolicBindings {
}
}
const writes = this.#writes.get(key) ?? [];
writes.push({ path: selected, value, site });
const exportValueReceiver =
alias !== undefined &&
selected.length === 0 &&
t.isAssignmentExpression(value, { operator: "=" })
? this.#exportValueReceiver(value.right, alias)
: undefined;
writes.push({
path: selected,
value,
site,
...(alias === undefined
? {}
: {
exportReceiver: this.#moduleBindings.has(binding.bindingId)
? ("module" as const)
: ("exports" as const),
}),
...(exportValueReceiver === undefined ? {} : { exportValueReceiver }),
});
this.#writes.set(key, writes);
}
}
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import {
analyzeJavaScriptStaticSource,
analyzeParsedJavaScriptStaticSource,
} from "./javascriptStaticAnalysis.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
describe("Vite preload dependency recovery", () => {
it("decodes helper table values and preserves sparse and dynamic slots", () => {
@@ -143,3 +147,84 @@ describe("Vite preload dependency recovery", () => {
]);
});
});
it.each([
{
name: "canonical declaration",
prefix: "",
suffix: "__vite__mapDeps([0]);",
specifiers: ["./canonical.js"],
},
{
name: "captured outer binding",
prefix: "",
suffix: "function load(){return __vite__mapDeps([0]);}load();",
specifiers: ["./canonical.js"],
},
{
name: "shadowed parameter",
prefix: "",
suffix:
"function load(__vite__mapDeps){return __vite__mapDeps([0]);}load(()=>[]);",
specifiers: [],
},
{
name: "out-of-scope declaration",
prefix: "function setup(){",
suffix: "}__vite__mapDeps([0]);",
specifiers: [],
},
{
name: "unrelated shadowed cache mutation",
prefix: "",
suffix:
"function change(__vite__mapDeps){__vite__mapDeps.f=['./other.js'];}__vite__mapDeps([0]);",
specifiers: ["./canonical.js"],
},
{
name: "uncertain with receiver",
prefix: "",
suffix: "with(unknown){__vite__mapDeps([0]);}",
specifiers: [null],
},
{
name: "uncertain with mutation",
prefix: "",
suffix:
"with(unknown){__vite__mapDeps.f=['./other.js'];}__vite__mapDeps([0]);",
specifiers: [null],
},
])(
"uses the canonical helper's lexical identity for $name",
({ prefix, suffix, specifiers }) => {
const source = `${prefix}
const __vite__mapDeps = (indexes, map = __vite__mapDeps, dependencies =
(map.f || (map.f = ["./canonical.js"]))) => indexes.map((index) => dependencies[index]);
${suffix}
`;
const parsed = parseJavaScriptSource(source, "preload.cjs");
expect(parsed).not.toBeNull();
if (parsed === null) throw new Error("preload fixture did not parse");
const analysis = analyzeParsedJavaScriptStaticSource(source, parsed);
expect(analysis.parse_error_count).toBe(0);
expect(
analysis.references
.filter(({ kind }) => kind === "dynamic-import")
.map(({ specifier }) => specifier),
).toEqual(specifiers);
},
);
it("retains the identity of a function-declared preload helper", () => {
const analysis = analyzeJavaScriptStaticSource(`
function __vite__mapDeps(indexes, map = __vite__mapDeps, dependencies =
(map.f || (map.f = ["./canonical.js"]))) { return indexes.map((index) => dependencies[index]); }
__vite__mapDeps([0]);
`);
expect(analysis.parse_error_count).toBe(0);
expect(
analysis.references
.filter(({ kind }) => kind === "dynamic-import")
.map(({ specifier }) => specifier),
).toEqual(["./canonical.js"]);
});
-36
View File
@@ -1,36 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { expect, it } from "vitest";
import { ANALYSIS_SETTINGS } from "./analysisSettingDefinitions.js";
import { NUMERIC_LIMIT_REGISTRY } from "./numericLimitRegistry.js";
const inventory = readFileSync(
join(import.meta.dirname, "../../docs/design/rea-limit-inventory.md"),
"utf8",
);
it("gives every numeric exception a reason and a matching inventory row", () => {
const names = NUMERIC_LIMIT_REGISTRY.map((entry) => entry.name);
expect(new Set(names).size).toBe(names.length);
const runtimeEnvironments = new Set<string>(
Object.values(ANALYSIS_SETTINGS).flatMap((definition) =>
"environment" in definition && definition.environment !== undefined
? [definition.environment]
: [],
),
);
for (const entry of NUMERIC_LIMIT_REGISTRY) {
expect(entry.reason.trim().length).toBeGreaterThanOrEqual(40);
expect(entry.read).toBe(
entry.policy === "per-call" ? "per-call" : "startup",
);
expect(runtimeEnvironments.has(entry.name)).toBe(false);
expect(inventory).toContain(`\`${entry.name}\``);
}
expect(
NUMERIC_LIMIT_REGISTRY.filter((entry) => entry.policy === "restart-only"),
).toHaveLength(15);
expect(
NUMERIC_LIMIT_REGISTRY.filter((entry) => entry.policy === "per-call"),
).toHaveLength(11);
});
-203
View File
@@ -1,203 +0,0 @@
/**
* Numeric `REA_` limits that are not runtime analysis settings.
*
* The static check in `scripts/verify/rea-numeric-limits.mjs` fails when a
* numeric `REA_` limit in `src` is absent from both this list and
* `ANALYSIS_SETTINGS`. `restart-only` is the durable exception. `per-call`
* names a limit still read from the launch environment on each use; the
* runtime migration deletes that entry instead of leaving it here.
*
* Narrative bounds, owners, and the non-numeric settings live in
* `docs/design/rea-limit-inventory.md`.
*/
export interface NumericLimitRegistryEntry {
readonly name: string;
readonly policy: "restart-only" | "per-call";
readonly read: "startup" | "per-call";
readonly reason: string;
}
export const NUMERIC_LIMIT_REGISTRY = [
{
name: "REA_BRIDGE_MAX_REQUEST_BYTES",
policy: "per-call",
read: "per-call",
reason:
"The Hopper, Ghidra, and Cutter clients measure each request against this ceiling. The value is still taken from the launch environment, so a running process does not see a new value until the runtime migration removes this entry.",
},
{
name: "REA_DEBUGGER_COMMAND_DEADLINE_MS",
policy: "per-call",
read: "per-call",
reason:
"GDB and Rizin apply this deadline to each debugger command. The session manager currently copies it at construction from the launch environment; the runtime migration reads the accepted operation snapshot instead.",
},
{
name: "REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES",
policy: "per-call",
read: "per-call",
reason:
"Each debugger command retains output up to this ceiling. The session manager currently copies it at construction from the launch environment; the runtime migration reads the accepted operation snapshot instead.",
},
{
name: "REA_DEBUGGER_MAX_PENDING_COMMANDS",
policy: "restart-only",
read: "startup",
reason:
"Caps the pending-command queue created with the debugger session manager. It is pool capacity for that manager, not a budget checked independently on each command.",
},
{
name: "REA_DEBUGGER_MAX_SESSIONS",
policy: "restart-only",
read: "startup",
reason:
"Caps how many debugger sessions the session manager created at startup will admit. Changing the pool size of a manager that already exists requires a new REA process.",
},
{
name: "REA_HOPPER_CALLER_SCAN_BUDGET_MS",
policy: "per-call",
read: "per-call",
reason:
"Forward caller reconciliation reads this budget for each scan. It is still resolved from the launch environment; the runtime migration reads the accepted operation snapshot.",
},
{
name: "REA_HOPPER_DEMO_DIALOG_TIMEOUT_MS",
policy: "restart-only",
read: "startup",
reason:
"The Linux private-display adapter reads this watch from the process environment when that helper starts. A running adapter does not observe a value stored only in the REA process.",
},
{
name: "REA_JADX_ACTIVE_PROCESSOR_COUNT",
policy: "restart-only",
read: "startup",
reason:
"Passed to the JVM as -XX:ActiveProcessorCount when the JADX worker process starts. A running JVM does not adopt a new processor count.",
},
{
name: "REA_JADX_HEAP_MIB",
policy: "restart-only",
read: "startup",
reason:
"Passed to the JVM as -Xmx when the JADX worker process starts. A running JVM does not adopt a new heap size.",
},
{
name: "REA_JEB_MCP_TIMEOUT_MS",
policy: "per-call",
read: "per-call",
reason:
"Bounds each JEB tools/call and catalog discovery. The connection currently captures the launch value when it is created; the runtime migration reads the accepted snapshot for each request.",
},
{
name: "REA_KUNA_CACHE_MAX_BYTES",
policy: "restart-only",
read: "startup",
reason:
"Sizes the Kuna project cache when that cache is constructed for the provider. An existing cache does not resize from a later value.",
},
{
name: "REA_KUNA_JOBS",
policy: "restart-only",
read: "startup",
reason:
"Selects the Kuna worker-pool width when the provider builds its run slots. The pool is not resized for a process that is already running.",
},
{
name: "REA_KUNA_PROJECT_TIMEOUT_MS",
policy: "per-call",
read: "per-call",
reason:
"Bounds a Kuna project export and the whole-program scans that use that deadline. The configuration object still comes from the launch environment; the runtime migration reads the accepted snapshot at each use.",
},
{
name: "REA_KUNA_TIMEOUT_MS",
policy: "per-call",
read: "per-call",
reason:
"Bounds one Kuna function decompile. The configuration object still comes from the launch environment; the runtime migration reads the accepted snapshot at each use.",
},
{
name: "REA_MCP_DOCTOR_DEADLINE_MS",
policy: "restart-only",
read: "startup",
reason:
"Bounds the MCP doctor phase while the server is starting. It shapes server startup, not a later analysis call.",
},
{
name: "REA_MCP_FIRST_CATALOG_BUDGET_MS",
policy: "restart-only",
read: "startup",
reason:
"Bounds the first MCP catalog phase while the server is starting. It shapes server startup, not a later analysis call.",
},
{
name: "REA_MCP_INITIALIZE_BUDGET_MS",
policy: "restart-only",
read: "startup",
reason:
"Bounds the MCP initialize phase while the server is starting. It shapes server startup, not a later analysis call.",
},
{
name: "REA_MCP_MAX_INPUT_ELEMENTS",
policy: "restart-only",
read: "startup",
reason:
"Sizes argument admission for the MCP server created at process start. A running server does not rebuild that parser for a new allowance.",
},
{
name: "REA_MCP_MAX_RESPONSE_BYTES",
policy: "restart-only",
read: "startup",
reason:
"Sizes the MCP stdio receive buffer when the server process starts. A running transport and the paired client buffer do not adopt a new ceiling.",
},
{
name: "REA_MCP_TOOLS_LIST_BUDGET_MS",
policy: "restart-only",
read: "startup",
reason:
"Bounds the MCP tools/list phase while the server is starting. It shapes server startup, not a later analysis call.",
},
{
name: "REA_PWNTOOLS_MAX_INPUT_BYTES",
policy: "per-call",
read: "per-call",
reason:
"Default artifact-size admission for a pwntools decode. A per-call producer_limits.input_bytes override already exists; the launch default is still read from the environment and the runtime migration moves that default into the snapshot.",
},
{
name: "REA_RECONNECT_TIMEOUT_SECONDS",
policy: "restart-only",
read: "startup",
reason:
"Fixed 30-second value written into the Hopper bridge helper from HOPPER_SHUTDOWN_TIMEOUT_MS. It is not a caller-selected launch setting.",
},
{
name: "REA_SWIFTC_COMPILE_TIMEOUT_MS",
policy: "restart-only",
read: "startup",
reason:
"Captured when the Darwin ownership-helper reader is constructed at process start. That helper compile uses the captured deadline, so a later value needs a new REA process.",
},
{
name: "REA_WINDOWS_CHANGE_TIME_DRIFT_MS",
policy: "per-call",
read: "per-call",
reason:
"Each Windows ChangeTime observation compares the file against this drift window. The observation still parses the launch environment; the runtime migration reads the accepted operation snapshot.",
},
{
name: "REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS",
policy: "per-call",
read: "per-call",
reason:
"Each Windows ChangeTime settle loop stops after this many polls. The observation still parses the launch environment; the runtime migration reads the accepted operation snapshot.",
},
{
name: "REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS",
policy: "per-call",
read: "per-call",
reason:
"Each Windows ChangeTime settle loop waits this long between reads. The observation still parses the launch environment; the runtime migration reads the accepted operation snapshot.",
},
] as const satisfies readonly NumericLimitRegistryEntry[];
-20
View File
@@ -1,4 +1,3 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import type { JsonValue } from "../domain/jsonValue.js";
@@ -1120,25 +1119,6 @@ describe("Ghidra native instruction decoder context for ARM TMode and MIPS ISA_M
});
});
// Kept on the file rather than in its own suite. The full source run collected
// that suite with no tests and failed the file before this assertion could run.
it("binds TMode to ARM and ISA_MODE to MIPS in the Ghidra bridge script", async () => {
const bridgePath = new URL(
"../../bridge/ghidra/ReaGhidraBridge.java",
import.meta.url,
);
const content = await readFile(bridgePath, "utf8");
expect(content).toContain(
'currentProgram.getLanguageID().getIdAsString().startsWith("ARM:")',
);
expect(content).toContain(
'currentProgram.getLanguageID().getIdAsString().startsWith("MIPS:")',
);
expect(content).toContain('"TMode"');
expect(content).toContain('"ISA_MODE"');
expect(content).toContain('"ghidra-program-context"');
});
describe("Ghidra dossier nested boundary composition", () => {
const identityLocations = [
"caller",
@@ -923,12 +923,27 @@ it("traces all endpoint carriers and their response consumers through CLI and MC
"response",
]);
const response = carriers.find(({ kind }) => kind === "response");
if (response === undefined) throw new Error("Expected matching response");
const request = carriers.find(({ kind }) => kind === "request");
if (response === undefined || request === undefined)
throw new Error("Expected matching request and response");
const consumer = graph.relations.find(
({ source_node_id, relation }) =>
source_node_id === response.node_id && relation === "consumed-by",
);
expect(consumer).toMatchObject({ resolution: "resolved" });
const requestLink = graph.relations.find(
({ source_node_id, target_node_id, relation }) =>
source_node_id === request.node_id &&
target_node_id === response.node_id &&
relation === "consumed-by",
);
expect(consumer).toMatchObject({
resolution: "resolved",
properties: { method: "json" },
});
expect(requestLink).toMatchObject({
resolution: "resolved",
properties: { method: "json" },
});
const client = await connectedMcpClient();
const trace = async (seed: JavaScriptSemanticQueryInput["seed"]) => {
const input = {
@@ -980,13 +995,13 @@ it("traces all endpoint carriers and their response consumers through CLI and MC
"request",
"response",
]);
expect(selected.relations).toEqual([consumer]);
expect(selected.relations).toEqual([requestLink, consumer]);
expect(selected).toMatchObject({
status: "ambiguous",
summary: {
total_seed_matches: 2,
traversed_nodes: 3,
traversed_relations: 1,
traversed_relations: 2,
},
coverage: { status: "partial" },
});
@@ -36,9 +36,7 @@ it("preserves full advertised schemas and handler meaning when presentation orde
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
REA_MCP_OUTPUT_SCHEMA_PROFILE: "full",
},
// The child advertises the full catalog and calls every browser, Electron,
// and JavaScript example. A busy runner exceeded 20s and was killed.
timeout: 60_000,
timeout: 20_000,
maxBuffer: 16 * 1024 * 1024,
});
const result = resultSchema.parse(JSON.parse(stdout));
-145
View File
@@ -1,145 +0,0 @@
import { expect, it } from "vitest";
import {
NUMERIC_LIMIT_DECLARATION,
discoverNumericLimitUses,
isNumericLimit,
requiredRuntimeLimitFailure,
} from "../../scripts/verify/rea-numeric-limits.mjs";
const ENDPOINT_CANDIDATES = "REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES";
it("recognizes the documented endpoint-candidate limit as numeric", () => {
expect(isNumericLimit(ENDPOINT_CANDIDATES)).toBe(true);
const discovered = discoverNumericLimitUses([
{
path: "src/domain/webBundleAnalysis.ts",
source: `const candidates = process.env.${ENDPOINT_CANDIDATES};`,
},
]);
expect(discovered.get(ENDPOINT_CANDIDATES)).toBe(
"src/domain/webBundleAnalysis.ts",
);
});
it("does not treat a registry declaration as a producer", () => {
const name = "REA_REMOVED_MAX_BYTES";
const discovered = discoverNumericLimitUses([
{
path: NUMERIC_LIMIT_DECLARATION,
source: `name: "${name}",`,
},
{
path: "src/config/removedLimit.ts",
source: `const value = process.env.${name};`,
},
]);
expect(discovered.get(name)).toBe("src/config/removedLimit.ts");
expect(
discoverNumericLimitUses([
{ path: NUMERIC_LIMIT_DECLARATION, source: `name: "${name}",` },
]).has(name),
).toBe(false);
});
it("keeps a helper-call argument that is not the first argument", () => {
const name = "REA_NEW_MAX_BYTES";
const discovered = discoverNumericLimitUses([
{
path: "src/config/newLimit.ts",
source: `const setting = "${name}";\nconst value = read(environment, setting);`,
},
{
path: "src/config/literalLimit.ts",
source: `const value = read(environment, "${name}");`,
},
]);
expect(discovered.get(name)).toBe("src/config/newLimit.ts");
});
it("does not treat prose that mentions a limit name as a producer", () => {
const name = "REA_NEW_MAX_BYTES";
const discovered = discoverNumericLimitUses([
{
path: "src/domain/native/nativeDeadlines.ts",
source: `const guidance = "input_bytes overrides ${name}; otherwise that launch default applies.";`,
},
]);
expect(discovered.has(name)).toBe(false);
});
it("keeps a destructured environment key, including a rename and a default", () => {
const name = "REA_NEW_MAX_BYTES";
const discovered = discoverNumericLimitUses([
{
path: "src/config/newLimit.ts",
source: `const { ${name}: renamed = 1 } = process.env;\nconst present = "${name}" in environment;\nconst owned = Object.hasOwn(environment, "${name}");\nconst source = { ${name}: renamed };\nconst copy = { ...source };`,
},
]);
expect(discovered.get(name)).toBe("src/config/newLimit.ts");
});
it("does not invent a limit from a spread of the environment object", () => {
const discovered = discoverNumericLimitUses([
{
path: "src/config/newLimit.ts",
source: "const copy = { ...process.env, ...environment };",
},
]);
expect(discovered.size).toBe(0);
});
it("keeps a bracket-access read as a producer", () => {
const name = "REA_NEW_MAX_BYTES";
const discovered = discoverNumericLimitUses([
{
path: "src/config/newLimit.ts",
source: `const selected = environment["${name}"] ?? environment?.[ "${name}" ];`,
},
]);
expect(discovered.get(name)).toBe("src/config/newLimit.ts");
});
it("does not treat a registration allowlist or a comment as a producer", () => {
const discovered = discoverNumericLimitUses([
{
path: NUMERIC_LIMIT_DECLARATION,
source: `name: "${ENDPOINT_CANDIDATES}",`,
},
{
path: "src/application/SetupRegistrationEnvironment.ts",
source: `const SELECTED_REGISTRATION_SETTINGS = ["${ENDPOINT_CANDIDATES}"] as const;`,
},
{
path: "src/config/removedLimit.ts",
source: `// ${ENDPOINT_CANDIDATES} used to be read here\nconst unrelated = 1;\n`,
},
]);
expect(discovered.has(ENDPOINT_CANDIDATES)).toBe(false);
});
it("requires the tracked endpoint-candidate limit to be an analysis setting", () => {
expect(
requiredRuntimeLimitFailure(ENDPOINT_CANDIDATES, {
inRuntime: false,
inRegistry: true,
}),
).toMatch(/ANALYSIS_SETTINGS/);
expect(
requiredRuntimeLimitFailure(ENDPOINT_CANDIDATES, {
inRuntime: true,
inRegistry: false,
}),
).toBeUndefined();
expect(
requiredRuntimeLimitFailure("REA_NTFS_MFT_MAX_FILE_BYTES", {
inRuntime: false,
inRegistry: false,
}),
).toMatch(/ANALYSIS_SETTINGS/);
expect(
requiredRuntimeLimitFailure("REA_REGF_HIVE_MAX_FILE_BYTES", {
inRuntime: true,
inRegistry: true,
}),
).toMatch(/numericLimitRegistry/);
});