Compare commits

...
Author SHA1 Message Date
3b79af28e8 chore(main): release rea-agents 6.5.0 (#1693)
* chore(main): release rea-agents 6.5.0

* docs(release): finalize contributor-aware 6.5.0 notes

* docs(release): retain verifier maintenance only in provenance

* style(release): format the contribution inventory

* docs(release): credit Windows fixes

* fix(comparison): recheck historical calls-only changes (#2223)

* fix(comparison): preserve incomplete function call evidence

Keep missing, unknown, partial and unresolved call relationships unknown in function comparisons and reconstruction claims. Aggregate IDA caller and callee coverage without overwriting uncertainty.

Validate with 144 focused tests, typecheck/lint, docs checks and test discovery. Real providers were not verified; the full headless IDA session suite requires unavailable Windows native controls.

* fix(comparison): recheck historical calls-only changes

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>

* fix(ci): isolate Linux Hopper process fixture inventory

* docs(release): credit selected-null and client setup changes

* fix(release): refresh 6.5.0 notes and align installation examples

* docs(release): clarify 6.5.0 migrations and recent fixes

* docs(release): cover omitted capabilities and refresh 6.5.0 provenance

* docs(release): pin final CI corrections in 6.5.0 inventory

* docs(release): include mutation scheduling in 6.5.0 provenance

* docs(release): include cache policy correction in 6.5.0 provenance

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Codex <codex@users.noreply.github.com>
Co-authored-by: Lucien <164857476+dakjdakd@users.noreply.github.com>
2026-10-14 20:31:45 +08:00
morluto cbc0e97c29 test(ci): bind npm cache IDs to output consumers
Remove the blanket caller ID requirement that rejected the standalone mutation workflow. Keep producer bindings where static cache authorization and package verification consume action outputs.
2026-10-14 20:10:17 +08:00
morluto 92a0edf187 perf(ci): move full mutation sweeps off the release path
Keep fresh coverage and both real mutation preflights required in every CI tier. Run complete canonical sweeps daily or manually in isolated jobs with distinct reports, retained failure diagnostics, and explicit report-presence checks.

Preserve regression inventories, release qualification and mutation settings; remove the superseded duplicate gate test. Related: #3281.
2026-10-14 19:49:08 +08:00
N0zoM1z0andKaoru0822 1b46398795 perf(ci): preflight mutation configurations during fast feedback
Keep full qualification and standalone mutation execution intact, retain distinct reports, and preserve fail-closed quality gates.

Related: #3281

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-14 19:48:30 +08:00
morluto f75b1a416b test(browser): retain bounded diagnostics for scenario policy failures 2026-10-14 19:24:14 +08:00
morluto 0686b07546 test(doctor): retain discovered Hopper paths despite version failures 2026-10-14 19:23:11 +08:00
morluto c2b79aa462 test(doctor): separate Hopper availability and version assertions 2026-10-14 19:10:22 +08:00
morluto efe0923914 fix(release): exclude Codex automation from contributor thanks 2026-10-14 19:06:36 +08:00
YsopwnableandKaoru0822 a32b4b6ac5 test(ci): correct Rizin readiness and Hopper launcher fixtures (#3299)
Correct two independently reproduced CI fixture preconditions: close the real Rizin child's stdin before its readiness frame, and provide scoped Linux loader evidence for the Hopper doctor shell fixture. Preserve original timeouts, rejection checks, path spelling, launch prevention and cleanup.

Current-head CI passed, including both complete reverse-tools mutation passes and 1,273 unique portable test files (14,521 passed, 126 skipped, zero failures). Each issue retains its own branch/commit; the validated integration tree includes the Hopper fix from #3410. That duplicate PR can close after the same code is verified on main.

Fixes #3295
Fixes #3406

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-14 18:42:33 +08:00
morluto 60c08802a7 test(native): validate demangling through owned contracts 2026-10-14 18:14:01 +08:00
morluto 7435e79d65 test(doctor): assert platform-specific Hopper readiness and exit status 2026-10-14 18:14:01 +08:00
morluto 44d98de236 test: align malformed XML and route seed assertions with owning contracts 2026-10-14 17:55:22 +08:00
morlutoandmsaimshafique ba33c706d2 fix(setup): inspect installed skill identity for unqualified doctor
Select installed skill destinations in the existing inspection pass, retaining malformed or foreign identity facts while ignoring empty marker-only roots.

Refs: #3208, #3296

Co-authored-by: msaimshafique <msaimshafique4664@gmail.com>
2026-10-14 17:49:20 +08:00
morlutoandhaoran20100322 aade9bbcb7 fix(setup): evaluate Gemini workspace trust before settings content
Treat only ENOENT as absence. Preserve unreadable metadata errors and ignore untrusted workspace content before parsing it.

Refs: #3206, #3319

Co-authored-by: haoran20100322 <haoran20100322@outlook.com>
2026-10-14 17:49:19 +08:00
morlutoandPaoloValletta 0930e94e07 fix(setup): validate registered MCP output schema profiles
Reuse the shared profile schema before setup writes and preserve supported values through registration and startup.

Refs: #3205, #3277

Co-authored-by: PaoloValletta <paovalletta@hotmail.it>
2026-10-14 17:49:19 +08:00
morluto 3f6b5755b3 fix(managed)!: share member list facts and retain verification uncertainty
Parse TypeDef member lists once at their owning boundary, retain malformed ranges as unknown, and admit representation storage before allocation. Reuse parsed facts across member ownership, inventory, attributes, and native boundaries. Preserve constant operands in structural matching.

Refs: #3202, #3201, #3199

BREAKING CHANGE: unresolved managed/native verification results may retain a non-null matched_native and candidate facts when module identity is unknown. Consumers must use status and reason rather than assume unresolved observations are empty.
2026-10-14 17:49:18 +08:00
morluto 45b6a7fe8f fix(native): distinguish proven pre-launch failure from unknown target ownership
Emit explicit target-creation facts only when known. Invalid post-launch process handles remain unknown and retain cleanup ownership.

Refs: #3185

Validation uses bridge fakes and provider-boundary tests; no real LLDB target launch was performed.
2026-10-14 17:49:18 +08:00
morlutoandSII-YDD 2daeff4b24 fix(application): normalize owned target admission cancellation
Normalize only the exact aborted signal reason, retaining unrelated failures and descriptor ownership after cleanup failures.

Refs: #3197, #3265

Co-authored-by: SII-YDD <38822679+SII-YDD@users.noreply.github.com>
2026-10-14 17:49:18 +08:00
morlutoandhhang e67c3642c8 fix(process): retain deletions after observed roots become obstructed
Carry initial root observations into final capture. Treat ENOTDIR as absence only for a previously observed root, preserving initial obstruction and cancellation failures.

Refs: #3238, #3263

Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com>
2026-10-14 17:49:17 +08:00
morluto 030c477328 fix(evidence): derive bundle comparison status from classified changes
Keep digests as provenance while status follows examined deltas and unresolved coverage.

Refs: #3216
2026-10-14 17:49:17 +08:00
morluto 3a34ad37bd fix(evidence): classify noncanonical bundles at the input boundary
Use a dedicated canonical-projection error so unrelated TypeErrors retain their protocol failure meaning.

Refs: #3219
2026-10-14 17:49:16 +08:00
morluto 1250aaedd8 fix(browser): decode SAZ XML through typed encoding validation
Reuse the format-aware XML text owner, preserve decoder causes, and reject contradictory declarations even in unselected records.

Refs: #3273
2026-10-14 17:49:16 +08:00
morluto 2b60e097f2 fix(browser): preserve uncertainty and compare redirect observations
Retain scenario selection limitations independently of known changes. Compare known status, MIME and byte observations for each redirect hop without inventing missing facts.

Refs: #3172, #3173
2026-10-14 17:49:15 +08:00
morluto 5e79d468e5 fix(javascript): retain runtime candidate ambiguity
Keep referenced ambiguous candidates unknown instead of classifying them as absent.

Refs: #3192
2026-10-14 17:49:15 +08:00
morlutoandJoão Pedro de Faria 44c9d2924d fix(javascript): match feature seeds against their owning source values
Apply exact, contains, and case options to node IDs. Route seeds consume retained route values rather than generated display labels.

Refs: #3189, #3190, #3298

Co-authored-by: João Pedro de Faria <jpdefo7@hotmail.com>
2026-10-14 17:49:14 +08:00
morluto 503109995f fix(javascript): retain early-return conditions in wrapped call paths
Share sequential fallthrough interpretation between block traversal and isolated seed context.

Refs: #3196
2026-10-14 17:49:14 +08:00
morluto 28f5761973 test(mcp): isolate target-free sessions from host provider discovery
Inject explicit unavailable provider ports into the fake-session harness, forbid accidental execution, and retain public availability coverage. This removes host-dependent timeouts without increasing deadlines.
2026-10-14 17:49:13 +08:00
morlutoandhhang dae1e55381 fix(javascript): preserve lexical binding and default export capture semantics
Resolve CommonJS wrapper bindings from the parser-owned source type. Capture default identifier export values at the export statement while preserving live named bindings.

Refs: #3261, #3297

Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com>
2026-10-14 17:49:13 +08:00
102 changed files with 33695 additions and 433 deletions

No files matched your search

+3 -3
View File
@@ -68,9 +68,9 @@ closing-issue reporters. Grouping preserves all those credits in one line.
When Highlights, detailed entries or migrations reference the same work,
put its thanks in one of those entries, not all of them. Include issue links.
Direct-commit bullets carry the commit link and known human credit.
Exclude GitHub bots and known agent accounts. REA's observed Anthropic and
Cursor co-author trailers resolve to `@claude` and `@cursoragent` with GitHub
type `User`; the credit policy excludes them while retaining their metadata.
Exclude GitHub bots and known agent accounts. REA's observed Anthropic, Cursor
and Codex co-author trailers resolve to `@claude`, `@cursoragent` and `@codex`
with GitHub type `User`; the credit policy excludes them while retaining their metadata.
At the REA maintainer's request, omit `@morluto` self-thanks while preserving
authorship in the inventory. Other human contributors remain eligible.
Use explicit GitHub links for cross-repository references to avoid ambiguous
+16 -3
View File
@@ -250,7 +250,7 @@ jobs:
retention-days: 7
reverse-tools-quality:
name: Reverse tools coverage and mutation
name: Reverse tools quality
needs: [changes, static]
if: needs.changes.outputs.reverse_tools == 'true'
runs-on: ubuntu-latest
@@ -266,8 +266,21 @@ jobs:
- run: npm ci
- name: Run reverse tools tests with coverage
run: npm run test:coverage:reverse-tools
- name: Report reverse tools mutation results
run: npm run test:mutation:reverse-tools
- name: Preflight both reverse tools mutation configurations
run: |
node scripts/prepare-reverse-tools-mutation.mjs
npx --no-install stryker run stryker.config.mjs --dryRunOnly
npx --no-install stryker run stryker.semantics.config.mjs --dryRunOnly
- name: Retain available reverse tools diagnostics
if: ${{ !cancelled() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: reverse-tools-quality-${{ needs.changes.outputs.tier }}-${{ github.run_attempt }}
path: |
coverage/reverse-tools/
reports/mutation/
if-no-files-found: warn
retention-days: 7
build:
name: Build package
+62
View File
@@ -0,0 +1,62 @@
name: Mutation sweep
on:
schedule:
- cron: "47 4 * * *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: mutation-${{ github.ref }}
cancel-in-progress: false
env:
HUSKY: "0"
jobs:
mutation:
name: Mutation sweep (${{ matrix.suite }})
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 2
matrix:
include:
- suite: reverse-tools
config: stryker.config.mjs
- suite: reverse-tools-semantics
config: stryker.semantics.config.mjs
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Cache npm package downloads
uses: ./.github/actions/npm-cache
- run: npm ci
- run: npm run test:prepare
- name: Run the complete configuration
env:
MUTATION_CONFIG: ${{ matrix.config }}
run: |
node scripts/prepare-reverse-tools-mutation.mjs
npx --no-install stryker run "${MUTATION_CONFIG}"
- name: Require both reports from the completed sweep
env:
REPORT_BASE: reports/mutation/${{ matrix.suite }}
run: |
test -s "${REPORT_BASE}.json"
test -s "${REPORT_BASE}.html"
- name: Retain available mutation diagnostics
if: ${{ !cancelled() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: mutation-${{ matrix.suite }}-${{ github.run_attempt }}
path: |
reports/mutation/${{ matrix.suite }}.json
reports/mutation/${{ matrix.suite }}.html
if-no-files-found: warn
retention-days: 7
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "6.4.0"
".": "6.5.0"
}
+113
View File
@@ -1,5 +1,118 @@
# Changelog
## [6.5.0](https://github.com/morluto/rea/compare/rea-agents-6.4.0...rea-agents-6.5.0) (2026-10-13)
Provider prerequisites and recorded verification scope are listed in [the testing guide](docs/testing.md).
### Highlights
- Analyze selected JavaScript files and recover cross-bundle relationships without expanding the selected source scope.
- Read compact summaries and page retained Evidence across binary, managed, Android, Dart, WASM and network workflows.
- Inspect SQLite snapshots, packet and signal recordings, WebAssembly, Rust dependency metadata and Electron fuses offline.
- Keep completed Evidence and partial observations available when analysis or cleanup fails.
### Changes
- **JavaScript source selection:** analyze exact application paths without expanding imports into the selected scope. Unselected files retain identity and explicit unknown coverage; summary delivery remains separate from source selection. ([#1944](https://github.com/morluto/rea/pull/1944), [issue morluto/rea#1848](https://github.com/morluto/rea/issues/1848)). Thanks @kaoru0822-kitauji, @N0zoM1z0.
- **Code-split recovery:** recover related bundles from multiple selected inputs, retaining per-file provenance and downstream cross-chunk imports. Recorded CLI/MCP coverage is Linux x64 with Wakaru 1.14.0 and source-owned esbuild/webpack fixtures; other hosts, releases and arbitrary application equivalence remain unverified. ([#1686](https://github.com/morluto/rea/pull/1686), [issue morluto/rea#1605](https://github.com/morluto/rea/issues/1605)). Thanks @JingHao-Leon, @pionxzh.
- **Package imports:** resolve exact package import aliases using the selected loader and package ancestry, rather than guessing sibling files. ([#1862](https://github.com/morluto/rea/pull/1862), [issue morluto/rea#1838](https://github.com/morluto/rea/issues/1838)). Thanks @AWUQAQ, @mvanhorn.
- **Retained analysis views:** opt into compact inventory/WASM, managed metadata, Android manifest, Dart AOT, Mach-O and EVM summaries, then page retained Evidence without rerunning analysis. Summary delivery preserves the complete recorded result and its original coverage. ([#2122](https://github.com/morluto/rea/pull/2122), [#2193](https://github.com/morluto/rea/pull/2193), [#2413](https://github.com/morluto/rea/pull/2413), [#2438](https://github.com/morluto/rea/pull/2438), [#2455](https://github.com/morluto/rea/pull/2455), [#2634](https://github.com/morluto/rea/pull/2634), [#2308](https://github.com/morluto/rea/pull/2308), [9a18251](https://github.com/morluto/rea/commit/9a18251426f891e6a24856b715ef8002ab8b9610)). Thanks @kaoru0822-kitauji, @N0zoM1z0.
- **Managed assemblies:** Select declaring types when inspecting members, decode inspected `ldstr` literals with independent coverage, and page retained member summaries. Call and field targets expose `target_declaring_type` and `field_declaring_type`, including supported generic method targets; unresolved metadata remains unknown. ([#1706](https://github.com/morluto/rea/pull/1706), [#2125](https://github.com/morluto/rea/pull/2125), [issue morluto/rea#2121](https://github.com/morluto/rea/issues/2121), [#2162](https://github.com/morluto/rea/pull/2162), [issue morluto/rea#2158](https://github.com/morluto/rea/issues/2158), [#3061](https://github.com/morluto/rea/pull/3061), [issue morluto/rea#3010](https://github.com/morluto/rea/issues/3010), [#2163](https://github.com/morluto/rea/pull/2163), [issue morluto/rea#2160](https://github.com/morluto/rea/issues/2160)). Thanks @kaoru0822-kitauji, @N0zoM1z0, @Nelglor, @skoodster.
- **Packet captures:** inspect saved packets with bounded TShark output, compact capture summaries and retained pages; discover installed protocol/field definitions. Recorded real-engine coverage uses Linux and Wireshark 4.6.6; native Windows/macOS and Bun behavior remain unverified. ([#2318](https://github.com/morluto/rea/pull/2318), [#2340](https://github.com/morluto/rea/pull/2340), [#2496](https://github.com/morluto/rea/pull/2496)). Thanks @3QL, @N0zoM1z0, @Olorinm.
- **Recorded signals:** decode existing recordings and discover installed decoders/options through caller-supplied sigrok-cli 0.7.2. This is Linux-only and requires util-linux `prlimit`; it acquires no hardware samples. ([#2339](https://github.com/morluto/rea/pull/2339), [#2487](https://github.com/morluto/rea/pull/2487)). Thanks @N0zoM1z0, @Olorinm.
- **Web captures:** derive an inferred method/origin/path-template API surface from retained HAR data including observed query-parameter names while retaining unknowns; query values, headers and bodies remain excluded. ([#1891](https://github.com/morluto/rea/pull/1891), [#2982](https://github.com/morluto/rea/pull/2982), [issue morluto/rea#2913](https://github.com/morluto/rea/issues/2913)). Thanks @mvanhorn, @wunianze666-netizen, @zhangguiping-xydt.
- **QUIC captures:** inspect retained aioquic 1.5.0 JSON qlog 0.3 objects and page traces/streams/observations. Other qlog layouts remain unsupported; overlap and loss declarations do not prove retransmission or network loss. ([#2277](https://github.com/morluto/rea/pull/2277)). Thanks @LuisTaffelli, @N0zoM1z0.
- **Fiddler captures:** inspect native SAZ archives with retained HTTP provenance and bounded decoding. Recorded producer coverage is the pinned public encoding corpus; current Fiddler Everywhere variants and Bun-native execution remain unverified. ([#2409](https://github.com/morluto/rea/pull/2409)). Thanks @N0zoM1z0, @zahhak.
- **SQLite snapshots:** inspect an explicitly selected offline database with table/column selection and bounded Evidence. This operation requires Node 24.x >=24.15 or 26+ with `DatabaseSync.limits`; supported Node 22 and earlier 24 runtimes cannot use it. A live database/WAL copy does not establish an atomic snapshot. ([#2271](https://github.com/morluto/rea/pull/2271)). Thanks @N0zoM1z0, @Y0UW1SH.
- **WASM artifacts:** Inspect local modules with bring-your-own WABT 1.0.42 without executing them. Compare imports/exports from two complete core-module inspection Evidence records without requiring an active WABT installation; unsupported signatures and duplicate identities remain unknown, and comparisons do not establish runtime compatibility. Recorded real-tool inspection coverage is macOS arm64. ([#1533](https://github.com/morluto/rea/pull/1533), [issue morluto/rea#884](https://github.com/morluto/rea/issues/884), [#2464](https://github.com/morluto/rea/pull/2464), [issue morluto/rea#2356](https://github.com/morluto/rea/issues/2356)). Thanks @matbrgz, @N0zoM1z0, @thangldw.
- **Go binaries:** inspect embedded build metadata, including file-backed unmapped Mach-O debug segments. ([#1363](https://github.com/morluto/rea/pull/1363), [7fb097d](https://github.com/morluto/rea/commit/7fb097d72c9931dd5dfae481ad2b9d3bb6029f15)). Thanks @Y0UW1SH.
- **Flutter APKs:** identify snapshot/engine builds and inspect AOT structure without an external engine. Recorded real APK coverage uses the documented Flutter Gallery fixture; macOS/Windows and complete pool semantics remain unverified, and pool coverage stays partial. ([#1582](https://github.com/morluto/rea/pull/1582)). Thanks @3QL.
- **HarmonyOS packages:** inventory HAP/HSP/App Pack structures without executing code. The recorded real workflow covers one pinned HAP through repository-built CLI/MCP; installed-package and real HSP/App Pack coverage remain unverified. ([#1447](https://github.com/morluto/rea/pull/1447)). Thanks @BaaRai.
- **Ghidra raw inputs and references:** compatible Linux/macOS Ghidra profiles accept caller-selected raw architecture/entry information and expose typed incoming references at exact addresses. Windows Ghidra P0 admits the raw NMOS 6502 profile described below; other raw targets and the incoming-reference facet remain outside Windows P0. ([#1868](https://github.com/morluto/rea/pull/1868), [issue morluto/rea#1836](https://github.com/morluto/rea/issues/1836), [#1715](https://github.com/morluto/rea/pull/1715), [issue morluto/rea#1245](https://github.com/morluto/rea/issues/1245)). Thanks @bugstan, @casesolved-co-uk, @mvanhorn, @N0zoM1z0.
- **Ghidra MIPS:** Add bounded MIPS32r2/o32 relocatable objects, soft-float integer-register storage, admitted MIPS16 shared ELF, and a narrowly admitted recordless legacy MIPS32 executable profile. The latter pins an endian-matched 32-bit language and records inferred o32 as an analysis assumption. Admission follows each documented ABI/flag/relocation boundary. ([#1949](https://github.com/morluto/rea/pull/1949), [issue morluto/rea#1937](https://github.com/morluto/rea/issues/1937), [#1950](https://github.com/morluto/rea/pull/1950), [issue morluto/rea#1938](https://github.com/morluto/rea/issues/1938), [#1991](https://github.com/morluto/rea/pull/1991), [issue morluto/rea#1851](https://github.com/morluto/rea/issues/1851), [#1898](https://github.com/morluto/rea/pull/1898), [issue morluto/rea#1620](https://github.com/morluto/rea/issues/1620)). Thanks @AurelioB, @BaaRai, @bqw371, @kaoru0822-kitauji, @N0zoM1z0.
- **Ghidra RISC-V:** admit little-endian RV32/RV64 `ET_EXEC` ELF declaring the supported double-float ABI. Relocatable/shared, big-endian and other ABI profiles remain refused; see [the profile and recorded lane](docs/ghidra-riscv.md). ([#2151](https://github.com/morluto/rea/pull/2151)). Thanks @NIU-123370.
- **PSP static ELF profile:** the Ghidra profile is Linux x64, bounded little-endian ELF32 `ET_EXEC` with the documented PSP/ABI declarations and a compatible caller-installed Allegrex extension. PRX, shared/relocatable images, PBP/ISO/CSO and runtime emulation are outside scope. Historical extension experiments are separate from public-provider verification; real public Ghidra PSP coverage remains unverified. ([#1599](https://github.com/morluto/rea/pull/1599)). Thanks @jaelliot.
- **Ghidra analysis settings:** apply validated analyzer overrides before analysis while retaining the selected analysis profile. ([#1913](https://github.com/morluto/rea/pull/1913)). Thanks @akushonkamen, @kaoru0822-kitauji, @N0zoM1z0.
- **Attached native IDA MCP:** add an optional native bridge alongside existing legacy/headless profiles. Recorded native runs use Linux IDA Free 9.4 with a navigation correction and Windows IDA Pro 9.3 with a loopback-listener change; stock native switching, native macOS and Windows IDA Free remain unverified. Attached databases remain read-only and are never saved or closed. ([#1544](https://github.com/morluto/rea/pull/1544)). Thanks @imissapixel, @N0zoM1z0.
- **Frida instrumentation:** add explicit local/remote target selection, caller-supplied scripts and retained messages, with script-read/input diagnostics and bounded message eviction. Recorded real coverage is the Linux local Node marker workflow; macOS, Windows and remote devices/servers remain unverified. Instrumentation executes foreign code and is destructive, not read-only. ([#1359](https://github.com/morluto/rea/pull/1359), [#2211](https://github.com/morluto/rea/pull/2211), [issue morluto/rea#2209](https://github.com/morluto/rea/issues/2209), [#2217](https://github.com/morluto/rea/pull/2217), [issue morluto/rea#2213](https://github.com/morluto/rea/issues/2213), [#2628](https://github.com/morluto/rea/pull/2628)). Thanks @dandgabr, @kaoru0822-kitauji, @N0zoM1z0.
- **Client setup:** register Qoder, Trae, ZCode, DeepSeek Harness, Kimi Code, Factory Droid and WorkBuddy through the existing scoped setup flow. DeepSeek is included from 6.5.0; the historical npm 6.4 check did not cover it. Live Trae and DeepSeek API integration remain unverified. ([#1564](https://github.com/morluto/rea/pull/1564), [#1572](https://github.com/morluto/rea/pull/1572), [#1573](https://github.com/morluto/rea/pull/1573), [#1929](https://github.com/morluto/rea/pull/1929), [issue morluto/rea#1839](https://github.com/morluto/rea/issues/1839), [#2744](https://github.com/morluto/rea/pull/2744), [#2309](https://github.com/morluto/rea/pull/2309), [#3041](https://github.com/morluto/rea/pull/3041), [issue morluto/rea#3040](https://github.com/morluto/rea/issues/3040)). Thanks @0xRafie, @BaaRai, @cainiao1992, @IvenKooLab, @whoishaha, @ysntony.
- **Website:** Add maintained language editions, persistent/system-aware themes, keyboard styling and nested Evidence indentation, plus the reverse-engineering models essay. A simpler home page and beginner journey connect platform prerequisites, client/provider setup and a supplied first investigation. ([#1779](https://github.com/morluto/rea/pull/1779), [issue morluto/rea#1022](https://github.com/morluto/rea/issues/1022), [#1699](https://github.com/morluto/rea/pull/1699), [#1714](https://github.com/morluto/rea/pull/1714), [#1894](https://github.com/morluto/rea/pull/1894), [#2250](https://github.com/morluto/rea/pull/2250), [#2432](https://github.com/morluto/rea/pull/2432), [#2732](https://github.com/morluto/rea/pull/2732), [#3274](https://github.com/morluto/rea/pull/3274)). Thanks @CassieFox23, @kaoru0822-kitauji, @N0zoM1z0.
- **Rust dependency metadata:** Inspect embedded cargo-auditable package/dependency records in ELF, PE and thin Mach-O without executing the file. Missing metadata and inferred compiler hashes retain explicit limitations; universal Mach-O requires a selected thin slice. ([#2456](https://github.com/morluto/rea/pull/2456)). Thanks @mvanhorn.
- **Electron fuses:** Read packaged fuse bytes and per-slice runtime implications without launching or modifying the app. Missing, truncated and unknown wires remain explicit; fuse observations do not prove other Inspector-enablement paths. ([#2399](https://github.com/morluto/rea/pull/2399)). Thanks @kaoru0822-kitauji, @mvanhorn, @N0zoM1z0.
- **Ghidra PowerPC:** Admit bounded ELF32 big-endian `ET_EXEC` PowerPC with an explicit language profile on supported Linux/macOS hosts. PPC64, DOL and other variants remain outside this lane; see [the profile and verification prerequisites](docs/ghidra-ppc.md). ([#1747](https://github.com/morluto/rea/pull/1747)). Thanks @arielfikru.
- **Reference-source summaries:** Use CLI `import-reference-source --detail summary` for compact manifest, count, state and path-outline output. The complete graph remains available and is still the default; hidden outline entries retain separate coverage counts. ([#2768](https://github.com/morluto/rea/pull/2768), [issue morluto/rea#2442](https://github.com/morluto/rea/issues/2442)). Thanks @matbrgz, @To3akaRin.
- **Recorded AArch64 crashes:** Decode 34 ABI register values per thread from supported Linux ELF64 little-endian core notes using the documented caller-provided Python dependencies. These are recorded facts, not proof of current process or library identity. ([#2505](https://github.com/morluto/rea/pull/2505), [issue morluto/rea#2353](https://github.com/morluto/rea/issues/2353)). Thanks @matbrgz, @NIU-123370.
- **Ghidra byte search:** Search initialized analyzed memory using wildcards, bit masks, address bounds and paged results. Unloaded, overlay and uninitialized blocks retain skipped coverage; this is not a raw-file search. ([#2341](https://github.com/morluto/rea/pull/2341)). Thanks @mvanhorn.
- **Raw NMOS 6502 images:** Analyze explicitly selected headerless images with Ghidra, including Windows P0, using a caller-selected entry and source mapping within the 16-bit address space. Cartridge parsing, reset-vector inference and 65C02/65816 are outside this profile. ([#2079](https://github.com/morluto/rea/pull/2079)). Thanks @Zhong-fan.
- **Swift String observations:** Ghidra function dossiers derive supported AArch64 Swift String literals from constant-register pairs at recognized use sites, retaining instruction/register provenance and explicit omissions. This is a bounded derivation, not a complete string inventory. ([#2049](https://github.com/morluto/rea/pull/2049), [issue morluto/rea#2030](https://github.com/morluto/rea/issues/2030)). Thanks @thefolahan.
- **Headless IDA procedure lookup:** Resolve addresses to canonical containing-procedure entries and identify the session’s admitted program document in the modern headless profile. Incomplete surveys remain errors; attached profiles do not advertise this containment capability. ([#2374](https://github.com/morluto/rea/pull/2374), [issue morluto/rea#2335](https://github.com/morluto/rea/issues/2335)). Thanks @nikolay-dementiev.
- **Passive browser attachment:** Six existing passive browser tools accept an explicit-port loopback browser WebSocket endpoint when HTTP target discovery is unavailable. Existing consent and target/origin checks remain required; this does not launch a browser or enable runtime instrumentation. ([#2346](https://github.com/morluto/rea/pull/2346)). Thanks @all-black-493.
- **Android DEX references:** Retain exact method descriptors and individual direct-invoke observations with source method, DEX entry, opcode and code-unit offset. These are static references; unavailable instruction facts remain separate from method-level usage totals. ([#2439](https://github.com/morluto/rea/pull/2439)). Thanks @LcokHcak.
- **Offline EVM metadata:** Retain bounded, lossless facts from plausible terminal Solidity CBOR root-map candidates. Candidates do not prove Solidity origin or source/compiler identity, and linked metadata is not fetched. ([#2790](https://github.com/morluto/rea/pull/2790)). Thanks @matbrgz.
### Fixes
- **JavaScript loader and export identity:** Respect import/require conditions and unavailable package metadata, keep export links separate from writes, and resolve default identifier exports to the value captured at the export statement. ([7e5cffa](https://github.com/morluto/rea/commit/7e5cffa2ae9dfc9afaeca0715110d4dd41c0dcdd), [322506d](https://github.com/morluto/rea/commit/322506d0c0d39d3cd1e2e1c285c2cc23cfa73ee7), [81f7638](https://github.com/morluto/rea/commit/81f763820319a762f003cf95651ed14ce9326a4c), [dae1e55](https://github.com/morluto/rea/commit/dae1e55381f0d6cfee7f2082d5d27b938e55704c)). Thanks @paoValle, @VarunGore36, @xu91102.
- **JavaScript traces:** Retain nested bridge calls and surviving early-return predicates. Honor node-ID match options, match route seeds against source values, and keep ambiguous runtime candidates unknown. ([0351328](https://github.com/morluto/rea/commit/0351328e54ce70ea3f2814482e76b132774aff48), [5031099](https://github.com/morluto/rea/commit/503109995f9dcf8a955585d39a344fc846d2f59e), [44c9d29](https://github.com/morluto/rea/commit/44c9d2924d5b95d7a86595e8d7b7cf4fd4f0f6ac), [5e79d46](https://github.com/morluto/rea/commit/5e79d468e5864204cbe79a7ca2898541cee5e012)). Thanks @jpdefo.
- **Browser comparison coverage:** Compare only known redirect response fields and retain network-selection recovery guidance alongside independent event changes. Missing response facts do not prove equality or a change. ([2b60e09](https://github.com/morluto/rea/commit/2b60e097f281f7708c69f1f7ba9263694547bcc0)).
- **Evidence comparison and ledger input:** Report display-only bundle differences as unchanged while retaining digests, and classify noncanonical bundle records as invalid caller input. ([030c477](https://github.com/morluto/rea/commit/030c47732833c9cedcc31db1309eb3a1ca2c62e8), [3a34ad3](https://github.com/morluto/rea/commit/3a34ad37bdb7b8debba0f6515ba85b80141f2716)).
- **Capture and admission cleanup:** Keep deleted-file observations and original bytes when a parent becomes a file, preserve unrelated I/O failures during cancellation, and require explicit pre-launch proof before releasing an unknown LLDB target owner. LLDB failure controls used simulated boundaries. ([e67c364](https://github.com/morluto/rea/commit/e67c3642c8920a2a9c0e231ee2f16f50b245df31), [2daeff4](https://github.com/morluto/rea/commit/2daeff4b243e1cd137c591ca394e18a1c05c8bbc), [45b6a7f](https://github.com/morluto/rea/commit/45b6a7fe8f51a89951aaa823ffa627f6f5453a33)). Thanks @SII-YDD, @xu91102.
- **Client configuration:** Validate MCP output-schema profiles before setup writes, ignore untrusted Gemini workspace settings before parsing them, and identify installed skills independently of marker-only client roots. ([0930e94](https://github.com/morluto/rea/commit/0930e94e077ab7fb4d695f62785919a8a25c2e21), [aade9bb](https://github.com/morluto/rea/commit/aade9bbcb79aa8504ab6921b25f8ab082aba78e7), [ba33c70](https://github.com/morluto/rea/commit/ba33c706d26b63e43308be50c0249c72cff62cc6)). Thanks @CmsChase, @paoValle, @sx4im.
- **Host tool discovery:** Share macOS Hopper launcher discovery across setup and runtime, preserve explicit paths, and report validated ADB and Apktool availability. ([7582f0d](https://github.com/morluto/rea/commit/7582f0dff29ae0e80c97b885ddd386ac61a92570), [e5deb83](https://github.com/morluto/rea/commit/e5deb832cb09bb524877d0df40f49d5b5bb7dea4)). Thanks @Chessing234, @kaoru0822-kitauji, @viniciusdsandrade.
- **SAZ encoding validation:** Reject invalid XML bytes, unsupported encodings and declarations that contradict the archive bytes, including records outside the selected request. ([1250aae](https://github.com/morluto/rea/commit/1250aaedd8b4d1e471d8a382f76418bde65c05d2)).
- **Managed member matching:** Keep malformed TypeDef list ownership unknown, distinguish constants during structural method matching, and accept legacy small exception-section sizes. ([3f6b575](https://github.com/morluto/rea/commit/3f6b5755b3e699bd9373641acffe0f2c5d2ab094), [ee5cfbe](https://github.com/morluto/rea/commit/ee5cfbe05cd309a88003c711721e67a3bee36554)).
- **Bounded diagnostics:** Bound SDK validation messages and terminal normalization before allocation while retaining diagnostics and explicit unknown final output sizes. ([7fb0a0c](https://github.com/morluto/rea/commit/7fb0a0cc577f7c9033a2674d877d8242b5864556), [1d826f3](https://github.com/morluto/rea/commit/1d826f304f032acfe69dad3ff824b300aacaeb43)). Thanks @CRYPTONIKAV.
- **Windows artifact classification:** identify `.exe`, `.com`, `.bat` and `.cmd` artifacts as launchable host executables. ([#1823](https://github.com/morluto/rea/pull/1823), [#1824](https://github.com/morluto/rea/pull/1824), [issue morluto/rea#1819](https://github.com/morluto/rea/issues/1819)). Thanks @dollarsmoney, @JasonBuildAI, @kaoru0822-kitauji.
- **CLI/MCP delivery:** defer command-specific startup work, bundle runtime entries, retain field/input correction details across output formats and preserve shared optional-provider lifetime. MCP selectors, paging continuation and retained partial-error references remain explicit; session liveness/provider-attribution corrections retain their controlled-provider verification limits. ([#1691](https://github.com/morluto/rea/pull/1691), [issue morluto/rea#1685](https://github.com/morluto/rea/issues/1685), [#1705](https://github.com/morluto/rea/pull/1705), [issue morluto/rea#1695](https://github.com/morluto/rea/issues/1695), [#1755](https://github.com/morluto/rea/pull/1755), [issue morluto/rea#1746](https://github.com/morluto/rea/issues/1746), [#1773](https://github.com/morluto/rea/pull/1773), [issue morluto/rea#1771](https://github.com/morluto/rea/issues/1771), [#1780](https://github.com/morluto/rea/pull/1780), [#1814](https://github.com/morluto/rea/pull/1814), [#1910](https://github.com/morluto/rea/pull/1910), [issue morluto/rea#1849](https://github.com/morluto/rea/issues/1849), [#1996](https://github.com/morluto/rea/pull/1996), [#2140](https://github.com/morluto/rea/pull/2140), [issue morluto/rea#2135](https://github.com/morluto/rea/issues/2135), [#2205](https://github.com/morluto/rea/pull/2205), [issue morluto/rea#2195](https://github.com/morluto/rea/issues/2195), [#2230](https://github.com/morluto/rea/pull/2230), [issue morluto/rea#2180](https://github.com/morluto/rea/issues/2180), [#2294](https://github.com/morluto/rea/pull/2294), [#2482](https://github.com/morluto/rea/pull/2482), [a82d14e](https://github.com/morluto/rea/commit/a82d14e4db4dca989ffa2e7dbe78fd43551da256), [a4dd4e8](https://github.com/morluto/rea/commit/a4dd4e8e8700ce350e1a0c612ebd61b91ca5e585), [48c352b](https://github.com/morluto/rea/commit/48c352b6bfa34c820a530272cd36600158c37b1e), [07b36c1](https://github.com/morluto/rea/commit/07b36c148946f46b99a4ce04e55726e97d0ee03a), [570c40c](https://github.com/morluto/rea/commit/570c40cde55d31e455ef9bc484b7b908658ded58), [8fc2043](https://github.com/morluto/rea/commit/8fc2043eb8bcedbf34dce064f977e455438635d6), [7fc0b5e](https://github.com/morluto/rea/commit/7fc0b5ec9c2532bbf789d7783b7a0cab6fc054d8)). Thanks @369Apps, @chqcjq, @cold-atom, @coygeek, @JackOdegard, @kaoru0822-kitauji, @N0zoM1z0, @sx4im.
- **Evidence import/replay:** import bundles across session transitions while authenticating selected target and file identities, preserve literal keys, metadata/mutation intent and relationship coverage, and honor cancellation before session mutation. Reuse authenticated snapshots and comparisons within their owned lifetime; reject malformed/non-object snapshots and unsupported metadata without disguising uncertainty. ([#1694](https://github.com/morluto/rea/pull/1694), [#1716](https://github.com/morluto/rea/pull/1716), [issue morluto/rea#1680](https://github.com/morluto/rea/issues/1680), [#1721](https://github.com/morluto/rea/pull/1721), [issue morluto/rea#1718](https://github.com/morluto/rea/issues/1718), [#1733](https://github.com/morluto/rea/pull/1733), [issue morluto/rea#1723](https://github.com/morluto/rea/issues/1723), [issue morluto/rea#1724](https://github.com/morluto/rea/issues/1724), [#1776](https://github.com/morluto/rea/pull/1776), [#1873](https://github.com/morluto/rea/pull/1873), [#2001](https://github.com/morluto/rea/pull/2001), [#2013](https://github.com/morluto/rea/pull/2013), [issue morluto/rea#2009](https://github.com/morluto/rea/issues/2009), [#2083](https://github.com/morluto/rea/pull/2083), [#2303](https://github.com/morluto/rea/pull/2303), [#2685](https://github.com/morluto/rea/pull/2685), [issue morluto/rea#2675](https://github.com/morluto/rea/issues/2675), [c002b89](https://github.com/morluto/rea/commit/c002b89e2d8130a3c07e25e5fd604876be18c703), [9c85114](https://github.com/morluto/rea/commit/9c8511409c0706f9979c2b68dd56aaaf9f21e191), [aada0ca](https://github.com/morluto/rea/commit/aada0ca8126bf14c60750a4ccd4a79667770a1e4), [4a4d36f](https://github.com/morluto/rea/commit/4a4d36f881861bbab3c734b0dd459f805766caec), [1c64fe3](https://github.com/morluto/rea/commit/1c64fe3eff48679c6880f42d2f0a4af46686ade7), [6fb3a34](https://github.com/morluto/rea/commit/6fb3a345fe952b8e8f59221aec993acc37ccbf31), [3bcc68c](https://github.com/morluto/rea/commit/3bcc68c037e9415c78093cb286069c1fcca6a2f8), [6bae774](https://github.com/morluto/rea/commit/6bae774328724403255590aa2ddad4d7a2aa727f), [e997085](https://github.com/morluto/rea/commit/e9970855ba16ba711816ac30212c5ca17ae085cd), [d1dcebc](https://github.com/morluto/rea/commit/d1dcebce2c0820469ed63e33497a14258971b710), [b123b2e](https://github.com/morluto/rea/commit/b123b2e51cc7d6d2157525664be2df88fb1ba3bd), [049132a](https://github.com/morluto/rea/commit/049132ab43fa962d31774f2b258c15a71217148b), [042dcaa](https://github.com/morluto/rea/commit/042dcaa1ee66f1b8668f08b193f38ab01bceaacb), [acf75e6](https://github.com/morluto/rea/commit/acf75e6700ace28bb1db25e54e56673ac15e0ed3), [1f7f808](https://github.com/morluto/rea/commit/1f7f808c4f3bb5eca6b13bcc71d5cceb2ace6614), [26bffb9](https://github.com/morluto/rea/commit/26bffb94bf80355c35e22a98a8fc629c6210307e)). Thanks @coygeek, @DeryFerd, @jiangjiang248, @kaoru0822-kitauji, @khanukov, @N0zoM1z0, @NIU-123370, @rohitg00, @sx4im.
- **Ghidra setup and analysis:** preserve launcher/JVM diagnostics, Java-property and extension identity, stale-client recovery, UTF-8 bridge output and pending-startup progress. File-offset decompilation is Ghidra-specific; recorded coverage is Linux x64 with Ghidra 12.1.4. Startup/progress/compiler failure paths that use simulations remain unverified with a real engine. ([#1698](https://github.com/morluto/rea/pull/1698), [#1877](https://github.com/morluto/rea/pull/1877), [#1931](https://github.com/morluto/rea/pull/1931), [#2011](https://github.com/morluto/rea/pull/2011), [issue morluto/rea#1992](https://github.com/morluto/rea/issues/1992), [#2014](https://github.com/morluto/rea/pull/2014), [#2101](https://github.com/morluto/rea/pull/2101), [issue morluto/rea#2078](https://github.com/morluto/rea/issues/2078), [#2206](https://github.com/morluto/rea/pull/2206), [issue morluto/rea#2204](https://github.com/morluto/rea/issues/2204), [2187e16](https://github.com/morluto/rea/commit/2187e16f149a7a7b512a4cb45d5f468cc04091fe), [b953f96](https://github.com/morluto/rea/commit/b953f96c0aae27e1876c317a8f7db1d6f292b8ff)). Thanks @EDMND-SRC, @Ha1baraA11, @kaoru0822-kitauji, @lovecontroing, @lyly-bonbon, @N0zoM1z0, @nosmile99, @Petru101, @sx4im, @Zhong-fan.
- **Process and cleanup ownership:** retain private runtime/file owners and detailed incomplete-cleanup observations, bound trace cycles/order state, preserve caller paths, and use monotonic time for budgets while retaining wall dates. Windows affinity/admission ([#2202](https://github.com/morluto/rea/pull/2202), [issue morluto/rea#2181](https://github.com/morluto/rea/issues/2181)) and Bun memory-enforcement paths retain their recorded native-versus-controlled-fixture limits; these fixes are not a general process-ownership guarantee. ([#1700](https://github.com/morluto/rea/pull/1700), [issue morluto/rea#1697](https://github.com/morluto/rea/issues/1697), [#1730](https://github.com/morluto/rea/pull/1730), [#1756](https://github.com/morluto/rea/pull/1756), [issue morluto/rea#1753](https://github.com/morluto/rea/issues/1753), [#1760](https://github.com/morluto/rea/pull/1760), [issue morluto/rea#1757](https://github.com/morluto/rea/issues/1757), [#1826](https://github.com/morluto/rea/pull/1826), [#1907](https://github.com/morluto/rea/pull/1907), [issue morluto/rea#1856](https://github.com/morluto/rea/issues/1856), [#1999](https://github.com/morluto/rea/pull/1999), [issue morluto/rea#1993](https://github.com/morluto/rea/issues/1993), [#2232](https://github.com/morluto/rea/pull/2232), [issue morluto/rea#2170](https://github.com/morluto/rea/issues/2170), [#2252](https://github.com/morluto/rea/pull/2252), [issue morluto/rea#2218](https://github.com/morluto/rea/issues/2218), [#2467](https://github.com/morluto/rea/pull/2467), [issue morluto/rea#2257](https://github.com/morluto/rea/issues/2257), [#2468](https://github.com/morluto/rea/pull/2468), [#2488](https://github.com/morluto/rea/pull/2488), [09b6828](https://github.com/morluto/rea/commit/09b68285166bef2b7633402c8adf7eb25823b47e), [7285e15](https://github.com/morluto/rea/commit/7285e15839a4fecf9940683699b781a60ccc4775), [b5ededd](https://github.com/morluto/rea/commit/b5ededd0f9c28d0f696a205103066928117368ee), [d8c76b6](https://github.com/morluto/rea/commit/d8c76b629639bda4405afa9ae2f7676b694f2f10), [98d8470](https://github.com/morluto/rea/commit/98d8470d36cfbf7cd25820a4ed430aebae20c0c5), [c22111e](https://github.com/morluto/rea/commit/c22111eca2c4bc84a084f12711f405b3e7422a40), [cecc6ec](https://github.com/morluto/rea/commit/cecc6ecbffe84689a173d1634f342e046b0c8ceb), [10935b7](https://github.com/morluto/rea/commit/10935b7b1136accab2a43b308ab380749da0f7ae), [63e796d](https://github.com/morluto/rea/commit/63e796d50ea323ae8dcfef80f86c8154bb106790), [d9e9fb2](https://github.com/morluto/rea/commit/d9e9fb2273543caab4eb21d896c33d462ada6971), [9049dba](https://github.com/morluto/rea/commit/9049dbaf0c3df32357419f9899965df2b611f493), [ed881c6](https://github.com/morluto/rea/commit/ed881c6d2ef6a8adba0c2a241ebc9ab4603b7868)). Thanks @Ashen00001, @charan-rathore, @coygeek, @EDMND-SRC, @JasonBuildAI, @jimpablo, @kaoru0822-kitauji, @N0zoM1z0, @NIU-123370, @Rinkia, @rudycelekli, @sx4im, @xeropresence, @XiaoBinGan, @xthunder0, @hzh666kevin-hue.
- **Native image identity:** preserve universal Mach-O containers and selected slice/subtype identity, chained fixup locations and library ordinals, exact signing text, PE resource capacity and ELF/header framing. Mach-O summary selection and independent slice commands avoid unnecessary delivery/work. Controlled routing/parser fixtures do not establish every installed-provider path. ([#1703](https://github.com/morluto/rea/pull/1703), [issue morluto/rea#1702](https://github.com/morluto/rea/issues/1702), [#1708](https://github.com/morluto/rea/pull/1708), [#1709](https://github.com/morluto/rea/pull/1709), [#1713](https://github.com/morluto/rea/pull/1713), [#1772](https://github.com/morluto/rea/pull/1772), [issue morluto/rea#1765](https://github.com/morluto/rea/issues/1765), [#1784](https://github.com/morluto/rea/pull/1784), [issue morluto/rea#1775](https://github.com/morluto/rea/issues/1775), [#1982](https://github.com/morluto/rea/pull/1982), [#1995](https://github.com/morluto/rea/pull/1995), [issue morluto/rea#1990](https://github.com/morluto/rea/issues/1990), [#2040](https://github.com/morluto/rea/pull/2040), [#2107](https://github.com/morluto/rea/pull/2107), [#2161](https://github.com/morluto/rea/pull/2161), [issue morluto/rea#2105](https://github.com/morluto/rea/issues/2105), [#2248](https://github.com/morluto/rea/pull/2248), [issue morluto/rea#2176](https://github.com/morluto/rea/issues/2176), [#2281](https://github.com/morluto/rea/pull/2281), [issue morluto/rea#2274](https://github.com/morluto/rea/issues/2274), [#2292](https://github.com/morluto/rea/pull/2292), [#2466](https://github.com/morluto/rea/pull/2466), [#2506](https://github.com/morluto/rea/pull/2506), [#2733](https://github.com/morluto/rea/pull/2733), [issue morluto/rea#2661](https://github.com/morluto/rea/issues/2661), [f598bab](https://github.com/morluto/rea/commit/f598bab488e38c378d69346fa3c106f9e18529ed), [108b5f9](https://github.com/morluto/rea/commit/108b5f94254281639badd25b1f448cca35dc89a2), [f1924cf](https://github.com/morluto/rea/commit/f1924cf9c1910d5d29d11e7bf634b51af8d738cf), [8bd5ba2](https://github.com/morluto/rea/commit/8bd5ba2d234cbd9a5bb9dd5daeaa191c3a1cad24), [b38429c](https://github.com/morluto/rea/commit/b38429cb6a0de8f4bb5ae01633e24abe2d7cde46), [8d4d631](https://github.com/morluto/rea/commit/8d4d631606c701aac150cf009d3b80ae4a7ae7d5), [ac57c88](https://github.com/morluto/rea/commit/ac57c88efe64c3125850b54d3d833227e6b8ec51), [dc038d1](https://github.com/morluto/rea/commit/dc038d16bba9046afd28970de9c47f6259a43524), [34a4bb6](https://github.com/morluto/rea/commit/34a4bb6508006fa113463ef20ed799a2994e9ee8), [ebc22d9](https://github.com/morluto/rea/commit/ebc22d99d62225505c0a84f6e39823ba4ef2f829), [c02fb0e](https://github.com/morluto/rea/commit/c02fb0ea8055f98def2b12ad557aed0faefd3639)). Thanks @1NormalGuy, @Aliciawque, @baron, @coygeek, @dhl84, @hemanthrayuduu, @JasonBuildAI, @kaoru0822-kitauji, @N0zoM1z0, @thangldw, @viniciusdsandrade, @XiaoBinGan, @Zburgers.
- **Archive and artifact provenance:** keep physical ZIP occurrence identity, exact signatures/names and partial inventory coverage; bound decompression and reject malformed keys/integers. Directory/ASAR/DMG reads retain ownership through cleanup and publication cancellation. Duplicate ZIP names remain explicitly ambiguous where the public projection cannot represent them; synthetic archive/cleanup cases do not prove every real Unblob or DMG failure path. ([#1726](https://github.com/morluto/rea/pull/1726), [issue morluto/rea#1725](https://github.com/morluto/rea/issues/1725), [#1854](https://github.com/morluto/rea/pull/1854), [issue morluto/rea#1844](https://github.com/morluto/rea/issues/1844), [#1865](https://github.com/morluto/rea/pull/1865), [issue morluto/rea#1860](https://github.com/morluto/rea/issues/1860), [#2069](https://github.com/morluto/rea/pull/2069), [#2106](https://github.com/morluto/rea/pull/2106), [#2144](https://github.com/morluto/rea/pull/2144), [#2365](https://github.com/morluto/rea/pull/2365), [#2493](https://github.com/morluto/rea/pull/2493), [918713a](https://github.com/morluto/rea/commit/918713af376e307ecf24d75e6a0dd9d1b544545b), [eb97da3](https://github.com/morluto/rea/commit/eb97da33881e3d1472d97a7d788ee35944fb6b07), [2260887](https://github.com/morluto/rea/commit/2260887f2092be6a3d9c79509e4ae4a3e52fce7f), [74d7da6](https://github.com/morluto/rea/commit/74d7da6ced0f869936ee0316b00616931b088066), [ed485a3](https://github.com/morluto/rea/commit/ed485a3dc8cc3a4baf8cabe01acb7aa0490b73b3), [921bb95](https://github.com/morluto/rea/commit/921bb9575505065f6cfd57b14d97d80d0f238546), [d8ceb3f](https://github.com/morluto/rea/commit/d8ceb3f276a65c181b146f68c0729ff4b6f9fa61), [9435c7a](https://github.com/morluto/rea/commit/9435c7aae279ba184937c0dfeb713fd574ba3f29), [bb54082](https://github.com/morluto/rea/commit/bb540826d65316c164d2de8ba4ef10f81a8ad073), [a73a55f](https://github.com/morluto/rea/commit/a73a55f53cc2b2e5b7618effe6ecb5cee6e0cee2), [3c57e1a](https://github.com/morluto/rea/commit/3c57e1a8b21b379fca55032d177078e1d8426e64), [ec0b081](https://github.com/morluto/rea/commit/ec0b081371f118e6e5e042fbef3479359c672ddb), [e7882b3](https://github.com/morluto/rea/commit/e7882b34f4ac4a26f903c5f49264acc2c19bb562), [b955b82](https://github.com/morluto/rea/commit/b955b825fc002a4bb408f2910b78768c566c2cd1)). Thanks @1052326311, @1NormalGuy, @baron, @Bortlesboat, @Chaoqi31, @DeryFerd, @kaoru0822-kitauji, @N0zoM1z0, @XiaoBinGan.
- **Setup/uninstall recovery:** preserve unrelated YAML text, caller-selected roots, symlinked data and existing recovery snapshots; explain malformed configuration shapes and retain failed rollback/cleanup owners. Show terminal dry-run plans and each selected skill location. Bunx registration and fault-injected recovery cases remain simulated rather than live-client verification. ([#1731](https://github.com/morluto/rea/pull/1731), [#1754](https://github.com/morluto/rea/pull/1754), [#1821](https://github.com/morluto/rea/pull/1821), [issue morluto/rea#1816](https://github.com/morluto/rea/issues/1816), [#1983](https://github.com/morluto/rea/pull/1983), [#2118](https://github.com/morluto/rea/pull/2118), [issue morluto/rea#2111](https://github.com/morluto/rea/issues/2111), [#2154](https://github.com/morluto/rea/pull/2154), [issue morluto/rea#2152](https://github.com/morluto/rea/issues/2152), [#2157](https://github.com/morluto/rea/pull/2157), [#2182](https://github.com/morluto/rea/pull/2182), [issue morluto/rea#2159](https://github.com/morluto/rea/issues/2159), [#2481](https://github.com/morluto/rea/pull/2481), [bb50813](https://github.com/morluto/rea/commit/bb508134efbba392a404b651d6ec05be8b7eac1e), [c240768](https://github.com/morluto/rea/commit/c2407689cb04df082e8db0b6afa8f8d022f6bcd1), [dcfc5d4](https://github.com/morluto/rea/commit/dcfc5d403ac997c2b08bee4753e57a1a4a8a8538), [21eff3b](https://github.com/morluto/rea/commit/21eff3bae10f58711a35ad44f86e9f96f9bb4790), [94153f1](https://github.com/morluto/rea/commit/94153f1c757e07a6d092e00903aa8b125d4a0afa), [2397137](https://github.com/morluto/rea/commit/23971376158e7815edcbe6c04a8df7c9504650dc), [c339f9e](https://github.com/morluto/rea/commit/c339f9ebe7bd1f30608d6ccb7175da523dd3c485), [f6775a3](https://github.com/morluto/rea/commit/f6775a36694a6a9c620f4248638e8056a6cbcb66)). Thanks @BeqaKs, @Jax367, @kaoru0822-kitauji, @NIU-123370, @shoyann, @viniciusdsandrade, @xeropresence, @zaydiscold.
- **Bun updates:** locate global installations before changing directories and reuse parsed client activation during maintenance. The update flow is covered with package-manager/filesystem simulations; a real Bun global installation update remains unverified. ([#1735](https://github.com/morluto/rea/pull/1735), [e90acae](https://github.com/morluto/rea/commit/e90acaea674f5f7f9f17ea472104afdc1422ada6), [b045df1](https://github.com/morluto/rea/commit/b045df100e1489e08a36d6ff04cea7feecf1332c)). Thanks @xeropresence.
- **JavaScript relationships:** preserve loader-specific package/TypeScript/CommonJS boundaries, exact file-URL and source-map identity, Electron intrinsic receivers/IPC guards and chunk metadata. Index repeated lookups and retain completed facts through resource limits and cancellation. Real Electron integration, Bun worker integration and failure-path cleanup remain unverified where the recorded lanes use fixtures. ([#1740](https://github.com/morluto/rea/pull/1740), [#1759](https://github.com/morluto/rea/pull/1759), [issue morluto/rea#1748](https://github.com/morluto/rea/issues/1748), [#1762](https://github.com/morluto/rea/pull/1762), [issue morluto/rea#1761](https://github.com/morluto/rea/issues/1761), [#1767](https://github.com/morluto/rea/pull/1767), [#1788](https://github.com/morluto/rea/pull/1788), [issue morluto/rea#1781](https://github.com/morluto/rea/issues/1781), [issue morluto/rea#1782](https://github.com/morluto/rea/issues/1782), [#1789](https://github.com/morluto/rea/pull/1789), [#1799](https://github.com/morluto/rea/pull/1799), [issue morluto/rea#1793](https://github.com/morluto/rea/issues/1793), [#1827](https://github.com/morluto/rea/pull/1827), [issue morluto/rea#1802](https://github.com/morluto/rea/issues/1802), [#1833](https://github.com/morluto/rea/pull/1833), [issue morluto/rea#1831](https://github.com/morluto/rea/issues/1831), [#1857](https://github.com/morluto/rea/pull/1857), [issue morluto/rea#1842](https://github.com/morluto/rea/issues/1842), [#1871](https://github.com/morluto/rea/pull/1871), [#1874](https://github.com/morluto/rea/pull/1874), [#1905](https://github.com/morluto/rea/pull/1905), [#1912](https://github.com/morluto/rea/pull/1912), [issue morluto/rea#1909](https://github.com/morluto/rea/issues/1909), [#1915](https://github.com/morluto/rea/pull/1915), [issue morluto/rea#1911](https://github.com/morluto/rea/issues/1911), [#1916](https://github.com/morluto/rea/pull/1916), [#1921](https://github.com/morluto/rea/pull/1921), [#1934](https://github.com/morluto/rea/pull/1934), [#1939](https://github.com/morluto/rea/pull/1939), [issue morluto/rea#1881](https://github.com/morluto/rea/issues/1881), [issue morluto/rea#1882](https://github.com/morluto/rea/issues/1882), [issue morluto/rea#1883](https://github.com/morluto/rea/issues/1883), [#1941](https://github.com/morluto/rea/pull/1941), [#2062](https://github.com/morluto/rea/pull/2062), [issue morluto/rea#2058](https://github.com/morluto/rea/issues/2058), [#2104](https://github.com/morluto/rea/pull/2104), [#2422](https://github.com/morluto/rea/pull/2422), [#2475](https://github.com/morluto/rea/pull/2475), [#2737](https://github.com/morluto/rea/pull/2737), [issue morluto/rea#2663](https://github.com/morluto/rea/issues/2663), [d3088c3](https://github.com/morluto/rea/commit/d3088c3795f6c37cb3b6aec0bb0c371dd0640ee8), [1dfb933](https://github.com/morluto/rea/commit/1dfb933e47db0156cf1cb61f28715b81e740c57b), [f8513a4](https://github.com/morluto/rea/commit/f8513a42dc44451a621127f06edde5d38bc93f69), [b8fab74](https://github.com/morluto/rea/commit/b8fab7429771bb17a7ca0297661de3c9935212af), [42b0ed1](https://github.com/morluto/rea/commit/42b0ed126df7d5b908ac369715378d5e519051b7), [c0904b4](https://github.com/morluto/rea/commit/c0904b4c1aabfccf2f0ab12f44960a0a1fbbbfae), [9f67e44](https://github.com/morluto/rea/commit/9f67e44e5d451d705bae9a579ad9802d5c9f097c), [e4532b5](https://github.com/morluto/rea/commit/e4532b537663026c3e5b784380eecd009c959a36), [1c37914](https://github.com/morluto/rea/commit/1c37914306b3ff257d7a0360f11c9bf2a3f2cac7), [5ab0cbb](https://github.com/morluto/rea/commit/5ab0cbb48ea5a4f11542eac44479b9af48f1dabd), [b7169c8](https://github.com/morluto/rea/commit/b7169c8b747cbc032913b31f008a3b81c77b6219), [f43fc02](https://github.com/morluto/rea/commit/f43fc0247527aebb8efc7d9db04276fe2d489323), [3ae826c](https://github.com/morluto/rea/commit/3ae826ceba6f25b08bce50bcb51001209bc16b57), [1b95302](https://github.com/morluto/rea/commit/1b953021eadf4cac09e20be388d79536903633b3), [eaa9616](https://github.com/morluto/rea/commit/eaa961658578522cecfbba7c7be1acf1c64da65a), [c923fb0](https://github.com/morluto/rea/commit/c923fb0dc9462e302b84a15770131ff899f89455), [c7a890d](https://github.com/morluto/rea/commit/c7a890d12987d9b2d496119c1fbf1a88c0fc298e)). Thanks @Aurantium-47, @baron, @chqcjq, @coygeek, @cyanseek, @fengdwx, @flitzrrr, @immanuwell, @kaoru0822-kitauji, @N0zoM1z0, @Nelglor, @ranmori, @rudycelekli, @shoyann, @xu91102.
- **Browser observations:** preserve effective source-map URLs, empty query/fragment identity, response-body provenance, service-worker policies and explicit uncertainty for malformed events. Bound discovery lifetimes and retain cleanup ownership. Interrupt/retry/failure cases validated with controlled CDP/session fixtures do not establish those paths in real Chromium. ([#1742](https://github.com/morluto/rea/pull/1742), [issue morluto/rea#1729](https://github.com/morluto/rea/issues/1729), [#1829](https://github.com/morluto/rea/pull/1829), [#2074](https://github.com/morluto/rea/pull/2074), [#2099](https://github.com/morluto/rea/pull/2099), [#2239](https://github.com/morluto/rea/pull/2239), [issue morluto/rea#2169](https://github.com/morluto/rea/issues/2169), [#2477](https://github.com/morluto/rea/pull/2477), [486b567](https://github.com/morluto/rea/commit/486b56724c4eaba50719177bcb9eae84d6907297), [a82e37c](https://github.com/morluto/rea/commit/a82e37c8065cc200a0ca3e14e7c4f85e4e468580), [a8d4984](https://github.com/morluto/rea/commit/a8d4984404b611e60b88bc42289c0ba07eee4578), [966f7cc](https://github.com/morluto/rea/commit/966f7cc84b4a8bb0043641cb64d7a7aaf8715e05), [221c856](https://github.com/morluto/rea/commit/221c85648108e9846d9920b0964dafa7f108d581), [951b095](https://github.com/morluto/rea/commit/951b095e11adf9faa9c4a27e212260060aa6b095), [f09bb8a](https://github.com/morluto/rea/commit/f09bb8a99773f79c7306b8799c86f856fe2c0d4b), [6bd2b28](https://github.com/morluto/rea/commit/6bd2b28161411f70ed5dc72e42c2736a1aab758b)). Thanks @1052326311, @chAng-L19, @chqcjq, @coygeek, @evenafterallisaid, @fisforfaheem, @itsF4LCON, @N0zoM1z0, @shayanxcyber.
- **Managed metadata/CIL:** preserve ExportedType and pointer-table ownership, generic/array/function-pointer/modifier signatures, ReadyToRun host identity, metadata ExtraData and signed zero. Charge retained facts and decoded instruction counts instead of broad decoding bounds; malformed layouts remain caller input errors. ([#1743](https://github.com/morluto/rea/pull/1743), [issue morluto/rea#1736](https://github.com/morluto/rea/issues/1736), [#1768](https://github.com/morluto/rea/pull/1768), [#1822](https://github.com/morluto/rea/pull/1822), [issue morluto/rea#1801](https://github.com/morluto/rea/issues/1801), [#1828](https://github.com/morluto/rea/pull/1828), [issue morluto/rea#1812](https://github.com/morluto/rea/issues/1812), [#1835](https://github.com/morluto/rea/pull/1835), [issue morluto/rea#1832](https://github.com/morluto/rea/issues/1832), [#1886](https://github.com/morluto/rea/pull/1886), [#1893](https://github.com/morluto/rea/pull/1893), [#1897](https://github.com/morluto/rea/pull/1897), [issue morluto/rea#1878](https://github.com/morluto/rea/issues/1878), [#1943](https://github.com/morluto/rea/pull/1943), [issue morluto/rea#1940](https://github.com/morluto/rea/issues/1940), [#1985](https://github.com/morluto/rea/pull/1985), [#2194](https://github.com/morluto/rea/pull/2194), [issue morluto/rea#2191](https://github.com/morluto/rea/issues/2191), [#2437](https://github.com/morluto/rea/pull/2437), [52e76c5](https://github.com/morluto/rea/commit/52e76c5d39b58d1685bb5e22bd40d19524dfa2cb)). Thanks @ac1982, @chqcjq, @fisHarly0, @flitzrrr, @JasonBuildAI, @JayOfTheKeyboard, @jhuang-tw, @kaoru0822-kitauji, @N0zoM1z0, @Nelglor.
- **Hopper failure ownership:** preserve startup/process outcomes, retain cleanup leases across one-shot failures, wait for late bridges, and stop owned Linux demo children when their REA owner exits. Denied/crashed/late-bridge/queued-request failure paths use simulated process or bridge fixtures; they are not fresh installed-Hopper verification. ([#1763](https://github.com/morluto/rea/pull/1763), [issue morluto/rea#1751](https://github.com/morluto/rea/issues/1751), [#1817](https://github.com/morluto/rea/pull/1817), [#1866](https://github.com/morluto/rea/pull/1866), [#1927](https://github.com/morluto/rea/pull/1927), [#2474](https://github.com/morluto/rea/pull/2474), [#2499](https://github.com/morluto/rea/pull/2499), [issue morluto/rea#2459](https://github.com/morluto/rea/issues/2459)). Thanks @coygeek, @Joshwong1908, @kaoru0822-kitauji, @N0zoM1z0, @rudycelekli.
- **IDA session boundaries:** retain non-x86 call coverage and unresolved-procedure uncertainty, and bound private universal-Mach-O inputs to the exact selected slice. Transient headless busy recovery was exercised on macOS arm64 IDA Pro 9.1; Linux headless, attached GUI navigation and worker-reaped cleanup paths retain their documented verification gaps. ([#1764](https://github.com/morluto/rea/pull/1764), [issue morluto/rea#1712](https://github.com/morluto/rea/issues/1712), [#1778](https://github.com/morluto/rea/pull/1778), [issue morluto/rea#1766](https://github.com/morluto/rea/issues/1766), [#1785](https://github.com/morluto/rea/pull/1785), [issue morluto/rea#1769](https://github.com/morluto/rea/issues/1769), [#1906](https://github.com/morluto/rea/pull/1906), [issue morluto/rea#1794](https://github.com/morluto/rea/issues/1794), [#2201](https://github.com/morluto/rea/pull/2201), [issue morluto/rea#2139](https://github.com/morluto/rea/issues/2139), [#2291](https://github.com/morluto/rea/pull/2291), [issue morluto/rea#2285](https://github.com/morluto/rea/issues/2285), [#2324](https://github.com/morluto/rea/pull/2324), [12fa8fd](https://github.com/morluto/rea/commit/12fa8fd9f986dbd4ff58165c3ec71a46dc8a8a3b)). Thanks @akushonkamen, @coygeek, @kaoru0822-kitauji, @N0zoM1z0, @nikolay-dementiev, @zecaro.
- **Input and capability diagnostics:** identify invalid paths, format refusals, missing host tools and non-retryable errors without losing caller selections or retryable capability facts. Foreign execution/device mutation annotations remain destructive, analysis-target text is untrusted, and output ordering follows code points rather than host locale. ([#1770](https://github.com/morluto/rea/pull/1770), [#1867](https://github.com/morluto/rea/pull/1867), [#1869](https://github.com/morluto/rea/pull/1869), [issue morluto/rea#1855](https://github.com/morluto/rea/issues/1855), [#1872](https://github.com/morluto/rea/pull/1872), [#1879](https://github.com/morluto/rea/pull/1879), [issue morluto/rea#1853](https://github.com/morluto/rea/issues/1853), [#1899](https://github.com/morluto/rea/pull/1899), [#1902](https://github.com/morluto/rea/pull/1902), [#1904](https://github.com/morluto/rea/pull/1904), [#1908](https://github.com/morluto/rea/pull/1908), [#1925](https://github.com/morluto/rea/pull/1925), [#1987](https://github.com/morluto/rea/pull/1987), [issue morluto/rea#1979](https://github.com/morluto/rea/issues/1979), [#2004](https://github.com/morluto/rea/pull/2004), [issue morluto/rea#2002](https://github.com/morluto/rea/issues/2002), [#2026](https://github.com/morluto/rea/pull/2026), [#2091](https://github.com/morluto/rea/pull/2091), [#2133](https://github.com/morluto/rea/pull/2133), [issue morluto/rea#2129](https://github.com/morluto/rea/issues/2129), [#2145](https://github.com/morluto/rea/pull/2145), [issue morluto/rea#2143](https://github.com/morluto/rea/issues/2143), [#2472](https://github.com/morluto/rea/pull/2472), [#2478](https://github.com/morluto/rea/pull/2478), [issue morluto/rea#2454](https://github.com/morluto/rea/issues/2454), [#2494](https://github.com/morluto/rea/pull/2494), [#2507](https://github.com/morluto/rea/pull/2507), [#2509](https://github.com/morluto/rea/pull/2509), [#2708](https://github.com/morluto/rea/pull/2708), [3d83135](https://github.com/morluto/rea/commit/3d8313598785359c424d7305f503cb69fc8639a6), [c9fed50](https://github.com/morluto/rea/commit/c9fed50691ae014890e91a72534773d2fb58ce44), [8b18171](https://github.com/morluto/rea/commit/8b181716aca2cb467be499cba226abfb5ae19141), [7a2cd1e](https://github.com/morluto/rea/commit/7a2cd1ea0a63e8c44166860b7ee9c7a1aa43492b)). Thanks @astralyx0129, @CassieFox23, @DeryFerd, @dollarsmoney, @fisforfaheem, @flitzrrr, @Ha1baraA11, @jimpablo, @kaoru0822-kitauji, @larinyo, @N0zoM1z0, @rudycelekli, @SII-YDD, @viniciusdsandrade, @XiaoBinGan.
- **Android analysis:** retain decoded values, duplicate-class inventories and executable static initializers, reject malformed JADX UTF-8, and keep inline search provenance. Apktool accepts `--no-strings` and explains host requirements. Windows batch-launcher selection also resolves PATH/PATHEXT before choosing its launch mode. Recorded Apktool coverage is Linux/OpenJDK, and changed JADX/batch failure paths do not establish native Windows behavior. ([#1777](https://github.com/morluto/rea/pull/1777), [issue morluto/rea#1774](https://github.com/morluto/rea/issues/1774), [#1876](https://github.com/morluto/rea/pull/1876), [issue morluto/rea#1846](https://github.com/morluto/rea/issues/1846), [#1890](https://github.com/morluto/rea/pull/1890), [issue morluto/rea#1850](https://github.com/morluto/rea/issues/1850), [#1946](https://github.com/morluto/rea/pull/1946), [issue morluto/rea#1945](https://github.com/morluto/rea/issues/1945), [#2707](https://github.com/morluto/rea/pull/2707), [issue morluto/rea#2672](https://github.com/morluto/rea/issues/2672), [0677468](https://github.com/morluto/rea/commit/0677468877465c8dfbe25c72f142a7b5bc8a3212), [fe8d0bd](https://github.com/morluto/rea/commit/fe8d0bdd36fe12bc0836468d1a702a34fbdc219b), [#2802](https://github.com/morluto/rea/pull/2802)). Thanks @akushonkamen, @coygeek, @HeadmasterEggy, @kaoru0822-kitauji, @N0zoM1z0, @psipher.
- **ADB acquisition:** preserve device paths and directory identity, prevent host destination collisions/overwrites, count UTF-8 logcat bytes and restricted permissions, and evaluate the complete Activity Manager start report. Launch attempts, status, activity and raw failure output remain observations; real-device timeout/mixed-report and Android 8 directory-read paths remain unverified beyond the documented fixtures. ([#1962](https://github.com/morluto/rea/pull/1962), [#2212](https://github.com/morluto/rea/pull/2212), [issue morluto/rea#2175](https://github.com/morluto/rea/issues/2175), [#2221](https://github.com/morluto/rea/pull/2221), [issue morluto/rea#2220](https://github.com/morluto/rea/issues/2220), [#2359](https://github.com/morluto/rea/pull/2359), [c42232f](https://github.com/morluto/rea/commit/c42232f566d55fe7191763649755148c768eef9f), [55b6148](https://github.com/morluto/rea/commit/55b61485e9f7568b69597eef8696ff6abfe53cfd), [3bdc873](https://github.com/morluto/rea/commit/3bdc8730bd98e073522bcb8ba86527be402c0cbe), [c89972c](https://github.com/morluto/rea/commit/c89972c8777d0114566fe76a060849a18bdd2659), [e1cd292](https://github.com/morluto/rea/commit/e1cd292aa6e8f5f13441d408c66126782c8dbb0d)). Thanks @coygeek, @dakjdakd, @DeryFerd, @huiq777, @sx4im.
- **WASM names and diagnostics:** preserve debug/custom-section names including NUL and leading BOM, accept unpadded WABT section rows, and avoid irrelevant tool-remediation advice on unrelated runtime-root failures. ([#1963](https://github.com/morluto/rea/pull/1963), [issue morluto/rea#1956](https://github.com/morluto/rea/issues/1956), [#1967](https://github.com/morluto/rea/pull/1967), [#1981](https://github.com/morluto/rea/pull/1981), [#2084](https://github.com/morluto/rea/pull/2084), [#2102](https://github.com/morluto/rea/pull/2102)). Thanks @1052326311, @bugstan, @N0zoM1z0, @sathariels, @STiFLeR7, @viniciusdsandrade.
- **Source-map and path handling:** retain source-map entries when embedded-content counts differ or an optional `sourceMappingURL` is used, and keep `file:` URLs out of operator-relative path resolution. ([#2873](https://github.com/morluto/rea/pull/2873), [#2886](https://github.com/morluto/rea/pull/2886), [#2882](https://github.com/morluto/rea/pull/2882), [issue morluto/rea#2821](https://github.com/morluto/rea/issues/2821), [issue morluto/rea#2883](https://github.com/morluto/rea/issues/2883), [issue morluto/rea#2577](https://github.com/morluto/rea/issues/2577)). Thanks @JFSAS, @rileygramlich, @shoyann, @shubhamahuja9999, @coygeek.
- **Native metadata:** accept declared facts in native dispatch inventories and share native verification input diagnostics. ([#2906](https://github.com/morluto/rea/pull/2906), [#2901](https://github.com/morluto/rea/pull/2901), [issue morluto/rea#2564](https://github.com/morluto/rea/issues/2564), [issue morluto/rea#2621](https://github.com/morluto/rea/issues/2621)). Thanks @ShitK, @coygeek.
### ⚠ BREAKING CHANGES
- **Runtime attachment state:** `attached` may be `null` in JavaScript, browser and Electron discovery/inspection results. Treat null as unknown; do not coerce it to false. ([e55ef72](https://github.com/morluto/rea/commit/e55ef7213515ba204c3d88a4487b4ee6a2773b06)).
- **Source maps:** original-source and mapping rows preserve a null source name when unavailable. Handle null explicitly instead of expecting an invented unknown-source string; new index fields remain optional when reading older Evidence. ([ee50ed1](https://github.com/morluto/rea/commit/ee50ed121e38cc6e1a2ff0dc8b789f15f71c5402)).
- **Streamed filtered JSON:** `--filter-output` now emits JSON `null` instead of `{}` when the selected value is null; consumers must distinguish a present null from a missing selection. Use `--full-output` when an object envelope is required. ([#2333](https://github.com/morluto/rea/pull/2333)). Thanks @Jackmeson1.
- **Call graphs:** `get_call_graph.normalized_result` is now `{ graph, limitations }`; read traversal-depth groups under `graph` and retain relationship-specific caveats. Contract/fixture validation does not establish new Ghidra or IDA engine coverage. ([5dfb9d0](https://github.com/morluto/rea/commit/5dfb9d050c2cc44a61b183dae765bef4d22a53b7)).
- **MCP discovery:** `binary_session` no longer repeats `server_identity.catalog.tools`. Use the connected server tool list, grouped `tool_availability` and provider capabilities; see [MCP contracts](docs/mcp-contracts.md). Origin: [6395e8d](https://github.com/morluto/rea/commit/6395e8d3007d5ee453477460e7ee1c51cff76be0). ([#1988](https://github.com/morluto/rea/pull/1988)). Thanks @kaoru0822-kitauji, @N0zoM1z0.
- **Provider admission:** MIPS targets with unresolved GNU ABI attributes are refused. Supply supported ABI declarations. Saved Hopper databases on macOS are unavailable because the launcher cannot run the analysis bridge when opening a database; select an executable target instead. Database support requires a script-capable database launch route. ([d4bd65c](https://github.com/morluto/rea/commit/d4bd65c53c0254d0195cc7ab8da163dcadfd3b9b)).
- **Segment extents:** `binary_overview` segment length may be null when Hopper coordinates cannot establish a finite extent in one address space. Preserve unknown rather than converting it to zero or a saturated value. ([36db19c](https://github.com/morluto/rea/commit/36db19c59)).
- **Invocation identity:** `server.command_path` may be null, and registration/setup statuses or doctor classifications may be unknown. Preserve these states and establish the actual invocation before treating a registration as aligned. ([187f113](https://github.com/morluto/rea/commit/187f113f7)). Thanks @hemv-857, @qorvr.
- **ADB installation:** the optional `replace` input is unchanged. Results now require `replace_requested` to record the requested `adb install -r` mode; `replaced` is nullable because prior device package state was not observed. Update result readers to accept null instead of treating reinstall intent as proof of replacement. ([864314a](https://github.com/morluto/rea/commit/864314ac857924a7e6e65bfb37e8dc720fcc21e7)).
- **Managed/native verification:** unresolved P/Invoke results may retain `matched_native` and candidate facts when module identity is unknown. Interpret `status`, `basis` and `limitations`; `module-identity-unknown` does not verify correspondence, and unresolved no longer implies empty observations. ([3f6b575](https://github.com/morluto/rea/commit/3f6b5755b3e699bd9373641acffe0f2c5d2ab094)).
Full contribution record: [893 commits and 529 merged PRs](https://github.com/morluto/rea/blob/rea-agents-6.5.0/docs/releases/6.5.0.contributions.json).
## [6.4.0](https://github.com/morluto/rea/compare/rea-agents-6.3.0...rea-agents-6.4.0) (2026-10-11)
### Highlights
+40 -6
View File
@@ -567,18 +567,27 @@ def trace(debugger, config):
before_digest = _hash_file(config["executable"])
before_identity = _file_identity(config["executable"])
except OSError as error:
return {"status": "target-integrity-error", "error": str(error)}
return {
"status": "target-integrity-error",
"error": str(error),
"target_process_created": False,
}
if before_digest != config["expected_sha256"]:
return {
"status": "target-integrity-error",
"error": "selected executable digest changed before LLDB target creation",
"target_process_created": False,
}
error = lldb.SBError()
target = debugger.CreateTarget(
config["executable"], config["architecture"], None, False, error
)
if not target.IsValid():
return {"status": "target-error", "error": error.GetCString()}
return {
"status": "target-error",
"error": error.GetCString(),
"target_process_created": False,
}
module_path_before, module_file_identity_before, module_uuid_before = (
_module_identity(target)
)
@@ -589,6 +598,7 @@ def trace(debugger, config):
return {
"status": "target-integrity-error",
"error": "LLDB executable module resolved to a different pre-launch file identity",
"target_process_created": False,
}
created = [_create_breakpoints(target, spec) for spec in config["breakpoints"]]
breakpoint_index = {}
@@ -622,12 +632,24 @@ def trace(debugger, config):
if not error.Success() or not process.IsValid():
stdout.close()
stderr.close()
return {"status": "launch-error", "error": error.GetCString()}
return {
"status": "launch-error",
"error": error.GetCString(),
**(
{"target_process_created": True}
if process.IsValid()
else {}
),
}
launch_failure = _failed_launch_description(process)
if launch_failure is not None:
stdout.close(expected_exit=True)
stderr.close(expected_exit=True)
return {"status": "launch-error", "error": launch_failure}
return {
"status": "launch-error",
"error": launch_failure,
"target_process_created": True,
}
stdout.mark_launched()
stderr.mark_launched()
# Publish the PID before any post-launch file or module inspection. The
@@ -644,6 +666,7 @@ def trace(debugger, config):
return {
"status": "target-identity-unavailable",
"error": "host could not establish the launched process identity; target was stopped",
"target_process_created": True,
}
try:
after_digest = _hash_file(config["executable"])
@@ -652,7 +675,11 @@ def trace(debugger, config):
process.Kill()
stdout.close(expected_exit=process.GetState() == lldb.eStateExited)
stderr.close(expected_exit=process.GetState() == lldb.eStateExited)
return {"status": "target-integrity-error", "error": str(identity_error)}
return {
"status": "target-integrity-error",
"error": str(identity_error),
"target_process_created": True,
}
if after_digest != config["expected_sha256"] or after_identity != before_identity:
process.Kill()
_wait_for_exit(listener, process, 5)
@@ -661,6 +688,7 @@ def trace(debugger, config):
return {
"status": "target-integrity-error",
"error": "selected executable digest or file identity changed during LLDB launch",
"target_process_created": True,
}
module_path_after, module_file_identity_after, module_uuid_after = (
_module_identity(target)
@@ -676,6 +704,7 @@ def trace(debugger, config):
return {
"status": "target-integrity-error",
"error": "LLDB executable module resolved to a different file identity",
"target_process_created": True,
}
if (
module_uuid_before is not None
@@ -689,6 +718,7 @@ def trace(debugger, config):
return {
"status": "target-integrity-error",
"error": "LLDB executable module identity changed during launch",
"target_process_created": True,
}
context = {
"target": target,
@@ -748,7 +778,11 @@ def trace(debugger, config):
# exit code; preserve the debugger's concrete launch denial.
launch_failure = _failed_launch_description(process)
if launch_failure is not None:
return {"status": "launch-error", "error": launch_failure}
return {
"status": "launch-error",
"error": launch_failure,
"target_process_created": True,
}
launch_identity_stable = (
module_path_before is not None
and module_path_after == module_path_before
+37
View File
@@ -8,6 +8,7 @@ import sys
import tempfile
import types
import unittest
from unittest.mock import patch
fake_lldb = sys.modules.setdefault("lldb", types.ModuleType("lldb"))
@@ -22,6 +23,42 @@ spec.loader.exec_module(tracer)
class NativeTraceBudgetTests(unittest.TestCase):
def test_pre_target_integrity_failure_reports_no_process_created(self):
original_debugger = getattr(tracer.lldb, "SBDebugger", None)
class DebuggerApi:
@staticmethod
def SetInternalVariable(*_arguments):
return None
class Debugger:
def SetAsync(self, _enabled):
return None
def GetInstanceName(self):
return "fixture"
def CreateTarget(self, *_arguments):
raise AssertionError("integrity failure precedes target creation")
tracer.lldb.SBDebugger = DebuggerApi
try:
with patch.object(tracer, "_hash_file", return_value="changed"), patch.object(
tracer, "_file_identity", return_value=(1, 2)
):
result = tracer.trace(
Debugger(),
{"executable": "fixture", "expected_sha256": "expected"},
)
finally:
if original_debugger is None:
del tracer.lldb.SBDebugger
else:
tracer.lldb.SBDebugger = original_debugger
self.assertEqual(result["status"], "target-integrity-error")
self.assertIs(result["target_process_created"], False)
def test_journal_checks_exact_byte_budget_before_writing(self):
record = {"kind": "other-stop", "reason": "signal"}
encoded_size = len((tracer.json.dumps(record) + "\n").encode("utf-8"))
+6 -5
View File
@@ -259,14 +259,14 @@ Register with the variable unset:
<!-- x-release-please-start-version -->
```bash
claude mcp add --scope local rea -- npx -y rea-agents@6.4.0 mcp
claude mcp add --scope local rea -- npx -y rea-agents@6.5.0 mcp
```
On native Windows, use the same project directory and wrap the package-runner
shim so the stdio client can launch it:
```powershell
claude mcp add --scope local rea -- cmd.exe /d /c npx -y rea-agents@6.4.0 mcp
claude mcp add --scope local rea -- cmd.exe /d /c npx -y rea-agents@6.5.0 mcp
```
<!-- x-release-please-end -->
@@ -398,8 +398,9 @@ The npm release checked on **2026-10-11** was **6.4.0**, published from
[`a56f7bb6`](https://github.com/morluto/rea/commit/a56f7bb668ab23fec8d33f7af353ac7ed2fe1895).
The actual registry package accepted dry-run setup plans for the 17 client IDs
in that release, including Qwen Code, Pi and Hermes. DeepSeek Harness (`dsh`)
is on main and is not in npm 6.4.0; `rea setup --client dsh` against `@latest`
reports an unsupported client until a release containing it is published.
is included from 6.5.0 and was absent from the checked npm 6.4.0 package.
The 6.4.0 registry check does not establish 6.5.0 publication or live DeepSeek
API integration.
OpenCode's plan selected
`OPENCODE_CONFIG_DIR` even when that directory did not yet exist. These checks
used an isolated home on macOS with Node 24; they verify planning and argument
@@ -1054,7 +1055,7 @@ For a client that requires manual configuration, use:
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@6.4.0", "mcp"]
"args": ["-y", "rea-agents@6.5.0", "mcp"]
}
}
}
+6 -2
View File
@@ -323,6 +323,9 @@ function implements a specific managed method.
`verify_managed_native_boundaries` compares declared P/Invoke module and entry
point identities with supplied native export or function evidence. Name/module
matches establish declaration links; they do not establish that a call executed.
When either side lacks a usable module identity, a matching name remains
unresolved and retains the candidate evidence; REA reports a module mismatch
only when both identities are available and differ.
Metadata tokens are not native addresses. Native body mapping for C++/CLI,
ReadyToRun, NativeAOT, and IL2CPP requires separately verified format and provider
@@ -360,8 +363,9 @@ 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.
definition rows. Invalid or nonmonotonic list coordinates and malformed or
ambiguous pointers leave affected owners unknown with partial coverage while
preserving readable member and attribute facts.
The retained inverse indexes are admitted against the existing representation
budget before allocation.
File diff suppressed because it is too large. Load diff
+3
View File
@@ -10,6 +10,9 @@ SHA returned by Release Please. Before either registry is published, the release
run calls full CI qualification at that exact revision, including coverage,
portable providers, and every native package host. Later main commits cannot
change or cancel that proof.
The required reverse-tools lane runs fresh coverage and both mutation
preflights. Complete mutation sweeps run separately on a schedule or manual
dispatch and do not block publication; see [the testing guide](testing.md).
Ordinary main pushes and closed, unmerged PRs cannot publish packages. Check
both publication jobs and the published-package canary; a merged PR or GitHub
tag alone does not establish that publication finished. Retry failed jobs in
+27
View File
@@ -280,6 +280,33 @@ focused suite also exercises affected MCP routes and the Python Cutter bridge.
Contract, integration, and real-provider lanes complement these focused tests;
simulated providers do not establish real-provider behavior.
Required CI retains fresh reverse-tools coverage and preflights both canonical
Stryker configurations with `--dryRunOnly`. These preflights resolve the owned
targets, instrument the source, and execute the real initial tests. Invalid
configuration, target resolution, and initial test failures still fail the job.
Preflights execute no mutants and produce no mutation-outcome report. Both fast
feedback and full qualification, including exact-revision release qualification,
require coverage and both preflights. A failed, canceled, skipped or missing
selected quality job still fails the required gate.
The separate **Mutation sweep** workflow runs both complete configurations daily
and on manual dispatch, in two isolated jobs. It is a maintenance check outside
the PR and release publication gates. `ci:full` requests full regression,
coverage and provider qualification; it does not request a complete mutation
sweep. Use the Mutation sweep workflow to request that result. Targets,
checkers, concurrency, deadlines and the existing non-blocking mutation-score
threshold are unchanged. `npm run test:mutation:reverse-tools` also retains both
complete passes for local callers.
The two passes write separate JSON and HTML reports under `reports/mutation/`:
`reverse-tools` and `reverse-tools-semantics`. Required CI retains focused
coverage; the sweep retains each configuration's JSON/HTML reports separately.
Both retain available diagnostics for seven days after failure and skip uploads
on cancellation. Artifact names identify the tier or configuration and run
attempt. A successful sweep requires both jobs and their nonempty reports;
partial diagnostics and preflights do not establish complete mutation outcomes
or qualify the release revision for mutation coverage.
Fast CI starts the source-only projects after classification, independently of
build and static checks, and runs five built-dependent shards after the build.
The six test jobs still select every portable test. Full qualification keeps six
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rea-agents",
"version": "6.4.0",
"version": "6.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rea-agents",
"version": "6.4.0",
"version": "6.5.0",
"license": "MIT",
"dependencies": {
"@babel/parser": "8.0.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rea-agents",
"mcpName": "io.github.morluto/rea",
"version": "6.4.0",
"version": "6.5.0",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"license": "MIT",
"repository": {
+1
View File
@@ -16,6 +16,7 @@ const GUARDED_WORKFLOWS = new Set([
".github/workflows/real-ghidra-windows.yml",
".github/workflows/sqlite-runtime.yml",
".github/workflows/ci-timings.yml",
".github/workflows/mutation.yml",
]);
// Sources compiled by build-conformance-fixtures.mjs. The readiness journey
// analyzes the built C fixture as well as its own applications.
+2
View File
@@ -3,6 +3,7 @@
// every workflow.
export const SCOPES = {
reverse_tools: [
".github/workflows/mutation.yml",
"src/application/reverse/**",
"src/application/binary/BinarySession.ts",
"src/cutter/**",
@@ -23,6 +24,7 @@ export const SCOPES = {
"tests/composition/analysis-sessions/binarySession.test.ts",
"tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts",
"tests/boundary/filesystem/reverseToolsMutationConfig.test.ts",
"tests/boundary/filesystem/mutationWorkflow.test.ts",
"src/server/registerRizinDebugTools.ts",
"tests/boundary/providers/CutterBridge*.test.ts",
"tests/boundary/providers/cutterBridgeProducer.test.ts",
@@ -12,6 +12,20 @@ import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
import { runScenarioCli } from "./browser-scenario-verifier.mjs";
import { verifyScenarioPolicySetup } from "./browser-scenario-service-worker-setup-e2e.mjs";
const diagnosticError = (error) =>
error instanceof Error
? {
name: error.name,
message: error.message.slice(0, 1_000),
reason: error.reason,
stack: error.stack?.slice(0, 2_000),
cause:
error.cause instanceof Error
? error.cause.stack?.slice(0, 3_000)
: typeof error.cause,
}
: { kind: typeof error };
const startSite = async () => {
let otherOrigin;
const server = createServer((request, response) => {
@@ -83,6 +97,7 @@ export async function verifyScenarioServiceWorkers(endpoint, targetId) {
first.setOtherOrigin(second.origin);
second.setOtherOrigin(first.origin);
const observer = await chromium.connectOverCDP(endpoint);
console.log("Service-worker fixture browser:", observer.version());
let provider = createBrowserScenarioProvider(process.env);
let session;
let failure;
@@ -154,7 +169,42 @@ export async function verifyScenarioServiceWorkers(endpoint, targetId) {
state: "visible",
},
]);
const cleanCapture = (await runScenarioCli(clean)).normalized_result;
let cleanCapture;
try {
cleanCapture = (await runScenarioCli(clean)).normalized_result;
} catch (cause) {
// Retain the original failure. One bounded replay navigates only this
// verifier-owned target and exposes causes the CLI does not serialize.
const state = await page
.locator("#state")
.evaluate(
(element) => ({
text: element.textContent?.slice(0, 100),
blocked: element.getAttribute("data-blocked"),
nativeRegister: navigator.serviceWorker.register
.toString()
.includes("[native code]"),
}),
undefined,
{ timeout: 1_000 },
)
.catch((error) => ({ unavailable: String(error).slice(0, 500) }));
console.error("Service-worker CLI failure state:", {
url: page.url(),
state,
});
const replay = await provider
.captureScenario(clean, { signal: AbortSignal.timeout(5_000) })
.then((result) =>
result.ok ? { ok: true } : diagnosticError(result.error),
)
.catch(diagnosticError);
console.error(
"Service-worker diagnostic replay (original CLI failure retained):",
replay,
);
throw cause;
}
assert.ok(cleanCapture.steps.every(({ status }) => status === "completed"));
assert.equal(cleanCapture.browser.cleanup, "disconnected-external");
await assertRestored();
+2 -2
View File
@@ -98,9 +98,9 @@ export async function readReleaseHistory(run, base, target) {
return { base: baseSha, target: targetSha, commits };
}
// REA history maps Anthropic and Cursor's agent trailers to ordinary User actors.
// GitHub maps these coding-agent trailers to ordinary User actors.
// Keep those source identities in the inventory while excluding automation credit.
const AGENT_ACCOUNTS = new Set(["claude", "cursoragent"]);
const AGENT_ACCOUNTS = new Set(["claude", "cursoragent", "codex"]);
// REA's maintainer requested external-contributor thanks without self-credit.
const MAINTAINER_ACCOUNTS = new Set(["morluto"]);
const human = (value) =>
+2 -2
View File
@@ -8,13 +8,13 @@
"url": "https://github.com/morluto/rea",
"source": "github"
},
"version": "6.4.0",
"version": "6.5.0",
"packages": [
{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "rea-agents",
"version": "6.4.0",
"version": "6.5.0",
"runtimeHint": "npx",
"transport": {
"type": "stdio"
+4
View File
@@ -223,6 +223,10 @@ export const parseBinaryTarget = async (
}
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) return err(cause);
if (signal?.aborted === true && cause === signal.reason)
return err(
new AnalysisCancelledError("resolve_binary_target", { cause }),
);
if (cause instanceof BinaryTargetError) return err(cause);
if (!(cause instanceof Error)) throw cause;
const failure = classifyBinaryTargetFilesystemFailure(candidate, cause);
+3 -4
View File
@@ -108,8 +108,9 @@ export interface DoctorHost {
/** Validated provider settings preserved by setup, independent of readiness. */
providerRegistrationEnvironment?(): Readonly<Record<string, string>>;
installationPaths?(): Promise<readonly string[]>;
/** Undefined checks installed skill roots; explicit selections stay scoped. */
installedSkillIdentity?(
clientIds: readonly string[],
clientIds: readonly string[] | undefined,
): Promise<InstalledSkillIdentity | undefined>;
close?(): Promise<string | undefined>;
clientRegistrations?(): Promise<readonly ClientRegistrationStatus[]>;
@@ -292,9 +293,7 @@ const collectDoctorIdentity = async (
const registrations = (await host.clientRegistrations?.()) ?? [];
const skillClientIds =
scope.skill_clients ??
(scope.mode === "audit-wide"
? registrations.map(({ client }) => client)
: scope.clients);
(scope.clients.length > 0 ? scope.clients : undefined);
const installedSkillIdentity =
await host.installedSkillIdentity?.(skillClientIds);
const skillAligned = skillIdentityAligned(installedSkillIdentity);
+22 -7
View File
@@ -1,4 +1,4 @@
import { realpath } from "node:fs/promises";
import { realpath, stat } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { stripComments } from "jsonc-parser";
import { z } from "zod";
@@ -149,12 +149,27 @@ export const readClientPolicyBlock = async (
".gemini",
"settings.json",
);
const document = await readConfigurationFile(path, "json", resources);
if (!document.ok) return document;
if (document.value !== undefined) {
const trusted = await workspaceTrusted(settings, sources, resources);
if (!trusted.ok) return trusted;
if (trusted.value) {
// Only ENOENT proves absence; preserve other metadata failures as unreadable.
const present = await stat(path).then(
() => ok(true),
(cause: unknown) =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT"
? ok(false)
: err({
kind: "unreadable" as const,
path,
detail: `Configuration ${path} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`,
}),
);
if (!present.ok) return present;
const includeWorkspace = present.value
? await workspaceTrusted(settings, sources, resources)
: ok(false);
if (!includeWorkspace.ok) return includeWorkspace;
if (includeWorkspace.value) {
const document = await readConfigurationFile(path, "json", resources);
if (!document.ok) return document;
if (document.value !== undefined) {
const source = { path, document: document.value };
const systemIndex = sources.findIndex(
(item) => item.path === settings.systemPath,
@@ -2,6 +2,7 @@ import { z } from "zod";
import { AnalysisProtocolError } from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { NonCanonicalEvidenceBundleError } from "../domain/evidenceBundle.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js";
import {
@@ -45,6 +46,8 @@ export const buildReconstructionObligationLedgerEvidenceValidated = (
});
return ok(createLedgerEvidence(input, ledger));
} catch (cause: unknown) {
if (cause instanceof NonCanonicalEvidenceBundleError)
return err(workflowInputError(OPERATION, cause));
if (cause instanceof z.ZodError)
return err(workflowInputError(OPERATION, cause));
return err(
+37 -11
View File
@@ -361,6 +361,7 @@ const canonicalSkillFiles = async (
interface CanonicalSkillInspection {
readonly canonical: boolean;
readonly manifest: string | undefined;
readonly installedDestination: boolean;
}
interface SkillInspectionFailure {
@@ -372,7 +373,7 @@ interface SkillInspectionFailure {
const inspectCanonicalSkill = async (
home: string,
clientIds: readonly string[],
clientIds: readonly string[] | undefined,
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
resources?: ArtifactResourceScope,
@@ -383,7 +384,10 @@ const inspectCanonicalSkill = async (
environment,
platform,
);
const issues = [...(await skillDirectoryIssues(destinations))];
const issues =
clientIds === undefined
? []
: [...(await skillDirectoryIssues(destinations))];
if (issues.length > 0)
return err({
_tag: "SkillInspectionFailure",
@@ -394,7 +398,21 @@ const inspectCanonicalSkill = async (
let canonical = true;
let manifest: string | undefined;
let failureCause: unknown;
for (const { path: root } of destinations) {
let installedDestination = false;
for (const destination of destinations) {
const { path: root } = destination;
const pathIssues =
clientIds === undefined ? await skillDirectoryIssues([destination]) : [];
if (pathIssues.length > 0) {
issues.push(...pathIssues);
if (failureCause === undefined) failureCause = pathIssues;
installedDestination = true;
canonical = false;
continue;
}
let rootCanonical = true;
let rootHasFiles = false;
const rootIssues: SkillPathIssue[] = [];
for (const relativePath of MANAGED_SKILL_FILES) {
const destination = join(root, relativePath);
let original: SkillFileState | undefined;
@@ -406,27 +424,34 @@ const inspectCanonicalSkill = async (
cause instanceof ArtifactReaderFailure
)
throw cause;
issues.push({
rootIssues.push({
path: destination,
kind: "unreadable",
detail: causeReason(cause),
});
if (issues.length === 1) failureCause = cause;
canonical = false;
if (failureCause === undefined) failureCause = cause;
rootCanonical = false;
rootHasFiles = true;
continue;
}
if (original !== undefined) rootHasFiles = true;
if (relativePath === "SKILL.md" && manifest === undefined)
manifest = original?.bytes.toString("utf8");
// A mismatch settles freshness, but not coverage of later read failures.
if (original === undefined) canonical = false;
else if (canonical) {
if (original === undefined) rootCanonical = false;
else if (rootCanonical) {
const content = await canonicalSkillContent(relativePath);
canonical = original.bytes.equals(Buffer.from(content, "utf8"));
rootCanonical = original.bytes.equals(Buffer.from(content, "utf8"));
}
}
if (clientIds !== undefined || rootHasFiles || rootIssues.length > 0) {
installedDestination = true;
canonical = canonical && rootCanonical;
issues.push(...rootIssues);
}
}
return issues.length === 0
? ok({ canonical, manifest })
? ok({ canonical, manifest, installedDestination })
: err({
_tag: "SkillInspectionFailure",
issues,
@@ -604,7 +629,7 @@ export const installCanonicalSkill = async (
/** Read installed skill metadata and compare every managed location. */
export const readInstalledSkillIdentity = async (
home: string,
clientIds: readonly string[],
clientIds: readonly string[] | undefined,
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
resources?: ArtifactResourceScope,
@@ -616,6 +641,7 @@ export const readInstalledSkillIdentity = async (
platform,
resources,
);
if (inspection.ok && !inspection.value.installedDestination) return undefined;
const content = inspection.ok
? inspection.value.manifest
: inspection.error.manifest;
@@ -33,7 +33,9 @@ describe("inert keyed archive decoding", () => {
decodeKeyedArchiveBytes(bytes, { offset: 0, limit: 1 }),
).toThrow(
expect.objectContaining({
code: "ERR_ENCODING_INVALID_ENCODED_DATA",
name: "XmlPlistTextDecodeError",
reason: "invalid-bytes",
cause: expect.any(TypeError),
}),
);
}
+2 -1
View File
@@ -9,7 +9,8 @@ export class CaptureFormatError extends Error {
| "limit",
message: string,
readonly pointer = "",
cause?: unknown,
) {
super(message);
super(message, cause === undefined ? undefined : { cause });
}
}
+44
View File
@@ -2,6 +2,50 @@ import { expect, it } from "vitest";
import { CaptureFormatError } from "./CaptureFormatError.js";
import { decodeSazMetadata } from "./SazMetadata.js";
it.each([
["UTF-8", Buffer.from('<?xml version="1.0" encoding="UTF-8"?><Session/>')],
[
"BOM-marked UTF-16LE",
Buffer.concat([
Buffer.from([0xff, 0xfe]),
Buffer.from(
'<?xml version="1.0" encoding="UTF-16"?><Session/>',
"utf16le",
),
]),
],
] as const)("accepts matching %s XML declarations", (_label, bytes) => {
expect(() => decodeSazMetadata(bytes)).not.toThrow();
});
it.each([
[
"UTF-16 declaration on UTF-8 bytes",
Buffer.from('<?xml version="1.0" encoding="UTF-16"?><Session/>'),
"format",
],
[
"UTF-8 declaration on UTF-16 bytes",
Buffer.concat([
Buffer.from([0xff, 0xfe]),
Buffer.from(
'<?xml version="1.0" encoding="UTF-8"?><Session/>',
"utf16le",
),
]),
"format",
],
[
"unsupported declared encoding",
Buffer.from('<?xml version="1.0" encoding="ISO-8859-1"?><Session/>'),
"unsupported",
],
] as const)("rejects %s", (_label, bytes, reason) => {
expect(() => decodeSazMetadata(bytes)).toThrow(
expect.objectContaining({ reason }),
);
});
it("retains U+FFFD in well-formed metadata as an ordinary XML character", () => {
const decoded = JSON.stringify(
decodeSazMetadata(
+28 -8
View File
@@ -2,23 +2,43 @@ import { DOMParser, ParseError, type Node } from "@xmldom/xmldom";
import { SaxesParser } from "saxes";
import type { JsonValue } from "../../domain/jsonValue.js";
import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js";
import {
decodeXmlPlistText,
XmlPlistTextDecodeError,
} from "../../domain/propertyListXmlText.js";
import { CaptureFormatError } from "./CaptureFormatError.js";
/** Preserve XML node order, attribute names and text without interpreting producer-specific flags. */
export const decodeSazMetadata = (bytes: Buffer): JsonValue => {
let xml: string;
try {
const encoding =
bytes[0] === 0xff && bytes[1] === 0xfe
? "utf-16le"
: bytes[0] === 0xfe && bytes[1] === 0xff
? "utf-16be"
: "utf-8";
xml = new TextDecoder(encoding, { fatal: true }).decode(bytes);
} catch {
xml = decodeXmlPlistText(bytes);
} catch (cause: unknown) {
if (
cause instanceof XmlPlistTextDecodeError &&
cause.reason === "unsupported-encoding"
)
throw new CaptureFormatError(
"unsupported",
`SAZ metadata declares unsupported XML encoding ${cause.declaredEncoding}.`,
"",
cause,
);
if (
cause instanceof XmlPlistTextDecodeError &&
cause.reason === "encoding-mismatch"
)
throw new CaptureFormatError(
"format",
"SAZ metadata encoding declaration disagrees with its bytes.",
"",
cause,
);
throw new CaptureFormatError(
"unsupported",
"SAZ metadata must use UTF-8 or BOM-marked UTF-16.",
"",
cause,
);
}
validateSazXml(xml);
+2
View File
@@ -16,6 +16,7 @@ import {
providerStartupTimeoutSchema,
} from "./providerStartupTimeout.js";
import { mcpInputSchemaProfileSchema } from "./mcpInputSchemaProfile.js";
import { mcpOutputSchemaProfileSchema } from "./mcpOutputSchemaProfile.js";
import { mcpResponseBudgetSchema } from "./mcpResponseBudget.js";
import { parseLoaderArgs, parseStringArray } from "./parsers.js";
@@ -23,6 +24,7 @@ const environmentSchema = z.object({
REA_ANALYSIS_PROVIDER: analysisProviderSelectorSchema.default("auto"),
REA_MCP_MAX_RESPONSE_BYTES: mcpResponseBudgetSchema.optional(),
REA_MCP_INPUT_SCHEMA_PROFILE: mcpInputSchemaProfileSchema.optional(),
REA_MCP_OUTPUT_SCHEMA_PROFILE: mcpOutputSchemaProfileSchema.optional(),
REA_IDA_MCP_CONFIG: z
.string()
.min(1)
@@ -116,7 +116,7 @@ export const MANAGED_WORKFLOW_TOOL_CONTRACTS = [
name: "verify_managed_native_boundaries",
...toolContractMetadata("verify_managed_native_boundaries"),
description:
"Verify managed P/Invoke/native-boundary declarations against authenticated native export or function Evidence without executing managed code or translating managed metadata tokens into native addresses. The workflow preserves declaration-only, verified, inferred, contradicted, and unresolved states.",
"Verify managed P/Invoke/native-boundary declarations against authenticated native export or function Evidence without executing managed code or translating managed metadata tokens into native addresses. The workflow preserves declaration-only, verified, inferred, contradicted, and unresolved states; a name candidate with missing managed or native module identity remains unresolved with its candidate evidence retained.",
kind: "application",
inputSchema: managedNativeVerificationInputSchema,
outputSchema: nativeVerificationOutputSchema,
+59
View File
@@ -539,6 +539,65 @@ describe("browser scenario network comparison stability", () => {
});
});
describe("scenario comparison recovery guidance", () => {
it("retains network guidance beside a changed console event", () => {
const before = scenarioCapture({});
const afterBase = scenarioCapture({});
const consoleEvent = afterBase.events.items[0];
if (consoleEvent?.kind !== "console")
throw new Error("Expected console fixture");
const after = browserScenarioCaptureSchema.parse({
...afterBase,
scenario: {
...afterBase.scenario,
network_content: {
...afterBase.scenario.network_content,
response_body: true,
},
},
events: {
...afterBase.events,
items: [{ ...consoleEvent, text: "settings-ready-again" }],
},
});
const compare = (left: typeof before, right: typeof before) =>
compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: left,
after_scenario: right,
}),
);
const changed = compare(before, after);
expect(changed.overall_status).toBe("changed");
expect(changed.steps.flatMap((step) => step.artifact_diffs)).toContainEqual(
expect.objectContaining({
artifact: "events",
status: "changed",
reason: expect.stringContaining("Network content selections differ"),
}),
);
const sameSelection = compare(before, scenarioCapture({}));
expect(sameSelection.overall_status).toBe("unchanged");
expect(
sameSelection.steps.flatMap((step) => step.artifact_diffs),
).not.toContainEqual(expect.objectContaining({ artifact: "events" }));
const quietMismatch = compare(
before,
browserScenarioCaptureSchema.parse({ ...after, events: before.events }),
);
expect(
quietMismatch.steps.flatMap((step) => step.artifact_diffs),
).toContainEqual(
expect.objectContaining({
artifact: "events",
status: "unknown",
reason: expect.stringContaining("Network content selections differ"),
}),
);
});
});
describe("browser scenario comparison", () => {
it("records canonical literal normalization and ignores declared volatile fields", () => {
const before = scenarioCapture({
+1 -1
View File
@@ -425,7 +425,7 @@ const compareScenarioEvents = (
after_state: afterState,
before_sha256: beforeSha256,
after_sha256: afterSha256,
reason: null,
reason: input.incomparableReason ?? null,
},
};
if (
+33
View File
@@ -76,6 +76,39 @@ describe("bundle comparison explicit cross-pairs", () => {
});
describe("bundle comparison", () => {
it("does not report display-only bundle differences as unexplained changes", () => {
const target = {
path: "/left/artifact.elf",
sha256: "a".repeat(64),
format: "elf" as const,
architecture: "x86_64" as const,
};
const record = createEvidence(target, PROVIDER, {
operation: "observe",
parameters: {},
result: { observed: true },
confidence: "observed",
authority: "shipped-artifact",
});
const left = createEvidenceBundle([record]);
const rightRecord = {
...record,
subject: {
...record.subject!,
name: "renamed.elf",
local_path: "/right/renamed.elf",
},
};
const right = { ...left, records: [rightRecord] };
const result = compareBundles(left, right);
expect(result).toMatchObject({
status: "unchanged",
summary: { records_unchanged: 1, unresolved: 0 },
changes: [],
});
expect(result.left_bundle_sha256).not.toBe(result.right_bundle_sha256);
});
it("accepts long caller-selected bundle paths", () => {
const path = `/${"deep/".repeat(1_000)}bundle.json`;
expect(
+1 -5
View File
@@ -102,11 +102,7 @@ export const compareBundles = (
).length;
return bundleComparisonResultSchema.parse({
status:
leftDigest === rightDigest && changes.length === 0
? "unchanged"
: unresolved > 0
? "unknown"
: "changed",
unresolved > 0 ? "unknown" : changes.length > 0 ? "changed" : "unchanged",
left_bundle_sha256: leftDigest,
right_bundle_sha256: rightDigest,
summary: {
+18
View File
@@ -5,6 +5,7 @@ import { ANALYSIS_SNAPSHOT_TARGET } from "./analysisSnapshot.fixture.js";
import {
createEvidenceBundle,
createImmutableEvidenceBundle,
NonCanonicalEvidenceBundleError,
describeEvidenceBundleFailure,
evidenceBundleForTarget,
parseEvidenceBundle,
@@ -20,6 +21,23 @@ const targetDigest = "a".repeat(64);
const foreignDigest = "b".repeat(64);
describe("evidence bundle failure classification", () => {
it("tags valid-shaped bundles that fail canonical ordering", () => {
const records = ["first", "second"].map((label) =>
createEvidence(undefined, provider, {
operation: "observe",
parameters: { label },
result: { label },
}),
);
const canonical = createEvidenceBundle(records);
expect(() =>
parseEvidenceBundle({
...canonical,
records: [...canonical.records].reverse(),
}),
).toThrow(NonCanonicalEvidenceBundleError);
});
it("treats only authenticated standalone Evidence as the wrong input kind", () => {
const record = createEvidence(undefined, provider, {
operation: "observe",
+9 -1
View File
@@ -52,6 +52,14 @@ export const evidenceBundleSchema = z.strictObject({
export type EvidenceBundle = z.infer<typeof evidenceBundleSchema>;
const immutableBundles = new WeakMap<object, EvidenceBundle>();
/** Caller input has valid shape but is not in authenticated canonical form. */
export class NonCanonicalEvidenceBundleError extends TypeError {
constructor() {
super("Evidence bundle manifests are not canonical");
this.name = "NonCanonicalEvidenceBundleError";
}
}
/** Build and authenticate a sealed bundle while reusing immutable Evidence records. */
export const createImmutableEvidenceBundle = (
records: readonly Evidence[],
@@ -307,7 +315,7 @@ const authenticateBundle = (
parsed.unknowns,
);
if (JSON.stringify(parsed) !== JSON.stringify(canonical))
throw new TypeError("Evidence bundle manifests are not canonical");
throw new NonCanonicalEvidenceBundleError();
if (seal) {
freezeJsonSnapshot(canonical);
immutableBundles.set(canonical, canonical);
@@ -108,6 +108,32 @@ export const collectElectronWrappedCallPaths = (
],
};
};
const sequentialFallthrough = (
statement: t.Statement,
env: SymbolicEnvironment,
context: PathContext,
): { readonly continues: boolean; readonly context: PathContext } => {
if (symbolicDefiniteExit(statement)) return { continues: false, context };
if (!t.isIfStatement(statement)) return { continues: true, context };
const truth = symbolicTruth(resolver.evaluate(statement.test, env));
if (truth !== undefined)
return symbolicDefiniteExit(
truth ? statement.consequent : statement.alternate,
)
? { continues: false, context }
: { continues: true, context };
if (symbolicDefiniteExit(statement.consequent))
return {
continues: true,
context: condition(context, statement.test, false),
};
if (symbolicDefiniteExit(statement.alternate))
return {
continues: true,
context: condition(context, statement.test),
};
return { continues: true, context };
};
const metadataContext = (
context: PathContext,
value: SymbolicValue,
@@ -534,21 +560,9 @@ export const collectElectronWrappedCallPaths = (
let next = context;
for (const statement of node.body) {
walk(statement, env, next);
if (symbolicDefiniteExit(statement)) break;
if (t.isIfStatement(statement)) {
const truth = symbolicTruth(resolver.evaluate(statement.test, env));
if (truth !== undefined) {
if (
symbolicDefiniteExit(
truth ? statement.consequent : statement.alternate,
)
)
break;
} else if (symbolicDefiniteExit(statement.consequent))
next = condition(next, statement.test, false);
else if (symbolicDefiniteExit(statement.alternate))
next = condition(next, statement.test);
}
const fallthrough = sequentialFallthrough(statement, env, next);
if (!fallthrough.continues) break;
next = fallthrough.context;
}
return;
}
@@ -639,13 +653,17 @@ export const collectElectronWrappedCallPaths = (
const index = parent.body.findIndex(
(statement) => statement === child,
);
if (
index >= 0 &&
parent.body
.slice(0, index)
.some((statement) => symbolicDefiniteExit(statement))
)
return;
if (index >= 0) {
for (const statement of parent.body.slice(0, index)) {
const fallthrough = sequentialFallthrough(
statement,
env,
context,
);
if (!fallthrough.continues) return;
context = fallthrough.context;
}
}
}
}
call(node, env, context, isolatedSeed?.callee);
@@ -165,7 +165,6 @@ describe("feature seed selection controls", () => {
{ kind: "module", nodeKind: "javascript-module", field: "original_source" },
{ kind: "native-export", nodeKind: "native-export", field: "label" },
{ kind: "native-export", nodeKind: "native-export", field: "key" },
{ kind: "route", nodeKind: "endpoint", field: "label" },
{ kind: "route", nodeKind: "endpoint", field: "value" },
{ kind: "route", nodeKind: "endpoint", field: "path" },
{ kind: "channel", nodeKind: "ipc-channel", field: "label" },
@@ -192,6 +191,30 @@ describe("feature seed selection controls", () => {
},
);
it("excludes route labels while preserving generic scalar and array matching", () => {
const scalar = memberNode("endpoint", {
endpoint_kind: "route",
label: "route endpoint",
});
const array = memberNode("endpoint", {
endpoint_kind: "route",
label: ["route endpoint"],
});
expect(
findApplicationFeatureSeeds([scalar], seed("route", "route endpoint")),
).toEqual([]);
expect(
findApplicationFeatureSeeds([array], seed("route", "route endpoint")),
).toEqual([]);
expect(
findApplicationFeatureSeeds([scalar], seed("string", "route endpoint")),
).toEqual([propertyMatch(scalar, "label")]);
expect(
findApplicationFeatureSeeds([array], seed("string", "route endpoint")),
).toEqual([propertyMatch(array, "label[0]")]);
});
it("keeps generic matching and scalar label and identity selection", () => {
const node = memberNode("context-bridge-api", { members: ["openProject"] });
expect(findApplicationFeatureSeeds([node], seed("string"))).toEqual([
+23 -17
View File
@@ -40,17 +40,9 @@ const seedMatch = (
node: ApplicationNode,
seed: ApplicationFeatureSeed,
): ApplicationFeatureSeedMatch[] => {
if (seed.kind === "node-id")
return node.node_id === seed.value
? [
{
node_id: node.node_id,
kind: node.kind,
basis: "node-id",
field: "node_id",
},
]
: [];
const mode = featureSeedMatchMode(seed);
const caseSensitive = seed.case_sensitive;
const query = normalized(seed.value, caseSensitive);
if (!kindMatches(node, seed.kind)) return [];
const requestedEntryPath =
seed.kind === "api" && "entry_path" in seed ? seed.entry_path : undefined;
@@ -69,9 +61,6 @@ const seedMatch = (
})
)
return [];
const mode = featureSeedMatchMode(seed);
const caseSensitive = seed.case_sensitive;
const query = normalized(seed.value, caseSensitive);
const match = candidateFields(node, seed.kind).find((candidate) => {
const value = normalized(candidate.value, caseSensitive);
return mode === "exact" ? value === query : value.includes(query);
@@ -90,9 +79,9 @@ const seedMatch = (
const kindMatches = (
node: ApplicationNode,
kind: Exclude<ApplicationFeatureSeed["kind"], "node-id">,
kind: ApplicationFeatureSeed["kind"],
): boolean => {
if (kind === "string") return true;
if (kind === "string" || kind === "node-id") return true;
if (kind === "route")
return (
node.kind === "endpoint" &&
@@ -116,8 +105,25 @@ const kindMatches = (
const candidateFields = (
node: ApplicationNode,
seedKind: Exclude<ApplicationFeatureSeed["kind"], "node-id">,
seedKind: ApplicationFeatureSeed["kind"],
): CandidateField[] => {
if (seedKind === "node-id")
return [{ basis: "node-id", field: "node_id", value: node.node_id }];
if (seedKind === "route")
return node.observations.flatMap((observation, index) =>
(["value", "path"] as const).flatMap((key) => {
const value = observation.properties[key];
return typeof value === "string"
? [
{
basis: "property" as const,
field: `observations[${String(index)}].properties.${key}`,
value,
},
]
: [];
}),
);
const fields: CandidateField[] = [
...identityFields(node),
...node.observations.flatMap((observation, index) => [
@@ -46,6 +46,7 @@ const classifyLayer = (
): { readonly states: JavaScriptStaticLoadState[] } => {
const nodes = layer.graph.nodes.filter(isLoadStateNode);
const direct = new Map<string, string[]>();
const ambiguous = new Set<string>();
for (const match of matches)
if (
match.status === "matched" &&
@@ -53,6 +54,10 @@ const classifyLayer = (
match.static_node_id !== null
)
append(direct, match.static_node_id, match.runtime_node_id);
else if (match.status === "ambiguous")
for (const candidate of match.candidate_static_nodes)
if (candidate.static_layer_id === layer.layerId)
ambiguous.add(candidate.static_node_id);
const resident = residentNodes(layer, new Set(direct.keys()));
const runtimeScope = createStaticRuntimeScope(layer, runtimeEntities);
const scopedEntities = runtimeEntities.filter(
@@ -77,6 +82,12 @@ const classifyLayer = (
runtimeNodeIds: [],
reason: "containing-asset-was-loaded",
});
if (ambiguous.has(node.node_id))
return state(layer, node, {
status: "unknown",
runtimeNodeIds: [],
reason: "ambiguous-runtime-candidate",
});
const nodeWithinScope = staticNodeWithinRuntimeScope(node, runtimeScope);
if (layerComplete && nodeWithinScope)
return state(layer, node, {
@@ -228,6 +228,7 @@ const staticLoadStateSchema = z.strictObject({
"not-observed-in-bounded-capture",
"layer-outside-runtime-scope",
"static-or-runtime-coverage-incomplete",
"ambiguous-runtime-candidate",
]),
});
@@ -62,6 +62,23 @@ describe("TypeScript import-equals links", () => {
});
describe("CommonJS wrapper parameter redeclarations", () => {
it("does not infer a CommonJS wrapper from resolution mode in an explicit module", () => {
const path = "app/main.mjs";
const parsed = parseJavaScriptSource(
'var require; require("./dependency.cjs");',
path,
);
expect(parsed).not.toBeNull();
expect(parsed?.program.sourceType).toBe("module");
const ir = analyzeParsedJavaScriptSemantics(
parsed!,
moduleResolutionModeForJavaScriptReference(path, "static-import"),
);
expect(ir.moduleLinks).not.toContainEqual(
expect.objectContaining({ specifier: "./dependency.cjs" }),
);
});
it("retains ambient exports and require through uninitialized top-level vars", () => {
const path = "app/main.cjs";
const parsed = parseJavaScriptSource(
@@ -687,6 +687,7 @@ const createState = (
moduleLinks: [],
linkedRequireCalls: new WeakSet(),
moduleLinkBindings: new WeakMap(),
defaultExportExpressions: new WeakMap(),
conditionallySkippedExpressions: new WeakSet(),
requiredModuleEffects: new Map(),
ambientBindingWrites: new Map(),
@@ -102,7 +102,7 @@ export const collectSemanticModuleLink = (
kind: "export",
specifier: null,
importedName: null,
localName: defaultDeclarationName(node.declaration),
localName: defaultExportLocalName(node.declaration),
exportedName: "default",
callableId: semanticCallableIdForNode(node.declaration),
location: range(node),
@@ -557,6 +557,11 @@ const addModuleLink = (
callableId: link.callableId ?? null,
};
state.moduleLinks.push(collected);
if (
t.isExportDefaultDeclaration(referenceNode) &&
t.isExpression(referenceNode.declaration)
)
state.defaultExportExpressions.set(collected, referenceNode.declaration);
if (link.localName !== null) {
const binding = resolveSemanticBindingState(
state,
@@ -816,13 +821,16 @@ const declarationNames = (declaration: t.Declaration): string[] => {
return [];
};
const defaultDeclarationName = (
const defaultExportLocalName = (
declaration: t.ExportDefaultDeclaration["declaration"],
): string | null =>
(t.isFunctionDeclaration(declaration) || t.isClassDeclaration(declaration)) &&
t.isIdentifier(declaration.id)
? declaration.id.name
): string | null => {
const value = unwrapJavaScriptExpression(declaration).node;
if (t.isIdentifier(value)) return value.name;
return (t.isFunctionDeclaration(value) || t.isClassDeclaration(value)) &&
t.isIdentifier(value.id)
? value.id.name
: null;
};
const commonJsExportName = (
node: t.Node,
@@ -28,6 +28,7 @@ import { evaluateSemanticExpression } from "./javascriptSemanticValues.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
import { semanticReturnCoverage } from "./javascriptSemanticCoverage.js";
import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js";
import { semanticReadInitializers } from "./javascriptSemanticMutationInitializers.js";
interface ReturnExpression {
readonly node: t.Node | null;
@@ -104,15 +105,18 @@ export const resolveSemanticModuleCallables = (
if (link.kind === "type-import") return { ...link, callableId: null };
// Named exports follow their lexical binding, including every assignment.
// A declaration's ID or a callable's display name cannot prove that link.
if (link.localName === null)
const captured = state.defaultExportExpressions.get(link);
if (link.localName === null && captured === undefined)
return link.callableId !== null && callableIds.has(link.callableId)
? link
: { ...link, callableId: null };
const binding = state.moduleLinkBindings.get(link);
const resolved =
binding === undefined
? []
: callableIdsForBinding(binding, state, callableIds, new Set());
captured !== undefined
? callableIdsForNode(captured, state, callableIds, new Set(), true)
: binding === undefined
? []
: callableIdsForBinding(binding, state, callableIds, new Set());
const provenance =
binding === undefined || link.specifier !== null
? undefined
@@ -139,21 +143,29 @@ const callableIdsForBinding = (
state: JavaScriptSemanticAnalysisState,
admitted: ReadonlySet<string>,
seen: ReadonlySet<string>,
read?: t.Node,
): string[] => {
// A single initializer does not prove an assignment executes or replace
// the unknown incoming value of a parameter/catch binding.
if (
seen.has(binding.bindingId) ||
binding.initializers.length !== 1 ||
(read === undefined && binding.initializers.length !== 1) ||
binding.definitions.some(
({ kind }) =>
kind === "assignment" || kind === "parameter" || kind === "catch",
(read === undefined && kind === "assignment") ||
kind === "parameter" ||
kind === "catch",
)
)
return [];
const initializer = binding.initializers[0];
const selected =
read === undefined
? binding
: semanticReadInitializers(binding, read, state.parentsByNode);
const initializer = selected.initializers[0];
if (
initializer === undefined ||
selected.initializers.length !== 1 ||
initializer.projection.length > 0 ||
state.conditionallySkippedExpressions.has(initializer.node)
)
@@ -163,6 +175,7 @@ const callableIdsForBinding = (
state,
admitted,
new Set([...seen, binding.bindingId]),
read !== undefined,
);
};
@@ -171,17 +184,24 @@ const callableIdsForNode = (
state: JavaScriptSemanticAnalysisState,
admitted: ReadonlySet<string>,
seen: ReadonlySet<string>,
snapshot = false,
): string[] => {
const unwrapped = unwrapJavaScriptExpression(node).node;
if (unwrapped !== node)
return callableIdsForNode(unwrapped, state, admitted, seen);
return callableIdsForNode(unwrapped, state, admitted, seen, snapshot);
const direct = semanticCallableIdForNode(node);
if (direct !== null && admitted.has(direct)) return [direct];
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
return binding === undefined
? []
: callableIdsForBinding(binding, state, admitted, seen);
: callableIdsForBinding(
binding,
state,
admitted,
seen,
snapshot ? node : undefined,
);
}
return [];
};
@@ -94,6 +94,11 @@ export interface JavaScriptSemanticAnalysisState {
JavaScriptSemanticModuleLink,
JavaScriptSemanticBindingState
>;
/** Default expressions capture their value at the export statement. */
readonly defaultExportExpressions: WeakMap<
JavaScriptSemanticModuleLink,
t.Node
>;
/** Initializer expressions whose write can be skipped by enclosing control flow. */
readonly conditionallySkippedExpressions: WeakSet<t.Node>;
/** Unbound identifier writes retain their source sites for capture ordering. */
@@ -263,8 +268,10 @@ export const isUnmodifiedCommonJsWrapperBinding = (
name: string,
state: JavaScriptSemanticAnalysisState,
): boolean => {
// Parser sourceType separates explicit modules from script/CommonJS source;
// specifier resolution mode does not establish that distinction.
if (
state.staticImportResolutionMode !== "require" ||
state.program.sourceType !== "script" ||
!["module", "exports", "require"].includes(name) ||
!t.isIdentifier(node, { name })
)
@@ -87,9 +87,10 @@ const keyMethod = (item: Method): Keyed<Method> => ({
item.body.max_stack,
item.body.init_locals,
item.body.opcode_counts,
item.body.anchors.map(({ opcode, operand_kind }) => [
item.body.anchors.map(({ opcode, operand_kind, operand }) => [
opcode,
operand_kind,
operand_kind === "constant" ? operand : null,
]),
item.body.exception_regions.map((region) => [
region.flags,
@@ -124,13 +124,21 @@ const moduleCompatible = (
scope: string | null,
symbol: NativeSymbol,
): boolean => {
if (scope === null) return true;
if (scope === null || scope.trim().length === 0) return false;
const expected = normalizeModule(scope);
return [symbol.module_name, symbol.module_path]
.filter((value): value is string => value !== null)
.filter(
(value): value is string =>
value !== null && normalizeModule(value).length > 0,
)
.some((value) => nativeModuleIdentities(value).has(expected));
};
const hasModuleIdentity = (symbol: NativeSymbol): boolean =>
[symbol.module_name, symbol.module_path].some(
(value) => value !== null && normalizeModule(value).length > 0,
);
const candidateNames = (
managed: Pick<
PinvokeImport,
@@ -190,6 +198,9 @@ const matchPinvoke = (
};
const candidates: [NativeSymbol, ...NativeSymbol[]] = [first, ...remaining];
const scopeKnown =
managed.import_scope_name !== null &&
managed.import_scope_name.trim().length > 0;
const exact = candidates.find(
(symbol) =>
managed.import_name === symbol.name &&
@@ -198,17 +209,13 @@ const matchPinvoke = (
const decorated = candidates.find((symbol) =>
moduleCompatible(managed.import_scope_name, symbol),
);
const selected = exact ?? decorated ?? first;
if (!moduleCompatible(managed.import_scope_name, selected))
return {
match: {
status: "contradicted",
basis: "module-mismatch",
confidence: "unknown",
matched_native: selected,
candidates,
},
};
const selected =
exact ??
decorated ??
(scopeKnown
? candidates.find((symbol) => !hasModuleIdentity(symbol))
: undefined) ??
first;
if (exact !== undefined)
return {
match: {
@@ -222,11 +229,31 @@ const matchPinvoke = (
candidates,
},
};
if (decorated !== undefined)
return {
match: {
basis: "decorated-name-candidate",
status: "inferred",
confidence: "inferred",
matched_native: selected,
candidates,
},
};
if (!scopeKnown || !hasModuleIdentity(selected))
return {
match: {
status: "unresolved",
basis: "module-identity-unknown",
confidence: "unknown",
matched_native: selected,
candidates,
},
};
return {
match: {
basis: "decorated-name-candidate",
status: "inferred",
confidence: "inferred",
status: "contradicted",
basis: "module-mismatch",
confidence: "unknown",
matched_native: selected,
candidates,
},
@@ -234,26 +261,31 @@ const matchPinvoke = (
};
const pinvokeLimitations = (
status: PinvokeVerification["status"],
match: PinvokeMatch,
managed: PinvokeImport,
): readonly string[] => [
...(managed.import_scope_name === null
...(managed.import_scope_name === null ||
managed.import_scope_name.trim().length === 0
? ["Managed declaration does not name an import scope/module."]
: []),
...(status === "inferred"
...(match.status === "inferred"
? [
"Native symbol matched a decorated candidate rather than the exact declared import name.",
]
: []),
...(status === "contradicted"
...(match.status === "contradicted"
? [
"A symbol name candidate exists, but the supplied native Evidence module identity does not match the managed import scope.",
]
: []),
...(status === "unresolved"
? [
"No supplied native export/function Evidence matched this declared import.",
]
...(match.status === "unresolved"
? match.basis === "module-identity-unknown"
? [
"A native name candidate exists, but a required module identity is unavailable; module correspondence is unknown.",
]
: [
"No supplied native export/function Evidence matched this declared import.",
]
: []),
];
@@ -283,7 +315,7 @@ export const verifyPinvoke = ({
declaration_verification: item.verification,
};
const selection = matchPinvoke(item, native.symbols, native.accepted);
const limitations = pinvokeLimitations(selection.match.status, item);
const limitations = pinvokeLimitations(selection.match, item);
return {
verification: pinvokeVerificationSchema.parse({
item_id: `mnv_pinvoke_${digest({
@@ -51,7 +51,7 @@ const observedCandidatesSchema = z
.tuple([nativeSymbolSchema])
.rest(nativeSymbolSchema);
export const pinvokeVerificationSchema = z.discriminatedUnion("status", [
export const pinvokeVerificationSchema = z.union([
z.strictObject({
...pinvokeVerificationContextShape,
status: z.literal("verified"),
@@ -76,6 +76,14 @@ export const pinvokeVerificationSchema = z.discriminatedUnion("status", [
matched_native: z.null(),
candidates: emptyArraySchema,
}),
z.strictObject({
...pinvokeVerificationContextShape,
status: z.literal("unresolved"),
basis: z.literal("module-identity-unknown"),
confidence: z.literal("unknown"),
matched_native: nativeSymbolSchema,
candidates: observedCandidatesSchema,
}),
z.strictObject({
...pinvokeVerificationContextShape,
status: z.literal("contradicted"),
+25
View File
@@ -0,0 +1,25 @@
import { expect, it } from "vitest";
import {
decodeXmlPlistText,
XmlPlistTextDecodeError,
} from "./propertyListXmlText.js";
it.each([
[
Buffer.from('<?xml version="1.0" encoding="UTF-16"?><plist/>'),
"encoding-mismatch",
],
[
Buffer.from('<?xml version="1.0" encoding="ISO-8859-1"?><plist/>'),
"unsupported-encoding",
],
[Buffer.from([0xff, 0xfe, 0x3c]), "invalid-bytes"],
] as const)("tags XML decoding failures as %s", (bytes, reason) => {
expect(() => decodeXmlPlistText(bytes)).toThrow(
expect.objectContaining({
name: "XmlPlistTextDecodeError",
reason,
} satisfies Partial<XmlPlistTextDecodeError>),
);
});
+35 -4
View File
@@ -1,4 +1,19 @@
/** Decode BOM-aware XML plist text with fatal byte validation. */
export class XmlPlistTextDecodeError extends TypeError {
constructor(
readonly reason:
| "invalid-bytes"
| "unsupported-encoding"
| "encoding-mismatch",
message: string,
readonly declaredEncoding?: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = "XmlPlistTextDecodeError";
}
}
/** Decode BOM-aware XML plist text and verify any declaration against its bytes. */
export const decodeXmlPlistText = (bytes: Uint8Array): string => {
const encoding =
bytes[0] === 0xff && bytes[1] === 0xfe
@@ -6,7 +21,17 @@ export const decodeXmlPlistText = (bytes: Uint8Array): string => {
: bytes[0] === 0xfe && bytes[1] === 0xff
? "utf-16be"
: "utf-8";
const text = new TextDecoder(encoding, { fatal: true }).decode(bytes);
let text: string;
try {
text = new TextDecoder(encoding, { fatal: true }).decode(bytes);
} catch (cause: unknown) {
throw new XmlPlistTextDecodeError(
"invalid-bytes",
"XML text bytes are invalid for the detected encoding",
undefined,
{ cause },
);
}
const declaration = /^<\?xml\s[^?]*\bencoding\s*=\s*["']([^"']+)["']/u.exec(
text,
)?.[1];
@@ -15,14 +40,20 @@ export const decodeXmlPlistText = (bytes: Uint8Array): string => {
declared !== undefined &&
!["utf-8", "utf-16", "utf-16le", "utf-16be"].includes(declared)
)
throw new TypeError(
throw new XmlPlistTextDecodeError(
"unsupported-encoding",
`Unsupported XML encoding declaration: ${declaration}. Supported encodings are UTF-8 and BOM-marked UTF-16 in either byte order.`,
declaration,
);
if (
declared !== undefined &&
declared !== encoding &&
!(encoding !== "utf-8" && declared === "utf-16")
)
throw new TypeError("XML encoding declaration disagrees with its bytes");
throw new XmlPlistTextDecodeError(
"encoding-mismatch",
"XML encoding declaration disagrees with its bytes",
declaration,
);
return text;
};
+64 -18
View File
@@ -1,5 +1,8 @@
import { digestCanonicalValue } from "./canonicalDigest.js";
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
import {
compareUnicodeCodePoints,
compositeKey,
} from "./unicodeCodePointOrder.js";
import type { WebPageInspection } from "./browserObservationSchemas.js";
import {
webCaptureDiffSchema,
@@ -258,14 +261,34 @@ const sharedNetworkResponseFields = (
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);
retainSharedResponseFields(shared, identity, request);
for (const [index, redirect] of (request.redirects ?? []).entries())
retainSharedResponseFields(
shared,
redirectNetworkIdentity(identity, index),
redirect,
);
}
return shared;
};
const retainSharedResponseFields = (
shared: Map<string, Set<NetworkResponseField>>,
identity: string,
response: Pick<
WebPageInspection["network"]["requests"][number],
NetworkResponseField
>,
): void => {
const fields = shared.get(identity) ?? new Set(networkResponseFields);
for (const field of networkResponseFields)
if (response[field] === null) fields.delete(field);
shared.set(identity, fields);
};
const redirectNetworkIdentity = (identity: string, index: number): string =>
compositeKey([identity, "redirect", index]);
const networkDimension = (
before: WebPageInspection,
after: WebPageInspection,
@@ -359,14 +382,19 @@ const networkDimension = (
"Network capture or selected HTTP/WebSocket payload-shape coverage is attach-window limited, unavailable, or incomplete.",
inventoryComplete,
);
return compared.status === "unchanged" && !responseFieldsComplete
? {
...compared,
status: "unknown",
reason:
"HTTP response status, MIME type, or byte-count evidence is incomplete.",
}
: compared;
if (responseFieldsComplete) return compared;
const responseEvidenceReason =
"HTTP response or redirect status, MIME type, or byte-count evidence is incomplete.";
if (compared.status === "changed") return compared;
return {
status: "unknown",
total_changes: 0,
changes: [],
reason:
compared.reason === null
? responseEvidenceReason
: `${responseEvidenceReason} ${compared.reason}`,
};
};
const networkMap = (
@@ -385,11 +413,29 @@ const networkMap = (
values.push({
...response,
redirects: (request.redirects ?? []).map(
({
request_timestamp: _requestTimestamp,
redirect_event_timestamp: _redirectEventTimestamp,
...redirect
}) => redirect,
(
{
request_timestamp: _requestTimestamp,
redirect_event_timestamp: _redirectEventTimestamp,
...redirect
},
index,
) => {
const response: Partial<
Record<NetworkResponseField, number | string>
> = {};
for (const field of responseFields.get(
redirectNetworkIdentity(identity, index),
) ?? [])
if (redirect[field] !== null) response[field] = redirect[field];
return {
url: redirect.url,
response_url: redirect.response_url,
method: redirect.method,
resource_type: redirect.resource_type,
...response,
};
},
),
initiator: request.initiator,
...(shapes?.request === true
+3
View File
@@ -24,6 +24,7 @@ import {
readManagedMetadataLayout,
type ManagedMetadataLayout,
} from "./ManagedMetadataLayout.js";
import { readTypeMemberListFacts } from "./ManagedTypeMemberLists.js";
import { ManagedReaderFailure } from "./ManagedReaderFailure.js";
const flagNames = (flags: number): string[] => {
@@ -384,10 +385,12 @@ export const inspectManagedArtifactBytes = (
resourceIssues,
);
try {
const memberLists = readTypeMemberListFacts(bytes, metadata);
const inventory = readManagedMetadataInventory(
bytes,
metadata,
resourceDirectory,
memberLists,
);
const issues = [...resourceIssues, ...inventory.issues];
const limitations = [
+168 -1
View File
@@ -11,6 +11,7 @@ import {
type ManagedMetadataLayout,
} from "./ManagedMetadataLayout.js";
import { readManagedPeLayout } from "./ManagedPeReader.js";
import { memberListIndexSize } from "./ManagedMemberPointers.js";
import { edges } from "./ManagedMemberRows.js";
import { parseUserStrings } from "./ManagedUserStrings.js";
import { signature, type ManagedMethod } from "./ManagedMemberInspectorCore.js";
@@ -1224,7 +1225,173 @@ describe("managed derived caller names", () => {
});
});
describe("managed member reference bounds", () => {
describe("managed TypeDef list ownership bounds", () => {
it("retains rows but leaves ownership unknown for zero TypeDef list starts", () => {
const bytes = buildManagedPeFixture();
const pe = readManagedPeLayout(bytes);
if (pe.cli === null) throw new Error("fixture has no CLI header");
const { rva, size } = pe.cli.metadata;
const layout = readManagedMetadataLayout(
bytes,
pe.rvaToOffset(rva, size, "cli.metadata"),
size,
);
const listOffset =
metadataRowOffset(layout, 2, 1) +
4 +
2 * layout.stringIndexSize +
layout.codedIndexSize("TypeDefOrRef");
const listIndexWidth = Math.max(
layout.tableIndexSize(3),
layout.tableIndexSize(4),
layout.tableIndexSize(5),
layout.tableIndexSize(6),
);
const writeIndex = (offset: number) =>
listIndexWidth === 2
? bytes.writeUInt16LE(0, offset)
: bytes.writeUInt32LE(0, offset);
writeIndex(listOffset);
writeIndex(listOffset + listIndexWidth);
const result = inspectManagedMembersBytes(
bytes,
managedPeFixtureTarget(bytes),
);
expect(result.types[0]).toMatchObject({
field_list: { first_row: null, last_row: null, count: 0 },
method_list: { first_row: null, last_row: null, count: 0 },
});
expect(result.fields[0]).toMatchObject({
token: "0x04000001",
name: "counter",
declaring_type: null,
});
expect(result.methods[0]).toMatchObject({
token: "0x06000001",
name: "Main",
declaring_type: null,
});
expect(result.coverage).toMatchObject({
state: "partial",
issues: [
expect.objectContaining({
code: "invalid-row",
detail: expect.stringContaining("TypeDef FieldList start 0"),
}),
expect.objectContaining({
code: "invalid-row",
detail: expect.stringContaining("TypeDef MethodList start 0"),
}),
],
});
});
it("does not infer an earlier owner from an invalid next list endpoint", () => {
const bytes = buildManagedPeFixture({ additionalEmptyTypes: 1 });
const pe = readManagedPeLayout(bytes);
if (pe.cli === null) throw new Error("fixture has no CLI header");
const { rva, size } = pe.cli.metadata;
const layout = readManagedMetadataLayout(
bytes,
pe.rvaToOffset(rva, size, "cli.metadata"),
size,
);
const methodStartOffset =
metadataRowOffset(layout, 2, 2) +
4 +
2 * layout.stringIndexSize +
layout.codedIndexSize("TypeDefOrRef") +
memberListIndexSize(layout, 4);
if (memberListIndexSize(layout, 6) === 2)
bytes.writeUInt16LE(0, methodStartOffset);
else bytes.writeUInt32LE(0, methodStartOffset);
const result = inspectManagedMembersBytes(
bytes,
managedPeFixtureTarget(bytes),
);
expect(result.methods[0]).toMatchObject({
token: "0x06000001",
declaring_type: null,
});
expect(result.types[0]?.method_list).toMatchObject({
first_row: null,
last_row: null,
count: 0,
});
expect(result.coverage.state).toBe("partial");
});
});
describe("managed TypeDef list ordering", () => {
it("leaves adjacent ownership unknown when valid list starts decrease", () => {
const bytes = buildManagedPeFixture({
additionalEmptyTypes: 1,
methodRowCount: 2,
});
const pe = readManagedPeLayout(bytes);
if (pe.cli === null) throw new Error("fixture has no CLI header");
const { rva, size } = pe.cli.metadata;
const layout = readManagedMetadataLayout(
bytes,
pe.rvaToOffset(rva, size, "cli.metadata"),
size,
);
const methodOffset =
4 +
2 * layout.stringIndexSize +
layout.codedIndexSize("TypeDefOrRef") +
memberListIndexSize(layout, 4);
const writeMethodStart = (row: number, value: number): void => {
const offset = metadataRowOffset(layout, 2, row) + methodOffset;
if (memberListIndexSize(layout, 6) === 2)
bytes.writeUInt16LE(value, offset);
else bytes.writeUInt32LE(value, offset);
};
writeMethodStart(1, 2);
writeMethodStart(2, 1);
const result = inspectManagedMembersBytes(
bytes,
managedPeFixtureTarget(bytes),
);
expect(result.methods.map(({ declaring_type }) => declaring_type)).toEqual([
null,
null,
]);
expect(result.coverage).toMatchObject({
state: "partial",
issues: [
expect.objectContaining({
code: "invalid-row",
detail: expect.stringContaining(
"MethodList start 1 decreases from preceding start 2",
),
}),
],
});
});
it("accepts the one-based start for an empty ordinary member table", () => {
const bytes = buildManagedPeFixture({ methodRowCount: 0 });
const result = inspectManagedMembersBytes(
bytes,
managedPeFixtureTarget(bytes),
);
expect(result.types[0]?.method_list).toEqual({
first_row: null,
last_row: null,
count: 0,
});
expect(result.coverage).toMatchObject({ state: "complete", issues: [] });
});
});
describe("managed metadata reference bounds", () => {
it("retains members and reports an out-of-range TypeDef coded index as partial", () => {
const bytes = buildManagedPeFixture({ extendsRaw: 12 });
const result = inspectManagedMembersBytes(
+12 -4
View File
@@ -31,6 +31,7 @@ import {
import { edgeTargets } from "./ManagedMethodSpecs.js";
import { typeNameLookup } from "./ManagedTypeNames.js";
import { parseUserStrings } from "./ManagedUserStrings.js";
import { readTypeMemberListFacts } from "./ManagedTypeMemberLists.js";
import {
edges,
parseExportedTypes,
@@ -113,12 +114,14 @@ const readMemberInventory = (
rootOffset,
cli.metadata.size,
);
const memberLists = readTypeMemberListFacts(bytes, layout);
const inventory = readManagedMetadataInventory(
bytes,
layout,
readManagedResourceDirectory(pe, issues),
memberLists,
);
return { layout, inventory };
return { layout, inventory, memberLists };
};
const selectTypes = (
@@ -185,9 +188,13 @@ export const inspectManagedMembersBytes = (
return unavailable(target, bytes, pe.cliIssue, typeNames);
const issues: ManagedParseIssue[] = [];
try {
const { layout, inventory } = readMemberInventory(bytes, pe, issues);
const { layout, inventory, memberLists } = readMemberInventory(
bytes,
pe,
issues,
);
appendManagedParseIssues(issues, inventory.issues);
const ranges = typeRanges(bytes, layout, issues);
const ranges = typeRanges(bytes, layout, issues, memberLists);
const {
tokens: selected,
selection,
@@ -197,7 +204,7 @@ export const inspectManagedMembersBytes = (
appendManagedParseIssues(issues, parsedTypes.issues);
const exportedTypes = parseExportedTypes(bytes, layout);
appendManagedParseIssues(issues, exportedTypes.issues);
const fields = parseFields(bytes, layout, ranges, selected);
const fields = parseFields(bytes, layout, ranges, memberLists, selected);
const memberRefs = parseMemberRefs(
bytes,
layout,
@@ -209,6 +216,7 @@ export const inspectManagedMembersBytes = (
layout,
pe,
ranges,
memberLists,
selected,
methodOffset,
methodLimit,
+31 -42
View File
@@ -20,10 +20,8 @@ import {
readMetadataString,
sha256Bytes,
} from "./ManagedMetadataHeaps.js";
import {
memberListIndexSize,
memberListTable,
} from "./ManagedMemberPointers.js";
import { memberListTable } from "./ManagedMemberPointers.js";
import type { TypeMemberListFacts } from "./ManagedTypeMemberLists.js";
export type ManagedType = ManagedMemberInspection["types"][number];
export type ManagedExportedType = NonNullable<
@@ -47,10 +45,10 @@ export type ManagedExceptionRegion =
export interface TypeRange {
readonly token: string;
readonly fullName: string | null;
readonly fieldStart: number;
readonly fieldEnd: number;
readonly methodStart: number;
readonly methodEnd: number;
readonly fieldStart: number | null;
readonly fieldEnd: number | null;
readonly methodStart: number | null;
readonly methodEnd: number | null;
}
/**
@@ -80,18 +78,23 @@ export interface ParsedInstruction {
const rowRange = (
table: MetadataTableLayout | undefined,
start: number,
nextStart: number,
start: number | null,
end: number | null,
): {
readonly first: number | null;
readonly last: number | null;
readonly count: number;
} => {
const total = table?.rowCount ?? 0;
if (total === 0 || start === 0) return { first: null, last: null, count: 0 };
const end = Math.min(nextStart === 0 ? total + 1 : nextStart, total + 1);
if (start >= end) return { first: null, last: null, count: 0 };
return { first: start, last: end - 1, count: end - start };
if (start === null || end === null)
return { first: null, last: null, count: 0 };
const lastExclusive = Math.min(end, total + 1);
if (start >= lastExclusive) return { first: null, last: null, count: 0 };
return {
first: start,
last: lastExclusive - 1,
count: lastExclusive - start,
};
};
export const fullName = (namespace: string, name: string): string =>
@@ -102,9 +105,8 @@ const readTypeRange = (
layout: ManagedMetadataLayout,
row: number,
issues: ManagedParseIssue[],
memberLists: TypeMemberListFacts,
): TypeRange => {
const methods = memberListTable(layout, 6);
const fields = memberListTable(layout, 4);
const cursor = metadataRowCursor(bytes, layout, 2, row);
cursor.readUInt32();
const nameIndex = cursor.readIndex(layout.stringIndexSize);
@@ -124,27 +126,14 @@ const readTypeRange = (
issues,
) ?? null;
cursor.readIndex(layout.codedIndexSize("TypeDefOrRef"));
const fieldStart = cursor.readIndex(memberListIndexSize(layout, 4));
const methodStart = cursor.readIndex(memberListIndexSize(layout, 6));
let nextField = (fields?.rowCount ?? 0) + 1;
let nextMethod = (methods?.rowCount ?? 0) + 1;
const typeTable = layout.table(2);
if (typeTable !== undefined && row < typeTable.rowCount) {
const next = metadataRowCursor(bytes, layout, 2, row + 1);
next.readUInt32();
next.readIndex(layout.stringIndexSize);
next.readIndex(layout.stringIndexSize);
next.readIndex(layout.codedIndexSize("TypeDefOrRef"));
nextField = next.readIndex(memberListIndexSize(layout, 4));
nextMethod = next.readIndex(memberListIndexSize(layout, 6));
}
const current = memberLists.entries[row - 1];
return {
token: metadataToken(2, row),
fullName: declaredName,
fieldStart,
fieldEnd: nextField,
methodStart,
methodEnd: nextMethod,
fieldStart: current?.field.start.value ?? null,
fieldEnd: current?.field.end ?? null,
methodStart: current?.method.start.value ?? null,
methodEnd: current?.method.end ?? null,
};
};
@@ -152,11 +141,13 @@ export const typeRanges = (
bytes: Buffer,
layout: ManagedMetadataLayout,
issues: ManagedParseIssue[],
memberLists: TypeMemberListFacts,
): readonly TypeRange[] => {
issues.push(...memberLists.issues);
const typeTable = layout.table(2);
const ranges: TypeRange[] = [];
for (let row = 1; row <= (typeTable?.rowCount ?? 0); row += 1)
ranges.push(readTypeRange(bytes, layout, row, issues));
ranges.push(readTypeRange(bytes, layout, row, issues, memberLists));
return ranges;
};
@@ -193,18 +184,16 @@ export const parseTypes = (
const extendsRaw = cursor.readIndex(
layout.codedIndexSize("TypeDefOrRef"),
);
const fieldStart = cursor.readIndex(memberListIndexSize(layout, 4));
const methodStart = cursor.readIndex(memberListIndexSize(layout, 6));
const nextRange = ranges[row] ?? null;
const currentRange = ranges[row - 1] ?? null;
const fieldRange = rowRange(
fields,
fieldStart,
nextRange?.fieldStart ?? 0,
currentRange?.fieldStart ?? null,
currentRange?.fieldEnd ?? null,
);
const methodRange = rowRange(
methods,
methodStart,
nextRange?.methodStart ?? 0,
currentRange?.methodStart ?? null,
currentRange?.methodEnd ?? null,
);
const extendsReason = metadataCodedTokenInvalidReason(
extendsRaw,
+40 -33
View File
@@ -6,6 +6,7 @@ import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js";
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
import { memberDeclaringTypeRowLookup } from "./ManagedMemberOwnership.js";
import { memberListIndexSize } from "./ManagedMemberPointers.js";
import { readTypeMemberListFacts } from "./ManagedTypeMemberLists.js";
import { readManagedMetadataLayout } from "./ManagedMetadataLayout.js";
import {
buildManagedMemberPointerFixture,
@@ -28,15 +29,15 @@ const metadataLayout = (bytes: Buffer) => {
describe("managed declaring type ownership", () => {
it.prop([
fc.tuple(
fc.tuple(
...Array.from({ length: 3 }, () => fc.integer({ min: 0, max: 6 })),
),
fc.tuple(
...Array.from({ length: 3 }, () => fc.integer({ min: 0, max: 6 })),
),
fc
.array(fc.integer({ min: 1, max: 3 }), { minLength: 3, maxLength: 3 })
.map((starts) => starts.sort((left, right) => left - right)),
fc
.array(fc.integer({ min: 1, max: 3 }), { minLength: 3, maxLength: 3 })
.map((starts) => starts.sort((left, right) => left - right)),
),
])(
"retains first-match field and method owners for arbitrary metadata lists",
"matches literal ownership ranges for valid nondecreasing list starts",
([fields, methods]) => {
const bytes = buildManagedMemberPointerFixture();
const layout = metadataLayout(bytes);
@@ -64,11 +65,16 @@ describe("managed declaring type ownership", () => {
] as const) {
// The fixture's pointers reorder physical rows 1,2 to list positions 2,1.
const expected = [2, 1].map((position) => {
const owner = starts.findIndex(
(start, index) =>
position >= start && position < (starts[index + 1] ?? 3),
);
return owner < 0 ? null : members.types[owner]?.full_name;
let owner: number | null = null;
for (let index = 0; index < starts.length; index += 1) {
const start = starts[index];
const end = starts[index + 1] ?? 3;
if (start !== undefined && position >= start && position < end) {
owner = index;
break;
}
}
return owner === null ? null : members.types[owner]?.full_name;
});
expect(members[table].map((member) => member.declaring_type)).toEqual(
expected,
@@ -80,10 +86,10 @@ describe("managed declaring type ownership", () => {
},
);
it("indexes large unordered member lists once per layout", () => {
const count = 100_000;
it("indexes wide valid member lists once per layout", () => {
const count = 65_538;
const bytes = buildManagedPeFixture({
additionalEmptyTypes: count - 1,
additionalEmptyTypes: count - 3,
methodRowCount: count,
});
const layout = metadataLayout(bytes);
@@ -95,24 +101,25 @@ describe("managed declaring type ownership", () => {
2 * layout.stringIndexSize +
layout.codedIndexSize("TypeDefOrRef") +
memberListIndexSize(layout, 4);
// The first reversed range forces first-match semantics, with the owner last.
// 100,000 MethodDef rows require a four-byte method-list index.
bytes.writeUInt32LE(2, methodOffset);
const start = performance.now();
const lookup = memberDeclaringTypeRowLookup(bytes, layout, 6);
expect(
Array.from({ length: count }, (_, row) => lookup(row + 1)).every(
(owner) => owner === count,
),
).toBe(true);
expect(performance.now() - start).toBeLessThan(2000);
expect(memberDeclaringTypeRowLookup(bytes, layout, 6)).toBe(lookup);
// Each preceding TypeDef owns one row; later TypeDefs have empty intervals.
// This exercises the four-byte list-index boundary without malformed starts.
for (let row = 0; row < count; row += 1)
bytes.writeUInt32LE(
Math.min(row + 1, 65_537),
methodOffset + row * types.rowSize,
);
const facts = readTypeMemberListFacts(bytes, layout);
const lookup = memberDeclaringTypeRowLookup(bytes, layout, 6, facts);
expect(lookup(65_536)).toBe(65_536);
expect(lookup(65_535)).toBe(65_535);
expect(memberDeclaringTypeRowLookup(bytes, layout, 6, facts)).toBe(lookup);
expect([lookup(0), lookup(count + 1)]).toEqual([0, 0]);
});
it("admits ownership storage before allocation and retains failed admission", () => {
const bytes = buildManagedPeFixture();
const layout = metadataLayout(bytes);
const facts = readTypeMemberListFacts(bytes, layout);
const budget = managedDecodeBudget(layout);
const capacityFailure = new ManagedReaderFailure({
code: "resource-limit",
@@ -134,12 +141,12 @@ describe("managed declaring type ownership", () => {
}),
);
try {
expect(() => memberDeclaringTypeRowLookup(bytes, layout, 6)).toThrow(
capacityFailure,
);
expect(() => memberDeclaringTypeRowLookup(bytes, layout, 6)).toThrow(
capacityFailure,
);
expect(() =>
memberDeclaringTypeRowLookup(bytes, layout, 6, facts),
).toThrow(capacityFailure);
expect(() =>
memberDeclaringTypeRowLookup(bytes, layout, 6, facts),
).toThrow(capacityFailure);
expect(admission).toHaveBeenCalledTimes(1);
expect(allocations).toBe(0);
} finally {
+28 -30
View File
@@ -1,33 +1,35 @@
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
import { metadataRowCursor } from "./ManagedMetadataHeaps.js";
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
import {
memberListIndexSize,
memberListRowLookup,
memberListTable,
} from "./ManagedMemberPointers.js";
import { ManagedReaderFailure } from "./ManagedReaderFailure.js";
import type {
MemberDefinitionTable,
TypeMemberListFacts,
} from "./ManagedTypeMemberLists.js";
type MemberTable = 4 | 6;
type OwnerLookup = (definitionRow: number) => number;
const lookups = new WeakMap<
ManagedMetadataLayout,
Map<MemberTable, OwnerLookup | ManagedReaderFailure>
TypeMemberListFacts,
Map<MemberDefinitionTable, OwnerLookup | ManagedReaderFailure>
>();
/** One first-match TypeDef owner per physical member, shared by every inspection facet. */
export const memberDeclaringTypeRowLookup = (
bytes: Buffer,
layout: ManagedMetadataLayout,
table: MemberTable,
table: MemberDefinitionTable,
facts: TypeMemberListFacts,
): OwnerLookup => {
let byTable = lookups.get(layout);
let byTable = lookups.get(facts);
const cached = byTable?.get(table);
if (cached instanceof ManagedReaderFailure) throw cached;
if (cached !== undefined) return cached;
if (byTable === undefined) {
byTable = new Map();
lookups.set(layout, byTable);
lookups.set(facts, byTable);
}
try {
const positionOf = memberListRowLookup(bytes, layout, table);
@@ -50,30 +52,26 @@ export const memberDeclaringTypeRowLookup = (
}
return root;
};
const startForType = (row: number): number => {
const cursor = metadataRowCursor(bytes, layout, 2, row);
cursor.readUInt32();
cursor.readIndex(layout.stringIndexSize);
cursor.readIndex(layout.stringIndexSize);
cursor.readIndex(layout.codedIndexSize("TypeDefOrRef"));
const fieldStart = cursor.readIndex(memberListIndexSize(layout, 4));
return table === 4
? fieldStart
: cursor.readIndex(memberListIndexSize(layout, 6));
};
let start = types === 0 ? 0 : startForType(1);
for (let row = 1; row <= types; row += 1) {
const end = row === types ? positions + 1 : startForType(row + 1);
const limit = Math.min(end, positions + 1);
for (
let p = unowned(Math.min(Math.max(start, 1), positions + 1));
p < limit;
p = unowned(p + 1)
) {
owners[p] = row;
next[p] = p + 1;
const memberStart =
table === 4
? facts.entries[row - 1]?.field
: facts.entries[row - 1]?.method;
const start = memberStart?.start.value ?? null;
const end = memberStart?.end ?? null;
// A malformed endpoint makes this entire interval uncertain. In
// particular, zero must not become row one through owner-side clamping.
if (start !== null && end !== null) {
const limit = Math.min(end, positions + 1);
for (
let p = unowned(Math.min(start, positions + 1));
p < limit;
p = unowned(p + 1)
) {
owners[p] = row;
next[p] = p + 1;
}
}
start = end;
}
const definitions = layout.table(table)?.rowCount ?? 0;
const lookup: OwnerLookup = (definition) =>
+15 -1
View File
@@ -1,5 +1,8 @@
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
import type {
ManagedMetadataLayout,
MetadataTableLayout,
} from "./ManagedMetadataLayout.js";
import {
managedFailure,
ManagedReaderFailure,
@@ -32,6 +35,17 @@ export const memberListIndexSize = (
? 4
: 2;
/** Normalize a TypeDef list coordinate once for parsing and owner lookup. */
export const validatedMemberListStart = (
start: number,
table: MetadataTableLayout | undefined,
): number | null => {
const total = table?.rowCount ?? 0;
return Number.isSafeInteger(start) && start >= 1 && start <= total + 1
? start
: null;
};
/** Resolve physical definition rows to list positions without changing token identity. */
export const memberListRowLookup = (
bytes: Buffer,
+6 -2
View File
@@ -30,6 +30,7 @@ import {
import { readManagedValue } from "./ManagedReaderFailure.js";
import { methodBody } from "./ManagedMethodBodyReader.js";
import { memberDeclaringTypeRowLookup } from "./ManagedMemberOwnership.js";
import type { TypeMemberListFacts } from "./ManagedTypeMemberLists.js";
/**
* Declaring-type tokens whose members are decoded in full; null selects every
@@ -47,6 +48,7 @@ export const parseFields = (
bytes: Buffer,
layout: ManagedMetadataLayout,
ranges: readonly TypeRange[],
memberLists: TypeMemberListFacts,
selected: SelectedTypeTokens = null,
): {
readonly fields: readonly ManagedField[];
@@ -58,7 +60,7 @@ export const parseFields = (
const issues: ManagedParseIssue[] = [];
const table = layout.table(4);
const ownerRow = readManagedValue(
() => memberDeclaringTypeRowLookup(bytes, layout, 4),
() => memberDeclaringTypeRowLookup(bytes, layout, 4, memberLists),
issues,
);
for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) {
@@ -256,6 +258,7 @@ interface ParseMethodsInput {
readonly layout: ManagedMetadataLayout;
readonly pe: ManagedPeLayout;
readonly ranges: readonly TypeRange[];
readonly memberLists: TypeMemberListFacts;
readonly selected?: SelectedTypeTokens;
readonly methodOffset?: number;
readonly methodLimit?: number;
@@ -266,6 +269,7 @@ export const parseMethods = ({
layout,
pe,
ranges,
memberLists,
selected = null,
methodOffset = 0,
methodLimit = Number.MAX_SAFE_INTEGER,
@@ -285,7 +289,7 @@ export const parseMethods = ({
let unscannedMethods = 0;
const table = layout.table(6);
const ownerRow = readManagedValue(
() => memberDeclaringTypeRowLookup(bytes, layout, 6),
() => memberDeclaringTypeRowLookup(bytes, layout, 6, memberLists),
issues,
);
for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) {
+14 -2
View File
@@ -15,6 +15,7 @@ import {
readResource,
} from "./ManagedMetadataInventoryRows.js";
import { metadataToken } from "./ManagedMetadataHeaps.js";
import type { TypeMemberListFacts } from "./ManagedTypeMemberLists.js";
import {
readManagedValue,
uniqueManagedParseIssues,
@@ -149,13 +150,22 @@ const readAttributes = (
bytes: Buffer,
layout: ManagedMetadataLayout,
issues: ManagedParseIssue[],
memberLists: TypeMemberListFacts,
): {
readonly attributes: readonly CustomAttribute[];
readonly targetFrameworks: readonly string[];
} => {
const attributes = readRows(layout.table(12), (row) =>
readManagedValue(
() => readCustomAttribute(bytes, layout, row, heapExtent(layout), issues),
() =>
readCustomAttribute(
bytes,
layout,
row,
heapExtent(layout),
issues,
memberLists,
),
issues,
),
);
@@ -177,8 +187,9 @@ export const readManagedMetadataInventory = (
bytes: Buffer,
layout: ManagedMetadataLayout,
resourceDirectory: ManagedResourceDirectory | null,
memberLists: TypeMemberListFacts,
): ManagedMetadataInventory => {
const issues: ManagedParseIssue[] = [];
const issues: ManagedParseIssue[] = [...memberLists.issues];
validateIdentityTableCounts(layout, issues);
const module =
readManagedValue(
@@ -201,6 +212,7 @@ export const readManagedMetadataInventory = (
bytes,
layout,
issues,
memberLists,
);
return {
module,
+21 -4
View File
@@ -1,5 +1,6 @@
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
import { memberDeclaringTypeRowLookup } from "./ManagedMemberOwnership.js";
import type { TypeMemberListFacts } from "./ManagedTypeMemberLists.js";
import type {
ManagedArtifactInspection,
@@ -248,8 +249,14 @@ const declaringTypeForMethod = (
layout: ManagedMetadataLayout,
methodRow: number,
heapExtent: number,
memberLists: TypeMemberListFacts,
): string | null => {
const owner = memberDeclaringTypeRowLookup(bytes, layout, 6)(methodRow);
const owner = memberDeclaringTypeRowLookup(
bytes,
layout,
6,
memberLists,
)(methodRow);
if (owner === 0) return null;
return readTypeName({ bytes, layout, table: 2, row: owner, heapExtent });
};
@@ -259,11 +266,13 @@ const attributeTypeName = (
layout: ManagedMetadataLayout,
rawType: number,
heapExtent: number,
memberLists: TypeMemberListFacts,
): string | null => {
const tag = rawType & 7;
const row = Math.floor(rawType / 8);
if (row === 0) return null;
if (tag === 2) return declaringTypeForMethod(bytes, layout, row, heapExtent);
if (tag === 2)
return declaringTypeForMethod(bytes, layout, row, heapExtent, memberLists);
if (tag !== 3) return null;
const memberRefs = layout.table(10);
if (memberRefs === undefined || row > memberRefs.rowCount) return null;
@@ -288,7 +297,13 @@ const attributeTypeName = (
heapExtent,
});
if (parentTag === 3)
return declaringTypeForMethod(bytes, layout, parentRow, heapExtent);
return declaringTypeForMethod(
bytes,
layout,
parentRow,
heapExtent,
memberLists,
);
return null;
};
@@ -349,6 +364,7 @@ export const readCustomAttribute = (
row: number,
heapExtent: number,
issues: ManagedParseIssue[],
memberLists: TypeMemberListFacts,
): CustomAttribute => {
const cursor = metadataRowCursor(bytes, layout, 12, row);
const parentRaw = cursor.readIndex(
@@ -411,7 +427,8 @@ export const readCustomAttribute = (
if (constructor !== null)
typeName =
readManagedValue(
() => attributeTypeName(bytes, layout, typeRaw, heapExtent),
() =>
attributeTypeName(bytes, layout, typeRaw, heapExtent, memberLists),
issues,
) ?? null;
return {
@@ -13,6 +13,7 @@ import {
readManagedMetadataLayout,
type ManagedMetadataLayout,
} from "./ManagedMetadataLayout.js";
import { readTypeMemberListFacts } from "./ManagedTypeMemberLists.js";
import {
readManagedPeLayout,
type ManagedPeLayout,
@@ -108,10 +109,12 @@ const readBoundaryInventory = (
metadataOffset,
cli.metadata.size,
);
const memberLists = readTypeMemberListFacts(bytes, layout);
const inventory = readManagedMetadataInventory(
bytes,
layout,
readManagedResourceDirectory(pe, issues),
memberLists,
);
return { layout, inventory };
};
+147
View File
@@ -0,0 +1,147 @@
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
import { metadataRowCursor, metadataToken } from "./ManagedMetadataHeaps.js";
import {
memberListIndexSize,
memberListTable,
validatedMemberListStart,
} from "./ManagedMemberPointers.js";
export type MemberDefinitionTable = 4 | 6;
export interface TypeMemberListStart {
readonly raw: number;
readonly value: number | null;
readonly offset: number;
}
export interface TypeMemberListRange {
readonly start: TypeMemberListStart;
/** Next TypeDef's validated start, or the one-past-end sentinel. */
readonly end: number | null;
}
export interface TypeMemberListEntry {
readonly field: TypeMemberListRange;
readonly method: TypeMemberListRange;
}
/** Parsed TypeDef list coordinates for one metadata-layout lifetime. */
export interface TypeMemberListFacts {
readonly entries: readonly TypeMemberListEntry[];
readonly issues: readonly ManagedParseIssue[];
}
export const readTypeMemberListFacts = (
bytes: Buffer,
layout: ManagedMetadataLayout,
): TypeMemberListFacts => {
const fields = memberListTable(layout, 4);
const methods = memberListTable(layout, 6);
const rows: { field: TypeMemberListStart; method: TypeMemberListStart }[] =
[];
const issues: ManagedParseIssue[] = [];
const types = layout.table(2);
const typeCount = types?.rowCount ?? 0;
// Peak retained shape includes both the parsed-start and range arrays, their
// nested records, plus each eventual JSON object/key projection. Malformed
// row diagnostics are admitted separately before they are allocated.
managedDecodeBudget(layout).reserve(
typeCount * 512 + 256,
types?.offset ?? null,
);
for (let row = 1; row <= typeCount; row += 1) {
const cursor = metadataRowCursor(bytes, layout, 2, row);
cursor.readUInt32();
cursor.readIndex(layout.stringIndexSize);
cursor.readIndex(layout.stringIndexSize);
cursor.readIndex(layout.codedIndexSize("TypeDefOrRef"));
const field = {
offset: cursor.offset,
raw: cursor.readIndex(memberListIndexSize(layout, 4)),
};
const method = {
offset: cursor.offset,
raw: cursor.readIndex(memberListIndexSize(layout, 6)),
};
const entry = {
field: { ...field, value: validatedMemberListStart(field.raw, fields) },
method: {
...method,
value: validatedMemberListStart(method.raw, methods),
},
};
rows.push(entry);
for (const [name, start, table] of [
["FieldList", entry.field, fields],
["MethodList", entry.method, methods],
] as const)
if (start.value === null) {
managedDecodeBudget(layout).reserve(512, start.offset);
issues.push({
code: "invalid-row",
scope: `metadata.TypeDef:${metadataToken(2, row)}`,
offset: start.offset,
detail: `TypeDef ${name} start ${String(start.raw)} is outside the one-based list range 1..${String((table?.rowCount ?? 0) + 1)}`,
});
}
}
const nonmonotonicFields = new Set<number>();
const nonmonotonicMethods = new Set<number>();
for (let index = 0; index + 1 < rows.length; index += 1) {
const current = rows[index];
const next = rows[index + 1];
if (current === undefined || next === undefined) continue;
for (const [name, before, after, affected] of [
["FieldList", current.field, next.field, nonmonotonicFields],
["MethodList", current.method, next.method, nonmonotonicMethods],
] as const)
if (
before.value !== null &&
after.value !== null &&
after.value < before.value
) {
managedDecodeBudget(layout).reserve(512, after.offset);
affected.add(index);
affected.add(index + 1);
issues.push({
code: "invalid-row",
scope: `metadata.TypeDef:${metadataToken(2, index + 2)}`,
offset: after.offset,
detail: `TypeDef ${name} start ${String(after.raw)} decreases from preceding start ${String(before.raw)}; adjacent member ranges are unknown`,
});
}
}
const entries: TypeMemberListEntry[] = rows.map((entry, index) => {
const next = rows[index + 1];
return {
field: {
start: {
...entry.field,
value: nonmonotonicFields.has(index) ? null : entry.field.value,
},
end:
next === undefined
? (fields?.rowCount ?? 0) + 1
: nonmonotonicFields.has(index) || nonmonotonicFields.has(index + 1)
? null
: next.field.value,
},
method: {
start: {
...entry.method,
value: nonmonotonicMethods.has(index) ? null : entry.method.value,
},
end:
next === undefined
? (methods?.rowCount ?? 0) + 1
: nonmonotonicMethods.has(index) ||
nonmonotonicMethods.has(index + 1)
? null
: next.method.value,
},
};
});
return { entries, issues };
};
+1 -1
View File
@@ -1,7 +1,7 @@
/** Generated from package.json and package-lock.json; do not edit. */
export const PACKAGE_METADATA = {
name: "rea-agents",
version: "6.4.0", // x-release-please-version
version: "6.5.0", // x-release-please-version
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
+5 -3
View File
@@ -123,6 +123,7 @@ const tracerOutputSchema = z.discriminatedUnion("status", [
"tracer-error",
]),
error: z.string().nullable(),
target_process_created: z.boolean().optional(),
}),
]);
@@ -423,11 +424,12 @@ export class LldbCallTracer implements NativeCallTracer {
let targetTermination: TargetTermination = exited.targetLaunched
? await ensureTerminated(paths.pid, exited.targetIdentity, true)
: "terminated";
// These two bridge outcomes are emitted before a target process exists.
// Other missing-PID cases, including a missing result, remain unknown.
// Only an explicit bridge phase fact may prove that no target process was
// created. A missing PID or an integrity-error status alone is ambiguous.
if (
output !== undefined &&
(output.status === "target-error" || output.status === "launch-error") &&
output.status !== "traced" &&
output.target_process_created === false &&
(await readTargetPid(paths.pid)) === undefined
)
targetTermination = "terminated";
+30 -5
View File
@@ -47,12 +47,26 @@ const fileUnavailableSystemCode = (cause: unknown): string | undefined => {
: undefined;
};
const lstatIfPresent = async (path: string): Promise<Stats | undefined> => {
const lstatIfPresent = async (
path: string,
previouslyObservedRoot = false,
signal?: AbortSignal,
): Promise<Stats | undefined> => {
signal?.throwIfAborted();
try {
return await lstat(path);
const stats = await lstat(path);
signal?.throwIfAborted();
return stats;
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" ||
(previouslyObservedRoot && cause.code === "ENOTDIR"))
) {
signal?.throwIfAborted();
return undefined;
}
throw cause;
}
};
@@ -169,7 +183,11 @@ export const hashFile = async (
export const snapshotRoots = async (
scenario: ProcessScenario,
signal?: AbortSignal,
previous?: ProcessFilesystemSnapshot,
): Promise<ProcessFilesystemSnapshot> => {
// A prior root entry establishes same-run admission. Preserve initial
// obstruction errors; a later non-directory ancestor makes it absent.
const previousPaths = new Set(previous?.files.map(({ path }) => path));
const entries: FileState[] = [];
const completeRoots: string[] = [];
let remainingBytes = scenario.limits.file_bytes;
@@ -198,7 +216,11 @@ export const snapshotRoots = async (
enumerationReasons.add("depth_limit");
return false;
}
const stats = await lstatIfPresent(path);
const stats = await lstatIfPresent(
path,
previousPaths.has(`${rootAlias}:.`),
signal,
);
if (stats === undefined) return true;
const relativePath = relative(root, path) || ".";
if (stats.isSymbolicLink()) {
@@ -326,7 +348,10 @@ export const snapshotRoots = async (
};
for (const [index, root] of scenario.filesystem_observation_paths.entries()) {
const alias = `root_${String(index)}`;
if ((await lstatIfPresent(root)) === undefined) {
if (
(await lstatIfPresent(root, previousPaths.has(`${alias}:.`), signal)) ===
undefined
) {
completeRoots.push(alias);
continue;
}
+5 -1
View File
@@ -508,7 +508,11 @@ const completeCapture = async (options: {
assertNotCancelled(options.signal);
let after: Awaited<ReturnType<typeof snapshotRoots>>;
try {
after = await options.captureSnapshot(scenario, options.signal);
after = await options.captureSnapshot(
scenario,
options.signal,
options.before,
);
} catch (cause: unknown) {
options.observationBuffer.filesystem_snapshots = {
...options.observationBuffer.filesystem_snapshots,
+2
View File
@@ -22,6 +22,8 @@ const config = {
concurrency: 2,
timeoutMS: 30000,
reporters: ["clear-text", "progress", "html", "json"],
jsonReporter: { fileName: "reports/mutation/reverse-tools.json" },
htmlReporter: { fileName: "reports/mutation/reverse-tools.html" },
};
export default {
+2
View File
@@ -18,6 +18,8 @@ const config = {
concurrency: 2,
timeoutMS: 30000,
reporters: ["clear-text", "progress", "html", "json"],
jsonReporter: { fileName: "reports/mutation/reverse-tools-semantics.json" },
htmlReporter: { fileName: "reports/mutation/reverse-tools-semantics.html" },
};
export default {
@@ -462,6 +462,48 @@ describe("stable exported function bindings through stdio MCP", () => {
const fixture = await analyzeVersions(source, "default");
expectCountChange(await compareThroughMcp(fixture.input));
});
it.each([
{
name: "a local callable captured by an identifier default export",
source: (count: number) =>
`const run = () => ({ kind: "result", count: ${String(count)} });\nexport default run;`,
runtimeValue: (count: number) => ({ kind: "result", count }),
expected: "known",
},
{
name: "the latest callable assigned before the default export",
source: (count: number) =>
`let run = () => ({ count: 0 });\nrun = () => ({ kind: "result", count: ${String(count)} });\nexport default run;`,
runtimeValue: (count: number) => ({ kind: "result", count }),
expected: "known",
},
{
name: "a callable assigned only after the default export capture",
source: (count: number) =>
`var run;\nexport default run;\nrun = () => ({ kind: "result", count: ${String(count)} });`,
runtimeValue: () => undefined,
expected: "unknown",
},
{
name: "a reassignment after the default export capture",
source: (count: number) =>
`let run = () => ({ kind: "result", count: ${String(count)} });\nexport default run;\nrun = () => ({ count: 99 });`,
runtimeValue: (count: number) => ({ kind: "result", count }),
expected: "known",
},
] as const)(
"uses the value at $name",
async ({ source, runtimeValue, expected }) => {
const fixture = await analyzeVersions(source, "default");
await expectRuntimeExports(fixture, runtimeValue);
const mcp = await compareThroughMcp(fixture.input);
const cli = await compareThroughCli(fixture);
expect(cli).toEqual(mcp);
if (expected === "known") expectCountChange(mcp);
else expectUncertainComparison(mcp);
},
);
});
describe("export bindings with loop and assignment-only writes", () => {
@@ -0,0 +1,256 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { expect } from "vitest";
import { z } from "zod";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import {
applicationFeatureSeedSchema,
applicationFeatureTraceResultSchema,
} from "../../../src/domain/javascript/javascriptFeatureTraceSchemas.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
type FeatureSeed = z.input<typeof applicationFeatureSeedSchema>;
type Trace = z.infer<typeof applicationFeatureTraceResultSchema>;
type SeedScenario = {
readonly name: string;
readonly seed: FeatureSeed;
readonly expected: {
readonly nodeId: string;
readonly basis: "node-id" | "property";
} | null;
};
const createSeedScenarios = (
graph: z.infer<typeof javascriptApplicationAnalysisResultSchema>["graph"],
): SeedScenario[] => {
const notificationRoute = graph.nodes.find(
(node) =>
node.kind === "endpoint" &&
node.observations.some(
({ properties }) =>
properties.endpoint_kind === "route" &&
properties.value === "/accounts/preferences/notifications",
),
);
const tinyRoute = graph.nodes.find(
(node) =>
node.kind === "endpoint" &&
node.observations.some(
({ properties }) =>
properties.endpoint_kind === "route" && properties.value === "/tiny",
),
);
const labeledNode = graph.nodes.find((node) =>
node.observations.some(
({ label }) => label !== null && label !== node.node_id,
),
);
if (notificationRoute === undefined || tinyRoute === undefined)
throw new Error(
"Expected literal route endpoints in the application graph",
);
if (labeledNode === undefined)
throw new Error("Expected a labeled node in the application graph");
const label = labeledNode.observations.find(
({ label: value }) => value !== null && value !== labeledNode.node_id,
)?.label;
if (label === undefined || label === null)
throw new Error("Expected the selected graph node to have a display label");
const id = labeledNode.node_id;
return [
{
name: "node ID contains prefix",
seed: { kind: "node-id", value: id.slice(0, 24), match: "contains" },
expected: { nodeId: id, basis: "node-id" },
},
{
name: "node ID exact ignores case by default",
seed: { kind: "node-id", value: id.toUpperCase(), match: "exact" },
expected: { nodeId: id, basis: "node-id" },
},
{
name: "node ID exact can be case sensitive",
seed: {
kind: "node-id",
value: id.toUpperCase(),
match: "exact",
case_sensitive: true,
},
expected: null,
},
{
name: "node ID never falls back to a display label",
seed: { kind: "node-id", value: label, match: "exact" },
expected: null,
},
{
name: "generated route label exact",
seed: {
kind: "route",
value: "route endpoint",
match: "exact",
case_sensitive: true,
},
expected: null,
},
{
name: "generated route label substring",
seed: { kind: "route", value: "route", match: "contains" },
expected: null,
},
{
name: "preserved long route exact",
seed: {
kind: "route",
value: "/accounts/preferences/notifications",
match: "exact",
},
expected: { nodeId: notificationRoute.node_id, basis: "property" },
},
{
name: "preserved route substring",
seed: {
kind: "route",
value: "/accounts/preferences",
match: "contains",
},
expected: { nodeId: notificationRoute.node_id, basis: "property" },
},
{
name: "short preserved route exact",
seed: { kind: "route", value: "/tiny", match: "exact" },
expected: { nodeId: tinyRoute.node_id, basis: "property" },
},
];
};
const analyzeFixture = async (cli: TestCli, root: string) => {
const response = await cli.run({
arguments: [
"analyze-javascript-application",
root,
"--artifact-format",
"directory",
"--json",
],
});
expect(response.exitCode).toBe(0);
const evidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(response.json);
return {
evidence,
result: javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
),
};
};
const traceThroughCli = async (
cli: TestCli,
application: unknown,
scenario: SeedScenario,
): Promise<Trace> => {
const response = await cli.run({
arguments: [
"trace-application-feature",
JSON.stringify({
application,
seed: scenario.seed,
direction: "both",
}),
"--json",
],
});
expect(response.exitCode, `${scenario.name} via CLI`).toBe(0);
const evidence = toolContract("trace_application_feature").outputSchema.parse(
response.json,
);
return applicationFeatureTraceResultSchema.parse(evidence.normalized_result);
};
const traceThroughMcp = async (
client: Client,
application: unknown,
scenario: SeedScenario,
): Promise<Trace> => {
const response = await client.callTool({
name: "trace_application_feature",
arguments: {
application,
seed: scenario.seed,
direction: "both",
},
});
expect(response.isError, `${scenario.name} via MCP`).not.toBe(true);
const evidence = toolContract("trace_application_feature").outputSchema.parse(
response.structuredContent,
);
return applicationFeatureTraceResultSchema.parse(evidence.normalized_result);
};
const expectTrace = (trace: Trace, scenario: SeedScenario): void => {
if (scenario.expected === null) {
expect(trace.seed_matches).toEqual([]);
expect(trace.summary.matched_seeds).toBe(0);
expect(trace.coverage.status).toBe("no-match");
return;
}
expect(trace.seed_matches).toHaveLength(1);
expect(trace.seed_matches[0]).toMatchObject({
node_id: scenario.expected.nodeId,
basis: scenario.expected.basis,
});
expect(trace.summary.matched_seeds).toBe(1);
expect(trace.coverage.status).toBe("complete-within-source");
if (scenario.seed.kind === "node-id")
expect(trace.seed_matches[0]).toMatchObject({
basis: "node-id",
field: "node_id",
});
else
expect(trace.seed_matches[0]?.field).toMatch(
/\.properties\.(value|path)$/u,
);
};
cliTest(
"matches feature seeds against node IDs and preserved route values through CLI and MCP",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-feature-seed-selection-");
await writeFile(
join(root, "routes.js"),
`const notificationRoute = { path: "/accounts/preferences/notifications" };\nconst tinyRoute = { path: "/tiny" };\nmodule.exports = { notificationRoute, tinyRoute };\n`,
);
const { evidence, result } = await analyzeFixture(cli, root);
const client = new Client({ name: "feature-seed-selection", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" },
stderr: "pipe",
});
transport.stderr?.on("data", () => undefined);
try {
await client.connect(transport);
for (const scenario of createSeedScenarios(result.graph)) {
const cliTrace = await traceThroughCli(cli, evidence, scenario);
const mcpTrace = await traceThroughMcp(client, evidence, scenario);
expectTrace(cliTrace, scenario);
expectTrace(mcpTrace, scenario);
expect(mcpTrace.seed_matches).toEqual(cliTrace.seed_matches);
expect(mcpTrace.coverage).toEqual(cliTrace.coverage);
}
} finally {
await client.close();
await transport.close();
}
},
);
@@ -0,0 +1,220 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
const setupArguments = ["setup", "--client", "gemini_cli", "--yes", "--json"];
const doctorArguments = ["doctor", "--client", "gemini_cli", "--json"];
const geminiWorkspace = async (cli: TestCli) => {
const home = await createTestTempDirectory("rea-gemini-workspace-trust-");
const project = join(home, "project");
const workspace = join(project, ".gemini/settings.json");
const user = join(home, ".gemini/settings.json");
await mkdir(dirname(workspace), { recursive: true });
const environment: NodeJS.ProcessEnv = {
HOME: home,
USERPROFILE: home,
GEMINI_CLI_HOME: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: join(home, "system.json"),
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "defaults.json"),
GEMINI_CLI_TRUSTED_FOLDERS_PATH: join(home, "trust.json"),
};
const initial = await cli.run({
arguments: setupArguments,
cwd: home,
environment,
});
expect(initial.exitCode, initial.stdout).toBe(0);
const originalUser = await readFile(user);
const run = (arguments_: readonly string[]) =>
cli.run({ arguments: arguments_, cwd: project, environment });
return { workspace, user, originalUser, environment, run };
};
cliTest(
"Gemini does not require workspace trust until workspace settings exist",
async ({ cli }) => {
const fixture = await geminiWorkspace(cli);
const trust = join(dirname(fixture.user), "trustedFolders.json");
fixture.environment.GEMINI_CLI_TRUSTED_FOLDERS_PATH = trust;
for (const text of ["{}", "{ not json"]) {
await writeFile(trust, text);
const setup = await fixture.run(setupArguments);
expect(setup.exitCode, setup.stdout).toBe(0);
expect(setup.json).toMatchObject({ status: "ready", appliedActions: [] });
const doctor = await fixture.run(doctorArguments);
expect(doctor.exitCode, doctor.stdout).toBe(0);
expect(doctor.json).toMatchObject({
healthy: true,
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({
client: "gemini_cli",
state: "aligned",
remediation: null,
}),
]),
},
});
await expect(access(fixture.workspace)).rejects.toMatchObject({
code: "ENOENT",
});
expect(await readFile(trust, "utf8")).toBe(text);
expect(await readFile(fixture.user)).toEqual(fixture.originalUser);
}
const workspaceText = "{ not json";
await writeFile(fixture.workspace, workspaceText);
const setup = await fixture.run(setupArguments);
expect(setup.exitCode, setup.stdout).toBe(1);
expect(setup.json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining(trust),
});
const doctor = await fixture.run(doctorArguments);
expect(doctor.exitCode, doctor.stdout).toBe(1);
expect(doctor.json).toMatchObject({
healthy: false,
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({
client: "gemini_cli",
state: "invalid",
remediation: expect.stringContaining(trust),
}),
]),
},
});
expect(await readFile(fixture.workspace, "utf8")).toBe(workspaceText);
expect(await readFile(trust, "utf8")).toBe("{ not json");
expect(await readFile(fixture.user)).toEqual(fixture.originalUser);
},
);
for (const scenario of [
{ name: "an unlisted workspace", environment: {} },
{
name: "an explicitly untrusted workspace",
environment: { GEMINI_CLI_TRUST_WORKSPACE: "false" },
},
{
name: "a restricted workspace with an explicit trust opt-in",
environment: {
GEMINI_CLI_TRUST_WORKSPACE: "true",
GEMINI_RESTRICTED_MODE: "true",
},
},
])
cliTest(
`Gemini setup and doctor ignore malformed settings in ${scenario.name}`,
async ({ cli }) => {
const fixture = await geminiWorkspace(cli);
Object.assign(fixture.environment, scenario.environment);
const malformed = Buffer.from("{ not json");
await writeFile(fixture.workspace, malformed);
const setup = await fixture.run(setupArguments);
expect(setup.exitCode, setup.stdout).toBe(0);
expect(setup.json).toMatchObject({ status: "ready", appliedActions: [] });
const doctor = await fixture.run(doctorArguments);
expect(doctor.exitCode, doctor.stdout).toBe(0);
expect(doctor.json).toMatchObject({
healthy: true,
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({
client: "gemini_cli",
state: "aligned",
remediation: null,
}),
]),
},
});
expect(await readFile(fixture.workspace)).toEqual(malformed);
expect(await readFile(fixture.user)).toEqual(fixture.originalUser);
await expect(
access(`${fixture.workspace}.rea.backup`),
).rejects.toMatchObject({
code: "ENOENT",
});
if (scenario.name === "an explicitly untrusted workspace") {
const excluded = Buffer.from(
JSON.stringify({ mcp: { excluded: ["rea"] } }),
);
await writeFile(fixture.workspace, excluded);
const excludedSetup = await fixture.run(setupArguments);
expect(excludedSetup.exitCode, excludedSetup.stdout).toBe(0);
const excludedDoctor = await fixture.run(doctorArguments);
expect(excludedDoctor.exitCode, excludedDoctor.stdout).toBe(0);
expect(excludedDoctor.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({
client: "gemini_cli",
state: "aligned",
}),
]),
},
});
expect(await readFile(fixture.workspace)).toEqual(excluded);
}
},
);
cliTest(
"Gemini still rejects malformed settings and exclusions in a trusted workspace",
async ({ cli }) => {
const fixture = await geminiWorkspace(cli);
fixture.environment.GEMINI_CLI_TRUST_WORKSPACE = "true";
for (const scenario of [
{
text: "{ not json",
state: "invalid",
diagnostic: "Invalid JSON/JSONC",
},
{
text: JSON.stringify({ mcp: { excluded: ["rea"] } }),
state: "stale",
diagnostic: "mcp.excluded",
},
]) {
const original = Buffer.from(scenario.text);
await writeFile(fixture.workspace, original);
const setup = await fixture.run(setupArguments);
expect(setup.exitCode, setup.stdout).toBe(1);
expect(setup.json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining(scenario.diagnostic),
});
const doctor = await fixture.run(doctorArguments);
expect(doctor.exitCode, doctor.stdout).toBe(1);
expect(doctor.json).toMatchObject({
healthy: false,
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({
client: "gemini_cli",
state: scenario.state,
remediation: expect.stringContaining(scenario.diagnostic),
}),
]),
},
});
expect(await readFile(fixture.workspace)).toEqual(original);
expect(await readFile(fixture.user)).toEqual(fixture.originalUser);
await expect(
access(`${fixture.workspace}.rea.backup`),
).rejects.toMatchObject({
code: "ENOENT",
});
}
},
);
+10 -4
View File
@@ -11,9 +11,14 @@ import { COMPACT_INPUT_SCHEMA_BUDGET_BYTES } from "../../../src/config/mcpInputS
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
for (const profile of ["", "bogus"])
for (const [setting, profile] of [
["REA_MCP_INPUT_SCHEMA_PROFILE", ""],
["REA_MCP_INPUT_SCHEMA_PROFILE", "bogus"],
["REA_MCP_OUTPUT_SCHEMA_PROFILE", ""],
["REA_MCP_OUTPUT_SCHEMA_PROFILE", "bogus"],
] as const)
cliTest(
`setup and doctor refuse an invalid retained schema profile ${JSON.stringify(profile)}`,
`setup and doctor refuse an invalid retained ${setting} value ${JSON.stringify(profile)}`,
async ({ cli }) => {
const home = await createTestTempDirectory("rea-pi-invalid-profile-cli-");
const agent = join(home, ".pi", "agent");
@@ -24,7 +29,7 @@ for (const profile of ["", "bogus"])
type: "stdio",
command: "rea",
args: ["mcp"],
env: { REA_MCP_INPUT_SCHEMA_PROFILE: profile },
env: { [setting]: profile },
},
},
});
@@ -52,7 +57,7 @@ for (const profile of ["", "bogus"])
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining("REA_MCP_INPUT_SCHEMA_PROFILE"),
remediation: expect.stringContaining(setting),
});
const doctor = await cli.run({
arguments: ["doctor", "--client", "pi", "--json"],
@@ -83,6 +88,7 @@ cliTest(
const config = join(agent, "mcp.json");
const custom = {
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
REA_MCP_OUTPUT_SCHEMA_PROFILE: "off",
REA_LOG_LEVEL: "silent",
};
const original = JSON.stringify({
@@ -0,0 +1,121 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { expect } from "vitest";
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"unqualified doctor checks the installed managed skill and preserves its files",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-unqualified-doctor-skill-");
const skill = join(
home,
".claude/skills/reverse-engineer-anything/SKILL.md",
);
const run = (arguments_: readonly string[]) =>
cli.run({
arguments: [...arguments_, "--json"],
cwd: home,
environment: { HOME: home, USERPROFILE: home },
});
const install = await run([
"setup",
"--skill-client",
"claude_code",
"--yes",
]);
expect(install.exitCode, install.stdout).toBe(0);
const original = await readFile(skill);
const skillCheck = await run(["doctor", "--skill"]);
expect(skillCheck.exitCode, skillCheck.stdout).toBe(0);
expect(skillCheck.json).toMatchObject({
healthy: true,
identity: {
skill: {
state: "aligned",
installed_version: PRODUCT_IDENTITY.skillVersion,
installed_tool_count: expect.any(Number),
},
},
});
expect(await readFile(skill)).toEqual(original);
const audit = await run(["doctor"]);
expect(audit.json).toMatchObject({
identity: {
skill: {
state: "aligned",
installed_version: PRODUCT_IDENTITY.skillVersion,
installed_tool_count: expect.any(Number),
},
},
});
expect(await readFile(skill)).toEqual(original);
await mkdir(join(home, ".cursor"));
const withMarker = await run(["doctor"]);
expect(withMarker.json).toMatchObject({
identity: {
skill: {
state: "aligned",
installed_version: PRODUCT_IDENTITY.skillVersion,
installed_tool_count: expect.any(Number),
},
},
});
expect(await readFile(skill)).toEqual(original);
await writeFile(
skill,
Buffer.concat([original, Buffer.from("\nlocal edit\n")]),
);
const changed = await run(["doctor", "--skill"]);
expect(changed.exitCode).toBe(1);
expect(changed.json).toMatchObject({
healthy: false,
identity: { skill: { state: "stale" } },
});
const emptyHome = await createTestTempDirectory(
"rea-unqualified-doctor-empty-",
);
const empty = await cli.run({
arguments: ["doctor", "--skill", "--json"],
cwd: emptyHome,
environment: { HOME: emptyHome, USERPROFILE: emptyHome },
});
expect(empty.exitCode).toBe(1);
expect(empty.json).toMatchObject({
healthy: false,
identity: { skill: { state: "missing" } },
});
const foreignHome = await createTestTempDirectory(
"rea-unqualified-doctor-foreign-skill-",
);
const foreignSkill = join(
foreignHome,
".claude/skills/reverse-engineer-anything/SKILL.md",
);
const foreignBytes = Buffer.from(
"---\nname: another-skill\n---\nA caller-owned workflow.\n",
);
await mkdir(dirname(foreignSkill), { recursive: true });
await writeFile(foreignSkill, foreignBytes);
const foreign = await cli.run({
arguments: ["doctor", "--skill", "--json"],
cwd: foreignHome,
environment: { HOME: foreignHome, USERPROFILE: foreignHome },
});
expect(foreign.exitCode).toBe(1);
expect(foreign.json).toMatchObject({
healthy: false,
identity: { skill: { state: "stale", installed_version: null } },
});
expect(await readFile(foreignSkill)).toEqual(foreignBytes);
},
);
@@ -342,6 +342,51 @@ it("keeps CRLF separators inside a body after LF-only HTTP headers", async () =>
expect(await readFile(path)).toEqual(bytes);
});
it("validates SAZ XML declarations against archive member bytes at the CLI boundary", async () => {
const withMetadata = (metadata: Buffer): [string, Buffer][] =>
members().map(([name, bytes]) =>
name === "raw/0042_m.xml" ? [name, metadata] : [name, bytes],
);
const utf8 = Buffer.from(
'<?xml version="1.0" encoding="UTF-8"?><Session SID="0042"/>',
);
const utf16le = Buffer.concat([
Buffer.from([0xff, 0xfe]),
Buffer.from(
'<?xml version="1.0" encoding="UTF-16"?><Session SID="0042"/>',
"utf16le",
),
]);
for (const metadata of [utf8, utf16le]) {
const result = await cli(
await fixture(await archive(withMetadata(metadata))),
);
expect(
webNetworkCaptureSchema.parse(result.normalized_result).records,
).toHaveLength(2);
}
const mismatch = Buffer.from(
'<?xml version="1.0" encoding="UTF-16"?><Session SID="0042"/>',
);
const rejected = await cliResponse(
await fixture(await archive(withMetadata(mismatch))),
["--record", "1"],
);
expect(rejected.ok).toBe(false);
expect(JSON.parse(rejected.stdout)).toMatchObject({
category: "invalid_input",
details: {
issues: [
expect.objectContaining({
message: expect.stringContaining(
"SAZ metadata encoding declaration disagrees with its bytes",
),
}),
],
},
});
});
it("excludes explicit literals from XML, HTTP byte views, member coordinates and binary sidecars", async () => {
const path = await fixture(await archive());
const result = await cli(path, [
@@ -869,6 +869,89 @@ describe("web capture redirect comparison", () => {
changes: [expect.objectContaining({ change: "modified" })],
});
});
it.each([
["status", 302],
["mime_type", "text/plain"],
["encoded_data_length", 12],
] as const)(
"keeps unavailable redirect %s evidence unknown without suppressing known changes",
async (field, knownValue) => {
const browser = await startFakeCdpBrowser();
browsers.push(browser);
const captured = await new CdpBrowserProvider().inspectPage(
inspectWebPageInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 0,
}),
);
if (!captured.ok) throw captured.error;
const before = structuredClone(captured.value);
const beforeRequest = before.network.requests[0];
if (beforeRequest === undefined)
throw new Error("Missing network request");
beforeRequest.redirects = [
{
url: `${browser.allowedOrigin}/prior`,
response_url: `${browser.allowedOrigin}/prior`,
method: "GET",
resource_type: "Fetch",
status: 302,
mime_type: "text/plain",
encoded_data_length: 12,
request_timestamp: 1,
redirect_event_timestamp: 2,
},
];
markSectionsComplete(before, [
"network_requests",
"websocket_connections",
"websocket_frames",
]);
const compare = (left: typeof before, right: typeof before) =>
compareWebCaptures(
compareWebCapturesInputSchema.parse({
before: { inspection: left },
after: { inspection: right },
}),
).dimensions.network;
for (const missingOnRight of [false, true]) {
const known = structuredClone(before);
const missing = structuredClone(before);
const knownHop = known.network.requests[0]?.redirects?.[0];
const missingHop = missing.network.requests[0]?.redirects?.[0];
if (knownHop === undefined || missingHop === undefined)
throw new Error("Missing redirect fixture");
Reflect.set(knownHop, field, knownValue);
Reflect.set(missingHop, field, null);
const result = missingOnRight
? compare(known, missing)
: compare(missing, known);
expect(result).toMatchObject({
status: "unknown",
total_changes: 0,
reason: expect.stringContaining("redirect"),
});
expect(compare(missing, missing)).toMatchObject({
status: "unknown",
total_changes: 0,
reason: expect.stringContaining("redirect"),
});
}
const changed = structuredClone(before);
const changedHop = changed.network.requests[0]?.redirects?.[0];
if (changedHop === undefined) throw new Error("Missing redirect fixture");
changedHop.status = 307;
expect(compare(before, changed)).toMatchObject({
status: "changed",
changes: [expect.objectContaining({ change: "modified" })],
});
},
);
});
describe("web capture diff incomplete inventories", () => {
+177
View File
@@ -183,6 +183,51 @@ it(
INTEGRATION_TEST_TIMEOUT_MS,
);
it(
"compares redirect metadata uncertainty through the public CLI",
async () => {
const browser = await startFakeCdpBrowser();
browsers.push(browser);
const inspected = await runCli(
[
"inspect-web-page",
browser.endpoint,
"allowed-page",
"--observation-ms",
"0",
"--json",
],
process.env,
);
const inspection = normalizedResult(inspected);
const missingVsKnown = await compareCapturesCli(
redirectCapture(inspection, browser.allowedOrigin, null),
redirectCapture(inspection, browser.allowedOrigin, 302),
);
expect(missingVsKnown).toMatchObject({
normalized_result: {
dimensions: {
network: {
status: "unknown",
total_changes: 0,
reason: expect.stringContaining("redirect"),
},
},
},
});
const knownDifference = await compareCapturesCli(
redirectCapture(inspection, browser.allowedOrigin, 302),
redirectCapture(inspection, browser.allowedOrigin, 307),
);
expect(knownDifference).toMatchObject({
normalized_result: {
dimensions: { network: { status: "changed", total_changes: 1 } },
},
});
},
INTEGRATION_TEST_TIMEOUT_MS,
);
it(
"browser CLI attaches through a browser socket and records it redacted",
async () => {
@@ -344,6 +389,68 @@ describe("scenario comparison CLI diagnostics", () => {
);
});
describe("scenario comparison recovery guidance", () => {
it("reports independent network recovery alongside console differences", async () => {
const base = completeScenarioCapture();
const matchingSelection = scenarioCaptureWithConsole(
"before",
base.scenario.network_content,
);
const differentSelection = scenarioCaptureWithConsole("after", {
...base.scenario.network_content,
response_body: true,
});
const changed = await compareCapturesCli(
matchingSelection,
differentSelection,
);
expect(changed).toMatchObject({
normalized_result: {
overall_status: "changed",
steps: expect.arrayContaining([
expect.objectContaining({
step_id: "open-settings",
artifact_diffs: expect.arrayContaining([
expect.objectContaining({
artifact: "events",
status: "changed",
reason: expect.stringContaining(
"Network content selections differ",
),
}),
]),
}),
]),
},
});
const quietMismatch = await compareCapturesCli(
matchingSelection,
scenarioCaptureWithConsole(
"before",
differentSelection.scenario.network_content,
),
);
expect(quietMismatch).toMatchObject({
normalized_result: {
steps: expect.arrayContaining([
expect.objectContaining({
step_id: "open-settings",
artifact_diffs: expect.arrayContaining([
expect.objectContaining({
artifact: "events",
status: "unknown",
reason: expect.stringContaining(
"Network content selections differ",
),
}),
]),
}),
]),
},
});
});
});
describe("browser CLI capture parity", () => {
it(
"runs analysis, session, WebMCP, screenshot, and comparison from explicit request scope",
@@ -468,6 +575,43 @@ const normalizedResult = (value: unknown): Record<string, unknown> => {
return value.normalized_result;
};
const redirectCapture = (
inspection: Record<string, unknown>,
origin: string,
status: number | null,
) => {
const copy = structuredClone(inspection);
if (!isRecord(copy.network) || !Array.isArray(copy.network.requests))
throw new Error("Missing network capture");
const request = copy.network.requests[0];
if (!isRecord(request)) throw new Error("Missing network request");
request.redirects = [
{
url: `${origin}/prior`,
response_url: `${origin}/prior`,
method: "GET",
resource_type: "Fetch",
status,
mime_type: "text/plain",
encoded_data_length: 12,
request_timestamp: 1,
redirect_event_timestamp: 2,
},
];
return { inspection: copy };
};
const compareCapturesCli = (before: unknown, after: unknown) =>
runCli(
[
"compare-web-captures",
JSON.stringify(before),
JSON.stringify(after),
"--json",
],
process.env,
);
const completeScenarioCapture = () => {
const url = {
url: "https://app.example.test/",
@@ -533,5 +677,38 @@ const completeScenarioCapture = () => {
};
};
const scenarioCaptureWithConsole = (
text: string,
networkContent: {
readonly request_body: boolean;
readonly response_body: boolean;
readonly header_values: boolean;
},
) => {
const capture = completeScenarioCapture();
return {
...capture,
scenario: {
...capture.scenario,
network_content: networkContent,
event_families: ["console"],
},
events: {
retained: 1,
dropped: 0,
items: [
{
sequence: 1,
step_index: 1,
kind: "console",
level: "info",
text,
url: null,
},
],
},
};
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
@@ -0,0 +1,105 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { createEvidence } from "../../../src/domain/evidence.js";
import { bundleComparisonResultSchema } from "../../../src/domain/bundleComparison.js";
import {
createEvidenceBundle,
serializeEvidenceBundle,
} from "../../../src/domain/evidenceBundle.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"compares Evidence bundles without unexplained display-only changes",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-bundle-comparison-cli-");
const leftPath = join(root, "left.json");
const displayPath = join(root, "display.json");
const changedPath = join(root, "changed.json");
const target = {
path: join(root, "left.elf"),
sha256: "a".repeat(64),
format: "elf" as const,
architecture: "x86_64" as const,
};
const record = createEvidence(
target,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "observe",
parameters: {},
result: { observed: true },
confidence: "observed",
authority: "shipped-artifact",
},
);
const left = createEvidenceBundle([record]);
const displayOnly = {
...left,
records: [
{
...record,
subject: {
...record.subject!,
name: "renamed.elf",
local_path: join(root, "renamed.elf"),
},
},
],
};
const different = createEvidenceBundle([
createEvidence(
target,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "observe",
parameters: {},
result: { observed: false },
confidence: "observed",
authority: "shipped-artifact",
},
),
]);
await Promise.all([
writeFile(leftPath, serializeEvidenceBundle(left)),
writeFile(displayPath, serializeEvidenceBundle(displayOnly)),
writeFile(changedPath, serializeEvidenceBundle(different)),
]);
const compare = async (path: string) =>
cli.run({ arguments: ["compare", leftPath, path, "--json"] });
const same = await compare(leftPath);
expect(same.exitCode).toBe(0);
expect(bundleComparisonResultSchema.parse(same.json)).toMatchObject({
status: "unchanged",
changes: [],
});
const display = await compare(displayPath);
expect(display.exitCode).toBe(0);
const displayResult = bundleComparisonResultSchema.parse(display.json);
expect(displayResult).toMatchObject({ status: "unchanged", changes: [] });
expect(displayResult.left_bundle_sha256).not.toBe(
displayResult.right_bundle_sha256,
);
const differentResult = await compare(changedPath);
expect(differentResult.exitCode).toBe(0);
expect(
bundleComparisonResultSchema.parse(differentResult.json),
).toMatchObject({
status: "unknown",
summary: { unresolved: 2 },
changes: [
expect.objectContaining({
entity: "evidence",
conclusion_kind: "unresolved_branch",
}),
expect.objectContaining({
entity: "evidence",
conclusion_kind: "unresolved_branch",
}),
],
});
},
);
@@ -1,13 +1,13 @@
import { access, chmod, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { delimiter, join } from "node:path";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest.skipIf(process.platform === "win32")(
"preserves a configured Hopper launcher through public CLI doctor without launching it",
cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
"retains a configured Hopper path in host diagnostics without launching it",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-doctor-hopper-home-");
const launcher = `${home}/bin/../bin/hopper`;
@@ -18,31 +18,75 @@ cliTest.skipIf(process.platform === "win32")(
'#!/bin/sh\nprintf launched > "$REA_HOPPER_TEST_MARKER"\nexit 99\n',
);
await chmod(launcher, 0o700);
const loaderMarker = join(home, "loader-checked-launcher");
if (process.platform === "linux") {
// A shell fixture needs controlled loader diagnostics on Linux. This
// checks discovery and path preservation, not actual Hopper compatibility.
const loader = join(home, "bin", "ldd");
await writeFile(
loader,
[
"#!/bin/sh",
'test "$#" -eq 1 || exit 87',
'test "$1" = "$HOPPER_LAUNCHER_PATH" || exit 88',
'printf "%s" "$1" > "$REA_HOPPER_LDD_TEST_MARKER"',
'printf "%s\\n" "libc.so.6 => /fixture/libc.so.6 (0x1)"',
"",
].join("\n"),
);
await chmod(loader, 0o700);
}
const result = await cli.run({
arguments: ["doctor", "--provider", "hopper", "--json"],
cwd: home,
environment: {
...(process.platform === "linux"
? {
PATH: [join(home, "bin"), process.env.PATH ?? ""].join(delimiter),
REA_HOPPER_LDD_TEST_MARKER: loaderMarker,
}
: {}),
HOME: home,
USERPROFILE: home,
HOPPER_LAUNCHER_PATH: launcher,
REA_HOPPER_TEST_MARKER: launchMarker,
GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"),
},
timeoutMs: 20_000,
});
const hopperReady = process.platform === "darwin";
expect(result.signal).toBeNull();
expect(result.exitCode).toBe(hopperReady ? 0 : 1);
expect(result.json).toMatchObject({
healthy: hopperReady,
environment_healthy: false,
hopperPath: launcher,
checks: expect.arrayContaining([
expect.objectContaining({
name: "hopper",
ok: true,
detail: launcher,
classification: "healthy",
}),
...(process.platform === "linux"
? [
expect.objectContaining({
name: "hopper-version",
ok: false,
detail: launcher,
classification: "config_drift",
remediation: expect.stringContaining("HOPPER_LAUNCHER_PATH"),
}),
]
: []),
]),
});
await expect(access(launchMarker)).rejects.toMatchObject({
code: "ENOENT",
});
if (process.platform === "linux")
expect(await readFile(loaderMarker, "utf8")).toBe(launcher);
},
);
+121
View File
@@ -13,6 +13,12 @@ import {
type FakeCdpBrowser,
} from "../../fixtures/fakeCdpBrowser.js";
import { writeElectronBoundaryFixture } from "../../fixtures/electronBoundaryApplication.js";
import {
SOURCE,
applicationFixture,
analyzeFixture,
electronRuntimeEvidence,
} from "../../fixtures/javascriptRuntimeReconciliation.js";
import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../../../src/contracts/javascript/javascriptRuntimeReconciliationExample.js";
const execute = promisify(execFile);
@@ -219,8 +225,123 @@ describe("Electron CLI runtime reconciliation", () => {
},
INTEGRATION_TEST_TIMEOUT_MS,
);
it(
"keeps ambiguous runtime candidates unknown while unrelated scoped assets remain absent",
async () => {
const application = await applicationFixture();
const assets = await createTestTempDirectory("rea-runtime-cli-assets-");
const runtime = await createTestTempDirectory("rea-runtime-cli-live-");
const inputPath = join(assets, "reconciliation.json");
temporary.push(application, assets, runtime);
await Promise.all([
writeFile(join(assets, "app.js"), SOURCE),
writeFile(join(assets, "absent.js"), "export const absent = true;\n"),
writeFile(join(runtime, "app.js"), SOURCE),
]);
const rootMapping = [{ kind: "file-root", root: runtime }] as const;
const input = {
static_layers: [
{
role: "application",
analysis: await analyzeFixture(application),
runtime_mappings: rootMapping,
},
{
role: "assets",
analysis: await analyzeFixture(assets),
runtime_mappings: rootMapping,
},
],
runtime_observations: [
electronRuntimeEvidence(runtime, SOURCE, { includeWorker: false }),
],
};
await writeFile(inputPath, JSON.stringify(input));
const reconciled = await runCli(
["reconcile-javascript-runtime", inputPath, "--json"],
process.env,
);
const normalized =
isRecord(reconciled) && isRecord(reconciled.normalized_result)
? reconciled.normalized_result
: undefined;
const reconciliations = normalized?.reconciliations;
const loadStates = normalized?.static_load_states;
if (!Array.isArray(reconciliations) || !Array.isArray(loadStates))
throw new Error("Missing runtime reconciliation output");
const ambiguous = reconciliations.find(
(item) =>
isRecord(item) &&
item.status === "ambiguous" &&
item.entity_kind === "script",
);
if (
!isRecord(ambiguous) ||
!Array.isArray(ambiguous.candidate_static_nodes)
)
throw new Error("Missing ambiguous runtime script");
const candidateIds = ambiguous.candidate_static_nodes.flatMap((item) =>
isRecord(item) && typeof item.static_node_id === "string"
? [item.static_node_id]
: [],
);
expect(candidateIds).toHaveLength(2);
const candidateStates = loadStates.filter(
(item) =>
isRecord(item) &&
typeof item.static_node_id === "string" &&
candidateIds.includes(item.static_node_id),
);
expect(candidateStates).toHaveLength(2);
expect(
candidateStates.every(
(item) =>
isRecord(item) &&
item.status === "unknown" &&
item.reason === "ambiguous-runtime-candidate",
),
).toBe(true);
expect(loadStates).toEqual(
expect.arrayContaining([
expect.objectContaining({
status: "not-observed-in-capture",
reason: "not-observed-in-bounded-capture",
}),
]),
);
const singleLayerPath = join(application, "single-reconciliation.json");
await writeFile(
singleLayerPath,
JSON.stringify({
static_layers: [input.static_layers[0]],
runtime_observations: input.runtime_observations,
}),
);
const uniquelyMatched = await runCli(
["reconcile-javascript-runtime", singleLayerPath, "--json"],
process.env,
);
expect(uniquelyMatched).toMatchObject({
normalized_result: {
static_load_states: expect.arrayContaining([
expect.objectContaining({
status: "loaded",
reason: "runtime-script-correspondence",
}),
]),
},
});
},
INTEGRATION_TEST_TIMEOUT_MS,
);
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const runCli = async (
arguments_: readonly string[],
environment: NodeJS.ProcessEnv,
@@ -9,6 +9,19 @@ import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/j
import { applicationFeatureTraceResultSchema } from "../../../src/domain/javascript/javascriptFeatureTraceSchemas.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
const sourcePositionSchema = z.object({ line: z.number(), column: z.number() });
const callPathConditionSchema = z.object({
expression: z.string(),
location: z.object({
start: sourcePositionSchema,
end: sourcePositionSchema,
}),
});
const callPathTerminalObservationSchema = z.object({
properties: z.object({ conditions: z.array(callPathConditionSchema) }),
evidence: z.object({ coverage: z.object({ status: z.string() }) }),
});
const analyze = async (cli: TestCli, extra = "") => {
const root = await createTestTempDirectory("rea-electron-wrapped-paths-");
await writeFixtureFiles(root, {
@@ -123,6 +136,80 @@ cliTest(
},
);
cliTest(
"preserves early-return predicates on recovered bridge paths through CLI and stdio MCP",
async ({ cli }) => {
const cases = [
{
name: "conditional early return",
body: 'if(x)return;e.contextBridge.exposeInMainWorld("api",{copy:()=>e.ipcRenderer.send("x")});',
conditions: ["!(x)"],
coverage: "partial",
},
{
name: "equivalent nested branch",
body: 'if(!x){e.contextBridge.exposeInMainWorld("api",{copy:()=>e.ipcRenderer.send("x")});}',
conditions: ["!x"],
coverage: "partial",
},
{
name: "definite early return",
body: 'return;e.contextBridge.exposeInMainWorld("api",{copy:()=>e.ipcRenderer.send("x")});',
conditions: [],
coverage: "complete",
},
{
name: "unguarded exposure",
body: 'e.contextBridge.exposeInMainWorld("api",{copy:()=>e.ipcRenderer.send("x")});',
conditions: [],
coverage: "complete",
},
] as const;
for (const surface of ["CLI", "stdio MCP"] as const)
for (const fixture of cases) {
const root = await createTestTempDirectory(
"rea-electron-early-return-",
);
await writeFixtureFiles(root, {
"index.js": `const e=require("electron");function f(x){${fixture.body}}f(globalThis.blocked);`,
});
const application = await analyzeJavaScriptDirectory(
surface,
cli,
root,
);
const observations = application.graph.nodes.flatMap(
(node) => node.observations,
);
const terminal = observations.find(
(observation) =>
observation.properties.entry === "api.copy" &&
observation.properties.step_kind === "terminal",
);
if (fixture.name === "definite early return") {
expect(terminal).toBeUndefined();
continue;
}
const terminalEvidence =
callPathTerminalObservationSchema.parse(terminal);
expect(
terminalEvidence.properties.conditions.map(
({ expression }) => expression,
),
).toEqual(fixture.conditions);
expect(terminalEvidence.evidence.coverage.status).toBe(
fixture.coverage,
);
expect(application.graph.coverage.status).toBe(fixture.coverage);
const source = observations.find(
(observation) => observation.properties.file_kind === "javascript",
);
expect(source?.properties.parse_status).toBe(fixture.coverage);
}
},
);
cliTest(
"traces a nested contextBridge member through a unique IPC channel",
async ({ cli }) => {
+31 -25
View File
@@ -18,10 +18,10 @@ const evidenceTarget = async () => {
return path;
};
describe.skipIf(process.platform !== "darwin")(
"native Swift demangling CLI with line-terminator characters",
() => {
cliTest("keeps a carriage return in the symbol", async ({ cli }) => {
describe("Swift demangling CLI with line-terminator characters", () => {
cliTest.skipIf(process.platform !== "darwin")(
"keeps a carriage return in the symbol",
async ({ cli }) => {
const result = await cli.run({
arguments: [
"demangle-swift",
@@ -41,26 +41,32 @@ describe.skipIf(process.platform !== "darwin")(
],
},
});
});
},
);
cliTest("rejects a symbol spanning lines as input", async ({ cli }) => {
const missingTarget = join(
await createTestTempDirectory("rea-demangle-invalid-input-"),
"absent",
);
const result = await cli.run({
arguments: [
"demangle-swift",
missingTarget,
"$s4main3FooV",
"$s4main\n3FooV",
"--json",
],
environment,
timeoutMs: 20_000,
});
expect(result.exitCode).not.toBe(0);
expect(result.json).toMatchObject({ code: "VALIDATION_ERROR" });
cliTest("rejects a symbol spanning lines as input", async ({ cli }) => {
const missingTarget = join(
await createTestTempDirectory("rea-demangle-invalid-input-"),
"absent",
);
const result = await cli.run({
arguments: [
"demangle-swift",
missingTarget,
"$s4main3FooV",
"$s4main\n3FooV",
"--json",
],
environment,
timeoutMs: 20_000,
});
},
);
expect(result.signal).toBeNull();
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "VALIDATION_ERROR",
fieldErrors: expect.arrayContaining([
expect.objectContaining({ path: "symbols.1", missing: false }),
]),
});
});
});
@@ -3,11 +3,86 @@ import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { createEvidence } from "../../../src/domain/evidence.js";
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
const execute = promisify(execFile);
describe("reconstruction obligation ledger CLI parity", () => {
it("reports non-canonical authenticated bundles as caller input", async () => {
const records = ["first", "second"].map((label) =>
createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "observe",
parameters: { label },
result: { label },
},
),
);
const canonical = createEvidenceBundle(records);
const input = {
evidence_bundle: canonical,
reviewed_obligations: [],
manifest: { bindings: [], contradictions: [] },
};
const accepted = await execute(
process.execPath,
[
"scripts/rea.mjs",
"build-reconstruction-obligation-ledger",
JSON.stringify(input),
"--json",
],
{ cwd: process.cwd(), env: process.env, maxBuffer: 16 * 1_024 * 1_024 },
);
expect(JSON.parse(accepted.stdout)).toMatchObject({
normalized_result: { status: "unknown", summary: { total: 0 } },
});
input.evidence_bundle = {
...canonical,
records: [...canonical.records].reverse(),
};
let stdout: string;
try {
await execute(
process.execPath,
[
"scripts/rea.mjs",
"build-reconstruction-obligation-ledger",
JSON.stringify(input),
"--json",
],
{ cwd: process.cwd(), env: process.env, maxBuffer: 16 * 1_024 * 1_024 },
);
throw new Error("Expected non-canonical bundle input to fail");
} catch (cause: unknown) {
if (
!(cause instanceof Error) ||
!("stdout" in cause) ||
typeof cause.stdout !== "string"
)
throw cause;
stdout = cause.stdout;
}
expect(JSON.parse(stdout)).toMatchObject({
code: "invalid_request",
category: "invalid_input",
retryable: true,
details: {
operation: "build_reconstruction_obligation_ledger",
issues: [
expect.objectContaining({
message: expect.stringContaining(
"Evidence bundle manifests are not canonical",
),
}),
],
},
});
}, 20_000);
it("builds the same Evidence-backed empty ledger through the CLI", async () => {
const input = {
evidence_bundle: createEvidenceBundle([]),
@@ -169,7 +169,6 @@ printf '%s\n' 'C:\Users\runner with spaces\AppData\Local\npm-cache'
const step = steps[index];
if (step?.uses !== "./.github/actions/npm-cache") continue;
npmCacheSteps++;
expect(step.id).toBe("npm-cache");
const setup = steps[index - 1];
expect(
typeof setup?.uses === "string" &&
@@ -198,6 +197,9 @@ it("shares static task snapshots only after validation using the npm cache owner
const workflows = await readWorkflows();
const ci = workflows.find(({ file }) => file === "ci.yml");
const steps = ci?.workflow.jobs?.static?.steps ?? [];
expect(
steps.find((step) => step.uses === "./.github/actions/npm-cache")?.id,
).toBe("npm-cache");
const restore = steps.find((step) => step.id === "static-cache");
const save = steps.find(
(step) => step.name === "Save validated static task cache",
@@ -221,6 +223,24 @@ it("shares static task snapshots only after validation using the npm cache owner
);
});
it("passes the npm cache owner's selected path to package installation", async () => {
const workflows = await readWorkflows();
const ci = workflows.find(({ file }) => file === "ci.yml");
const steps = ci?.workflow.jobs?.["package-e2e"]?.steps ?? [];
const cacheIndex = steps.findIndex(
(step) => step.uses === "./.github/actions/npm-cache",
);
expect(steps[cacheIndex]?.id).toBe("npm-cache");
const consumerIndex = steps.findIndex(
(step) => step.run === "npm run verify:package",
);
expect(consumerIndex).toBeGreaterThan(cacheIndex);
expect(
z.record(z.string(), z.unknown()).parse(steps[consumerIndex]?.env)
.npm_config_cache,
).toBe("${{ steps.npm-cache.outputs.path }}");
});
it("bounds the rolling static snapshot with Turbo age and size eviction", async () => {
const turbo = z
.object({
@@ -338,68 +338,6 @@ it.each(reverseToolsInputs)(
},
);
it("requires the reverse-tool gate when its scope is selected", async () => {
const workflow = parse(
await readFile(
new URL("../../../.github/workflows/ci.yml", import.meta.url),
"utf8",
),
);
expect(workflow.jobs["reverse-tools-quality"].if).toBe(
"needs.changes.outputs.reverse_tools == 'true'",
);
expect(workflow.jobs.required.needs).toContain("reverse-tools-quality");
const { CI_FLAGS } = await loadScopes();
const outputs = {
...Object.fromEntries(CI_FLAGS.map((flag) => [flag, "false"])),
tier: "fast",
reverse_tools: "true",
};
const command = new URL("../../../scripts/ci/required.mjs", import.meta.url)
.pathname;
await expect(
execFileAsync(process.execPath, [command], {
env: {
...process.env,
CI_JOBS: JSON.stringify({
changes: { result: "success", outputs },
static: { result: "success" },
test: { result: "success" },
"reverse-tools-quality": { result: "skipped" },
}),
},
}),
).rejects.toMatchObject({ code: 1 });
await expect(
execFileAsync(process.execPath, [command], {
env: {
...process.env,
CI_JOBS: JSON.stringify({
changes: { result: "success", outputs },
static: { result: "success" },
test: { result: "success" },
"reverse-tools-quality": { result: "failure" },
}),
},
}),
).rejects.toMatchObject({ code: 1 });
await expect(
execFileAsync(process.execPath, [command], {
env: {
...process.env,
CI_JOBS: JSON.stringify({
changes: { result: "success", outputs },
static: { result: "success" },
test: { result: "success" },
"reverse-tools-quality": { result: "success" },
}),
},
}),
).resolves.toMatchObject({
stdout: expect.stringContaining("Every selected CI lane passed"),
});
});
it.each([
"src/README.md",
"README.md.ts",
@@ -958,6 +896,10 @@ it("rejects skipped selected work while allowing unselected lanes to skip", asyn
});
it.each([
[
".github/workflows/mutation.yml",
{ workflows: true, code: true, reverse_tools: true, package: false },
],
[
".github/workflows/sqlite-runtime.yml",
{ workflows: true, code: true, package: false, website: false },
@@ -38,6 +38,9 @@ const workflow = async () =>
z
.object({
run: z.string().optional(),
if: z.string().optional(),
uses: z.string().optional(),
with: z.record(z.string(), z.unknown()).optional(),
env: z.record(z.string(), z.string()).optional(),
})
.passthrough(),
@@ -56,6 +59,143 @@ const workflow = async () =>
),
).jobs;
const evaluateCondition = (
condition: string,
context: Record<string, unknown>,
): boolean =>
z
.boolean()
.parse(
runInNewContext(
condition.startsWith("${{") ? condition.slice(3, -2) : condition,
context,
{ timeout: 1_000 },
),
);
it.each(["fast", "full"] as const)(
"requires coverage and both actual mutation preflights in %s qualification",
async (tier) => {
const jobs = await workflow();
expect(jobs["reverse-tools-quality"]?.if).toBe(
"needs.changes.outputs.reverse_tools == 'true'",
);
expect(jobs.required?.needs).toContain("reverse-tools-quality");
const steps = jobs["reverse-tools-quality"]?.steps ?? [];
const commands = steps
.filter(
(step) =>
step.if === undefined ||
evaluateCondition(step.if, {
needs: { changes: { outputs: { tier } } },
cancelled: () => false,
success: () => true,
failure: () => false,
always: () => true,
}),
)
.flatMap((step) => step.run?.trim().split("\n") ?? [])
.map((command) => command.trim());
const mutationCommands = commands.filter(
(command) => command.includes("mutation") || command.includes("stryker"),
);
expect(mutationCommands).toEqual([
"node scripts/prepare-reverse-tools-mutation.mjs",
"npx --no-install stryker run stryker.config.mjs --dryRunOnly",
"npx --no-install stryker run stryker.semantics.config.mjs --dryRunOnly",
]);
const coverage = commands.indexOf("npm run test:coverage:reverse-tools");
expect(coverage).toBeGreaterThanOrEqual(0);
expect(coverage).toBeLessThan(commands.indexOf(mutationCommands[0]!));
const flags = await scopeFlags();
const context: Record<
string,
{ result: string; outputs?: Record<string, string> }
> = Object.fromEntries(
Object.keys(jobs).map((name) => [name, { result: "success" }]),
);
context.changes = {
result: "success",
outputs: {
...Object.fromEntries(
flags.map((flag) => [flag, tier === "full" ? "true" : "false"]),
),
tier,
code: "true",
reverse_tools: "true",
},
};
for (const result of [
"success",
"failure",
"cancelled",
"skipped",
"missing",
]) {
const needs = { ...context };
if (result === "missing") delete needs["reverse-tools-quality"];
else needs["reverse-tools-quality"] = { result };
const execution = execute(
process.execPath,
[
fileURLToPath(
new URL("../../../scripts/ci/required.mjs", import.meta.url),
),
],
{
env: {
...process.env,
CI_JOBS: JSON.stringify(needs),
CI_QUALIFICATION_DIRECTORY: "",
},
},
);
if (result === "success")
await expect(execution).resolves.toMatchObject({ stderr: "" });
else
await expect(execution).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("reverse-tools-quality"),
});
}
},
);
it("retains available quality diagnostics after failure without keeping canceled uploads alive", async () => {
const steps = (await workflow())["reverse-tools-quality"]?.steps ?? [];
const upload = steps.find((step) =>
step.uses?.startsWith("actions/upload-artifact@"),
);
const condition = z.string().parse(upload?.if);
for (const cancelled of [false, true])
expect(
evaluateCondition(condition, {
cancelled: () => cancelled,
success: () => false,
failure: () => true,
always: () => true,
}),
).toBe(!cancelled);
const options = upload?.with ?? {};
expect(z.string().parse(options.name)).toContain(
"${{ needs.changes.outputs.tier }}",
);
expect(z.string().parse(options.name)).toContain("${{ github.run_attempt }}");
expect(
z
.string()
.parse(options.path)
.trim()
.split("\n")
.map((path) => path.trim()),
).toEqual(
expect.arrayContaining(["coverage/reverse-tools/", "reports/mutation/"]),
);
expect(options["retention-days"]).toBe(7);
expect(options["if-no-files-found"]).toBe("warn");
});
it("starts source feedback before build and requires both groups without waiving missing jobs", async () => {
const jobs = await workflow();
expect(
@@ -29,6 +29,7 @@ it.each([
const client = { name, format, configPath };
const custom = {
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
REA_MCP_OUTPUT_SCHEMA_PROFILE: "off",
REA_MCP_MAX_RESPONSE_BYTES: "33554432",
REA_BROWSER_EXECUTABLE: join(root, "custom-browser"),
GHIDRA_INSTALL_DIR: join(root, "old-ghidra"),
@@ -143,6 +144,16 @@ it.each([
{ REA_MCP_INPUT_SCHEMA_PROFILE: "bogus" },
"REA_MCP_INPUT_SCHEMA_PROFILE",
],
[
"empty output profile",
{ REA_MCP_OUTPUT_SCHEMA_PROFILE: "" },
"REA_MCP_OUTPUT_SCHEMA_PROFILE",
],
[
"invalid output profile",
{ REA_MCP_OUTPUT_SCHEMA_PROFILE: "bogus" },
"REA_MCP_OUTPUT_SCHEMA_PROFILE",
],
[
"invalid response budget",
{ REA_MCP_MAX_RESPONSE_BYTES: "1" },
@@ -0,0 +1,145 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { parse } from "yaml";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
const stepSchema = z
.object({
name: z.string().optional(),
run: z.string().optional(),
if: z.string().optional(),
uses: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
with: z.record(z.string(), z.unknown()).optional(),
})
.passthrough();
const readWorkflow = async () =>
z
.object({
on: z.record(z.string(), z.unknown()),
permissions: z.object({ contents: z.literal("read") }).strict(),
concurrency: z.object({ "cancel-in-progress": z.literal(false) }),
jobs: z.object({
mutation: z
.object({
strategy: z.object({
"fail-fast": z.literal(false),
"max-parallel": z.literal(2),
matrix: z.object({
include: z.array(
z.object({ suite: z.string(), config: z.string() }),
),
}),
}),
steps: z.array(stepSchema),
})
.passthrough(),
}),
})
.parse(
parse(
await readFile(
new URL("../../../.github/workflows/mutation.yml", import.meta.url),
"utf8",
),
),
);
it("runs both complete canonical sweeps independently of PR and release gates", async () => {
const workflow = await readWorkflow();
expect(Object.keys(workflow.on).sort()).toEqual([
"schedule",
"workflow_dispatch",
]);
const job = workflow.jobs.mutation;
expect(job["continue-on-error"]).toBeUndefined();
expect(job.if).toBeUndefined();
expect(job.strategy.matrix.include).toEqual([
{ suite: "reverse-tools", config: "stryker.config.mjs" },
{
suite: "reverse-tools-semantics",
config: "stryker.semantics.config.mjs",
},
]);
const sweep = job.steps.find(
(step) => step.env?.MUTATION_CONFIG !== undefined,
);
expect(sweep?.env?.MUTATION_CONFIG).toBe("${{ matrix.config }}");
expect(sweep?.run?.trim().split("\n")).toEqual([
"node scripts/prepare-reverse-tools-mutation.mjs",
'npx --no-install stryker run "${MUTATION_CONFIG}"',
]);
expect(job.steps.some((step) => step.run === "npm run test:prepare")).toBe(
true,
);
const upload = job.steps.find((step) =>
step.uses?.startsWith("actions/upload-artifact@"),
);
expect(upload?.if).toBe("${{ !cancelled() }}");
expect(upload?.with?.name).toBe(
"mutation-${{ matrix.suite }}-${{ github.run_attempt }}",
);
expect(upload?.with?.["retention-days"]).toBe(7);
const paths = z.string().parse(upload?.with?.path).trim().split("\n");
for (const { suite, config } of job.strategy.matrix.include) {
const canonical = z
.object({
default: z.object({
jsonReporter: z.object({ fileName: z.string() }),
htmlReporter: z.object({ fileName: z.string() }),
}),
})
.parse(
await import(new URL(`../../../${config}`, import.meta.url).href),
).default;
expect(
paths.map((path) => path.replace("${{ matrix.suite }}", suite)),
).toEqual([
canonical.jsonReporter.fileName,
canonical.htmlReporter.fileName,
]);
}
for (const step of job.steps)
expect(step["continue-on-error"]).toBeUndefined();
});
it("fails a successful sweep when either required report is absent or empty", async () => {
const steps = (await readWorkflow()).jobs.mutation.steps;
const guard = steps.find((step) => step.env?.REPORT_BASE !== undefined);
expect(guard?.if).toBeUndefined();
const command = z.string().parse(guard?.run);
const guardIndex = steps.indexOf(guard!);
expect(guardIndex).toBeGreaterThan(
steps.findIndex((step) => step.env?.MUTATION_CONFIG),
);
expect(guardIndex).toBeLessThan(
steps.findIndex((step) =>
step.uses?.startsWith("actions/upload-artifact@"),
),
);
for (const state of ["missing", "empty-json", "empty-html", "complete"]) {
const directory = await createTestTempDirectory("rea-mutation-reports-");
const base = join(directory, "reports", "reverse-tools");
await mkdir(join(directory, "reports"));
if (state !== "missing") {
await writeFile(`${base}.json`, state === "empty-json" ? "" : "{}");
await writeFile(
`${base}.html`,
state === "empty-html" ? "" : "<html></html>",
);
}
const result = execute("bash", ["-eu", "-c", command], {
cwd: directory,
env: { ...process.env, REPORT_BASE: base },
});
if (state === "complete")
await expect(result).resolves.toMatchObject({ stderr: "" });
else await expect(result).rejects.toMatchObject({ code: 1 });
}
});
+15 -2
View File
@@ -106,6 +106,7 @@ beforeAll(async () => {
author("platformbot", "Bot"),
author("claude"),
author("cursoragent"),
author("codex"),
author("morluto"),
]),
associatedPullRequests: page([]),
@@ -218,18 +219,30 @@ it("inventories side branches and paginated credits, then checks and renders syn
const f = await fixture();
const inventory = z
.object({
commits: z.array(z.object({ sha: z.string() })),
commits: z.array(
z.object({
sha: z.string(),
authors: z.array(
z.object({ user: z.object({ login: z.string() }).nullable() }),
),
}),
),
pullRequests: z.array(z.object({ number: z.number() })),
})
.parse(JSON.parse(await readFile(f.manifest, "utf8")));
expect(inventory.commits).toHaveLength(3);
expect(
inventory.commits.flatMap(({ authors }) =>
authors.map(({ user }) => user?.login),
),
).toEqual(expect.arrayContaining(["claude", "cursoragent", "codex"]));
expect(inventory.pullRequests.map((pr) => pr.number)).toEqual([101]);
expect(f.record).toContain(
"Thanks @alice, @bob, @reporter, @second-reporter.",
);
expect(f.record).toContain("Thanks @dana.");
expect(f.record).not.toMatch(
/@(?:merger|automation|platformbot|claude|cursoragent|morluto)/u,
/@(?:merger|automation|platformbot|claude|cursoragent|codex|morluto)/u,
);
const body = join(f.directory, "body.md");
await writeFile(
@@ -1,9 +1,10 @@
import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
@@ -16,24 +17,45 @@ const runNode = (source: string, ...arguments_: string[]): string =>
);
describe("reverse-tools mutation configuration", () => {
it("loads nonempty targets from both actual Stryker configs", () => {
it("loads nonempty targets and distinct report destinations from both actual Stryker configs", () => {
const result = runNode(
`const [mainUrl, semanticsUrl] = process.argv.slice(1);
const main = await import(mainUrl);
const semantics = await import(semanticsUrl);
process.stdout.write(JSON.stringify({
main: main.default.mutate.length,
semantics: semantics.default.mutate.length,
}));`,
process.stdout.write(JSON.stringify([main.default, semantics.default].map(config => ({
targets: config.mutate.length,
json: config.jsonReporter?.fileName,
html: config.htmlReporter?.fileName,
dryRunOnly: config.dryRunOnly ?? false,
reporters: config.reporters,
}))));`,
pathToFileURL(join(root, "stryker.config.mjs")).href,
pathToFileURL(join(root, "stryker.semantics.config.mjs")).href,
);
const targets = JSON.parse(result) as {
main: number;
semantics: number;
};
expect(targets.main).toBeGreaterThan(0);
expect(targets.semantics).toBeGreaterThan(0);
const configs = z
.array(
z.object({
targets: z.number().int().positive(),
json: z.string().regex(/^reports\/mutation\/.+\.json$/u),
html: z.string().regex(/^reports\/mutation\/.+\.html$/u),
dryRunOnly: z.literal(false),
reporters: z.array(z.string()),
}),
)
.length(2)
.parse(JSON.parse(result));
expect(
new Set(
configs.flatMap(({ json, html }) => [
resolve(root, json),
resolve(root, html),
]),
).size,
).toBe(4);
for (const config of configs)
expect(config.reporters).toEqual(
expect.arrayContaining(["json", "html"]),
);
});
it("fails closed when an owned mutation method is missing", async () => {
@@ -9,6 +9,8 @@ import { afterEach, describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { bundleComparisonResultSchema } from "../../../src/domain/bundleComparison.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import {
createEvidenceBundle,
@@ -37,6 +39,107 @@ const sourceEvidence = (label: string) =>
},
);
describe("bundle comparison MCP semantic status", () => {
it("keeps display-only metadata out of deltas and preserves semantic changes", async () => {
const root = await createTestTempDirectory("rea-bundle-metadata-mcp-");
roots.push(root);
const leftPath = join(root, "left.json");
const displayPath = join(root, "display.json");
const changedPath = join(root, "changed.json");
const target = {
path: join(root, "left.elf"),
sha256: "a".repeat(64),
format: "elf" as const,
architecture: "x86_64" as const,
};
const record = createEvidence(
target,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "observe",
parameters: {},
result: { observed: true },
confidence: "observed",
authority: "shipped-artifact",
},
);
const left = createEvidenceBundle([record]);
const displayRecord = {
...record,
subject: {
...record.subject!,
name: "renamed.elf",
local_path: join(root, "renamed.elf"),
},
};
const displayOnly = { ...left, records: [displayRecord] };
const changedRecord = createEvidence(
target,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "observe",
parameters: {},
result: { observed: false },
confidence: "observed",
authority: "shipped-artifact",
},
);
await Promise.all([
writeFile(leftPath, serializeEvidenceBundle(left)),
writeFile(displayPath, serializeEvidenceBundle(displayOnly)),
writeFile(
changedPath,
serializeEvidenceBundle(createEvidenceBundle([changedRecord])),
),
]);
const connected = await connect();
try {
const compare = async (
right: string,
recordPairs: readonly {
left_evidence_id: string;
right_evidence_id: string;
}[] = [],
) => {
const result = await connected.client.callTool({
name: "compare_bundles",
arguments: {
left_bundle_path: leftPath,
right_bundle_path: right,
record_pairs: recordPairs,
},
});
expect(result.isError).not.toBe(true);
const evidence = toolContract("compare_bundles").outputSchema.parse(
result.structuredContent,
);
return bundleComparisonResultSchema.parse(evidence.normalized_result);
};
const same = await compare(leftPath);
expect(same).toMatchObject({
status: "unchanged",
changes: [],
});
const display = await compare(displayPath);
expect(display).toMatchObject({
status: "unchanged",
changes: [],
});
expect(display.left_bundle_sha256).not.toBe(display.right_bundle_sha256);
const changed = await compare(changedPath, [
{
left_evidence_id: record.evidence_id,
right_evidence_id: changedRecord.evidence_id,
},
]);
expect(changed.status).toBe("changed");
expect(changed.changes).toHaveLength(1);
} finally {
await connected.close();
}
});
});
describe("bundle comparison MCP integration", () => {
it("reads caller-selected bundle files and returns complete Evidence", async () => {
const root = await createTestTempDirectory("rea-bundle-mcp-");
+4 -8
View File
@@ -2,19 +2,15 @@ import { copyFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { z } from "zod";
import { demangleSwiftSchema } from "../../../src/domain/native/nativeInspection.js";
import { evidenceResultOf } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { mcpTest } from "../../support/mcp/mcpFixture.js";
import { openProductionTarget } from "../../support/mcp/productionSession.js";
const demangledSchema = z.object({
normalized_result: z.object({
symbols: z.array(
z.object({ input: z.string(), output: z.string(), status: z.string() }),
),
}),
});
const demangledSchema = evidenceResultOf(demangleSwiftSchema);
describe.skipIf(process.platform !== "darwin")(
"native Swift demangling through MCP",
@@ -3,11 +3,79 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { describe, expect, it } from "vitest";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import { createServer } from "../../../src/server/createServer.js";
import { observed } from "../../fixtures/analysisExecution.js";
describe("reconstruction obligation ledger MCP parity", () => {
it("returns canonical bundle failures as caller input errors", async () => {
const records = ["first", "second"].map((label) =>
createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "observe",
parameters: { label },
result: { label },
},
),
);
const canonical = createEvidenceBundle(records);
const session = createTestBinarySession(() => ({
execute: () => Promise.resolve(observed(null)),
close: () => Promise.resolve(resultOk(null)),
}));
const server = createServer({ kind: "session", session });
const client = new Client({ name: "obligation-ledger-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const request = (evidenceBundle: unknown) =>
client.callTool({
name: "build_reconstruction_obligation_ledger",
arguments: {
evidence_bundle: evidenceBundle,
reviewed_obligations: [],
manifest: { bindings: [], contradictions: [] },
},
});
const valid = await request(canonical);
expect(valid.isError).not.toBe(true);
expect(valid.structuredContent).toMatchObject({
normalized_result: { status: "unknown", summary: { total: 0 } },
});
const result = await request({
...canonical,
records: [...canonical.records].reverse(),
});
expect(result.isError).toBe(true);
expect(parseMcpToolError(result)).toMatchObject({
error: {
code: "invalid_request",
category: "invalid_input",
details: {
operation: "build_reconstruction_obligation_ledger",
issues: [
expect.objectContaining({
message: expect.stringContaining(
"Evidence bundle manifests are not canonical",
),
}),
],
},
},
});
} finally {
await client.close();
await server.close();
await session.close();
}
});
it("returns the complete Evidence-backed ledger inline", async () => {
const session = createTestBinarySession(() => ({
execute: () => Promise.resolve(observed(null)),
+27
View File
@@ -217,6 +217,33 @@ describe("public provider availability", () => {
});
});
describe("target-free MCP fixture host isolation", () => {
it("uses fixed unavailable ports instead of probing installed Android tools", async () => {
directory = await createTestTempDirectory("rea-mcp-host-isolation-");
const { mcp } = await createSessionMcpHarness(
directory,
provider,
resources,
);
const status = structured(
await mcp.callTool({ name: "binary_session", arguments: {} }),
);
const availability = toolAvailabilityInventory.parse(
(status.result as { tool_availability: unknown }).tool_availability,
);
for (const name of [
"inspect_android_package",
"inspect_adb_client",
"inspect_apktool_client",
]) {
expect(
availability.groups.find((group) => group.names.includes(name)),
).toMatchObject({ available: false, reason: "provider_missing" });
}
});
});
describe("target-free MCP lifecycle", () => {
it("takes the session snapshot after Android readiness inspection completes", async () => {
directory = await createTestTempDirectory("rea-mcp-status-readiness-");
+39
View File
@@ -10,6 +10,10 @@ import { z } from "zod";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import type { ProviderAvailability } from "../../../src/application/AnalysisProvider.js";
import type { AndroidAnalysisPort } from "../../../src/application/android/AndroidAnalysisPort.js";
import type { AdbDeviceAnalysisPort } from "../../../src/application/adb/AdbDeviceAnalysisPort.js";
import type { ApktoolResourceAnalysisPort } from "../../../src/application/apktool/ApktoolResourceAnalysisPort.js";
import {
createEvidenceBundle,
evidenceBundleSchema,
@@ -24,6 +28,38 @@ const SNAPSHOT_PROFILE = createAnalysisProfile(
{ fixture: true },
);
const HOST_TOOLS_NOT_CONFIGURED: ProviderAvailability = {
status: "unavailable",
code: "not_configured",
reason: "The target-free MCP fixture does not probe host tools.",
diagnostics: { source: "test_fixture" },
};
const unusedHostToolExecution = async (): Promise<never> => {
throw new Error("Host-tool execution is outside the target-free fixture");
};
// These fixed ports keep this fake-session harness independent of whichever
// Android tools happen to be installed on the test host. The dedicated public
// availability test below continues to exercise status projection separately.
const androidAnalysis: AndroidAnalysisPort = {
inspectAvailability: async () => HOST_TOOLS_NOT_CONFIGURED,
close: async () => {},
execute: unusedHostToolExecution,
};
const adbDeviceAnalysis: AdbDeviceAnalysisPort = {
inspectAvailability: async () => HOST_TOOLS_NOT_CONFIGURED,
close: async () => {},
execute: unusedHostToolExecution,
};
const apktoolAnalysis: ApktoolResourceAnalysisPort = {
inspectAvailability: async () => HOST_TOOLS_NOT_CONFIGURED,
close: async () => {},
execute: unusedHostToolExecution,
};
export interface SessionMcpHarness {
readonly mcp: Client;
readonly first: string;
@@ -47,6 +83,9 @@ export const createSessionMcpHarness = async (
const server = createServer(
{ kind: "session", session },
{
androidAnalysis,
adbDeviceAnalysis,
apktoolAnalysis,
logger: silentLogger,
},
);
@@ -1,6 +1,13 @@
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { access, readFile, rm, symlink, writeFile } from "node:fs/promises";
import {
access,
mkdir,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
@@ -123,6 +130,48 @@ const terminateFixtureProcess = async (pid: number): Promise<void> => {
await waitForProcessExit(pid);
};
itWithCaptureCapability(
"retains a deleted observed file when its parent becomes a regular file",
async () => {
const root = await createTestTempDirectory("rea-observed-parent-type-");
const payload = '{"result":42}\n';
await mkdir(join(root, "reports"));
await writeFile(join(root, "reports", "final.json"), payload);
const result = await captureProcessScenario(
parseProcessScenario({
executable: process.execPath,
arguments: [
"-e",
`const fs = require('node:fs');
fs.renameSync('reports', 'saved-reports');
fs.writeFileSync('reports', 'regular file'); console.log('finished');`,
],
working_directory: root,
filesystem_observation_paths: [join(root, "reports", "final.json")],
timeout_ms: 5_000,
idle_timeout_ms: 5_000,
settle_ms: 100,
}),
);
if (!result.ok) throw result.error;
expect(result.value.exit).toMatchObject({ code: 0, reason: "exited" });
expect(result.value.filesystem_effects).toContainEqual(
expect.objectContaining({
path: "root_0:.",
status: "deleted",
before: expect.objectContaining({
type: "file",
sha256: createHash("sha256").update(payload).digest("hex"),
}),
after: null,
}),
);
expect(
await readFile(join(root, "saved-reports", "final.json"), "utf8"),
).toBe(payload);
},
);
itWithCaptureCapability(
"keeps the process deadline bounded when the wall clock moves backward",
async () => {
@@ -19,7 +19,8 @@ it.skipIf(process.platform === "win32")(
"stops the session when the provider closes its stdin pipe",
async () => {
const provider = await makeProvider(
'printf "\\000"; exec 0<&-; while :; do :; done',
// Readiness must guarantee that the child's stdin is already closed.
'exec 0<&-; printf "\\000"; while :; do :; done',
"#!/bin/sh",
);
const { manager, sessionId } = await start(provider, {
@@ -12,6 +12,75 @@ import {
managedPeFixtureTarget,
} from "../../../../src/dotnet/ManagedPe.fixture.js";
describe("managed member structural matching", () => {
it("keeps unequal constants from becoming a structural remap", () => {
const observe = (
typeName: string,
methodName: string,
constant: number,
branchOffset: number,
) =>
inspect(
buildManagedPeFixture({
typeName,
methodName,
ilBody: Buffer.from([
0x1e,
0x1f,
constant,
0x26,
0x2b,
branchOffset,
0x00,
0x2a,
]),
}),
`/tmp/${typeName}-${constant}.dll`,
);
const left = observe("OldType", "OldMethod", 5, 1);
const differentConstant = observe("NewType", "NewMethod", 7, 1);
const sameConstant = observe("NewType", "NewMethod", 5, 0);
for (const [observed, constant] of [
[left, 5],
[differentConstant, 7],
[sameConstant, 5],
] as const) {
expect(observed.result.methods[0]?.body).toMatchObject({
status: "present",
normalized_il_sha256: expect.any(String),
anchors: expect.arrayContaining([
expect.objectContaining({
operand_kind: "constant",
operand: String(constant),
}),
]),
});
}
const different = compareManagedMembers(left, differentConstant);
expect(different.methods.map(({ status }) => status)).toEqual([
"removed",
"added",
]);
expect(
different.methods.some(
({ match }) => match.basis === "structural-method-shape",
),
).toBe(false);
expect(compareManagedMembers(left, sameConstant).methods).toContainEqual(
expect.objectContaining({
status: "changed",
match: expect.objectContaining({
status: "matched",
basis: "structural-method-shape",
confidence: "high",
}),
}),
);
});
});
describe("managed member comparison", () => {
it("remaps renamed methods by exact CIL/signature without using names", () => {
const leftBytes = buildManagedPeFixture();
@@ -1,4 +1,4 @@
import { access, readFile, writeFile } from "node:fs/promises";
import { access, readFile, rm, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { describe, expect, it } from "vitest";
@@ -215,3 +215,165 @@ describe("LLDB failure retention through the production tracer", () => {
});
});
});
describe("LLDB pre-target phase proof", () => {
it("releases a proven pre-target integrity rejection and allows tracer reuse", async () => {
let launches = 0;
let firstRuntime: string | undefined;
const tracer = new LldbCallTracer(
{},
async (_executable, arguments_) => {
const { config } = await configFromArguments(arguments_);
launches += 1;
if (firstRuntime === undefined)
firstRuntime = dirname(config.result_path);
await writeFile(config.observation_path, "");
await writeFile(config.stdout_capture_path, "");
await writeFile(
config.result_path,
JSON.stringify(
launches === 1
? {
status: "target-integrity-error",
error:
"selected executable digest changed before LLDB target creation",
target_process_created: false,
}
: {
status: "target-error",
error: "second launch reached the bridge",
},
),
);
return {
kind: "exited",
targetLaunched: launches === 1,
exitCode: 1,
output: "",
targetIdentity: undefined,
cleanupFailure: undefined,
};
},
async () => ok(fakeTool),
);
const rejected = await tracer.trace(request);
if (rejected.ok) throw new Error("expected pre-target integrity failure");
expect(rejected.error).toBeInstanceOf(EvidenceIntegrityError);
expect(projectAnalysisError(rejected.error).code).toBe(
"evidence_integrity_mismatch",
);
if (firstRuntime === undefined) throw new Error("LLDB was not launched");
await expect(access(firstRuntime)).rejects.toThrow();
const retried = await tracer.trace(request);
expect(launches).toBe(2);
if (retried.ok) throw new Error("expected the second bridge result");
expect(retried.error).toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining("second launch reached the bridge"),
});
});
});
describe("LLDB retains ambiguous target ownership", () => {
it("keeps a post-target integrity rejection unresolved when its PID is missing", async () => {
let runtime: string | undefined;
const tracer = new LldbCallTracer(
{},
async (_executable, arguments_) => {
const { config } = await configFromArguments(arguments_);
runtime = dirname(config.result_path);
await writeFile(config.observation_path, "");
await writeFile(config.stdout_capture_path, "");
await writeFile(
config.result_path,
JSON.stringify({
status: "target-integrity-error",
error: "selected executable changed during LLDB launch",
target_process_created: true,
}),
);
return {
kind: "exited",
targetLaunched: true,
exitCode: 1,
output: "",
targetIdentity: undefined,
cleanupFailure: undefined,
};
},
async () => ok(fakeTool),
);
try {
const result = await tracer.trace(request);
if (result.ok) throw new Error("expected unresolved target identity");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
details: {
resources: expect.arrayContaining(["native-target:unknown"]),
},
});
expect(runtime).toBeDefined();
if (runtime !== undefined) await access(runtime);
const retry = await tracer.trace(request);
if (retry.ok) throw new Error("expected retained target cleanup");
expect(projectAnalysisError(retry.error).code).toBe("cleanup_incomplete");
} finally {
if (runtime !== undefined)
await rm(runtime, { recursive: true, force: true });
}
});
it("keeps an invalid process after Launch unresolved without producer proof", async () => {
let launches = 0;
let runtime: string | undefined;
const tracer = new LldbCallTracer(
{},
async (_executable, arguments_) => {
launches += 1;
const { config } = await configFromArguments(arguments_);
runtime = dirname(config.result_path);
await writeFile(config.observation_path, "");
await writeFile(config.stdout_capture_path, "");
await writeFile(
config.result_path,
JSON.stringify({
status: "launch-error",
error: "LLDB returned an invalid process after Launch",
}),
);
return {
kind: "exited",
targetLaunched: true,
exitCode: 1,
output: "",
targetIdentity: undefined,
cleanupFailure: undefined,
};
},
async () => ok(fakeTool),
);
try {
const result = await tracer.trace(request);
if (result.ok) throw new Error("expected unresolved launch cleanup");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
details: {
resources: expect.arrayContaining(["native-target:unknown"]),
},
});
expect(runtime).toBeDefined();
if (runtime !== undefined) await access(runtime);
const retry = await tracer.trace(request);
if (retry.ok) throw new Error("expected retained target cleanup");
expect(projectAnalysisError(retry.error).code).toBe("cleanup_incomplete");
expect(launches).toBe(1);
} finally {
if (runtime !== undefined)
await rm(runtime, { recursive: true, force: true });
}
});
});
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more