Merge remote-tracking branch 'origin/main' into codex/pr-1899-remove-duplicate-schema-test
This commit is contained in:
commit
0b2aa69531
159 files changed
+8039
-1135
No files matched your search
@@ -9,7 +9,10 @@ provenance; inputs and recovered code are never executed. Recovery requires a
|
||||
caller-supplied Wakaru on Linux x64; see [the recovery guide](https://github.com/morluto/rea/blob/main/docs/javascript-recovery.md).
|
||||
|
||||
Use `analyze_javascript_application` directly on the operator-supplied ASAR or
|
||||
extracted tree. When size is unknown or large and the connected schema advertises
|
||||
extracted tree. Exact `#alias` specifiers resolve through the importer's
|
||||
enclosing package `imports` map to package-local files; nested `node_modules`
|
||||
packages use their own map. Wildcard, recursive, and external-package import
|
||||
targets stay unresolved with that limitation. When size is unknown or large and the connected schema advertises
|
||||
`detail`, request `detail: "summary"`. This returns an `inspect_analysis_view`
|
||||
Evidence record with a distinct ID and a `normalized_result.parent_evidence_id`
|
||||
for the complete retained application analysis. Source-map contents are part of
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**See a feature you like. Understand how it works, down to the binary level.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#what-you-can-analyze)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
@@ -245,7 +244,9 @@ Ghidra and IDA use your existing installations. See [provider setup](docs/instal
|
||||
<summary><strong>Do I need to start Hopper first?</strong></summary>
|
||||
|
||||
REA starts Hopper when an operation needs it. On macOS, a first-run dialog may
|
||||
ask you to choose demo mode or activate your license. See
|
||||
ask you to choose demo mode or activate your license. Complete that prompt in the
|
||||
same user session before your first CLI/MCP operation or unattended job. Open
|
||||
Hopper manually if needed. See
|
||||
[Hopper startup and troubleshooting](docs/installation.md#launcher-paths-and-troubleshooting).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**وجدت ميزة تعجبك؟ افهم طريقة عملها حتى مستوى الشيفرة الثنائية.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Eine Funktion gefällt dir? Verstehe, wie sie funktioniert – bis hinunter auf die Binärebene.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**¿Ves una función que te gusta? Entiende cómo funciona, hasta el nivel binario.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**قابلیتی دیدهاید که دوستش دارید؟ سازوکارش را تا سطح باینری درک کنید.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Repérez une fonctionnalité qui vous plaît. Comprenez son fonctionnement, jusqu'au niveau du binaire.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Melihat fitur yang Anda sukai. Pahami cara kerjanya hingga ke tingkat biner.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Vedi una funzionalità che ti piace. Capisci come funziona, fino al livello binario.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#cosa-puoi-analizzare)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**気に入った機能を見つけたら、その仕組みをバイナリのレベルまで理解する。**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**마음에 드는 기능을 발견했다면, 바이너리 수준까지 작동 원리를 이해하세요.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Widzisz funkcję, która Ci się podoba? Dowiedz się, jak działa, aż do poziomu kodu binarnego.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Viu uma funcionalidade de que gostou? Entenda como ela funciona, até o nível binário.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Нашли интересную функцию? Разберитесь, как она работает, вплоть до двоичного кода.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**เห็นฟีเจอร์ที่ชอบแล้วอยากรู้ว่าทำงานอย่างไร? เข้าใจได้ลึกถึงระดับไบนารี**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Beğendiğiniz bir özellik mi gördünüz? İkili kod düzeyine kadar nasıl çalıştığını anlayın.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Побачили цікаву функцію? Зрозумійте, як вона працює, аж до рівня двійкового коду.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Thấy một tính năng bạn thích? Hiểu cách nó hoạt động, đến tận mức mã nhị phân.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**看到喜歡的功能,弄清它的工作原理,深入到二進位層面。**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**看到喜欢的功能,弄清它的工作原理,深入到二进制层面。**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
+18
-2
@@ -352,6 +352,12 @@ Other integrations, including Qwen Code, use `~/.agents/skills`.
|
||||
A mixed selection plans each distinct destination. Selecting the skill without a client uses the shared
|
||||
directory. Setup leaves existing skill copies in other locations untouched.
|
||||
|
||||
`rea setup --skill-client claude_code --yes --json` refreshes and verifies the
|
||||
selected skill destination independently of MCP registration selection. The
|
||||
final `doctor.scope.skill_clients` records which skill destinations were
|
||||
checked; `doctor.scope.clients` continues to describe required registrations.
|
||||
An unselected shared skill does not affect a client-only refresh.
|
||||
|
||||
`doctor --skill --json` verifies the selected copies against the bundled
|
||||
instructions and references. Consumers should use `identity.skill.state`,
|
||||
`installed_version`, and `installed_tool_count` for skill readiness. The
|
||||
@@ -561,8 +567,13 @@ need Xvfb, Python 3, X11 and XTEST; approved setup installs those dependencies
|
||||
on supported distributions. Add the curl installer's reported executable
|
||||
directory, commonly `~/.local/bin`, to your shell `PATH` if needed.
|
||||
|
||||
REA starts Hopper when needed. On macOS, its launcher can bring a window or
|
||||
first-run dialog forward; choose demo mode or activate your existing license.
|
||||
REA starts Hopper when needed. On macOS, complete its demo or license prompt in
|
||||
the same user session before the first CLI/MCP operation or unattended job. Open
|
||||
Hopper manually if needed; its launcher can also bring the dialog forward.
|
||||
If startup reports `Cannot launch Hopper! Connection is invalid.` or waits for
|
||||
bridge readiness, check Hopper for a pending demo, license, or loader dialog.
|
||||
Complete the dialog and retry. If REA reports `cleanup_incomplete`, follow the
|
||||
retained-resource guidance below before reopening the target.
|
||||
Hopper serializes analysis requests. Cancelling a wait can leave provider work
|
||||
running, which the session reports. Successful decompilation is cached until
|
||||
a relevant rename or comment changes it.
|
||||
@@ -591,6 +602,11 @@ if document closure is unconfirmed, REA retains the backing image and reports
|
||||
MCP-exit behavior.
|
||||
Startup deadlines report missing bridge readiness and preserve the launcher
|
||||
outcome; a successful helper exit does not prove that a loader dialog completed.
|
||||
If an owned provider process exits, request failures preserve its observed exit
|
||||
code or signal and bounded output in `details.launcher`. An exit can precede
|
||||
output-stream closure; `output_closed: false` keeps that uncertainty explicit.
|
||||
Later requests retain the known exit instead of reducing it to socket loss.
|
||||
An external GUI's helper exit does not establish that the GUI itself exited.
|
||||
Cursor navigation returns the observed object start when Hopper snaps an interior
|
||||
address; adjacent-object navigation rejects unmapped inputs and document ends.
|
||||
Native API text rejects NUL characters and unpaired Unicode surrogates before
|
||||
|
||||
@@ -231,6 +231,11 @@ unknown instead of silently treating them as absent or verified.
|
||||
Plain `.ts` artifact sources use TypeScript syntax without JSX, including
|
||||
angle-bracket type assertions. `.tsx` and `.jsx` retain JSX parsing.
|
||||
|
||||
Relative TypeScript imports can name their emitted `.js`, `.mjs`, or `.cjs`
|
||||
counterparts while resolving to inventoried source files. This substitution
|
||||
requires import or require semantics. Literal Electron preload, renderer, and
|
||||
utility-process runtime paths retain the runtime filename selected by the caller.
|
||||
|
||||
Selected bounded text is then parsed as inert data to recover:
|
||||
|
||||
- `package.json` metadata and declared main or renderer entrypoints;
|
||||
@@ -323,15 +328,44 @@ substitute version-dependent Electron defaults for omitted `webPreferences`.
|
||||
Dynamic option objects, bridge keys, API objects, and IPC channel expressions
|
||||
remain unknown and make coverage partial.
|
||||
|
||||
Electron API identity follows proven lexical module aliases. Bundled CommonJS
|
||||
namespace imports are also followed through the interop helpers that bundlers
|
||||
and compilers emit (`__toESM`, `__importStar`, `_interopRequireWildcard`),
|
||||
including the `electron = __toESM(electron)` rewrap that Rolldown emits. This
|
||||
step is inferred: a helper is recognized by its emitted name and a single
|
||||
program-scope function definition, and its body is not evaluated, so a local
|
||||
function that reuses one of these names is trusted to pass the module through.
|
||||
Other reassigned bindings, dynamic selections, and visibly overwritten namespace
|
||||
members are unresolved.
|
||||
Electron API identity follows proven lexical module aliases. Namespace interop
|
||||
also preserves identity when the complete Rolldown helper operations, lexical
|
||||
roles, live getters, and captured intrinsic origins match, or when a stable
|
||||
helper simply returns its module parameter. Harmless single-statement control
|
||||
blocks preserve recognition; added operations still require proof. Literal
|
||||
member and destructuring projections are retained from proven results.
|
||||
Omitted or literal node-mode arguments preserve named exports; a module-valued
|
||||
default projection requires a truthy literal node mode. Computed or extra
|
||||
arguments remain unresolved.
|
||||
Recognition uses structure rather than emitted names; other helper bodies and ambiguous
|
||||
runtime fallbacks remain unresolved. Captured function identities survive later global-property
|
||||
replacement, including copies through initialized aliases. Reads before an
|
||||
alias initializer remain unresolved. Visible intrinsic writes before capture or
|
||||
conversion are checked in their owning callable scope, including identifier leaves in nested/default/rest
|
||||
destructuring and loop targets. Property keys and shadowed local bindings do not
|
||||
overwrite the global intrinsic identity. Cross-call side effects, including
|
||||
writes inside directly invoked local functions, are not evaluated. Other
|
||||
reassignments, dynamic selections, and visibly overwritten namespace members
|
||||
are unresolved.
|
||||
Known Object/Reflect mutators remain recognizable through literal destructuring
|
||||
of intrinsic objects and stable bound-function aliases. Calls retain bound
|
||||
argument prefixes and the original bound receiver through `call`, `apply`, and
|
||||
`Reflect.apply`, including generic `Function.prototype` invocation and binding
|
||||
creation. Arguments follow each actual callable invocation rather than property
|
||||
chain depth; rebinding retains the original bound receiver.
|
||||
Visibly replaced invocation intrinsics produce unknown effects instead
|
||||
of using their former call semantics; captured function aliases retain their
|
||||
earlier identity. Unexamined targets remain unresolved. `Reflect.set` examines
|
||||
its explicit receiver as well as its target, retaining unknown receiver effects.
|
||||
Creating an unused binding does not itself mutate an intrinsic. Variable
|
||||
redeclarations retain a single unconditional initializer
|
||||
when they introduce no assignment or second initializer.
|
||||
Stable identifier chains to fresh object, array, function,
|
||||
undecorated class or RegExp syntax keep those targets separate from intrinsic
|
||||
objects when initialized before use; opaque, decorated, reassigned or
|
||||
not-yet-initialized targets remain unresolved. Writes during class initialization
|
||||
are still retained. Function declarations are available before their textual
|
||||
position; variable and class initializers must precede use.
|
||||
Direct and destructuring assignment, loop, update, and delete targets are checked
|
||||
through namespace aliases; unrelated member writes do not discard observed API
|
||||
identity. This static check does
|
||||
@@ -425,16 +459,40 @@ such as `./web3:app.js` still names a local artifact.
|
||||
Package `exports` fallback arrays are supported both at the top level and under
|
||||
the root `"."` entry. The resolver selects targets in declared order using the
|
||||
same conditional and invalid-entry handling as nested exports arrays.
|
||||
Nonempty arrays containing only inactive conditions remain unmatched, allowing
|
||||
the enclosing condition object to continue to a later active condition.
|
||||
Empty arrays and explicit null targets keep their blocking outcome.
|
||||
Refusal evidence distinguishes those source values and retains condition names
|
||||
from unmatched nested targets rather than reporting array indices.
|
||||
For imports and requires, the active Node conditions include `module-sync`;
|
||||
earlier active conditions retain precedence over later ones.
|
||||
|
||||
Exact package-private `#alias` specifiers resolve against the importer's
|
||||
enclosing `package.json` `imports` map — the nearest same-container package
|
||||
scope, stopping at a `node_modules` directory so a nested package cannot inherit
|
||||
an ancestor's aliases. Selected targets are package-local `./` files with no
|
||||
extension or index fallback, using the same condition, array, and null selection
|
||||
as exports subpaths. A missing key, explicit null, or out-of-scope alias keeps
|
||||
an unresolved edge with that reason. Wildcard keys (`#internal/*`), recursive
|
||||
`#` targets, and external-package import targets remain unresolved.
|
||||
|
||||
Exact keys such as `#/x` are literal lookup identities. `#` and names ending
|
||||
in `/` are invalid. Imports arrays skip invalid parent-relative, absolute, and
|
||||
URL targets before selecting a later package-local file; an unsupported bare
|
||||
package or recursive alias keeps its unresolved outcome rather than selecting
|
||||
a later fallback.
|
||||
|
||||
ESM relative module paths and selected package exports targets use URL suffix handling
|
||||
and one percent-decoding pass. CommonJS relative paths and legacy package main
|
||||
fields retain literal filename punctuation. Exports targets must start with
|
||||
`./` and exclude `.`, `..`, and `node_modules` path segments, including their
|
||||
encoded forms, before URL normalization. Arrays skip invalid exports targets
|
||||
in declared order; URL decoding and file lookup failures for a selected target
|
||||
do not select a later entry. Rejected references retain the package metadata
|
||||
in declared order, including URLs that escape the package root after normalization.
|
||||
Selected package targets use the normalized URL path for lookup; tabs and
|
||||
newlines in the URL text are removed by the URL parser. CommonJS package targets
|
||||
reject encoded separators anywhere in the selected URL; ESM checks its pathname.
|
||||
URL decoding and file lookup failures for a selected target do not select a
|
||||
later entry. Rejected references retain the package metadata
|
||||
path, original target, failed constraint, and importing source location.
|
||||
|
||||
HTML script references resolve to exact inventoried files after applying the
|
||||
|
||||
@@ -126,6 +126,24 @@ Member inspection binds build-local tokens, signatures, row offsets, method
|
||||
bodies, and decoded relationships to that artifact and MVID. Missing or malformed
|
||||
facts remain explicit in coverage and per-member status.
|
||||
|
||||
Metadata table streams may carry the CLR-compatible `ExtraData` extension
|
||||
(`HeapSizes` flag `0x40`), which Microsoft readers honor although it is not
|
||||
specified by ECMA-335. REA skips its fixed four-byte word after the row counts
|
||||
before decoding table rows; the word's value is not a length. An incomplete
|
||||
word is reported as a metadata coverage failure at its file offset, without
|
||||
reading beyond the declared table stream.
|
||||
|
||||
For unoptimized `#-` metadata, nonempty `FieldPtr` and `MethodPtr` tables
|
||||
translate TypeDef list positions to physical member definitions. REA resolves
|
||||
these indirections for member ownership and local attribute-constructor types;
|
||||
field and method tokens still identify the original definition rows.
|
||||
`field_list` and `method_list` retain the metadata's list coordinates, which
|
||||
refer to the pointer table when present rather than necessarily contiguous
|
||||
definition rows. Malformed or ambiguous pointers leave the owner unknown with
|
||||
partial coverage while preserving readable member and attribute facts.
|
||||
The retained inverse indexes are admitted against the existing representation
|
||||
budget before allocation.
|
||||
|
||||
`exported_types` reports ExportedType rows as declared. Facade assemblies such
|
||||
as .NET `System.Runtime.dll` provide their types this way: `forwarder: true`
|
||||
marks a type forwarder, and `implementation_token` names the AssemblyRef, File,
|
||||
|
||||
@@ -450,7 +450,13 @@ Tool annotations describe effects and are hints, not authorization controls
|
||||
([MCP ToolAnnotations](https://modelcontextprotocol.io/specification/2025-06-18/schema#toolannotations)).
|
||||
`readOnlyHint` includes session state: an analysis call that records additive
|
||||
Evidence is marked non-read-only even when it leaves the target unchanged.
|
||||
`destructiveHint` describes possible data loss, not ordinary Evidence recording.
|
||||
`destructiveHint` is true when a tool may discard data (overwriting or clearing
|
||||
analyst metadata, replacing files, dropping session state), runs or drives code
|
||||
REA does not own (`executesForeignCode`: process, LLDB, Electron, browser, and
|
||||
native UI scenarios), or changes an attached Android device (`mutatesDevice`:
|
||||
push, install, uninstall, start, and stop). Ordinary Evidence recording is not
|
||||
destructive. The effect record in `binary_session` and the generated catalog
|
||||
carries each flag separately.
|
||||
Effect metadata covers possible behavior across supported inputs: DMG inventory
|
||||
can launch `hdiutil` and create an owned temporary mount directory; extraction
|
||||
creates a fresh output directory on every call. Comparing supplied web captures
|
||||
|
||||
+13
-8
@@ -139,8 +139,11 @@ directory, so default OS-home discovery remains unverified. Live model APIs and
|
||||
native Windows are also unverified.
|
||||
|
||||
`verify:claude-client` requires installed Claude Code (verified with
|
||||
`@anthropic-ai/claude-code@2.1.296`); select it with `REA_VERIFY_CLAUDE_COMMAND`.
|
||||
The optional POSIX lane uses native `CLAUDE_CONFIG_DIR` discovery, an independent
|
||||
`@anthropic-ai/claude-code@2.1.296` on POSIX and native Windows); select it
|
||||
with `REA_VERIFY_CLAUDE_COMMAND`. On Windows, select
|
||||
`claude.exe`: npm installs put only a `claude.cmd` shim on `PATH`, and the real
|
||||
executable is under `node_modules/@anthropic-ai/claude-code/bin`.
|
||||
The optional lane uses native `CLAUDE_CONFIG_DIR` discovery, an independent
|
||||
Git workspace, user settings and default built-in tools. It preserves caller
|
||||
preferences and backups, checks idempotent setup, waits through the native
|
||||
`WaitForMcpServers` tool when discovery is pending, and validates the complete
|
||||
@@ -149,8 +152,8 @@ native `Skill`, checks its full body and verifies named-schema JavaScript Eviden
|
||||
for a Unicode path. Use `-- chat` for ordinary chat with REA enabled, or
|
||||
`REA_VERIFY_RUNTIME_ROOT` for a production-only installed package. The local
|
||||
Anthropic Messages/SSE fixture preserves native resource-hint envelopes in its
|
||||
request artifacts. Usage is synthetic; live Anthropic API, native Windows and
|
||||
bare-mode personal-skill activation remain unverified by this lane.
|
||||
request artifacts. Usage is synthetic; live Anthropic API and bare-mode
|
||||
personal-skill activation remain unverified by this lane.
|
||||
|
||||
`verify:copilot-client` requires installed GitHub Copilot CLI (verified with
|
||||
`@github/copilot@1.0.95`); select it with `REA_VERIFY_COPILOT_COMMAND`.
|
||||
@@ -279,10 +282,12 @@ establish live DeepSeek API or native Windows coverage. Receipts include
|
||||
request artifacts, result digests and owned-process lineage.
|
||||
|
||||
Full E2E tests invoke the production command dispatcher and real providers,
|
||||
without fake launchers, runners or responses. `verify:keyed-archive` writes an
|
||||
actual Foundation binary archive, runs the CLI and a separate stdio MCP
|
||||
subprocess, checks parity and pagination, rejects an escaping path and checks
|
||||
an XML graph golden. `verify:asset-catalog` compiles source-owned colors with
|
||||
without fake launchers, runners or responses. `verify:keyed-archive` writes
|
||||
Foundation binary, XML, and integer archives using one run-owned Swift module
|
||||
cache. It checks CLI and separate stdio MCP parity, pagination, escaping-path
|
||||
rejection, an XML graph golden, and numeric meaning for safe controls, unsafe
|
||||
integers, boxed numbers, and integral-valued reals. `verify:asset-catalog`
|
||||
compiles source-owned colors with
|
||||
`actool`, invokes real `assetutil`, then checks CLI/MCP results, exact catalog
|
||||
digest, every raw metadata field, pagination and malformed input rejection.
|
||||
Neither artifact workflow requires Hopper or launches it. Both run in macOS CI.
|
||||
|
||||
@@ -13,10 +13,6 @@ import { exec } from "./lib/verify-package-core.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { createAnthropicModelFixture } from "./verify/clients/anthropic-model-fixture.mjs";
|
||||
|
||||
assert(
|
||||
process.platform !== "win32",
|
||||
"Native Claude Code verifier requires POSIX; Windows is unverified",
|
||||
);
|
||||
const repo = fileURLToPath(new URL("..", import.meta.url));
|
||||
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
|
||||
const command = process.env.REA_VERIFY_CLAUDE_COMMAND ?? "claude";
|
||||
@@ -46,8 +42,13 @@ try {
|
||||
await exec(command, ["--version"], { env: environment, timeout: 30_000 })
|
||||
).stdout.trim();
|
||||
} catch (cause) {
|
||||
// npm installs put only a claude.cmd shim on PATH, which Node does not spawn without a shell.
|
||||
const windowsHint =
|
||||
process.platform === "win32"
|
||||
? " On Windows, select claude.exe; npm installs provide it under node_modules/@anthropic-ai/claude-code/bin."
|
||||
: "";
|
||||
throw new Error(
|
||||
`Claude Code preflight failed for ${command}; install Claude Code or set REA_VERIFY_CLAUDE_COMMAND. ${String(cause)}`,
|
||||
`Claude Code preflight failed for ${command}; install Claude Code or set REA_VERIFY_CLAUDE_COMMAND.${windowsHint} ${String(cause)}`,
|
||||
);
|
||||
}
|
||||
const target = join(lab, "fixture α测试");
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { keyedArchiveResultSchema } from "../../../dist/domain/apple/keyedArchive.js";
|
||||
|
||||
const rowSchema = z.object({
|
||||
container: z.enum(["roots", "objects"]),
|
||||
root: z.string(),
|
||||
objectId: z.number().int().nonnegative().optional(),
|
||||
path: z.array(z.union([z.string(), z.number().int().nonnegative()])),
|
||||
kind: z.enum(["integer", "real"]),
|
||||
literal: z.string(),
|
||||
});
|
||||
const archiveSchema = z.object({
|
||||
filename: z.string(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
rows: z.array(rowSchema),
|
||||
});
|
||||
const oracleSchema = z.object({ xml: archiveSchema, binary: archiveSchema });
|
||||
|
||||
const formats = ["xml", "binary"];
|
||||
const requiredRows = [
|
||||
{ root: "unsafeSigned", kind: "integer", literal: "9007199254740993" },
|
||||
{
|
||||
root: "unsafeNegative",
|
||||
kind: "integer",
|
||||
literal: "-9223372036854775808",
|
||||
},
|
||||
{ root: "safeInteger", kind: "integer", literal: "9007199254740991" },
|
||||
{ root: "safeNegative", kind: "integer", literal: "-42" },
|
||||
{ root: "integralReal", kind: "real", literal: "9007199254740992" },
|
||||
{
|
||||
root: "boxedUnsigned",
|
||||
kind: "integer",
|
||||
literal: "9007199254740993",
|
||||
container: "objects",
|
||||
},
|
||||
{
|
||||
root: "boxedReal",
|
||||
kind: "real",
|
||||
literal: "9007199254740992",
|
||||
container: "objects",
|
||||
},
|
||||
];
|
||||
|
||||
const valueAt = (value, path) => {
|
||||
for (const key of path) {
|
||||
if (value === null || typeof value !== "object") return undefined;
|
||||
value = value[key];
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const isUnsafeInteger = (row) =>
|
||||
row.kind === "integer" &&
|
||||
(BigInt(row.literal) > BigInt(Number.MAX_SAFE_INTEGER) ||
|
||||
BigInt(row.literal) < BigInt(Number.MIN_SAFE_INTEGER));
|
||||
|
||||
/** Verify the actual Foundation output and the production CLI/MCP result. */
|
||||
export async function verifyKeyedArchiveIntegerArchives(root) {
|
||||
const oracle = oracleSchema.parse(
|
||||
JSON.parse(await readFile(join(root, "oracle.json"), "utf8")),
|
||||
);
|
||||
const archives = [];
|
||||
|
||||
for (const format of formats) {
|
||||
const archive = oracle[format];
|
||||
for (const required of requiredRows) {
|
||||
assert.ok(
|
||||
archive.rows.some(
|
||||
(row) =>
|
||||
row.root === required.root &&
|
||||
row.kind === required.kind &&
|
||||
row.literal === required.literal &&
|
||||
(required.container === undefined ||
|
||||
row.container === required.container),
|
||||
),
|
||||
`${format} Foundation oracle omitted ${required.root} ${required.literal}`,
|
||||
);
|
||||
}
|
||||
|
||||
const archivePath = join(root, archive.filename);
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(archivePath))
|
||||
.digest("hex");
|
||||
assert.equal(digest, archive.sha256, `${format} archive digest`);
|
||||
archives.push({ format, archive, archivePath, digest });
|
||||
}
|
||||
return archives;
|
||||
}
|
||||
|
||||
/** Check every oracle numeric slot, preserving object-table identity lookups. */
|
||||
export function assertKeyedArchiveIntegerResult(
|
||||
result,
|
||||
format,
|
||||
digest,
|
||||
oracle,
|
||||
) {
|
||||
const parsed = keyedArchiveResultSchema.parse(result);
|
||||
assert.equal(parsed.archive_sha256, digest);
|
||||
assert.equal(parsed.target_sha256, digest);
|
||||
assert.equal(
|
||||
parsed.archive_format,
|
||||
format === "xml" ? "xml-plist" : "binary-plist",
|
||||
);
|
||||
|
||||
for (const row of oracle.rows) {
|
||||
const unsafe = isUnsafeInteger(row);
|
||||
const source =
|
||||
row.container === "roots"
|
||||
? parsed.roots
|
||||
: parsed.objects.find((object) => object.id === row.objectId)?.value;
|
||||
const value = valueAt(source, row.path);
|
||||
assert.notEqual(
|
||||
value,
|
||||
undefined,
|
||||
`${row.root} numeric slot remains present`,
|
||||
);
|
||||
|
||||
if (unsafe) {
|
||||
const exact =
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
value.$plist_type === "integer" &&
|
||||
value.decimal === BigInt(row.literal).toString();
|
||||
const disclosed = parsed.limitations.some(
|
||||
(reason) =>
|
||||
/integer|number/iu.test(reason) &&
|
||||
/precision|exact range|rounded/iu.test(reason),
|
||||
);
|
||||
assert.ok(
|
||||
exact || disclosed,
|
||||
`${row.root} ${row.literal} must be exact or disclose numeric loss`,
|
||||
);
|
||||
} else {
|
||||
assert.equal(
|
||||
typeof value,
|
||||
"number",
|
||||
`${row.root} retains its numeric type`,
|
||||
);
|
||||
assert.equal(value, Number(row.literal), `${row.root} numeric value`);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile, spawnSync } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
} from "../../lib/artifact-e2e.mjs";
|
||||
import {
|
||||
assertKeyedArchiveIntegerResult,
|
||||
verifyKeyedArchiveIntegerArchives,
|
||||
} from "./keyed-archive-integers.mjs";
|
||||
|
||||
if (process.platform !== "darwin")
|
||||
throw new Error(
|
||||
@@ -170,26 +174,37 @@ try {
|
||||
);
|
||||
});
|
||||
}
|
||||
const numericTests = spawnSync(
|
||||
"npm",
|
||||
[
|
||||
"run",
|
||||
"test:focused",
|
||||
"--",
|
||||
"tests/boundary/filesystem/keyedArchiveNativeIntegers.test.ts",
|
||||
],
|
||||
{
|
||||
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
|
||||
stdio: "inherit",
|
||||
timeout: 240_000,
|
||||
},
|
||||
);
|
||||
if (numericTests.error) throw numericTests.error;
|
||||
assert.equal(
|
||||
numericTests.status,
|
||||
0,
|
||||
"Native Foundation integer cases failed",
|
||||
);
|
||||
const integers = join(root, "integers");
|
||||
await mkdir(integers);
|
||||
await promisify(execFile)("/usr/bin/xcrun", [
|
||||
"swift",
|
||||
"-module-cache-path",
|
||||
join(root, "modules"),
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../../tests/conformance/native/keyed-archive-integers.swift",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
integers,
|
||||
]);
|
||||
for (const fixture of await verifyKeyedArchiveIntegerArchives(integers)) {
|
||||
const cliResult = assertKeyedArchiveIntegerResult(
|
||||
await artifactCli("inspect-keyed-archive", fixture.archivePath),
|
||||
fixture.format,
|
||||
fixture.digest,
|
||||
fixture.archive,
|
||||
);
|
||||
await withArtifactMcp(fixture.archivePath, async (client) => {
|
||||
const mcpResult = assertKeyedArchiveIntegerResult(
|
||||
await artifactMcpResult(client, "inspect_keyed_archive"),
|
||||
fixture.format,
|
||||
fixture.digest,
|
||||
fixture.archive,
|
||||
);
|
||||
assert.deepEqual(mcpResult, cliResult);
|
||||
});
|
||||
}
|
||||
report = {
|
||||
ok: true,
|
||||
mocked: false,
|
||||
@@ -197,6 +212,9 @@ try {
|
||||
stdio_mcp: true,
|
||||
xml_golden: true,
|
||||
uid_named_root: true,
|
||||
numeric_formats: ["xml", "binary"],
|
||||
numeric_cli: true,
|
||||
numeric_stdio_mcp: true,
|
||||
format: graph.archive_format,
|
||||
objects: graph.total_objects,
|
||||
references: graph.total_references,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
clientRequirementsFor,
|
||||
NO_CLIENT_FEATURES,
|
||||
} from "./CapabilityClientRequirements.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
const statusSchema = z.object({
|
||||
open: z.boolean(),
|
||||
@@ -210,7 +211,7 @@ export const buildCapabilityInventory = (
|
||||
reason: availability.reason,
|
||||
remediation: availability.remediation,
|
||||
};
|
||||
}).sort((left, right) => left.name.localeCompare(right.name));
|
||||
}).sort((left, right) => compareUnicodeCodePoints(left.name, right.name));
|
||||
};
|
||||
|
||||
const availabilityFor = (context: AvailabilityContext): Availability => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { z } from "zod";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
export type ClientConfigurationFormat = NonNullable<SetupClient["format"]>;
|
||||
export type ClientServersKey = "mcp_servers" | "mcpServers" | "mcp" | "servers";
|
||||
@@ -63,7 +64,7 @@ const normalizeConfigurationValue = (value: unknown): unknown => {
|
||||
return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
|
||||
.map(([key, nested]) => [key, normalizeConfigurationValue(nested)]),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ClientConfigurationDocument,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Read explicit REA overrides without dropping peers when one value is invalid. */
|
||||
export const clientRegistrationEnvironment = (
|
||||
@@ -47,7 +48,7 @@ export const clientRegistrationEnvironment = (
|
||||
return ok(
|
||||
Object.fromEntries(
|
||||
Object.entries(environment).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
compareUnicodeCodePoints(left, right),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
supportedClients,
|
||||
} from "./SupportedClients.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
interface ClientRegistrationStatusBase {
|
||||
readonly client: string;
|
||||
@@ -184,7 +185,7 @@ export const readClientRegistrationStatuses = async (
|
||||
}
|
||||
}
|
||||
return statuses.sort((left, right) =>
|
||||
left.client.localeCompare(right.client),
|
||||
compareUnicodeCodePoints(left.client, right.client),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -281,6 +281,42 @@ describe("doctor installation identity", () => {
|
||||
});
|
||||
|
||||
describe("doctor scoped readiness", () => {
|
||||
it("checks explicitly selected skill destinations independently of registrations", async () => {
|
||||
const selected: (readonly string[] | undefined)[] = [];
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
clientRegistrations: () => Promise.resolve([]),
|
||||
installedSkillIdentity: (clients) => {
|
||||
selected.push(clients);
|
||||
return Promise.resolve({
|
||||
version: "stale",
|
||||
toolCount: 0,
|
||||
canonical: false,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
clients: [],
|
||||
skillClients: ["claude_code", "claude_code"],
|
||||
skill: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(selected).toEqual([["claude_code"]]);
|
||||
expect(result.scope).toMatchObject({
|
||||
mode: "explicit",
|
||||
clients: [],
|
||||
skill_clients: ["claude_code"],
|
||||
skill: true,
|
||||
});
|
||||
expect(result.healthy).toBe(false);
|
||||
expect(result.scope_checks.map(({ name }) => name)).toEqual([
|
||||
"node",
|
||||
"skill:identity",
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes effective readiness to selected clients without hiding drift", async () => {
|
||||
const registrations: readonly ClientRegistrationStatus[] = [
|
||||
{
|
||||
|
||||
@@ -120,6 +120,7 @@ export interface DoctorReport {
|
||||
/** Caller-selected readiness boundary; omitted values retain audit semantics. */
|
||||
export interface DoctorScope {
|
||||
readonly clients?: readonly string[];
|
||||
readonly skillClients?: readonly string[];
|
||||
readonly providers?: readonly string[];
|
||||
readonly skill?: boolean;
|
||||
}
|
||||
@@ -206,9 +207,10 @@ const collectDoctorIdentity = async (
|
||||
const installationPaths = (await host.installationPaths?.()) ?? [];
|
||||
const registrations = (await host.clientRegistrations?.()) ?? [];
|
||||
const skillClientIds =
|
||||
scope.mode === "audit-wide"
|
||||
scope.skill_clients ??
|
||||
(scope.mode === "audit-wide"
|
||||
? registrations.map(({ client }) => client)
|
||||
: scope.clients;
|
||||
: scope.clients);
|
||||
const installedSkillIdentity =
|
||||
await host.installedSkillIdentity?.(skillClientIds);
|
||||
const skillAligned = skillIdentityAligned(installedSkillIdentity);
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
export interface DoctorScopeReport {
|
||||
readonly mode: "audit-wide" | "explicit";
|
||||
readonly clients: readonly string[];
|
||||
readonly skill_clients?: readonly string[];
|
||||
readonly providers: readonly string[];
|
||||
readonly skill: boolean;
|
||||
readonly target: string | null;
|
||||
@@ -40,6 +41,9 @@ export const normalizeDoctorScope = (
|
||||
): DoctorScopeReport => ({
|
||||
mode: requested === undefined ? "audit-wide" : "explicit",
|
||||
clients: uniqueSorted(requested?.clients ?? []),
|
||||
...(requested?.skillClients === undefined
|
||||
? {}
|
||||
: { skill_clients: uniqueSorted(requested.skillClients) }),
|
||||
providers: uniqueSorted(requested?.providers ?? []),
|
||||
skill: requested?.skill === true,
|
||||
target: target ?? null,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
semanticObligationPolicy,
|
||||
type ReconstructionObligationCandidatePolicy,
|
||||
} from "./ReconstructionObligationPolicies.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
type CandidateFields = Omit<
|
||||
ReconstructionObligation,
|
||||
@@ -54,7 +55,7 @@ export const deriveReconstructionObligationCandidates = (
|
||||
addCandidate(candidates, reviewedCandidate(obligation, records));
|
||||
return {
|
||||
candidates: [...candidates.values()].sort((left, right) =>
|
||||
left.obligation_id.localeCompare(right.obligation_id),
|
||||
compareUnicodeCodePoints(left.obligation_id, right.obligation_id),
|
||||
),
|
||||
limitations: [...limitations].sort(),
|
||||
};
|
||||
@@ -430,7 +431,7 @@ const uniqueBy = <Value>(
|
||||
key: (value: Value) => string,
|
||||
): Value[] =>
|
||||
[...new Map(values.map((value) => [key(value), value])).values()].sort(
|
||||
(left, right) => key(left).localeCompare(key(right)),
|
||||
(left, right) => compareUnicodeCodePoints(key(left), key(right)),
|
||||
);
|
||||
|
||||
const obligationId = (key: string): string =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ReconstructionObligationManifest,
|
||||
} from "../domain/reconstructionObligationLedgerSchemas.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
type Diagnostic = ReconstructionObligation["diagnostics"][number];
|
||||
type Binding = NonNullable<ReconstructionObligation["binding"]>;
|
||||
@@ -50,7 +51,8 @@ export const uniqueObligationDiagnostics = (
|
||||
]),
|
||||
).values(),
|
||||
].sort((left, right) =>
|
||||
`${left.code}:${left.detail}`.localeCompare(
|
||||
compareUnicodeCodePoints(
|
||||
`${left.code}:${left.detail}`,
|
||||
`${right.code}:${right.detail}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
obligationReports,
|
||||
summarizeObligations,
|
||||
} from "./ReconstructionObligationLedgerProjection.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
type Diagnostic = ReconstructionObligation["diagnostics"][number];
|
||||
type Binding = NonNullable<ReconstructionObligation["binding"]>;
|
||||
@@ -60,7 +61,7 @@ export const evaluateReconstructionObligationLedger = ({
|
||||
}): ReconstructionObligationLedger => {
|
||||
const bundle = parseEvidenceBundle(bundleInput);
|
||||
const allCandidates = [...candidatesInput].sort((left, right) =>
|
||||
left.obligation_id.localeCompare(right.obligation_id),
|
||||
compareUnicodeCodePoints(left.obligation_id, right.obligation_id),
|
||||
);
|
||||
const orphanManifestIds = findOrphanManifestIds(allCandidates, manifest);
|
||||
const candidates = allCandidates;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Evidence } from "../domain/evidence.js";
|
||||
import type { ReconstructionObligation } from "../domain/reconstructionObligationLedgerSchemas.js";
|
||||
import { evidenceSupportsOriginalAuthority } from "./ReconstructionObligationAuthority.js";
|
||||
import type { ReconstructionObligationCandidate } from "./ReconstructionObligationCandidates.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
type Binding = NonNullable<ReconstructionObligation["binding"]>;
|
||||
type Diagnostic = ReconstructionObligation["diagnostics"][number];
|
||||
@@ -21,7 +22,8 @@ export const originalCasesFor = (
|
||||
),
|
||||
).values(),
|
||||
].sort((left, right) =>
|
||||
`${left.kind}:${left.evidence_id}:${left.location}`.localeCompare(
|
||||
compareUnicodeCodePoints(
|
||||
`${left.kind}:${left.evidence_id}:${left.location}`,
|
||||
`${right.kind}:${right.evidence_id}:${right.location}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
ReferenceSourceRead,
|
||||
} from "../reference/ReferenceSourceReaderTypes.js";
|
||||
import { PARSEABLE_REFERENCE_SOURCE_LANGUAGES } from "./ReferenceSourceImportTypes.js";
|
||||
import { typescriptEmittedSourceCandidates } from "./TypeScriptEmittedSourceCandidates.js";
|
||||
|
||||
export interface ParsedReferenceSourceEntries {
|
||||
readonly entries: HistoricalSourceGraphInput["entries"];
|
||||
@@ -99,22 +100,9 @@ const resolveInternalSpecifier = (
|
||||
`${normalized}${suffix}`,
|
||||
`${normalized}/index${suffix}`,
|
||||
]);
|
||||
// TypeScript source commonly names the extension emitted for NodeNext.
|
||||
// Retain present runtime files first; substitute only a missing counterpart.
|
||||
// Counterparts cover explicit filenames, not directory/index resolution.
|
||||
if (/\.(?:ts|tsx|mts|cts)$/u.test(fromPath)) {
|
||||
const sourceSuffixes = normalized.endsWith(".js")
|
||||
? [".ts", ".tsx", ".d.ts"]
|
||||
: normalized.endsWith(".mjs")
|
||||
? [".mts", ".d.mts"]
|
||||
: normalized.endsWith(".cjs")
|
||||
? [".cts", ".d.cts"]
|
||||
: normalized.endsWith(".jsx")
|
||||
? [".tsx", ".d.ts"]
|
||||
: [];
|
||||
const stem = normalized.slice(0, -posix.extname(normalized).length);
|
||||
candidates.push(...sourceSuffixes.map((suffix) => `${stem}${suffix}`));
|
||||
}
|
||||
candidates.push(...typescriptEmittedSourceCandidates(fromPath, normalized));
|
||||
const match = lookupPath.resolveCandidates(candidates);
|
||||
return match === undefined
|
||||
? { to: normalized, resolution: "unresolved" }
|
||||
|
||||
@@ -159,6 +159,7 @@ export class FakeSetupHost implements SetupHost {
|
||||
scope === undefined
|
||||
? environmentHealthy
|
||||
: (scope.clients?.length ?? 0) === 0 &&
|
||||
(scope.skillClients?.length ?? 0) === 0 &&
|
||||
(scope.providers?.length ?? 0) === 0 &&
|
||||
scope.skill !== true
|
||||
? true
|
||||
@@ -169,6 +170,9 @@ export class FakeSetupHost implements SetupHost {
|
||||
scope: {
|
||||
mode: scope === undefined ? "audit-wide" : "explicit",
|
||||
clients: scope?.clients ?? [],
|
||||
...(scope?.skillClients === undefined
|
||||
? {}
|
||||
: { skill_clients: scope.skillClients }),
|
||||
providers: scope?.providers ?? [],
|
||||
skill: scope?.skill === true,
|
||||
target: null,
|
||||
|
||||
@@ -232,6 +232,7 @@ describe("setup scoped readiness", () => {
|
||||
clients: ["codex"],
|
||||
providers: [],
|
||||
skill: true,
|
||||
skillClients: ["codex"],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -125,7 +125,10 @@ const runSetupWorkflow = async (
|
||||
: (options.clientIds ??
|
||||
options.readinessScope?.clients ??
|
||||
discovery.defaultClientIds);
|
||||
let selectedSkillClientIds = options.skillClientIds ?? selectedClientIds;
|
||||
let selectedSkillClientIds =
|
||||
options.skillClientIds ??
|
||||
options.readinessScope?.skillClients ??
|
||||
selectedClientIds;
|
||||
let skillNeedsInstall = await host.skillNeedsInstall(selectedSkillClientIds);
|
||||
let skillSelected =
|
||||
options.installSkill === true ||
|
||||
@@ -220,6 +223,7 @@ const runSetupWorkflow = async (
|
||||
installHopper: planDiscovery.installHopper,
|
||||
installSkill,
|
||||
skillSelected,
|
||||
selectedSkillClientIds,
|
||||
selectedClients: planned.selectedClients,
|
||||
clientsToConfigure: planned.clientsToConfigure,
|
||||
};
|
||||
@@ -519,6 +523,7 @@ const resolvedReadinessScope = (
|
||||
readonly selectedClients: readonly SetupClient[];
|
||||
readonly installHopper: boolean;
|
||||
readonly skillSelected?: boolean;
|
||||
readonly selectedSkillClientIds: readonly string[];
|
||||
},
|
||||
): DoctorScope => ({
|
||||
clients: selection.selectedClients.map(({ name }) => name),
|
||||
@@ -526,6 +531,9 @@ const resolvedReadinessScope = (
|
||||
? ["hopper"]
|
||||
: (requested?.providers ?? []),
|
||||
skill: selection.skillSelected === true || requested?.skill === true,
|
||||
...(selection.skillSelected === true || requested?.skill === true
|
||||
? { skillClients: selection.selectedSkillClientIds }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const summarizeDoctor = (report: DoctorReport): SetupDoctorSummary => ({
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type { DoctorReport } from "./Doctor.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Read-only setup facts shared by client selection and preflight. */
|
||||
export interface SetupDiscovery {
|
||||
@@ -308,7 +309,7 @@ const clientConfigurationDetail = (
|
||||
installHopper: boolean,
|
||||
): string => {
|
||||
const entries = Object.entries(environment)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
|
||||
.map(([name, value]) => `${name}=${value}`);
|
||||
const environmentDetail =
|
||||
entries.length === 0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DoctorProviderInspection } from "./Doctor.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Preserve adapter-published settings independently of runtime readiness. */
|
||||
export const providerRegistrationEnvironment = (
|
||||
@@ -9,5 +10,5 @@ export const providerRegistrationEnvironment = (
|
||||
.flatMap(({ registrationEnvironment }) =>
|
||||
Object.entries(registrationEnvironment),
|
||||
)
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
.sort(([left], [right]) => compareUnicodeCodePoints(left, right)),
|
||||
);
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
HermesClientPathError,
|
||||
resolveHermesClientHome,
|
||||
} from "./HermesClientHome.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** One supported client configuration location. */
|
||||
export interface SetupClient {
|
||||
@@ -642,6 +643,6 @@ export const clientSkillDirectories = (
|
||||
});
|
||||
}
|
||||
return [...destinations.values()].sort((left, right) =>
|
||||
left.directory.localeCompare(right.directory),
|
||||
compareUnicodeCodePoints(left.directory, right.directory),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Emitted NodeNext suffixes and the TypeScript source counterparts they name. */
|
||||
const EMITTED_SUFFIXES: ReadonlyArray<
|
||||
readonly [suffix: string, sourceSuffixes: readonly string[]]
|
||||
> = [
|
||||
[".js", [".ts", ".tsx", ".d.ts"]],
|
||||
[".mjs", [".mts", ".d.mts"]],
|
||||
[".cjs", [".cts", ".d.cts"]],
|
||||
[".jsx", [".tsx", ".d.ts"]],
|
||||
];
|
||||
|
||||
/**
|
||||
* Return TypeScript source counterparts for an explicit emitted filename.
|
||||
* Callers retain exact runtime-file precedence and must not use these for
|
||||
* directory/index, URL, HTML, or package target resolution.
|
||||
*/
|
||||
export const typescriptEmittedSourceCandidates = (
|
||||
sourcePath: string,
|
||||
candidate: string,
|
||||
): readonly string[] => {
|
||||
if (!/\.(?:ts|tsx|mts|cts)$/u.test(sourcePath)) return [];
|
||||
const emitted = EMITTED_SUFFIXES.find(([suffix]) =>
|
||||
candidate.endsWith(suffix),
|
||||
);
|
||||
if (emitted === undefined) return [];
|
||||
// Strip the matched suffix by length. posix.extname would miss dotfile
|
||||
// names such as ".js", whose whole basename is the emitted suffix itself.
|
||||
const stem = candidate.slice(0, candidate.length - emitted[0].length);
|
||||
return emitted[1].map((suffix) => `${stem}${suffix}`);
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { ok, type Result } from "../../domain/result.js";
|
||||
|
||||
/**
|
||||
* Complete class-inventory pages dominate raw-call provenance for focused
|
||||
* searches: one traversal embeds every examined class name regardless of the
|
||||
* match count. The summary keeps the pagination audit inline (totals, page
|
||||
* offsets and sizes, elided item counts) and links the retained record that
|
||||
* still carries the complete inventory.
|
||||
*/
|
||||
const elideInventoryText = (text: string): string | undefined => {
|
||||
let page: JsonValue;
|
||||
try {
|
||||
page = JSON.parse(text) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
typeof page !== "object" ||
|
||||
page === null ||
|
||||
Array.isArray(page) ||
|
||||
!Array.isArray(page.items)
|
||||
)
|
||||
return undefined;
|
||||
const { items, ...audited } = page;
|
||||
return JSON.stringify({ ...audited, items_omitted: items.length });
|
||||
};
|
||||
|
||||
/** Replace inventoried page bodies with their pagination audit. */
|
||||
const summarizeInventoryCall = (call: JsonValue): JsonValue => {
|
||||
if (
|
||||
typeof call !== "object" ||
|
||||
call === null ||
|
||||
Array.isArray(call) ||
|
||||
call.operation !== "list_classes"
|
||||
)
|
||||
return call;
|
||||
const response = call.response;
|
||||
if (
|
||||
typeof response !== "object" ||
|
||||
response === null ||
|
||||
Array.isArray(response) ||
|
||||
!Array.isArray(response.content)
|
||||
)
|
||||
return call;
|
||||
let elided = false;
|
||||
const content = response.content.map((entry: JsonValue) => {
|
||||
if (
|
||||
typeof entry !== "object" ||
|
||||
entry === null ||
|
||||
Array.isArray(entry) ||
|
||||
typeof entry.text !== "string"
|
||||
)
|
||||
return entry;
|
||||
const summarized = elideInventoryText(entry.text);
|
||||
if (summarized === undefined) return entry;
|
||||
elided = true;
|
||||
return { ...entry, text: summarized };
|
||||
});
|
||||
return elided
|
||||
? { ...call, inventory_elided: true, response: { ...response, content } }
|
||||
: call;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retain the complete search Evidence — full raw provider calls included —
|
||||
* in the session record, then deliver a bounded summary Evidence inline.
|
||||
* Callers without a retaining session keep the complete inline delivery, so
|
||||
* complete CLI JSON output is unchanged. Coverage metadata stays truthful in
|
||||
* both copies: the examined inventory is never rewritten to matched-only.
|
||||
*/
|
||||
export const summarizeRetainedAndroidSearch = (
|
||||
evidence: Evidence,
|
||||
recordEvidence: EvidenceWriter["recordEvidence"] | undefined,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
if (recordEvidence === undefined) return ok(evidence);
|
||||
const recorded = recordEvidence(evidence);
|
||||
if (!recorded.ok) return recorded;
|
||||
const subject = evidence.subject;
|
||||
const raw = evidence.raw_result;
|
||||
const summarized: JsonValue =
|
||||
raw === null ||
|
||||
typeof raw !== "object" ||
|
||||
Array.isArray(raw) ||
|
||||
!Array.isArray(raw.calls)
|
||||
? raw
|
||||
: {
|
||||
...raw,
|
||||
complete_evidence_id: evidence.evidence_id,
|
||||
calls: raw.calls.map(summarizeInventoryCall),
|
||||
};
|
||||
return ok(
|
||||
createEvidence(
|
||||
subject === null
|
||||
? undefined
|
||||
: {
|
||||
path: subject.local_path,
|
||||
sha256: subject.digest.sha256,
|
||||
format: subject.format,
|
||||
...(subject.architecture === null
|
||||
? {}
|
||||
: { architecture: subject.architecture }),
|
||||
},
|
||||
evidence.provider,
|
||||
{
|
||||
operation: evidence.operation,
|
||||
parameters: evidence.parameters,
|
||||
result: evidence.normalized_result,
|
||||
rawResult: summarized,
|
||||
confidence: evidence.confidence,
|
||||
limitations: evidence.limitations,
|
||||
locations: evidence.locations,
|
||||
evidenceLinks: [evidence.evidence_id],
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
evaluateAnalysisProviderCandidate,
|
||||
type AnalysisProviderCandidateEvaluation,
|
||||
} from "./AnalysisProviderEvaluation.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Deterministic discovery truth for one configured deep provider. */
|
||||
export interface AnalysisProviderCandidateStatus {
|
||||
@@ -109,12 +110,14 @@ export class AnalysisProviderRegistry {
|
||||
}
|
||||
this.#providers = Object.freeze(
|
||||
[...providers].sort((left, right) =>
|
||||
left.identity().id.localeCompare(right.identity().id),
|
||||
compareUnicodeCodePoints(left.identity().id, right.identity().id),
|
||||
),
|
||||
);
|
||||
this.#byId = byId;
|
||||
this.#declaredOperations = Object.freeze(
|
||||
[...declaredOperations].sort((left, right) => left.localeCompare(right)),
|
||||
[...declaredOperations].sort((left, right) =>
|
||||
compareUnicodeCodePoints(left, right),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SessionProviderRoute,
|
||||
SessionProviderRouter,
|
||||
} from "./SessionProviderRouter.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
|
||||
interface BinarySessionStatusInput {
|
||||
readonly target: BinaryTarget | undefined;
|
||||
@@ -39,7 +40,9 @@ export const binarySessionStatus = ({
|
||||
}: BinarySessionStatusInput): JsonValue => {
|
||||
const capabilities = [...route.capabilities.values()]
|
||||
.filter(({ operation }) => operation !== "inventory_artifact")
|
||||
.sort((left, right) => left.operation.localeCompare(right.operation))
|
||||
.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.operation, right.operation),
|
||||
)
|
||||
.map((descriptor) => {
|
||||
const unavailable = runtimeUnavailability.get(descriptor.operation);
|
||||
return unavailable === undefined
|
||||
@@ -69,7 +72,9 @@ export const binarySessionStatus = ({
|
||||
capabilities: candidate.capabilities
|
||||
.slice()
|
||||
.filter(({ operation }) => operation !== "inventory_artifact")
|
||||
.sort((left, right) => left.operation.localeCompare(right.operation))
|
||||
.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.operation, right.operation),
|
||||
)
|
||||
.map(capabilityStatus),
|
||||
}));
|
||||
const common = {
|
||||
@@ -102,7 +107,9 @@ const requestActivityStatus = (
|
||||
) => {
|
||||
const providers = snapshots
|
||||
.slice()
|
||||
.sort((left, right) => left.provider.id.localeCompare(right.provider.id))
|
||||
.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.provider.id, right.provider.id),
|
||||
)
|
||||
.map(({ provider, active, queuedRequests }) => ({
|
||||
provider: providerSummary(provider),
|
||||
active:
|
||||
@@ -150,7 +157,7 @@ const processLineageStatus = (
|
||||
snapshots: snapshots
|
||||
.slice()
|
||||
.sort((left, right) =>
|
||||
left.provider.id.localeCompare(right.provider.id),
|
||||
compareUnicodeCodePoints(left.provider.id, right.provider.id),
|
||||
)
|
||||
.map(({ provider, observation }) => ({
|
||||
provider: providerSummary(provider),
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Synthetic identity for a deterministic provider set. */
|
||||
const compositeProviderIdentity = (
|
||||
@@ -53,8 +54,8 @@ export class CompositeProvider implements AnalysisProvider {
|
||||
this.#capabilities = Object.freeze(
|
||||
capabilities.sort(
|
||||
(left, right) =>
|
||||
left.operation.localeCompare(right.operation) ||
|
||||
left.provider.id.localeCompare(right.provider.id),
|
||||
compareUnicodeCodePoints(left.operation, right.operation) ||
|
||||
compareUnicodeCodePoints(left.provider.id, right.provider.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -122,13 +123,13 @@ export class CompositeProvider implements AnalysisProvider {
|
||||
[...clients.values()]
|
||||
.flatMap((client) => client.runtimeLineageSnapshots?.() ?? [])
|
||||
.sort((left, right) =>
|
||||
left.provider.id.localeCompare(right.provider.id),
|
||||
compareUnicodeCodePoints(left.provider.id, right.provider.id),
|
||||
),
|
||||
requestActivitySnapshots: () =>
|
||||
[...clients.values()]
|
||||
.flatMap((client) => client.requestActivitySnapshots?.() ?? [])
|
||||
.sort((left, right) =>
|
||||
left.provider.id.localeCompare(right.provider.id),
|
||||
compareUnicodeCodePoints(left.provider.id, right.provider.id),
|
||||
),
|
||||
operationHealthSnapshot: () => {
|
||||
for (const client of clients.values()) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type UpdateUnknownInput,
|
||||
} from "../../domain/residualUnknown.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
|
||||
type EvidenceLedgerFailure = EvidenceIntegrityError;
|
||||
type RecordResult = Result<"added" | "duplicate", EvidenceLedgerFailure>;
|
||||
@@ -321,7 +322,9 @@ export class EvidenceLedger {
|
||||
unknown.severity === filters.severity) &&
|
||||
(filters.domain === undefined || unknown.domain === filters.domain),
|
||||
)
|
||||
.sort((left, right) => left.unknown_id.localeCompare(right.unknown_id))
|
||||
.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.unknown_id, right.unknown_id),
|
||||
)
|
||||
.map((unknown) => structuredClone(unknown));
|
||||
}
|
||||
|
||||
|
||||
@@ -868,6 +868,365 @@ describe("package exports exact targets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("package import alias resolution", () => {
|
||||
it.each(["import", "require"] as const)(
|
||||
"resolves an exact #%s alias from the enclosing package imports map",
|
||||
(moduleKind) => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./config.cjs" },
|
||||
}),
|
||||
"main.js": "",
|
||||
"config.cjs": "",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind,
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "config.cjs",
|
||||
declared_path: "#config",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("resolves an alias from a nested file through the enclosing package root", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./config.cjs" },
|
||||
}),
|
||||
"src/main.js": "",
|
||||
"config.cjs": "",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "src/main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "config.cjs",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a nested package imports map rather than the application root", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./root.cjs" },
|
||||
}),
|
||||
"root.cjs": "",
|
||||
"node_modules/fixture/package.json": JSON.stringify({
|
||||
imports: { "#config": "./local.cjs" },
|
||||
}),
|
||||
"node_modules/fixture/index.js": "",
|
||||
"node_modules/fixture/local.cjs": "",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "node_modules/fixture/index.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "node_modules/fixture/local.cjs",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inherit an ancestor imports map across a nested package.json", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./root.cjs" },
|
||||
}),
|
||||
"root.cjs": "",
|
||||
"node_modules/fixture/package.json": "{}",
|
||||
"node_modules/fixture/index.js": "",
|
||||
});
|
||||
const outcome = resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "node_modules/fixture/index.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(outcome.limitations.join(" ")).toContain(
|
||||
"node_modules/fixture/package.json",
|
||||
);
|
||||
expect(outcome.limitations.join(" ")).not.toContain("root.cjs");
|
||||
});
|
||||
|
||||
it("does not apply a nested package map to the application root importer", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./root.cjs" },
|
||||
}),
|
||||
"main.js": "",
|
||||
"root.cjs": "",
|
||||
"node_modules/fixture/package.json": JSON.stringify({
|
||||
imports: { "#config": "./local.cjs" },
|
||||
}),
|
||||
"node_modules/fixture/local.cjs": "",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "root.cjs",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("package import alias target selection", () => {
|
||||
it("selects import and require conditions from an imports target", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: {
|
||||
"#config": {
|
||||
import: "./config.mjs",
|
||||
require: "./config.cjs",
|
||||
default: "./fallback.js",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"main.js": "",
|
||||
"config.mjs": "",
|
||||
"config.cjs": "",
|
||||
"fallback.js": "",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files,
|
||||
}).resolved_path,
|
||||
).toBe("config.mjs");
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
}).resolved_path,
|
||||
).toBe("config.cjs");
|
||||
});
|
||||
|
||||
it("resolves array import targets at their first string entry", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": [{ import: "./missing.mjs" }, "./config.cjs"] },
|
||||
}),
|
||||
"main.js": "",
|
||||
"config.cjs": "",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "config.cjs",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not add an extension or index to a selected import target", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./config" },
|
||||
}),
|
||||
"main.js": "",
|
||||
"config.cjs": "",
|
||||
"config/index.js": "",
|
||||
});
|
||||
const outcome = resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
resolution_status: "not-found",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(outcome.limitations.join(" ")).toContain(
|
||||
"import targets are exact files",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("package import alias refusals", () => {
|
||||
it("reports a missing exact alias without looking in another package", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./config.cjs" },
|
||||
}),
|
||||
"main.js": "",
|
||||
"config.cjs": "",
|
||||
});
|
||||
const outcome = resolve({
|
||||
declaredPath: "#other",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(outcome.limitations.join(" ")).toContain("#other");
|
||||
expect(outcome.limitations.join(" ")).toContain("#config");
|
||||
});
|
||||
|
||||
it("reports an explicit null import target as blocked", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": null },
|
||||
}),
|
||||
"main.js": "",
|
||||
"config.cjs": "",
|
||||
});
|
||||
const outcome = resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(outcome.limitations.join(" ")).toContain("explicit null");
|
||||
});
|
||||
|
||||
it("leaves wildcard import aliases unresolved", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#internal/*": "./src/*.js" },
|
||||
}),
|
||||
"main.js": "",
|
||||
"src/util.js": "",
|
||||
});
|
||||
const outcome = resolve({
|
||||
declaredPath: "#internal/util",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(outcome.limitations.join(" ")).toMatch(/wildcard/i);
|
||||
});
|
||||
|
||||
it("leaves recursive and external-package import targets unresolved", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#loop": "#config", "#dep": "fixture" },
|
||||
}),
|
||||
"main.js": "",
|
||||
"node_modules/fixture/index.js": "",
|
||||
});
|
||||
const recursive = resolve({
|
||||
declaredPath: "#loop",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
});
|
||||
const external = resolve({
|
||||
declaredPath: "#dep",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
});
|
||||
expect(recursive).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(recursive.limitations.join(" ")).toMatch(/recursive/i);
|
||||
expect(external).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(external.limitations.join(" ")).toMatch(/external-package/i);
|
||||
});
|
||||
|
||||
it("rejects reserved hash specifiers", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./config.cjs" },
|
||||
}),
|
||||
"main.js": "",
|
||||
"config.cjs": "",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({ resolution_status: "rejected", resolved_path: null });
|
||||
});
|
||||
|
||||
it("does not use a foreign-container enclosing package.json", () => {
|
||||
const files = fileMap([
|
||||
file(
|
||||
"package.json",
|
||||
"other",
|
||||
JSON.stringify({ imports: { "#config": "./config.cjs" } }),
|
||||
),
|
||||
file("main.js", "root"),
|
||||
file("config.cjs", "root"),
|
||||
]);
|
||||
const outcome = resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(outcome.limitations.join(" ")).toContain("package scopes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("directory package entrypoint precedence", () => {
|
||||
it.each([
|
||||
["require", '{"main":"actual.cjs"}', "actual.cjs"],
|
||||
@@ -1105,6 +1464,159 @@ describe("contextual JavaScript module identity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TypeScript NodeNext emitted-extension resolution", () => {
|
||||
const container = "container";
|
||||
|
||||
it.each([
|
||||
["./dep.js", "src/dep.ts"],
|
||||
["./ui/Button.js", "src/ui/Button.tsx"],
|
||||
["./cfg.mjs", "src/cfg.mts"],
|
||||
["./legacy.cjs", "src/legacy.cts"],
|
||||
] as const)(
|
||||
"resolves %s to its TypeScript source counterpart",
|
||||
(specifier, target) => {
|
||||
const files = fileMap([
|
||||
file("src/main.ts", container),
|
||||
file(target, container, "export const value = 1;"),
|
||||
]);
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: specifier,
|
||||
sourcePath: "src/main.ts",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({ resolution_status: "resolved", resolved_path: target });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["./dep.js", "src/dep.d.ts"],
|
||||
["./cfg.mjs", "src/cfg.d.mts"],
|
||||
["./legacy.cjs", "src/legacy.d.cts"],
|
||||
] as const)(
|
||||
"resolves %s to a declaration counterpart",
|
||||
(specifier, target) => {
|
||||
const files = fileMap([
|
||||
file("src/main.ts", container),
|
||||
file(target, container, "export const value = 1;"),
|
||||
]);
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: specifier,
|
||||
sourcePath: "src/main.ts",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({ resolution_status: "resolved", resolved_path: target });
|
||||
},
|
||||
);
|
||||
|
||||
it("prefers a present runtime file and keeps the fallback off for JS callers", () => {
|
||||
const files = fileMap([
|
||||
file("src/main.ts", container),
|
||||
file("src/dep.js", container),
|
||||
file("src/dep.ts", container),
|
||||
file("src/js-main.js", container),
|
||||
]);
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "./dep.js",
|
||||
sourcePath: "src/main.ts",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "src/dep.js",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "./dep.js",
|
||||
sourcePath: "src/js-main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files: fileMap([
|
||||
file("src/js-main.js", container),
|
||||
file("src/dep.ts", container),
|
||||
]),
|
||||
}),
|
||||
).toMatchObject({ resolution_status: "not-found", resolved_path: null });
|
||||
});
|
||||
|
||||
it("does not apply the mapping to URL resources or package export targets", () => {
|
||||
const urlFiles = fileMap([
|
||||
file("src/main.ts", container),
|
||||
file("src/asset.ts", container),
|
||||
]);
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "asset.js",
|
||||
sourcePath: "src/main.ts",
|
||||
context: "url-reference",
|
||||
files: urlFiles,
|
||||
}),
|
||||
).toMatchObject({ resolution_status: "not-found", resolved_path: null });
|
||||
|
||||
const packageFiles = fileMap([
|
||||
file("src/main.ts", container),
|
||||
file(
|
||||
"src/node_modules/fixture/package.json",
|
||||
container,
|
||||
'{"exports":"./dep.js"}',
|
||||
),
|
||||
file("src/node_modules/fixture/dep.ts", container),
|
||||
]);
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "fixture",
|
||||
sourcePath: "src/main.ts",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files: packageFiles,
|
||||
}),
|
||||
).toMatchObject({ resolution_status: "not-found", resolved_path: null });
|
||||
});
|
||||
|
||||
it("does not substitute source counterparts for directory or index resolution", () => {
|
||||
const files = fileMap([
|
||||
file("src/main.ts", container),
|
||||
file("src/lib/index.ts", container),
|
||||
]);
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "./lib.js",
|
||||
sourcePath: "src/main.ts",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({ resolution_status: "not-found", resolved_path: null });
|
||||
});
|
||||
|
||||
it("resolves an emitted dotfile name within its own directory", () => {
|
||||
const files = fileMap([
|
||||
file("src/main.ts", container),
|
||||
file("src/.ts", container),
|
||||
]);
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "./.js",
|
||||
sourcePath: "src/main.ts",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "src/.ts",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("url-reference JavaScript artifact path resolution", () => {
|
||||
it.each([
|
||||
"app%00.js.map",
|
||||
@@ -1211,6 +1723,15 @@ const file = (
|
||||
: { included: true, value: text },
|
||||
});
|
||||
|
||||
const aliasFiles = (
|
||||
entries: Readonly<Record<string, string | null>>,
|
||||
): ReturnType<typeof fileMap> =>
|
||||
fileMap(
|
||||
Object.entries(entries).map(([path, text]) =>
|
||||
file(path, "root", text ?? ""),
|
||||
),
|
||||
);
|
||||
|
||||
describe("exact HTML resource resolution", () => {
|
||||
it.each([
|
||||
["extension", ["app.js"]],
|
||||
|
||||
@@ -14,6 +14,12 @@ import {
|
||||
percentDecodeUrlPath,
|
||||
stripQueryAndFragment,
|
||||
} from "../../domain/artifactPathSyntax.js";
|
||||
import {
|
||||
classifyPackageImportSpecifier,
|
||||
classifyPackageImportTargetString,
|
||||
lookupExactPackageImport,
|
||||
} from "../../domain/javascript/javascriptPackageImportAliases.js";
|
||||
import { typescriptEmittedSourceCandidates } from "../TypeScriptEmittedSourceCandidates.js";
|
||||
|
||||
type ArtifactPathResolutionContext =
|
||||
| "package-entrypoint"
|
||||
@@ -54,6 +60,7 @@ export interface ResolveArtifactPathInput {
|
||||
readonly context: ArtifactPathResolutionContext;
|
||||
readonly files: ReadonlyMap<string, JavaScriptArtifactFile>;
|
||||
readonly htmlBaseHref?: string | null;
|
||||
/** Import/require semantics; runtime resource paths omit this provenance. */
|
||||
readonly moduleKind?: "import" | "require";
|
||||
}
|
||||
|
||||
@@ -68,6 +75,21 @@ const EXTENSIONS = [
|
||||
".node",
|
||||
];
|
||||
|
||||
// A private URL namespace for logical artifact paths, including root packages.
|
||||
// Encode inventory segments so filename punctuation remains literal in the base.
|
||||
const PACKAGE_ARTIFACT_ROOT_URL = new URL("file:///rea-artifact/");
|
||||
const packageMetadataUrl = (path: string): URL =>
|
||||
new URL(
|
||||
path.split("/").map(encodeURIComponent).join("/"),
|
||||
PACKAGE_ARTIFACT_ROOT_URL,
|
||||
);
|
||||
|
||||
interface PackageFileTarget {
|
||||
readonly value: string;
|
||||
/** Normalized URL retained until loader-specific validation and file lookup. */
|
||||
readonly url: URL;
|
||||
}
|
||||
|
||||
type CandidateResolution =
|
||||
| {
|
||||
readonly resolvedPath: string;
|
||||
@@ -103,6 +125,13 @@ export const resolveArtifactPathByContext = (
|
||||
};
|
||||
|
||||
const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => {
|
||||
// Package import keys are literal lookup identities, not artifact paths.
|
||||
// The selected target is admitted separately by resolveExportTarget.
|
||||
if (
|
||||
input.context === "module-specifier" &&
|
||||
input.declaredPath.startsWith("#")
|
||||
)
|
||||
return resolvePackageImportAlias(input, input.declaredPath);
|
||||
const rejected = rejectDeclaration(input.declaredPath);
|
||||
if (rejected !== null) return rejected;
|
||||
const candidate = contextualCandidate(input);
|
||||
@@ -116,7 +145,16 @@ const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => {
|
||||
`The exact HTML resource ${confined} was not found among the selected application files.`,
|
||||
])
|
||||
);
|
||||
return resolveCandidate(input, confined, new Set());
|
||||
return resolveCandidate(
|
||||
input,
|
||||
confined,
|
||||
new Set(),
|
||||
input.context === "module-specifier" &&
|
||||
input.moduleKind !== undefined &&
|
||||
(input.declaredPath.startsWith(".") || input.declaredPath.startsWith("/"))
|
||||
? typescriptEmittedSourceCandidates(input.sourcePath, confined)
|
||||
: [],
|
||||
);
|
||||
};
|
||||
|
||||
const rejectDeclaration = (declared: string): CandidateResolution | null => {
|
||||
@@ -202,9 +240,9 @@ const bareModuleCandidate = (
|
||||
declared: string,
|
||||
): CandidateResolution => {
|
||||
const packageName = barePackageName(declared);
|
||||
if (packageName === null || isBuiltin(declared) || declared.startsWith("#"))
|
||||
if (packageName === null || isBuiltin(declared))
|
||||
return unresolvedCandidate("external", [
|
||||
"The bare specifier is a Node builtin, package import map, or invalid package name.",
|
||||
"The bare specifier is a Node builtin or invalid package name.",
|
||||
]);
|
||||
if (input.moduleKind === "import" && packageName.includes("%"))
|
||||
return unresolvedCandidate("rejected", [
|
||||
@@ -235,6 +273,125 @@ const bareModuleCandidate = (
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Nearest same-container package.json, stopping at a `node_modules` directory
|
||||
* so a nested package cannot inherit an ancestor's map.
|
||||
*/
|
||||
const enclosingPackageMetadata = (
|
||||
input: ResolveArtifactPathInput,
|
||||
):
|
||||
| Extract<PackageMetadata, { readonly kind: "available" | "unavailable" }>
|
||||
| { readonly kind: "none" } => {
|
||||
let directory = posix.dirname(input.sourcePath);
|
||||
while (posix.basename(directory) !== "node_modules") {
|
||||
const metadata = readPackageMetadata(input, directory);
|
||||
if (metadata.kind !== "absent") return metadata;
|
||||
if (directory === "." || directory === "") break;
|
||||
directory = posix.dirname(directory);
|
||||
}
|
||||
return { kind: "none" };
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve an exact `#alias` against the importer's enclosing `imports` map.
|
||||
* Selected relative targets are exact files; patterns, recursive aliases, and
|
||||
* external-package targets stay unresolved without falling through.
|
||||
*/
|
||||
const resolvePackageImportAlias = (
|
||||
input: ResolveArtifactPathInput,
|
||||
specifier: string,
|
||||
): CandidateResolution => {
|
||||
const classified = classifyPackageImportSpecifier(specifier);
|
||||
if (classified.kind === "invalid")
|
||||
return unresolvedCandidate("rejected", [
|
||||
`The package import specifier ${JSON.stringify(specifier)} is invalid; "#" and specifiers that end with "/" are not valid import aliases.`,
|
||||
]);
|
||||
if (classified.kind === "pattern")
|
||||
return unresolvedCandidate("external", [
|
||||
`Wildcard and directory package import aliases remain unresolved; ${JSON.stringify(specifier)} is not an exact imports key.`,
|
||||
]);
|
||||
const metadata = enclosingPackageMetadata(input);
|
||||
if (metadata.kind === "none")
|
||||
return unresolvedCandidate("external", [
|
||||
`No enclosing package.json defines package import alias ${specifier}; package import maps do not cross package scopes.`,
|
||||
]);
|
||||
if (metadata.kind === "unavailable")
|
||||
return unresolvedCandidate("unavailable", [metadata.limitation]);
|
||||
const lookup = lookupExactPackageImport(metadata.imports, specifier);
|
||||
if (lookup.kind === "undefined-map")
|
||||
return unresolvedCandidate("external", [
|
||||
`Package metadata ${metadata.path} does not define package import alias ${specifier}.`,
|
||||
]);
|
||||
if (lookup.kind === "missing-key")
|
||||
return unresolvedCandidate("external", [
|
||||
`Package metadata ${metadata.path} does not define package import alias ${specifier}; it declares ${lookup.declared.join(", ") || "no imports keys"}.`,
|
||||
]);
|
||||
if (lookup.kind === "pattern-unresolved")
|
||||
return unresolvedCandidate("external", [
|
||||
`Wildcard package import aliases remain unresolved; package metadata ${metadata.path} has no exact imports key ${specifier}.`,
|
||||
]);
|
||||
const selected = selectPackageImportTarget(
|
||||
lookup.target,
|
||||
input.moduleKind,
|
||||
metadata.path,
|
||||
);
|
||||
if (selected.status === "rejected")
|
||||
return unresolvedCandidate("rejected", [
|
||||
`Package metadata ${metadata.path}: ${selected.limitation}`,
|
||||
]);
|
||||
if (selected.status === "unmatched")
|
||||
return unresolvedCandidate("external", [
|
||||
`Package metadata ${metadata.path} declares no imports target for alias ${specifier} under the active conditions; it declares ${selected.declared.join(", ") || "no conditions"}.`,
|
||||
]);
|
||||
if (selected.status === "blocked")
|
||||
return unresolvedCandidate("external", [
|
||||
`Package metadata ${metadata.path} blocks import alias ${specifier} with ${selected.cause === "null" ? "an explicit null imports target" : "an empty imports target array"}.`,
|
||||
]);
|
||||
if (selected.status === "non-relative") {
|
||||
const target = classifyPackageImportTargetString(selected.value);
|
||||
return unresolvedCandidate("external", [
|
||||
target.kind === "recursive"
|
||||
? `Recursive package import aliases remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`
|
||||
: `External-package import targets remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`,
|
||||
]);
|
||||
}
|
||||
return resolveExportTarget(input, metadata.path, selected, "imports");
|
||||
};
|
||||
|
||||
const selectPackageImportTarget = (
|
||||
value: unknown,
|
||||
moduleKind: ResolveArtifactPathInput["moduleKind"],
|
||||
packagePath: string,
|
||||
):
|
||||
| ({ readonly status: "value" } & PackageFileTarget)
|
||||
| { readonly status: "non-relative"; readonly value: string }
|
||||
| { readonly status: "blocked"; readonly cause: PackageTargetBlockCause }
|
||||
| { readonly status: "unmatched"; readonly declared: readonly string[] }
|
||||
| { readonly status: "rejected"; readonly limitation: string } => {
|
||||
const selected = selectExportTarget(
|
||||
value,
|
||||
packageExportConditions(moduleKind),
|
||||
packageMetadataUrl(packagePath),
|
||||
"imports",
|
||||
);
|
||||
if (selected.kind === "invalid" || selected.kind === "invalid-config")
|
||||
return { status: "rejected", limitation: selected.limitation };
|
||||
if (selected.kind === "unmatched")
|
||||
return {
|
||||
status: "unmatched",
|
||||
declared: selected.declared,
|
||||
};
|
||||
if (selected.kind === "blocked")
|
||||
return { status: "blocked", cause: selected.cause };
|
||||
if (selected.kind === "non-relative")
|
||||
return { status: "non-relative", value: selected.value };
|
||||
return {
|
||||
status: "value",
|
||||
value: selected.value,
|
||||
url: selected.url,
|
||||
};
|
||||
};
|
||||
|
||||
/** Self references use the nearest package scope before dependency lookup. */
|
||||
const commonJsSelfCandidate = (
|
||||
input: ResolveArtifactPathInput,
|
||||
@@ -242,35 +399,22 @@ const commonJsSelfCandidate = (
|
||||
): CandidateResolution | null => {
|
||||
if (input.moduleKind !== "require" || !input.files.has(input.sourcePath))
|
||||
return null;
|
||||
let directory = posix.dirname(input.sourcePath);
|
||||
while (posix.basename(directory) !== "node_modules") {
|
||||
const metadata = readPackageMetadata(input, directory);
|
||||
if (metadata.kind === "unavailable")
|
||||
return unresolvedCandidate("unavailable", [metadata.limitation]);
|
||||
if (metadata.kind === "available") {
|
||||
if (
|
||||
metadata.name === undefined ||
|
||||
metadata.exports === undefined ||
|
||||
(declared !== metadata.name &&
|
||||
!declared.startsWith(`${metadata.name}/`))
|
||||
)
|
||||
return null;
|
||||
const subpath =
|
||||
declared === metadata.name
|
||||
? "."
|
||||
: `.${declared.slice(metadata.name.length)}`;
|
||||
// Self resolution does not apply the CommonJS dependency-name matcher.
|
||||
return resolvePackageExports(
|
||||
input,
|
||||
metadata.path,
|
||||
subpath,
|
||||
metadata.exports,
|
||||
);
|
||||
}
|
||||
if (directory === "." || directory === "") break;
|
||||
directory = posix.dirname(directory);
|
||||
}
|
||||
return null;
|
||||
const metadata = enclosingPackageMetadata(input);
|
||||
if (metadata.kind === "none") return null;
|
||||
if (metadata.kind === "unavailable")
|
||||
return unresolvedCandidate("unavailable", [metadata.limitation]);
|
||||
if (
|
||||
metadata.name === undefined ||
|
||||
metadata.exports === undefined ||
|
||||
(declared !== metadata.name && !declared.startsWith(`${metadata.name}/`))
|
||||
)
|
||||
return null;
|
||||
const subpath =
|
||||
declared === metadata.name
|
||||
? "."
|
||||
: `.${declared.slice(metadata.name.length)}`;
|
||||
// Self resolution does not apply the CommonJS dependency-name matcher.
|
||||
return resolvePackageExports(input, metadata.path, subpath, metadata.exports);
|
||||
};
|
||||
|
||||
/** Package roots and exact subpaths share metadata and exports selection. */
|
||||
@@ -313,7 +457,12 @@ const resolvePackageExports = (
|
||||
subpath: string,
|
||||
rawExports: string | object,
|
||||
): CandidateResolution => {
|
||||
const exported = selectPackageExport(rawExports, subpath, input.moduleKind);
|
||||
const exported = selectPackageExport(
|
||||
rawExports,
|
||||
subpath,
|
||||
input.moduleKind,
|
||||
packagePath,
|
||||
);
|
||||
if (exported.status === "rejected")
|
||||
return unresolvedCandidate("rejected", [
|
||||
`Package metadata ${packagePath}: ${exported.limitation}`,
|
||||
@@ -324,26 +473,47 @@ const resolvePackageExports = (
|
||||
]);
|
||||
if (exported.status === "blocked")
|
||||
return unresolvedCandidate("external", [
|
||||
`Directory package metadata ${packagePath} blocks subpath ${subpath} with an explicit null exports target.`,
|
||||
`Directory package metadata ${packagePath} blocks subpath ${subpath} with ${exported.cause === "null" ? "an explicit null exports target" : "an empty exports target array"}.`,
|
||||
]);
|
||||
return resolveExportTarget(input, packagePath, exported.value);
|
||||
return resolveExportTarget(input, packagePath, exported);
|
||||
};
|
||||
|
||||
/** An exports target is a URL naming one exact file for both Node loaders. */
|
||||
/** An exports or import target is a URL naming one exact file for both Node loaders. */
|
||||
const resolveExportTarget = (
|
||||
input: ResolveArtifactPathInput,
|
||||
packagePath: string,
|
||||
target: string,
|
||||
target: PackageFileTarget,
|
||||
mapKind: "exports" | "imports" = "exports",
|
||||
): CandidateResolution => {
|
||||
const rejected = rejectDeclaration(target);
|
||||
if (rejected !== null) return rejected;
|
||||
const decoded = decodeModuleUrlPath(stripQueryAndFragment(target));
|
||||
if (typeof decoded !== "string") return decoded;
|
||||
const candidate = confineCandidate(relativeCandidate(packagePath, decoded));
|
||||
if (typeof candidate !== "string") return candidate;
|
||||
const withTargetSource = (
|
||||
outcome: CandidateResolution,
|
||||
): CandidateResolution => ({
|
||||
...outcome,
|
||||
limitations: outcome.limitations.map(
|
||||
(reason) =>
|
||||
`Package metadata ${packagePath}, ${mapKind} target ${JSON.stringify(target.value)}: ${reason}`,
|
||||
),
|
||||
});
|
||||
// Node's CommonJS finalizer checks the serialized URL, including suffixes;
|
||||
// its ESM finalizer checks only the pathname. Neither error retries arrays.
|
||||
if (input.moduleKind === "require" && /%(?:2f|5c)/iu.test(target.url.href))
|
||||
return withTargetSource(
|
||||
unresolvedCandidate("rejected", [
|
||||
"The selected package URL contains encoded separator bytes.",
|
||||
]),
|
||||
);
|
||||
const encodedPath = target.url.pathname.slice(
|
||||
PACKAGE_ARTIFACT_ROOT_URL.pathname.length,
|
||||
);
|
||||
const rejected = rejectDeclaration(encodedPath);
|
||||
if (rejected !== null) return withTargetSource(rejected);
|
||||
const decoded = decodeModuleUrlPath(encodedPath);
|
||||
if (typeof decoded !== "string") return withTargetSource(decoded);
|
||||
const candidate = confineCandidate(decoded);
|
||||
if (typeof candidate !== "string") return withTargetSource(candidate);
|
||||
return (
|
||||
resolveFileCandidates(input, [candidate]) ??
|
||||
exactExportsTargetNotFound(candidate)
|
||||
withTargetSource(exactPackageTargetNotFound(candidate, mapKind))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -492,6 +662,7 @@ type PackageMetadata =
|
||||
readonly path: string;
|
||||
readonly name: string | undefined;
|
||||
readonly exports: string | object | undefined;
|
||||
readonly imports: object | undefined;
|
||||
readonly main: unknown;
|
||||
readonly module: unknown;
|
||||
}
|
||||
@@ -532,6 +703,7 @@ const readPackageMetadata = (
|
||||
};
|
||||
const name = Reflect.get(value, "name");
|
||||
const exports = Reflect.get(value, "exports");
|
||||
const imports = Reflect.get(value, "imports");
|
||||
return {
|
||||
kind: "available",
|
||||
path,
|
||||
@@ -543,6 +715,11 @@ const readPackageMetadata = (
|
||||
(typeof exports === "object" && exports !== null)
|
||||
? exports
|
||||
: undefined,
|
||||
// Import maps must be non-array objects; other shapes are not a map.
|
||||
imports:
|
||||
typeof imports === "object" && imports !== null && !Array.isArray(imports)
|
||||
? imports
|
||||
: undefined,
|
||||
main: Reflect.get(value, "main"),
|
||||
module: Reflect.get(value, "module"),
|
||||
};
|
||||
@@ -553,8 +730,12 @@ const resolveCandidate = (
|
||||
input: ResolveArtifactPathInput,
|
||||
candidate: string,
|
||||
packageChain: ReadonlySet<string>,
|
||||
emittedSourceCandidates: readonly string[] = [],
|
||||
): CandidateResolution =>
|
||||
resolveFileCandidates(input, fileCandidates(candidate)) ??
|
||||
resolveFileCandidates(input, [
|
||||
...fileCandidates(candidate),
|
||||
...emittedSourceCandidates,
|
||||
]) ??
|
||||
resolveDirectory(
|
||||
input,
|
||||
candidate,
|
||||
@@ -644,10 +825,12 @@ const fileCandidates = (candidate: string): readonly string[] => [
|
||||
const indexCandidates = (candidate: string): readonly string[] =>
|
||||
EXTENSIONS.map((extension) => posix.join(candidate, `index${extension}`));
|
||||
|
||||
type PackageTargetBlockCause = "null" | "empty-array";
|
||||
|
||||
type PackageExportOutcome =
|
||||
| { readonly status: "value"; readonly value: string }
|
||||
| ({ readonly status: "value" } & PackageFileTarget)
|
||||
| { readonly status: "rejected"; readonly limitation: string }
|
||||
| { readonly status: "blocked" }
|
||||
| { readonly status: "blocked"; readonly cause: PackageTargetBlockCause }
|
||||
| { readonly status: "unmatched"; readonly declared: readonly string[] };
|
||||
|
||||
/** Only exact non-directory keys participate; wildcard expansion is unsupported. */
|
||||
@@ -655,6 +838,7 @@ const selectPackageExport = (
|
||||
value: unknown,
|
||||
subpath: string,
|
||||
moduleKind: ResolveArtifactPathInput["moduleKind"],
|
||||
packagePath: string,
|
||||
): PackageExportOutcome => {
|
||||
let target = value;
|
||||
let declared: string[] = [];
|
||||
@@ -681,35 +865,44 @@ const selectPackageExport = (
|
||||
const selected = selectExportTarget(
|
||||
target,
|
||||
packageExportConditions(moduleKind),
|
||||
packageMetadataUrl(packagePath),
|
||||
);
|
||||
if (selected.kind === "invalid" || selected.kind === "invalid-config")
|
||||
return { status: "rejected", limitation: selected.limitation };
|
||||
if (selected.kind === "unmatched")
|
||||
return {
|
||||
status: "unmatched",
|
||||
declared:
|
||||
typeof target === "object" && target !== null
|
||||
? Object.keys(target)
|
||||
: declared,
|
||||
declared: selected.declared,
|
||||
};
|
||||
return selected.kind === "blocked"
|
||||
? { status: "blocked" }
|
||||
: { status: "value", value: selected.value };
|
||||
if (selected.kind === "blocked")
|
||||
return { status: "blocked", cause: selected.cause };
|
||||
if (selected.kind === "non-relative")
|
||||
return {
|
||||
status: "rejected",
|
||||
limitation: `The package exports target ${JSON.stringify(selected.value)} must start with "./".`,
|
||||
};
|
||||
return {
|
||||
status: "value",
|
||||
value: selected.value,
|
||||
url: selected.url,
|
||||
};
|
||||
};
|
||||
|
||||
type ExportTargetOutcome =
|
||||
| { readonly kind: "value"; readonly value: string }
|
||||
| { readonly kind: "blocked" }
|
||||
| { readonly kind: "unmatched" }
|
||||
| ({ readonly kind: "value" } & PackageFileTarget)
|
||||
| { readonly kind: "non-relative"; readonly value: string }
|
||||
| { readonly kind: "blocked"; readonly cause: PackageTargetBlockCause }
|
||||
| { readonly kind: "unmatched"; readonly declared: readonly string[] }
|
||||
| { readonly kind: "invalid"; readonly limitation: string }
|
||||
| { readonly kind: "invalid-config"; readonly limitation: string };
|
||||
|
||||
const invalidExportTarget = (
|
||||
value: unknown,
|
||||
reason: string,
|
||||
mapKind: "exports" | "imports" = "exports",
|
||||
): ExportTargetOutcome => ({
|
||||
kind: "invalid",
|
||||
limitation: `The package exports target ${JSON.stringify(value) ?? String(value)} ${reason}.`,
|
||||
limitation: `The package ${mapKind === "imports" ? "import" : "exports"} target ${JSON.stringify(value) ?? String(value)} ${reason}.`,
|
||||
});
|
||||
|
||||
// Node checks raw target segments before URL normalization. Compare encoded
|
||||
@@ -729,50 +922,97 @@ const forbiddenExportSegment = (value: string): string | undefined =>
|
||||
);
|
||||
});
|
||||
|
||||
const selectStringPackageTarget = (
|
||||
value: string,
|
||||
packageUrl: URL,
|
||||
mapKind: "exports" | "imports",
|
||||
): ExportTargetOutcome => {
|
||||
if (mapKind === "imports") {
|
||||
const target = classifyPackageImportTargetString(value);
|
||||
if (target.kind === "invalid")
|
||||
return invalidExportTarget(
|
||||
value,
|
||||
"cannot be parent-relative, absolute, or a URL",
|
||||
mapKind,
|
||||
);
|
||||
if (target.kind !== "relative") return { kind: "non-relative", value };
|
||||
} else if (!value.startsWith("./"))
|
||||
return invalidExportTarget(value, 'must start with "./"', mapKind);
|
||||
const forbidden = forbiddenExportSegment(value);
|
||||
if (forbidden !== undefined)
|
||||
return invalidExportTarget(
|
||||
value,
|
||||
`contains forbidden path segment ${JSON.stringify(forbidden)} before URL normalization`,
|
||||
mapKind,
|
||||
);
|
||||
// Node constructs the URL before deciding whether this array entry is an
|
||||
// invalid package target. Tabs/newlines and dot segments normalize here;
|
||||
// percent decoding and existence checks belong after target selection.
|
||||
const url = new URL(value, packageUrl);
|
||||
if (!url.pathname.startsWith(new URL(".", packageUrl).pathname))
|
||||
return invalidExportTarget(
|
||||
value,
|
||||
"escapes the package root after URL normalization",
|
||||
mapKind,
|
||||
);
|
||||
return {
|
||||
kind: "value",
|
||||
value,
|
||||
url,
|
||||
};
|
||||
};
|
||||
|
||||
const selectArrayPackageTarget = (
|
||||
value: readonly unknown[],
|
||||
conditions: ReadonlySet<string>,
|
||||
packageUrl: URL,
|
||||
mapKind: "exports" | "imports",
|
||||
): ExportTargetOutcome => {
|
||||
if (value.length === 0) return { kind: "blocked", cause: "empty-array" };
|
||||
let lastRefusal:
|
||||
| Extract<ExportTargetOutcome, { readonly kind: "invalid" | "blocked" }>
|
||||
| undefined;
|
||||
const declared = new Set<string>();
|
||||
for (const entry of value) {
|
||||
const nested = selectExportTarget(entry, conditions, packageUrl, mapKind);
|
||||
// Node's array fallback catches invalid targets, not invalid configuration.
|
||||
if (nested.kind === "invalid-config") return nested;
|
||||
if (nested.kind === "unmatched") {
|
||||
for (const condition of nested.declared) declared.add(condition);
|
||||
continue;
|
||||
}
|
||||
if (nested.kind === "invalid" || nested.kind === "blocked") {
|
||||
lastRefusal = nested;
|
||||
continue;
|
||||
}
|
||||
return nested;
|
||||
}
|
||||
return lastRefusal ?? { kind: "unmatched", declared: [...declared] };
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a target in Node's declared order. Explicit null blocks an active
|
||||
* condition, whereas an unmatched nested object permits the next condition.
|
||||
* An array skips unmatched and invalid entries, stops at its first string, and
|
||||
* preserves the final invalid-versus-null refusal if no string is selected.
|
||||
* preserves the last invalid/null outcome if no string is selected. A nonempty
|
||||
* array of unmatched conditions stays unmatched; an empty array blocks.
|
||||
*/
|
||||
const selectExportTarget = (
|
||||
value: unknown,
|
||||
conditions: ReadonlySet<string>,
|
||||
packageUrl: URL,
|
||||
mapKind: "exports" | "imports" = "exports",
|
||||
): ExportTargetOutcome => {
|
||||
if (typeof value === "string") {
|
||||
if (!value.startsWith("./"))
|
||||
return invalidExportTarget(value, 'must start with "./"');
|
||||
const forbidden = forbiddenExportSegment(value);
|
||||
return forbidden === undefined
|
||||
? { kind: "value", value }
|
||||
: invalidExportTarget(
|
||||
value,
|
||||
`contains forbidden path segment ${JSON.stringify(forbidden)} before URL normalization`,
|
||||
);
|
||||
}
|
||||
if (value === null) return { kind: "blocked" };
|
||||
if (Array.isArray(value)) {
|
||||
let invalid: string | null = null;
|
||||
for (const entry of value) {
|
||||
const nested = selectExportTarget(entry, conditions);
|
||||
// Node's array fallback catches invalid targets, not invalid configuration.
|
||||
if (nested.kind === "invalid-config") return nested;
|
||||
if (nested.kind === "invalid") {
|
||||
invalid = nested.limitation;
|
||||
continue;
|
||||
}
|
||||
if (nested.kind === "unmatched") continue;
|
||||
invalid = null;
|
||||
if (nested.kind === "value") return nested;
|
||||
}
|
||||
return invalid === null
|
||||
? { kind: "blocked" }
|
||||
: { kind: "invalid", limitation: invalid };
|
||||
}
|
||||
if (typeof value === "string")
|
||||
return selectStringPackageTarget(value, packageUrl, mapKind);
|
||||
if (value === null) return { kind: "blocked", cause: "null" };
|
||||
if (Array.isArray(value))
|
||||
return selectArrayPackageTarget(value, conditions, packageUrl, mapKind);
|
||||
if (typeof value !== "object")
|
||||
return invalidExportTarget(
|
||||
value,
|
||||
"must be a relative string, object, array, or null",
|
||||
mapKind,
|
||||
);
|
||||
for (const key of Object.keys(value)) {
|
||||
const numeric = Number(key);
|
||||
@@ -782,12 +1022,15 @@ const selectExportTarget = (
|
||||
limitation: `The package exports configuration contains numeric condition key ${JSON.stringify(key)}.`,
|
||||
};
|
||||
}
|
||||
const declared = new Set<string>();
|
||||
for (const [condition, target] of Object.entries(value)) {
|
||||
declared.add(condition);
|
||||
if (!conditions.has(condition)) continue;
|
||||
const nested = selectExportTarget(target, conditions);
|
||||
const nested = selectExportTarget(target, conditions, packageUrl, mapKind);
|
||||
if (nested.kind !== "unmatched") return nested;
|
||||
for (const name of nested.declared) declared.add(name);
|
||||
}
|
||||
return { kind: "unmatched" };
|
||||
return { kind: "unmatched", declared: [...declared] };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -816,11 +1059,14 @@ const notFoundCandidate = (): CandidateResolution => ({
|
||||
],
|
||||
});
|
||||
|
||||
const exactExportsTargetNotFound = (path: string): CandidateResolution => ({
|
||||
const exactPackageTargetNotFound = (
|
||||
path: string,
|
||||
mapKind: "exports" | "imports",
|
||||
): CandidateResolution => ({
|
||||
resolvedPath: null,
|
||||
status: "not-found",
|
||||
limitations: [
|
||||
`The selected package exports target ${path} does not exist in the inventoried artifact container; exports targets are exact files, so no extension or directory index was tried.`,
|
||||
`The selected package ${mapKind === "imports" ? "import" : "exports"} target ${path} does not exist in the inventoried artifact container; ${mapKind === "imports" ? "import" : "exports"} targets are exact files, so no extension or directory index was tried.`,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { orderAsarListing } from "./AsarArtifactReader.js";
|
||||
|
||||
describe("orderAsarListing", () => {
|
||||
it("orders host-spelled listings by their portable archive path", () => {
|
||||
const posix = orderAsarListing(["/a0", "/a", "/a/b"], "/");
|
||||
const windows = orderAsarListing(["\\a0", "\\a", "\\a\\b"], "\\");
|
||||
|
||||
// "/" (0x2F) precedes "0" (0x30); "\" (0x5C) would follow it.
|
||||
expect(posix.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]);
|
||||
expect(windows.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]);
|
||||
expect(windows.map(({ listed }) => listed)).toEqual([
|
||||
"\\a",
|
||||
"\\a\\b",
|
||||
"\\a0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps POSIX backslashes as literal filename characters", () => {
|
||||
expect(orderAsarListing(["/a\\b"], "/")).toEqual([
|
||||
{ listed: "/a\\b", path: "a\\b" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js";
|
||||
import { admitAsarHeader } from "./AsarHeader.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/**
|
||||
* Official Electron ASAR adapter with range-streamed member reads.
|
||||
@@ -64,7 +65,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
) {}
|
||||
|
||||
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
|
||||
let paths: string[];
|
||||
let paths: readonly AsarListedPath[];
|
||||
try {
|
||||
await this.prepareContainer(undefined, signal);
|
||||
const snapshotPath = this.#snapshotPath();
|
||||
@@ -82,9 +83,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
await headerHandle.close();
|
||||
}
|
||||
abortIfNeeded(signal);
|
||||
paths = listPackage(snapshotPath, { isPack: false }).sort((left, right) =>
|
||||
left.localeCompare(right, "en"),
|
||||
);
|
||||
paths = orderAsarListing(listPackage(snapshotPath, { isPack: false }));
|
||||
const archiveMetadata = await lstat(snapshotPath);
|
||||
if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink())
|
||||
throw new ArtifactReaderFailure(
|
||||
@@ -95,13 +94,8 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
} catch (cause: unknown) {
|
||||
throw asarFailure(this.path, "inventory", cause);
|
||||
}
|
||||
for (const listed of paths) {
|
||||
for (const { listed, path } of paths) {
|
||||
abortIfNeeded(signal);
|
||||
// @electron/asar returns paths assembled with the host path module.
|
||||
// Keep that spelling for its API calls, but expose archive paths with
|
||||
// portable separators. On POSIX, backslashes can be literal filename
|
||||
// characters and must remain untouched.
|
||||
const path = toArtifactPath(listed);
|
||||
if (path.length === 0) continue;
|
||||
const providerPath = listed.startsWith(sep)
|
||||
? listed.slice(sep.length)
|
||||
@@ -577,8 +571,30 @@ const waitForOperation = async (
|
||||
});
|
||||
};
|
||||
|
||||
const toArtifactPath = (listed: string): string => {
|
||||
const portable = sep === "\\" ? listed.replaceAll("\\", "/") : listed;
|
||||
interface AsarListedPath {
|
||||
readonly listed: string;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair each host-spelled listing with its portable archive path, ordered by
|
||||
* the portable path so Windows and POSIX hosts agree.
|
||||
*
|
||||
* @electron/asar returns paths assembled with the host path module. Keep that
|
||||
* spelling for its API calls, but expose archive paths with portable
|
||||
* separators. On POSIX, backslashes can be literal filename characters and
|
||||
* must remain untouched.
|
||||
*/
|
||||
export const orderAsarListing = (
|
||||
listed: readonly string[],
|
||||
separator: string = sep,
|
||||
): AsarListedPath[] =>
|
||||
listed
|
||||
.map((entry) => ({ listed: entry, path: toArtifactPath(entry, separator) }))
|
||||
.sort((left, right) => compareUnicodeCodePoints(left.path, right.path));
|
||||
|
||||
const toArtifactPath = (listed: string, separator: string): string => {
|
||||
const portable = separator === "\\" ? listed.replaceAll("\\", "/") : listed;
|
||||
return portable.replace(/^\/+|\/+$/gu, "");
|
||||
};
|
||||
|
||||
|
||||
@@ -526,12 +526,16 @@ const CPU_TYPE_X86 = 7;
|
||||
const CPU_TYPE_ARM = 12;
|
||||
const CPU_SUBTYPE_MASK = 0xff000000;
|
||||
const CPU_SUBTYPE_ARM64E = 2;
|
||||
const CPU_SUBTYPE_ARM64E_X1 = 12;
|
||||
const CPU_SUBTYPE_X86_64_H = 8;
|
||||
|
||||
const architectureName = (cpuType: number, cpuSubtype: number): string => {
|
||||
const subtype = cpuSubtype & ~CPU_SUBTYPE_MASK;
|
||||
if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64))
|
||||
return subtype === CPU_SUBTYPE_ARM64E ? "arm64e" : "arm64";
|
||||
// Both pointer-authentication subtypes belong to the arm64e family.
|
||||
return subtype === CPU_SUBTYPE_ARM64E || subtype === CPU_SUBTYPE_ARM64E_X1
|
||||
? "arm64e"
|
||||
: "arm64";
|
||||
if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64_32)) return "arm64_32";
|
||||
if (cpuType === (CPU_TYPE_X86 | CPU_ARCH_ABI64))
|
||||
return subtype === CPU_SUBTYPE_X86_64_H ? "x86_64h" : "x86_64";
|
||||
|
||||
@@ -13,6 +13,16 @@ describe("hostExecutableMarking", () => {
|
||||
expect(
|
||||
hostExecutableMarking("C:\\scratch\\SETUP.CMD", file(0o444), "win32"),
|
||||
).toBe(true);
|
||||
for (const path of [
|
||||
"tools/SETUP.EXE",
|
||||
"run.Cmd",
|
||||
"build.bat",
|
||||
"legacy.com",
|
||||
])
|
||||
expect(hostExecutableMarking(path, file(0o666), "win32")).toBe(true);
|
||||
expect(hostExecutableMarking("readonly.exe", file(0o444), "win32")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
hostExecutableMarking("C:\\scratch\\library.dll", file(0o666), "win32"),
|
||||
).toBe(false);
|
||||
@@ -25,6 +35,11 @@ describe("hostExecutableMarking", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat other Windows filenames as executable even with execute bits", () => {
|
||||
for (const path of ["notes.txt", "README", "archive.exe.bak"])
|
||||
expect(hostExecutableMarking(path, file(0o777), "win32")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps POSIX permission bits authoritative regardless of extension", () => {
|
||||
expect(hostExecutableMarking("/scratch/tool", file(0o755), "linux")).toBe(
|
||||
true,
|
||||
|
||||
@@ -68,6 +68,7 @@ import { captureCdpScreenshot } from "./CdpScreenshot.js";
|
||||
import { comparePngScreenshots } from "./PngVisualDiff.js";
|
||||
|
||||
import { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
const CLEANUP_DOMAINS = ["Network", "Debugger", "Runtime", "Page"] as const;
|
||||
|
||||
/** Passive browser observation through a selected user-owned CDP endpoint. */
|
||||
@@ -408,7 +409,9 @@ const selectTargets = (
|
||||
attached: target.attached,
|
||||
});
|
||||
}
|
||||
allowed.sort((left, right) => left.target_id.localeCompare(right.target_id));
|
||||
allowed.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.target_id, right.target_id),
|
||||
);
|
||||
return {
|
||||
allowed,
|
||||
excluded: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js";
|
||||
import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { createDocumentSvgAncestry, domSvgLink } from "./CdpCaptureDomSvg.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
export type CapturedResource = Omit<
|
||||
WebPageInspection["resources"][number],
|
||||
@@ -346,7 +347,7 @@ const accessibilityStates = (
|
||||
if (state !== undefined) states.set(name, state);
|
||||
}
|
||||
return [...states]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
|
||||
.map(([name, state]) => ({ name, value: state }));
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { captureElectronScripts } from "./CdpElectronScripts.js";
|
||||
import { captureElectronWorkers } from "./CdpElectronWorkers.js";
|
||||
import { authorizedElectronFile } from "./ElectronFileScope.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
interface ElectronContext {
|
||||
readonly connection: CdpConnection;
|
||||
@@ -300,7 +301,7 @@ const captureResources = async (
|
||||
pending.push(...recordsValue(tree.childFrames));
|
||||
}
|
||||
return resources.sort((left, right) =>
|
||||
left.resource_key.localeCompare(right.resource_key),
|
||||
compareUnicodeCodePoints(left.resource_key, right.resource_key),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { inspectCdpElectronPage } from "./CdpElectronInspection.js";
|
||||
import { authorizedElectronFile } from "./ElectronFileScope.js";
|
||||
|
||||
import { CDP_ELECTRON_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
/** Passive Electron provider for local file pages exposed by loopback CDP. */
|
||||
export class CdpElectronProvider implements ElectronObservationPort {
|
||||
identity(): ProviderIdentity {
|
||||
@@ -73,7 +74,7 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
||||
});
|
||||
}
|
||||
allowed.sort((left, right) =>
|
||||
left.target_id.localeCompare(right.target_id),
|
||||
compareUnicodeCodePoints(left.target_id, right.target_id),
|
||||
);
|
||||
return ok(
|
||||
electronTargetListSchema.parse({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { requiredScriptSource } from "./CdpCaptureValues.js";
|
||||
import type { ElectronScriptDraft } from "./CdpElectronScriptEvents.js";
|
||||
import { authorizedElectronFile } from "./ElectronFileScope.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
interface ElectronScriptCaptureInput {
|
||||
readonly connection: CdpConnection;
|
||||
@@ -65,7 +66,7 @@ export const captureElectronScripts = async (
|
||||
return {
|
||||
total,
|
||||
items: items.sort((left, right) =>
|
||||
left.script_key.localeCompare(right.script_key),
|
||||
compareUnicodeCodePoints(left.script_key, right.script_key),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
|
||||
import { authorizedElectronFile } from "./ElectronFileScope.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Inventory local worker targets without attaching to them. */
|
||||
export const captureElectronWorkers = async (input: {
|
||||
@@ -70,7 +71,7 @@ export const captureElectronWorkers = async (input: {
|
||||
});
|
||||
}
|
||||
return workers.sort((left, right) =>
|
||||
left.target_id.localeCompare(right.target_id),
|
||||
compareUnicodeCodePoints(left.target_id, right.target_id),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "./CdpCaptureValues.js";
|
||||
import type { CaptureContext } from "./CdpPageCapture.js";
|
||||
import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
export interface CapturedScripts {
|
||||
readonly inventory: WebPageInspection["scripts"];
|
||||
@@ -131,11 +132,15 @@ const keyAndBuildInventory = (
|
||||
}));
|
||||
keyed.sort(
|
||||
(left, right) =>
|
||||
left.base.localeCompare(right.base) ||
|
||||
sourceDigest(left.item.source).localeCompare(
|
||||
compareUnicodeCodePoints(left.base, right.base) ||
|
||||
compareUnicodeCodePoints(
|
||||
sourceDigest(left.item.source),
|
||||
sourceDigest(right.item.source),
|
||||
) ||
|
||||
(left.item.frame_id ?? "").localeCompare(right.item.frame_id ?? ""),
|
||||
compareUnicodeCodePoints(
|
||||
left.item.frame_id ?? "",
|
||||
right.item.frame_id ?? "",
|
||||
),
|
||||
);
|
||||
const totals = new Map<string, number>();
|
||||
for (const { base } of keyed) totals.set(base, (totals.get(base) ?? 0) + 1);
|
||||
|
||||
@@ -7,7 +7,10 @@ import type {
|
||||
} from "../domain/webMcpDiscovery.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { digestCanonicalValue } from "../domain/canonicalDigest.js";
|
||||
import { compositeKey } from "../domain/unicodeCodePointOrder.js";
|
||||
import {
|
||||
compareUnicodeCodePoints,
|
||||
compositeKey,
|
||||
} from "../domain/unicodeCodePointOrder.js";
|
||||
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import type { CdpConnection, CdpEvent } from "./CdpConnection.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
@@ -521,7 +524,7 @@ const buildWebMcpResult = (options: WebMcpResultOptions): WebMcpDiscovery => {
|
||||
tools: {
|
||||
total: tools.size + completeness.droppedTotal,
|
||||
items: [...tools.values()].sort((left, right) =>
|
||||
left.tool_key.localeCompare(right.tool_key),
|
||||
compareUnicodeCodePoints(left.tool_key, right.tool_key),
|
||||
),
|
||||
},
|
||||
completeness: completeness.snapshot(),
|
||||
|
||||
@@ -6,9 +6,10 @@ import { z } from "zod";
|
||||
import { PROMPT_CONTRACTS } from "./contracts/promptContracts.js";
|
||||
import { TOOL_CONTRACTS } from "./contracts/toolContracts.js";
|
||||
import { CLI_COMMAND_NAMES } from "./cliCommandNames.js";
|
||||
import { compareUnicodeCodePoints } from "./domain/unicodeCodePointOrder.js";
|
||||
|
||||
const sortedToolContracts = [...TOOL_CONTRACTS].sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
compareUnicodeCodePoints(left.name, right.name),
|
||||
);
|
||||
|
||||
const createToolCatalog = () =>
|
||||
@@ -39,7 +40,7 @@ const promptCatalog = PROMPT_CONTRACTS.map((contract) => ({
|
||||
description: contract.description,
|
||||
arguments: contract.arguments,
|
||||
steps: contract.steps,
|
||||
})).sort((left, right) => left.name.localeCompare(right.name));
|
||||
})).sort((left, right) => compareUnicodeCodePoints(left.name, right.name));
|
||||
|
||||
const digest = (value: unknown): string => {
|
||||
const encoded = canonicalize(value);
|
||||
|
||||
@@ -263,6 +263,9 @@ const setupRunOptions = (
|
||||
? {
|
||||
readinessScope: {
|
||||
clients: options.client,
|
||||
...(options.skillClient.length > 0
|
||||
? { skillClients: options.skillClient }
|
||||
: {}),
|
||||
providers: options.installHopper ? ["hopper"] : [],
|
||||
skill: options.skill ?? agentIntegrationSelected,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TOOL_CONTRACTS } from "./toolContracts.js";
|
||||
import { TOOL_EFFECTS, annotationsFromEffects } from "./toolEffects.js";
|
||||
|
||||
/**
|
||||
* Descriptions that name running, launching, executing, starting, installing,
|
||||
* or uninstalling something. A matching tool must declare foreign execution or
|
||||
* device mutation unless it is listed below with the reason the wording does
|
||||
* not describe that effect.
|
||||
*/
|
||||
const EXECUTION_WORDING =
|
||||
/\b(?:run|runs|running|launch|launches|launching|execute|executes|executing|execution|start|starts|install|installs|uninstall|uninstalls)\b/iu;
|
||||
|
||||
const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
|
||||
inspect_native_load_image: "states that it does not execute the target",
|
||||
resolve_native_call_targets: "states that it does not execute code",
|
||||
inspect_pe_resources: "states that it does not execute the image",
|
||||
inspect_binary_layout:
|
||||
"parses the binary offline with operator-configured pwntools",
|
||||
inspect_recorded_crash: "inspects a core file without launching its target",
|
||||
inspect_macho: "runs host Mach-O inspection tools, not the target",
|
||||
inspect_evm_interface:
|
||||
"decodes bytecode in REA's bundled worker without executing it",
|
||||
inspect_wasm_artifact:
|
||||
"WABT validates and decompiles the module without executing it",
|
||||
inspect_managed_artifact: "reads metadata without executing target code",
|
||||
inspect_managed_members: "reads metadata without executing target code",
|
||||
inspect_managed_native_boundaries:
|
||||
"reads metadata without executing target code",
|
||||
verify_managed_native_boundaries: "states it does not execute managed code",
|
||||
project_managed_application_graph: "states it does not execute managed code",
|
||||
inspect_android_package: "states it does not execute the APK",
|
||||
search_android_classes: "states it does not execute the APK",
|
||||
inspect_apktool_client:
|
||||
"inspects the operator-selected apktool launcher; never installs it",
|
||||
inspect_jeb_client: "talks to a caller-started JEB; never launches it",
|
||||
open_jeb_project: "the engine analyzes the file; REA does not execute it",
|
||||
inspect_adb_client:
|
||||
"inspects the operator-selected adb client; never starts its server",
|
||||
list_adb_devices: "may start the local adb server, which REA does not own",
|
||||
inspect_firmware_regions: "states there is no target execution",
|
||||
extract_firmware: "states that extracted code is never executed",
|
||||
analyze_javascript_application: "reconstructs the graph without executing",
|
||||
reconcile_javascript_runtime: "never invents execution claims",
|
||||
observe_javascript_runtime: "never evaluates or resumes the target",
|
||||
trace_application_feature: "never executes the application",
|
||||
trace_javascript_semantics: "static reachability never claims execution",
|
||||
compare_javascript_export_shapes: "states it does not execute JavaScript",
|
||||
project_android_application_graph: "execution-free inventory",
|
||||
project_apple_application_graph: "execution-free inventory",
|
||||
project_harmony_application_graph: "execution-free inventory",
|
||||
trace_web_source_location: "states there is no application execution",
|
||||
trace_web_module_imports:
|
||||
"runs only REA's resolver stub in operator-configured Chromium",
|
||||
export_web_scripts: "states there is no refetch or execution",
|
||||
observe_web_execution:
|
||||
"instruments an externally driven page; it does not run code itself",
|
||||
recover_javascript_sources: "states there is no application code execution",
|
||||
goto_address: "start names an object boundary, not a launch",
|
||||
prev_address: "start names an object boundary, not a launch",
|
||||
trace_call_path: "start names the traversal origin",
|
||||
observe_native_ui: "observes an already-running app without launching it",
|
||||
inspect_adb_package: "reports install times without changing the device",
|
||||
list_adb_processes: "lists running processes without changing the device",
|
||||
inspect_web_event_listeners: "states it does not evaluate JavaScript",
|
||||
};
|
||||
|
||||
const declaresForeignAuthority = (name: string): boolean => {
|
||||
const effects = TOOL_EFFECTS[name];
|
||||
return (
|
||||
effects !== undefined &&
|
||||
(effects.executesForeignCode || effects.mutatesDevice)
|
||||
);
|
||||
};
|
||||
|
||||
describe("tool effects", () => {
|
||||
it("audits every public tool exactly once", () => {
|
||||
expect(Object.keys(TOOL_EFFECTS).sort()).toEqual(
|
||||
TOOL_CONTRACTS.map(({ name }) => name).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps execution and device wording aligned with declared effects", () => {
|
||||
const undeclared = TOOL_CONTRACTS.filter(
|
||||
({ name, description }) =>
|
||||
EXECUTION_WORDING.test(description) &&
|
||||
!declaresForeignAuthority(name) &&
|
||||
EXECUTION_WORDING_EXCEPTIONS[name] === undefined,
|
||||
).map(({ name }) => name);
|
||||
expect(undeclared).toEqual([]);
|
||||
const staleExceptions = Object.keys(EXECUTION_WORDING_EXCEPTIONS).filter(
|
||||
(name) => {
|
||||
const contract = TOOL_CONTRACTS.find(
|
||||
(candidate) => candidate.name === name,
|
||||
);
|
||||
return (
|
||||
contract === undefined ||
|
||||
!EXECUTION_WORDING.test(contract.description) ||
|
||||
declaresForeignAuthority(name)
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(staleExceptions).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks foreign execution and device mutation as destructive", () => {
|
||||
const destructive = TOOL_CONTRACTS.filter(
|
||||
({ effects }) => effects.executesForeignCode || effects.mutatesDevice,
|
||||
);
|
||||
expect(destructive.map(({ name }) => name).sort()).toEqual([
|
||||
"capture_browser_scenario",
|
||||
"capture_electron_scenario",
|
||||
"capture_native_ui_scenario",
|
||||
"capture_process_scenario",
|
||||
"install_adb_package",
|
||||
"observe_native_calls",
|
||||
"push_adb_file",
|
||||
"start_adb_activity",
|
||||
"start_adb_app",
|
||||
"stop_adb_app",
|
||||
"uninstall_adb_package",
|
||||
]);
|
||||
for (const contract of destructive)
|
||||
expect(contract.annotations, contract.name).toMatchObject({
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives destructiveHint from each destructive effect alone", () => {
|
||||
const base = TOOL_EFFECTS["binary_session"];
|
||||
if (base === undefined) throw new Error("binary_session is unaudited");
|
||||
expect(annotationsFromEffects(base)).toEqual({
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
});
|
||||
for (const flag of [
|
||||
"mayDiscardData",
|
||||
"executesForeignCode",
|
||||
"mutatesDevice",
|
||||
] as const)
|
||||
expect(annotationsFromEffects({ ...base, [flag]: true }), flag).toEqual({
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes the advertised annotations derived from canonical effects", () => {
|
||||
for (const contract of TOOL_CONTRACTS)
|
||||
expect(contract.annotations, contract.name).toEqual(
|
||||
annotationsFromEffects(contract.effects),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,17 @@ export interface ToolEffects {
|
||||
readonly launchesProcess: boolean;
|
||||
readonly accessesNetwork: boolean;
|
||||
readonly changesUiState: boolean;
|
||||
/** Can overwrite, clear, or drop state that the caller cannot recover from REA. */
|
||||
readonly mayDiscardData: boolean;
|
||||
/**
|
||||
* Runs or drives code REA does not own on the host: the analysis target, a
|
||||
* caller-selected executable, or the application loaded into one. Running
|
||||
* REA's own pinned helpers or operator-configured analysis engines does not
|
||||
* count.
|
||||
*/
|
||||
readonly executesForeignCode: boolean;
|
||||
/** Changes an attached Android device: packages, files, or running apps. */
|
||||
readonly mutatesDevice: boolean;
|
||||
readonly idempotent: boolean;
|
||||
}
|
||||
|
||||
@@ -27,6 +37,8 @@ const effects = (overrides: Partial<ToolEffects> = {}): ToolEffects => ({
|
||||
accessesNetwork: false,
|
||||
changesUiState: false,
|
||||
mayDiscardData: false,
|
||||
executesForeignCode: false,
|
||||
mutatesDevice: false,
|
||||
idempotent: true,
|
||||
...overrides,
|
||||
});
|
||||
@@ -178,6 +190,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
accessesNetwork: true,
|
||||
mutatesDevice: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
capture_adb_screen: effects({
|
||||
@@ -239,6 +252,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
mutatesDevice: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
uninstall_adb_package: effects({
|
||||
@@ -246,6 +260,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
mutatesDevice: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
start_adb_app: effects({
|
||||
@@ -253,6 +268,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
mutatesDevice: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
start_adb_activity: effects({
|
||||
@@ -260,6 +276,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
mutatesDevice: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
stop_adb_app: effects({
|
||||
@@ -267,6 +284,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
mutatesDevice: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
search_android_classes: effects({
|
||||
@@ -294,6 +312,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
mayDiscardData: true,
|
||||
}),
|
||||
inspect_native_load_image: evidence,
|
||||
inspect_native_dispatch_metadata: evidence,
|
||||
@@ -330,11 +349,31 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
resolve_containing_procedure: evidence,
|
||||
search_procedures: evidence,
|
||||
search_strings: evidence,
|
||||
set_address_name: effects({ mutatesTarget: true, mutatesSession: true }),
|
||||
set_addresses_names: effects({ mutatesTarget: true, mutatesSession: true }),
|
||||
set_bookmark: effects({ mutatesTarget: true, mutatesSession: true }),
|
||||
set_comment: effects({ mutatesTarget: true, mutatesSession: true }),
|
||||
set_inline_comment: effects({ mutatesTarget: true, mutatesSession: true }),
|
||||
set_address_name: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
mayDiscardData: true,
|
||||
}),
|
||||
set_addresses_names: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
mayDiscardData: true,
|
||||
}),
|
||||
set_bookmark: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
mayDiscardData: true,
|
||||
}),
|
||||
set_comment: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
mayDiscardData: true,
|
||||
}),
|
||||
set_inline_comment: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
mayDiscardData: true,
|
||||
}),
|
||||
unset_bookmark: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
@@ -365,6 +404,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
accessesNetwork: true,
|
||||
changesUiState: true,
|
||||
mayDiscardData: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
// The owned process can change files, show UI and use the network.
|
||||
@@ -374,6 +414,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
changesUiState: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
inspect_macho: nativeEvidence,
|
||||
@@ -419,6 +460,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
accessesNetwork: true,
|
||||
changesUiState: true,
|
||||
mayDiscardData: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
list_electron_targets: browserEvidence,
|
||||
@@ -431,6 +473,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
accessesNetwork: true,
|
||||
changesUiState: true,
|
||||
mayDiscardData: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
list_javascript_runtime_targets: browserEvidence,
|
||||
@@ -479,6 +522,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
writesFilesystem: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
compare_process_captures: sessionEvidence,
|
||||
@@ -506,9 +550,15 @@ export const annotationsFromEffects = (
|
||||
value.launchesProcess ||
|
||||
value.accessesNetwork ||
|
||||
value.changesUiState ||
|
||||
value.mayDiscardData
|
||||
value.mayDiscardData ||
|
||||
value.executesForeignCode ||
|
||||
value.mutatesDevice
|
||||
),
|
||||
destructiveHint: value.mayDiscardData,
|
||||
// MCP defines destructive as possibly non-additive changes to the
|
||||
// environment. Running foreign code or changing a device cannot be bounded
|
||||
// to additive effects, so either one is destructive even without data loss.
|
||||
destructiveHint:
|
||||
value.mayDiscardData || value.executesForeignCode || value.mutatesDevice,
|
||||
idempotentHint: value.idempotent,
|
||||
openWorldHint: value.launchesProcess || value.accessesNetwork,
|
||||
});
|
||||
|
||||
@@ -293,6 +293,8 @@ const toolAvailabilityFacts = {
|
||||
accessesNetwork: z.boolean(),
|
||||
changesUiState: z.boolean(),
|
||||
mayDiscardData: z.boolean(),
|
||||
executesForeignCode: z.boolean(),
|
||||
mutatesDevice: z.boolean(),
|
||||
idempotent: z.boolean(),
|
||||
}),
|
||||
annotations: z.object({
|
||||
|
||||
@@ -51,9 +51,9 @@ export interface AnalysisCleanupObservation {
|
||||
|
||||
/** Optional provider-neutral context that must survive typed error projection. */
|
||||
export interface AnalysisErrorOptions extends ErrorOptions {
|
||||
readonly capturedOutput?: AnalysisCapturedOutput;
|
||||
readonly cleanup?: AnalysisCleanupObservation;
|
||||
readonly partialObservation?: AnalysisPartialObservation;
|
||||
readonly capturedOutput?: AnalysisCapturedOutput | undefined;
|
||||
readonly cleanup?: AnalysisCleanupObservation | undefined;
|
||||
readonly partialObservation?: AnalysisPartialObservation | undefined;
|
||||
}
|
||||
|
||||
/** Base class for expected analysis, provider, and session failures. */
|
||||
|
||||
@@ -265,9 +265,12 @@ const hopperProcessMessage = (error: HopperProcessError): string => {
|
||||
const startup =
|
||||
error.userMessage === undefined ? "" : ` ${error.userMessage}`;
|
||||
if (error.providerState === "exited") {
|
||||
const signal = error.launcherFailure?.signal;
|
||||
const exit =
|
||||
error.exitCode === null
|
||||
? "no exit code was observed"
|
||||
? signal === undefined || signal === null
|
||||
? "no exit code was observed"
|
||||
: `signal ${signal}`
|
||||
: `exit code ${String(error.exitCode)}`;
|
||||
const recovery =
|
||||
error.failureCode === undefined && stage !== "launch"
|
||||
|
||||
@@ -572,7 +572,7 @@ describe("analysis error projection: caller contract", () => {
|
||||
describe("analysis error projection: operational diagnostics", () => {
|
||||
it("distinguishes an observed Hopper exit from an undetermined provider failure", () => {
|
||||
const exited = projectAnalysisError(
|
||||
new HopperProcessError(7, undefined, "search_strings", 4),
|
||||
new HopperProcessError(7, { operation: "search_strings", requestId: 4 }),
|
||||
);
|
||||
expect(exited).toMatchObject({
|
||||
code: "provider_unavailable",
|
||||
@@ -590,7 +590,10 @@ describe("analysis error projection: operational diagnostics", () => {
|
||||
expect(exited.remediation.action).toContain("Restart the owned provider");
|
||||
|
||||
const unknown = projectAnalysisError(
|
||||
new HopperProcessError(null, undefined, "search_strings", 5),
|
||||
new HopperProcessError(null, {
|
||||
operation: "search_strings",
|
||||
requestId: 5,
|
||||
}),
|
||||
);
|
||||
expect(unknown.details).toMatchObject({
|
||||
stage: "analysis",
|
||||
|
||||
+34
-16
@@ -1,4 +1,7 @@
|
||||
import { AnalysisError } from "./analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisError,
|
||||
type AnalysisErrorOptions,
|
||||
} from "./analysisErrorBase.js";
|
||||
import type {
|
||||
HopperProcessProviderState,
|
||||
ProviderFailureStage,
|
||||
@@ -73,10 +76,10 @@ export type HopperDiagnosticType =
|
||||
| "bridge_exception";
|
||||
|
||||
/** Hopper's JSON-RPC endpoint returned an expected remote error response. */
|
||||
export interface HopperRemoteErrorContext {
|
||||
readonly diagnosticType?: HopperDiagnosticType;
|
||||
readonly operation?: string;
|
||||
readonly requestId?: number;
|
||||
export interface HopperRemoteErrorContext extends AnalysisErrorOptions {
|
||||
readonly diagnosticType?: HopperDiagnosticType | undefined;
|
||||
readonly operation?: string | undefined;
|
||||
readonly requestId?: number | undefined;
|
||||
}
|
||||
|
||||
export class HopperRemoteError extends HopperError {
|
||||
@@ -90,7 +93,7 @@ export class HopperRemoteError extends HopperError {
|
||||
readonly safeMessage: string,
|
||||
context: HopperRemoteErrorContext = {},
|
||||
) {
|
||||
super(`Hopper request failed (${String(code)}): ${safeMessage}`);
|
||||
super(`Hopper request failed (${String(code)}): ${safeMessage}`, context);
|
||||
this.diagnosticType = context.diagnosticType ?? "remote";
|
||||
this.operation = context.operation;
|
||||
this.requestId = context.requestId;
|
||||
@@ -98,35 +101,50 @@ export class HopperRemoteError extends HopperError {
|
||||
}
|
||||
|
||||
/** The owned Hopper bridge stopped before its client was closed. */
|
||||
export interface HopperProcessErrorContext extends AnalysisErrorOptions {
|
||||
readonly diagnostic?: HopperStartupFailureDiagnostic | undefined;
|
||||
readonly operation?: string | undefined;
|
||||
readonly requestId?: number | undefined;
|
||||
readonly providerState?: HopperProcessProviderState | undefined;
|
||||
readonly stage?: ProviderFailureStage | undefined;
|
||||
readonly launcherFailure?: HopperLauncherOutcome | undefined;
|
||||
}
|
||||
|
||||
export class HopperProcessError extends HopperError {
|
||||
readonly _tag = "HopperProcessError";
|
||||
readonly failureCode: HopperStartupFailureCode | undefined;
|
||||
override readonly userMessage: string | undefined;
|
||||
readonly providerState: HopperProcessProviderState;
|
||||
readonly stage: ProviderFailureStage;
|
||||
readonly diagnostic: HopperStartupFailureDiagnostic | undefined;
|
||||
readonly operation: string | undefined;
|
||||
readonly requestId: number | undefined;
|
||||
readonly launcherFailure: HopperLauncherOutcome | undefined;
|
||||
|
||||
constructor(
|
||||
readonly exitCode: number | null,
|
||||
readonly diagnostic?: HopperStartupFailureDiagnostic,
|
||||
readonly operation?: string,
|
||||
readonly requestId?: number,
|
||||
providerState?: HopperProcessProviderState,
|
||||
stage?: ProviderFailureStage,
|
||||
readonly launcherFailure?: HopperLauncherOutcome,
|
||||
context: HopperProcessErrorContext = {},
|
||||
) {
|
||||
super(`Hopper bridge stopped unexpectedly with code ${String(exitCode)}`);
|
||||
super(
|
||||
`Hopper bridge stopped unexpectedly with code ${String(exitCode)}`,
|
||||
context,
|
||||
);
|
||||
this.diagnostic = context.diagnostic;
|
||||
this.operation = context.operation;
|
||||
this.requestId = context.requestId;
|
||||
this.launcherFailure = context.launcherFailure;
|
||||
const failure = hopperStartupFailure(exitCode);
|
||||
this.stage =
|
||||
stage ??
|
||||
context.stage ??
|
||||
(failure === undefined
|
||||
? operation === undefined
|
||||
? context.operation === undefined
|
||||
? "connection"
|
||||
: "analysis"
|
||||
: "launch");
|
||||
this.failureCode = failure?.code;
|
||||
this.userMessage = failure?.message;
|
||||
this.providerState =
|
||||
providerState ?? (exitCode === null ? "unknown" : "exited");
|
||||
context.providerState ?? (exitCode === null ? "unknown" : "exited");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
import { ROLLDOWN_MODULE_INTEROP } from "./javascriptModuleInterop.fixture.js";
|
||||
|
||||
const WINDOW =
|
||||
'new electron.BrowserWindow({webPreferences:{preload:"./preload.js"}});';
|
||||
const IMPORT = 'let electron = require("electron");';
|
||||
const WRAP = "electron = __toESM(electron, 1);";
|
||||
// The upstream runtime uses control-body braces that its bundled printer omits.
|
||||
// https://github.com/rolldown/rolldown/blob/22a0ab13732d81f2559ad2b7064b688699324889/crates/rolldown/src/runtime/runtime-base.js
|
||||
const BLOCK_MODULE_INTEROP = ROLLDOWN_MODULE_INTEROP.replace(
|
||||
'if (from && typeof from === "object" || typeof from === "function") for',
|
||||
'if (from && typeof from === "object" || typeof from === "function") { for',
|
||||
)
|
||||
.replace(
|
||||
"if (!__hasOwnProp.call(to, key) && key !== except) __defProp",
|
||||
"if (!__hasOwnProp.call(to, key) && key !== except) { __defProp",
|
||||
)
|
||||
.replace(" });", " });\n }")
|
||||
.replace(" return to;", " }\n return to;");
|
||||
|
||||
describe("Electron module interop identity", () => {
|
||||
it("preserves named APIs through the released Rolldown namespace conversion", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP}
|
||||
${IMPORT} ${WRAP} ${WINDOW}
|
||||
new electron.BrowserWindow({webPreferences:{preload:runtimePath}});
|
||||
electron.ipcMain.handle("ping", () => "pong");
|
||||
electron.contextBridge.exposeInMainWorld("api", {ping:true});
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toMatchObject([
|
||||
{ preload_path: "./preload.js" },
|
||||
{ preload_path: null },
|
||||
]);
|
||||
expect(analysis.electron.ipc).toMatchObject([
|
||||
{ channel: "ping", side: "main" },
|
||||
]);
|
||||
expect(analysis.electron.context_bridge_apis).toMatchObject([
|
||||
{ api_key: "api" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("follows lexical identities when helper and parameter names change", () => {
|
||||
const source = `${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`
|
||||
.replaceAll("__toESM", "namespaceOf")
|
||||
.replaceAll("__copyProps", "copyNamed")
|
||||
.replaceAll("mod", "inputModule")
|
||||
.replaceAll("isNodeMode", "nodeMode")
|
||||
.replaceAll("target", "namespaceTarget")
|
||||
.replaceAll("from", "sourceObject");
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(source).electron.browser_windows,
|
||||
).toMatchObject([{ preload_path: "./preload.js" }]);
|
||||
});
|
||||
|
||||
it("preserves the same operations through control-body braces", () => {
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(
|
||||
`${BLOCK_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`,
|
||||
).electron.browser_windows,
|
||||
).toMatchObject([{ preload_path: "./preload.js" }]);
|
||||
});
|
||||
|
||||
it("keeps additional operations inside a control block unresolved", () => {
|
||||
const altered = BLOCK_MODULE_INTEROP.replace(
|
||||
"{ __defProp",
|
||||
"{ from = unrelated; __defProp",
|
||||
);
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(`${altered}\n${IMPORT} ${WRAP} ${WINDOW}`)
|
||||
.electron.browser_windows,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const BOUND_INTRINSIC_MUTATIONS: [string, string][] = [
|
||||
[
|
||||
"intrinsic changed through aliased prebound arguments",
|
||||
`const objectAlias = Object; const key = "create"; const define = Object.defineProperty; const mutate = define.bind(null, objectAlias, key); mutate({value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"intrinsic changed through nested prebinding",
|
||||
`const first = Object.defineProperty.bind(null, Object); const second = first.bind(null, "create"); second({value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"a replaced bound mutator bind method is unknown",
|
||||
`const first = Object.defineProperty.bind(null, Object); first.bind = customBind; const second = first.bind(null, "create"); second({value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"intrinsic changed through a computed prebound call",
|
||||
`const mutate = Object.defineProperty.bind(null, Object, "create"); mutate["call"](null, {value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"intrinsic changed through a computed prebound apply",
|
||||
`const mutate = Object.defineProperty.bind(null, Object, "create"); mutate["apply"](null, [{value:customCreate}]); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"a replaced bind intrinsic makes a prebound mutator unknown",
|
||||
`Function.prototype.bind = customBind; const mutate = Object.defineProperty.bind(null, Object, "create"); mutate({value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
];
|
||||
|
||||
describe("Electron module interop uncertainty", () => {
|
||||
it.each([
|
||||
...BOUND_INTRINSIC_MUTATIONS,
|
||||
[
|
||||
"intrinsic changed through an unresolved mutator target",
|
||||
`Object.defineProperty(getTarget(), "create", {value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"intrinsic changed via dynamic apply",
|
||||
`const args = [Object, "getOwnPropertyNames", {value:() => []}]; Object.defineProperty.apply(null, args); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"intrinsic changed via spread call",
|
||||
`const args = [Object, "getOwnPropertyNames", {value:() => []}]; Object.defineProperty.call(null, ...args); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"intrinsic changed via Reflect.apply",
|
||||
`const args = [Object, "getOwnPropertyNames", {value:() => []}]; Reflect.apply(Object.defineProperty, null, args); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"intrinsic changed via spread Reflect.apply",
|
||||
`const args = [null, [Object, "create", {value:customCreate}]]; Reflect.apply(Object.defineProperty, ...args); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"intrinsic changed via call",
|
||||
`Object.defineProperty.call(null, Object, "getOwnPropertyNames", {value:() => []}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"intrinsic changed via apply",
|
||||
`Object.defineProperty.apply(null, [Object, "getOwnPropertyNames", {value:() => []}]); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"intrinsic changed with defineProperty",
|
||||
`Object.defineProperty(Object, "getOwnPropertyNames", {value:() => []}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"intrinsic changed with Reflect.set",
|
||||
`Reflect.set(Object, "getOwnPropertyNames", () => []); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"intrinsic changed with an aliased mutator",
|
||||
`const change = Object.defineProperty; change(Object, "create", {value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"intrinsic changed with assign",
|
||||
`Object.assign(Object, {getOwnPropertyNames:() => []}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"intrinsic changed with defineProperties",
|
||||
`Object.defineProperties(Function.prototype, {bind:{value:customBind}}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"overwritten Object global",
|
||||
`Object = customObject; ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"overwritten intrinsic through an alias",
|
||||
`const objectAlias = Object; objectAlias.create = customCreate; ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"overwritten intrinsic through globalThis",
|
||||
`globalThis.Object.create = customCreate; ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"helper called before initialization",
|
||||
`${IMPORT} ${WRAP} ${ROLLDOWN_MODULE_INTEROP}`,
|
||||
],
|
||||
[
|
||||
"fake helper",
|
||||
`${ROLLDOWN_MODULE_INTEROP}\n__toESM = (mod) => ({BrowserWindow:Fake}); ${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"changed getter",
|
||||
`${ROLLDOWN_MODULE_INTEROP.replace("from[k]", "to[k]")}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"replaced copy helper",
|
||||
`${ROLLDOWN_MODULE_INTEROP}\n__copyProps = () => ({}); ${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"replaced intrinsic alias",
|
||||
`${ROLLDOWN_MODULE_INTEROP}\n__defProp = () => ({}); ${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"shadowed Object",
|
||||
`const Object = customObject; ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"overwritten intrinsic",
|
||||
`Object.create = customCreate; ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"overwritten alias call",
|
||||
`${ROLLDOWN_MODULE_INTEROP}\n__hasOwnProp.call = customCall; ${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"overwritten Function bind",
|
||||
`${ROLLDOWN_MODULE_INTEROP}\nFunction.prototype.bind = customBind; ${IMPORT} ${WRAP}`,
|
||||
],
|
||||
[
|
||||
"conditional conversion",
|
||||
`${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} if (flag) { ${WRAP} }`,
|
||||
],
|
||||
[
|
||||
"compound assignment",
|
||||
`${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} electron ||= __toESM(electron, 1);`,
|
||||
],
|
||||
[
|
||||
"other input",
|
||||
`${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} electron = __toESM(other, 1);`,
|
||||
],
|
||||
[
|
||||
"later overwrite",
|
||||
`${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} electron = unrelated;`,
|
||||
],
|
||||
[
|
||||
"overwritten constructor",
|
||||
`${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} electron.BrowserWindow = Fake;`,
|
||||
],
|
||||
])("does not infer Electron identity after %s", (_name, setup) => {
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(`${setup}\n${WINDOW}`).electron
|
||||
.browser_windows,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Electron module interop helper results", () => {
|
||||
it.each([
|
||||
'const {BrowserWindow} = __toESM(require("electron"), 1); new BrowserWindow({});',
|
||||
'const {BrowserWindow: Window} = __toESM(require("electron"), 1); new Window({});',
|
||||
'const {default: {BrowserWindow}} = __toESM(require("electron"), 1); new BrowserWindow({});',
|
||||
'const Window = __toESM(require("electron"), 1).BrowserWindow; new Window({});',
|
||||
])("preserves a proven result projection: %s", (source) => {
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP}\n${source}`)
|
||||
.electron.browser_windows,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'const {[key]: Window} = __toESM(require("electron"), 1); new Window({});',
|
||||
'const {...electron} = __toESM(require("electron"), 1); new electron.BrowserWindow({});',
|
||||
'let {BrowserWindow} = __toESM(require("electron"), 1); BrowserWindow = Fake; new BrowserWindow({});',
|
||||
])("keeps an uncertain result projection unresolved: %s", (source) => {
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP}\n${source}`)
|
||||
.electron.browser_windows,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
", 0",
|
||||
", false",
|
||||
", true",
|
||||
", 1",
|
||||
', "node"',
|
||||
', ""',
|
||||
", null",
|
||||
])("preserves named exports with a literal node mode: %s", (mode) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP}
|
||||
const fresh = __toESM(require("electron")${mode});
|
||||
new fresh.BrowserWindow({});
|
||||
const {BrowserWindow} = __toESM(require("electron")${mode});
|
||||
new BrowserWindow({});
|
||||
let electron = require("electron");
|
||||
electron = __toESM(electron${mode}); ${WINDOW}
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it.each(["", ", 0", ", false"])(
|
||||
"does not assume a module-valued default with node mode: %s",
|
||||
(mode) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP}
|
||||
const original = require("electron");
|
||||
original.__esModule = true;
|
||||
original.default = {BrowserWindow: Fake};
|
||||
const {default: {BrowserWindow}} = __toESM(require("electron")${mode});
|
||||
new BrowserWindow({});
|
||||
const other = __toESM(require("electron")${mode}).default;
|
||||
new other.BrowserWindow({});
|
||||
new (__toESM(require("electron")${mode}).default.BrowserWindow)({});
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["__toESM", "identity"])(
|
||||
"rejects side-effecting extra arguments to %s",
|
||||
(helper) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP}
|
||||
function identity(mod) { return mod; }
|
||||
const electron = ${helper}(require("electron"), mutate()); ${WINDOW}
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"const __toESM = () => ({BrowserWindow:FakeWindow});",
|
||||
"const __toESM = async (mod) => mod;",
|
||||
"function* __toESM(mod) { return mod; }",
|
||||
"const __toESM = (this && this.__toESM) || ((mod) => mod);",
|
||||
])("keeps an unproven fresh result unresolved: %s", (helper) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`${helper}
|
||||
const electron = __toESM(require("electron"), 1); ${WINDOW}
|
||||
new (__toESM(require("electron"), 1)).BrowserWindow({});
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves a proven fresh namespace and a pure identity helper", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP}
|
||||
const electron = __toESM(require("electron"), 1); ${WINDOW}
|
||||
new (__toESM(require("electron"), 1)).BrowserWindow({});
|
||||
function passthrough(value) {return value;}
|
||||
const plain = passthrough(require("electron"));
|
||||
new plain.BrowserWindow({});
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Electron module interop pattern writes", () => {
|
||||
it.each([
|
||||
"({Object} = replacements);",
|
||||
"[Function] = replacements;",
|
||||
"({nested:{Object}} = replacements);",
|
||||
"({replacement:Object = customObject} = replacements);",
|
||||
"({...Object} = replacements);",
|
||||
"[...Function] = replacements;",
|
||||
"for ({Object} of replacements) {}",
|
||||
"for ([Function] in replacements) {}",
|
||||
])("rejects an intrinsic replaced through %s", (write) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
${write} ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW}
|
||||
`);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"({Object:unrelated} = replacements);",
|
||||
"function decoy(Object) {({Object} = replacements);}",
|
||||
])("preserves intrinsic identity for an unrelated target: %s", (write) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
${write} ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW}
|
||||
`);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves captured operations after later property destructuring", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
${ROLLDOWN_MODULE_INTEROP} ({create:Object.create} = replacements);
|
||||
${IMPORT} ${WRAP} ${WINDOW}
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Electron module interop intrinsic captures", () => {
|
||||
it.each([
|
||||
"var captured = Object.defineProperty;",
|
||||
"const {defineProperty:captured} = Object;",
|
||||
"var first = Object.defineProperty; const captured = first;",
|
||||
])("retains the original function capture through %s", (capture) => {
|
||||
const runtime = ROLLDOWN_MODULE_INTEROP.replace(
|
||||
"__defProp = Object.defineProperty",
|
||||
"__defProp = captured",
|
||||
);
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
${capture} Object.defineProperty = custom;
|
||||
${runtime} ${IMPORT} ${WRAP} ${WINDOW}
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["var", "const"])(
|
||||
"keeps an intrinsic alias read before its %s initializer unresolved",
|
||||
(kind) => {
|
||||
const runtime = ROLLDOWN_MODULE_INTEROP.replace(
|
||||
"__defProp = Object.defineProperty",
|
||||
"__defProp = later",
|
||||
);
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
${runtime} ${kind} later = Object.defineProperty;
|
||||
${IMPORT} ${WRAP} ${WINDOW}
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("requires the helper's intrinsic binding to initialize before conversion", () => {
|
||||
const runtime = ROLLDOWN_MODULE_INTEROP.replace(
|
||||
"var __defProp = Object.defineProperty;",
|
||||
"",
|
||||
);
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
var captured = Object.defineProperty;
|
||||
${runtime} ${IMPORT} ${WRAP} ${WINDOW}
|
||||
var __defProp = captured;
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Electron module interop capture and scope", () => {
|
||||
it.each(["metadata", "alias"])(
|
||||
"recognizes a hoisted function target through %s",
|
||||
(target) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
const alias = metadata;
|
||||
Object.defineProperty(${target}, "x", {value:1});
|
||||
function metadata() {}
|
||||
${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW}
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"var metadata = function () {};",
|
||||
"const metadata = () => {};",
|
||||
"class metadata {}",
|
||||
])("does not hoist fresh targets from %s", (declaration) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
Object.defineProperty(metadata, "x", {value:1});
|
||||
${declaration}
|
||||
${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW}
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'const {defineProperty:change} = Object; change(Object,"create",{value:customCreate});',
|
||||
"const objectAlias = Object; const {assign:change} = objectAlias; change(Object,{create:customCreate});",
|
||||
'const {set:change} = Reflect; change(Object,"create",customCreate);',
|
||||
'const {Object:{defineProperty:change}} = globalThis; change(Object,"create",{value:customCreate});',
|
||||
])("rejects a projected intrinsic mutator alias: %s", (write) => {
|
||||
const source = `${write} ${ROLLDOWN_MODULE_INTEROP}
|
||||
${IMPORT} ${WRAP} ${WINDOW}
|
||||
`.replaceAll("__toESM", "namespaceOf");
|
||||
const analysis = analyzeJavaScriptStaticSource(source);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"const metadata = {};",
|
||||
"const metadata = [];",
|
||||
"const initial = {}; const metadata = initial;",
|
||||
"const metadata = () => {};",
|
||||
"const metadata = function () {};",
|
||||
"const metadata = async () => {};",
|
||||
"const metadata = function* () {};",
|
||||
"const metadata = class {};",
|
||||
"const metadata = /local/;",
|
||||
"function metadata() {}",
|
||||
"class metadata {}",
|
||||
])(
|
||||
"keeps a fresh mutator target separate from intrinsic identity: %s",
|
||||
(setup) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
${setup} Object.defineProperty(metadata,"x",{value:1});
|
||||
${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW}
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"const metadata = getMetadata();",
|
||||
"let metadata = {}; metadata = Object;",
|
||||
"Object.defineProperty(metadata,'x',{value:1}); const metadata = {};",
|
||||
"const metadata = {intrinsic:Object}; Object.defineProperty(metadata.intrinsic,'create',{value:customCreate});",
|
||||
"const metadata = class {static {Object.create = customCreate;}};",
|
||||
"@(() => Object) class metadata {}",
|
||||
])("keeps an unproven mutator target unresolved: %s", (setup) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
${setup} Object.defineProperty(metadata,"x",{value:1});
|
||||
${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW}
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not reject unrelated intrinsic property definitions", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
Object.defineProperty(Object, "unrelated", {value:1});
|
||||
${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW}
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps opaque targets in a generic helper separate from outer module identity", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
function genericAssign() { Object.assign.apply(null, arguments); }
|
||||
${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW}
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects an opaque intrinsic mutation in the namespace's own callable", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP}
|
||||
function openWindow(args) {
|
||||
Object.defineProperty.apply(null, args);
|
||||
${IMPORT} ${WRAP} ${WINDOW}
|
||||
}
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves captured intrinsics across later vendor polyfills and callable writes", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP}
|
||||
Object.create = customCreate;
|
||||
${IMPORT} ${WRAP}
|
||||
function runLoop(key) { globalThis[key] = this; }
|
||||
function polyfill() { Object.create = customCreate; }
|
||||
${WINDOW}
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a shadowed namespace separate from the converted module", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP}
|
||||
${IMPORT} ${WRAP} ${WINDOW}
|
||||
function decoy(electron) { ${WINDOW} }
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Electron module interop generic invocations", () => {
|
||||
it.each([
|
||||
'const change = Function.prototype.call.bind(Object.defineProperty); change(null,Object,"create",{value:customCreate});',
|
||||
'const call = Function.prototype.call; const change = call.bind(Object.defineProperty); change(null,Object,"create",{value:customCreate});',
|
||||
'const change = Function.prototype.call.bind(Object.defineProperty,null,Object,"create"); change({value:customCreate});',
|
||||
'Function.prototype.call.apply(Object.defineProperty,[null,Object,"create",{value:customCreate}]);',
|
||||
'Reflect.apply(Function.prototype.call,Object.defineProperty,[null,Object,"create",{value:customCreate}]);',
|
||||
'const change = Function.prototype.apply.bind(Object.defineProperty); change(null,[Object,"create",{value:customCreate}]);',
|
||||
'Reflect.apply(Function.prototype.apply,Object.defineProperty,[null,[Object,"create",{value:customCreate}]]);',
|
||||
'const first = Function.prototype.call.bind(Object.defineProperty,null,Object,"create"); const change = first.bind(fake); change({value:customCreate});',
|
||||
"const change = Function.prototype.call.bind(Object.defineProperty); change(null,...args);",
|
||||
"const change = Function.prototype.apply.bind(Object.defineProperty); change(null,args);",
|
||||
'const change = Function.prototype.call.bind(Object.defineProperty,null,...args); change(metadata,"x",{value:1});',
|
||||
'Function.prototype.call.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});',
|
||||
'Object.defineProperty.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});',
|
||||
'Function.prototype.apply.apply(Object.defineProperty,[null,[Object,"create",{value:customCreate}]]);',
|
||||
'const change = Object.defineProperty.call.bind(Object.defineProperty); change(null,Object,"create",{value:customCreate});',
|
||||
'const change = Function.prototype.call.call.bind(Object.defineProperty,null,Object,"create"); change({value:customCreate});',
|
||||
])("follows the callable receiver of a generic invocation: %s", (write) => {
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(
|
||||
`${write}\n${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`,
|
||||
).electron.browser_windows,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'const change = Function.prototype.call.bind(Object.defineProperty); change(null,metadata,"x",{value:1});',
|
||||
'const change = Function.prototype.call.bind(Object.defineProperty,null,metadata,"x"); change({value:1});',
|
||||
'Function.prototype.call.call(Object.defineProperty,null,metadata,"x",{value:1});',
|
||||
'const change = Function.prototype.apply.bind(Object.defineProperty); change(null,[metadata,"x",{value:1}]);',
|
||||
'Reflect.apply(Function.prototype.apply,Object.defineProperty,[null,[metadata,"x",{value:1}]]);',
|
||||
'Function.prototype.call.call.call(Object.defineProperty,null,metadata,"x",{value:1});',
|
||||
'Function.prototype.apply.apply(Object.defineProperty,[null,[metadata,"x",{value:1}]]);',
|
||||
'const change = Object.defineProperty.call.bind(Object.defineProperty); change(null,metadata,"x",{value:1});',
|
||||
])("preserves fresh targets through a generic invocation: %s", (write) => {
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(
|
||||
`const metadata = {}; ${write}\n${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`,
|
||||
).electron.browser_windows,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Electron module interop bound mutators", () => {
|
||||
it.each([
|
||||
'const change = Object.defineProperty.bind(null); change(Object,"create",{value:customCreate});',
|
||||
'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});',
|
||||
'const first = Object.defineProperty.bind(null,Object); const change = first; change("create",{value:customCreate});',
|
||||
'const change = Object.defineProperty.bind(null,Object); change.call(null,"create",{value:customCreate});',
|
||||
'const change = Object.defineProperty.bind(null,Object); change.apply(null,["create",{value:customCreate}]);',
|
||||
'const change = Object.defineProperty.bind(null,Object); Reflect.apply(change,null,["create",{value:customCreate}]);',
|
||||
'Object.defineProperty.bind(null,Object,"create")({value:customCreate});',
|
||||
'const change = Object.defineProperty.bind(null,...args); change(Object,"create",{value:customCreate});',
|
||||
'const metadata = {}; Object.defineProperty.call(...args,metadata,"x",{value:1});',
|
||||
'const metadata = {}; Object.defineProperty.apply(...args,[metadata,"x",{value:1}]);',
|
||||
'const change = Function.prototype.call.bind(Object.defineProperty,null); change(Object,"create",{value:customCreate});',
|
||||
'const first = Function.prototype.call.bind(Object.defineProperty,null); const change = first.bind(customReceiver,Object,"create"); change({value:customCreate});',
|
||||
'const change = Function.prototype.apply.bind(Object.defineProperty,null); change([Object,"create",{value:customCreate}]);',
|
||||
'Function.prototype.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});',
|
||||
'Function.prototype.apply.call(Object.defineProperty,null,[Object,"create",{value:customCreate}]);',
|
||||
'const uncurry = Function.prototype.call.bind(Function.prototype.call); uncurry(Object.defineProperty,null,Object,"create",{value:customCreate});',
|
||||
'const change = Function.prototype.bind.call(Object.defineProperty,null); change(Object,"create",{value:customCreate});',
|
||||
'const bind = Function.prototype.call.bind(Function.prototype.bind); const change = bind(Object.defineProperty,null); change(Object,"create",{value:customCreate});',
|
||||
'const change = Function.prototype.bind.apply(Object.defineProperty,[null,Object,"create"]); change({value:customCreate});',
|
||||
'const change = Reflect.apply(Function.prototype.bind,Object.defineProperty,[null,Object,"create"]); change({value:customCreate});',
|
||||
'const first = Object.defineProperty.bind(null,Object); const change = Function.prototype.bind.call(first,null,"create"); change({value:customCreate});',
|
||||
'Reflect.apply = Object.defineProperty; Reflect.apply(Object,"create",{value:customCreate});',
|
||||
'const apply = Reflect.apply; Reflect.apply = Object.defineProperty; Reflect.apply(Object,"create",{value:customCreate});',
|
||||
'const change = Reflect.set.bind(null,Object,"create"); change(customCreate);',
|
||||
'const change = Object.defineProperty.call; change.call(Object.defineProperty,null,Object,"create",{value:customCreate});',
|
||||
'const {apply:change} = Object.defineProperty; change.call(Object.defineProperty,null,[Object,"create",{value:customCreate}]);',
|
||||
'const change = Reflect.apply.call; change.call(Reflect.apply,null,Object.defineProperty,null,[Object,"create",{value:customCreate}]);',
|
||||
'Reflect.set({},"create",customCreate,Object);',
|
||||
'const metadata = {}; Reflect.set(metadata,"create",customCreate,Object);',
|
||||
'const change = Reflect.set.bind(null,{},"create",customCreate); change(Object);',
|
||||
'Reflect.apply(Reflect.set,null,[{},"create",customCreate,Object]);',
|
||||
'Reflect.set({},"create",customCreate,...receivers);',
|
||||
'Reflect.set({},"create",...valuesAndReceivers);',
|
||||
"Reflect.set({},...keysValuesAndReceivers);",
|
||||
'Reflect.set({},"create",customCreate,receiver);',
|
||||
])("rejects intrinsic writes through a bound mutator: %s", (write) => {
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(
|
||||
`${write}\n${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`,
|
||||
).electron.browser_windows,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'const change = Object.defineProperty.bind(null); change(metadata,"x",{value:1});',
|
||||
'const change = Object.defineProperty.bind(null,metadata,"x"); change({value:1});',
|
||||
'const first = Object.defineProperty.bind(null,metadata); const change = first.bind(null,"x"); change({value:1});',
|
||||
'const change = Function.prototype.call.bind(Object.defineProperty,null); change(metadata,"x",{value:1});',
|
||||
'const change = Function.prototype.apply.bind(Object.defineProperty,null); change([metadata,"x",{value:1}]);',
|
||||
'const change = Function.prototype.bind.call(Object.defineProperty,null,metadata,"x"); change({value:1});',
|
||||
'const bind = Function.prototype.call.bind(Function.prototype.bind); const change = bind(Object.defineProperty,null,metadata,"x"); change({value:1});',
|
||||
'const change = Function.prototype.bind.apply(Object.defineProperty,[null,metadata,"x"]); change({value:1});',
|
||||
'const change = Reflect.apply(Function.prototype.bind,Object.defineProperty,[null,metadata,"x"]); change({value:1});',
|
||||
'const apply = Reflect.apply; Reflect.apply = replacement; apply(Object.defineProperty,null,[metadata,"x",{value:1}]);',
|
||||
'const change = Object.defineProperty.bind(null,Object,"create");',
|
||||
'Object.defineProperty.call(null,metadata,"x",{value:1});',
|
||||
'Object.defineProperty.apply(null,[metadata,"x",{value:1}]);',
|
||||
'const change = Object.defineProperty.call; change.call(Object.defineProperty,null,metadata,"x",{value:1});',
|
||||
'const {apply:change} = Object.defineProperty; change.call(Object.defineProperty,null,[metadata,"x",{value:1}]);',
|
||||
'Reflect.set(metadata,"x",1,{});',
|
||||
'const receiver = {}; Reflect.set({},"x",1,receiver);',
|
||||
])("keeps a fresh bound target or unused binding separate: %s", (write) => {
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(
|
||||
`const metadata = {}; ${write}\n${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`,
|
||||
).electron.browser_windows,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unrelated exports after an aliased prebound key write", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
const electron = require("electron");
|
||||
const key = "Unrelated";
|
||||
const mutate = Object.defineProperty.bind(null, electron, key);
|
||||
mutate({value: custom});
|
||||
new electron.BrowserWindow({});
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(1);
|
||||
});
|
||||
@@ -77,7 +77,7 @@ describe("Electron API identity", () => {
|
||||
it("follows bundler namespace interop helpers without accepting other writes", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
var __toESM = (mod) => mod;
|
||||
var __importStar = (this && this.__importStar) || ((mod) => mod);
|
||||
var __importStar = (mod) => mod;
|
||||
let electron = require("electron");
|
||||
electron = __toESM(electron);
|
||||
new electron.BrowserWindow({ webPreferences: { preload: "./rolldown.js" } });
|
||||
@@ -120,4 +120,53 @@ describe("Electron API identity", () => {
|
||||
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("follows stable declaration aliases but not an assigned default parameter", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
const direct = require("electron");
|
||||
const constAlias = direct;
|
||||
new constAlias.BrowserWindow({webPreferences:{preload:"./const.js"}});
|
||||
|
||||
let letAlias = require("electron");
|
||||
new letAlias.BrowserWindow({webPreferences:{preload:"./let.js"}});
|
||||
|
||||
const { BrowserWindow } = require("electron");
|
||||
new BrowserWindow({webPreferences:{preload:"./destructured.js"}});
|
||||
|
||||
function withDefault(electron = require("./not-electron")) {
|
||||
if (flag) electron = require("electron");
|
||||
new electron.BrowserWindow({webPreferences:{preload:"./default.js"}});
|
||||
}
|
||||
`);
|
||||
|
||||
expect(
|
||||
analysis.electron.browser_windows.map(({ preload_path }) => preload_path),
|
||||
).toEqual(["./const.js", "./let.js", "./destructured.js"]);
|
||||
});
|
||||
|
||||
it("preserves one initializer across variable redeclarations without accepting writes", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
var electron;
|
||||
var electron = require("electron");
|
||||
var electron;
|
||||
new electron.BrowserWindow({webPreferences:{preload:"./redeclared.js"}});
|
||||
|
||||
var replaced;
|
||||
var replaced = require("electron");
|
||||
replaced = fake;
|
||||
new replaced.BrowserWindow({webPreferences:{preload:"./replaced.js"}});
|
||||
|
||||
var initializedTwice = require("electron");
|
||||
var initializedTwice = fake;
|
||||
new initializedTwice.BrowserWindow({});
|
||||
|
||||
var conditional;
|
||||
if (flag) { var conditional = require("electron"); }
|
||||
new conditional.BrowserWindow({});
|
||||
`);
|
||||
|
||||
expect(
|
||||
analysis.electron.browser_windows.map(({ preload_path }) => preload_path),
|
||||
).toEqual(["./redeclared.js"]);
|
||||
});
|
||||
});
|
||||
@@ -86,16 +86,20 @@ export const semanticObjectPatternKeys = (
|
||||
return key === null ? [] : [key];
|
||||
});
|
||||
|
||||
/** Visit member write targets in direct and destructuring assignment forms. */
|
||||
export function* semanticMemberWriteTargets(
|
||||
/** Visit binding and member leaves in direct and destructuring write targets. */
|
||||
export function* semanticWriteTargets(
|
||||
root: t.Node,
|
||||
): Generator<t.MemberExpression | t.OptionalMemberExpression> {
|
||||
): Generator<t.Identifier | t.MemberExpression | t.OptionalMemberExpression> {
|
||||
const pending = [root];
|
||||
while (pending.length > 0) {
|
||||
const next = pending.pop();
|
||||
if (next === undefined) break;
|
||||
const node = unwrapJavaScriptExpression(next).node;
|
||||
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node))
|
||||
if (
|
||||
t.isIdentifier(node) ||
|
||||
t.isMemberExpression(node) ||
|
||||
t.isOptionalMemberExpression(node)
|
||||
)
|
||||
yield node;
|
||||
else if (t.isRestElement(node)) pending.push(node.argument);
|
||||
else if (t.isAssignmentPattern(node)) pending.push(node.left);
|
||||
|
||||
@@ -2,10 +2,9 @@ import * as t from "@babel/types";
|
||||
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
semanticMemberWriteTargets,
|
||||
semanticWriteTargets,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js";
|
||||
import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js";
|
||||
|
||||
/** Match Electron's supported module entry points. */
|
||||
@@ -24,7 +23,7 @@ export const electronExportPath = (
|
||||
|
||||
/** Static identity recovery cannot establish the absence of Electron usage. */
|
||||
export const ELECTRON_IDENTITY_LIMITATION =
|
||||
"Electron API identity follows proven lexical module aliases, including results of namespace interop helpers (__toESM, __importStar, _interopRequireWildcard) recognized by name and a single program-scope function definition; helper bodies are not evaluated. Other reassigned bindings, dynamic selections, and visibly overwritten namespace members are unresolved; missing findings do not establish absence. Arbitrary call side effects and runtime registration are not evaluated.";
|
||||
"Electron API identity follows proven lexical module aliases and structurally verified module-namespace interop. Other reassignments, dynamic selections, and visibly overwritten namespace members are unresolved; missing findings do not establish absence. Arbitrary call side effects and runtime registration are not evaluated.";
|
||||
|
||||
/** Preserve exact property segments instead of flattening literal dotted keys. */
|
||||
export const electronMemberPath = (
|
||||
@@ -42,14 +41,98 @@ export const electronMemberPath = (
|
||||
return { root, members: members.toReversed() };
|
||||
};
|
||||
|
||||
/** Index visible writes through proven Electron aliases, without executing them. */
|
||||
export function* collectElectronMemberWritesSteps(
|
||||
/** A known intrinsic callable and its examined positional arguments. */
|
||||
export interface JavaScriptIntrinsicInvocation {
|
||||
readonly kind: "known" | "opaque";
|
||||
/** The original bound receiver; rebinding cannot replace it. */
|
||||
readonly receiver?: t.Node;
|
||||
readonly path: readonly string[];
|
||||
readonly arguments:
|
||||
| readonly t.CallExpression["arguments"][number][]
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/** Index member and binding writes through caller-proven lexical identities. */
|
||||
export function* collectJavaScriptIdentityWritesSteps(
|
||||
program: t.Program,
|
||||
origin: (root: t.Node) => readonly string[] | undefined,
|
||||
): Generator<void, SemanticPropertyPathCoverage> {
|
||||
const writes = new SemanticPropertyPathCoverage();
|
||||
origin: (
|
||||
root: t.Node,
|
||||
kind: "binding" | "member" | "opaque-call",
|
||||
) => readonly string[] | undefined,
|
||||
intrinsicInvocation: (
|
||||
node: t.CallExpression | t.OptionalCallExpression,
|
||||
) => JavaScriptIntrinsicInvocation | undefined,
|
||||
staticString: (node: t.Node | undefined) => string | undefined,
|
||||
freshObject: (node: t.Node) => boolean,
|
||||
retain: (node: t.Node, path: readonly (string | null)[]) => void,
|
||||
): Generator<void> {
|
||||
const objectMutators = new Set([
|
||||
"defineProperty",
|
||||
"defineProperties",
|
||||
"assign",
|
||||
"setPrototypeOf",
|
||||
]);
|
||||
const reflectMutators = new Set([
|
||||
"set",
|
||||
"deleteProperty",
|
||||
"defineProperty",
|
||||
"setPrototypeOf",
|
||||
]);
|
||||
const keyedMutators = new Set(["set", "deleteProperty", "defineProperty"]);
|
||||
yield* traverseJavaScriptAstSteps(program, {
|
||||
enter: (node) => {
|
||||
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
|
||||
const invocation = intrinsicInvocation(node);
|
||||
if (invocation?.kind === "opaque") {
|
||||
const scope = origin(node, "opaque-call");
|
||||
if (scope !== undefined) retain(node, scope);
|
||||
return;
|
||||
}
|
||||
const callee = invocation?.path;
|
||||
const arguments_ = invocation?.arguments;
|
||||
const method = callee?.at(-1);
|
||||
const owner = callee?.length === 3 ? callee[1] : undefined;
|
||||
if (
|
||||
(owner === "Object" && objectMutators.has(method ?? "")) ||
|
||||
(owner === "Reflect" && reflectMutators.has(method ?? ""))
|
||||
) {
|
||||
// Reflect.set can write through its explicit receiver as well as
|
||||
// dispatching through its target. Both identities need examination.
|
||||
const targets =
|
||||
owner === "Reflect" &&
|
||||
method === "set" &&
|
||||
arguments_ !== undefined &&
|
||||
(arguments_.length >= 4 || arguments_.some(t.isSpreadElement))
|
||||
? [
|
||||
arguments_[0],
|
||||
arguments_.some(t.isSpreadElement)
|
||||
? undefined
|
||||
: arguments_[3],
|
||||
]
|
||||
: [arguments_?.[0]];
|
||||
for (const target of targets) {
|
||||
// A known mutator with an opaque argument list can overwrite any
|
||||
// captured intrinsic. Do not turn an unexamined target into proof.
|
||||
if (target === undefined || t.isSpreadElement(target)) {
|
||||
const scope = origin(node, "opaque-call");
|
||||
if (scope !== undefined) retain(node, scope);
|
||||
}
|
||||
if (t.isNode(target) && !t.isSpreadElement(target)) {
|
||||
const { root, members } = electronMemberPath(target);
|
||||
const exported = origin(root, "member");
|
||||
if (exported !== undefined) {
|
||||
const key = keyedMutators.has(method ?? "")
|
||||
? [staticString(arguments_?.[1]) ?? null]
|
||||
: [];
|
||||
retain(node, [...exported, ...members, ...key]);
|
||||
} else if (!freshObject(target)) {
|
||||
const scope = origin(node, "opaque-call");
|
||||
if (scope !== undefined) retain(node, scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const target = t.isAssignmentExpression(node)
|
||||
? node.left
|
||||
: t.isUpdateExpression(node)
|
||||
@@ -60,12 +143,16 @@ export function* collectElectronMemberWritesSteps(
|
||||
? node.left
|
||||
: undefined;
|
||||
if (target === undefined) return;
|
||||
for (const member of semanticMemberWriteTargets(target)) {
|
||||
const { root, members } = electronMemberPath(member);
|
||||
const exported = origin(root);
|
||||
if (exported !== undefined) writes.retain([...exported, ...members]);
|
||||
for (const leaf of semanticWriteTargets(target)) {
|
||||
if (t.isIdentifier(leaf)) {
|
||||
const exported = origin(leaf, "binding");
|
||||
if (exported !== undefined) retain(node, exported);
|
||||
continue;
|
||||
}
|
||||
const { root, members } = electronMemberPath(leaf);
|
||||
const exported = origin(root, "member");
|
||||
if (exported !== undefined) retain(node, [...exported, ...members]);
|
||||
}
|
||||
},
|
||||
});
|
||||
return writes;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import * as t from "@babel/types";
|
||||
|
||||
import type { JavaScriptIntrinsicInvocation } from "./javascriptElectronMemberWrites.js";
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
|
||||
const arrayArguments = (array: t.Node | undefined) =>
|
||||
t.isArrayExpression(array) &&
|
||||
array.elements.every((element) => t.isExpression(element))
|
||||
? array.elements.filter((element): element is t.Expression =>
|
||||
t.isExpression(element),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const invocationArguments = (
|
||||
method: "call" | "apply",
|
||||
arguments_: JavaScriptIntrinsicInvocation["arguments"],
|
||||
) =>
|
||||
t.isSpreadElement(arguments_?.[0])
|
||||
? undefined
|
||||
: method === "apply"
|
||||
? arrayArguments(arguments_?.[1])
|
||||
: arguments_?.slice(1);
|
||||
|
||||
/**
|
||||
* Normalize known invocation syntax once, retaining bound argument prefixes.
|
||||
* Binding a function alone is not a write; only its eventual call is examined.
|
||||
*/
|
||||
export const resolveJavaScriptIntrinsicInvocation = (
|
||||
node: t.CallExpression | t.OptionalCallExpression,
|
||||
intrinsicPath: (node: t.Node) => readonly string[] | undefined,
|
||||
boundIntrinsic: (node: t.Node) => JavaScriptIntrinsicInvocation | undefined,
|
||||
intrinsicReadChanged: (node: t.Node, path: readonly string[]) => boolean,
|
||||
): JavaScriptIntrinsicInvocation | undefined => {
|
||||
const visited = new Set<t.Node>();
|
||||
let callee: t.Node = node.callee;
|
||||
let arguments_: JavaScriptIntrinsicInvocation["arguments"] = node.arguments;
|
||||
let receiver: t.Node | undefined;
|
||||
let explicitReceiver = false;
|
||||
for (;;) {
|
||||
callee = unwrapJavaScriptExpression(callee).node;
|
||||
if (visited.has(callee)) return undefined;
|
||||
visited.add(callee);
|
||||
let path = intrinsicPath(callee);
|
||||
if (path !== undefined && intrinsicReadChanged(callee, path))
|
||||
return { kind: "opaque", path, arguments: undefined };
|
||||
if (path === undefined) {
|
||||
if (
|
||||
t.isMemberExpression(callee) ||
|
||||
t.isOptionalMemberExpression(callee)
|
||||
) {
|
||||
const method = semanticStaticPropertyKey(
|
||||
callee.property,
|
||||
callee.computed,
|
||||
);
|
||||
if (method === "call" || method === "apply") {
|
||||
const bound = boundIntrinsic(callee.object);
|
||||
if (
|
||||
bound !== undefined &&
|
||||
intrinsicReadChanged(callee, [...bound.path, method])
|
||||
)
|
||||
return { kind: "opaque", path: bound.path, arguments: undefined };
|
||||
receiver = arguments_?.[0];
|
||||
explicitReceiver = true;
|
||||
callee = callee.object;
|
||||
arguments_ = invocationArguments(method, arguments_);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const bound = boundIntrinsic(callee);
|
||||
if (bound === undefined) return undefined;
|
||||
if (bound.kind === "opaque") return bound;
|
||||
path = bound.path;
|
||||
receiver = bound.receiver;
|
||||
explicitReceiver = true;
|
||||
arguments_ =
|
||||
bound.arguments === undefined
|
||||
? undefined
|
||||
: [...bound.arguments, ...(arguments_ ?? [])];
|
||||
}
|
||||
const method = path.length > 3 ? path.at(-1) : undefined;
|
||||
if (method === "call" || method === "apply") {
|
||||
// These properties hold generic function values. Their receiver selects
|
||||
// the callable; property-chain depth does not consume more arguments.
|
||||
const callable: t.Node | undefined = explicitReceiver
|
||||
? receiver
|
||||
: t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)
|
||||
? callee.object
|
||||
: undefined;
|
||||
if (!t.isExpression(callable)) return undefined;
|
||||
callee = callable;
|
||||
receiver = arguments_?.[0];
|
||||
explicitReceiver = true;
|
||||
arguments_ = invocationArguments(method, arguments_);
|
||||
continue;
|
||||
}
|
||||
if (path.join(".") === "intrinsic.Reflect.apply") {
|
||||
const callable = arguments_?.[0];
|
||||
if (!t.isExpression(callable)) return undefined;
|
||||
callee = callable;
|
||||
receiver = arguments_?.[1];
|
||||
explicitReceiver = true;
|
||||
arguments_ = arrayArguments(arguments_?.[2]);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
kind: "known",
|
||||
path,
|
||||
arguments: arguments_,
|
||||
...(receiver === undefined ? {} : { receiver }),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// Rolldown runtime from the public T3 Code nightly 20261011.2967 main.cjs.
|
||||
// The input is inert source: none of these helpers or Electron APIs is executed.
|
||||
export const ROLLDOWN_MODULE_INTEROP = `
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
||||
key = keys[i];
|
||||
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
||||
get: ((k) => from[k]).bind(null, key),
|
||||
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
||||
});
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
||||
value: mod,
|
||||
enumerable: true
|
||||
}) : target, mod));
|
||||
`;
|
||||
@@ -0,0 +1,774 @@
|
||||
import { parseExpression } from "@babel/parser";
|
||||
import * as t from "@babel/types";
|
||||
|
||||
import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js";
|
||||
import {
|
||||
isUnshadowedGlobal,
|
||||
resolveSemanticBindingState,
|
||||
semanticScopeId,
|
||||
semanticStableInitializer,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
type JavaScriptSemanticBindingState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js";
|
||||
import {
|
||||
childNodes,
|
||||
javaScriptAstChildKeys,
|
||||
} from "./javascriptSemanticTraversal.js";
|
||||
import {
|
||||
electronMemberPath,
|
||||
type JavaScriptIntrinsicInvocation,
|
||||
} from "./javascriptElectronMemberWrites.js";
|
||||
import { resolveJavaScriptIntrinsicInvocation } from "./javascriptIntrinsicInvocation.js";
|
||||
import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js";
|
||||
import {
|
||||
readExactJavaScriptLiteral,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
|
||||
// Match the complete Rolldown runtime operations, including its live getters.
|
||||
// Names and formatting are irrelevant; lexical roles, operators and keys are not.
|
||||
const TO_ESM = parseExpression(`(mod, isNodeMode, target) => (
|
||||
target = mod != null ? __create(__getProtoOf(mod)) : {},
|
||||
__copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default")
|
||||
? __defProp(target, "default", {value: mod, enumerable: true}) : target, mod)
|
||||
)`);
|
||||
const COPY_PROPS = parseExpression(`(to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function")
|
||||
for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
||||
key = keys[i];
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, {
|
||||
get: ((k) => from[k]).bind(null, key),
|
||||
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
||||
});
|
||||
}
|
||||
return to;
|
||||
}`);
|
||||
const INTRINSICS: Readonly<Record<string, readonly string[]>> = {
|
||||
__create: ["Object", "create"],
|
||||
__getProtoOf: ["Object", "getPrototypeOf"],
|
||||
__defProp: ["Object", "defineProperty"],
|
||||
__getOwnPropNames: ["Object", "getOwnPropertyNames"],
|
||||
__getOwnPropDesc: ["Object", "getOwnPropertyDescriptor"],
|
||||
__hasOwnProp: ["Object", "prototype", "hasOwnProperty"],
|
||||
};
|
||||
|
||||
interface IntrinsicDependency {
|
||||
readonly path: readonly string[];
|
||||
readonly capturedAt?: t.Node;
|
||||
}
|
||||
|
||||
interface InteropOrigin {
|
||||
readonly origin: JavaScriptModuleOrigin;
|
||||
readonly dependencies: readonly IntrinsicDependency[];
|
||||
readonly defaultIsModule: boolean;
|
||||
}
|
||||
|
||||
interface InteropHelper {
|
||||
readonly dependencies: readonly IntrinsicDependency[];
|
||||
readonly readyAfter: number;
|
||||
readonly identity: boolean;
|
||||
}
|
||||
|
||||
/** Prove one inert namespace conversion without relaxing general assignment semantics. */
|
||||
export const createSemanticModuleInterop = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
) => {
|
||||
const helpers = new Map<string, InteropHelper | null>();
|
||||
const origins = new Map<string, InteropOrigin>();
|
||||
const calls = new WeakMap<t.Node, InteropOrigin>();
|
||||
const intrinsicBindings = new Map<string, readonly string[] | null>();
|
||||
const writes: {
|
||||
readonly scope: string;
|
||||
readonly offset: number;
|
||||
readonly coverage: SemanticPropertyPathCoverage;
|
||||
}[] = [];
|
||||
const invalidatedBindings = new Map<string, boolean>();
|
||||
const invalidatedCalls = new WeakMap<t.Node, boolean>();
|
||||
const opaqueMutationPath = (node: t.Node): readonly string[] => {
|
||||
let current: t.Node | undefined = node;
|
||||
while (current !== undefined) {
|
||||
if (t.isFunction(current))
|
||||
return ["opaque-intrinsic", semanticScopeId("function", current)];
|
||||
current = state.parentsByNode.get(current);
|
||||
}
|
||||
return ["intrinsic"];
|
||||
};
|
||||
|
||||
const intrinsicPath = (node: t.Node): readonly string[] | undefined => {
|
||||
let current = node;
|
||||
const visited = new Set<string>();
|
||||
const pending: { id: string; suffixLength: number }[] = [];
|
||||
let suffix: readonly string[] = [];
|
||||
const finish = (
|
||||
path: readonly string[] | undefined,
|
||||
): readonly string[] | undefined => {
|
||||
for (const binding of pending)
|
||||
intrinsicBindings.set(
|
||||
binding.id,
|
||||
path === undefined
|
||||
? null
|
||||
: path.slice(0, path.length - binding.suffixLength),
|
||||
);
|
||||
return path;
|
||||
};
|
||||
for (;;) {
|
||||
const { root, members } = electronMemberPath(current);
|
||||
if (members.some((member) => member === null)) return finish(undefined);
|
||||
suffix = [...(members as readonly string[]), ...suffix];
|
||||
for (const global of ["Object", "Function", "Reflect"])
|
||||
if (isUnshadowedGlobal(root, state, global))
|
||||
return finish(["intrinsic", global, ...suffix]);
|
||||
for (const global of ["globalThis", "global", "window", "self"])
|
||||
if (isUnshadowedGlobal(root, state, global))
|
||||
return finish(["intrinsic", ...suffix]);
|
||||
if (!t.isIdentifier(root)) return finish(undefined);
|
||||
const binding = resolveSemanticBindingState(state, root, root.name);
|
||||
const cached =
|
||||
binding === undefined
|
||||
? undefined
|
||||
: intrinsicBindings.get(binding.bindingId);
|
||||
if (cached !== undefined)
|
||||
return finish(cached === null ? undefined : [...cached, ...suffix]);
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (
|
||||
binding === undefined ||
|
||||
initializer === undefined ||
|
||||
initializer.projection.some((segment) => segment === null) ||
|
||||
visited.has(binding.bindingId)
|
||||
)
|
||||
return finish(undefined);
|
||||
visited.add(binding.bindingId);
|
||||
pending.push({ id: binding.bindingId, suffixLength: suffix.length });
|
||||
suffix = [...initializer.projection.map(String), ...suffix];
|
||||
current = initializer.node;
|
||||
}
|
||||
};
|
||||
|
||||
// A nominal alias path is sufficient for conservative mutation lookup, but
|
||||
// helper proof also needs each read to follow initialization. An alias copy
|
||||
// retains the earlier function capture; a member read captures it anew.
|
||||
const capturedIntrinsic = (node: t.Node): IntrinsicDependency | undefined => {
|
||||
const path = intrinsicPath(node);
|
||||
if (path === undefined) return undefined;
|
||||
const visited = new Set<string>();
|
||||
let current = node;
|
||||
let capturedAt = node;
|
||||
let suffixLength = 0;
|
||||
for (;;) {
|
||||
const { root, members } = electronMemberPath(current);
|
||||
suffixLength += members.length;
|
||||
if (!t.isIdentifier(root)) return undefined;
|
||||
const binding = resolveSemanticBindingState(state, root, root.name);
|
||||
if (binding === undefined) return { path, capturedAt };
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (
|
||||
initializer === undefined ||
|
||||
(initializer.node.end ?? Infinity) >= (root.start ?? -1) ||
|
||||
visited.has(binding.bindingId)
|
||||
)
|
||||
return undefined;
|
||||
visited.add(binding.bindingId);
|
||||
if (suffixLength === 0) capturedAt = initializer.node;
|
||||
suffixLength += initializer.projection.length;
|
||||
current = initializer.node;
|
||||
}
|
||||
};
|
||||
|
||||
const staticString = (node: t.Node | undefined): string | undefined => {
|
||||
if (node === undefined) return undefined;
|
||||
const visited = new Set<string>();
|
||||
let current = node;
|
||||
for (;;) {
|
||||
const literal = readExactJavaScriptLiteral(current);
|
||||
if (literal.found)
|
||||
return typeof literal.value === "string" ? literal.value : undefined;
|
||||
const expression = unwrapJavaScriptExpression(current).node;
|
||||
if (!t.isIdentifier(expression)) return undefined;
|
||||
const binding = resolveSemanticBindingState(
|
||||
state,
|
||||
expression,
|
||||
expression.name,
|
||||
);
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (
|
||||
binding === undefined ||
|
||||
initializer === undefined ||
|
||||
initializer.projection.length !== 0 ||
|
||||
(initializer.node.end ?? Infinity) >= (expression.start ?? -1) ||
|
||||
visited.has(binding.bindingId)
|
||||
)
|
||||
return undefined;
|
||||
visited.add(binding.bindingId);
|
||||
current = initializer.node;
|
||||
}
|
||||
};
|
||||
|
||||
const intrinsicReadChanged = (
|
||||
node: t.Node,
|
||||
path: readonly string[],
|
||||
): boolean => {
|
||||
const capture = capturedIntrinsic(node)?.capturedAt ?? node;
|
||||
const scope = opaqueMutationPath(capture).join(".");
|
||||
const method = path.at(-1);
|
||||
const invocationMethod =
|
||||
method === "bind" || method === "call" || method === "apply";
|
||||
return writes.some(
|
||||
(write) =>
|
||||
(write.scope === scope || write.scope === "intrinsic") &&
|
||||
write.offset < (capture.start ?? -1) &&
|
||||
(write.coverage.covers(path) ||
|
||||
(invocationMethod &&
|
||||
write.coverage.covers([
|
||||
"intrinsic",
|
||||
"Function",
|
||||
"prototype",
|
||||
method,
|
||||
]))),
|
||||
);
|
||||
};
|
||||
|
||||
const resolveBoundIntrinsic = (
|
||||
node: t.Node,
|
||||
): JavaScriptIntrinsicInvocation | undefined => {
|
||||
const visited = new Set<string>();
|
||||
const argumentGroups: t.CallExpression["arguments"][] = [];
|
||||
const bindReads: t.Node[] = [];
|
||||
let opaqueArguments = false;
|
||||
let receiver: t.Node | undefined;
|
||||
let current = node;
|
||||
for (;;) {
|
||||
current = unwrapJavaScriptExpression(current).node;
|
||||
if (t.isIdentifier(current)) {
|
||||
const binding = resolveSemanticBindingState(
|
||||
state,
|
||||
current,
|
||||
current.name,
|
||||
);
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (
|
||||
binding === undefined ||
|
||||
initializer === undefined ||
|
||||
initializer.projection.length !== 0 ||
|
||||
(initializer.node.end ?? Infinity) >= (current.start ?? -1) ||
|
||||
visited.has(binding.bindingId)
|
||||
)
|
||||
return undefined;
|
||||
visited.add(binding.bindingId);
|
||||
current = initializer.node;
|
||||
continue;
|
||||
}
|
||||
if (!t.isCallExpression(current)) return undefined;
|
||||
const { root, members } = electronMemberPath(current.callee);
|
||||
let bindTarget = root;
|
||||
let bindingArguments: JavaScriptIntrinsicInvocation["arguments"] =
|
||||
current.arguments;
|
||||
let captured = capturedIntrinsic(current.callee);
|
||||
let terminal = captured?.path.at(-1) === "bind";
|
||||
let path: readonly string[] | undefined = captured?.path.slice(0, -1);
|
||||
if (members.at(-1) === "bind") {
|
||||
// A direct .bind may continue a bound callable, without discarding members.
|
||||
if (!terminal && members.length !== 1) return undefined;
|
||||
bindReads.push(captured?.capturedAt ?? current.callee);
|
||||
} else {
|
||||
// Reuse the invocation owner for generic call/apply/Reflect.apply of bind.
|
||||
const invocation = resolveJavaScriptIntrinsicInvocation(
|
||||
current,
|
||||
intrinsicPath,
|
||||
boundIntrinsic,
|
||||
intrinsicReadChanged,
|
||||
);
|
||||
if (
|
||||
invocation?.kind !== "known" ||
|
||||
invocation.path.join(".") !== "intrinsic.Function.prototype.bind" ||
|
||||
invocation.receiver === undefined
|
||||
)
|
||||
return undefined;
|
||||
bindTarget = invocation.receiver;
|
||||
bindingArguments = invocation.arguments;
|
||||
captured = capturedIntrinsic(bindTarget);
|
||||
terminal = captured !== undefined;
|
||||
path = captured?.path;
|
||||
}
|
||||
receiver = bindingArguments?.[0];
|
||||
const ownArguments = bindingArguments?.slice(1) ?? [];
|
||||
opaqueArguments ||=
|
||||
bindingArguments === undefined || t.isSpreadElement(receiver);
|
||||
opaqueArguments ||= ownArguments.some((argument) =>
|
||||
t.isSpreadElement(argument),
|
||||
);
|
||||
argumentGroups.push(ownArguments);
|
||||
if (!terminal || path === undefined) {
|
||||
current = bindTarget;
|
||||
continue;
|
||||
}
|
||||
const boundPath = [...path, "bind"];
|
||||
const functionBind = ["intrinsic", "Function", "prototype", "bind"];
|
||||
opaqueArguments ||= bindReads.some((capture) => {
|
||||
const scope = opaqueMutationPath(capture).join(".");
|
||||
return writes.some(
|
||||
(write) =>
|
||||
(write.scope === scope || write.scope === "intrinsic") &&
|
||||
write.offset < (capture.start ?? -1) &&
|
||||
(write.coverage.covers(boundPath) ||
|
||||
write.coverage.covers(functionBind)),
|
||||
);
|
||||
});
|
||||
return {
|
||||
kind: opaqueArguments ? "opaque" : "known",
|
||||
path,
|
||||
...(receiver === undefined ? {} : { receiver }),
|
||||
// Rebinding appends arguments to the earlier bound argument list.
|
||||
arguments: opaqueArguments
|
||||
? undefined
|
||||
: argumentGroups.toReversed().flat(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const activeBoundValues = new WeakSet<t.Node>();
|
||||
const boundIntrinsic = (
|
||||
node: t.Node,
|
||||
): JavaScriptIntrinsicInvocation | undefined => {
|
||||
if (activeBoundValues.has(node)) return undefined;
|
||||
activeBoundValues.add(node);
|
||||
try {
|
||||
return resolveBoundIntrinsic(node);
|
||||
} finally {
|
||||
activeBoundValues.delete(node);
|
||||
}
|
||||
};
|
||||
|
||||
const isFreshObject = (node: t.Node): boolean => {
|
||||
const visited = new Set<string>();
|
||||
let current = node;
|
||||
for (;;) {
|
||||
current = unwrapJavaScriptExpression(current).node;
|
||||
if (
|
||||
t.isObjectExpression(current) ||
|
||||
t.isArrayExpression(current) ||
|
||||
t.isFunction(current) ||
|
||||
(t.isClass(current) && !current.decorators?.length) ||
|
||||
t.isRegExpLiteral(current)
|
||||
)
|
||||
return true;
|
||||
if (!t.isIdentifier(current)) return false;
|
||||
const binding = resolveSemanticBindingState(state, current, current.name);
|
||||
const initializer = unprojectedInitializer(binding, state);
|
||||
if (
|
||||
binding === undefined ||
|
||||
initializer === undefined ||
|
||||
(!t.isFunctionDeclaration(initializer) &&
|
||||
(initializer.end ?? Infinity) >= (current.start ?? -1)) ||
|
||||
visited.has(binding.bindingId)
|
||||
)
|
||||
return false;
|
||||
visited.add(binding.bindingId);
|
||||
current = initializer;
|
||||
}
|
||||
};
|
||||
|
||||
const helperProof = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
): InteropHelper | undefined => {
|
||||
const cached = helpers.get(binding.bindingId);
|
||||
if (cached !== undefined) return cached ?? undefined;
|
||||
helpers.set(binding.bindingId, null);
|
||||
if (state.scopesById.get(binding.scopeId)?.kind !== "program")
|
||||
return undefined;
|
||||
const wrapper = unprojectedInitializer(binding, state);
|
||||
if (wrapper === undefined) return undefined;
|
||||
if (identityFunction(wrapper, state)) {
|
||||
const proof = {
|
||||
dependencies: [],
|
||||
readyAfter: t.isFunctionDeclaration(wrapper)
|
||||
? -1
|
||||
: (wrapper.end ?? Infinity),
|
||||
identity: true,
|
||||
};
|
||||
helpers.set(binding.bindingId, proof);
|
||||
return proof;
|
||||
}
|
||||
const wrapperRoles = canonicalRoles(wrapper, TO_ESM, state);
|
||||
const copyBinding = wrapperRoles?.get("__copyProps");
|
||||
const copy = unprojectedInitializer(copyBinding, state);
|
||||
const copyRoles =
|
||||
copy === undefined ? undefined : canonicalRoles(copy, COPY_PROPS, state);
|
||||
if (
|
||||
wrapperRoles === undefined ||
|
||||
copyRoles === undefined ||
|
||||
copy === undefined
|
||||
)
|
||||
return undefined;
|
||||
const dependencies: IntrinsicDependency[] = [];
|
||||
let readyAfter = Math.max(wrapper.end ?? Infinity, copy.end ?? Infinity);
|
||||
for (const roles of [wrapperRoles, copyRoles])
|
||||
for (const [role, dependency] of roles) {
|
||||
const expected = INTRINSICS[role];
|
||||
if (expected === undefined) continue;
|
||||
const initializer = unprojectedInitializer(dependency, state);
|
||||
const captured =
|
||||
initializer === undefined
|
||||
? undefined
|
||||
: capturedIntrinsic(initializer);
|
||||
if (
|
||||
initializer === undefined ||
|
||||
captured === undefined ||
|
||||
JSON.stringify(captured.path) !==
|
||||
JSON.stringify(["intrinsic", ...expected])
|
||||
)
|
||||
return undefined;
|
||||
readyAfter = Math.max(readyAfter, initializer.end ?? Infinity);
|
||||
dependencies.push(captured);
|
||||
if (role === "__hasOwnProp")
|
||||
dependencies.push({ path: [...captured.path, "call"] });
|
||||
}
|
||||
dependencies.push(
|
||||
{ path: ["intrinsic", "Function", "prototype", "bind"] },
|
||||
{ path: ["intrinsic", "Function", "prototype", "call"] },
|
||||
);
|
||||
const proof = {
|
||||
dependencies,
|
||||
identity: false,
|
||||
readyAfter,
|
||||
};
|
||||
helpers.set(binding.bindingId, proof);
|
||||
return proof;
|
||||
};
|
||||
|
||||
const provenCall = (node: t.Node) => {
|
||||
if (!t.isCallExpression(node) || !t.isIdentifier(node.callee))
|
||||
return undefined;
|
||||
const helper = resolveSemanticBindingState(
|
||||
state,
|
||||
node.callee,
|
||||
node.callee.name,
|
||||
);
|
||||
const proof = helper === undefined ? undefined : helperProof(helper);
|
||||
const input = node.arguments[0];
|
||||
const mode = node.arguments[1];
|
||||
const literalMode =
|
||||
mode === undefined
|
||||
? { found: true, value: false }
|
||||
: t.isExpression(mode)
|
||||
? readExactJavaScriptLiteral(mode)
|
||||
: { found: false };
|
||||
if (
|
||||
proof === undefined ||
|
||||
proof.readyAfter >= (node.start ?? -1) ||
|
||||
!t.isExpression(input) ||
|
||||
node.arguments.length > 2 ||
|
||||
!literalMode.found
|
||||
)
|
||||
return undefined;
|
||||
return {
|
||||
input,
|
||||
// A truthy node mode forces the module-valued default. Other modes
|
||||
// preserve named exports without proving the source's default property.
|
||||
defaultIsModule: !proof.identity && Boolean(literalMode.value),
|
||||
dependencies: proof.dependencies.map((dependency) => ({
|
||||
...dependency,
|
||||
capturedAt: dependency.capturedAt ?? node,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const callOrigin = (node: t.Node): InteropOrigin | undefined => {
|
||||
const cached = calls.get(node);
|
||||
if (cached !== undefined) return cached;
|
||||
const { root, members } = electronMemberPath(node);
|
||||
if (!members.every((member): member is string => member !== null))
|
||||
return undefined;
|
||||
const converted = provenCall(root);
|
||||
const required =
|
||||
converted === undefined
|
||||
? undefined
|
||||
: semanticRequireOrigin(converted.input, state)?.origin;
|
||||
if (
|
||||
converted === undefined ||
|
||||
required === undefined ||
|
||||
required.importedPath.length !== 0 ||
|
||||
(members[0] === "default" && !converted.defaultIsModule)
|
||||
)
|
||||
return undefined;
|
||||
const result = {
|
||||
origin: { ...required, importedPath: members },
|
||||
dependencies: converted.dependencies,
|
||||
defaultIsModule: converted.defaultIsModule,
|
||||
};
|
||||
calls.set(node, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const origin = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
): InteropOrigin | undefined => {
|
||||
const single = semanticStableInitializer(binding, state);
|
||||
if (single !== undefined) {
|
||||
if (
|
||||
!single.projection.every(
|
||||
(key): key is string => typeof key === "string",
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
const converted = callOrigin(single.node);
|
||||
if (
|
||||
converted?.origin.importedPath.length === 0 &&
|
||||
single.projection[0] === "default" &&
|
||||
!converted.defaultIsModule
|
||||
)
|
||||
return undefined;
|
||||
const result =
|
||||
converted === undefined
|
||||
? undefined
|
||||
: {
|
||||
...converted,
|
||||
origin: {
|
||||
...converted.origin,
|
||||
importedPath: [
|
||||
...converted.origin.importedPath,
|
||||
...single.projection,
|
||||
],
|
||||
},
|
||||
};
|
||||
if (result !== undefined) origins.set(binding.bindingId, result);
|
||||
return result;
|
||||
}
|
||||
const [initial, converted] = binding.initializers;
|
||||
if (
|
||||
binding.definitions.length !== 2 ||
|
||||
binding.definitions[0]?.kind !== "variable" ||
|
||||
binding.definitions[1]?.kind !== "assignment" ||
|
||||
binding.initializers.length !== 2 ||
|
||||
initial === undefined ||
|
||||
converted === undefined ||
|
||||
initial.projection.length !== 0 ||
|
||||
converted.projection.length !== 0 ||
|
||||
state.conditionalInitializers.has(initial.node)
|
||||
)
|
||||
return undefined;
|
||||
const call = converted.node;
|
||||
const assignment = state.parentsByNode.get(call);
|
||||
const statement =
|
||||
assignment === undefined
|
||||
? undefined
|
||||
: state.parentsByNode.get(assignment);
|
||||
const declaration = state.parentsByNode.get(initial.node);
|
||||
const declarationStatement =
|
||||
declaration === undefined
|
||||
? undefined
|
||||
: state.parentsByNode.get(declaration);
|
||||
if (
|
||||
!t.isCallExpression(call) ||
|
||||
!t.isIdentifier(call.callee) ||
|
||||
!t.isAssignmentExpression(assignment, { operator: "=" }) ||
|
||||
assignment.right !== call ||
|
||||
!t.isIdentifier(assignment.left, { name: binding.name }) ||
|
||||
!t.isExpressionStatement(statement) ||
|
||||
!t.isVariableDeclarator(declaration) ||
|
||||
!t.isVariableDeclaration(declarationStatement) ||
|
||||
state.parentsByNode.get(statement) !==
|
||||
state.parentsByNode.get(declarationStatement) ||
|
||||
(initial.node.start ?? Infinity) >= (call.start ?? -1)
|
||||
)
|
||||
return undefined;
|
||||
const convertedCall = provenCall(call);
|
||||
const input = convertedCall?.input;
|
||||
if (
|
||||
!t.isIdentifier(input) ||
|
||||
resolveSemanticBindingState(state, input, input.name) !== binding
|
||||
)
|
||||
return undefined;
|
||||
const required = semanticRequireOrigin(initial.node, state)?.origin;
|
||||
if (required === undefined || required.importedPath.length !== 0)
|
||||
return undefined;
|
||||
if (convertedCall === undefined) return undefined;
|
||||
const result = {
|
||||
origin: required,
|
||||
dependencies: convertedCall.dependencies,
|
||||
defaultIsModule: convertedCall.defaultIsModule,
|
||||
};
|
||||
origins.set(binding.bindingId, result);
|
||||
return result;
|
||||
};
|
||||
const invalidatedOrigin = (record: InteropOrigin | undefined): boolean =>
|
||||
record?.dependencies.some(({ path, capturedAt }) => {
|
||||
if (capturedAt === undefined) return true;
|
||||
const scope = opaqueMutationPath(capturedAt).join(".");
|
||||
// Captured function values survive later replacement of global
|
||||
// properties. Other callable bodies are not execution evidence.
|
||||
return writes.some(
|
||||
(write) =>
|
||||
(write.scope === scope || write.scope === "intrinsic") &&
|
||||
write.offset < (capturedAt.start ?? -1) &&
|
||||
write.coverage.covers(path),
|
||||
);
|
||||
}) ?? false;
|
||||
return {
|
||||
origin,
|
||||
callOrigin,
|
||||
intrinsicPath,
|
||||
intrinsicInvocation: (node: t.CallExpression | t.OptionalCallExpression) =>
|
||||
resolveJavaScriptIntrinsicInvocation(
|
||||
node,
|
||||
intrinsicPath,
|
||||
boundIntrinsic,
|
||||
intrinsicReadChanged,
|
||||
),
|
||||
boundIntrinsic,
|
||||
staticString,
|
||||
isFreshObject,
|
||||
opaqueMutationPath,
|
||||
retainWrite: (node: t.Node, path: readonly (string | null)[]) => {
|
||||
const coverage = new SemanticPropertyPathCoverage();
|
||||
coverage.retain(path[0] === "opaque-intrinsic" ? ["intrinsic"] : path);
|
||||
writes.push({
|
||||
scope: opaqueMutationPath(node).join("."),
|
||||
offset: node.start ?? Infinity,
|
||||
coverage,
|
||||
});
|
||||
},
|
||||
invalidated: (binding: JavaScriptSemanticBindingState): boolean => {
|
||||
const cached = invalidatedBindings.get(binding.bindingId);
|
||||
if (cached !== undefined) return cached;
|
||||
const result = invalidatedOrigin(origins.get(binding.bindingId));
|
||||
invalidatedBindings.set(binding.bindingId, result);
|
||||
return result;
|
||||
},
|
||||
invalidatedCall: (node: t.Node): boolean => {
|
||||
const cached = invalidatedCalls.get(node);
|
||||
if (cached !== undefined) return cached;
|
||||
const result = invalidatedOrigin(calls.get(node));
|
||||
invalidatedCalls.set(node, result);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const identityFunction = (
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
if (
|
||||
!t.isFunction(node) ||
|
||||
node.async ||
|
||||
node.generator ||
|
||||
node.params.length !== 1
|
||||
)
|
||||
return false;
|
||||
const parameter = node.params[0];
|
||||
const result = t.isBlockStatement(node.body)
|
||||
? node.body.body.length === 1 && t.isReturnStatement(node.body.body[0])
|
||||
? node.body.body[0].argument
|
||||
: undefined
|
||||
: node.body;
|
||||
return (
|
||||
t.isIdentifier(parameter) &&
|
||||
t.isIdentifier(result) &&
|
||||
resolveSemanticBindingState(state, parameter, parameter.name) !==
|
||||
undefined &&
|
||||
resolveSemanticBindingState(state, parameter, parameter.name) ===
|
||||
resolveSemanticBindingState(state, result, result.name)
|
||||
);
|
||||
};
|
||||
|
||||
const unprojectedInitializer = (
|
||||
binding: JavaScriptSemanticBindingState | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): t.Node | undefined => {
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
return initializer?.projection.length === 0 ? initializer.node : undefined;
|
||||
};
|
||||
|
||||
/** Match operations and lexical roles, allowing single-statement control blocks. */
|
||||
const canonicalRoles = (
|
||||
actual: t.Node,
|
||||
pattern: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): ReadonlyMap<string, JavaScriptSemanticBindingState> | undefined => {
|
||||
const roles = new Map<string, JavaScriptSemanticBindingState>();
|
||||
const used = new Map<string, string>();
|
||||
const pending: { actual: t.Node; pattern: t.Node; parent: t.Node | null }[] =
|
||||
[{ actual, pattern, parent: null }];
|
||||
while (pending.length !== 0) {
|
||||
const pair = pending.pop();
|
||||
if (pair === undefined) return undefined;
|
||||
const { parent } = pair;
|
||||
const controlBody =
|
||||
(t.isIfStatement(parent) &&
|
||||
(parent.consequent === pair.pattern ||
|
||||
parent.alternate === pair.pattern)) ||
|
||||
(t.isForStatement(parent) && parent.body === pair.pattern);
|
||||
const node = controlBody ? controlBodyOperation(pair.actual) : pair.actual;
|
||||
const expected = controlBody
|
||||
? controlBodyOperation(pair.pattern)
|
||||
: pair.pattern;
|
||||
if (node.type !== expected.type) return undefined;
|
||||
const literalKey =
|
||||
parent !== null &&
|
||||
((t.isMemberExpression(parent) &&
|
||||
!parent.computed &&
|
||||
parent.property === expected) ||
|
||||
(t.isObjectProperty(parent) &&
|
||||
!parent.computed &&
|
||||
parent.key === expected));
|
||||
if (t.isIdentifier(expected) && t.isIdentifier(node) && !literalKey) {
|
||||
const binding = resolveSemanticBindingState(state, node, node.name);
|
||||
if (
|
||||
binding === undefined ||
|
||||
(roles.has(expected.name) && roles.get(expected.name) !== binding) ||
|
||||
(used.has(binding.bindingId) &&
|
||||
used.get(binding.bindingId) !== expected.name)
|
||||
)
|
||||
return undefined;
|
||||
roles.set(expected.name, binding);
|
||||
used.set(binding.bindingId, expected.name);
|
||||
}
|
||||
const visitorKeys = javaScriptAstChildKeys(expected);
|
||||
for (const [key, field] of Object.entries(
|
||||
t.NODE_FIELDS[expected.type] ?? {},
|
||||
)) {
|
||||
if (
|
||||
visitorKeys.includes(key) ||
|
||||
(t.isIdentifier(expected) && key === "name" && !literalKey)
|
||||
)
|
||||
continue;
|
||||
const fallback: unknown = Reflect.get(field, "default");
|
||||
if (
|
||||
JSON.stringify(Reflect.get(node, key) ?? fallback) !==
|
||||
JSON.stringify(Reflect.get(expected, key) ?? fallback)
|
||||
)
|
||||
return undefined;
|
||||
}
|
||||
const children = childNodes(node),
|
||||
expectedChildren = childNodes(expected);
|
||||
if (children.length !== expectedChildren.length) return undefined;
|
||||
for (const [index, child] of children.entries()) {
|
||||
const expectedChild = expectedChildren[index];
|
||||
if (expectedChild !== undefined)
|
||||
pending.push({
|
||||
actual: child,
|
||||
pattern: expectedChild,
|
||||
parent: expected,
|
||||
});
|
||||
}
|
||||
}
|
||||
return roles;
|
||||
};
|
||||
|
||||
// Braces around one control-body statement do not add operations. Keep other
|
||||
// blocks, directives and extra statements intact; their semantics need proof.
|
||||
// Lexical declarations/references still pass through canonicalRoles' bijection.
|
||||
const controlBodyOperation = (node: t.Node): t.Node => {
|
||||
while (
|
||||
t.isBlockStatement(node) &&
|
||||
node.directives.length === 0 &&
|
||||
node.body.length === 1
|
||||
) {
|
||||
const statement = node.body[0];
|
||||
if (statement === undefined) break;
|
||||
node = statement;
|
||||
}
|
||||
return node;
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
classifyPackageImportSpecifier,
|
||||
classifyPackageImportTargetString,
|
||||
lookupExactPackageImport,
|
||||
} from "./javascriptPackageImportAliases.js";
|
||||
|
||||
describe("package import specifier classification", () => {
|
||||
it("treats an exact hash alias as an exact key", () => {
|
||||
expect(classifyPackageImportSpecifier("#config")).toEqual({
|
||||
kind: "exact",
|
||||
key: "#config",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["#internal/env", "#/hidden"])(
|
||||
"treats %j as an exact key",
|
||||
(specifier) => {
|
||||
expect(classifyPackageImportSpecifier(specifier)).toEqual({
|
||||
kind: "exact",
|
||||
key: specifier,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["#", "#/", "#utils/"])(
|
||||
"rejects reserved specifier %j",
|
||||
(specifier) => {
|
||||
expect(classifyPackageImportSpecifier(specifier)).toEqual({
|
||||
kind: "invalid",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["#internal/*", "#*"])(
|
||||
"leaves pattern specifier %j unresolved",
|
||||
(specifier) => {
|
||||
expect(classifyPackageImportSpecifier(specifier)).toEqual({
|
||||
kind: "pattern",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("exact package import lookup", () => {
|
||||
it("returns the exact target without falling through to another key", () => {
|
||||
expect(
|
||||
lookupExactPackageImport(
|
||||
{ "#config": "./config.cjs", "#other": "./other.cjs" },
|
||||
"#config",
|
||||
),
|
||||
).toEqual({
|
||||
kind: "found",
|
||||
target: "./config.cjs",
|
||||
declared: ["#config", "#other"],
|
||||
});
|
||||
});
|
||||
|
||||
it("names declared exact keys when the requested alias is absent", () => {
|
||||
expect(
|
||||
lookupExactPackageImport({ "#config": "./config.cjs" }, "#missing"),
|
||||
).toEqual({
|
||||
kind: "missing-key",
|
||||
declared: ["#config"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not claim a miss when the map only has pattern keys", () => {
|
||||
expect(
|
||||
lookupExactPackageImport({ "#internal/*": "./src/*.js" }, "#internal/a"),
|
||||
).toEqual({
|
||||
kind: "pattern-unresolved",
|
||||
declared: ["#internal/*"],
|
||||
});
|
||||
});
|
||||
|
||||
it("still selects an exact key when the same map also has patterns", () => {
|
||||
expect(
|
||||
lookupExactPackageImport(
|
||||
{ "#config": "./config.cjs", "#internal/*": "./src/*.js" },
|
||||
"#config",
|
||||
),
|
||||
).toEqual({
|
||||
kind: "found",
|
||||
target: "./config.cjs",
|
||||
declared: ["#config", "#internal/*"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([undefined, null, "./config.cjs", ["#config"], 7] as const)(
|
||||
"treats non-object imports value %j as an undefined map",
|
||||
(imports) => {
|
||||
expect(lookupExactPackageImport(imports, "#config")).toEqual({
|
||||
kind: "undefined-map",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not treat a pattern key as an exact match for itself", () => {
|
||||
expect(
|
||||
lookupExactPackageImport({ "#internal/*": "./src/*.js" }, "#internal/*"),
|
||||
).toEqual({
|
||||
kind: "pattern-unresolved",
|
||||
declared: ["#internal/*"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("package import target string classification", () => {
|
||||
it("keeps package-local relative targets", () => {
|
||||
expect(classifyPackageImportTargetString("./config.cjs")).toEqual({
|
||||
kind: "relative",
|
||||
target: "./config.cjs",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks recursive hash aliases as recursive", () => {
|
||||
expect(classifyPackageImportTargetString("#other")).toEqual({
|
||||
kind: "recursive",
|
||||
target: "#other",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["lodash", "pkg/subpath"])(
|
||||
"marks %j as an external-package target",
|
||||
(target) => {
|
||||
expect(classifyPackageImportTargetString(target)).toEqual({
|
||||
kind: "external-package",
|
||||
target,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"../outside.js",
|
||||
"/outside.js",
|
||||
"https://example.invalid/mod.js",
|
||||
"file:///outside.js",
|
||||
"node:fs",
|
||||
])("marks %j as an invalid import target", (target) => {
|
||||
expect(classifyPackageImportTargetString(target)).toEqual({
|
||||
kind: "invalid",
|
||||
target,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Exact package-private import-alias lookup. Node resolves `#` specifiers
|
||||
* against the importer's enclosing package `imports` map; this module owns the
|
||||
* specifier and exact-key rules. Condition, array, and null target selection
|
||||
* stay with the shared package-target machinery. Wildcard keys, recursive
|
||||
* `#` targets, and external-package targets remain explicit unknowns.
|
||||
*/
|
||||
|
||||
/** How one `#` specifier participates in exact import-alias lookup. */
|
||||
export type PackageImportSpecifierClassification =
|
||||
| { readonly kind: "invalid" }
|
||||
| { readonly kind: "pattern" }
|
||||
| { readonly kind: "exact"; readonly key: string };
|
||||
|
||||
/** Outcome of looking up one exact `#` key in a package `imports` object. */
|
||||
export type PackageImportExactLookup =
|
||||
| { readonly kind: "undefined-map" }
|
||||
| {
|
||||
readonly kind: "missing-key";
|
||||
readonly declared: readonly string[];
|
||||
}
|
||||
| {
|
||||
readonly kind: "pattern-unresolved";
|
||||
readonly declared: readonly string[];
|
||||
}
|
||||
| {
|
||||
readonly kind: "found";
|
||||
readonly target: unknown;
|
||||
readonly declared: readonly string[];
|
||||
};
|
||||
|
||||
/** How a selected string target is interpreted for package-private imports. */
|
||||
export type PackageImportTargetClassification =
|
||||
| { readonly kind: "relative"; readonly target: string }
|
||||
| { readonly kind: "invalid"; readonly target: string }
|
||||
| { readonly kind: "recursive"; readonly target: string }
|
||||
| { readonly kind: "external-package"; readonly target: string };
|
||||
|
||||
/**
|
||||
* Classify a `#` module specifier before any package-scope walk.
|
||||
* `#` and names ending in `/` are invalid. Other literal keys, including
|
||||
* `#/…`, participate in exact lookup; wildcard expansion remains unsupported.
|
||||
*/
|
||||
export const classifyPackageImportSpecifier = (
|
||||
specifier: string,
|
||||
): PackageImportSpecifierClassification => {
|
||||
if (specifier === "#" || specifier.endsWith("/")) return { kind: "invalid" };
|
||||
if (specifier.includes("*")) return { kind: "pattern" };
|
||||
return { kind: "exact", key: specifier };
|
||||
};
|
||||
|
||||
/** True when an `imports` key is a pattern rather than an exact alias. */
|
||||
export const isPackageImportPatternKey = (key: string): boolean =>
|
||||
key.includes("*") || key.endsWith("/");
|
||||
|
||||
/**
|
||||
* Look up one exact `#` key in a package `imports` value.
|
||||
* A missing exact key is reported as missing when every declared key is also
|
||||
* exact; if the map contains pattern keys, the miss stays pattern-unresolved
|
||||
* rather than claiming the specifier is undefined.
|
||||
*/
|
||||
export const lookupExactPackageImport = (
|
||||
imports: unknown,
|
||||
key: string,
|
||||
): PackageImportExactLookup => {
|
||||
if (typeof imports !== "object" || imports === null || Array.isArray(imports))
|
||||
return { kind: "undefined-map" };
|
||||
const declared = Object.keys(imports);
|
||||
if (Object.hasOwn(imports, key) && !isPackageImportPatternKey(key))
|
||||
return {
|
||||
kind: "found",
|
||||
target: Reflect.get(imports, key),
|
||||
declared,
|
||||
};
|
||||
return declared.some(isPackageImportPatternKey)
|
||||
? { kind: "pattern-unresolved", declared }
|
||||
: { kind: "missing-key", declared };
|
||||
};
|
||||
|
||||
/**
|
||||
* Classify a selected string import target. Only package-local `./` paths are
|
||||
* resolved as files; recursive `#` aliases and bare packages stay unknown.
|
||||
* Parent-relative, absolute, and URL targets are invalid, allowing array
|
||||
* selection to skip them before choosing a package-local fallback.
|
||||
*/
|
||||
export const classifyPackageImportTargetString = (
|
||||
target: string,
|
||||
): PackageImportTargetClassification => {
|
||||
if (target.startsWith("./")) return { kind: "relative", target };
|
||||
if (
|
||||
target.startsWith("../") ||
|
||||
target.startsWith("/") ||
|
||||
URL.canParse(target)
|
||||
)
|
||||
return { kind: "invalid", target };
|
||||
if (target.startsWith("#")) return { kind: "recursive", target };
|
||||
return { kind: "external-package", target };
|
||||
};
|
||||
@@ -195,6 +195,32 @@ describe("tRPC binding identity and bundled syntax", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains one client initializer across variable redeclarations", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
var c;
|
||||
var c = createTRPCClient({});
|
||||
var c;
|
||||
c.users.query();
|
||||
|
||||
var replaced;
|
||||
var replaced = createTRPCClient({});
|
||||
replaced = pool;
|
||||
replaced.posts.query();
|
||||
|
||||
var initializedTwice = createTRPCClient({});
|
||||
var initializedTwice = pool;
|
||||
initializedTwice.comments.query();
|
||||
|
||||
var conditional;
|
||||
if (flag) { var conditional = createTRPCClient({}); }
|
||||
conditional.groups.query();
|
||||
`),
|
||||
).toEqual([
|
||||
{ kind: "network", value: "users", mechanism: "trpc:procedure:query" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes surviving factory names inside bundled call wrappers", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
|
||||
@@ -179,6 +179,34 @@ describe("JavaScript semantic analysis: calls 2", () => {
|
||||
).not.toBe(left.functionFingerprints[0]?.components.literalSetSha256);
|
||||
});
|
||||
|
||||
it("keeps nested callable syntax out of its owner's fingerprint", () => {
|
||||
const analyze = (nested: number, owned: number) =>
|
||||
analyzeJavaScriptSemantics(`
|
||||
function outer() {
|
||||
function inner() { return ${nested}; }
|
||||
const object = { method() { return ${nested}; } };
|
||||
class Nested { method() { return ${nested}; } }
|
||||
return ${owned};
|
||||
}
|
||||
`);
|
||||
const first = analyze(1, 2);
|
||||
const changedNested = analyze(3, 2);
|
||||
const changedOwner = analyze(1, 4);
|
||||
const syntax = (ir: typeof first) =>
|
||||
ir.functionFingerprints.map(({ components }) => ({
|
||||
ast: components.normalizedAstSha256,
|
||||
control: components.controlFlowSha256,
|
||||
literals: components.literalSetSha256,
|
||||
}));
|
||||
const original = syntax(first);
|
||||
expect(original).toHaveLength(4);
|
||||
expect(syntax(changedNested)[0]).toEqual(original[0]);
|
||||
for (const index of [1, 2, 3])
|
||||
expect(syntax(changedNested)[index]).not.toEqual(original[index]);
|
||||
expect(syntax(changedOwner)[0]).not.toEqual(original[0]);
|
||||
expect(syntax(changedOwner).slice(1)).toEqual(original.slice(1));
|
||||
});
|
||||
|
||||
it("keeps fingerprint relations ordered and effects scoped to their owner", () => {
|
||||
const ordered = analyzeJavaScriptSemantics(`
|
||||
function first() {}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import {
|
||||
semanticObjectPatternKeys,
|
||||
semanticStaticPropertyKey,
|
||||
semanticWriteTargets,
|
||||
} from "./javascriptAstValues.js";
|
||||
import type {
|
||||
JavaScriptSemanticAnalysisState,
|
||||
@@ -31,16 +32,14 @@ import {
|
||||
resolveSemanticBindingFromScope,
|
||||
semanticBindingId,
|
||||
semanticScopeId,
|
||||
semanticStableInitializer,
|
||||
semanticVariableScope,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import {
|
||||
completeSemanticSteps,
|
||||
traverseJavaScriptAstSteps,
|
||||
} from "./javascriptSemanticTraversal.js";
|
||||
import {
|
||||
semanticNamespaceInteropOperand,
|
||||
semanticRequireOrigin,
|
||||
} from "./javascriptSemanticRequireOrigin.js";
|
||||
import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js";
|
||||
import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js";
|
||||
import {
|
||||
collectSemanticReturns,
|
||||
@@ -49,7 +48,7 @@ import {
|
||||
import { collectJavaScriptDerivedSemanticsSteps } from "./javascriptSemanticDerivedAnalysis.js";
|
||||
import { evaluateSemanticProvenance } from "./javascriptSemanticValues.js";
|
||||
import {
|
||||
collectElectronMemberWritesSteps,
|
||||
collectJavaScriptIdentityWritesSteps,
|
||||
electronExportPath,
|
||||
electronMemberPath,
|
||||
ELECTRON_MODULE,
|
||||
@@ -64,6 +63,8 @@ import {
|
||||
} from "./javascriptSourceParser.js";
|
||||
|
||||
import { isJavaScriptRpcClientFactory } from "./javascriptRpcEndpoints.js";
|
||||
import { createSemanticModuleInterop } from "./javascriptModuleInterop.js";
|
||||
import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js";
|
||||
|
||||
interface BindPatternInput {
|
||||
readonly pattern: t.Node;
|
||||
@@ -205,13 +206,10 @@ export function* classifyParsedJavaScriptRpcClientRootsSteps(
|
||||
if (binding === undefined) return;
|
||||
let client = clients.get(binding.bindingId);
|
||||
if (client === undefined) {
|
||||
const [initializer] = binding.initializers;
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
client =
|
||||
initializer !== undefined &&
|
||||
initializer.projection.length === 0 &&
|
||||
binding.initializers.length === 1 &&
|
||||
!binding.definitions.some(({ kind }) => kind === "assignment") &&
|
||||
!state.conditionalInitializers.has(initializer.node) &&
|
||||
isJavaScriptRpcClientFactory(initializer.node);
|
||||
clients.set(binding.bindingId, client);
|
||||
}
|
||||
@@ -374,6 +372,9 @@ export function* classifyParsedJavaScriptElectronBindingsSteps(
|
||||
const facts = new Map<number, readonly string[]>();
|
||||
const state = createState(file.program);
|
||||
yield* collectDefinitionsSteps(file.program, state);
|
||||
const interop = createSemanticModuleInterop(state);
|
||||
const writes = new SemanticPropertyPathCoverage();
|
||||
let collectedWrites = false;
|
||||
const exportsByBinding = new Map<string, readonly string[] | null>();
|
||||
const rootExport = (root: t.Node): readonly string[] | undefined => {
|
||||
if (t.isIdentifier(root)) {
|
||||
@@ -382,23 +383,45 @@ export function* classifyParsedJavaScriptElectronBindingsSteps(
|
||||
if (binding === undefined) return undefined;
|
||||
let exported = exportsByBinding.get(binding.bindingId);
|
||||
if (exported === undefined) {
|
||||
exported = electronExport(binding, state);
|
||||
exported = electronExport(binding, state, interop);
|
||||
exportsByBinding.set(binding.bindingId, exported);
|
||||
}
|
||||
return exported ?? undefined;
|
||||
return collectedWrites && interop.invalidated(binding)
|
||||
? undefined
|
||||
: (exported ?? undefined);
|
||||
}
|
||||
const origin = semanticRequireOrigin(
|
||||
semanticNamespaceInteropOperand(root, state) ?? root,
|
||||
state,
|
||||
)?.origin;
|
||||
const wrapped = interop.callOrigin(root);
|
||||
const origin =
|
||||
wrapped === undefined
|
||||
? semanticRequireOrigin(root, state)?.origin
|
||||
: collectedWrites && interop.invalidatedCall(root)
|
||||
? undefined
|
||||
: wrapped.origin;
|
||||
return origin !== undefined && ELECTRON_MODULE.test(origin.specifier)
|
||||
? electronExportPath(origin.importedPath)
|
||||
: undefined;
|
||||
};
|
||||
const writes = yield* collectElectronMemberWritesSteps(
|
||||
yield* collectJavaScriptIdentityWritesSteps(
|
||||
file.program,
|
||||
rootExport,
|
||||
(root, kind) => {
|
||||
if (kind === "opaque-call") return interop.opaqueMutationPath(root);
|
||||
const exported = kind === "binding" ? undefined : rootExport(root);
|
||||
return exported === undefined
|
||||
? (interop.intrinsicPath(root) ??
|
||||
(kind === "member"
|
||||
? interop.boundIntrinsic(root)?.path
|
||||
: undefined))
|
||||
: ["electron", ...exported];
|
||||
},
|
||||
interop.intrinsicInvocation,
|
||||
interop.staticString,
|
||||
interop.isFreshObject,
|
||||
(node, path) => {
|
||||
if (path[0] === "electron") writes.retain(path);
|
||||
else interop.retainWrite(node, path);
|
||||
},
|
||||
);
|
||||
collectedWrites = true;
|
||||
yield* traverseJavaScriptAstSteps(file.program, {
|
||||
enter: (node) => {
|
||||
if (
|
||||
@@ -409,7 +432,10 @@ export function* classifyParsedJavaScriptElectronBindingsSteps(
|
||||
return;
|
||||
const { root, members } = electronMemberPath(node.callee);
|
||||
const exported = rootExport(root);
|
||||
if (exported !== undefined && !writes.covers([...exported, ...members]))
|
||||
if (
|
||||
exported !== undefined &&
|
||||
!writes.covers(["electron", ...exported, ...members])
|
||||
)
|
||||
facts.set(root.start ?? -1, exported);
|
||||
},
|
||||
});
|
||||
@@ -421,19 +447,16 @@ export function* classifyParsedJavaScriptElectronBindingsSteps(
|
||||
const electronExport = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
interop: ReturnType<typeof createSemanticModuleInterop>,
|
||||
): readonly string[] | null => {
|
||||
const initializers = namespacePreservingInitializers(binding, state);
|
||||
if (initializers === undefined) return null;
|
||||
const directOrigin = bindingOrigin(binding, initializers, state);
|
||||
const [initializer] = initializers;
|
||||
if (
|
||||
directOrigin === undefined &&
|
||||
(initializer === undefined ||
|
||||
initializers.length !== 1 ||
|
||||
initializers.length !== binding.initializers.length ||
|
||||
state.conditionalInitializers.has(initializer.node))
|
||||
)
|
||||
return null;
|
||||
const reassigned = binding.definitions.some(
|
||||
({ kind }) => kind === "assignment",
|
||||
);
|
||||
const converted = interop.origin(binding);
|
||||
if (reassigned && converted === undefined) return null;
|
||||
const directOrigin = converted?.origin ?? bindingOrigin(binding, state);
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (directOrigin === undefined && initializer === undefined) return null;
|
||||
const provenance =
|
||||
directOrigin === undefined
|
||||
? evaluateSemanticProvenance(binding, state)
|
||||
@@ -448,66 +471,17 @@ const electronExport = (
|
||||
: null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the initializers that can change which module a binding names.
|
||||
* Bundlers emit `name = __toESM(name)` after a CommonJS require; rewrapping
|
||||
* the binding's own value keeps every member, so it is not a reassignment.
|
||||
*/
|
||||
const namespacePreservingInitializers = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptSemanticBindingState["initializers"] | undefined => {
|
||||
const initializers = binding.initializers.filter(
|
||||
({ node }) => !rewrapsOwnNamespace(binding, node, state),
|
||||
);
|
||||
const rewraps = binding.initializers.length - initializers.length;
|
||||
const assignments = binding.definitions.filter(
|
||||
({ kind }) => kind === "assignment",
|
||||
).length;
|
||||
return assignments === rewraps ? initializers : undefined;
|
||||
};
|
||||
|
||||
const rewrapsOwnNamespace = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
const assignment = state.parentsByNode.get(node);
|
||||
const operand = semanticNamespaceInteropOperand(node, state);
|
||||
return (
|
||||
t.isAssignmentExpression(assignment) &&
|
||||
assignment.operator === "=" &&
|
||||
assignment.right === node &&
|
||||
t.isIdentifier(assignment.left, { name: binding.name }) &&
|
||||
t.isIdentifier(operand, { name: binding.name }) &&
|
||||
!semanticResolutionBlocked(state, operand, operand.name) &&
|
||||
resolveSemanticBindingState(state, operand, operand.name) === binding
|
||||
);
|
||||
};
|
||||
|
||||
const bindingOrigin = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
initializers: JavaScriptSemanticBindingState["initializers"],
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptModuleOrigin | undefined => {
|
||||
if (binding.directOrigins.length > 0)
|
||||
return binding.directOrigins.length === 1
|
||||
? binding.directOrigins[0]
|
||||
: undefined;
|
||||
const [initializer] = initializers;
|
||||
// A var/let declaration permits writes without proving one occurred.
|
||||
// Require one unconditional initializer and no actual binding writes.
|
||||
if (
|
||||
initializer === undefined ||
|
||||
initializers.length !== 1 ||
|
||||
state.conditionalInitializers.has(initializer.node)
|
||||
)
|
||||
return undefined;
|
||||
const origin = semanticRequireOrigin(
|
||||
semanticNamespaceInteropOperand(initializer.node, state) ??
|
||||
initializer.node,
|
||||
state,
|
||||
)?.origin;
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (initializer === undefined) return undefined;
|
||||
const origin = semanticRequireOrigin(initializer.node, state)?.origin;
|
||||
const projection = initializer.projection;
|
||||
return origin === undefined ||
|
||||
!projection.every((key): key is string => typeof key === "string")
|
||||
@@ -650,10 +624,11 @@ function* collectDefinitionsSteps(
|
||||
for (const [index, { body, scope, parameters }] of functionBodies.entries()) {
|
||||
if (index % 64 === 0) yield;
|
||||
for (const parameter of parameters)
|
||||
for (const identifier of assignedPatternIdentifiers(
|
||||
for (const identifier of semanticWriteTargets(
|
||||
t.isTSParameterProperty(parameter) ? parameter.parameter : parameter,
|
||||
))
|
||||
copyParameterToBody({ identifier, body, scope, state });
|
||||
if (t.isIdentifier(identifier))
|
||||
copyParameterToBody({ identifier, body, scope, state });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,16 +776,18 @@ const bindAssignment = (
|
||||
state,
|
||||
);
|
||||
else
|
||||
for (const identifier of assignedPatternIdentifiers(node.left))
|
||||
addAssignment(identifier, node, scope, state);
|
||||
for (const identifier of semanticWriteTargets(node.left))
|
||||
if (t.isIdentifier(identifier))
|
||||
addAssignment(identifier, node, scope, state);
|
||||
} else if (t.isUpdateExpression(node) && t.isIdentifier(node.argument))
|
||||
addAssignment(node.argument, node, scope, state);
|
||||
else if (t.isForOfStatement(node) || t.isForInStatement(node))
|
||||
for (const pattern of t.isVariableDeclaration(node.left)
|
||||
? node.left.declarations.map(({ id }) => id)
|
||||
: [node.left]) {
|
||||
for (const identifier of assignedPatternIdentifiers(pattern))
|
||||
addAssignment(identifier, node, scope, state);
|
||||
for (const identifier of semanticWriteTargets(pattern))
|
||||
if (t.isIdentifier(identifier))
|
||||
addAssignment(identifier, node, scope, state);
|
||||
if (t.isForOfStatement(node))
|
||||
// Keep the loop value unknown, but preserve references yielded into
|
||||
// each bound slot so writes/escapes can reach their iterable origins.
|
||||
@@ -825,30 +802,6 @@ const bindAssignment = (
|
||||
}
|
||||
};
|
||||
|
||||
const assignedPatternIdentifiers = (
|
||||
pattern: t.Node,
|
||||
): readonly t.Identifier[] => {
|
||||
const identifiers: t.Identifier[] = [];
|
||||
const pending = [pattern];
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop();
|
||||
if (node === undefined) continue;
|
||||
if (t.isIdentifier(node)) identifiers.push(node);
|
||||
else if (t.isRestElement(node)) pending.push(node.argument);
|
||||
else if (t.isAssignmentPattern(node)) pending.push(node.left);
|
||||
else if (t.isArrayPattern(node)) {
|
||||
for (const element of node.elements)
|
||||
if (element !== null) pending.push(element);
|
||||
} else if (t.isObjectPattern(node)) {
|
||||
for (const property of node.properties)
|
||||
pending.push(
|
||||
t.isRestElement(property) ? property.argument : property.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
return identifiers;
|
||||
};
|
||||
|
||||
const nestedScope = (
|
||||
node: t.Node,
|
||||
parent: t.Node | null,
|
||||
|
||||
@@ -63,6 +63,35 @@ it("traverses a large tree in steps with the synchronous visit order (#1462)", (
|
||||
expect(stepped).toEqual(synchronous);
|
||||
});
|
||||
|
||||
it("skips child subtrees without changing sibling ancestry or exit callbacks", () => {
|
||||
const parsed = parseJavaScriptSource("[1, [2], 3]", "selection.js");
|
||||
if (parsed === null) throw new Error("Expected parsed source");
|
||||
const entered: number[] = [];
|
||||
const exited: number[] = [];
|
||||
const parents: string[][] = [];
|
||||
const steps = traverseJavaScriptAstSteps(parsed.program, {
|
||||
skip: (node) => node.type === "ArrayExpression" && node.start === 4,
|
||||
enter: (node, parent, readAncestors) => {
|
||||
if (node.type !== "NumericLiteral") return;
|
||||
entered.push(node.value);
|
||||
expect(readAncestors().at(-1)).toBe(parent);
|
||||
parents.push(readAncestors().map(({ type }) => type));
|
||||
},
|
||||
exit: (node) => {
|
||||
if (node.type === "NumericLiteral") exited.push(node.value);
|
||||
},
|
||||
});
|
||||
while (steps.next().done !== true) {
|
||||
// Complete the same cooperative traversal used by fingerprint collection.
|
||||
}
|
||||
expect(entered).toEqual([1, 3]);
|
||||
expect(exited).toEqual([1, 3]);
|
||||
expect(parents).toEqual([
|
||||
["Program", "ExpressionStatement", "ArrayExpression"],
|
||||
["Program", "ExpressionStatement", "ArrayExpression"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves receiver and Electron alias scope across traversal pauses (#1462)", () => {
|
||||
const padding = Array.from(
|
||||
{ length: 400 },
|
||||
|
||||
@@ -73,7 +73,7 @@ export function* collectJavaScriptSemanticFingerprintsSteps(
|
||||
const node = state.callableNodesById.get(callable.callableId);
|
||||
if (node === undefined) continue;
|
||||
yield;
|
||||
const syntax = yield* fingerprintSyntaxSteps(node, callable.callableId);
|
||||
const syntax = yield* fingerprintSyntaxSteps(node);
|
||||
const promiseEffects = promiseEffectsByOwner.get(callable.callableId) ?? [];
|
||||
const eventEffects = eventEffectsByOwner.get(callable.callableId) ?? [];
|
||||
const timerEffects = timerEffectsByOwner.get(callable.callableId) ?? [];
|
||||
@@ -164,28 +164,19 @@ interface FingerprintSyntax {
|
||||
|
||||
function* fingerprintSyntaxSteps(
|
||||
root: t.Node,
|
||||
targetCallableId: string,
|
||||
): Generator<void, FingerprintSyntax> {
|
||||
const normalizedTokens: string[] = [];
|
||||
const controlFlowTokens: string[] = [];
|
||||
const literals: string[] = [];
|
||||
const callableStack: string[] = [];
|
||||
yield* traverseJavaScriptAstSteps(root, {
|
||||
skip: (node) => semanticCallableIdForNode(node) !== null,
|
||||
enter: (node, parent) => {
|
||||
const callableId = semanticCallableIdForNode(node);
|
||||
if (callableId !== null) callableStack.push(callableId);
|
||||
if (callableStack.at(-1) !== targetCallableId) return;
|
||||
normalizedTokens.push(normalizedNodeToken(node, parent));
|
||||
const control = controlFlowToken(node);
|
||||
if (control !== null) controlFlowTokens.push(control);
|
||||
const literal = semanticLiteral(node);
|
||||
if (literal !== null) literals.push(literal);
|
||||
},
|
||||
exit: (node) => {
|
||||
const callableId = semanticCallableIdForNode(node);
|
||||
if (callableId !== null && callableStack.at(-1) === callableId)
|
||||
callableStack.pop();
|
||||
},
|
||||
});
|
||||
return { normalizedTokens, controlFlowTokens, literals };
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||
import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js";
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
semanticMemberWriteTargets,
|
||||
semanticWriteTargets,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
|
||||
@@ -401,14 +401,15 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
markEscaped(expression.object, mutation);
|
||||
};
|
||||
const markTarget = (node: t.Node, mutation: t.Node): void => {
|
||||
for (const target of semanticMemberWriteTargets(node))
|
||||
markValue(
|
||||
target.object,
|
||||
[semanticStaticPropertyKey(target.property, target.computed)],
|
||||
new Set(),
|
||||
"write",
|
||||
mutation,
|
||||
);
|
||||
for (const target of semanticWriteTargets(node))
|
||||
if (!t.isIdentifier(target))
|
||||
markValue(
|
||||
target.object,
|
||||
[semanticStaticPropertyKey(target.property, target.computed)],
|
||||
new Set(),
|
||||
"write",
|
||||
mutation,
|
||||
);
|
||||
};
|
||||
yield* traverseJavaScriptAstSteps(program, {
|
||||
enter: (node, parent) => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
resolveSemanticBindingState,
|
||||
semanticResolutionBlocked,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
type JavaScriptSemanticBindingState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
@@ -59,73 +58,3 @@ export const semanticRequireOrigin = (
|
||||
call: current,
|
||||
};
|
||||
};
|
||||
|
||||
// esbuild/Rolldown, TypeScript, and Babel emit these helpers for namespace
|
||||
// imports of CommonJS modules; each returns an object exposing every own
|
||||
// member of its operand, so member paths through the result are unchanged.
|
||||
// Helper bodies are not evaluated: recognition trusts a single program-scope
|
||||
// function definition under one of these emitted names.
|
||||
const NAMESPACE_INTEROP_HELPERS = new Set([
|
||||
"__importStar",
|
||||
"__toESM",
|
||||
"_interopRequireWildcard",
|
||||
]);
|
||||
|
||||
/** Return the module operand of a locally declared namespace interop helper. */
|
||||
export const semanticNamespaceInteropOperand = (
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): t.Expression | undefined => {
|
||||
const call = unwrapJavaScriptExpression(node).node;
|
||||
if (
|
||||
!t.isCallExpression(call) ||
|
||||
!t.isIdentifier(call.callee) ||
|
||||
!NAMESPACE_INTEROP_HELPERS.has(call.callee.name) ||
|
||||
semanticResolutionBlocked(state, call.callee, call.callee.name)
|
||||
)
|
||||
return undefined;
|
||||
const helper = resolveSemanticBindingState(
|
||||
state,
|
||||
call.callee,
|
||||
call.callee.name,
|
||||
);
|
||||
const [operand] = call.arguments;
|
||||
return helper !== undefined &&
|
||||
emittedInteropHelper(helper, state) &&
|
||||
t.isExpression(operand)
|
||||
? operand
|
||||
: undefined;
|
||||
};
|
||||
|
||||
// Emitters declare each helper once at program scope as a function, an
|
||||
// arrow/function expression, or `(this && this.name) || <function>`.
|
||||
const emittedInteropHelper = (
|
||||
helper: JavaScriptSemanticBindingState,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
const [initializer] = helper.initializers;
|
||||
if (
|
||||
initializer === undefined ||
|
||||
helper.initializers.length !== 1 ||
|
||||
helper.definitions.length !== 1 ||
|
||||
(helper.kind !== "function" && helper.kind !== "variable") ||
|
||||
state.scopesById.get(helper.scopeId)?.kind !== "program"
|
||||
)
|
||||
return false;
|
||||
const value = unwrapJavaScriptExpression(initializer.node).node;
|
||||
const emitted =
|
||||
t.isLogicalExpression(value, { operator: "||" }) &&
|
||||
t.isLogicalExpression(unwrapJavaScriptExpression(value.left).node, {
|
||||
operator: "&&",
|
||||
})
|
||||
? unwrapJavaScriptExpression(value.right).node
|
||||
: value;
|
||||
return (
|
||||
t.isFunctionDeclaration(emitted) ||
|
||||
t.isFunctionExpression(emitted) ||
|
||||
t.isArrowFunctionExpression(emitted) ||
|
||||
(t.isCallExpression(emitted) &&
|
||||
(t.isFunctionExpression(emitted.callee) ||
|
||||
t.isArrowFunctionExpression(emitted.callee)))
|
||||
);
|
||||
};
|
||||
@@ -75,6 +75,30 @@ export interface JavaScriptSemanticAnalysisState {
|
||||
readonly conditionalInitializers: WeakSet<t.Node>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return one unconditional source initializer for a declared lexical binding.
|
||||
* Repeated variable declarations without another initializer do not write it.
|
||||
* This establishes a stable source fact only; it does not prove immutability or
|
||||
* that the declaration has executed at a particular program point.
|
||||
*/
|
||||
export const semanticStableInitializer = (
|
||||
binding: JavaScriptSemanticBindingState | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptSemanticBindingState["initializers"][number] | undefined => {
|
||||
const [initializer] = binding?.initializers ?? [];
|
||||
const [definition] = binding?.definitions ?? [];
|
||||
return binding !== undefined &&
|
||||
((definition?.kind === "variable" &&
|
||||
binding.definitions.every(({ kind }) => kind === "variable")) ||
|
||||
(binding.definitions.length === 1 &&
|
||||
(definition?.kind === "function" || definition?.kind === "class"))) &&
|
||||
binding.initializers.length === 1 &&
|
||||
initializer !== undefined &&
|
||||
!state.conditionalInitializers.has(initializer.node)
|
||||
? initializer
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Return the active scope from a non-empty construction stack. */
|
||||
export const currentSemanticScope = (
|
||||
stack: readonly JavaScriptSemanticScopeState[],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as t from "@babel/types";
|
||||
|
||||
/**
|
||||
* Visit every Babel AST node in deterministic source-tree order.
|
||||
* Visit Babel AST nodes in deterministic source-tree order, excluding skipped subtrees.
|
||||
*
|
||||
* `readAncestors` returns a snapshot during `enter`, outermost-first and
|
||||
* ending with the direct parent. Callers
|
||||
@@ -17,6 +17,8 @@ export const traverseJavaScriptAst = (
|
||||
|
||||
/** Callbacks for one deterministic AST traversal. */
|
||||
export interface JavaScriptAstVisitor {
|
||||
/** Omit a child and its subtree without entering or exiting it. */
|
||||
readonly skip?: (node: t.Node) => boolean;
|
||||
readonly enter: (
|
||||
node: t.Node,
|
||||
parent: t.Node | null,
|
||||
@@ -50,6 +52,7 @@ export function* traverseJavaScriptAstSteps(
|
||||
current.nextIndex += 1;
|
||||
if (visited % 1024 === 0) yield;
|
||||
visited += 1;
|
||||
if (visitor.skip?.(child) === true) continue;
|
||||
pending.push(enter(child, current.node));
|
||||
} else {
|
||||
visitor.exit?.(current.node, current.parent);
|
||||
@@ -82,8 +85,7 @@ interface TraversalFrame {
|
||||
* not be reintroduced (they diverge on loc/comment fields and ordering).
|
||||
*/
|
||||
export const childNodes = (node: t.Node): t.Node[] => {
|
||||
const keys = t.VISITOR_KEYS[node.type];
|
||||
if (keys === undefined) return [];
|
||||
const keys = javaScriptAstChildKeys(node);
|
||||
const children: t.Node[] = [];
|
||||
for (const key of keys) {
|
||||
const value: unknown = Reflect.get(node, key);
|
||||
@@ -98,3 +100,7 @@ export const childNodes = (node: t.Node): t.Node[] => {
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
/** Child-bearing fields; shared by traversal and structural AST comparison. */
|
||||
export const javaScriptAstChildKeys = (node: t.Node): readonly string[] =>
|
||||
t.VISITOR_KEYS[node.type] ?? [];
|
||||
@@ -249,10 +249,37 @@ const networkIdentity = (
|
||||
): string =>
|
||||
`net_${digestCanonicalValue({ method: request.method, url: request.url, resource_type: request.resource_type })}`;
|
||||
|
||||
const networkResponseFields = [
|
||||
"status",
|
||||
"mime_type",
|
||||
"encoded_data_length",
|
||||
] as const;
|
||||
type NetworkResponseField = (typeof networkResponseFields)[number];
|
||||
|
||||
const sharedNetworkResponseFields = (
|
||||
before: WebPageInspection,
|
||||
after: WebPageInspection,
|
||||
): ReadonlyMap<string, ReadonlySet<NetworkResponseField>> => {
|
||||
const shared = new Map<string, Set<NetworkResponseField>>();
|
||||
for (const inspection of [before, after])
|
||||
for (const request of inspection.network.requests) {
|
||||
const identity = networkIdentity(request);
|
||||
const fields = shared.get(identity) ?? new Set(networkResponseFields);
|
||||
for (const field of networkResponseFields)
|
||||
if (request[field] === null) fields.delete(field);
|
||||
shared.set(identity, fields);
|
||||
}
|
||||
return shared;
|
||||
};
|
||||
|
||||
const networkDimension = (
|
||||
before: WebPageInspection,
|
||||
after: WebPageInspection,
|
||||
): Dimension => {
|
||||
const responseFields = sharedNetworkResponseFields(before, after);
|
||||
const responseFieldsComplete = [...responseFields.values()].every(
|
||||
(fields) => fields.size === networkResponseFields.length,
|
||||
);
|
||||
const selected = bodyShapesSelected(before) && bodyShapesSelected(after);
|
||||
const webSocketShapes =
|
||||
webSocketShapesSelected(before) && webSocketShapesSelected(after);
|
||||
@@ -313,14 +340,14 @@ const networkDimension = (
|
||||
afterWebSockets,
|
||||
webSocketShapes,
|
||||
);
|
||||
return compareDimension(
|
||||
const compared = compareDimension(
|
||||
new Map([
|
||||
...networkMap(before, sources),
|
||||
...networkMap(before, sources, responseFields),
|
||||
...webSocketMetadataMap(beforeWebSockets),
|
||||
...beforeWebSocketPayload,
|
||||
]),
|
||||
new Map([
|
||||
...networkMap(after, sources),
|
||||
...networkMap(after, sources, responseFields),
|
||||
...webSocketMetadataMap(afterWebSockets),
|
||||
...afterWebSocketPayload,
|
||||
]),
|
||||
@@ -336,21 +363,31 @@ const networkDimension = (
|
||||
webSocketShapesComplete,
|
||||
"Network capture or selected HTTP/WebSocket payload-shape coverage is attach-window limited, unavailable, or incomplete.",
|
||||
);
|
||||
return compared.status === "unchanged" && !responseFieldsComplete
|
||||
? {
|
||||
...compared,
|
||||
status: "unknown",
|
||||
reason:
|
||||
"HTTP response status, MIME type, or byte-count evidence is incomplete.",
|
||||
}
|
||||
: compared;
|
||||
};
|
||||
|
||||
const networkMap = (
|
||||
inspection: WebPageInspection,
|
||||
sources: ReadonlyMap<string, BodyShapeSources>,
|
||||
responseFields: ReadonlyMap<string, ReadonlySet<NetworkResponseField>>,
|
||||
): ReadonlyMap<string, string> => {
|
||||
const grouped = new Map<string, unknown[]>();
|
||||
for (const request of inspection.network.requests) {
|
||||
const identity = networkIdentity(request);
|
||||
const shapes = sources.get(identity);
|
||||
const values = grouped.get(identity) ?? [];
|
||||
const response: Record<string, unknown> = {};
|
||||
for (const field of responseFields.get(identity) ?? [])
|
||||
response[field] = request[field];
|
||||
values.push({
|
||||
status: request.status,
|
||||
mime_type: request.mime_type,
|
||||
encoded_data_length: request.encoded_data_length,
|
||||
...response,
|
||||
redirects: (request.redirects ?? []).map(
|
||||
({
|
||||
request_timestamp: _requestTimestamp,
|
||||
|
||||
@@ -38,6 +38,18 @@ describe("managed projection admission", () => {
|
||||
});
|
||||
|
||||
describe("managed decoding bounds", () => {
|
||||
it("bounds new row identities without charging lookup replay or merging tables", () => {
|
||||
const budget = new ManagedDecodeBudget();
|
||||
budget.reserve(64 * 1024 * 1024 - 512, null);
|
||||
budget.reserveRow(2, 1, 100);
|
||||
budget.reserveRow(12, 1, 200);
|
||||
|
||||
expect(() => budget.reserveRow(2, 1, 100)).not.toThrow();
|
||||
expect(() => budget.reserveRow(2, 2, 300)).toThrow(ManagedReaderFailure);
|
||||
// A rejected row has no retained reservation; replay cannot bypass capacity.
|
||||
expect(() => budget.reserveRow(2, 2, 300)).toThrow(ManagedReaderFailure);
|
||||
});
|
||||
|
||||
it("keeps concurrent decoding bounds within one budget until released", () => {
|
||||
const budget = new ManagedDecodeBudget();
|
||||
const bound = 40 * 1024 * 1024;
|
||||
|
||||
@@ -15,6 +15,7 @@ interface CachedString {
|
||||
export class ManagedDecodeBudget {
|
||||
#remaining = MAX_METADATA_REPRESENTATION_BYTES;
|
||||
#transient = 0;
|
||||
readonly #rows = new Map<number, Set<number>>();
|
||||
readonly #strings = new Map<number, CachedString>();
|
||||
readonly #digests = new WeakMap<ArrayBufferLike, Map<string, string>>();
|
||||
|
||||
@@ -23,6 +24,18 @@ export class ManagedDecodeBudget {
|
||||
this.#remaining -= bytes;
|
||||
}
|
||||
|
||||
/** Retain one bookkeeping reservation per physical row, independently of lookup replay. */
|
||||
reserveRow(table: number, row: number, offset: number): void {
|
||||
let rows = this.#rows.get(table);
|
||||
if (rows?.has(row) === true) return;
|
||||
this.reserve(256, offset);
|
||||
if (rows === undefined) {
|
||||
rows = new Set<number>();
|
||||
this.#rows.set(table, rows);
|
||||
}
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
/** Bound a decoding allocation that stays live until `releaseTransient`; only retained output is reserved. */
|
||||
admitTransient(bytes: number, offset: number | null): void {
|
||||
this.#admit(bytes, offset);
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
readMetadataString,
|
||||
sha256Bytes,
|
||||
} from "./ManagedMetadataHeaps.js";
|
||||
import {
|
||||
memberListIndexSize,
|
||||
memberListTable,
|
||||
} from "./ManagedMemberPointers.js";
|
||||
|
||||
export type ManagedType = ManagedMemberInspection["types"][number];
|
||||
export type ManagedExportedType = NonNullable<
|
||||
@@ -93,8 +97,8 @@ const readTypeRange = (
|
||||
row: number,
|
||||
issues: ManagedParseIssue[],
|
||||
): TypeRange => {
|
||||
const methods = layout.table(6);
|
||||
const fields = layout.table(4);
|
||||
const methods = memberListTable(layout, 6);
|
||||
const fields = memberListTable(layout, 4);
|
||||
const cursor = metadataRowCursor(bytes, layout, 2, row);
|
||||
cursor.readUInt32();
|
||||
const nameIndex = cursor.readIndex(layout.stringIndexSize);
|
||||
@@ -114,8 +118,8 @@ const readTypeRange = (
|
||||
issues,
|
||||
) ?? null;
|
||||
cursor.readIndex(layout.codedIndexSize("TypeDefOrRef"));
|
||||
const fieldStart = cursor.readIndex(layout.tableIndexSize(4));
|
||||
const methodStart = cursor.readIndex(layout.tableIndexSize(6));
|
||||
const fieldStart = cursor.readIndex(memberListIndexSize(layout, 4));
|
||||
const methodStart = cursor.readIndex(memberListIndexSize(layout, 6));
|
||||
let nextField = (fields?.rowCount ?? 0) + 1;
|
||||
let nextMethod = (methods?.rowCount ?? 0) + 1;
|
||||
const typeTable = layout.table(2);
|
||||
@@ -125,8 +129,8 @@ const readTypeRange = (
|
||||
next.readIndex(layout.stringIndexSize);
|
||||
next.readIndex(layout.stringIndexSize);
|
||||
next.readIndex(layout.codedIndexSize("TypeDefOrRef"));
|
||||
nextField = next.readIndex(layout.tableIndexSize(4));
|
||||
nextMethod = next.readIndex(layout.tableIndexSize(6));
|
||||
nextField = next.readIndex(memberListIndexSize(layout, 4));
|
||||
nextMethod = next.readIndex(memberListIndexSize(layout, 6));
|
||||
}
|
||||
return {
|
||||
token: metadataToken(2, row),
|
||||
@@ -209,8 +213,8 @@ export const parseTypes = (
|
||||
readonly issues: readonly ManagedParseIssue[];
|
||||
} => {
|
||||
const typeTable = layout.table(2);
|
||||
const fields = layout.table(4);
|
||||
const methods = layout.table(6);
|
||||
const fields = memberListTable(layout, 4);
|
||||
const methods = memberListTable(layout, 6);
|
||||
const items: ManagedType[] = [];
|
||||
const issues: ManagedParseIssue[] = [];
|
||||
for (let row = 1; row <= (typeTable?.rowCount ?? 0); row += 1) {
|
||||
@@ -233,8 +237,8 @@ export const parseTypes = (
|
||||
const extendsRaw = cursor.readIndex(
|
||||
layout.codedIndexSize("TypeDefOrRef"),
|
||||
);
|
||||
const fieldStart = cursor.readIndex(layout.tableIndexSize(4));
|
||||
const methodStart = cursor.readIndex(layout.tableIndexSize(6));
|
||||
const fieldStart = cursor.readIndex(memberListIndexSize(layout, 4));
|
||||
const methodStart = cursor.readIndex(memberListIndexSize(layout, 6));
|
||||
const nextRange = ranges[row] ?? null;
|
||||
const fieldRange = rowRange(
|
||||
fields,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
attachManagedDecodeBudget,
|
||||
ManagedDecodeBudget,
|
||||
} from "./ManagedDecodeBudget.js";
|
||||
import { memberListRowLookup } from "./ManagedMemberPointers.js";
|
||||
import { readManagedMetadataLayout } from "./ManagedMetadataLayout.js";
|
||||
import { buildManagedMemberPointerFixture } from "./ManagedPe.fixture.js";
|
||||
import { readManagedPeLayout } from "./ManagedPeReader.js";
|
||||
import { ManagedReaderFailure } from "./ManagedReaderFailure.js";
|
||||
|
||||
const representationCapacity = 64 * 1024 * 1024;
|
||||
|
||||
const pointerFixture = () => {
|
||||
const bytes = buildManagedMemberPointerFixture();
|
||||
const pe = readManagedPeLayout(bytes);
|
||||
if (pe.cli === null) throw new Error("Fixture has no CLI metadata");
|
||||
const layout = readManagedMetadataLayout(
|
||||
bytes,
|
||||
pe.rvaToOffset(pe.cli.metadata.rva, pe.cli.metadata.size, "cli.metadata"),
|
||||
pe.cli.metadata.size,
|
||||
);
|
||||
// A fresh owner lets the test set exact remaining capacity independently of
|
||||
// the metadata version/string admission already exercised by the parser.
|
||||
const budget = new ManagedDecodeBudget();
|
||||
attachManagedDecodeBudget(layout, budget);
|
||||
const indexBytes =
|
||||
((layout.table(6)?.rowCount ?? 0) + 1) * Uint32Array.BYTES_PER_ELEMENT +
|
||||
256;
|
||||
return { bytes, layout, budget, indexBytes };
|
||||
};
|
||||
|
||||
describe("managed member pointer index capacity", () => {
|
||||
it("rejects insufficient capacity before allocating the inverse index", () => {
|
||||
const { bytes, layout, budget, indexBytes } = pointerFixture();
|
||||
budget.reserve(representationCapacity - indexBytes + 1, null);
|
||||
let allocations = 0;
|
||||
let failure: unknown;
|
||||
// Proxy forwards static BYTES_PER_ELEMENT too: losing it would produce a
|
||||
// NaN admission failure and falsely pass a before-allocation assertion.
|
||||
vi.stubGlobal(
|
||||
"Uint32Array",
|
||||
new Proxy(Uint32Array, {
|
||||
construct(target, arguments_, newTarget) {
|
||||
allocations += 1;
|
||||
return Reflect.construct(target, arguments_, newTarget);
|
||||
},
|
||||
}),
|
||||
);
|
||||
try {
|
||||
memberListRowLookup(bytes, layout, 6);
|
||||
} catch (cause: unknown) {
|
||||
failure = cause;
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
expect(failure).toBeInstanceOf(ManagedReaderFailure);
|
||||
expect(failure).toMatchObject({
|
||||
issue: {
|
||||
code: "resource-limit",
|
||||
scope: "metadata.representation",
|
||||
offset: layout.table(5)?.offset,
|
||||
},
|
||||
});
|
||||
expect(allocations).toBe(0);
|
||||
});
|
||||
|
||||
it("reuses one layout's index without allocating or charging it again", () => {
|
||||
const { bytes, layout, budget, indexBytes } = pointerFixture();
|
||||
budget.reserve(representationCapacity - indexBytes, null);
|
||||
let allocations = 0;
|
||||
let observed: number[];
|
||||
vi.stubGlobal(
|
||||
"Uint32Array",
|
||||
new Proxy(Uint32Array, {
|
||||
construct(target, arguments_, newTarget) {
|
||||
allocations += 1;
|
||||
return Reflect.construct(target, arguments_, newTarget);
|
||||
},
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const first = memberListRowLookup(bytes, layout, 6);
|
||||
const repeated = memberListRowLookup(bytes, layout, 6);
|
||||
observed = [first(1), first(2), repeated(1), repeated(2)];
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
expect(observed).toEqual([2, 1, 2, 1]);
|
||||
expect(allocations).toBe(1);
|
||||
expect(() => budget.reserve(1, null)).toThrow(ManagedReaderFailure);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { inspectManagedArtifactBytes } from "./ManagedArtifactInspector.js";
|
||||
import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js";
|
||||
import { readManagedMetadataLayout } from "./ManagedMetadataLayout.js";
|
||||
import { readManagedPeLayout } from "./ManagedPeReader.js";
|
||||
import {
|
||||
buildManagedMemberPointerFixture,
|
||||
managedPeFixtureTarget,
|
||||
} from "./ManagedPe.fixture.js";
|
||||
|
||||
const metadataLayout = (bytes: Buffer) => {
|
||||
const pe = readManagedPeLayout(bytes);
|
||||
if (pe.cli === null) throw new Error("Fixture omitted CLI metadata");
|
||||
return readManagedMetadataLayout(
|
||||
bytes,
|
||||
pe.rvaToOffset(pe.cli.metadata.rva, pe.cli.metadata.size, "cli.metadata"),
|
||||
pe.cli.metadata.size,
|
||||
);
|
||||
};
|
||||
|
||||
describe("managed member pointer ownership", () => {
|
||||
it.each([
|
||||
{ table: "fields" as const, prefix: "0x040000" },
|
||||
{ table: "methods" as const, prefix: "0x060000" },
|
||||
])("maps reordered pointer slots to physical $table", ({ table, prefix }) => {
|
||||
const bytes = buildManagedMemberPointerFixture();
|
||||
const result = inspectManagedMembersBytes(
|
||||
bytes,
|
||||
managedPeFixtureTarget(bytes),
|
||||
);
|
||||
|
||||
expect(result.coverage).toEqual({ state: "complete", issues: [] });
|
||||
expect(result.types.map((type) => type.full_name)).toEqual([
|
||||
"<Module>",
|
||||
"Fixture.A",
|
||||
"Fixture.B",
|
||||
]);
|
||||
expect(
|
||||
result[table].map(({ token, declaring_type, declaring_type_token }) => ({
|
||||
token,
|
||||
declaring_type,
|
||||
declaring_type_token,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
token: `${prefix}01`,
|
||||
declaring_type: "Fixture.B",
|
||||
declaring_type_token: "0x02000003",
|
||||
},
|
||||
{
|
||||
token: `${prefix}02`,
|
||||
declaring_type: "Fixture.A",
|
||||
declaring_type_token: "0x02000002",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves a local custom-attribute constructor through MethodPtr", () => {
|
||||
const bytes = buildManagedMemberPointerFixture();
|
||||
const target = managedPeFixtureTarget(bytes);
|
||||
const artifact = inspectManagedArtifactBytes(bytes, target);
|
||||
const members = inspectManagedMembersBytes(bytes, target);
|
||||
|
||||
expect(artifact.metadata.stream_names).toContain("#-");
|
||||
expect(artifact.coverage).toEqual({ state: "complete", issues: [] });
|
||||
expect(artifact.attributes).toEqual([
|
||||
expect.objectContaining({
|
||||
parent_token: "0x20000001",
|
||||
constructor_token: "0x06000001",
|
||||
type_name: "Fixture.B",
|
||||
}),
|
||||
]);
|
||||
expect(artifact.attributes[0]?.type_name).toBe(
|
||||
members.methods[0]?.declaring_type,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "identity pointer tables",
|
||||
fieldPointers: [1, 2],
|
||||
methodPointers: [1, 2],
|
||||
streamName: "#-" as const,
|
||||
},
|
||||
{
|
||||
label: "ordinary direct lists",
|
||||
fieldPointers: null,
|
||||
methodPointers: null,
|
||||
streamName: "#~" as const,
|
||||
},
|
||||
{
|
||||
label: "empty pointer tables with direct lists",
|
||||
fieldPointers: [],
|
||||
methodPointers: [],
|
||||
streamName: "#-" as const,
|
||||
},
|
||||
])("preserves $label", ({ fieldPointers, methodPointers, streamName }) => {
|
||||
const bytes = buildManagedMemberPointerFixture({
|
||||
fieldPointers,
|
||||
methodPointers,
|
||||
streamName,
|
||||
});
|
||||
const target = managedPeFixtureTarget(bytes);
|
||||
const members = inspectManagedMembersBytes(bytes, target);
|
||||
const artifact = inspectManagedArtifactBytes(bytes, target);
|
||||
|
||||
expect(members.coverage).toEqual({ state: "complete", issues: [] });
|
||||
expect(members.fields.map((field) => field.declaring_type)).toEqual([
|
||||
"Fixture.A",
|
||||
"Fixture.B",
|
||||
]);
|
||||
expect(members.methods.map((method) => method.declaring_type)).toEqual([
|
||||
"Fixture.A",
|
||||
"Fixture.B",
|
||||
]);
|
||||
expect(artifact.attributes[0]?.type_name).toBe("Fixture.A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid managed member pointers", () => {
|
||||
describe.each([
|
||||
{
|
||||
table: "fields" as const,
|
||||
pointers: "fieldPointers" as const,
|
||||
scope: "metadata.FieldPtr",
|
||||
prefix: "0x040000",
|
||||
names: ["firstField", "secondField"],
|
||||
independent: "methods" as const,
|
||||
},
|
||||
{
|
||||
table: "methods" as const,
|
||||
pointers: "methodPointers" as const,
|
||||
scope: "metadata.MethodPtr",
|
||||
prefix: "0x060000",
|
||||
names: [".ctor", ".ctor"],
|
||||
independent: "fields" as const,
|
||||
},
|
||||
])(
|
||||
"invalid $table pointers",
|
||||
({ table, pointers, scope, prefix, names, independent }) => {
|
||||
it.each([
|
||||
{ label: "zero row", rows: [0, 1], owners: [null, null] },
|
||||
{ label: "out-of-range row", rows: [3, 1], owners: [null, null] },
|
||||
{ label: "duplicate row", rows: [1, 1], owners: [null, null] },
|
||||
{ label: "omitted row", rows: [1], owners: ["Fixture.A", null] },
|
||||
])(
|
||||
"preserves member facts and independent ownership for $label",
|
||||
({ rows, owners }) => {
|
||||
const bytes = buildManagedMemberPointerFixture({ [pointers]: rows });
|
||||
const result = inspectManagedMembersBytes(
|
||||
bytes,
|
||||
managedPeFixtureTarget(bytes),
|
||||
);
|
||||
|
||||
expect(result.coverage.state).toBe("partial");
|
||||
expect(result.coverage.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "invalid-row",
|
||||
scope,
|
||||
offset: expect.any(Number),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
result[table].map(
|
||||
({ token, name, declaring_type, declaring_type_token }) => ({
|
||||
token,
|
||||
name,
|
||||
declaring_type,
|
||||
declaring_type_token,
|
||||
}),
|
||||
),
|
||||
).toEqual(
|
||||
owners.map((owner, index) => ({
|
||||
token: `${prefix}0${index + 1}`,
|
||||
name: names[index],
|
||||
declaring_type: owner,
|
||||
declaring_type_token: owner === null ? null : "0x02000002",
|
||||
})),
|
||||
);
|
||||
expect(
|
||||
result[independent].map((member) => member.declaring_type),
|
||||
).toEqual(["Fixture.B", "Fixture.A"]);
|
||||
expect(
|
||||
result.coverage.issues.some(
|
||||
(issue) =>
|
||||
issue.scope ===
|
||||
(table === "fields"
|
||||
? "metadata.MethodPtr"
|
||||
: "metadata.FieldPtr"),
|
||||
),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ label: "zero row", methodPointers: [2, 0] },
|
||||
{ label: "duplicate row", methodPointers: [2, 2] },
|
||||
{ label: "omitted constructor row", methodPointers: [2] },
|
||||
])(
|
||||
"preserves attribute facts with an unknown constructor owner for $label",
|
||||
({ methodPointers }) => {
|
||||
const controlBytes = buildManagedMemberPointerFixture();
|
||||
const control = inspectManagedArtifactBytes(
|
||||
controlBytes,
|
||||
managedPeFixtureTarget(controlBytes),
|
||||
).attributes[0];
|
||||
if (control === undefined)
|
||||
throw new Error("Fixture omitted custom attribute");
|
||||
expect(control.type_name).toBe("Fixture.B");
|
||||
const bytes = buildManagedMemberPointerFixture({ methodPointers });
|
||||
const result = inspectManagedArtifactBytes(
|
||||
bytes,
|
||||
managedPeFixtureTarget(bytes),
|
||||
);
|
||||
|
||||
expect(result.coverage.state).toBe("partial");
|
||||
expect(result.coverage.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "invalid-row",
|
||||
scope: "metadata.MethodPtr",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.attributes).toEqual([
|
||||
{ ...control, row_offset: expect.any(Number), type_name: null },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ name: "FieldPtr", table: 3 },
|
||||
{ name: "MethodPtr", table: 5 },
|
||||
])("rejects a table stream ending inside a $name row", ({ table }) => {
|
||||
const bytes = buildManagedMemberPointerFixture();
|
||||
const layout = metadataLayout(bytes);
|
||||
const pointers = layout.table(table);
|
||||
if (pointers === undefined)
|
||||
throw new Error("Fixture omitted pointer table");
|
||||
const versionLength = bytes.readUInt32LE(layout.rootOffset + 12);
|
||||
const firstStreamHeader =
|
||||
((layout.rootOffset + 16 + versionLength + 3) & ~3) + 4;
|
||||
const streamNameEnd = bytes.indexOf(0, firstStreamHeader + 8);
|
||||
expect(bytes.toString("ascii", firstStreamHeader + 8, streamNameEnd)).toBe(
|
||||
"#-",
|
||||
);
|
||||
const tablesOffset =
|
||||
layout.rootOffset + bytes.readUInt32LE(firstStreamHeader);
|
||||
bytes.writeUInt32LE(
|
||||
pointers.offset + pointers.rowSize - 1 - tablesOffset,
|
||||
firstStreamHeader + 4,
|
||||
);
|
||||
|
||||
const result = inspectManagedMembersBytes(
|
||||
bytes,
|
||||
managedPeFixtureTarget(bytes),
|
||||
);
|
||||
expect(result.coverage).toMatchObject({
|
||||
state: "unavailable",
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
code: "invalid-metadata-root",
|
||||
scope: "metadata.table-data",
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(result.fields).toEqual([]);
|
||||
expect(result.methods).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
|
||||
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
|
||||
import {
|
||||
managedFailure,
|
||||
ManagedReaderFailure,
|
||||
} from "./ManagedReaderFailure.js";
|
||||
|
||||
type MemberTable = 4 | 6;
|
||||
type ListRowLookup = (definitionRow: number) => number;
|
||||
const lookups = new WeakMap<
|
||||
ManagedMetadataLayout,
|
||||
Map<MemberTable, ListRowLookup | ManagedReaderFailure>
|
||||
>();
|
||||
|
||||
/** TypeDef list coordinates refer to the nonempty pointer table when present. */
|
||||
export const memberListTable = (
|
||||
layout: ManagedMetadataLayout,
|
||||
table: MemberTable,
|
||||
) => {
|
||||
const pointers = layout.table(table - 1);
|
||||
return pointers !== undefined && pointers.rowCount > 0
|
||||
? pointers
|
||||
: layout.table(table);
|
||||
};
|
||||
|
||||
/** List columns must accommodate either physical definition or pointer rows. */
|
||||
export const memberListIndexSize = (
|
||||
layout: ManagedMetadataLayout,
|
||||
table: MemberTable,
|
||||
): 2 | 4 =>
|
||||
layout.tableIndexSize(table - 1) === 4 || layout.tableIndexSize(table) === 4
|
||||
? 4
|
||||
: 2;
|
||||
|
||||
/** Resolve physical definition rows to list positions without changing token identity. */
|
||||
export const memberListRowLookup = (
|
||||
bytes: Buffer,
|
||||
layout: ManagedMetadataLayout,
|
||||
table: MemberTable,
|
||||
): ListRowLookup => {
|
||||
const pointers = layout.table(table - 1);
|
||||
if (pointers === undefined || pointers.rowCount === 0) return (row) => row;
|
||||
let byTable = lookups.get(layout);
|
||||
const cached = byTable?.get(table);
|
||||
if (cached instanceof ManagedReaderFailure) throw cached;
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const definitionCount = layout.table(table)?.rowCount ?? 0;
|
||||
// The layout already admitted the source table extent. Reserve retained index
|
||||
// storage and its fixed bookkeeping before allocating either container.
|
||||
managedDecodeBudget(layout).reserve(
|
||||
(definitionCount + 1) * Uint32Array.BYTES_PER_ELEMENT + 256,
|
||||
pointers.offset,
|
||||
);
|
||||
if (byTable === undefined) {
|
||||
byTable = new Map();
|
||||
lookups.set(layout, byTable);
|
||||
}
|
||||
const positions = new Uint32Array(definitionCount + 1);
|
||||
try {
|
||||
const width = layout.tableIndexSize(table);
|
||||
for (let row = 1; row <= pointers.rowCount; row += 1) {
|
||||
const offset: number = pointers.offset + (row - 1) * pointers.rowSize;
|
||||
const definition =
|
||||
width === 2 ? bytes.readUInt16LE(offset) : bytes.readUInt32LE(offset);
|
||||
if (definition === 0 || definition > definitionCount)
|
||||
throw managedFailure(
|
||||
"invalid-row",
|
||||
`metadata.${pointers.name}`,
|
||||
`Pointer row ${String(row)} names an out-of-range definition row ${String(definition)}`,
|
||||
offset,
|
||||
);
|
||||
if (positions[definition] !== 0)
|
||||
throw managedFailure(
|
||||
"invalid-row",
|
||||
`metadata.${pointers.name}`,
|
||||
`Definition row ${String(definition)} has more than one pointer row; its declaring type is ambiguous`,
|
||||
offset,
|
||||
);
|
||||
positions[definition] = row;
|
||||
}
|
||||
const lookup: ListRowLookup = (definition) => {
|
||||
const position = positions[definition] ?? 0;
|
||||
if (position === 0)
|
||||
throw managedFailure(
|
||||
"invalid-row",
|
||||
`metadata.${pointers.name}`,
|
||||
`Definition row ${String(definition)} has no pointer row identifying its declaring type`,
|
||||
pointers.offset,
|
||||
);
|
||||
return position;
|
||||
};
|
||||
byTable.set(table, lookup);
|
||||
return lookup;
|
||||
} catch (cause: unknown) {
|
||||
// Invalid metadata must not rebuild and charge the same retained index for
|
||||
// every attribute/member that asks for its owner.
|
||||
if (cause instanceof ManagedReaderFailure) byTable.set(table, cause);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user