Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92fddf3176 | ||
|
|
9655845bbc | ||
|
|
12ee785e5e | ||
|
|
e59db2f5de | ||
|
|
d802304c54 | ||
|
|
0f4992fb9e | ||
|
|
72489a9445 | ||
|
|
b6651cb99f | ||
|
|
0b2aa69531 | ||
|
|
b5b73019f9 | ||
|
|
d0edeec100 | ||
|
|
3b490a743f | ||
|
|
3298219d8c | ||
|
|
b07e54a10e | ||
|
|
fb43dd9716 | ||
|
|
0485cd3bce | ||
|
|
c32d84622f | ||
|
|
601814ed0e | ||
|
|
dfe31172ca | ||
|
|
6407718f30 | ||
|
|
f8513a42dc | ||
|
|
a95795c6ed | ||
|
|
369cd3dfdc | ||
|
|
2976fac30d | ||
|
|
f598bab488 | ||
|
|
bb508134ef | ||
|
|
1dfb933e47 | ||
|
|
486b56724c | ||
|
|
9d8e42bdd6 | ||
|
|
d28b41ea4e | ||
|
|
e8491e023a | ||
|
|
097f0d54af | ||
|
|
e4ac7bbfc2 | ||
|
|
0b7fc40eee | ||
|
|
7a0140b4aa | ||
|
|
9b3e59804d | ||
|
|
1769417001 | ||
|
|
898d5096f9 | ||
|
|
38539a2f4a | ||
|
|
23d50ea3be | ||
|
|
499ac18011 |
No files matched your search
@@ -9,7 +9,10 @@ provenance; inputs and recovered code are never executed. Recovery requires a
|
||||
caller-supplied Wakaru on Linux x64; see [the recovery guide](https://github.com/morluto/rea/blob/main/docs/javascript-recovery.md).
|
||||
|
||||
Use `analyze_javascript_application` directly on the operator-supplied ASAR or
|
||||
extracted tree. When size is unknown or large and the connected schema advertises
|
||||
extracted tree. Exact `#alias` specifiers resolve through the importer's
|
||||
enclosing package `imports` map to package-local files; nested `node_modules`
|
||||
packages use their own map. Wildcard, recursive, and external-package import
|
||||
targets stay unresolved with that limitation. When size is unknown or large and the connected schema advertises
|
||||
`detail`, request `detail: "summary"`. This returns an `inspect_analysis_view`
|
||||
Evidence record with a distinct ID and a `normalized_result.parent_evidence_id`
|
||||
for the complete retained application analysis. Source-map contents are part of
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**See a feature you like. Understand how it works, down to the binary level.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#what-you-can-analyze)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
@@ -245,7 +244,9 @@ Ghidra and IDA use your existing installations. See [provider setup](docs/instal
|
||||
<summary><strong>Do I need to start Hopper first?</strong></summary>
|
||||
|
||||
REA starts Hopper when an operation needs it. On macOS, a first-run dialog may
|
||||
ask you to choose demo mode or activate your license. See
|
||||
ask you to choose demo mode or activate your license. Complete that prompt in the
|
||||
same user session before your first CLI/MCP operation or unattended job. Open
|
||||
Hopper manually if needed. See
|
||||
[Hopper startup and troubleshooting](docs/installation.md#launcher-paths-and-troubleshooting).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**وجدت ميزة تعجبك؟ افهم طريقة عملها حتى مستوى الشيفرة الثنائية.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Eine Funktion gefällt dir? Verstehe, wie sie funktioniert – bis hinunter auf die Binärebene.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**¿Ves una función que te gusta? Entiende cómo funciona, hasta el nivel binario.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**قابلیتی دیدهاید که دوستش دارید؟ سازوکارش را تا سطح باینری درک کنید.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Repérez une fonctionnalité qui vous plaît. Comprenez son fonctionnement, jusqu'au niveau du binaire.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Melihat fitur yang Anda sukai. Pahami cara kerjanya hingga ke tingkat biner.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Vedi una funzionalità che ti piace. Capisci come funziona, fino al livello binario.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#cosa-puoi-analizzare)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**気に入った機能を見つけたら、その仕組みをバイナリのレベルまで理解する。**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**마음에 드는 기능을 발견했다면, 바이너리 수준까지 작동 원리를 이해하세요.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Widzisz funkcję, która Ci się podoba? Dowiedz się, jak działa, aż do poziomu kodu binarnego.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Viu uma funcionalidade de que gostou? Entenda como ela funciona, até o nível binário.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Нашли интересную функцию? Разберитесь, как она работает, вплоть до двоичного кода.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**เห็นฟีเจอร์ที่ชอบแล้วอยากรู้ว่าทำงานอย่างไร? เข้าใจได้ลึกถึงระดับไบนารี**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Beğendiğiniz bir özellik mi gördünüz? İkili kod düzeyine kadar nasıl çalıştığını anlayın.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Побачили цікаву функцію? Зрозумійте, як вона працює, аж до рівня двійкового коду.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Thấy một tính năng bạn thích? Hiểu cách nó hoạt động, đến tận mức mã nhị phân.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**看到喜歡的功能,弄清它的工作原理,深入到二進位層面。**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**看到喜欢的功能,弄清它的工作原理,深入到二进制层面。**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import org.osgi.framework.BundleActivator;
|
||||
import org.osgi.framework.BundleContext;
|
||||
|
||||
// Compile the no-op activator with REA's scripts. Otherwise Ghidra generates
|
||||
// one afterward and opens a second Java compiler and file manager.
|
||||
public final class ReaGhidraBundleActivator implements BundleActivator {
|
||||
@Override
|
||||
public void start(BundleContext context) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop(BundleContext context) {
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -352,6 +352,12 @@ Other integrations, including Qwen Code, use `~/.agents/skills`.
|
||||
A mixed selection plans each distinct destination. Selecting the skill without a client uses the shared
|
||||
directory. Setup leaves existing skill copies in other locations untouched.
|
||||
|
||||
`rea setup --skill-client claude_code --yes --json` refreshes and verifies the
|
||||
selected skill destination independently of MCP registration selection. The
|
||||
final `doctor.scope.skill_clients` records which skill destinations were
|
||||
checked; `doctor.scope.clients` continues to describe required registrations.
|
||||
An unselected shared skill does not affect a client-only refresh.
|
||||
|
||||
`doctor --skill --json` verifies the selected copies against the bundled
|
||||
instructions and references. Consumers should use `identity.skill.state`,
|
||||
`installed_version`, and `installed_tool_count` for skill readiness. The
|
||||
@@ -561,8 +567,13 @@ need Xvfb, Python 3, X11 and XTEST; approved setup installs those dependencies
|
||||
on supported distributions. Add the curl installer's reported executable
|
||||
directory, commonly `~/.local/bin`, to your shell `PATH` if needed.
|
||||
|
||||
REA starts Hopper when needed. On macOS, its launcher can bring a window or
|
||||
first-run dialog forward; choose demo mode or activate your existing license.
|
||||
REA starts Hopper when needed. On macOS, complete its demo or license prompt in
|
||||
the same user session before the first CLI/MCP operation or unattended job. Open
|
||||
Hopper manually if needed; its launcher can also bring the dialog forward.
|
||||
If startup reports `Cannot launch Hopper! Connection is invalid.` or waits for
|
||||
bridge readiness, check Hopper for a pending demo, license, or loader dialog.
|
||||
Complete the dialog and retry. If REA reports `cleanup_incomplete`, follow the
|
||||
retained-resource guidance below before reopening the target.
|
||||
Hopper serializes analysis requests. Cancelling a wait can leave provider work
|
||||
running, which the session reports. Successful decompilation is cached until
|
||||
a relevant rename or comment changes it.
|
||||
@@ -591,6 +602,11 @@ if document closure is unconfirmed, REA retains the backing image and reports
|
||||
MCP-exit behavior.
|
||||
Startup deadlines report missing bridge readiness and preserve the launcher
|
||||
outcome; a successful helper exit does not prove that a loader dialog completed.
|
||||
If an owned provider process exits, request failures preserve its observed exit
|
||||
code or signal and bounded output in `details.launcher`. An exit can precede
|
||||
output-stream closure; `output_closed: false` keeps that uncertainty explicit.
|
||||
Later requests retain the known exit instead of reducing it to socket loss.
|
||||
An external GUI's helper exit does not establish that the GUI itself exited.
|
||||
Cursor navigation returns the observed object start when Hopper snaps an interior
|
||||
address; adjacent-object navigation rejects unmapped inputs and document ends.
|
||||
Native API text rejects NUL characters and unpaired Unicode surrogates before
|
||||
|
||||
@@ -159,9 +159,11 @@ completed partial Evidence. MCP failure details return
|
||||
after recording succeeds, so `inspect_analysis_view` can open that analysis
|
||||
directly without serializing the entire partial graph into the error response.
|
||||
CLI failure output retains the complete partial Evidence.
|
||||
Cancellation after source analysis begins likewise
|
||||
retains completed facts; an SDK client may reject its cancelled request before
|
||||
receiving that reply. On the same MCP connection, inspect the Evidence bundle
|
||||
Cancellation after source analysis begins likewise retains completed facts,
|
||||
including cancellation during analyzer cleanup, graph construction, or Evidence
|
||||
sealing. REA finishes projecting those owned facts cooperatively to return partial
|
||||
Evidence. An SDK client may reject its cancelled request before receiving that
|
||||
reply. On the same MCP connection, inspect the Evidence bundle
|
||||
and use the retained analysis ID with `inspect_analysis_view`. Owned analyzer
|
||||
cleanup is verified before its private files are released. Cleanup uncertainty
|
||||
is reported separately and retains ownership for retry.
|
||||
@@ -231,6 +233,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;
|
||||
@@ -454,16 +461,40 @@ such as `./web3:app.js` still names a local artifact.
|
||||
Package `exports` fallback arrays are supported both at the top level and under
|
||||
the root `"."` entry. The resolver selects targets in declared order using the
|
||||
same conditional and invalid-entry handling as nested exports arrays.
|
||||
Nonempty arrays containing only inactive conditions remain unmatched, allowing
|
||||
the enclosing condition object to continue to a later active condition.
|
||||
Empty arrays and explicit null targets keep their blocking outcome.
|
||||
Refusal evidence distinguishes those source values and retains condition names
|
||||
from unmatched nested targets rather than reporting array indices.
|
||||
For imports and requires, the active Node conditions include `module-sync`;
|
||||
earlier active conditions retain precedence over later ones.
|
||||
|
||||
Exact package-private `#alias` specifiers resolve against the importer's
|
||||
enclosing `package.json` `imports` map — the nearest same-container package
|
||||
scope, stopping at a `node_modules` directory so a nested package cannot inherit
|
||||
an ancestor's aliases. Selected targets are package-local `./` files with no
|
||||
extension or index fallback, using the same condition, array, and null selection
|
||||
as exports subpaths. A missing key, explicit null, or out-of-scope alias keeps
|
||||
an unresolved edge with that reason. Wildcard keys (`#internal/*`), recursive
|
||||
`#` targets, and external-package import targets remain unresolved.
|
||||
|
||||
Exact keys such as `#/x` are literal lookup identities. `#` and names ending
|
||||
in `/` are invalid. Imports arrays skip invalid parent-relative, absolute, and
|
||||
URL targets before selecting a later package-local file; an unsupported bare
|
||||
package or recursive alias keeps its unresolved outcome rather than selecting
|
||||
a later fallback.
|
||||
|
||||
ESM relative module paths and selected package exports targets use URL suffix handling
|
||||
and one percent-decoding pass. CommonJS relative paths and legacy package main
|
||||
fields retain literal filename punctuation. Exports targets must start with
|
||||
`./` and exclude `.`, `..`, and `node_modules` path segments, including their
|
||||
encoded forms, before URL normalization. Arrays skip invalid exports targets
|
||||
in declared order; URL decoding and file lookup failures for a selected target
|
||||
do not select a later entry. Rejected references retain the package metadata
|
||||
in declared order, including URLs that escape the package root after normalization.
|
||||
Selected package targets use the normalized URL path for lookup; tabs and
|
||||
newlines in the URL text are removed by the URL parser. CommonJS package targets
|
||||
reject encoded separators anywhere in the selected URL; ESM checks its pathname.
|
||||
URL decoding and file lookup failures for a selected target do not select a
|
||||
later entry. Rejected references retain the package metadata
|
||||
path, original target, failed constraint, and importing source location.
|
||||
|
||||
HTML script references resolve to exact inventoried files after applying the
|
||||
|
||||
@@ -126,6 +126,24 @@ Member inspection binds build-local tokens, signatures, row offsets, method
|
||||
bodies, and decoded relationships to that artifact and MVID. Missing or malformed
|
||||
facts remain explicit in coverage and per-member status.
|
||||
|
||||
Metadata table streams may carry the CLR-compatible `ExtraData` extension
|
||||
(`HeapSizes` flag `0x40`), which Microsoft readers honor although it is not
|
||||
specified by ECMA-335. REA skips its fixed four-byte word after the row counts
|
||||
before decoding table rows; the word's value is not a length. An incomplete
|
||||
word is reported as a metadata coverage failure at its file offset, without
|
||||
reading beyond the declared table stream.
|
||||
|
||||
For unoptimized `#-` metadata, nonempty `FieldPtr` and `MethodPtr` tables
|
||||
translate TypeDef list positions to physical member definitions. REA resolves
|
||||
these indirections for member ownership and local attribute-constructor types;
|
||||
field and method tokens still identify the original definition rows.
|
||||
`field_list` and `method_list` retain the metadata's list coordinates, which
|
||||
refer to the pointer table when present rather than necessarily contiguous
|
||||
definition rows. Malformed or ambiguous pointers leave the owner unknown with
|
||||
partial coverage while preserving readable member and attribute facts.
|
||||
The retained inverse indexes are admitted against the existing representation
|
||||
budget before allocation.
|
||||
|
||||
`exported_types` reports ExportedType rows as declared. Facade assemblies such
|
||||
as .NET `System.Runtime.dll` provide their types this way: `forwarder: true`
|
||||
marks a type forwarder, and `implementation_token` names the AssemblyRef, File,
|
||||
|
||||
@@ -450,7 +450,13 @@ Tool annotations describe effects and are hints, not authorization controls
|
||||
([MCP ToolAnnotations](https://modelcontextprotocol.io/specification/2025-06-18/schema#toolannotations)).
|
||||
`readOnlyHint` includes session state: an analysis call that records additive
|
||||
Evidence is marked non-read-only even when it leaves the target unchanged.
|
||||
`destructiveHint` describes possible data loss, not ordinary Evidence recording.
|
||||
`destructiveHint` is true when a tool may discard data (overwriting or clearing
|
||||
analyst metadata, replacing files, dropping session state), runs or drives code
|
||||
REA does not own (`executesForeignCode`: process, LLDB, Electron, browser, and
|
||||
native UI scenarios), or changes an attached Android device (`mutatesDevice`:
|
||||
push, install, uninstall, start, and stop). Ordinary Evidence recording is not
|
||||
destructive. The effect record in `binary_session` and the generated catalog
|
||||
carries each flag separately.
|
||||
Effect metadata covers possible behavior across supported inputs: DMG inventory
|
||||
can launch `hdiutil` and create an owned temporary mount directory; extraction
|
||||
creates a fresh output directory on every call. Comparing supplied web captures
|
||||
|
||||
+13
-8
@@ -139,8 +139,11 @@ directory, so default OS-home discovery remains unverified. Live model APIs and
|
||||
native Windows are also unverified.
|
||||
|
||||
`verify:claude-client` requires installed Claude Code (verified with
|
||||
`@anthropic-ai/claude-code@2.1.296`); select it with `REA_VERIFY_CLAUDE_COMMAND`.
|
||||
The optional POSIX lane uses native `CLAUDE_CONFIG_DIR` discovery, an independent
|
||||
`@anthropic-ai/claude-code@2.1.296` on POSIX and native Windows); select it
|
||||
with `REA_VERIFY_CLAUDE_COMMAND`. On Windows, select
|
||||
`claude.exe`: npm installs put only a `claude.cmd` shim on `PATH`, and the real
|
||||
executable is under `node_modules/@anthropic-ai/claude-code/bin`.
|
||||
The optional lane uses native `CLAUDE_CONFIG_DIR` discovery, an independent
|
||||
Git workspace, user settings and default built-in tools. It preserves caller
|
||||
preferences and backups, checks idempotent setup, waits through the native
|
||||
`WaitForMcpServers` tool when discovery is pending, and validates the complete
|
||||
@@ -149,8 +152,8 @@ native `Skill`, checks its full body and verifies named-schema JavaScript Eviden
|
||||
for a Unicode path. Use `-- chat` for ordinary chat with REA enabled, or
|
||||
`REA_VERIFY_RUNTIME_ROOT` for a production-only installed package. The local
|
||||
Anthropic Messages/SSE fixture preserves native resource-hint envelopes in its
|
||||
request artifacts. Usage is synthetic; live Anthropic API, native Windows and
|
||||
bare-mode personal-skill activation remain unverified by this lane.
|
||||
request artifacts. Usage is synthetic; live Anthropic API and bare-mode
|
||||
personal-skill activation remain unverified by this lane.
|
||||
|
||||
`verify:copilot-client` requires installed GitHub Copilot CLI (verified with
|
||||
`@github/copilot@1.0.95`); select it with `REA_VERIFY_COPILOT_COMMAND`.
|
||||
@@ -279,10 +282,12 @@ establish live DeepSeek API or native Windows coverage. Receipts include
|
||||
request artifacts, result digests and owned-process lineage.
|
||||
|
||||
Full E2E tests invoke the production command dispatcher and real providers,
|
||||
without fake launchers, runners or responses. `verify:keyed-archive` writes an
|
||||
actual Foundation binary archive, runs the CLI and a separate stdio MCP
|
||||
subprocess, checks parity and pagination, rejects an escaping path and checks
|
||||
an XML graph golden. `verify:asset-catalog` compiles source-owned colors with
|
||||
without fake launchers, runners or responses. `verify:keyed-archive` writes
|
||||
Foundation binary, XML, and integer archives using one run-owned Swift module
|
||||
cache. It checks CLI and separate stdio MCP parity, pagination, escaping-path
|
||||
rejection, an XML graph golden, and numeric meaning for safe controls, unsafe
|
||||
integers, boxed numbers, and integral-valued reals. `verify:asset-catalog`
|
||||
compiles source-owned colors with
|
||||
`actool`, invokes real `assetutil`, then checks CLI/MCP results, exact catalog
|
||||
digest, every raw metadata field, pagination and malformed input rejection.
|
||||
Neither artifact workflow requires Hopper or launches it. Both run in macOS CI.
|
||||
|
||||
+104
-88
@@ -1,4 +1,12 @@
|
||||
#!/bin/bash
|
||||
{
|
||||
# Truncation guard: this brace group opens on line 2 and closes on the last
|
||||
# line, so bash must read the entire file before it runs any command. On the
|
||||
# `curl ... | bash` path, a download cut off anywhere before the final `}` is
|
||||
# an unterminated compound command, which bash reports as a syntax error at
|
||||
# EOF without running anything. Without the guard, a cut right after `set`
|
||||
# would run the bare builtin and print every shell variable, and a cut right
|
||||
# after `main` would run the installer without the caller's flags.
|
||||
set -euo pipefail
|
||||
|
||||
PACKAGE="rea-agents"
|
||||
@@ -21,104 +29,112 @@ usage() {
|
||||
cat <<'EOF'
|
||||
Usage: install.sh [--version VERSION] [--dry-run] [--no-setup] [--no-prompt] [--verbose]
|
||||
|
||||
Installs only the REA CLI. Run `rea setup` separately to configure Hopper and agents.
|
||||
Installs the REA CLI, then starts `rea setup` interactively to configure Hopper
|
||||
and agents when a terminal is available. Pass --no-setup (or --no-prompt) to
|
||||
skip setup and run `rea setup` later.
|
||||
EOF
|
||||
}
|
||||
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--version)
|
||||
(($# >= 2)) || fail "--version requires a value."
|
||||
version="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
--no-setup|--no-prompt)
|
||||
start_setup=false
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
verbose=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
main() {
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--version)
|
||||
(($# >= 2)) || fail "--version requires a value."
|
||||
version="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
--no-setup|--no-prompt)
|
||||
start_setup=false
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
verbose=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$verbose" == true ]]; then
|
||||
set -x
|
||||
fi
|
||||
if [[ "$verbose" == true ]]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
platform="$(uname -s)"
|
||||
[[ "$platform" == "Darwin" || "$platform" == "Linux" ]] || fail "REA supports macOS and selected 64-bit Linux distributions."
|
||||
command -v curl >/dev/null 2>&1 || fail "curl is required. Install curl, then rerun this installer."
|
||||
command -v node >/dev/null 2>&1 || fail "Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+ is required; install it from https://nodejs.org and retry."
|
||||
command -v npm >/dev/null 2>&1 || fail "npm is required; install it with Node.js and retry."
|
||||
platform="$(uname -s)"
|
||||
[[ "$platform" == "Darwin" || "$platform" == "Linux" ]] || fail "REA supports macOS and selected 64-bit Linux distributions."
|
||||
command -v curl >/dev/null 2>&1 || fail "curl is required. Install curl, then rerun this installer."
|
||||
command -v node >/dev/null 2>&1 || fail "Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+ is required; install it from https://nodejs.org and retry."
|
||||
command -v npm >/dev/null 2>&1 || fail "npm is required; install it with Node.js and retry."
|
||||
|
||||
node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
|
||||
IFS=. read -r node_major node_minor _node_patch <<<"$node_version"
|
||||
[[ "$node_major" =~ ^[0-9]+$ && "$node_minor" =~ ^[0-9]+$ ]] || fail "the active Node.js version could not be read. Check that node works and is on PATH, then retry."
|
||||
if ! [[ "$_node_patch" =~ ^[0-9]+(\+[0-9A-Za-z.-]+)?$ ]] ||
|
||||
! ((node_major == 22 && node_minor >= 19 || node_major == 24 && node_minor >= 11 || node_major >= 26)); then
|
||||
fail "Node.js $node_version is unsupported; use Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+."
|
||||
fi
|
||||
node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
|
||||
IFS=. read -r node_major node_minor _node_patch <<<"$node_version"
|
||||
[[ "$node_major" =~ ^[0-9]+$ && "$node_minor" =~ ^[0-9]+$ ]] || fail "the active Node.js version could not be read. Check that node works and is on PATH, then retry."
|
||||
if ! [[ "$_node_patch" =~ ^[0-9]+(\+[0-9A-Za-z.-]+)?$ ]] ||
|
||||
! ((node_major == 22 && node_minor >= 19 || node_major == 24 && node_minor >= 11 || node_major >= 26)); then
|
||||
fail "Node.js $node_version is unsupported; use Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+."
|
||||
fi
|
||||
|
||||
if [[ -n "$version" ]]; then
|
||||
[[ "$version" =~ ^${version_pattern}$ ]] || fail "version must be an exact semantic version."
|
||||
else
|
||||
release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "the latest REA release could not be resolved. Check network access or pass --version VERSION, then retry."
|
||||
tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})' 2>/dev/null)" || fail "the release response was invalid. Retry later or pass --version VERSION."
|
||||
[[ "$tag" =~ ^rea-agents-(${version_pattern})$ ]] || fail "the latest release tag was invalid. Retry later or pass --version VERSION."
|
||||
version="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
if [[ -n "$version" ]]; then
|
||||
[[ "$version" =~ ^${version_pattern}$ ]] || fail "version must be an exact semantic version."
|
||||
else
|
||||
release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "the latest REA release could not be resolved. Check network access or pass --version VERSION, then retry."
|
||||
tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})' 2>/dev/null)" || fail "the release response was invalid. Retry later or pass --version VERSION."
|
||||
[[ "$tag" =~ ^rea-agents-(${version_pattern})$ ]] || fail "the latest release tag was invalid. Retry later or pass --version VERSION."
|
||||
version="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
# npm's exact-version resolver cleans build metadata from the requested version,
|
||||
# so it may install the base release and make the post-install identity check fail.
|
||||
if [[ "$version" =~ \+ ]]; then
|
||||
fail "npm cannot install an exact version with build metadata. Pass a version without build metadata, then retry."
|
||||
fi
|
||||
# npm's exact-version resolver cleans build metadata from the requested version,
|
||||
# so it may install the base release and make the post-install identity check fail.
|
||||
if [[ "$version" =~ \+ ]]; then
|
||||
fail "npm cannot install an exact version with build metadata. Pass a version without build metadata, then retry."
|
||||
fi
|
||||
|
||||
install_args=(install --global)
|
||||
if [[ "$platform" == "Linux" ]]; then
|
||||
install_args+=(--prefix "$HOME/.local")
|
||||
install_bin="$HOME/.local/bin/rea"
|
||||
else
|
||||
npm_prefix="$(npm prefix --global)" || fail "the npm global prefix could not be read. Repair the npm configuration, then retry."
|
||||
install_bin="$npm_prefix/bin/rea"
|
||||
fi
|
||||
install_args+=("$PACKAGE@$version")
|
||||
install_args=(install --global)
|
||||
if [[ "$platform" == "Linux" ]]; then
|
||||
install_args+=(--prefix "$HOME/.local")
|
||||
install_bin="$HOME/.local/bin/rea"
|
||||
else
|
||||
npm_prefix="$(npm prefix --global)" || fail "the npm global prefix could not be read. Repair the npm configuration, then retry."
|
||||
install_bin="$npm_prefix/bin/rea"
|
||||
fi
|
||||
install_args+=("$PACKAGE@$version")
|
||||
|
||||
printf 'REA install plan\n'
|
||||
printf ' Version: %s\n' "$version"
|
||||
printf ' Runtime: Node.js %s\n' "$node_version"
|
||||
printf ' Command:'
|
||||
printf ' %q' npm "${install_args[@]}"
|
||||
printf '\n'
|
||||
printf ' Binary: %s\n' "$install_bin"
|
||||
printf ' Setup: %s\n' "$([[ "$start_setup" == true ]] && printf 'start when a terminal is available' || printf 'skipped')"
|
||||
printf 'REA install plan\n'
|
||||
printf ' Version: %s\n' "$version"
|
||||
printf ' Runtime: Node.js %s\n' "$node_version"
|
||||
printf ' Command:'
|
||||
printf ' %q' npm "${install_args[@]}"
|
||||
printf '\n'
|
||||
printf ' Binary: %s\n' "$install_bin"
|
||||
printf ' Setup: %s\n' "$([[ "$start_setup" == true ]] && printf 'start when a terminal is available' || printf 'skipped')"
|
||||
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
printf 'Dry run complete; no changes made.\n'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
printf 'Dry run complete; no changes made.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'Installing %s@%s...\n' "$PACKAGE" "$version"
|
||||
npm "${install_args[@]}" || fail "npm could not install REA. Check registry access and npm permissions, then retry."
|
||||
[[ -x "$install_bin" ]] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry."
|
||||
installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "the installed REA version could not be read. Reinstall the requested version, then retry."
|
||||
[[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry."
|
||||
printf 'Installing %s@%s...\n' "$PACKAGE" "$version"
|
||||
npm "${install_args[@]}" || fail "npm could not install REA. Check registry access and npm permissions, then retry."
|
||||
[[ -x "$install_bin" ]] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry."
|
||||
installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "the installed REA version could not be read. Reinstall the requested version, then retry."
|
||||
[[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry."
|
||||
|
||||
printf 'REA %s is installed.\n' "$version"
|
||||
if [[ "$start_setup" == true && -r /dev/tty && -w /dev/tty && -t 1 ]]; then
|
||||
printf 'Starting REA setup...\n'
|
||||
"$install_bin" setup </dev/tty >/dev/tty
|
||||
else
|
||||
printf 'Run %s setup to configure Hopper and agents.\n' "$install_bin"
|
||||
fi
|
||||
printf 'REA %s is installed.\n' "$version"
|
||||
if [[ "$start_setup" == true && -r /dev/tty && -w /dev/tty && -t 1 ]]; then
|
||||
printf 'Starting REA setup...\n'
|
||||
"$install_bin" setup </dev/tty >/dev/tty
|
||||
else
|
||||
printf 'Run %s setup to configure Hopper and agents.\n' "$install_bin"
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
exit $?
|
||||
}
|
||||
@@ -51,6 +51,7 @@
|
||||
"bridge/process/ProcessRunTokenReader.swift",
|
||||
"bridge/process/ProcessRunTokenReaderMain.swift",
|
||||
"bridge/ghidra/ReaGhidraBridge.java",
|
||||
"bridge/ghidra/ReaGhidraBundleActivator.java",
|
||||
"bridge/ghidra/ReaGhidraExtensions.java",
|
||||
"bridge/ghidra/ReaGhidraPrepareCom.java",
|
||||
"bridge/ghidra/ReaGhidraApplySeeds.java",
|
||||
|
||||
@@ -401,6 +401,22 @@ class WebsiteTranslationChecks(unittest.TestCase):
|
||||
server.shutdown()
|
||||
worker.join()
|
||||
|
||||
def test_every_reading_page_offers_light_and_dark_theme_colors(self):
|
||||
site = ROOT / "website/_site"
|
||||
english_pages = reading_pages(ROOT / "website/public")
|
||||
self.assertTrue(english_pages)
|
||||
for language in languages(ROOT):
|
||||
code = language["code"]
|
||||
for original in english_pages:
|
||||
relative = original.relative_to(ROOT / "website/public")
|
||||
for path in {original, site / relative if code == "en" else site / code / relative}:
|
||||
with self.subTest(language=code, page=str(path.relative_to(ROOT))):
|
||||
doc = Document(path.read_text())
|
||||
media = sorted(node.attrs.get("media") for node in doc.nodes if node.tag == "meta" and node.attrs.get("name") == "theme-color")
|
||||
self.assertEqual(media, ["(prefers-color-scheme: dark)", "(prefers-color-scheme: light)"])
|
||||
styles = (ROOT / "website/public/assets/styles.css").read_text()
|
||||
self.assertTrue("@media (prefers-color-scheme: dark)" in styles, "styles.css has no dark color scheme block")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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 α测试");
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
@@ -291,6 +291,23 @@ try {
|
||||
await client.connect(transport);
|
||||
await call("open_binary", { path: target });
|
||||
await call("binary_overview", {});
|
||||
const activators = (
|
||||
await readdir(runtimeParent, { recursive: true })
|
||||
).filter((path) => basename(path) === "ReaGhidraBundleActivator.class");
|
||||
assert.equal(activators.length, 1);
|
||||
const [activator] = activators;
|
||||
assert.ok(activator !== undefined);
|
||||
const bundleDirectory = dirname(join(runtimeParent, activator));
|
||||
const manifest = await readFile(
|
||||
join(bundleDirectory, "META-INF", "MANIFEST.MF"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(manifest, /^Bundle-Activator: ReaGhidraBundleActivator\r?$/mu);
|
||||
await assert.rejects(
|
||||
readFile(join(bundleDirectory, "GeneratedActivator.class")),
|
||||
{ code: "ENOENT" },
|
||||
);
|
||||
report.bundleActivator = "ReaGhidraBundleActivator";
|
||||
assert.equal((await call("list_documents", {})).length, 1);
|
||||
await call("list_names", {});
|
||||
const procedures = await call("list_procedures", {});
|
||||
|
||||
@@ -5,10 +5,16 @@ import { promisify } from "node:util";
|
||||
const exec = promisify(execFile);
|
||||
const packedHopperBridge = "package/bridge/hopper_bridge.py";
|
||||
const packedGhidraBridge = "package/bridge/ghidra/ReaGhidraBridge.java";
|
||||
const packedGhidraActivator =
|
||||
"package/bridge/ghidra/ReaGhidraBundleActivator.java";
|
||||
|
||||
/** Check shipped bridge files; real-provider lanes verify runtime behavior. */
|
||||
export async function verifyPackedBridge({ workspace, tarball, packedFiles }) {
|
||||
for (const path of [packedHopperBridge, packedGhidraBridge]) {
|
||||
for (const path of [
|
||||
packedHopperBridge,
|
||||
packedGhidraBridge,
|
||||
packedGhidraActivator,
|
||||
]) {
|
||||
if (!packedFiles.includes(path)) throw new Error(`package omitted ${path}`);
|
||||
}
|
||||
await exec("tar", ["-xf", tarball, "-C", workspace]);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { keyedArchiveResultSchema } from "../../../dist/domain/apple/keyedArchive.js";
|
||||
|
||||
const rowSchema = z.object({
|
||||
container: z.enum(["roots", "objects"]),
|
||||
root: z.string(),
|
||||
objectId: z.number().int().nonnegative().optional(),
|
||||
path: z.array(z.union([z.string(), z.number().int().nonnegative()])),
|
||||
kind: z.enum(["integer", "real"]),
|
||||
literal: z.string(),
|
||||
});
|
||||
const archiveSchema = z.object({
|
||||
filename: z.string(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
rows: z.array(rowSchema),
|
||||
});
|
||||
const oracleSchema = z.object({ xml: archiveSchema, binary: archiveSchema });
|
||||
|
||||
const formats = ["xml", "binary"];
|
||||
const requiredRows = [
|
||||
{ root: "unsafeSigned", kind: "integer", literal: "9007199254740993" },
|
||||
{
|
||||
root: "unsafeNegative",
|
||||
kind: "integer",
|
||||
literal: "-9223372036854775808",
|
||||
},
|
||||
{ root: "safeInteger", kind: "integer", literal: "9007199254740991" },
|
||||
{ root: "safeNegative", kind: "integer", literal: "-42" },
|
||||
{ root: "integralReal", kind: "real", literal: "9007199254740992" },
|
||||
{
|
||||
root: "boxedUnsigned",
|
||||
kind: "integer",
|
||||
literal: "9007199254740993",
|
||||
container: "objects",
|
||||
},
|
||||
{
|
||||
root: "boxedReal",
|
||||
kind: "real",
|
||||
literal: "9007199254740992",
|
||||
container: "objects",
|
||||
},
|
||||
];
|
||||
|
||||
const valueAt = (value, path) => {
|
||||
for (const key of path) {
|
||||
if (value === null || typeof value !== "object") return undefined;
|
||||
value = value[key];
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const isUnsafeInteger = (row) =>
|
||||
row.kind === "integer" &&
|
||||
(BigInt(row.literal) > BigInt(Number.MAX_SAFE_INTEGER) ||
|
||||
BigInt(row.literal) < BigInt(Number.MIN_SAFE_INTEGER));
|
||||
|
||||
/** Verify the actual Foundation output and the production CLI/MCP result. */
|
||||
export async function verifyKeyedArchiveIntegerArchives(root) {
|
||||
const oracle = oracleSchema.parse(
|
||||
JSON.parse(await readFile(join(root, "oracle.json"), "utf8")),
|
||||
);
|
||||
const archives = [];
|
||||
|
||||
for (const format of formats) {
|
||||
const archive = oracle[format];
|
||||
for (const required of requiredRows) {
|
||||
assert.ok(
|
||||
archive.rows.some(
|
||||
(row) =>
|
||||
row.root === required.root &&
|
||||
row.kind === required.kind &&
|
||||
row.literal === required.literal &&
|
||||
(required.container === undefined ||
|
||||
row.container === required.container),
|
||||
),
|
||||
`${format} Foundation oracle omitted ${required.root} ${required.literal}`,
|
||||
);
|
||||
}
|
||||
|
||||
const archivePath = join(root, archive.filename);
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(archivePath))
|
||||
.digest("hex");
|
||||
assert.equal(digest, archive.sha256, `${format} archive digest`);
|
||||
archives.push({ format, archive, archivePath, digest });
|
||||
}
|
||||
return archives;
|
||||
}
|
||||
|
||||
/** Check every oracle numeric slot, preserving object-table identity lookups. */
|
||||
export function assertKeyedArchiveIntegerResult(
|
||||
result,
|
||||
format,
|
||||
digest,
|
||||
oracle,
|
||||
) {
|
||||
const parsed = keyedArchiveResultSchema.parse(result);
|
||||
assert.equal(parsed.archive_sha256, digest);
|
||||
assert.equal(parsed.target_sha256, digest);
|
||||
assert.equal(
|
||||
parsed.archive_format,
|
||||
format === "xml" ? "xml-plist" : "binary-plist",
|
||||
);
|
||||
|
||||
for (const row of oracle.rows) {
|
||||
const unsafe = isUnsafeInteger(row);
|
||||
const source =
|
||||
row.container === "roots"
|
||||
? parsed.roots
|
||||
: parsed.objects.find((object) => object.id === row.objectId)?.value;
|
||||
const value = valueAt(source, row.path);
|
||||
assert.notEqual(
|
||||
value,
|
||||
undefined,
|
||||
`${row.root} numeric slot remains present`,
|
||||
);
|
||||
|
||||
if (unsafe) {
|
||||
const exact =
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
value.$plist_type === "integer" &&
|
||||
value.decimal === BigInt(row.literal).toString();
|
||||
const disclosed = parsed.limitations.some(
|
||||
(reason) =>
|
||||
/integer|number/iu.test(reason) &&
|
||||
/precision|exact range|rounded/iu.test(reason),
|
||||
);
|
||||
assert.ok(
|
||||
exact || disclosed,
|
||||
`${row.root} ${row.literal} must be exact or disclose numeric loss`,
|
||||
);
|
||||
} else {
|
||||
assert.equal(
|
||||
typeof value,
|
||||
"number",
|
||||
`${row.root} retains its numeric type`,
|
||||
);
|
||||
assert.equal(value, Number(row.literal), `${row.root} numeric value`);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile, spawnSync } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
} from "../../lib/artifact-e2e.mjs";
|
||||
import {
|
||||
assertKeyedArchiveIntegerResult,
|
||||
verifyKeyedArchiveIntegerArchives,
|
||||
} from "./keyed-archive-integers.mjs";
|
||||
|
||||
if (process.platform !== "darwin")
|
||||
throw new Error(
|
||||
@@ -170,26 +174,37 @@ try {
|
||||
);
|
||||
});
|
||||
}
|
||||
const numericTests = spawnSync(
|
||||
"npm",
|
||||
[
|
||||
"run",
|
||||
"test:focused",
|
||||
"--",
|
||||
"tests/boundary/filesystem/keyedArchiveNativeIntegers.test.ts",
|
||||
],
|
||||
{
|
||||
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
|
||||
stdio: "inherit",
|
||||
timeout: 240_000,
|
||||
},
|
||||
);
|
||||
if (numericTests.error) throw numericTests.error;
|
||||
assert.equal(
|
||||
numericTests.status,
|
||||
0,
|
||||
"Native Foundation integer cases failed",
|
||||
);
|
||||
const integers = join(root, "integers");
|
||||
await mkdir(integers);
|
||||
await promisify(execFile)("/usr/bin/xcrun", [
|
||||
"swift",
|
||||
"-module-cache-path",
|
||||
join(root, "modules"),
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../../tests/conformance/native/keyed-archive-integers.swift",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
integers,
|
||||
]);
|
||||
for (const fixture of await verifyKeyedArchiveIntegerArchives(integers)) {
|
||||
const cliResult = assertKeyedArchiveIntegerResult(
|
||||
await artifactCli("inspect-keyed-archive", fixture.archivePath),
|
||||
fixture.format,
|
||||
fixture.digest,
|
||||
fixture.archive,
|
||||
);
|
||||
await withArtifactMcp(fixture.archivePath, async (client) => {
|
||||
const mcpResult = assertKeyedArchiveIntegerResult(
|
||||
await artifactMcpResult(client, "inspect_keyed_archive"),
|
||||
fixture.format,
|
||||
fixture.digest,
|
||||
fixture.archive,
|
||||
);
|
||||
assert.deepEqual(mcpResult, cliResult);
|
||||
});
|
||||
}
|
||||
report = {
|
||||
ok: true,
|
||||
mocked: false,
|
||||
@@ -197,6 +212,9 @@ try {
|
||||
stdio_mcp: true,
|
||||
xml_golden: true,
|
||||
uid_named_root: true,
|
||||
numeric_formats: ["xml", "binary"],
|
||||
numeric_cli: true,
|
||||
numeric_stdio_mcp: true,
|
||||
format: graph.archive_format,
|
||||
objects: graph.total_objects,
|
||||
references: graph.total_references,
|
||||
|
||||
@@ -10,12 +10,12 @@ import type {
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
providerConfigurationRefusal,
|
||||
type AnalysisExecution,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { AdbRequest } from "../domain/adb/adbDeviceAnalysis.js";
|
||||
import {
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
@@ -417,8 +417,8 @@ export class AdbProvider {
|
||||
async inspectAvailability(
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProviderAvailability> {
|
||||
const selection = this.selection();
|
||||
try {
|
||||
const selection = this.selection();
|
||||
await inspectAdbClient(selection, signal);
|
||||
return {
|
||||
status: "available",
|
||||
@@ -555,21 +555,19 @@ export class AdbProvider {
|
||||
const signal = options?.signal;
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
const selection = this.selection();
|
||||
let selection: AdbBinarySelection;
|
||||
let identity: AdbClientIdentity;
|
||||
try {
|
||||
selection = this.selection();
|
||||
identity = await inspectAdbClient(selection, signal);
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisCancelledError) return err(cause);
|
||||
if (cause instanceof AdbConfigurationFailure)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
providerConfigurationRefusal(
|
||||
ADB_PROVIDER_IDENTITY.id,
|
||||
request.operation,
|
||||
typeof cause.diagnostics.remediation === "string"
|
||||
? `${cause.message} ${cause.diagnostics.remediation}`
|
||||
: cause.message,
|
||||
{ cause },
|
||||
cause,
|
||||
),
|
||||
);
|
||||
return err(
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
providerConfigurationRefusal,
|
||||
type AnalysisExecution,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { ApktoolRequest } from "../domain/apktool/apktoolResourceAnalysis.js";
|
||||
@@ -153,12 +154,13 @@ const runApktool = async (
|
||||
invocation = apktoolInvocation(selection, arguments_, hostEnvironment);
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof WindowsBatchCommandError)) throw cause;
|
||||
const reason = `The apktool batch launcher cannot receive REA's decode paths through cmd.exe: ${cause.message}`;
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
APKTOOL_PROVIDER_IDENTITY.id,
|
||||
operation,
|
||||
`The apktool batch launcher cannot receive REA's decode paths through cmd.exe: ${cause.message}`,
|
||||
{ cause },
|
||||
reason,
|
||||
{ cause, userMessage: reason },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -345,8 +347,8 @@ export class ApktoolProvider {
|
||||
async #inspectAvailability(
|
||||
signal: AbortSignal,
|
||||
): Promise<ProviderAvailability> {
|
||||
const selection = this.selection();
|
||||
try {
|
||||
const selection = this.selection();
|
||||
await inspectApktoolClient(selection, signal, this.environment);
|
||||
throwIfCancelled("inspect_apktool_client", signal);
|
||||
return {
|
||||
@@ -397,9 +399,10 @@ export class ApktoolProvider {
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
const selection = this.selection();
|
||||
let selection: ApktoolCommandSelection;
|
||||
let identity: ApktoolClientIdentity;
|
||||
try {
|
||||
selection = this.selection();
|
||||
identity = await inspectApktoolClient(
|
||||
selection,
|
||||
signal,
|
||||
@@ -409,13 +412,10 @@ export class ApktoolProvider {
|
||||
if (cause instanceof AnalysisCancelledError) return err(cause);
|
||||
if (cause instanceof ApktoolConfigurationFailure)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
providerConfigurationRefusal(
|
||||
APKTOOL_PROVIDER_IDENTITY.id,
|
||||
request.operation,
|
||||
typeof cause.diagnostics.remediation === "string"
|
||||
? `${cause.message} ${cause.diagnostics.remediation}`
|
||||
: cause.message,
|
||||
{ cause },
|
||||
cause,
|
||||
),
|
||||
);
|
||||
return err(
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { NativeToolName } from "../contracts/native/nativeToolContracts.js"
|
||||
import type { ArtifactAnalysisOperation } from "../contracts/artifactToolContracts.js";
|
||||
import type { ManagedToolName } from "../contracts/managed/managedToolContracts.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
@@ -210,6 +211,31 @@ export type ProviderAvailability =
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>>;
|
||||
};
|
||||
|
||||
/** A host tool configuration failure observed before any target work. */
|
||||
export interface ProviderConfigurationFailure extends Error {
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse an operation because the host tool is misconfigured. The reason,
|
||||
* not the generic target advice, tells the caller what to change.
|
||||
*/
|
||||
export const providerConfigurationRefusal = (
|
||||
providerId: string,
|
||||
operation: string,
|
||||
failure: ProviderConfigurationFailure,
|
||||
): AnalysisCapabilityUnavailableError => {
|
||||
const remediation = failure.diagnostics.remediation;
|
||||
const reason =
|
||||
typeof remediation === "string"
|
||||
? `${failure.message}. ${remediation}.`
|
||||
: `${failure.message}.`;
|
||||
return new AnalysisCapabilityUnavailableError(providerId, operation, reason, {
|
||||
cause: failure,
|
||||
userMessage: reason,
|
||||
});
|
||||
};
|
||||
|
||||
/** Provider-owned target support observation before profile resolution. */
|
||||
export type ProviderTargetSupport =
|
||||
| {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
clientRequirementsFor,
|
||||
NO_CLIENT_FEATURES,
|
||||
} from "./CapabilityClientRequirements.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
const statusSchema = z.object({
|
||||
open: z.boolean(),
|
||||
@@ -210,7 +211,7 @@ export const buildCapabilityInventory = (
|
||||
reason: availability.reason,
|
||||
remediation: availability.remediation,
|
||||
};
|
||||
}).sort((left, right) => left.name.localeCompare(right.name));
|
||||
}).sort((left, right) => compareUnicodeCodePoints(left.name, right.name));
|
||||
};
|
||||
|
||||
const availabilityFor = (context: AvailabilityContext): Availability => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { z } from "zod";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
export type ClientConfigurationFormat = NonNullable<SetupClient["format"]>;
|
||||
export type ClientServersKey = "mcp_servers" | "mcpServers" | "mcp" | "servers";
|
||||
@@ -63,7 +64,7 @@ const normalizeConfigurationValue = (value: unknown): unknown => {
|
||||
return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
|
||||
.map(([key, nested]) => [key, normalizeConfigurationValue(nested)]),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ClientConfigurationDocument,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Read explicit REA overrides without dropping peers when one value is invalid. */
|
||||
export const clientRegistrationEnvironment = (
|
||||
@@ -47,7 +48,7 @@ export const clientRegistrationEnvironment = (
|
||||
return ok(
|
||||
Object.fromEntries(
|
||||
Object.entries(environment).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
compareUnicodeCodePoints(left, right),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
supportedClients,
|
||||
} from "./SupportedClients.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
interface ClientRegistrationStatusBase {
|
||||
readonly client: string;
|
||||
@@ -184,7 +185,7 @@ export const readClientRegistrationStatuses = async (
|
||||
}
|
||||
}
|
||||
return statuses.sort((left, right) =>
|
||||
left.client.localeCompare(right.client),
|
||||
compareUnicodeCodePoints(left.client, right.client),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -281,6 +281,42 @@ describe("doctor installation identity", () => {
|
||||
});
|
||||
|
||||
describe("doctor scoped readiness", () => {
|
||||
it("checks explicitly selected skill destinations independently of registrations", async () => {
|
||||
const selected: (readonly string[] | undefined)[] = [];
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
clientRegistrations: () => Promise.resolve([]),
|
||||
installedSkillIdentity: (clients) => {
|
||||
selected.push(clients);
|
||||
return Promise.resolve({
|
||||
version: "stale",
|
||||
toolCount: 0,
|
||||
canonical: false,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
clients: [],
|
||||
skillClients: ["claude_code", "claude_code"],
|
||||
skill: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(selected).toEqual([["claude_code"]]);
|
||||
expect(result.scope).toMatchObject({
|
||||
mode: "explicit",
|
||||
clients: [],
|
||||
skill_clients: ["claude_code"],
|
||||
skill: true,
|
||||
});
|
||||
expect(result.healthy).toBe(false);
|
||||
expect(result.scope_checks.map(({ name }) => name)).toEqual([
|
||||
"node",
|
||||
"skill:identity",
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes effective readiness to selected clients without hiding drift", async () => {
|
||||
const registrations: readonly ClientRegistrationStatus[] = [
|
||||
{
|
||||
|
||||
@@ -120,6 +120,7 @@ export interface DoctorReport {
|
||||
/** Caller-selected readiness boundary; omitted values retain audit semantics. */
|
||||
export interface DoctorScope {
|
||||
readonly clients?: readonly string[];
|
||||
readonly skillClients?: readonly string[];
|
||||
readonly providers?: readonly string[];
|
||||
readonly skill?: boolean;
|
||||
}
|
||||
@@ -206,9 +207,10 @@ const collectDoctorIdentity = async (
|
||||
const installationPaths = (await host.installationPaths?.()) ?? [];
|
||||
const registrations = (await host.clientRegistrations?.()) ?? [];
|
||||
const skillClientIds =
|
||||
scope.mode === "audit-wide"
|
||||
scope.skill_clients ??
|
||||
(scope.mode === "audit-wide"
|
||||
? registrations.map(({ client }) => client)
|
||||
: scope.clients;
|
||||
: scope.clients);
|
||||
const installedSkillIdentity =
|
||||
await host.installedSkillIdentity?.(skillClientIds);
|
||||
const skillAligned = skillIdentityAligned(installedSkillIdentity);
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
export interface DoctorScopeReport {
|
||||
readonly mode: "audit-wide" | "explicit";
|
||||
readonly clients: readonly string[];
|
||||
readonly skill_clients?: readonly string[];
|
||||
readonly providers: readonly string[];
|
||||
readonly skill: boolean;
|
||||
readonly target: string | null;
|
||||
@@ -40,6 +41,9 @@ export const normalizeDoctorScope = (
|
||||
): DoctorScopeReport => ({
|
||||
mode: requested === undefined ? "audit-wide" : "explicit",
|
||||
clients: uniqueSorted(requested?.clients ?? []),
|
||||
...(requested?.skillClients === undefined
|
||||
? {}
|
||||
: { skill_clients: uniqueSorted(requested.skillClients) }),
|
||||
providers: uniqueSorted(requested?.providers ?? []),
|
||||
skill: requested?.skill === true,
|
||||
target: target ?? null,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
semanticObligationPolicy,
|
||||
type ReconstructionObligationCandidatePolicy,
|
||||
} from "./ReconstructionObligationPolicies.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
type CandidateFields = Omit<
|
||||
ReconstructionObligation,
|
||||
@@ -54,7 +55,7 @@ export const deriveReconstructionObligationCandidates = (
|
||||
addCandidate(candidates, reviewedCandidate(obligation, records));
|
||||
return {
|
||||
candidates: [...candidates.values()].sort((left, right) =>
|
||||
left.obligation_id.localeCompare(right.obligation_id),
|
||||
compareUnicodeCodePoints(left.obligation_id, right.obligation_id),
|
||||
),
|
||||
limitations: [...limitations].sort(),
|
||||
};
|
||||
@@ -430,7 +431,7 @@ const uniqueBy = <Value>(
|
||||
key: (value: Value) => string,
|
||||
): Value[] =>
|
||||
[...new Map(values.map((value) => [key(value), value])).values()].sort(
|
||||
(left, right) => key(left).localeCompare(key(right)),
|
||||
(left, right) => compareUnicodeCodePoints(key(left), key(right)),
|
||||
);
|
||||
|
||||
const obligationId = (key: string): string =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ReconstructionObligationManifest,
|
||||
} from "../domain/reconstructionObligationLedgerSchemas.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
type Diagnostic = ReconstructionObligation["diagnostics"][number];
|
||||
type Binding = NonNullable<ReconstructionObligation["binding"]>;
|
||||
@@ -50,7 +51,8 @@ export const uniqueObligationDiagnostics = (
|
||||
]),
|
||||
).values(),
|
||||
].sort((left, right) =>
|
||||
`${left.code}:${left.detail}`.localeCompare(
|
||||
compareUnicodeCodePoints(
|
||||
`${left.code}:${left.detail}`,
|
||||
`${right.code}:${right.detail}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
obligationReports,
|
||||
summarizeObligations,
|
||||
} from "./ReconstructionObligationLedgerProjection.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
type Diagnostic = ReconstructionObligation["diagnostics"][number];
|
||||
type Binding = NonNullable<ReconstructionObligation["binding"]>;
|
||||
@@ -60,7 +61,7 @@ export const evaluateReconstructionObligationLedger = ({
|
||||
}): ReconstructionObligationLedger => {
|
||||
const bundle = parseEvidenceBundle(bundleInput);
|
||||
const allCandidates = [...candidatesInput].sort((left, right) =>
|
||||
left.obligation_id.localeCompare(right.obligation_id),
|
||||
compareUnicodeCodePoints(left.obligation_id, right.obligation_id),
|
||||
);
|
||||
const orphanManifestIds = findOrphanManifestIds(allCandidates, manifest);
|
||||
const candidates = allCandidates;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Evidence } from "../domain/evidence.js";
|
||||
import type { ReconstructionObligation } from "../domain/reconstructionObligationLedgerSchemas.js";
|
||||
import { evidenceSupportsOriginalAuthority } from "./ReconstructionObligationAuthority.js";
|
||||
import type { ReconstructionObligationCandidate } from "./ReconstructionObligationCandidates.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
type Binding = NonNullable<ReconstructionObligation["binding"]>;
|
||||
type Diagnostic = ReconstructionObligation["diagnostics"][number];
|
||||
@@ -21,7 +22,8 @@ export const originalCasesFor = (
|
||||
),
|
||||
).values(),
|
||||
].sort((left, right) =>
|
||||
`${left.kind}:${left.evidence_id}:${left.location}`.localeCompare(
|
||||
compareUnicodeCodePoints(
|
||||
`${left.kind}:${left.evidence_id}:${left.location}`,
|
||||
`${right.kind}:${right.evidence_id}:${right.location}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
ReferenceSourceRead,
|
||||
} from "../reference/ReferenceSourceReaderTypes.js";
|
||||
import { PARSEABLE_REFERENCE_SOURCE_LANGUAGES } from "./ReferenceSourceImportTypes.js";
|
||||
import { typescriptEmittedSourceCandidates } from "./TypeScriptEmittedSourceCandidates.js";
|
||||
|
||||
export interface ParsedReferenceSourceEntries {
|
||||
readonly entries: HistoricalSourceGraphInput["entries"];
|
||||
@@ -99,22 +100,9 @@ const resolveInternalSpecifier = (
|
||||
`${normalized}${suffix}`,
|
||||
`${normalized}/index${suffix}`,
|
||||
]);
|
||||
// TypeScript source commonly names the extension emitted for NodeNext.
|
||||
// Retain present runtime files first; substitute only a missing counterpart.
|
||||
// Counterparts cover explicit filenames, not directory/index resolution.
|
||||
if (/\.(?:ts|tsx|mts|cts)$/u.test(fromPath)) {
|
||||
const sourceSuffixes = normalized.endsWith(".js")
|
||||
? [".ts", ".tsx", ".d.ts"]
|
||||
: normalized.endsWith(".mjs")
|
||||
? [".mts", ".d.mts"]
|
||||
: normalized.endsWith(".cjs")
|
||||
? [".cts", ".d.cts"]
|
||||
: normalized.endsWith(".jsx")
|
||||
? [".tsx", ".d.ts"]
|
||||
: [];
|
||||
const stem = normalized.slice(0, -posix.extname(normalized).length);
|
||||
candidates.push(...sourceSuffixes.map((suffix) => `${stem}${suffix}`));
|
||||
}
|
||||
candidates.push(...typescriptEmittedSourceCandidates(fromPath, normalized));
|
||||
const match = lookupPath.resolveCandidates(candidates);
|
||||
return match === undefined
|
||||
? { to: normalized, resolution: "unresolved" }
|
||||
|
||||
@@ -159,6 +159,7 @@ export class FakeSetupHost implements SetupHost {
|
||||
scope === undefined
|
||||
? environmentHealthy
|
||||
: (scope.clients?.length ?? 0) === 0 &&
|
||||
(scope.skillClients?.length ?? 0) === 0 &&
|
||||
(scope.providers?.length ?? 0) === 0 &&
|
||||
scope.skill !== true
|
||||
? true
|
||||
@@ -169,6 +170,9 @@ export class FakeSetupHost implements SetupHost {
|
||||
scope: {
|
||||
mode: scope === undefined ? "audit-wide" : "explicit",
|
||||
clients: scope?.clients ?? [],
|
||||
...(scope?.skillClients === undefined
|
||||
? {}
|
||||
: { skill_clients: scope.skillClients }),
|
||||
providers: scope?.providers ?? [],
|
||||
skill: scope?.skill === true,
|
||||
target: null,
|
||||
|
||||
@@ -232,6 +232,7 @@ describe("setup scoped readiness", () => {
|
||||
clients: ["codex"],
|
||||
providers: [],
|
||||
skill: true,
|
||||
skillClients: ["codex"],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -125,7 +125,10 @@ const runSetupWorkflow = async (
|
||||
: (options.clientIds ??
|
||||
options.readinessScope?.clients ??
|
||||
discovery.defaultClientIds);
|
||||
let selectedSkillClientIds = options.skillClientIds ?? selectedClientIds;
|
||||
let selectedSkillClientIds =
|
||||
options.skillClientIds ??
|
||||
options.readinessScope?.skillClients ??
|
||||
selectedClientIds;
|
||||
let skillNeedsInstall = await host.skillNeedsInstall(selectedSkillClientIds);
|
||||
let skillSelected =
|
||||
options.installSkill === true ||
|
||||
@@ -220,6 +223,7 @@ const runSetupWorkflow = async (
|
||||
installHopper: planDiscovery.installHopper,
|
||||
installSkill,
|
||||
skillSelected,
|
||||
selectedSkillClientIds,
|
||||
selectedClients: planned.selectedClients,
|
||||
clientsToConfigure: planned.clientsToConfigure,
|
||||
};
|
||||
@@ -519,6 +523,7 @@ const resolvedReadinessScope = (
|
||||
readonly selectedClients: readonly SetupClient[];
|
||||
readonly installHopper: boolean;
|
||||
readonly skillSelected?: boolean;
|
||||
readonly selectedSkillClientIds: readonly string[];
|
||||
},
|
||||
): DoctorScope => ({
|
||||
clients: selection.selectedClients.map(({ name }) => name),
|
||||
@@ -526,6 +531,9 @@ const resolvedReadinessScope = (
|
||||
? ["hopper"]
|
||||
: (requested?.providers ?? []),
|
||||
skill: selection.skillSelected === true || requested?.skill === true,
|
||||
...(selection.skillSelected === true || requested?.skill === true
|
||||
? { skillClients: selection.selectedSkillClientIds }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const summarizeDoctor = (report: DoctorReport): SetupDoctorSummary => ({
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type { DoctorReport } from "./Doctor.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Read-only setup facts shared by client selection and preflight. */
|
||||
export interface SetupDiscovery {
|
||||
@@ -308,7 +309,7 @@ const clientConfigurationDetail = (
|
||||
installHopper: boolean,
|
||||
): string => {
|
||||
const entries = Object.entries(environment)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
|
||||
.map(([name, value]) => `${name}=${value}`);
|
||||
const environmentDetail =
|
||||
entries.length === 0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DoctorProviderInspection } from "./Doctor.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Preserve adapter-published settings independently of runtime readiness. */
|
||||
export const providerRegistrationEnvironment = (
|
||||
@@ -9,5 +10,5 @@ export const providerRegistrationEnvironment = (
|
||||
.flatMap(({ registrationEnvironment }) =>
|
||||
Object.entries(registrationEnvironment),
|
||||
)
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
.sort(([left], [right]) => compareUnicodeCodePoints(left, right)),
|
||||
);
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
HermesClientPathError,
|
||||
resolveHermesClientHome,
|
||||
} from "./HermesClientHome.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** One supported client configuration location. */
|
||||
export interface SetupClient {
|
||||
@@ -642,6 +643,6 @@ export const clientSkillDirectories = (
|
||||
});
|
||||
}
|
||||
return [...destinations.values()].sort((left, right) =>
|
||||
left.directory.localeCompare(right.directory),
|
||||
compareUnicodeCodePoints(left.directory, right.directory),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Emitted NodeNext suffixes and the TypeScript source counterparts they name. */
|
||||
const EMITTED_SUFFIXES: ReadonlyArray<
|
||||
readonly [suffix: string, sourceSuffixes: readonly string[]]
|
||||
> = [
|
||||
[".js", [".ts", ".tsx", ".d.ts"]],
|
||||
[".mjs", [".mts", ".d.mts"]],
|
||||
[".cjs", [".cts", ".d.cts"]],
|
||||
[".jsx", [".tsx", ".d.ts"]],
|
||||
];
|
||||
|
||||
/**
|
||||
* Return TypeScript source counterparts for an explicit emitted filename.
|
||||
* Callers retain exact runtime-file precedence and must not use these for
|
||||
* directory/index, URL, HTML, or package target resolution.
|
||||
*/
|
||||
export const typescriptEmittedSourceCandidates = (
|
||||
sourcePath: string,
|
||||
candidate: string,
|
||||
): readonly string[] => {
|
||||
if (!/\.(?:ts|tsx|mts|cts)$/u.test(sourcePath)) return [];
|
||||
const emitted = EMITTED_SUFFIXES.find(([suffix]) =>
|
||||
candidate.endsWith(suffix),
|
||||
);
|
||||
if (emitted === undefined) return [];
|
||||
// Strip the matched suffix by length. posix.extname would miss dotfile
|
||||
// names such as ".js", whose whole basename is the emitted suffix itself.
|
||||
const stem = candidate.slice(0, candidate.length - emitted[0].length);
|
||||
return emitted[1].map((suffix) => `${stem}${suffix}`);
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { ok, type Result } from "../../domain/result.js";
|
||||
|
||||
/**
|
||||
* Complete class-inventory pages dominate raw-call provenance for focused
|
||||
* searches: one traversal embeds every examined class name regardless of the
|
||||
* match count. The summary keeps the pagination audit inline (totals, page
|
||||
* offsets and sizes, elided item counts) and links the retained record that
|
||||
* still carries the complete inventory.
|
||||
*/
|
||||
const elideInventoryText = (text: string): string | undefined => {
|
||||
let page: JsonValue;
|
||||
try {
|
||||
page = JSON.parse(text) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
typeof page !== "object" ||
|
||||
page === null ||
|
||||
Array.isArray(page) ||
|
||||
!Array.isArray(page.items)
|
||||
)
|
||||
return undefined;
|
||||
const { items, ...audited } = page;
|
||||
return JSON.stringify({ ...audited, items_omitted: items.length });
|
||||
};
|
||||
|
||||
/** Replace inventoried page bodies with their pagination audit. */
|
||||
const summarizeInventoryCall = (call: JsonValue): JsonValue => {
|
||||
if (
|
||||
typeof call !== "object" ||
|
||||
call === null ||
|
||||
Array.isArray(call) ||
|
||||
call.operation !== "list_classes"
|
||||
)
|
||||
return call;
|
||||
const response = call.response;
|
||||
if (
|
||||
typeof response !== "object" ||
|
||||
response === null ||
|
||||
Array.isArray(response) ||
|
||||
!Array.isArray(response.content)
|
||||
)
|
||||
return call;
|
||||
let elided = false;
|
||||
const content = response.content.map((entry: JsonValue) => {
|
||||
if (
|
||||
typeof entry !== "object" ||
|
||||
entry === null ||
|
||||
Array.isArray(entry) ||
|
||||
typeof entry.text !== "string"
|
||||
)
|
||||
return entry;
|
||||
const summarized = elideInventoryText(entry.text);
|
||||
if (summarized === undefined) return entry;
|
||||
elided = true;
|
||||
return { ...entry, text: summarized };
|
||||
});
|
||||
return elided
|
||||
? { ...call, inventory_elided: true, response: { ...response, content } }
|
||||
: call;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retain the complete search Evidence — full raw provider calls included —
|
||||
* in the session record, then deliver a bounded summary Evidence inline.
|
||||
* Callers without a retaining session keep the complete inline delivery, so
|
||||
* complete CLI JSON output is unchanged. Coverage metadata stays truthful in
|
||||
* both copies: the examined inventory is never rewritten to matched-only.
|
||||
*/
|
||||
export const summarizeRetainedAndroidSearch = (
|
||||
evidence: Evidence,
|
||||
recordEvidence: EvidenceWriter["recordEvidence"] | undefined,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
if (recordEvidence === undefined) return ok(evidence);
|
||||
const recorded = recordEvidence(evidence);
|
||||
if (!recorded.ok) return recorded;
|
||||
const subject = evidence.subject;
|
||||
const raw = evidence.raw_result;
|
||||
const summarized: JsonValue =
|
||||
raw === null ||
|
||||
typeof raw !== "object" ||
|
||||
Array.isArray(raw) ||
|
||||
!Array.isArray(raw.calls)
|
||||
? raw
|
||||
: {
|
||||
...raw,
|
||||
complete_evidence_id: evidence.evidence_id,
|
||||
calls: raw.calls.map(summarizeInventoryCall),
|
||||
};
|
||||
return ok(
|
||||
createEvidence(
|
||||
subject === null
|
||||
? undefined
|
||||
: {
|
||||
path: subject.local_path,
|
||||
sha256: subject.digest.sha256,
|
||||
format: subject.format,
|
||||
...(subject.architecture === null
|
||||
? {}
|
||||
: { architecture: subject.architecture }),
|
||||
},
|
||||
evidence.provider,
|
||||
{
|
||||
operation: evidence.operation,
|
||||
parameters: evidence.parameters,
|
||||
result: evidence.normalized_result,
|
||||
rawResult: summarized,
|
||||
confidence: evidence.confidence,
|
||||
limitations: evidence.limitations,
|
||||
locations: evidence.locations,
|
||||
evidenceLinks: [evidence.evidence_id],
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
evaluateAnalysisProviderCandidate,
|
||||
type AnalysisProviderCandidateEvaluation,
|
||||
} from "./AnalysisProviderEvaluation.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Deterministic discovery truth for one configured deep provider. */
|
||||
export interface AnalysisProviderCandidateStatus {
|
||||
@@ -109,12 +110,14 @@ export class AnalysisProviderRegistry {
|
||||
}
|
||||
this.#providers = Object.freeze(
|
||||
[...providers].sort((left, right) =>
|
||||
left.identity().id.localeCompare(right.identity().id),
|
||||
compareUnicodeCodePoints(left.identity().id, right.identity().id),
|
||||
),
|
||||
);
|
||||
this.#byId = byId;
|
||||
this.#declaredOperations = Object.freeze(
|
||||
[...declaredOperations].sort((left, right) => left.localeCompare(right)),
|
||||
[...declaredOperations].sort((left, right) =>
|
||||
compareUnicodeCodePoints(left, right),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,30 @@ export class BinarySession
|
||||
this.#providerRouter = providerRouter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast-path liveness gate (#1719): short-circuit only when the retained
|
||||
* client is genuinely alive. The probe reads the child's operation health
|
||||
* rather than the composite's unconditionally-ok `health`, so a dead child
|
||||
* falls through and a real client is started; an aborted caller leaves the
|
||||
* session untouched. Returns the fast-path result, or undefined to fall
|
||||
* through to the slow path.
|
||||
*/
|
||||
async #tryRetainedFastPath(
|
||||
retained: SessionBinding,
|
||||
target: BinaryTarget,
|
||||
options: Pick<BinarySessionOpenOptions, "signal" | "snapshot">,
|
||||
): Promise<Result<BinaryTarget, AnalysisError> | undefined> {
|
||||
const live = await this.#retainedClientLive(retained.client, options);
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (!live) return undefined;
|
||||
if (options.snapshot !== undefined) {
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) return imported;
|
||||
}
|
||||
return ok(target);
|
||||
}
|
||||
|
||||
/** Identify the provider producing evidence for this session. */
|
||||
providerIdentity(operation?: AnalysisOperation): ProviderIdentity {
|
||||
const route = this.#currentRoute();
|
||||
@@ -247,12 +271,14 @@ export class BinarySession
|
||||
if (!pendingClosed.ok) return pendingClosed;
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (sameTarget && sameProfile) {
|
||||
if (options.snapshot !== undefined) {
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) return imported;
|
||||
}
|
||||
return ok(target);
|
||||
const retained = sameTarget && sameProfile ? this.#active : undefined;
|
||||
if (retained !== undefined) {
|
||||
const fastPath = await this.#tryRetainedFastPath(
|
||||
retained,
|
||||
target,
|
||||
options,
|
||||
);
|
||||
if (fastPath !== undefined) return fastPath;
|
||||
}
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
@@ -703,6 +729,36 @@ export class BinarySession
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe whether a retained provider client is still live.
|
||||
*
|
||||
* On production routes the client is a CompositeProvider whose `health`
|
||||
* answers unconditionally ok without touching a child, so `health` alone
|
||||
* cannot see a dead child. `operationHealthSnapshot` reaches the
|
||||
* instantiated children instead. Only the known-live states allow the
|
||||
* fast path: `exited`/`unreachable` are dead, `unknown` is never
|
||||
* evidence of health (it is what a recorded but uncharacterized failure
|
||||
* reports), and `not_started` is a startup that never completed — in the
|
||||
* Hopper timeout case the client holds retained resources that block the
|
||||
* next `start()` until `close()` retries cleanup, so treating it as live
|
||||
* would skip the retire path and hand the wedged client to subsequent
|
||||
* analysis. The gate fails closed: without a snapshot signal, fall back
|
||||
* to the `health` admission probe.
|
||||
*/
|
||||
async #retainedClientLive(
|
||||
client: AnalysisClient,
|
||||
options: Pick<BinarySessionOpenOptions, "signal">,
|
||||
): Promise<boolean> {
|
||||
const snapshot = client.operationHealthSnapshot?.();
|
||||
if (snapshot !== undefined)
|
||||
return snapshot.state === "idle" || snapshot.state === "busy";
|
||||
try {
|
||||
return (await client.execute("health", {}, options)).ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async #restore(
|
||||
previous: SessionBinding | undefined,
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisClient,
|
||||
type AnalysisOperation,
|
||||
type AnalysisProvider,
|
||||
type AnalysisProviderCandidate,
|
||||
type CapabilityDescriptor,
|
||||
type ProviderAvailability,
|
||||
type ProviderIdentity,
|
||||
type ProviderTargetSupport,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import type { ProviderOperationHealth } from "../../domain/providerOperationHealth.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
|
||||
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
|
||||
import { BinarySession } from "./BinarySession.js";
|
||||
import { SessionProviderRouter } from "./SessionProviderRouter.js";
|
||||
|
||||
/**
|
||||
* Regression coverage for https://github.com/morluto/rea/issues/1719:
|
||||
* `BinarySession#open` must not short-circuit on the `sameTarget &&
|
||||
* sameProfile` fast path when the retained provider client is dead. It must
|
||||
* start a fresh client instead.
|
||||
*
|
||||
* The fixtures are production-composed: a deep provider plus an auxiliary
|
||||
* provider, so `SessionProviderRouter` wraps them in a real
|
||||
* `CompositeProvider` exactly like `src/composition/binary.ts` does for the
|
||||
* CLI/MCP runtime. The composite's `health` answers unconditionally ok
|
||||
* without touching a child, so a probe that only calls `health` cannot see a
|
||||
* dead child — the liveness signal must reach the actual child through
|
||||
* `operationHealthSnapshot`, the way `HopperClient.operationHealth` reports a
|
||||
* recorded process exit.
|
||||
*/
|
||||
describe("BinarySession open liveness", () => {
|
||||
it("reuses the retained client while the child stays live", async () => {
|
||||
const { session, deep } = openSession();
|
||||
expect((await session.open(process.execPath)).ok).toBe(true);
|
||||
// Instantiate the deep child inside the composite, like a real query would.
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect(deep.created).toHaveLength(1);
|
||||
|
||||
const second = await session.open(process.execPath);
|
||||
expect(second.ok).toBe(true);
|
||||
// Still the same composite and child: nothing was retired or recreated.
|
||||
expect(deep.created).toHaveLength(1);
|
||||
expect(deep.created[0]?.closeCalled).toBe(false);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect(deep.created).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("starts a fresh client when the retained child died", async () => {
|
||||
const { session, deep } = openSession();
|
||||
expect((await session.open(process.execPath)).ok).toBe(true);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect(deep.created).toHaveLength(1);
|
||||
|
||||
// Kill the provider process out from under the session, then re-open
|
||||
// the same target: the session must not short-circuit on the dead
|
||||
// child — it must retire it and start a real one.
|
||||
const dead = deep.created[0];
|
||||
dead?.kill();
|
||||
|
||||
const second = await session.open(process.execPath);
|
||||
expect(second.ok).toBe(true);
|
||||
// The dead binding was retired, not retained.
|
||||
expect(dead?.closeCalled).toBe(true);
|
||||
// The next query runs against a freshly created child, not the dead one.
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect(deep.created).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("treats an uncharacterized operation failure as dead", async () => {
|
||||
// A recorded failure the provider could not characterize ("unknown",
|
||||
// e.g. a Hopper spawn failure with no observed exit code) is never
|
||||
// evidence of health: the gate fails closed.
|
||||
const { session, deep } = openSession();
|
||||
expect((await session.open(process.execPath)).ok).toBe(true);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect(deep.created).toHaveLength(1);
|
||||
|
||||
const dead = deep.created[0];
|
||||
dead?.kill("unknown");
|
||||
|
||||
const second = await session.open(process.execPath);
|
||||
expect(second.ok).toBe(true);
|
||||
expect(dead?.closeCalled).toBe(true);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect(deep.created).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("retires a retained client whose startup never completed", async () => {
|
||||
// A Hopper startup timeout leaves the operation-health snapshot in
|
||||
// "not_started" while the retained resources block the next start()
|
||||
// until close() retries cleanup. Classifying that state as live would return success
|
||||
// without entering the retire path, so subsequent analysis still hits
|
||||
// the wedged client.
|
||||
const { session, deep } = openSession();
|
||||
expect((await session.open(process.execPath)).ok).toBe(true);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect(deep.created).toHaveLength(1);
|
||||
|
||||
const wedged = deep.created[0];
|
||||
wedged?.kill("not_started");
|
||||
|
||||
const second = await session.open(process.execPath);
|
||||
expect(second.ok).toBe(true);
|
||||
// The wedged binding was retired (close ran, so cleanup is retried),
|
||||
// not reused.
|
||||
expect(wedged?.closeCalled).toBe(true);
|
||||
// The next query runs against a freshly created child, not the wedged one.
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect(deep.created).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("falls back to the health probe when the child reports no operation health", async () => {
|
||||
// Without an operation-health hook there is no child-liveness signal,
|
||||
// so the probe keeps the previous `health` admission behavior.
|
||||
const { session, deep } = openSession({ operationHealth: false });
|
||||
expect((await session.open(process.execPath)).ok).toBe(true);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect(deep.created).toHaveLength(1);
|
||||
|
||||
const second = await session.open(process.execPath);
|
||||
expect(second.ok).toBe(true);
|
||||
// The composite's `health` answers ok, so the live client is reused.
|
||||
expect(deep.created).toHaveLength(1);
|
||||
expect(deep.created[0]?.closeCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("reports cancellation without touching the session when aborted mid-probe", async () => {
|
||||
const { session, deep, onProbe } = openSession();
|
||||
expect((await session.open(process.execPath)).ok).toBe(true);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
// Fire the abort while the fast-path liveness probe is in flight: the
|
||||
// hook runs synchronously inside the snapshot read, so the abort lands
|
||||
// after the probe started but before the fast-path decision completes.
|
||||
let fired = false;
|
||||
onProbe(() => {
|
||||
if (!fired) {
|
||||
fired = true;
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
|
||||
const cancelled = await session.open(process.execPath, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(cancelled).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
// The dead-or-live question is moot: the session must be untouched.
|
||||
expect(deep.created).toHaveLength(1);
|
||||
expect(deep.created[0]?.closeCalled).toBe(false);
|
||||
const reopened = await session.open(process.execPath);
|
||||
expect(reopened.ok).toBe(true);
|
||||
expect(deep.created).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
interface KillableChild extends AnalysisClient {
|
||||
kill(state?: "exited" | "unreachable" | "unknown" | "not_started"): void;
|
||||
closeCalled: boolean;
|
||||
}
|
||||
|
||||
const deepIdentity: ProviderIdentity = {
|
||||
id: "liveness-deep",
|
||||
name: "Liveness deep provider",
|
||||
version: "1",
|
||||
};
|
||||
|
||||
const auxIdentity: ProviderIdentity = {
|
||||
id: "liveness-aux",
|
||||
name: "Liveness auxiliary provider",
|
||||
version: "1",
|
||||
};
|
||||
|
||||
const capability = (
|
||||
identity: ProviderIdentity,
|
||||
operation: Exclude<AnalysisOperation, "health">,
|
||||
): CapabilityDescriptor => ({
|
||||
provider: identity,
|
||||
operation,
|
||||
available: true,
|
||||
reason: null,
|
||||
effects: {
|
||||
mutatesArtifact: false,
|
||||
launchesProcess: false,
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem: false,
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
},
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
const openSession = (
|
||||
options: { operationHealth?: boolean } = {},
|
||||
): {
|
||||
session: BinarySession;
|
||||
deep: { created: KillableChild[] };
|
||||
onProbe: (hook: () => void) => void;
|
||||
} => {
|
||||
const { provider, created, onProbe } = killableDeepProvider(options);
|
||||
// Mirror the production composition: a deep provider plus auxiliary
|
||||
// providers, so the route wraps them in a real CompositeProvider (whose
|
||||
// trivially-ok `health` must not mask a dead child).
|
||||
const aux: AnalysisProvider = {
|
||||
identity: () => auxIdentity,
|
||||
capabilities: () => [],
|
||||
createClient: (): AnalysisClient => ({
|
||||
execute: (operation) =>
|
||||
Promise.resolve(
|
||||
ok(createAnalysisExecution(`aux:${operation}`, auxIdentity)),
|
||||
),
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
}),
|
||||
};
|
||||
const session = new BinarySession(
|
||||
SessionProviderRouter.selectable(new AnalysisProviderRegistry([provider]), [
|
||||
aux,
|
||||
]),
|
||||
new InvestigationRecords(),
|
||||
);
|
||||
return { session, deep: { created }, onProbe };
|
||||
};
|
||||
|
||||
const killableDeepProvider = (options: {
|
||||
operationHealth?: boolean;
|
||||
}): {
|
||||
provider: AnalysisProviderCandidate;
|
||||
created: KillableChild[];
|
||||
onProbe: (hook: () => void) => void;
|
||||
} => {
|
||||
const created: KillableChild[] = [];
|
||||
let probeHook: (() => void) | undefined;
|
||||
const withOperationHealth = options.operationHealth !== false;
|
||||
const provider: AnalysisProviderCandidate = {
|
||||
identity: () => deepIdentity,
|
||||
capabilities: () => [capability(deepIdentity, "address_name")],
|
||||
inspectAvailability: (): ProviderAvailability => ({
|
||||
status: "available",
|
||||
code: null,
|
||||
reason: null,
|
||||
diagnostics: {},
|
||||
}),
|
||||
inspectTargetSupport: (): ProviderTargetSupport => ({
|
||||
status: "supported",
|
||||
code: null,
|
||||
reason: null,
|
||||
diagnostics: {},
|
||||
}),
|
||||
resolveAnalysisProfile: () =>
|
||||
Promise.resolve(
|
||||
ok({
|
||||
profile: createAnalysisProfile(
|
||||
{ id: deepIdentity.id, name: deepIdentity.name, version: "1" },
|
||||
{ fixture: "liveness" },
|
||||
),
|
||||
}),
|
||||
),
|
||||
createClient: (): KillableChild => {
|
||||
let alive = true;
|
||||
let closeCalled = false;
|
||||
let deadState: "exited" | "unreachable" | "unknown" | "not_started" =
|
||||
"exited";
|
||||
const client: KillableChild = {
|
||||
execute: (operation) => {
|
||||
return alive
|
||||
? Promise.resolve(
|
||||
ok(
|
||||
createAnalysisExecution(
|
||||
operation === "health" ? null : `liveness:${operation}`,
|
||||
deepIdentity,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Promise.resolve(
|
||||
err(
|
||||
new ProviderAdapterError(deepIdentity.id, operation, {
|
||||
diagnostics: { reason: "process_killed" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
// Genuine child-liveness signal, like HopperClient.operationHealth:
|
||||
// a recorded process exit stays visible after the kill.
|
||||
...(withOperationHealth
|
||||
? {
|
||||
operationHealthSnapshot: (): ProviderOperationHealth => {
|
||||
probeHook?.();
|
||||
return {
|
||||
state: alive ? "idle" : deadState,
|
||||
stage: alive ? null : "connection",
|
||||
retryAction: alive ? null : "restart_provider",
|
||||
exitCode: alive ? null : 1,
|
||||
requests: [],
|
||||
};
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
close: () => {
|
||||
closeCalled = true;
|
||||
return Promise.resolve(ok(null));
|
||||
},
|
||||
get closeCalled() {
|
||||
return closeCalled;
|
||||
},
|
||||
kill: (
|
||||
state:
|
||||
| "exited"
|
||||
| "unreachable"
|
||||
| "unknown"
|
||||
| "not_started" = "exited",
|
||||
) => {
|
||||
alive = false;
|
||||
deadState = state;
|
||||
},
|
||||
};
|
||||
created.push(client);
|
||||
return client;
|
||||
},
|
||||
};
|
||||
return {
|
||||
provider,
|
||||
created,
|
||||
onProbe: (hook: () => void) => {
|
||||
probeHook = hook;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SessionProviderRoute,
|
||||
SessionProviderRouter,
|
||||
} from "./SessionProviderRouter.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
|
||||
interface BinarySessionStatusInput {
|
||||
readonly target: BinaryTarget | undefined;
|
||||
@@ -39,7 +40,9 @@ export const binarySessionStatus = ({
|
||||
}: BinarySessionStatusInput): JsonValue => {
|
||||
const capabilities = [...route.capabilities.values()]
|
||||
.filter(({ operation }) => operation !== "inventory_artifact")
|
||||
.sort((left, right) => left.operation.localeCompare(right.operation))
|
||||
.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.operation, right.operation),
|
||||
)
|
||||
.map((descriptor) => {
|
||||
const unavailable = runtimeUnavailability.get(descriptor.operation);
|
||||
return unavailable === undefined
|
||||
@@ -69,7 +72,9 @@ export const binarySessionStatus = ({
|
||||
capabilities: candidate.capabilities
|
||||
.slice()
|
||||
.filter(({ operation }) => operation !== "inventory_artifact")
|
||||
.sort((left, right) => left.operation.localeCompare(right.operation))
|
||||
.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.operation, right.operation),
|
||||
)
|
||||
.map(capabilityStatus),
|
||||
}));
|
||||
const common = {
|
||||
@@ -102,7 +107,9 @@ const requestActivityStatus = (
|
||||
) => {
|
||||
const providers = snapshots
|
||||
.slice()
|
||||
.sort((left, right) => left.provider.id.localeCompare(right.provider.id))
|
||||
.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.provider.id, right.provider.id),
|
||||
)
|
||||
.map(({ provider, active, queuedRequests }) => ({
|
||||
provider: providerSummary(provider),
|
||||
active:
|
||||
@@ -150,7 +157,7 @@ const processLineageStatus = (
|
||||
snapshots: snapshots
|
||||
.slice()
|
||||
.sort((left, right) =>
|
||||
left.provider.id.localeCompare(right.provider.id),
|
||||
compareUnicodeCodePoints(left.provider.id, right.provider.id),
|
||||
)
|
||||
.map(({ provider, observation }) => ({
|
||||
provider: providerSummary(provider),
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Synthetic identity for a deterministic provider set. */
|
||||
const compositeProviderIdentity = (
|
||||
@@ -53,8 +54,8 @@ export class CompositeProvider implements AnalysisProvider {
|
||||
this.#capabilities = Object.freeze(
|
||||
capabilities.sort(
|
||||
(left, right) =>
|
||||
left.operation.localeCompare(right.operation) ||
|
||||
left.provider.id.localeCompare(right.provider.id),
|
||||
compareUnicodeCodePoints(left.operation, right.operation) ||
|
||||
compareUnicodeCodePoints(left.provider.id, right.provider.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -122,13 +123,13 @@ export class CompositeProvider implements AnalysisProvider {
|
||||
[...clients.values()]
|
||||
.flatMap((client) => client.runtimeLineageSnapshots?.() ?? [])
|
||||
.sort((left, right) =>
|
||||
left.provider.id.localeCompare(right.provider.id),
|
||||
compareUnicodeCodePoints(left.provider.id, right.provider.id),
|
||||
),
|
||||
requestActivitySnapshots: () =>
|
||||
[...clients.values()]
|
||||
.flatMap((client) => client.requestActivitySnapshots?.() ?? [])
|
||||
.sort((left, right) =>
|
||||
left.provider.id.localeCompare(right.provider.id),
|
||||
compareUnicodeCodePoints(left.provider.id, right.provider.id),
|
||||
),
|
||||
operationHealthSnapshot: () => {
|
||||
for (const client of clients.values()) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type UpdateUnknownInput,
|
||||
} from "../../domain/residualUnknown.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
|
||||
type EvidenceLedgerFailure = EvidenceIntegrityError;
|
||||
type RecordResult = Result<"added" | "duplicate", EvidenceLedgerFailure>;
|
||||
@@ -321,7 +322,9 @@ export class EvidenceLedger {
|
||||
unknown.severity === filters.severity) &&
|
||||
(filters.domain === undefined || unknown.domain === filters.domain),
|
||||
)
|
||||
.sort((left, right) => left.unknown_id.localeCompare(right.unknown_id))
|
||||
.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.unknown_id, right.unknown_id),
|
||||
)
|
||||
.map((unknown) => structuredClone(unknown));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
analyzeJavaScriptApplicationInputSchema,
|
||||
type JavaScriptApplicationAnalysisResult,
|
||||
parseOwnedJavaScriptApplicationAnalysisSteps,
|
||||
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
@@ -20,7 +21,10 @@ import {
|
||||
checkpointJavaScriptAnalysis,
|
||||
completeJavaScriptAnalysisSteps,
|
||||
} from "./JavaScriptAnalysisControl.js";
|
||||
import { reconstructJavaScriptArtifact } from "./JavaScriptArtifactReconstruction.js";
|
||||
import {
|
||||
reconstructJavaScriptArtifact,
|
||||
type JavaScriptArtifactReconstructionResult,
|
||||
} from "./JavaScriptArtifactReconstruction.js";
|
||||
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
|
||||
const OPERATION = "analyze_javascript_application" as const;
|
||||
@@ -45,6 +49,9 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
scope: ArtifactResourceScope,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
let reconstructed: JavaScriptArtifactReconstructionResult | undefined;
|
||||
let result: JavaScriptApplicationAnalysisResult | undefined;
|
||||
let evidence: Evidence | undefined;
|
||||
try {
|
||||
assertJavaScriptAnalysisActive(options.signal);
|
||||
await options.progress?.report({
|
||||
@@ -54,7 +61,7 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
message: "Inventorying and parsing application artifacts",
|
||||
});
|
||||
await checkpointJavaScriptAnalysis(options.signal);
|
||||
const reconstructed = await reconstructJavaScriptArtifact(
|
||||
reconstructed = await reconstructJavaScriptArtifact(
|
||||
{
|
||||
input_path: input.input_path,
|
||||
format: input.format,
|
||||
@@ -78,7 +85,7 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
message: "Validating result metadata and cross-graph bindings",
|
||||
});
|
||||
await checkpointJavaScriptAnalysis(options.signal);
|
||||
const result = await completeJavaScriptAnalysisSteps(
|
||||
result = await completeJavaScriptAnalysisSteps(
|
||||
parseOwnedJavaScriptApplicationAnalysisSteps({
|
||||
...application,
|
||||
summary,
|
||||
@@ -93,13 +100,12 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
message: "Creating and hashing application analysis Evidence",
|
||||
});
|
||||
await checkpointJavaScriptAnalysis(options.signal);
|
||||
const evidence =
|
||||
await createOwnedJavaScriptApplicationEvidenceCooperatively(
|
||||
input,
|
||||
result,
|
||||
options.signal,
|
||||
options.progress,
|
||||
);
|
||||
evidence = await createOwnedJavaScriptApplicationEvidenceCooperatively(
|
||||
input,
|
||||
result,
|
||||
options.signal,
|
||||
options.progress,
|
||||
);
|
||||
await checkpointJavaScriptAnalysis(options.signal);
|
||||
await options.progress?.report({
|
||||
phase: "analyze_javascript_application",
|
||||
@@ -111,6 +117,38 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
assertJavaScriptAnalysisActive(options.signal);
|
||||
return ok(evidence);
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof ArtifactReaderFailure &&
|
||||
cause.reason === "cancelled" &&
|
||||
reconstructed !== undefined
|
||||
) {
|
||||
try {
|
||||
if (result === undefined) {
|
||||
const { electron_summary: summary, ...application } = reconstructed;
|
||||
result = await completeJavaScriptAnalysisSteps(
|
||||
parseOwnedJavaScriptApplicationAnalysisSteps({
|
||||
...application,
|
||||
summary,
|
||||
limitations: reconstructed.graph.limitations,
|
||||
}),
|
||||
);
|
||||
}
|
||||
evidence ??=
|
||||
await createOwnedJavaScriptApplicationEvidenceCooperatively(
|
||||
input,
|
||||
result,
|
||||
);
|
||||
return err(
|
||||
artifactFailureToOperationError(cause).retainPartialObservation(
|
||||
evidence,
|
||||
),
|
||||
);
|
||||
} catch (completionCause: unknown) {
|
||||
// A validation or Evidence failure while finishing the partial result
|
||||
// remains the primary failure instead of being hidden by cancellation.
|
||||
cause = completionCause;
|
||||
}
|
||||
}
|
||||
// Preserve execution constraints and their completed partial Evidence.
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
|
||||
@@ -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.`,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, lstat, realpath } from "node:fs/promises";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascript
|
||||
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { parseOwnedJavaScriptApplicationAnalysisSteps } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
import type { ArtifactInventoryPartialObservation } from "../../domain/artifactPartialObservation.js";
|
||||
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
@@ -68,19 +70,17 @@ export const reconstructJavaScriptArtifact = async (
|
||||
progress?: ProgressReporter,
|
||||
): Promise<JavaScriptArtifactReconstructionResult> =>
|
||||
scope.run(async () => {
|
||||
let effectiveSignal = signal;
|
||||
let effectiveProgress = progress;
|
||||
const reportPhase = async (
|
||||
phase: string,
|
||||
message: string,
|
||||
): Promise<void> => {
|
||||
await effectiveProgress?.report({
|
||||
await progress?.report({
|
||||
phase,
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message,
|
||||
});
|
||||
await checkpointJavaScriptAnalysis(effectiveSignal);
|
||||
await checkpointJavaScriptAnalysis(signal);
|
||||
};
|
||||
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
|
||||
abortIfNeeded(signal);
|
||||
@@ -182,14 +182,24 @@ export const reconstructJavaScriptArtifact = async (
|
||||
}
|
||||
}
|
||||
const analysis = outcome.analysis;
|
||||
const interruption = cleanupFailure ?? outcome.interruption;
|
||||
if (interruption !== null && interruption !== undefined) {
|
||||
effectiveSignal = undefined;
|
||||
effectiveProgress = undefined;
|
||||
}
|
||||
await checkpointJavaScriptAnalysis(effectiveSignal);
|
||||
abortIfNeeded(effectiveSignal);
|
||||
await reportPhase(
|
||||
let interruption = cleanupFailure ?? outcome.interruption;
|
||||
// Source facts are owned now. Finish their projection cooperatively so a
|
||||
// later cancellation can retain Evidence instead of discarding those facts.
|
||||
const checkpointCompletedAnalysis = async (): Promise<void> => {
|
||||
await setImmediate();
|
||||
if (interruption == null && signal?.aborted === true)
|
||||
interruption = new ArtifactOperationError(OPERATION, "cancelled");
|
||||
};
|
||||
const reportCompletedPhase = async (
|
||||
phase: string,
|
||||
message: string,
|
||||
): Promise<void> => {
|
||||
await checkpointCompletedAnalysis();
|
||||
if (interruption == null)
|
||||
await progress?.report({ phase, completed: 0, total: 1, message });
|
||||
await checkpointCompletedAnalysis();
|
||||
};
|
||||
await reportCompletedPhase(
|
||||
"build_javascript_application_graph",
|
||||
"Constructing application and Electron boundary relationships",
|
||||
);
|
||||
@@ -198,15 +208,14 @@ export const reconstructJavaScriptArtifact = async (
|
||||
files,
|
||||
analysis,
|
||||
);
|
||||
await reportPhase(
|
||||
await reportCompletedPhase(
|
||||
"seal_javascript_application_graph",
|
||||
"Sealing the validated application graph",
|
||||
);
|
||||
const graph = await completeJavaScriptAnalysisSteps(
|
||||
applicationGraphSteps,
|
||||
effectiveSignal,
|
||||
);
|
||||
await reportPhase(
|
||||
await reportCompletedPhase(
|
||||
"build_javascript_semantic_graph",
|
||||
"Binding and validating static semantic relationships",
|
||||
);
|
||||
@@ -214,15 +223,13 @@ export const reconstructJavaScriptArtifact = async (
|
||||
snapshot.manifest.root_sha256,
|
||||
graph,
|
||||
);
|
||||
await reportPhase(
|
||||
await reportCompletedPhase(
|
||||
"seal_javascript_semantic_graph",
|
||||
"Validating and sealing the semantic graph",
|
||||
);
|
||||
const semanticGraph = await completeJavaScriptAnalysisSteps(
|
||||
semanticGraphSteps,
|
||||
effectiveSignal,
|
||||
);
|
||||
await checkpointJavaScriptAnalysis(effectiveSignal);
|
||||
const semanticGraph =
|
||||
await completeJavaScriptAnalysisSteps(semanticGraphSteps);
|
||||
await checkpointCompletedAnalysis();
|
||||
const completed = {
|
||||
input_path: path,
|
||||
format,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { orderAsarListing } from "./AsarArtifactReader.js";
|
||||
|
||||
describe("orderAsarListing", () => {
|
||||
it("orders host-spelled listings by their portable archive path", () => {
|
||||
const posix = orderAsarListing(["/a0", "/a", "/a/b"], "/");
|
||||
const windows = orderAsarListing(["\\a0", "\\a", "\\a\\b"], "\\");
|
||||
|
||||
// "/" (0x2F) precedes "0" (0x30); "\" (0x5C) would follow it.
|
||||
expect(posix.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]);
|
||||
expect(windows.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]);
|
||||
expect(windows.map(({ listed }) => listed)).toEqual([
|
||||
"\\a",
|
||||
"\\a\\b",
|
||||
"\\a0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps POSIX backslashes as literal filename characters", () => {
|
||||
expect(orderAsarListing(["/a\\b"], "/")).toEqual([
|
||||
{ listed: "/a\\b", path: "a\\b" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js";
|
||||
import { admitAsarHeader } from "./AsarHeader.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/**
|
||||
* Official Electron ASAR adapter with range-streamed member reads.
|
||||
@@ -64,7 +65,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
) {}
|
||||
|
||||
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
|
||||
let paths: string[];
|
||||
let paths: readonly AsarListedPath[];
|
||||
try {
|
||||
await this.prepareContainer(undefined, signal);
|
||||
const snapshotPath = this.#snapshotPath();
|
||||
@@ -82,9 +83,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
await headerHandle.close();
|
||||
}
|
||||
abortIfNeeded(signal);
|
||||
paths = listPackage(snapshotPath, { isPack: false }).sort((left, right) =>
|
||||
left.localeCompare(right, "en"),
|
||||
);
|
||||
paths = orderAsarListing(listPackage(snapshotPath, { isPack: false }));
|
||||
const archiveMetadata = await lstat(snapshotPath);
|
||||
if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink())
|
||||
throw new ArtifactReaderFailure(
|
||||
@@ -95,13 +94,8 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
} catch (cause: unknown) {
|
||||
throw asarFailure(this.path, "inventory", cause);
|
||||
}
|
||||
for (const listed of paths) {
|
||||
for (const { listed, path } of paths) {
|
||||
abortIfNeeded(signal);
|
||||
// @electron/asar returns paths assembled with the host path module.
|
||||
// Keep that spelling for its API calls, but expose archive paths with
|
||||
// portable separators. On POSIX, backslashes can be literal filename
|
||||
// characters and must remain untouched.
|
||||
const path = toArtifactPath(listed);
|
||||
if (path.length === 0) continue;
|
||||
const providerPath = listed.startsWith(sep)
|
||||
? listed.slice(sep.length)
|
||||
@@ -577,8 +571,30 @@ const waitForOperation = async (
|
||||
});
|
||||
};
|
||||
|
||||
const toArtifactPath = (listed: string): string => {
|
||||
const portable = sep === "\\" ? listed.replaceAll("\\", "/") : listed;
|
||||
interface AsarListedPath {
|
||||
readonly listed: string;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair each host-spelled listing with its portable archive path, ordered by
|
||||
* the portable path so Windows and POSIX hosts agree.
|
||||
*
|
||||
* @electron/asar returns paths assembled with the host path module. Keep that
|
||||
* spelling for its API calls, but expose archive paths with portable
|
||||
* separators. On POSIX, backslashes can be literal filename characters and
|
||||
* must remain untouched.
|
||||
*/
|
||||
export const orderAsarListing = (
|
||||
listed: readonly string[],
|
||||
separator: string = sep,
|
||||
): AsarListedPath[] =>
|
||||
listed
|
||||
.map((entry) => ({ listed: entry, path: toArtifactPath(entry, separator) }))
|
||||
.sort((left, right) => compareUnicodeCodePoints(left.path, right.path));
|
||||
|
||||
const toArtifactPath = (listed: string, separator: string): string => {
|
||||
const portable = separator === "\\" ? listed.replaceAll("\\", "/") : listed;
|
||||
return portable.replace(/^\/+|\/+$/gu, "");
|
||||
};
|
||||
|
||||
|
||||
@@ -526,12 +526,16 @@ const CPU_TYPE_X86 = 7;
|
||||
const CPU_TYPE_ARM = 12;
|
||||
const CPU_SUBTYPE_MASK = 0xff000000;
|
||||
const CPU_SUBTYPE_ARM64E = 2;
|
||||
const CPU_SUBTYPE_ARM64E_X1 = 12;
|
||||
const CPU_SUBTYPE_X86_64_H = 8;
|
||||
|
||||
const architectureName = (cpuType: number, cpuSubtype: number): string => {
|
||||
const subtype = cpuSubtype & ~CPU_SUBTYPE_MASK;
|
||||
if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64))
|
||||
return subtype === CPU_SUBTYPE_ARM64E ? "arm64e" : "arm64";
|
||||
// Both pointer-authentication subtypes belong to the arm64e family.
|
||||
return subtype === CPU_SUBTYPE_ARM64E || subtype === CPU_SUBTYPE_ARM64E_X1
|
||||
? "arm64e"
|
||||
: "arm64";
|
||||
if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64_32)) return "arm64_32";
|
||||
if (cpuType === (CPU_TYPE_X86 | CPU_ARCH_ABI64))
|
||||
return subtype === CPU_SUBTYPE_X86_64_H ? "x86_64h" : "x86_64";
|
||||
|
||||
@@ -13,6 +13,16 @@ describe("hostExecutableMarking", () => {
|
||||
expect(
|
||||
hostExecutableMarking("C:\\scratch\\SETUP.CMD", file(0o444), "win32"),
|
||||
).toBe(true);
|
||||
for (const path of [
|
||||
"tools/SETUP.EXE",
|
||||
"run.Cmd",
|
||||
"build.bat",
|
||||
"legacy.com",
|
||||
])
|
||||
expect(hostExecutableMarking(path, file(0o666), "win32")).toBe(true);
|
||||
expect(hostExecutableMarking("readonly.exe", file(0o444), "win32")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
hostExecutableMarking("C:\\scratch\\library.dll", file(0o666), "win32"),
|
||||
).toBe(false);
|
||||
@@ -25,6 +35,11 @@ describe("hostExecutableMarking", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat other Windows filenames as executable even with execute bits", () => {
|
||||
for (const path of ["notes.txt", "README", "archive.exe.bak"])
|
||||
expect(hostExecutableMarking(path, file(0o777), "win32")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps POSIX permission bits authoritative regardless of extension", () => {
|
||||
expect(hostExecutableMarking("/scratch/tool", file(0o755), "linux")).toBe(
|
||||
true,
|
||||
|
||||
@@ -68,6 +68,7 @@ import { captureCdpScreenshot } from "./CdpScreenshot.js";
|
||||
import { comparePngScreenshots } from "./PngVisualDiff.js";
|
||||
|
||||
import { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
const CLEANUP_DOMAINS = ["Network", "Debugger", "Runtime", "Page"] as const;
|
||||
|
||||
/** Passive browser observation through a selected user-owned CDP endpoint. */
|
||||
@@ -408,7 +409,9 @@ const selectTargets = (
|
||||
attached: target.attached,
|
||||
});
|
||||
}
|
||||
allowed.sort((left, right) => left.target_id.localeCompare(right.target_id));
|
||||
allowed.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.target_id, right.target_id),
|
||||
);
|
||||
return {
|
||||
allowed,
|
||||
excluded: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js";
|
||||
import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { createDocumentSvgAncestry, domSvgLink } from "./CdpCaptureDomSvg.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
export type CapturedResource = Omit<
|
||||
WebPageInspection["resources"][number],
|
||||
@@ -346,7 +347,7 @@ const accessibilityStates = (
|
||||
if (state !== undefined) states.set(name, state);
|
||||
}
|
||||
return [...states]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
|
||||
.map(([name, state]) => ({ name, value: state }));
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { captureElectronScripts } from "./CdpElectronScripts.js";
|
||||
import { captureElectronWorkers } from "./CdpElectronWorkers.js";
|
||||
import { authorizedElectronFile } from "./ElectronFileScope.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
interface ElectronContext {
|
||||
readonly connection: CdpConnection;
|
||||
@@ -300,7 +301,7 @@ const captureResources = async (
|
||||
pending.push(...recordsValue(tree.childFrames));
|
||||
}
|
||||
return resources.sort((left, right) =>
|
||||
left.resource_key.localeCompare(right.resource_key),
|
||||
compareUnicodeCodePoints(left.resource_key, right.resource_key),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { inspectCdpElectronPage } from "./CdpElectronInspection.js";
|
||||
import { authorizedElectronFile } from "./ElectronFileScope.js";
|
||||
|
||||
import { CDP_ELECTRON_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
/** Passive Electron provider for local file pages exposed by loopback CDP. */
|
||||
export class CdpElectronProvider implements ElectronObservationPort {
|
||||
identity(): ProviderIdentity {
|
||||
@@ -73,7 +74,7 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
||||
});
|
||||
}
|
||||
allowed.sort((left, right) =>
|
||||
left.target_id.localeCompare(right.target_id),
|
||||
compareUnicodeCodePoints(left.target_id, right.target_id),
|
||||
);
|
||||
return ok(
|
||||
electronTargetListSchema.parse({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { requiredScriptSource } from "./CdpCaptureValues.js";
|
||||
import type { ElectronScriptDraft } from "./CdpElectronScriptEvents.js";
|
||||
import { authorizedElectronFile } from "./ElectronFileScope.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
interface ElectronScriptCaptureInput {
|
||||
readonly connection: CdpConnection;
|
||||
@@ -65,7 +66,7 @@ export const captureElectronScripts = async (
|
||||
return {
|
||||
total,
|
||||
items: items.sort((left, right) =>
|
||||
left.script_key.localeCompare(right.script_key),
|
||||
compareUnicodeCodePoints(left.script_key, right.script_key),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
|
||||
import { authorizedElectronFile } from "./ElectronFileScope.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Inventory local worker targets without attaching to them. */
|
||||
export const captureElectronWorkers = async (input: {
|
||||
@@ -70,7 +71,7 @@ export const captureElectronWorkers = async (input: {
|
||||
});
|
||||
}
|
||||
return workers.sort((left, right) =>
|
||||
left.target_id.localeCompare(right.target_id),
|
||||
compareUnicodeCodePoints(left.target_id, right.target_id),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "./CdpCaptureValues.js";
|
||||
import type { CaptureContext } from "./CdpPageCapture.js";
|
||||
import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
export interface CapturedScripts {
|
||||
readonly inventory: WebPageInspection["scripts"];
|
||||
@@ -131,11 +132,15 @@ const keyAndBuildInventory = (
|
||||
}));
|
||||
keyed.sort(
|
||||
(left, right) =>
|
||||
left.base.localeCompare(right.base) ||
|
||||
sourceDigest(left.item.source).localeCompare(
|
||||
compareUnicodeCodePoints(left.base, right.base) ||
|
||||
compareUnicodeCodePoints(
|
||||
sourceDigest(left.item.source),
|
||||
sourceDigest(right.item.source),
|
||||
) ||
|
||||
(left.item.frame_id ?? "").localeCompare(right.item.frame_id ?? ""),
|
||||
compareUnicodeCodePoints(
|
||||
left.item.frame_id ?? "",
|
||||
right.item.frame_id ?? "",
|
||||
),
|
||||
);
|
||||
const totals = new Map<string, number>();
|
||||
for (const { base } of keyed) totals.set(base, (totals.get(base) ?? 0) + 1);
|
||||
|
||||
@@ -7,7 +7,10 @@ import type {
|
||||
} from "../domain/webMcpDiscovery.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { digestCanonicalValue } from "../domain/canonicalDigest.js";
|
||||
import { compositeKey } from "../domain/unicodeCodePointOrder.js";
|
||||
import {
|
||||
compareUnicodeCodePoints,
|
||||
compositeKey,
|
||||
} from "../domain/unicodeCodePointOrder.js";
|
||||
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import type { CdpConnection, CdpEvent } from "./CdpConnection.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
@@ -521,7 +524,7 @@ const buildWebMcpResult = (options: WebMcpResultOptions): WebMcpDiscovery => {
|
||||
tools: {
|
||||
total: tools.size + completeness.droppedTotal,
|
||||
items: [...tools.values()].sort((left, right) =>
|
||||
left.tool_key.localeCompare(right.tool_key),
|
||||
compareUnicodeCodePoints(left.tool_key, right.tool_key),
|
||||
),
|
||||
},
|
||||
completeness: completeness.snapshot(),
|
||||
|
||||
@@ -6,9 +6,10 @@ import { z } from "zod";
|
||||
import { PROMPT_CONTRACTS } from "./contracts/promptContracts.js";
|
||||
import { TOOL_CONTRACTS } from "./contracts/toolContracts.js";
|
||||
import { CLI_COMMAND_NAMES } from "./cliCommandNames.js";
|
||||
import { compareUnicodeCodePoints } from "./domain/unicodeCodePointOrder.js";
|
||||
|
||||
const sortedToolContracts = [...TOOL_CONTRACTS].sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
compareUnicodeCodePoints(left.name, right.name),
|
||||
);
|
||||
|
||||
const createToolCatalog = () =>
|
||||
@@ -39,7 +40,7 @@ const promptCatalog = PROMPT_CONTRACTS.map((contract) => ({
|
||||
description: contract.description,
|
||||
arguments: contract.arguments,
|
||||
steps: contract.steps,
|
||||
})).sort((left, right) => left.name.localeCompare(right.name));
|
||||
})).sort((left, right) => compareUnicodeCodePoints(left.name, right.name));
|
||||
|
||||
const digest = (value: unknown): string => {
|
||||
const encoded = canonicalize(value);
|
||||
|
||||
@@ -263,6 +263,9 @@ const setupRunOptions = (
|
||||
? {
|
||||
readinessScope: {
|
||||
clients: options.client,
|
||||
...(options.skillClient.length > 0
|
||||
? { skillClients: options.skillClient }
|
||||
: {}),
|
||||
providers: options.installHopper ? ["hopper"] : [],
|
||||
skill: options.skill ?? agentIntegrationSelected,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TOOL_CONTRACTS } from "./toolContracts.js";
|
||||
import { TOOL_EFFECTS, annotationsFromEffects } from "./toolEffects.js";
|
||||
|
||||
/**
|
||||
* Descriptions that name running, launching, executing, starting, installing,
|
||||
* or uninstalling something. A matching tool must declare foreign execution or
|
||||
* device mutation unless it is listed below with the reason the wording does
|
||||
* not describe that effect.
|
||||
*/
|
||||
const EXECUTION_WORDING =
|
||||
/\b(?:run|runs|running|launch|launches|launching|execute|executes|executing|execution|start|starts|install|installs|uninstall|uninstalls)\b/iu;
|
||||
|
||||
const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
|
||||
inspect_native_load_image: "states that it does not execute the target",
|
||||
resolve_native_call_targets: "states that it does not execute code",
|
||||
inspect_pe_resources: "states that it does not execute the image",
|
||||
inspect_binary_layout:
|
||||
"parses the binary offline with operator-configured pwntools",
|
||||
inspect_recorded_crash: "inspects a core file without launching its target",
|
||||
inspect_macho: "runs host Mach-O inspection tools, not the target",
|
||||
inspect_evm_interface:
|
||||
"decodes bytecode in REA's bundled worker without executing it",
|
||||
inspect_wasm_artifact:
|
||||
"WABT validates and decompiles the module without executing it",
|
||||
inspect_managed_artifact: "reads metadata without executing target code",
|
||||
inspect_managed_members: "reads metadata without executing target code",
|
||||
inspect_managed_native_boundaries:
|
||||
"reads metadata without executing target code",
|
||||
verify_managed_native_boundaries: "states it does not execute managed code",
|
||||
project_managed_application_graph: "states it does not execute managed code",
|
||||
inspect_android_package: "states it does not execute the APK",
|
||||
search_android_classes: "states it does not execute the APK",
|
||||
inspect_apktool_client:
|
||||
"inspects the operator-selected apktool launcher; never installs it",
|
||||
inspect_jeb_client: "talks to a caller-started JEB; never launches it",
|
||||
open_jeb_project: "the engine analyzes the file; REA does not execute it",
|
||||
inspect_adb_client:
|
||||
"inspects the operator-selected adb client; never starts its server",
|
||||
list_adb_devices: "may start the local adb server, which REA does not own",
|
||||
inspect_firmware_regions: "states there is no target execution",
|
||||
extract_firmware: "states that extracted code is never executed",
|
||||
analyze_javascript_application: "reconstructs the graph without executing",
|
||||
reconcile_javascript_runtime: "never invents execution claims",
|
||||
observe_javascript_runtime: "never evaluates or resumes the target",
|
||||
trace_application_feature: "never executes the application",
|
||||
trace_javascript_semantics: "static reachability never claims execution",
|
||||
compare_javascript_export_shapes: "states it does not execute JavaScript",
|
||||
project_android_application_graph: "execution-free inventory",
|
||||
project_apple_application_graph: "execution-free inventory",
|
||||
project_harmony_application_graph: "execution-free inventory",
|
||||
trace_web_source_location: "states there is no application execution",
|
||||
trace_web_module_imports:
|
||||
"runs only REA's resolver stub in operator-configured Chromium",
|
||||
export_web_scripts: "states there is no refetch or execution",
|
||||
observe_web_execution:
|
||||
"instruments an externally driven page; it does not run code itself",
|
||||
recover_javascript_sources: "states there is no application code execution",
|
||||
goto_address: "start names an object boundary, not a launch",
|
||||
prev_address: "start names an object boundary, not a launch",
|
||||
trace_call_path: "start names the traversal origin",
|
||||
observe_native_ui: "observes an already-running app without launching it",
|
||||
inspect_adb_package: "reports install times without changing the device",
|
||||
list_adb_processes: "lists running processes without changing the device",
|
||||
inspect_web_event_listeners: "states it does not evaluate JavaScript",
|
||||
};
|
||||
|
||||
const declaresForeignAuthority = (name: string): boolean => {
|
||||
const effects = TOOL_EFFECTS[name];
|
||||
return (
|
||||
effects !== undefined &&
|
||||
(effects.executesForeignCode || effects.mutatesDevice)
|
||||
);
|
||||
};
|
||||
|
||||
describe("tool effects", () => {
|
||||
it("audits every public tool exactly once", () => {
|
||||
expect(Object.keys(TOOL_EFFECTS).sort()).toEqual(
|
||||
TOOL_CONTRACTS.map(({ name }) => name).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps execution and device wording aligned with declared effects", () => {
|
||||
const undeclared = TOOL_CONTRACTS.filter(
|
||||
({ name, description }) =>
|
||||
EXECUTION_WORDING.test(description) &&
|
||||
!declaresForeignAuthority(name) &&
|
||||
EXECUTION_WORDING_EXCEPTIONS[name] === undefined,
|
||||
).map(({ name }) => name);
|
||||
expect(undeclared).toEqual([]);
|
||||
const staleExceptions = Object.keys(EXECUTION_WORDING_EXCEPTIONS).filter(
|
||||
(name) => {
|
||||
const contract = TOOL_CONTRACTS.find(
|
||||
(candidate) => candidate.name === name,
|
||||
);
|
||||
return (
|
||||
contract === undefined ||
|
||||
!EXECUTION_WORDING.test(contract.description) ||
|
||||
declaresForeignAuthority(name)
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(staleExceptions).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks foreign execution and device mutation as destructive", () => {
|
||||
const destructive = TOOL_CONTRACTS.filter(
|
||||
({ effects }) => effects.executesForeignCode || effects.mutatesDevice,
|
||||
);
|
||||
expect(destructive.map(({ name }) => name).sort()).toEqual([
|
||||
"capture_browser_scenario",
|
||||
"capture_electron_scenario",
|
||||
"capture_native_ui_scenario",
|
||||
"capture_process_scenario",
|
||||
"install_adb_package",
|
||||
"observe_native_calls",
|
||||
"push_adb_file",
|
||||
"start_adb_activity",
|
||||
"start_adb_app",
|
||||
"stop_adb_app",
|
||||
"uninstall_adb_package",
|
||||
]);
|
||||
for (const contract of destructive)
|
||||
expect(contract.annotations, contract.name).toMatchObject({
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives destructiveHint from each destructive effect alone", () => {
|
||||
const base = TOOL_EFFECTS["binary_session"];
|
||||
if (base === undefined) throw new Error("binary_session is unaudited");
|
||||
expect(annotationsFromEffects(base)).toEqual({
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
});
|
||||
for (const flag of [
|
||||
"mayDiscardData",
|
||||
"executesForeignCode",
|
||||
"mutatesDevice",
|
||||
] as const)
|
||||
expect(annotationsFromEffects({ ...base, [flag]: true }), flag).toEqual({
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes the advertised annotations derived from canonical effects", () => {
|
||||
for (const contract of TOOL_CONTRACTS)
|
||||
expect(contract.annotations, contract.name).toEqual(
|
||||
annotationsFromEffects(contract.effects),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,17 @@ export interface ToolEffects {
|
||||
readonly launchesProcess: boolean;
|
||||
readonly accessesNetwork: boolean;
|
||||
readonly changesUiState: boolean;
|
||||
/** Can overwrite, clear, or drop state that the caller cannot recover from REA. */
|
||||
readonly mayDiscardData: boolean;
|
||||
/**
|
||||
* Runs or drives code REA does not own on the host: the analysis target, a
|
||||
* caller-selected executable, or the application loaded into one. Running
|
||||
* REA's own pinned helpers or operator-configured analysis engines does not
|
||||
* count.
|
||||
*/
|
||||
readonly executesForeignCode: boolean;
|
||||
/** Changes an attached Android device: packages, files, or running apps. */
|
||||
readonly mutatesDevice: boolean;
|
||||
readonly idempotent: boolean;
|
||||
}
|
||||
|
||||
@@ -27,6 +37,8 @@ const effects = (overrides: Partial<ToolEffects> = {}): ToolEffects => ({
|
||||
accessesNetwork: false,
|
||||
changesUiState: false,
|
||||
mayDiscardData: false,
|
||||
executesForeignCode: false,
|
||||
mutatesDevice: false,
|
||||
idempotent: true,
|
||||
...overrides,
|
||||
});
|
||||
@@ -178,6 +190,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
accessesNetwork: true,
|
||||
mutatesDevice: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
capture_adb_screen: effects({
|
||||
@@ -239,6 +252,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
mutatesDevice: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
uninstall_adb_package: effects({
|
||||
@@ -246,6 +260,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
mutatesDevice: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
start_adb_app: effects({
|
||||
@@ -253,6 +268,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
mutatesDevice: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
start_adb_activity: effects({
|
||||
@@ -260,6 +276,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
mutatesDevice: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
stop_adb_app: effects({
|
||||
@@ -267,6 +284,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
mutatesDevice: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
search_android_classes: effects({
|
||||
@@ -294,6 +312,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
mayDiscardData: true,
|
||||
}),
|
||||
inspect_native_load_image: evidence,
|
||||
inspect_native_dispatch_metadata: evidence,
|
||||
@@ -330,11 +349,31 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
resolve_containing_procedure: evidence,
|
||||
search_procedures: evidence,
|
||||
search_strings: evidence,
|
||||
set_address_name: effects({ mutatesTarget: true, mutatesSession: true }),
|
||||
set_addresses_names: effects({ mutatesTarget: true, mutatesSession: true }),
|
||||
set_bookmark: effects({ mutatesTarget: true, mutatesSession: true }),
|
||||
set_comment: effects({ mutatesTarget: true, mutatesSession: true }),
|
||||
set_inline_comment: effects({ mutatesTarget: true, mutatesSession: true }),
|
||||
set_address_name: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
mayDiscardData: true,
|
||||
}),
|
||||
set_addresses_names: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
mayDiscardData: true,
|
||||
}),
|
||||
set_bookmark: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
mayDiscardData: true,
|
||||
}),
|
||||
set_comment: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
mayDiscardData: true,
|
||||
}),
|
||||
set_inline_comment: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
mayDiscardData: true,
|
||||
}),
|
||||
unset_bookmark: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
@@ -365,6 +404,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
accessesNetwork: true,
|
||||
changesUiState: true,
|
||||
mayDiscardData: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
// The owned process can change files, show UI and use the network.
|
||||
@@ -374,6 +414,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
changesUiState: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
inspect_macho: nativeEvidence,
|
||||
@@ -419,6 +460,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
accessesNetwork: true,
|
||||
changesUiState: true,
|
||||
mayDiscardData: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
list_electron_targets: browserEvidence,
|
||||
@@ -431,6 +473,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
accessesNetwork: true,
|
||||
changesUiState: true,
|
||||
mayDiscardData: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
list_javascript_runtime_targets: browserEvidence,
|
||||
@@ -479,6 +522,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
writesFilesystem: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
compare_process_captures: sessionEvidence,
|
||||
@@ -506,9 +550,15 @@ export const annotationsFromEffects = (
|
||||
value.launchesProcess ||
|
||||
value.accessesNetwork ||
|
||||
value.changesUiState ||
|
||||
value.mayDiscardData
|
||||
value.mayDiscardData ||
|
||||
value.executesForeignCode ||
|
||||
value.mutatesDevice
|
||||
),
|
||||
destructiveHint: value.mayDiscardData,
|
||||
// MCP defines destructive as possibly non-additive changes to the
|
||||
// environment. Running foreign code or changing a device cannot be bounded
|
||||
// to additive effects, so either one is destructive even without data loss.
|
||||
destructiveHint:
|
||||
value.mayDiscardData || value.executesForeignCode || value.mutatesDevice,
|
||||
idempotentHint: value.idempotent,
|
||||
openWorldHint: value.launchesProcess || value.accessesNetwork,
|
||||
});
|
||||
|
||||
@@ -293,6 +293,8 @@ const toolAvailabilityFacts = {
|
||||
accessesNetwork: z.boolean(),
|
||||
changesUiState: z.boolean(),
|
||||
mayDiscardData: z.boolean(),
|
||||
executesForeignCode: z.boolean(),
|
||||
mutatesDevice: z.boolean(),
|
||||
idempotent: z.boolean(),
|
||||
}),
|
||||
annotations: z.object({
|
||||
|
||||
@@ -51,9 +51,9 @@ export interface AnalysisCleanupObservation {
|
||||
|
||||
/** Optional provider-neutral context that must survive typed error projection. */
|
||||
export interface AnalysisErrorOptions extends ErrorOptions {
|
||||
readonly capturedOutput?: AnalysisCapturedOutput;
|
||||
readonly cleanup?: AnalysisCleanupObservation;
|
||||
readonly partialObservation?: AnalysisPartialObservation;
|
||||
readonly capturedOutput?: AnalysisCapturedOutput | undefined;
|
||||
readonly cleanup?: AnalysisCleanupObservation | undefined;
|
||||
readonly partialObservation?: AnalysisPartialObservation | undefined;
|
||||
}
|
||||
|
||||
/** Base class for expected analysis, provider, and session failures. */
|
||||
|
||||
@@ -265,9 +265,12 @@ const hopperProcessMessage = (error: HopperProcessError): string => {
|
||||
const startup =
|
||||
error.userMessage === undefined ? "" : ` ${error.userMessage}`;
|
||||
if (error.providerState === "exited") {
|
||||
const signal = error.launcherFailure?.signal;
|
||||
const exit =
|
||||
error.exitCode === null
|
||||
? "no exit code was observed"
|
||||
? signal === undefined || signal === null
|
||||
? "no exit code was observed"
|
||||
: `signal ${signal}`
|
||||
: `exit code ${String(error.exitCode)}`;
|
||||
const recovery =
|
||||
error.failureCode === undefined && stage !== "launch"
|
||||
|
||||
@@ -572,7 +572,7 @@ describe("analysis error projection: caller contract", () => {
|
||||
describe("analysis error projection: operational diagnostics", () => {
|
||||
it("distinguishes an observed Hopper exit from an undetermined provider failure", () => {
|
||||
const exited = projectAnalysisError(
|
||||
new HopperProcessError(7, undefined, "search_strings", 4),
|
||||
new HopperProcessError(7, { operation: "search_strings", requestId: 4 }),
|
||||
);
|
||||
expect(exited).toMatchObject({
|
||||
code: "provider_unavailable",
|
||||
@@ -590,7 +590,10 @@ describe("analysis error projection: operational diagnostics", () => {
|
||||
expect(exited.remediation.action).toContain("Restart the owned provider");
|
||||
|
||||
const unknown = projectAnalysisError(
|
||||
new HopperProcessError(null, undefined, "search_strings", 5),
|
||||
new HopperProcessError(null, {
|
||||
operation: "search_strings",
|
||||
requestId: 5,
|
||||
}),
|
||||
);
|
||||
expect(unknown.details).toMatchObject({
|
||||
stage: "analysis",
|
||||
|
||||
+34
-16
@@ -1,4 +1,7 @@
|
||||
import { AnalysisError } from "./analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisError,
|
||||
type AnalysisErrorOptions,
|
||||
} from "./analysisErrorBase.js";
|
||||
import type {
|
||||
HopperProcessProviderState,
|
||||
ProviderFailureStage,
|
||||
@@ -73,10 +76,10 @@ export type HopperDiagnosticType =
|
||||
| "bridge_exception";
|
||||
|
||||
/** Hopper's JSON-RPC endpoint returned an expected remote error response. */
|
||||
export interface HopperRemoteErrorContext {
|
||||
readonly diagnosticType?: HopperDiagnosticType;
|
||||
readonly operation?: string;
|
||||
readonly requestId?: number;
|
||||
export interface HopperRemoteErrorContext extends AnalysisErrorOptions {
|
||||
readonly diagnosticType?: HopperDiagnosticType | undefined;
|
||||
readonly operation?: string | undefined;
|
||||
readonly requestId?: number | undefined;
|
||||
}
|
||||
|
||||
export class HopperRemoteError extends HopperError {
|
||||
@@ -90,7 +93,7 @@ export class HopperRemoteError extends HopperError {
|
||||
readonly safeMessage: string,
|
||||
context: HopperRemoteErrorContext = {},
|
||||
) {
|
||||
super(`Hopper request failed (${String(code)}): ${safeMessage}`);
|
||||
super(`Hopper request failed (${String(code)}): ${safeMessage}`, context);
|
||||
this.diagnosticType = context.diagnosticType ?? "remote";
|
||||
this.operation = context.operation;
|
||||
this.requestId = context.requestId;
|
||||
@@ -98,35 +101,50 @@ export class HopperRemoteError extends HopperError {
|
||||
}
|
||||
|
||||
/** The owned Hopper bridge stopped before its client was closed. */
|
||||
export interface HopperProcessErrorContext extends AnalysisErrorOptions {
|
||||
readonly diagnostic?: HopperStartupFailureDiagnostic | undefined;
|
||||
readonly operation?: string | undefined;
|
||||
readonly requestId?: number | undefined;
|
||||
readonly providerState?: HopperProcessProviderState | undefined;
|
||||
readonly stage?: ProviderFailureStage | undefined;
|
||||
readonly launcherFailure?: HopperLauncherOutcome | undefined;
|
||||
}
|
||||
|
||||
export class HopperProcessError extends HopperError {
|
||||
readonly _tag = "HopperProcessError";
|
||||
readonly failureCode: HopperStartupFailureCode | undefined;
|
||||
override readonly userMessage: string | undefined;
|
||||
readonly providerState: HopperProcessProviderState;
|
||||
readonly stage: ProviderFailureStage;
|
||||
readonly diagnostic: HopperStartupFailureDiagnostic | undefined;
|
||||
readonly operation: string | undefined;
|
||||
readonly requestId: number | undefined;
|
||||
readonly launcherFailure: HopperLauncherOutcome | undefined;
|
||||
|
||||
constructor(
|
||||
readonly exitCode: number | null,
|
||||
readonly diagnostic?: HopperStartupFailureDiagnostic,
|
||||
readonly operation?: string,
|
||||
readonly requestId?: number,
|
||||
providerState?: HopperProcessProviderState,
|
||||
stage?: ProviderFailureStage,
|
||||
readonly launcherFailure?: HopperLauncherOutcome,
|
||||
context: HopperProcessErrorContext = {},
|
||||
) {
|
||||
super(`Hopper bridge stopped unexpectedly with code ${String(exitCode)}`);
|
||||
super(
|
||||
`Hopper bridge stopped unexpectedly with code ${String(exitCode)}`,
|
||||
context,
|
||||
);
|
||||
this.diagnostic = context.diagnostic;
|
||||
this.operation = context.operation;
|
||||
this.requestId = context.requestId;
|
||||
this.launcherFailure = context.launcherFailure;
|
||||
const failure = hopperStartupFailure(exitCode);
|
||||
this.stage =
|
||||
stage ??
|
||||
context.stage ??
|
||||
(failure === undefined
|
||||
? operation === undefined
|
||||
? context.operation === undefined
|
||||
? "connection"
|
||||
: "analysis"
|
||||
: "launch");
|
||||
this.failureCode = failure?.code;
|
||||
this.userMessage = failure?.message;
|
||||
this.providerState =
|
||||
providerState ?? (exitCode === null ? "unknown" : "exited");
|
||||
context.providerState ?? (exitCode === null ? "unknown" : "exited");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -539,11 +539,9 @@ describe("Electron module interop generic invocations", () => {
|
||||
'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.call(Object.defineProperty,null,Object,"create",{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}]);',
|
||||
'Function.prototype.apply.call(Object.defineProperty,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);",
|
||||
|
||||
@@ -18,6 +18,7 @@ export const javaScriptAnalysisCoverageLimits = (
|
||||
"worker_process_memory_limit_bytes",
|
||||
"worker_timeout_ms",
|
||||
"parent_result_budget_bytes",
|
||||
"parent_available_result_budget_bytes",
|
||||
"semantic_result_budget_bytes",
|
||||
"transfer_record_bytes",
|
||||
"transfer_file_bytes",
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { digestSchema } from "./digests.js";
|
||||
import { localPathStringSchema } from "./localPath.js";
|
||||
import {
|
||||
exportedWebScriptSchema,
|
||||
webScriptExportManifestSchema,
|
||||
@@ -8,10 +9,9 @@ import {
|
||||
|
||||
/** Inspect one captured script's native ES module relationships. */
|
||||
export const webModuleTraceInputSchema = z.strictObject({
|
||||
manifest_path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Absolute path to an export_web_scripts manifest"),
|
||||
manifest_path: localPathStringSchema.describe(
|
||||
"Absolute path to an export_web_scripts manifest",
|
||||
),
|
||||
script_index: z
|
||||
.number()
|
||||
.int()
|
||||
@@ -26,10 +26,9 @@ export const webModuleTraceInputSchema = z.strictObject({
|
||||
),
|
||||
import_map: z
|
||||
.strictObject({
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Absolute local import-map JSON path; no fetch"),
|
||||
path: localPathStringSchema.describe(
|
||||
"Absolute local import-map JSON path; no fetch",
|
||||
),
|
||||
base_url: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
import { localPathStringSchema } from "./localPath.js";
|
||||
|
||||
/** Complete historical evidence budgets; exceeding a budget returns no partial capture. */
|
||||
export const WEB_NETWORK_CAPTURE_LIMITS = {
|
||||
@@ -11,10 +12,9 @@ export const WEB_NETWORK_CAPTURE_LIMITS = {
|
||||
|
||||
/** Inspect an explicit retained capture without contacting its recorded destinations. */
|
||||
export const inspectWebNetworkCaptureInputSchema = z.strictObject({
|
||||
capture_path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Absolute local HAR or mitmproxy capture path."),
|
||||
capture_path: localPathStringSchema.describe(
|
||||
"Absolute local HAR or mitmproxy capture path.",
|
||||
),
|
||||
format: z.enum(["har", "mitmproxy"]),
|
||||
record_ordinals: z
|
||||
.array(z.number().int().nonnegative())
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { digestSchema } from "./digests.js";
|
||||
import { localPathStringSchema } from "./localPath.js";
|
||||
import { webScriptExportManifestSchema } from "./webScriptExport.js";
|
||||
|
||||
/** Select one source from an existing captured-script export. */
|
||||
export const selectedWebScriptInputSchema = z.strictObject({
|
||||
manifest_path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Absolute export_web_scripts manifest path"),
|
||||
manifest_path: localPathStringSchema.describe(
|
||||
"Absolute export_web_scripts manifest path",
|
||||
),
|
||||
script_index: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user