Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3062c207c | ||
|
|
3b79af28e8 | ||
|
|
cbc0e97c29 | ||
|
|
92a0edf187 | ||
|
|
1b46398795 | ||
|
|
07da653ae5 | ||
|
|
f75b1a416b | ||
|
|
0686b07546 | ||
|
|
f3602a4b3b | ||
|
|
e2259b5264 | ||
|
|
c2b79aa462 | ||
|
|
e8c0ecfd47 | ||
|
|
efe0923914 | ||
|
|
3698e4f899 | ||
|
|
a32b4b6ac5 | ||
|
|
60c08802a7 | ||
|
|
7435e79d65 | ||
|
|
117f2e4476 | ||
|
|
2a2c9fa574 | ||
|
|
44d98de236 | ||
|
|
a89ce212d6 | ||
|
|
ba33c706d2 | ||
|
|
aade9bbcb7 | ||
|
|
0930e94e07 | ||
|
|
3f6b5755b3 | ||
|
|
45b6a7fe8f | ||
|
|
2daeff4b24 | ||
|
|
e67c3642c8 | ||
|
|
030c477328 | ||
|
|
3a34ad37bd | ||
|
|
1250aaedd8 | ||
|
|
2b60e097f2 | ||
|
|
5e79d468e5 | ||
|
|
44c9d2924d | ||
|
|
503109995f | ||
|
|
28f5761973 | ||
|
|
dae1e55381 | ||
|
|
a37755d57c | ||
|
|
74195532be | ||
|
|
7a9991289c | ||
|
|
82f9b42996 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "6.4.0"
|
||||
".": "6.5.0"
|
||||
}
|
||||
+113
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ export class CaptureFormatError extends Error {
|
||||
| "limit",
|
||||
message: string,
|
||||
readonly pointer = "",
|
||||
cause?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -425,7 +425,7 @@ const compareScenarioEvents = (
|
||||
after_state: afterState,
|
||||
before_sha256: beforeSha256,
|
||||
after_sha256: afterSha256,
|
||||
reason: null,
|
||||
reason: input.incomparableReason ?? null,
|
||||
},
|
||||
};
|
||||
if (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,19 +62,20 @@ describe("TypeScript import-equals links", () => {
|
||||
});
|
||||
|
||||
describe("CommonJS wrapper parameter redeclarations", () => {
|
||||
it("keeps a top-level wrapper require redeclaration", () => {
|
||||
const path = "app/main.cjs";
|
||||
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("./wrapper.cjs");',
|
||||
'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).toContainEqual(
|
||||
expect.objectContaining({ kind: "require", specifier: "./wrapper.cjs" }),
|
||||
expect(ir.moduleLinks).not.toContainEqual(
|
||||
expect.objectContaining({ specifier: "./dependency.cjs" }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 })
|
||||
)
|
||||
|
||||
@@ -78,12 +78,12 @@ it("does not resolve a parameter named require as a module dependency", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a formatless top-level require redeclaration unresolved", () => {
|
||||
it("keeps a top-level wrapper require redeclaration", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(
|
||||
`var require; require("./wrapper.cjs");`,
|
||||
);
|
||||
expect(analysis.references).toContainEqual(
|
||||
expect.objectContaining({ kind: "require", specifier: null }),
|
||||
expect.objectContaining({ specifier: "./wrapper.cjs" }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>),
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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-");
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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-");
|
||||
|
||||
@@ -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();
|
||||
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user