Merge remote-tracking branch 'origin/main' into codex/pr-1899-remove-duplicate-schema-test

This commit is contained in:
morluto committed 2026-10-12 02:38:57 +08:00
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
+3 -2
View File
@@ -9,7 +9,6 @@
**See a feature you like. Understand how it works, down to the binary level.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#what-you-can-analyze)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](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>
-1
View File
@@ -9,7 +9,6 @@
**وجدت ميزة تعجبك؟ افهم طريقة عملها حتى مستوى الشيفرة الثنائية.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -9,7 +9,6 @@
**Eine Funktion gefällt dir? Verstehe, wie sie funktioniert – bis hinunter auf die Binärebene.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -9,7 +9,6 @@
**¿Ves una función que te gusta? Entiende cómo funciona, hasta el nivel binario.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -9,7 +9,6 @@
**قابلیتی دیده‌اید که دوستش دارید؟ سازوکارش را تا سطح باینری درک کنید.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -9,7 +9,6 @@
**Repérez une fonctionnalité qui vous plaît. Comprenez son fonctionnement, jusqu'au niveau du binaire.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -9,7 +9,6 @@
**Melihat fitur yang Anda sukai. Pahami cara kerjanya hingga ke tingkat biner.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -9,7 +9,6 @@
**Vedi una funzionalità che ti piace. Capisci come funziona, fino al livello binario.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#cosa-puoi-analizzare)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -9,7 +9,6 @@
**気に入った機能を見つけたら、その仕組みをバイナリのレベルまで理解する。**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -9,7 +9,6 @@
**마음에 드는 기능을 발견했다면, 바이너리 수준까지 작동 원리를 이해하세요.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -9,7 +9,6 @@
**Widzisz funkcję, która Ci się podoba? Dowiedz się, jak działa, aż do poziomu kodu binarnego.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -9,7 +9,6 @@
**Viu uma funcionalidade de que gostou? Entenda como ela funciona, até o nível binário.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -9,7 +9,6 @@
**Нашли интересную функцию? Разберитесь, как она работает, вплоть до двоичного кода.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -9,7 +9,6 @@
**เห็นฟีเจอร์ที่ชอบแล้วอยากรู้ว่าทำงานอย่างไร? เข้าใจได้ลึกถึงระดับไบนารี**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -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.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -9,7 +9,6 @@
**Побачили цікаву функцію? Зрозумійте, як вона працює, аж до рівня двійкового коду.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -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.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -9,7 +9,6 @@
**看到喜歡的功能,弄清它的工作原理,深入到二進位層面。**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
-1
View File
@@ -9,7 +9,6 @@
**看到喜欢的功能,弄清它的工作原理,深入到二进制层面。**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
+18 -2
View File
@@ -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
+69 -11
View File
@@ -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
+18
View File
@@ -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,
+7 -1
View 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
View File
@@ -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.
+6 -5
View File
@@ -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;
}
+40 -22
View File
@@ -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,
+2 -1
View File
@@ -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),
),
),
);
+2 -1
View File
@@ -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),
);
};
+36
View File
@@ -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[] = [
{
+4 -2
View File
@@ -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);
+4
View File
@@ -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" }
+4
View File
@@ -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,
+1
View File
@@ -232,6 +232,7 @@ describe("setup scoped readiness", () => {
clients: ["codex"],
providers: [],
skill: true,
skillClients: ["codex"],
});
});
+9 -1
View File
@@ -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 => ({
+2 -1
View File
@@ -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)),
);
+2 -1
View File
@@ -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 -4
View File
@@ -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),
+5 -4
View File
@@ -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.`,
],
});
+25
View File
@@ -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" },
]);
});
});
+28 -12
View File
@@ -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,
+4 -1
View File
@@ -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: {
+2 -1
View File
@@ -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 }));
};
+2 -1
View File
@@ -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),
);
};
+2 -1
View File
@@ -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({
+2 -1
View File
@@ -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),
),
};
};
+2 -1
View File
@@ -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),
);
};
+8 -3
View File
@@ -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);
+5 -2
View File
@@ -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(),
+3 -2
View File
@@ -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);
+3
View File
@@ -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,
},
+159
View File
@@ -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),
);
});
});
+57 -7
View File
@@ -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({
+3 -3
View File
@@ -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. */
+4 -1
View File
@@ -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"
+5 -2
View File
@@ -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
View File
@@ -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"]);
});
});
+8 -4
View File
@@ -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] ?? [];
+43 -6
View File
@@ -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,
+12
View File
@@ -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;
+13
View File
@@ -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);
+14 -10
View File
@@ -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);
});
});
+275
View File
@@ -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([]);
});
});
+101
View File
@@ -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