Compare commits

...
Author SHA1 Message Date
github-actions[bot]andGitHub 0f337cb398 Saving release notes 2026-10-13 11:39:49 +00:00
Kaoru0822andGitHub fb28ec1537 test(apple): keep the same-named slice dyld fixture openable on x64 hosts (#2755)
Add a plain x86_64 slice to the dyld fixture while retaining both same-named arm64e slices and their independent environment assertions.

The original fixture was rejected by real x64 CLI admission before tracing. The contributing PR's complete Linux CI run passes all 30 jobs, including the portable suite. Codex and Greptile reviews completed without findings; no unresolved threads remain.

This restores the main test baseline needed for #2635. The change is limited to test data and preserves the original author's contribution.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-13 19:31:18 +08:00
morluto 490a285070 ci: reuse current-run builds across provider lanes 2026-10-13 19:30:50 +08:00
morluto fa0a9b2d32 ci: observe affected-owner test plans against full results 2026-10-13 19:21:09 +08:00
morluto c8d861f976 ci: scope timing helpers to portable verification 2026-10-13 19:12:26 +08:00
morluto bd2c202dd9 fix(ci): reject incomplete verification plans 2026-10-13 19:06:31 +08:00
morluto 9f3f392eb8 ci: bound core job execution time
Limit classification to five minutes, static/build/docs/report jobs to ten minutes, and each portable shard to thirty minutes. The saved successful-job sample peaked at 392 seconds per shard and 61 seconds for other core jobs, leaving generous setup and coverage headroom. Runner queue waiting is unaffected.
2026-10-13 19:00:47 +08:00
morluto f0f09950f3 ci: finish main validation and balance complete test costs 2026-10-13 18:54:05 +08:00
morluto 3af8f847f0 ci: fix PR metadata triggers and defer expensive runner work 2026-10-13 18:38:46 +08:00
morluto 747e729914 test(release): validate only the selected preflight job
Scope the steps schema to the release job whose preflight is executed, allowing unrelated reusable-workflow jobs such as qualification. Preserve all four valid and invalid metadata execution checks.

Reproduced on main ec3db3745: 35 passed, 4 failed. After the fix: 104 release and CI boundary tests pass; check:fast and format:check pass.
2026-10-13 18:24:40 +08:00
记得吃中饭andGitHub 2995ec6910 fix(ghidra): resolve file offsets in decompile (#2101)
* fix(native): expose loaded image bases in overviews

* test(ghidra): retain resources when verifier cleanup fails

* docs(ghidra): spell out file-offset address mapping

* fix(ci): separate and retry NativeAOT dependency restoration

* fix(test): resolve CI script file URLs on Windows

* fix(native): retain conflict observations and actionable CLI guidance

* fix(fixtures): keep NativeAOT retry logs credential-safe

* fix(fixtures): redact authenticated URLs at diagnostic creation

* fix(ghidra): restrict source-coordinate observations to decompilation

* fix(cli): identify conflicting file-offset selectors accurately

* fix(ghidra): bind file offsets to admitted target sources

* fix(ghidra): preserve resolved file-offset failure context

* test(ghidra): verify ELF address and file-offset round trips

* test(nativeaot): align diagnostic regressions with fixture host support
2026-10-13 18:23:17 +08:00
Yijie LuandGitHub ec3db37457 fix(native): preserve literal line breaks in signing identifiers (#2107)
* fix(native): preserve literal line breaks in signing identifiers

* fix(native): infer codesign framing without a matching path
2026-10-13 18:22:34 +08:00
N0zoM1z0 f1f5582ce8 fix(website): clarify translated evidence and runtime observations 2026-10-13 18:21:09 +08:00
N0zoM1z0 3d78a57043 docs(website): publish model essay in all supported languages 2026-10-13 18:21:09 +08:00
N0zoM1z0 b06751b703 docs(website): simplify reconstruction evidence in model essay
Explain each case through the obstacle, useful hypothesis and verified result. Remove byte totals, relocation counts and trial counts from the main narrative; preserve commit references and the constant-value example that explains the oracle defect. Make the experiment table and compiler terminology easier to follow.

Validation: website.py check, formatting, preserved source links, and eight article layout checks across both preview paths and light/dark themes.
2026-10-13 18:21:09 +08:00
N0zoM1z0 806ff57567 docs(website): use straight quotes in model essay
Normalize the three quoted phrases to straight English quotation marks. Website checks and formatting pass; the local preview serves the revised punctuation.
2026-10-13 18:21:09 +08:00
N0zoM1z0 ca07373e44 docs(website): improve model essay transitions and narrative flow
Connect the reconstruction cases to the questions about specialization, retrieval and feedback-driven search. Replace fragmented sentence sequences with explicit causal links and smooth the progression into oracle design and the proposed comparison.

Validation: website.py check, formatting, preserved source links and evidence figures, and eight desktop/mobile light/dark layout checks at both preview paths.
2026-10-13 18:21:09 +08:00
N0zoM1z0 8583a190c0 fix(website): keep omitted language selectors free of whitespace
Indent the optional language selector with its value so a nonindexable draft does not generate a line containing only spaces. Indexed-page markup is unchanged.

Validation: website.py check passes all 12 maintenance regressions; synchronization changes only the draft header blank line.
2026-10-13 18:21:09 +08:00
N0zoM1z0 fe496bd1cf docs(website): add essay on reverse engineering models
Add an English review draft connecting Touhou reconstruction evidence with model specialization, repository knowledge and feedback-driven learning. Preserve model attribution and historical verification scope in supporting notes, and translate the new Blog listing for all existing editions.

Validation: website.py check, focused formatting checks, and 32 Chromium cases covering both preview paths, light/dark themes, responsive layouts, translated indexes and no-JavaScript reading.
2026-10-13 18:21:09 +08:00
Yijie LuandGitHub f8e95799f9 fix(artifacts): preserve NIB field and class identities (#2106) 2026-10-13 18:20:54 +08:00
HarlyandGitHub 6c816af119 fix(managed): preserve negative zero in CIL operands (#2437) 2026-10-13 18:19:44 +08:00
morluto ab02f3c8d6 ci: reduce reporter fan-out and preserve shard history 2026-10-13 18:16:10 +08:00
ChaoqiandGitHub 9928f357fb fix(artifacts): read ASCII control bytes in legacy ZIP names as ASCII (#2365)
* fix(artifacts): read ASCII control bytes in legacy ZIP names as ASCII

ZIP names without the UTF-8 flag were decoded with zip.js's CP437 table,
which maps bytes 0x01-0x1F and 0x7F to IBM PC display glyphs. A folder
zipped on macOS with a custom icon therefore listed and extracted
`Folder/Icon\r` as `Folder/Icon♪`, a name that is not in the archive and
differs from REA's directory inventory of the same folder. Tabs became `○`
and DEL became `⌂`.

Decode legacy names in REA's existing decodeText hook with the Unicode
CP437 mapping that Python's zipfile also uses: bytes below 0x80 as ASCII,
and the upper half with the same table zip.js uses.

Windows file names cannot contain U+0001-U+001F, so a Windows extraction
destination now refuses such a path with a typed path failure before
creating anything, as it already does for ':'.

* test(artifacts): check extracted legacy ZIP control names on disk

The control-name cases checked only the logical path that inventory
reports. Extraction writes through SafeOutputTree, so a change that
renamed the extracted files would still pass those cases.

Extract `Icon\r` and `é\t\u007F.a`, then compare the reported
relative_path, the directory listing, and the file contents. The new
cases skip on Windows, which refuses these names.
safeOutputTree.test.ts covers that refusal.
2026-10-13 18:05:20 +08:00
morluto d13c9f840d ci: reduce redundant compatibility and qualification work
Select SQLite compatibility by explicit source, fixture, lifecycle, and script ownership while retaining every version in full qualification. Share the canonical scope inventory between planning and the required gate.

Reuse recent exact-SHA full qualification only for hourly schedules, with trusted successful-run metadata and receipts. Force a daily refresh, reject stale original runs even after retries, and never renew proof from reused validation.

Report attempt-specific queue, execution, and matched step timing in a separate completion workflow using trusted default-branch code and read-only API access. Preserve raw metadata and unknown timings without delaying validation or publication.

Validation: 177 focused tests; typecheck/lint; knip; actionlint 1.7.7; all 1046 tests discovered; live 37-job API report smoke. Remote workflow scheduling and real-provider matrices remain CI verification.
2026-10-13 17:46:04 +08:00
0cf224e762 fix(javascript): retain distinct chunk registration callback metadata (#2737)
Co-authored-by: LouisChen <3361008+chqcjq@users.noreply.github.com>
2026-10-13 17:42:41 +08:00
52d4ce6ced fix(apple): derive dyld environment findings from each traced slice (#2733)
deriveFindings looked up each traced root's slice again by architecture
name. A universal executable with arm64e and arm64e.x1 slices reports
both as "arm64e", so both roots resolved to the first slice: a later
slice's LC_DYLD_ENVIRONMENT produced no finding, or the first slice's
command was reported twice. The per-slice dyld_environment arrays were
already correct.

traceDylibLoading already has the exact slice when it records a root, so
pass it to deriveFindings instead of re-deriving it from the name. The
public roots and findings shapes are unchanged.

Fixes #2661

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:42:09 +08:00
morluto e05d3399f1 test(javascript): expect unknown parameter provenance 2026-10-13 17:23:36 +08:00
morluto db3c9f084b fix(ci): keep cancellation ownership in the calling workflow
Remove redundant ref-based concurrency from reusable provider and website workflows. Their old groups could cancel qualification from another CI tier even though the callers used separate groups. Disposable hosted jobs now inherit caller scheduling; direct manual dispatches remain independent.

Validate every called CI workflow has no independent cancellation group. Validation: 100 focused scope/gate tests and actionlint 1.7.7.
2026-10-13 17:15:42 +08:00
morluto 0c7f4dfbd9 ci: separate fast feedback from full qualification
Scope main pushes from a successful ancestor so canceled runs cannot hide changes. Keep the complete portable suite in fast feedback and move coverage plus Intel macOS to hourly, manual, explicit-full, and release qualification. Let qualification finish independently of superseded feedback.

Bind publication to full qualification at its exact revision and publish the verified tarball after checking workflow identity, version, and SHA-256. Keep trusted cache writers and reject incomplete merged test inventories.

Validation: 151 focused CI tests; typecheck/lint; actionlint 1.7.7; knip; all 1044 files discovered; bounded blob/merge smoke; macOS package verifier with retention and cleanup; npm pack dry run. Native Linux/ARM/Windows and remote workflow execution remain CI verification.
2026-10-13 17:09:53 +08:00
morluto c02fb0ea80 fix(native): rank Mach-O slices by parsed CPU subtype 2026-10-13 16:43:12 +08:00
96fc84f1fb fix(native): select one slice when universal Mach-O slices share a CPU type (#2040)
* fix(native): select one slice when universal Mach-O slices share a CPU type

On macOS 27 most system binaries are universal files whose slices share
CPU_TYPE_ARM64: arm64e + arm64e.x1 (Calculator, TextEdit), x86_64 + arm64e +
arm64e.x1 (/bin/ls), or arm64 + arm64e.x1 (ShortcutDroplet). Target admission
names slices by CPU family, so these targets have availableArchitectures
["arm64"] while the file holds two arm64 slices.

inspect_macho read that family count as a slice count and ran otool, nm,
dyld_info, dwarfdump and vtool without -arch. ShortcutDroplet failed with
"Malformed otool load-command output" (40 load commands against ncmds 20).
Calculator reported both slices' imports (4138 instead of 2069), nm's
"(for architecture ...)" header lines as exports, and no selected slice.
selectArchitecture now also filters when lipo lists more than one slice. Its
preference is unchanged: the family name, then arm64e, then the first
arm64e.<variant>.

inspect_native_dispatch_metadata matched FAT slices by CPU type only and
refused /bin/ls and TextEdit as "Ambiguous FAT architecture slice".
selectMachoSlice now ranks slices of the requested CPU type by base subtype in
the same order: arm64 (0, then 1) or x86_64 (3), then arm64e (2), then other
variants such as arm64e.x1 in FAT table order. It reports ambiguity only when
the top-ranked slices share a subtype.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(native): share Mach-O slice preference across inspections

* chore: preserve upstream process capture test during merge

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 16:36:23 +08:00
b69d20a300 fix(captures): name the violated HAR schema constraint (#2708)
HAR validation reported only the AJV keyword, so a capture missing
`log.version` failed with "HAR schema validation failed: required." and
the caller could not tell which property to supply. Include AJV's
constraint message, which names the missing property, expected type, or
pattern without restating captured values.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:22:24 +08:00
AvocadoandGitHub 11f8b76257 fix(apktool): accept --no-strings CLI option (#2707) 2026-10-13 16:20:03 +08:00
morluto 11f1d952e7 ci: raise duplication threshold to 3.5 percent 2026-10-13 16:18:53 +08:00
morluto a0317cf732 fix(ci): align FAT fixtures and consolidate duplicated workflow helpers
Declare the synthetic 4 KiB Mach-O slice alignment accurately while preserving the IDA thinning scenario. Share web cleanup aggregation and process-trace fixture facts instead of duplicating them across consumers and tests; keep the 3% duplication threshold unchanged.

Validation: npm run check:ci, test discovery, and 42 focused IDA, process-trace, web workflow and cleanup boundary tests. Duplication falls from 3.01% to 2.99%.
2026-10-13 16:16:31 +08:00
morluto 67c7a24e60 Merge remote-tracking branch 'origin/main' 2026-10-13 16:08:58 +08:00
morluto c7a890d129 fix(javascript): preserve unknown module-alias provenance
Replace the implicit local-provenance fallback with explicit locally constructed values. Keep opaque results, parameters and caught values unknown; follow assignment and sequence results while retaining exact module aliases. Clarify that alias provenance does not represent transitive data taint.

Validation: 26 focused provenance/module-link tests, 135 semantic and graph-builder tests, six application module-relationship boundary tests, and the fast typecheck/lint gate.
2026-10-13 16:08:41 +08:00
morluto b955b825fc fix(artifacts): retain read cleanup ownership through CLI and MCP shutdown
Keep failed stable-read handles and WABT runtime roots under retryable resource scopes. Preserve cancellation causes and primary errors, close the same service instances bound to MCP tools, and consolidate CLI operation/cleanup projection.

Validation: fast typecheck and lint, cached runtime build, docs checks, SDK schema validation, focused service and real-file cleanup tests, CLI cleanup regressions, and MCP lifecycle/shutdown workflows. Provider process fixtures simulate engine output; real WABT and Linux EVM execution are not established by this pass.
2026-10-13 16:08:41 +08:00
9cf49c7a78 fix(snapshot): classify non-object roots as invalid content (#2685)
Co-authored-by: jiangjiang248 <237556902+jiangjiang248@users.noreply.github.com>
2026-10-13 16:04:44 +08:00
morluto c923fb0dc9 fix(javascript): retain compiler-dependent module resolution facts
Compare import and require outcomes for static TypeScript references when compiler options are unknown. Preserve branch facts through worker IR and both graph projections; retain declaration fallbacks as candidates without changing explicitly selected paths.

Validation: 210 focused resolver and semantic tests; 21 production-boundary and source-extension tests. A bounded 2,000-reference probe adds about 16–17 ms wall time for dual resolution; full task token costs and real compiled application behavior remain unmeasured.
2026-10-13 15:58:25 +08:00
morluto ded199abcd Merge remote-tracking branch 'origin/main' 2026-10-13 15:25:24 +08:00
morluto ed881c6d2e fix(progress): measure delivery intervals with a monotonic clock 2026-10-13 15:23:56 +08:00
morluto eaa9616585 fix(javascript): preserve cancellation through recovery publication cleanup 2026-10-13 15:19:18 +08:00
bf34d0d2fc perf(native): add opt-in Mach-O summary delivery and retained pages (#2634)
* perf(native): add opt-in Mach-O summary delivery and retained pages

inspect_macho returns every import and export inline. For VS Code's
Electron Framework one MCP call carries 2.5 MB of text (about 828k o200k
tokens), and the same again as structuredContent; the MCP response
budget admits it. Agents asking for slice identity, UUID or dependency
counts paid that cost on every first call.

Add the established opt-in detail selection: omitted or complete keeps
the existing complete result and Evidence identity, while summary
retains the complete Evidence and returns slice identity with
per-collection returned/total/exhaustive counts (956 tokens).
inspect_analysis_view kind macho pages the original rows of any
collection or the command provenance, repeating the producer's own
total and exhaustive flag so page exhaustion is never read as absence.

Delivery detail never reaches the native tools or the source Evidence
ID: registerEvidenceTools gains a parametersForEvidence hook, so the
summary's parent is the same Evidence as a complete call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): refuse unretainable summary delivery before analysis

A server without Evidence retention (a fixed analysis source) ran the
complete inspect_macho or inspect_artifact analysis for detail summary
and only then refused it in summarizeRetainedAnalysis, paying the very
cost summary delivery exists to avoid. registerEvidenceTools gains a
preflight hook; both registrations check retention there, as the WASM,
EVM, managed, pcap and capture handlers already do before dispatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(native): keep whole rows in Mach-O architecture and segment pages

The page item union listed plain-object row arrays, and zod returns the
first union member that parses while dropping unknown keys. Entrypoint
rows declare only file_offset, so every architecture and segment row
(which also carry a numeric file_offset) matched it and lost its name,
CPU fields, permissions and sections. Rows now use exact key sets, and
imports/exports share one member. The MCP test pages every facet of a
real /bin/ls and compares it with the unparsed complete rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:13:12 -07:00
morluto e802441bce Merge remote-tracking branch 'origin/main' 2026-10-13 15:12:34 +08:00
morluto e7882b34f4 fix(artifacts): honor cancellation at the output publication boundary 2026-10-13 15:12:17 +08:00
morluto 9049dbaf0c fix(process): preserve monotonic capture timing and observed wall dates 2026-10-13 15:12:08 +08:00
morluto 7fc0b5ec9c fix(mcp): join owned doctor shutdown without signaling saved PIDs 2026-10-13 15:12:08 +08:00
6a05248d46 perf(frida): evict captured messages without copying the whole queue (#2628)
Once a Frida session reached its 16 MiB capture budget, every new message
evicted the oldest one with Array#shift, re-serialized it to recover its
byte charge, and searched and spliced its per-script array. Each of
those is linear in the retained messages, so with about 188k small
messages retained the cost per message rose from 0.14 µs to 32 µs and
capped the main thread at roughly 31k messages per second, which a
hooked hot function can exceed.

Keep both queues as head-offset FIFOs that compact once half their slots
are evicted, and record each message's charge when it is admitted.
Arrival order already makes the globally oldest message the oldest one
of its script, so per-script eviction becomes a head check. Eviction is
now amortized O(1) at about 0.14 µs per message; retained messages,
their order and the truncation flag are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:11:38 -07:00
20f51d7286 fix(native): name the ownership helper prerequisite in command failures (#2506)
* fix(native): name the ownership helper prerequisite in command failures

Every supervised native command launch needs the Darwin process-ownership
helper. When that helper cannot be compiled (relative REA_DARWIN_SWIFTC_PATH,
missing SDKROOT, a missing compiler, or an SDK/compiler mismatch), the
runner reduced the typed DarwinProcessOwnershipInspectionError to a bare
`io` failure. inspect_macho then reported `execution_failure` with no hint,
and inspect_artifact on any universal Mach-O said only "lipo could not
enumerate universal Mach-O slices".

The runner now keeps the inspection message as the failure's `detail` at
the launch boundary and carries it through xcrun resolution. The native
provider adds it to its diagnostics and the slice reader appends it to its
message, matching what the SQLite and capture providers already report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(native): cover the provider's ownership helper diagnostics

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:10:58 -07:00
morluto 6bd2b28161 fix(browser): keep elapsed capture timing independent of wall-clock changes 2026-10-13 15:02:54 +08:00
morluto d9e9fb2273 fix(process): use monotonic time for provider execution budgets 2026-10-13 15:01:59 +08:00
morluto b953f96c0a fix(ghidra): share Java properties semantics with PSP extension admission 2026-10-13 15:00:14 +08:00
OlorinandGitHub 4276aaf18b feat(signals): discover installed decoders and option help (#2487)
* feat(signals): discover installed decoders and selected option help

* fix(signals): bound retained discovery data and preserve resource failures

* fix(signals): complete CLI help and portable decoder patterns

* test(signals): verify case-insensitive decoder help queries

* fix(signals): retain discovery operation across cleanup retries
2026-10-13 15:58:19 +09:00
morluto fe8d0bdd36 fix(android): reject malformed UTF-8 at the JADX protocol boundary 2026-10-13 14:56:04 +08:00
9c34c091b3 feat(captures): discover installed protocol and field definitions (#2496)
* feat(captures): discover installed protocol and field definitions

* test(captures): isolate the direct glossary oracle profile

* test(captures): accept padded kernel limit reports

* fix(captures): detach every retained glossary column

* fix(captures): describe every field discovery CLI option

* fix(captures): retain discovery operation across cleanup retries

---------

Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
2026-10-13 15:55:25 +09:00
Rudy Mizrahi CelekliandGitHub b9ee921560 fix(process): preserve nested path identities during normalization (#2468) 2026-10-13 15:54:18 +09:00
15463bdbe6 fix(adb): count requested permissions that the device marks restricted (#2359)
Android 10 and 11 print restricted entries of the requested-permissions
list as "NAME: restricted=true". The parser took the first such line as
the end of the list, so the count stopped there while coverage still
read complete.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:52:48 +08:00
morluto 55e21a2eec Merge remote-tracking branch 'origin/main' 2026-10-13 14:51:59 +08:00
morluto f6775a3669 fix(setup): preserve current recovery snapshots and decoding failures 2026-10-13 14:51:55 +08:00
Rudy Mizrahi CelekliandGitHub a07ceaec36 fix(config): reject NUL bytes in Hopper loader arguments (#2474) 2026-10-13 14:51:29 +08:00
Rudy Mizrahi CelekliandGitHub 6a04f922e1 fix(javascript): parse explicit CommonJS wrapper syntax (#2475) 2026-10-13 14:51:23 +08:00
Rudy Mizrahi CelekliandGitHub e74e4e951b fix(screenshot): reject invalid PNG header dimensions (#2472) 2026-10-13 15:50:44 +09:00
68ecf3df8c fix(browser): exclude sensitive binary lengths from historical captures (#2477)
Co-authored-by: chAngE <172472630+chAng-L19@users.noreply.github.com>
2026-10-13 14:49:47 +08:00
ab62ee9dcf fix(windows): preserve caller affinity only within one processor group (#2202)
* fix(windows): preserve caller affinity only within one processor group

`spawnProcess` read the caller's affinity mask and applied it to the
suspended child unconditionally. A mask names processors relative to one
processor group, and Windows assigns each new process to a group of its
own choosing, so on hosts with more than 64 logical processors the child
is often placed in another group. `SetProcessAffinityMask` then rejects
the mask with ERROR_INVALID_PARAMETER (87) and the spawn fails, making
every owned provider process start intermittently on such hosts.

Affinity is a nicety, not an authority or isolation control, so it is now
applied only when the caller and the suspended child both occupy the same
single processor group. A child placed in another group keeps that group's
default affinity and the spawn proceeds. Job Object membership,
kill-on-owner-close policy, owner normalization and priority preservation
are unchanged, and the mask failure remains fatal within a shared group,
where it is not expected.

Single-group hosts, including every host with 64 or fewer logical
processors, are unaffected: `GetProcessGroupAffinity` reports one group
for both processes and the same mask is applied as before.

The optional process-boundary fixture gains an `affinity` observation,
and the native verification lane asserts that an owned spawn succeeds and
that the child's mask matches the caller's whenever both share a single
group.

Fixes #2181

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(windows): refuse to widen a pinned caller's affinity across groups

Addresses review feedback on the first commit.

An unconstrained caller loses nothing when Windows places an owned child
in another processor group, so that spawn proceeds with the group's
default. A caller deliberately pinned to fewer processors than its group
holds is a different case: silently accepting the default would hand the
provider tree the whole group, which `docs/installation.md` tells
operators to rely on affinity to prevent. Such a spawn now fails naming
the constraint instead. `GetProcessAffinityMask` already reports the
group's active set, which distinguishes the two without another query.

Selecting the caller's group at creation through
PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY was tried first and rejected:
`CreateProcessW` fails with ERROR_INVALID_PARAMETER on a host with a
single processor group, both alongside PROC_THREAD_ATTRIBUTE_JOB_LIST and
on its own, so it cannot carry the ordinary path.

The boundary fixture's affinity observation now reports a process id's
own mask rather than its own, because a separate probe process is
assigned its own group and cannot stand in for the process that spawns
the child. Masks and the group's active set cross as fixed-width
hexadecimal strings: a mask can set bits above the safe-integer range,
where `JSON.parse` would round two different masks to one value.

The verification lane asserts the mask is inherited exactly when caller
and child share a group, and otherwise that the caller was unconstrained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(windows): record what owned-process affinity does not preserve

A mask covering its group's whole active set does not distinguish a
caller the system placed in that group from one that selected the group
deliberately, so an owned child can still be placed elsewhere with its
processor count bounded and its group placement lost. Treating that case
as constrained instead is not available: on a multi-group host an
ordinary process is assigned a group by the system and its mask is that
group's active set, so refusing it would fail every owned spawn there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 14:49:34 +08:00
2c6a4fac4f fix(process): accept a trace identifier the comparison result can represent (#2488)
The trace specification owns the declared identifier vocabulary and the
comparison result echoes those identifiers verbatim into raw_trace[].event_id,
matched_variant and diagnostic event_ids. The result module restated the shared
IDENTIFIER_PATTERN by hand with a 64-character cap, so a specification with a
longer identifier validated and then its own comparison result failed to parse:
compareProcessTraces threw a raw ZodError at the caller instead of returning a
comparison.

Export the identifier schema from the specification as the single owner and
reuse it in the evaluation module, removing the capped copy. IDENTIFIER_PATTERN
has no length bound and every other consumer uses it unbounded, so the
duplicate was the outlier. Removing the bound rather than adding one keeps the
advertised trace_spec input contract unchanged; identifiers of 64 characters or
fewer behave exactly as before.

Verified with 48 process tests, 24 boundary CLI/MCP trace tests, check:fast and
verify:test-discovery on macOS arm64. The regression test failed with the
ZodError before the change and passes after it.

Co-authored-by: 吴佳浩 <wujiahao@supremind.com>
2026-10-13 14:48:26 +08:00
Mykola DementievandGitHub c6acd19b6d fix(ida): stop the private input of a universal Mach-O at the end of the selected slice (#2291)
* test(ida): expect the private input of a universal Mach-O to end at the selected slice

Cover a universal file larger than one read chunk with another slice after the selected one, for both a middle slice and the first slice.

* fix(ida): stop the private input of a universal Mach-O at the end of the selected slice

A read chunk that starts after the slice produced a negative end offset, and Buffer.subarray counts a negative bound from the end, so the head of the following slice was written to the private input and its digest.
2026-10-13 14:48:17 +08:00
1a1b67c888 fix(javascript): preserve POSIX runtime path case on Windows (#2422)
Use POSIX path semantics for recorded POSIX roots when reconciling runtime Evidence on Windows. Preserve Windows mapping behavior and cover implicit and explicit file roots through the reconciliation service.

Assisted-by: OpenAI GPT-6 <noreply@openai.com>

Co-authored-by: Aurantium-47 <280805072+Aurantium-47@users.noreply.github.com>
2026-10-13 14:46:28 +08:00
Rohit GhumareandGitHub 12303812a8 fix(evidence): admit bundle imports across session transitions (#2303) 2026-10-13 14:45:03 +08:00
f6556ad2bc fix(pcap): order evidence ties by code point instead of host locale (#2509)
The pcap summary sorted equal-count endpoints and protocol stacks with
String#localeCompare, and Flutter build inspection sorted APK ABI
directory names the same way. localeCompare follows the host's ICU
locale, so the same capture produced differently ordered Evidence on
different hosts: under da_DK "aarp" sorts after "zz", under lt_LT
"syslog" sorts before "ssdp", and every locale puts "fe80::1" before
"fe80:1::1". Use the repository's compareUnicodeCodePoints, as the
other Evidence producers already do.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:44:09 +08:00
morluto 5f0f25ad61 Merge remote-tracking branch 'origin/main' 2026-10-13 14:43:23 +08:00
d73ed6fce2 fix(domain): preserve retryable capability failures through redaction (#2494)
redactExplicitFailure rebuilds each typed failure field by field so explicit
literals are removed while the projected code stays the same. The rebuild for
AnalysisCapabilityUnavailableError carried providerId, operation, reason and
userMessage but not retryable, and the constructor defaults that field to false.
Redacting a transient capability failure therefore made it permanent and changed
the remediation shown to the caller.

Pass the field through with the same conditional-spread shape used for
userMessage. The constructor recomputes retryable from cleanupIncomplete, and
cleanup is already part of the reconstructed context, so a value that was true
before redaction stays true and a false value stays absent.

Verified with 66 error projection, access-denied, redaction and capture-failure
tests, check:fast and verify:test-discovery on macOS arm64. The regression test
failed with "expected false to be true" before the change.

Co-authored-by: 吴佳浩 <wujiahao@supremind.com>
2026-10-13 14:43:10 +08:00
Vinícius AndradeandGitHub 60f771a11e fix(apple): limit already-loaded reuse to rpath dependencies (#2292)
* fix(apple): limit already-loaded reuse to rpath dependencies

dyld reuses an already-loaded image by install name only for @rpath/
dependencies (Loader.cpp:802-816) and evaluates other dependency forms
against their expanded path for each loader. When multiple images in
different directories depend on @loader_path/ libraries sharing an
install name, checking loaded images before candidate expansion caused
subsequent loaders to bind to the first loaded image's copy or appear
resolved when their own copy was absent.

Limit byInstallName registration and lookup to install names that start
with @rpath/, leaving caller-relative and literal dependencies to expand
and evaluate their own candidate paths.

Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>

* test(apple): verify loader_path dependencies across isolated directories

Assert that multiple images depending on @loader_path/ libraries with
the same install name do not reuse already-loaded images across loaders.
When the second loader's helper is absent, its candidate path is absent
and dyld reports required-load-unresolved; when both helpers exist, each
loader resolves to its own directory.

Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>

---------

Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>
2026-10-13 14:40:43 +08:00
The one -NiuandGitHub 650237efb0 fix(setup): pin Hopper's macOS publisher identity before install (#2481)
The macOS installer accepted any bundle that passed `codesign --verify`
and whose `Info.plist` said `com.cryptic-apps.hopper-web-4`. An ad-hoc
signed bundle with that identifier satisfies both, so a substituted DMG
could be copied to ~/Applications and launched.

Verify a pinned code requirement instead, covering the sealed bundle
identifier and the signing team together, and report a distinct
`publisher_verification_failed` code so a vendor key change is not
confused with a corrupt package.
2026-10-13 14:40:04 +08:00
morluto 26bffb94bf perf(evidence): reuse authenticated comparisons through aggregation 2026-10-13 14:39:49 +08:00
7017c4d68c fix(artifacts): read every ZIP package family extraction is asked to open (#2493)
extract_artifact carried its own copy of the ZIP package format list with five
of the eight members the domain owner declares, and used it both as the guard
that refuses a format without an extraction reader and as the selector for the
reader itself. A .hap, .hsp or App Pack target was therefore refused with
"Artifact format has no extraction reader" even though the next branch builds a
ZipArtifactReader for exactly those formats, while inventory_artifact classifies
the same path through the owner and reads it.

Import isZipPackageFormat from the domain owner and delete the duplicate list
and its predicate, removing the second representation instead of reconciling it.
The reader branch now narrows to ZipPackageFormat, which is what the
ZipArtifactReader constructor already requires.

Verified with 41 boundary extraction, ZIP integrity and detection tests,
check:fast and verify:test-discovery on macOS arm64. Reverting only the source
change fails exactly the three added families and leaves the zip control green.

Co-authored-by: 吴佳浩 <wujiahao@supremind.com>
2026-10-13 14:39:29 +08:00
141d2095f5 test(hopper): repair Linux demo setup and native verification (#2638)
* fix(verification): align Linux Hopper setup lane with current contracts

* fix(verification): retain orphan checks on clean Linux runners

* fix(verification): detect Hopper through alternate launcher paths

* fix(verification): report identified Hopper leaks before proc errors

* fix(verification): distinguish Hopper string recognition from byte fidelity

* fix(verification): report full literal decoding coverage explicitly

---------

Co-authored-by: Luis Taffelli <86675342+LuisTaffelli@users.noreply.github.com>
2026-10-13 14:39:18 +08:00
morluto 1b953021ea fix(javascript): centralize module syntax and separate erased type dependencies 2026-10-13 14:39:06 +08:00
morluto e1cd292aa6 fix(adb): admit canonical device states and preserve malformed output 2026-10-13 14:38:39 +08:00
YsopwnableandGitHub 18831491cf fix(ci): ignore generated website translation staging (#2511)
Ignore generator-owned website translation staging output while preserving authored lint errors and complete diagnostics.

Verified with a real checker regression, a production translation build, complete coverage, developer/CI regression checks and native lanes.

Fixes #2508
2026-10-13 14:32:22 +08:00
380 changed files with 44838 additions and 2649 deletions
@@ -76,6 +76,9 @@ explicit path or endpoint and do not need it.
Facts retain all-recorded-events-before-selection scope. Other qlog layouts Facts retain all-recorded-events-before-selection scope. Other qlog layouts
and PCAP correlation are unsupported. and PCAP correlation are unsupported.
- User-owned Electron runtime already open: `list_electron_targets`. - User-owned Electron runtime already open: `list_electron_targets`.
- Packet-field discovery: `inspect_packet_fields` searches installed protocols
and field names/types before choosing fields for `inspect_packet_capture`.
Query/page the relevant records; registrations do not establish observed traffic.
- Recorded packet captures: `inspect_packet_capture` with selected TShark fields, - Recorded packet captures: `inspect_packet_capture` with selected TShark fields,
display filters and decode-as rules. Requires Linux, util-linux prlimit and `REA_TSHARK_COMMAND`; see display filters and decode-as rules. Requires Linux, util-linux prlimit and `REA_TSHARK_COMMAND`; see
[capture guide](https://github.com/morluto/rea/blob/main/docs/packet-captures.md). [capture guide](https://github.com/morluto/rea/blob/main/docs/packet-captures.md).
@@ -89,6 +92,9 @@ explicit path or endpoint and do not need it.
[packet summaries](https://github.com/morluto/rea/blob/main/docs/pcap-analysis.md). [packet summaries](https://github.com/morluto/rea/blob/main/docs/pcap-analysis.md).
Select an exact DNS question for address pages; page exhaustion does not prove Select an exact DNS question for address pages; page exhaustion does not prove
network completeness. This reads recordings and does not acquire traffic. network completeness. This reads recordings and does not acquire traffic.
- Decoder discovery: `inspect_signal_decoders` searches installed decoder IDs
or selected decoder help, including channels and options. Query and page the
relevant details before choosing settings; this does not scan hardware.
- Recorded logic/signal traces: `decode_signal_recording` with explicit decoder, - Recorded logic/signal traces: `decode_signal_recording` with explicit decoder,
channel and protocol settings. On Linux with util-linux prlimit, supply sigrok-cli 0.7.2 through channel and protocol settings. On Linux with util-linux prlimit, supply sigrok-cli 0.7.2 through
`REA_SIGROK_COMMAND`; see [recording guide](https://github.com/morluto/rea/blob/main/docs/signal-recordings.md). `REA_SIGROK_COMMAND`; see [recording guide](https://github.com/morluto/rea/blob/main/docs/signal-recordings.md).
@@ -83,6 +83,14 @@ names, the native API record, and value-flow summaries. Pseudocode offsets and l
code units; use the returned `next_offset` for continuation. Provider limitations code units; use the returned `next_offset` for continuation. Provider limitations
and unavailable facts remain explicit; the complete parent Evidence is retained. and unavailable facts remain explicit; the complete parent Evidence is retained.
`inspect_macho` on a large framework returns megabytes of imports and exports.
Select `detail: "summary"` for slice identity, UUID and per-collection counts,
then page rows with `view: {kind: "macho", facet: "exports", offset: 0, limit: 64}`
and `normalized_result.parent_evidence_id`. Facets are entrypoints,
architectures, build_metadata, load_commands, dependencies, imports, exports,
segments and provenance. `source_exhaustive: false` means unreturned symbols
may exist.
To see which functions or Objective-C methods a Mach-O actually calls in one To see which functions or Objective-C methods a Mach-O actually calls in one
run, use `observe_native_calls` with explicit breakpoints and a bounded run, use `observe_native_calls` with explicit breakpoints and a bounded
`duration_ms`/`max_events`. It launches a new process under LLDB, so the target `duration_ms`/`max_events`. It launches a new process under LLDB, so the target
@@ -111,6 +119,8 @@ loaded bytes, file mappings, relocations and the entry against the immutable
snapshot. Keep `mismatch` and `unsupported` explicit. Verified import covers the snapshot. Keep `mismatch` and `unsupported` explicit. Verified import covers the
reported static image, not runtime DOS or PC-98 hardware behavior. reported static image, not runtime DOS or PC-98 hardware behavior.
For Ghidra decompilation, `procedure_pseudo_code` also accepts `procedure: "file-offset:0x..."`; CLI `rea decompile <path> --file-offset=0x... --provider ghidra` resolves the source byte offset internally. An unresolved 0x-prefixed numeric procedure selector also falls back to a unique file mapping. Existing symbols and analysis addresses win; multiple mappings fail as ambiguous. Preserve the returned file-offset and address locations and raw selection observations. ELF virtual addresses are distinct from file offsets; never assume an arbitrary offset plus image base is an analysis address.
Use `read_bytes` for initialized analysis memory and `address_to_file_offset` to Use `read_bytes` for initialized analysis memory and `address_to_file_offset` to
anchor one observed address to original file bytes. Loader fixups can change a anchor one observed address to original file bytes. Loader fixups can change a
word; a source offset does not imply byte equality. Complete function body ranges word; a source offset does not imply byte equality. Complete function body ranges
@@ -1,14 +1,24 @@
name: Portable build cache name: Portable build cache
description: Reuse Turbo build outputs without serializing verification jobs. description: Restore portable Turbo build outputs from the current run or cross-run cache.
inputs: inputs:
save: save:
description: Save newly built outputs after the job succeeds. description: Save newly built outputs after the job succeeds.
default: "false" default: "false"
build_cache_artifact:
description: Reuse portable Turbo outputs uploaded by the current workflow run.
default: ""
runs: runs:
using: composite using: composite
steps: steps:
- name: Download portable build outputs from this workflow run
if: inputs.build_cache_artifact != ''
uses: actions/download-artifact@v4
with:
name: ${{ inputs.build_cache_artifact }}
path: .turbo/cache/
- name: Resolve portable build task hashes - name: Resolve portable build task hashes
id: plan id: plan
if: inputs.build_cache_artifact == ''
shell: bash shell: bash
env: env:
BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'package-lock.json') }} BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'package-lock.json') }}
@@ -28,14 +38,14 @@ runs:
console.log(`prefix=${process.env.BUILD_CACHE_PREFIX}-`); console.log(`prefix=${process.env.BUILD_CACHE_PREFIX}-`);
' >> "$GITHUB_OUTPUT" ' >> "$GITHUB_OUTPUT"
- name: Restore portable build outputs - name: Restore portable build outputs
if: inputs.save != 'true' if: inputs.build_cache_artifact == '' && inputs.save != 'true'
uses: actions/cache/restore@v4 uses: actions/cache/restore@v4
with: with:
path: .turbo/cache/ path: .turbo/cache/
key: ${{ steps.plan.outputs.key }} key: ${{ steps.plan.outputs.key }}
restore-keys: ${{ steps.plan.outputs.prefix }} restore-keys: ${{ steps.plan.outputs.prefix }}
- name: Restore and save portable build outputs - name: Restore and save portable build outputs
if: inputs.save == 'true' if: inputs.build_cache_artifact == '' && inputs.save == 'true'
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: .turbo/cache/ path: .turbo/cache/
+18
View File
@@ -0,0 +1,18 @@
name: Full PR qualification
on:
pull_request:
branches: [main, "release/*"]
types: [labeled]
permissions:
contents: read
actions: read
jobs:
qualification:
name: Qualify labeled PR revision
if: github.event.label.name == 'ci:full'
uses: ./.github/workflows/ci.yml
with:
source_sha: ${{ github.sha }}
+46
View File
@@ -0,0 +1,46 @@
name: CI timings
on:
schedule:
- cron: "17 * * * *"
workflow_dispatch:
concurrency:
group: ci-timings-${{ github.repository }}
cancel-in-progress: false
permissions:
contents: read
actions: read
jobs:
report:
name: Collect recent completed workflow attempts
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# Consume only API data and execute batch code from the trusted default branch.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Record queue, execution, and matched step timing
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
CI_REPOSITORY: ${{ github.repository }}
run: node scripts/ci/job-timings-batch.mjs
- name: Upload CI timing artifacts
if: always() && hashFiles('.cache/ci-job-timings/*') != ''
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: ci-job-timings-batch
path: .cache/ci-job-timings/
include-hidden-files: true
if-no-files-found: error
overwrite: true
retention-days: 14
+175 -71
View File
@@ -5,42 +5,44 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main, "release/*"] branches: [main, "release/*"]
schedule:
- cron: "23 0-2,4-23 * * *"
- cron: "23 3 * * *"
workflow_dispatch: workflow_dispatch:
workflow_call:
inputs:
source_sha:
description: Exact qualification source; must match the caller revision.
required: true
type: string
permissions: permissions:
contents: read contents: read
concurrency: concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }} # Complete qualification finishes even while main receives more pushes.
# The latest main push verifies the full tree, including earlier changes. # Main feedback also finishes, coalescing newer pushes to one pending run.
# Publication uses its own release workflow and concurrency group. # Scheduled/manual main runs coalesce to one active and the latest pending;
cancel-in-progress: true # release and label-triggered qualification use exact-revision groups.
group: ci-${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full')) && 'qualification' || 'feedback' }}-${{ inputs.source_sha || github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' && inputs.source_sha == '' && !contains(github.event.pull_request.labels.*.name, 'ci:full') }}
env: env:
HUSKY: "0" HUSKY: "0"
jobs: jobs:
pr-title:
name: Conventional PR title
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Validate title for Release Please
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: >-
node -e 'const title = process.env.PR_TITLE ?? "";
const conventional = /^(?:feat|fix|docs|refactor|perf|test|build|ci|chore|revert)(?:\([a-z0-9._/-]+\))?!?: .+/u;
if (!conventional.test(title)) {
console.error("PR title must use Conventional Commits, for example: feat: add historical source import");
process.exit(1);
}'
changes: changes:
name: Classify changes name: Classify changes
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
actions: read
outputs: outputs:
tier: ${{ steps.plan.outputs.tier }}
qualification_run_id: ${{ steps.qualification.outputs.run_id }}
code: ${{ steps.plan.outputs.code }} code: ${{ steps.plan.outputs.code }}
sqlite: ${{ steps.plan.outputs.sqlite }}
runtime: ${{ steps.plan.outputs.runtime }} runtime: ${{ steps.plan.outputs.runtime }}
package: ${{ steps.plan.outputs.package }} package: ${{ steps.plan.outputs.package }}
apple: ${{ steps.plan.outputs.apple }} apple: ${{ steps.plan.outputs.apple }}
@@ -59,26 +61,58 @@ jobs:
nativeaot: ${{ steps.plan.outputs.nativeaot }} nativeaot: ${{ steps.plan.outputs.nativeaot }}
website: ${{ steps.plan.outputs.website }} website: ${{ steps.plan.outputs.website }}
matrix: ${{ steps.plan.outputs.matrix }} matrix: ${{ steps.plan.outputs.matrix }}
test_selection: ${{ steps.plan.outputs.test_selection }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: ${{ inputs.source_sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version-file: .nvmrc node-version-file: .nvmrc
- name: Bind qualification to the caller revision
if: inputs.source_sha != ''
env:
SOURCE_SHA: ${{ inputs.source_sha }}
run: |
test "${SOURCE_SHA}" = "${GITHUB_SHA}"
test "${SOURCE_SHA}" = "$(git rev-parse HEAD)"
- name: Find a verified ancestor for push selection
id: baseline
if: github.event_name == 'push' && inputs.source_sha == ''
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.sha }}
CI_REPOSITORY: ${{ github.repository }}
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: node scripts/ci/baseline.mjs
- name: Admit scheduled full qualification
id: qualification
if: github.event_name == 'schedule' && inputs.source_sha == ''
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.sha }}
CI_REPOSITORY: ${{ github.repository }}
CI_REPOSITORY_ID: ${{ github.repository_id }}
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
CI_FORCE_FULL: ${{ github.event.schedule == '23 3 * * *' }}
run: node scripts/ci/qualification.mjs
- name: Select verification lanes - name: Select verification lanes
id: plan id: plan
env: env:
BASE_SHA: ${{ github.event.pull_request.base.sha }} BASE_SHA: ${{ steps.baseline.outputs.base || github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }} BASELINE_REASON: ${{ steps.baseline.outputs.reason }}
CI_FULL: ${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }} HEAD_SHA: ${{ inputs.source_sha || github.event.pull_request.head.sha || github.sha }}
CI_FULL: ${{ inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full') }}
CI_SKIP_FULL: ${{ steps.qualification.outputs.run_full == 'false' }}
run: node scripts/ci/plan.mjs run: node scripts/ci/plan.mjs
static: static:
name: Static checks name: Static checks
needs: changes needs: changes
if: ${{ !cancelled() }} if: ${{ !cancelled() && needs.changes.outputs.tier != 'reused' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- name: Require successful change classification - name: Require successful change classification
env: env:
@@ -90,6 +124,9 @@ jobs:
node-version-file: .nvmrc node-version-file: .nvmrc
cache: npm cache: npm
- run: npm ci - run: npm ci
- name: Validate workflow syntax and expressions
if: needs.changes.outputs.workflows == 'true'
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
- if: needs.changes.outputs.code == 'true' - if: needs.changes.outputs.code == 'true'
run: npm run check:ci run: npm run check:ci
- if: needs.changes.outputs.code == 'true' - if: needs.changes.outputs.code == 'true'
@@ -114,6 +151,7 @@ jobs:
needs: [changes, build] needs: [changes, build]
if: needs.changes.outputs.code == 'true' if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -129,7 +167,14 @@ jobs:
with: with:
name: rea-test-runtime name: rea-test-runtime
path: . path: .
- run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }} - name: Run complete portable suite with coverage
if: needs.changes.outputs.tier == 'full'
run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
env:
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
- name: Run complete portable suite for fast feedback
if: needs.changes.outputs.tier == 'fast'
run: npm run test:run -- --reporter=default --reporter=blob --shard=${{ matrix.shard }}
env: env:
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
@@ -145,8 +190,20 @@ jobs:
build: build:
name: Build package name: Build package
needs: changes needs: changes
if: needs.changes.outputs.code == 'true' # Reusable provider workflows consume this run's portable build artifact,
# even when a workflow-only edit selects the lane without portable tests.
if: >-
needs.changes.outputs.code == 'true' ||
needs.changes.outputs.browser == 'true' ||
needs.changes.outputs.binary_layout == 'true' ||
needs.changes.outputs.evm == 'true' ||
needs.changes.outputs.javascript_recovery == 'true' ||
needs.changes.outputs.recorded_crash == 'true' ||
needs.changes.outputs.web_network == 'true' ||
needs.changes.outputs.web_runtime == 'true' ||
needs.changes.outputs.web_source_map == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
permissions: permissions:
contents: read contents: read
actions: read actions: read
@@ -160,21 +217,17 @@ jobs:
- name: Reuse portable build outputs across runs - name: Reuse portable build outputs across runs
uses: ./.github/actions/portable-build-cache uses: ./.github/actions/portable-build-cache
with: with:
save: "true" save: ${{ github.event_name != 'pull_request' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
- run: npm run test:prepare - run: npm run test:prepare
- name: Find successful default-branch timing history - name: Find available trusted full-suite timing history
id: history id: history
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
CI_REPOSITORY: ${{ github.repository }} CI_REPOSITORY: ${{ github.repository }}
CI_REPOSITORY_ID: ${{ github.repository_id }}
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: | run: node scripts/ci/test-history.mjs
run_id="$(gh api --method GET \
"repos/${CI_REPOSITORY}/actions/workflows/ci.yml/runs" \
-f branch="${CI_DEFAULT_BRANCH}" -f event=push -f status=success -f per_page=1 \
--jq '.workflow_runs[0].id // empty')"
echo "run-id=${run_id}" >> "${GITHUB_OUTPUT}"
- name: Download previous timing report - name: Download previous timing report
if: steps.history.outputs.run-id != '' if: steps.history.outputs.run-id != ''
continue-on-error: true continue-on-error: true
@@ -210,15 +263,18 @@ jobs:
retention-days: 7 retention-days: 7
sqlite-runtime: sqlite-runtime:
needs: [changes, build] # Preserve all selected coverage, but admit expensive host/provider work
if: needs.changes.outputs.code == 'true' # only after static validation. Portable tests still provide early feedback.
needs: [changes, static, build]
if: needs.changes.outputs.sqlite == 'true'
uses: ./.github/workflows/sqlite-runtime.yml uses: ./.github/workflows/sqlite-runtime.yml
test: test:
name: Test (coverage + timing) name: Portable tests
needs: [changes, test-shard] needs: [changes, test-shard]
if: ${{ !cancelled() }} if: ${{ !cancelled() && needs.changes.outputs.tier != 'reused' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- name: Require successful change classification - name: Require successful change classification
env: env:
@@ -241,7 +297,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Merge coverage, JUnit, and timing reports - name: Merge coverage, JUnit, and timing reports
id: merge id: merge
if: needs.changes.outputs.code == 'true' && success() if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success()
run: >- run: >-
npm run test:ci:merge -- npm run test:ci:merge --
--coverage.reportsDirectory=coverage --coverage.reportsDirectory=coverage
@@ -251,13 +307,28 @@ jobs:
--reporter=default --reporter=default
--reporter=junit --reporter=junit
--reporter=json --reporter=json
--reporter=./scripts/ci/test-timing-reporter.mjs
--outputFile.junit=test-results.xml
--outputFile.json=test-results.json
- name: Merge fast-suite JUnit and timing reports
id: merge-fast
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'fast' && success()
run: >-
npm exec -- vitest --merge-reports
--reporter=default
--reporter=junit
--reporter=json
--reporter=./scripts/ci/test-timing-reporter.mjs
--outputFile.junit=test-results.xml --outputFile.junit=test-results.xml
--outputFile.json=test-results.json --outputFile.json=test-results.json
- name: Summarize slowest test files - name: Summarize slowest test files
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != '' if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-timings.json') != ''
env: env:
SHARD_RESULT: ${{ needs.test-shard.result }} SHARD_RESULT: ${{ needs.test-shard.result }}
MERGE_RESULT: ${{ steps.merge.outcome }} MERGE_RESULT: ${{ needs.changes.outputs.tier == 'full' && steps.merge.outcome || steps.merge-fast.outcome }}
CI_VERIFY_TEST_INVENTORY: "true"
CI_TEST_SELECTION: ${{ needs.changes.outputs.test_selection }}
CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: node scripts/ci/test-report.mjs run: node scripts/ci/test-report.mjs
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != '' if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
@@ -266,11 +337,14 @@ jobs:
path: | path: |
test-results.xml test-results.xml
test-results.json test-results.json
test-timings.json
coverage/ coverage/
.cache/ci-test-selection/report.json
include-hidden-files: true
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
- name: Publish successful timing history - name: Publish successful timing history
if: needs.changes.outputs.code == 'true' && success() && needs.test-shard.result == 'success' if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success() && needs.test-shard.result == 'success'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: test-timings name: test-timings
@@ -287,7 +361,7 @@ jobs:
conformance-fixtures: conformance-fixtures:
name: Source-built conformance fixtures name: Source-built conformance fixtures
needs: changes needs: [changes, static]
if: needs.changes.outputs.fixtures == 'true' if: needs.changes.outputs.fixtures == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
@@ -298,7 +372,7 @@ jobs:
windows-native-build: windows-native-build:
name: Build bundled Windows x64 native controls name: Build bundled Windows x64 native controls
needs: changes needs: [changes, static]
if: needs.changes.outputs.runtime == 'true' if: needs.changes.outputs.runtime == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
@@ -334,7 +408,7 @@ jobs:
package-e2e: package-e2e:
name: Native package baseline (${{ matrix.platform }} ${{ matrix.architecture }}) name: Native package baseline (${{ matrix.platform }} ${{ matrix.architecture }})
needs: [changes, build, windows-native-build] needs: [changes, static, build, windows-native-build]
if: needs.changes.outputs.runtime == 'true' if: needs.changes.outputs.runtime == 'true'
strategy: strategy:
fail-fast: false fail-fast: false
@@ -373,6 +447,8 @@ jobs:
name: windows-native-controls name: windows-native-controls
path: native/windows/build/ path: native/windows/build/
- run: npm run verify:windows-native:artifact - run: npm run verify:windows-native:artifact
- name: Verify NativeAOT diagnostic applicability on this host
run: npm run test:focused -- tests/evaluation/package/nativeAotDiagnostics.test.ts
- name: Resolve npm cache before verifier HOME isolation - name: Resolve npm cache before verifier HOME isolation
id: npm-cache id: npm-cache
run: | run: |
@@ -381,13 +457,24 @@ jobs:
- run: npm run verify:package - run: npm run verify:package
env: env:
npm_config_cache: ${{ steps.npm-cache.outputs.path }} npm_config_cache: ${{ steps.npm-cache.outputs.path }}
REA_VERIFIED_PACKAGE_DIRECTORY: ${{ needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64' && '.cache/verified-package' || '' }}
- name: Verify real Node Inspector discovery and observation - name: Verify real Node Inspector discovery and observation
timeout-minutes: 10 timeout-minutes: 10
run: npm run verify:inspector run: npm run verify:inspector
- name: Retain the exact qualified release package
if: needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64'
uses: actions/upload-artifact@v4
with:
name: verified-release-package
path: .cache/verified-package/
include-hidden-files: true
if-no-files-found: error
overwrite: true
retention-days: 7
apple-artifacts: apple-artifacts:
name: Real Apple artifact CLI/MCP E2E name: Real Apple artifact CLI/MCP E2E
needs: [changes, build] needs: [changes, static, build]
if: needs.changes.outputs.apple == 'true' if: needs.changes.outputs.apple == 'true'
runs-on: macos-15 runs-on: macos-15
timeout-minutes: 15 timeout-minutes: 15
@@ -435,7 +522,7 @@ jobs:
reconstruction-readiness: reconstruction-readiness:
name: Reconstruction readiness name: Reconstruction readiness
needs: changes needs: [changes, static]
if: needs.changes.outputs.readiness == 'true' if: needs.changes.outputs.readiness == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
@@ -464,7 +551,7 @@ jobs:
windows-curated: windows-curated:
name: Windows capability and package lane name: Windows capability and package lane
needs: [changes, build, windows-native-build] needs: [changes, static, build, windows-native-build]
if: needs.changes.outputs.package == 'true' if: needs.changes.outputs.package == 'true'
runs-on: windows-latest runs-on: windows-latest
timeout-minutes: 25 timeout-minutes: 25
@@ -576,6 +663,7 @@ jobs:
needs: changes needs: changes
if: needs.changes.outputs.docs == 'true' if: needs.changes.outputs.docs == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@@ -595,73 +683,78 @@ jobs:
skills/reverse-engineer-anything skills/reverse-engineer-anything
if-no-files-found: error if-no-files-found: error
workflows:
name: Validate workflows
needs: changes
if: needs.changes.outputs.workflows == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate workflow syntax and expressions
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
browser: browser:
needs: changes needs: [changes, static, build]
if: needs.changes.outputs.browser == 'true' if: needs.changes.outputs.browser == 'true'
uses: ./.github/workflows/real-browser.yml uses: ./.github/workflows/real-browser.yml
with:
build_cache_artifact: rea-build-cache
binary_layout: binary_layout:
needs: changes needs: [changes, static, build]
if: needs.changes.outputs.binary_layout == 'true' if: needs.changes.outputs.binary_layout == 'true'
uses: ./.github/workflows/real-binary-layout.yml uses: ./.github/workflows/real-binary-layout.yml
with:
build_cache_artifact: rea-build-cache
evm: evm:
needs: changes needs: [changes, static, build]
if: needs.changes.outputs.evm == 'true' if: needs.changes.outputs.evm == 'true'
uses: ./.github/workflows/real-evm-interface.yml uses: ./.github/workflows/real-evm-interface.yml
with:
build_cache_artifact: rea-build-cache
javascript_recovery: javascript_recovery:
needs: changes needs: [changes, static, build]
if: needs.changes.outputs.javascript_recovery == 'true' if: needs.changes.outputs.javascript_recovery == 'true'
uses: ./.github/workflows/real-javascript-recovery.yml uses: ./.github/workflows/real-javascript-recovery.yml
with:
build_cache_artifact: rea-build-cache
recorded_crash: recorded_crash:
needs: changes needs: [changes, static, build]
if: needs.changes.outputs.recorded_crash == 'true' if: needs.changes.outputs.recorded_crash == 'true'
uses: ./.github/workflows/real-recorded-crash.yml uses: ./.github/workflows/real-recorded-crash.yml
with:
build_cache_artifact: rea-build-cache
web_network: web_network:
needs: changes needs: [changes, static, build]
if: needs.changes.outputs.web_network == 'true' if: needs.changes.outputs.web_network == 'true'
uses: ./.github/workflows/real-web-network-captures.yml uses: ./.github/workflows/real-web-network-captures.yml
with:
build_cache_artifact: rea-build-cache
web_runtime: web_runtime:
needs: changes needs: [changes, static, build]
if: needs.changes.outputs.web_runtime == 'true' if: needs.changes.outputs.web_runtime == 'true'
uses: ./.github/workflows/real-web-runtime.yml uses: ./.github/workflows/real-web-runtime.yml
with:
build_cache_artifact: rea-build-cache
web_source_map: web_source_map:
needs: changes needs: [changes, static, build]
if: needs.changes.outputs.web_source_map == 'true' if: needs.changes.outputs.web_source_map == 'true'
uses: ./.github/workflows/real-web-source-map.yml uses: ./.github/workflows/real-web-source-map.yml
with:
build_cache_artifact: rea-build-cache
nativeaot: nativeaot:
needs: changes needs: [changes, static]
if: needs.changes.outputs.nativeaot == 'true' if: needs.changes.outputs.nativeaot == 'true'
uses: ./.github/workflows/nativeaot-fixtures.yml uses: ./.github/workflows/nativeaot-fixtures.yml
website: website:
needs: changes needs: [changes, static]
if: needs.changes.outputs.website == 'true' if: needs.changes.outputs.website == 'true'
uses: ./.github/workflows/website-check.yml uses: ./.github/workflows/website-check.yml
required: required:
name: CI required name: CI required
if: ${{ !cancelled() }} if: ${{ always() }}
needs: needs:
[ [
changes, changes,
pr-title,
static, static,
build, build,
test-shard, test-shard,
@@ -674,7 +767,6 @@ jobs:
reconstruction-readiness, reconstruction-readiness,
windows-curated, windows-curated,
docs, docs,
workflows,
browser, browser,
binary_layout, binary_layout,
evm, evm,
@@ -687,12 +779,24 @@ jobs:
website, website,
] ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version-file: .nvmrc node-version-file: .nvmrc
- name: Require every selected lane to pass - name: Require every selected lane to pass
id: gate
env: env:
CI_JOBS: ${{ toJSON(needs) }} CI_JOBS: ${{ toJSON(needs) }}
CI_QUALIFICATION_DIRECTORY: .cache/ci-qualification
run: node scripts/ci/required.mjs run: node scripts/ci/required.mjs
- name: Upload full qualification receipt artifact
if: needs.changes.outputs.tier == 'full' && success()
uses: actions/upload-artifact@v4
with:
name: ${{ steps.gate.outputs.receipt_name }}
path: ${{ steps.gate.outputs.receipt_path }}
if-no-files-found: error
overwrite: true
retention-days: 7
+2 -1
View File
@@ -12,7 +12,8 @@ jobs:
name: Build Windows x64 NativeAOT fixture name: Build Windows x64 NativeAOT fixture
# .NET 8.0.22's bundled linker discovery supports VS 2019/2022. # .NET 8.0.22's bundled linker discovery supports VS 2019/2022.
runs-on: windows-2022 runs-on: windows-2022
timeout-minutes: 15 # Two bounded restore attempts and one compile can each take five minutes.
timeout-minutes: 20
env: env:
DOTNET_CLI_TELEMETRY_OPTOUT: "1" DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_PROCESSOR_COUNT: "1" DOTNET_PROCESSOR_COUNT: "1"
+29
View File
@@ -0,0 +1,29 @@
name: PR title
on:
pull_request:
branches: [main, "release/*"]
types: [opened, synchronize, reopened, edited]
permissions: {}
concurrency:
group: pr-title-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
pr-title:
name: Conventional PR title
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Validate title for Release Please
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: >-
node -e 'const title = process.env.PR_TITLE ?? "";
const conventional = /^(?:feat|fix|docs|refactor|perf|test|build|ci|chore|revert)(?:\([a-z0-9._/-]+\))?!?: .+/u;
if (!conventional.test(title)) {
console.error("PR title must use Conventional Commits, for example: feat: add historical source import");
process.exit(1);
}'
+8 -4
View File
@@ -8,14 +8,16 @@ on:
type: boolean type: boolean
default: false default: false
workflow_call: workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
permissions: permissions:
contents: read contents: read
concurrency:
group: real-binary-layout-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env: env:
NODE_OPTIONS: --max-old-space-size=768 NODE_OPTIONS: --max-old-space-size=768
HUSKY: "0" HUSKY: "0"
@@ -33,6 +35,8 @@ jobs:
cache: npm cache: npm
- run: npm ci - run: npm ci
- uses: ./.github/actions/portable-build-cache - uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Acquire exact upstream libraries in an isolated runner environment - name: Acquire exact upstream libraries in an isolated runner environment
shell: bash shell: bash
run: | run: |
+8 -4
View File
@@ -2,12 +2,14 @@ name: Real browser verification
on: on:
workflow_call: workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
workflow_dispatch: workflow_dispatch:
concurrency:
group: real-browser-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env: env:
HUSKY: "0" HUSKY: "0"
@@ -26,6 +28,8 @@ jobs:
cache: npm cache: npm
- run: npm ci - run: npm ci
- uses: ./.github/actions/portable-build-cache - uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- run: npm run verify:browser - run: npm run verify:browser
env: env:
REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome
+8 -4
View File
@@ -3,14 +3,16 @@ name: Real offline EVM interface
on: on:
workflow_dispatch: workflow_dispatch:
workflow_call: workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
permissions: permissions:
contents: read contents: read
concurrency:
group: real-evm-interface-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env: env:
NODE_OPTIONS: --max-old-space-size=768 NODE_OPTIONS: --max-old-space-size=768
HUSKY: "0" HUSKY: "0"
@@ -27,6 +29,8 @@ jobs:
cache: npm cache: npm
- run: npm ci - run: npm ci
- uses: ./.github/actions/portable-build-cache - uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Acquire unchanged fixture compiler in isolated runner storage - name: Acquire unchanged fixture compiler in isolated runner storage
shell: bash shell: bash
run: | run: |
@@ -2,15 +2,17 @@ name: Real JavaScript recovery
on: on:
workflow_call: workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: read
concurrency:
group: javascript-recovery-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env: env:
HUSKY: "0" HUSKY: "0"
NODE_OPTIONS: "--max-old-space-size=768" NODE_OPTIONS: "--max-old-space-size=768"
@@ -28,6 +30,8 @@ jobs:
cache: npm cache: npm
- run: npm ci - run: npm ci
- uses: ./.github/actions/portable-build-cache - uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Fetch pinned Wakaru release into the job workspace - name: Fetch pinned Wakaru release into the job workspace
shell: bash shell: bash
run: | run: |
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
- run: sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y tshark util-linux python3 - run: sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y tshark util-linux python3
- run: npm ci - run: npm ci
- run: npm run build:cached - run: npm run build:cached
- run: npm run test:focused -- src/process/LinuxResourceLimits.test.ts tests/boundary/captures/captureProvider.test.ts tests/boundary/captures/captureDescriptorCleanup.test.ts tests/boundary/captures/resourceLimits.test.ts tests/boundary/captures/packetOutput.test.ts tests/boundary/mcp/packetCaptureMcp.test.ts tests/boundary/cli/captureCleanup.test.ts - run: npm run test:focused -- tests/boundary/captures/fieldDiscovery.test.ts src/process/LinuxResourceLimits.test.ts tests/boundary/captures/captureProvider.test.ts tests/boundary/captures/captureDescriptorCleanup.test.ts tests/boundary/captures/resourceLimits.test.ts tests/boundary/captures/packetOutput.test.ts tests/boundary/mcp/packetCaptureMcp.test.ts tests/boundary/cli/captureCleanup.test.ts
- run: node scripts/verify-packet-capture.mjs - run: node scripts/verify-packet-capture.mjs
env: env:
REA_TSHARK_COMMAND: /usr/bin/tshark REA_TSHARK_COMMAND: /usr/bin/tshark
+8 -4
View File
@@ -3,14 +3,16 @@ name: Real recorded crash evidence
on: on:
workflow_dispatch: workflow_dispatch:
workflow_call: workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
permissions: permissions:
contents: read contents: read
concurrency:
group: real-recorded-crash-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env: env:
NODE_OPTIONS: --max-old-space-size=768 NODE_OPTIONS: --max-old-space-size=768
HUSKY: "0" HUSKY: "0"
@@ -30,6 +32,8 @@ jobs:
cache: npm cache: npm
- run: npm ci - run: npm ci
- uses: ./.github/actions/portable-build-cache - uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Install GDB on the disposable CI runner - name: Install GDB on the disposable CI runner
shell: bash shell: bash
run: | run: |
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
- run: sudo apt-get update && sudo apt-get install -y sigrok-cli util-linux python3 - run: sudo apt-get update && sudo apt-get install -y sigrok-cli util-linux python3
- run: npm ci - run: npm ci
- run: npm run build:cached - run: npm run build:cached
- run: npm run test:focused -- src/process/LinuxResourceLimits.test.ts src/domain/signals/annotationTextWindow.test.ts tests/boundary/signals/recordingProvider.test.ts tests/boundary/signals/recordingDescriptorCleanup.test.ts tests/boundary/signals/resourceLimits.test.ts tests/boundary/mcp/signalRecordingMcp.test.ts tests/boundary/cli/signalCleanup.test.ts - run: npm run test:focused -- tests/boundary/signals/decoderDiscovery.test.ts src/process/LinuxResourceLimits.test.ts src/domain/signals/annotationTextWindow.test.ts tests/boundary/signals/recordingProvider.test.ts tests/boundary/signals/recordingDescriptorCleanup.test.ts tests/boundary/signals/resourceLimits.test.ts tests/boundary/mcp/signalRecordingMcp.test.ts tests/boundary/cli/signalCleanup.test.ts
- run: node scripts/verify-signal-recording.mjs - run: node scripts/verify-signal-recording.mjs
env: env:
REA_SIGROK_COMMAND: /usr/bin/sigrok-cli REA_SIGROK_COMMAND: /usr/bin/sigrok-cli
@@ -3,14 +3,16 @@ name: Real historical web network captures
on: on:
workflow_dispatch: workflow_dispatch:
workflow_call: workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
permissions: permissions:
contents: read contents: read
concurrency:
group: real-web-network-captures-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env: env:
HUSKY: "0" HUSKY: "0"
NODE_OPTIONS: --max-old-space-size=768 NODE_OPTIONS: --max-old-space-size=768
@@ -29,6 +31,8 @@ jobs:
run: npm install --global npm@11.16.0 run: npm install --global npm@11.16.0
- run: npm ci - run: npm ci
- uses: ./.github/actions/portable-build-cache - uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Acquire fixed upstream native profile in runner temporary storage - name: Acquire fixed upstream native profile in runner temporary storage
shell: bash shell: bash
run: | run: |
+8 -3
View File
@@ -1,12 +1,15 @@
name: Real website runtime attribution name: Real website runtime attribution
on: on:
workflow_call: workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: read
concurrency:
group: real-web-runtime-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env: env:
HUSKY: "0" HUSKY: "0"
jobs: jobs:
@@ -21,6 +24,8 @@ jobs:
cache: npm cache: npm
- run: npm ci - run: npm ci
- uses: ./.github/actions/portable-build-cache - uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- run: npm run verify:browser:runtime - run: npm run verify:browser:runtime
env: env:
REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome
+8 -3
View File
@@ -1,12 +1,15 @@
name: Real website source-map verification name: Real website source-map verification
on: on:
workflow_call: workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: read
concurrency:
group: real-web-source-map-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env: env:
HUSKY: "0" HUSKY: "0"
jobs: jobs:
@@ -21,6 +24,8 @@ jobs:
cache: npm cache: npm
- run: npm ci - run: npm ci
- uses: ./.github/actions/portable-build-cache - uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Install the pinned compiler into an isolated fixture directory - name: Install the pinned compiler into an isolated fixture directory
run: npm install --prefix "$RUNNER_TEMP/rea-web-source-map-compiler" --no-audit --no-fund --package-lock=false esbuild@0.25.10 run: npm install --prefix "$RUNNER_TEMP/rea-web-source-map-compiler" --no-audit --no-fund --package-lock=false esbuild@0.25.10
- run: npm run verify:browser:source-maps - run: npm run verify:browser:source-maps
+25 -10
View File
@@ -290,13 +290,25 @@ jobs:
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true' if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
run: npm run docs:check && git diff --exit-code run: npm run docs:check && git diff --exit-code
publish: qualification:
name: Publish to npm name: Qualify exact release revision
needs: [merged-release, release-please] needs: [merged-release, release-please]
if: >- if: >-
always() && !cancelled() && always() && !cancelled() &&
((needs.merged-release.result == 'success' && needs.merged-release.outputs.release_created == 'true') || ((needs.merged-release.result == 'success' && needs.merged-release.outputs.release_created == 'true') ||
(needs.release-please.result == 'success' && inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true')) (needs.release-please.result == 'success' && inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true'))
permissions:
contents: read
actions: read
uses: ./.github/workflows/ci.yml
with:
source_sha: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
publish:
name: Publish to npm
needs: [merged-release, release-please, qualification]
if: >-
always() && !cancelled() && needs.qualification.result == 'success'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@@ -315,17 +327,20 @@ jobs:
- run: npm ci - run: npm ci
- name: Prepare Windows native build toolchain - name: Download the exact qualified package
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64 uses: actions/download-artifact@v4
with:
name: verified-release-package
path: .cache/verified-package/
- name: Build and verify bundled Windows native controls - name: Verify qualified package provenance and digest
run: npm run build:windows-native && npm run verify:windows-native:artifact id: package
run: node scripts/ci/verified-package.mjs validate .cache/verified-package
- name: Verify packaged release before publishing
run: npm run verify:package
- name: Publish - name: Publish
shell: bash shell: bash
env:
VERIFIED_PACKAGE_TARBALL: ${{ steps.package.outputs.tarball }}
run: | run: |
version="$(node --input-type=module -e 'import packageJson from "./package.json" with { type: "json" }; process.stdout.write(packageJson.version)')" version="$(node --input-type=module -e 'import packageJson from "./package.json" with { type: "json" }; process.stdout.write(packageJson.version)')"
published_version="$(npm view "rea-agents@${version}" version --json 2>/dev/null || true)" published_version="$(npm view "rea-agents@${version}" version --json 2>/dev/null || true)"
@@ -333,7 +348,7 @@ jobs:
echo "rea-agents@${version} is already published; verifying the existing package." echo "rea-agents@${version} is already published; verifying the existing package."
else else
tag="$(node scripts/release-npm-tag.mjs "${version}")" tag="$(node scripts/release-npm-tag.mjs "${version}")"
npm publish --access public --tag "${tag}" npm publish "${VERIFIED_PACKAGE_TARBALL}" --access public --tag "${tag}"
fi fi
- name: Verify published CLI from npm - name: Verify published CLI from npm
-4
View File
@@ -7,10 +7,6 @@ on:
permissions: permissions:
contents: read contents: read
concurrency:
group: website-check-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs: jobs:
check: check:
name: Website checks name: Website checks
+3
View File
@@ -36,6 +36,7 @@ coverage/
.vitest/ .vitest/
.vitest-reports/ .vitest-reports/
test-results.xml test-results.xml
test-timings.json
.turbo/ .turbo/
.cache/ .cache/
docs/.vitepress/cache/ docs/.vitepress/cache/
@@ -72,3 +73,5 @@ Thumbs.db
# Complete static website output, including generated language editions. # Complete static website output, including generated language editions.
website/_site/ website/_site/
# Temporary translation output prepared before replacing the complete site.
website/.i18n-*/
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"threshold": 3.0, "threshold": 3.5,
"reporters": ["consoleFull"], "reporters": ["consoleFull"],
"ignore": [ "ignore": [
"node_modules/**", "node_modules/**",
+112 -15
View File
@@ -106,6 +106,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
private static final int MAX_HIGH_PCODE_INPUTS_PER_OP = 64; private static final int MAX_HIGH_PCODE_INPUTS_PER_OP = 64;
private static final int MAX_HIGH_PCODE_DEF_USE_EDGES = 12000; private static final int MAX_HIGH_PCODE_DEF_USE_EDGES = 12000;
private static final Gson GSON = new GsonBuilder().serializeNulls().create(); private static final Gson GSON = new GsonBuilder().serializeNulls().create();
private final Map<FileBytes, Boolean> targetSourceIdentity = new IdentityHashMap<>();
private String admittedTargetSha256;
private static final Set<String> DESCRIPTOR_KEYS = Set.of( private static final Set<String> DESCRIPTOR_KEYS = Set.of(
"transport", "transport",
"endpoint_path", "endpoint_path",
@@ -207,6 +209,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
"Ghidra imported-byte digest does not match the admitted target" "Ghidra imported-byte digest does not match the admitted target"
); );
} }
admittedTargetSha256 = descriptor.targetSha256;
// GhidraScript wraps run() in a transaction. End it before serving so // GhidraScript wraps run() in a transaction. End it before serving so
// each edit owns a real outer transaction and can roll back immediately. // each edit owns a real outer transaction and can roll back immediately.
end(true); end(true);
@@ -633,7 +636,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result; return result;
} }
private JsonObject addressToFileOffset(JsonObject params) { private JsonObject addressToFileOffset(JsonObject params) throws Exception {
requireKeys(params, Set.of("document", "address")); requireKeys(params, Set.of("document", "address"));
requireDocument(params); requireDocument(params);
Address address = requireAddress(params, "address"); Address address = requireAddress(params, "address");
@@ -643,14 +646,15 @@ public final class ReaGhidraBridge extends HeadlessScript {
Long fileOffset = null; Long fileOffset = null;
int mappings = 0; int mappings = 0;
for (MemoryBlockSourceInfo source : block.getSourceInfos()) { for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
if (!source.contains(address) || source.getFileBytes().isEmpty()) continue; if (!source.contains(address) || source.getFileBytes().isEmpty() ||
!isTargetSource(source.getFileBytes().get())) continue;
long offset = source.getFileBytesOffset(address); long offset = source.getFileBytesOffset(address);
if (offset < 0) continue; if (offset < 0) continue;
fileOffset = offset; fileOffset = offset;
mappings++; mappings++;
} }
if (mappings != 1) if (mappings != 1)
throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative source mappings: " + canonicalAddress(address)); throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative target-file mappings: " + canonicalAddress(address));
JsonObject result = new JsonObject(); JsonObject result = new JsonObject();
result.addProperty("address", canonicalAddress(address)); result.addProperty("address", canonicalAddress(address));
result.addProperty("file_offset", fileOffset); result.addProperty("file_offset", fileOffset);
@@ -673,14 +677,96 @@ public final class ReaGhidraBridge extends HeadlessScript {
private JsonElement procedurePseudocode(JsonObject params) throws Exception { private JsonElement procedurePseudocode(JsonObject params) throws Exception {
requireKeys(params, Set.of("document", "procedure")); requireKeys(params, Set.of("document", "procedure"));
requireDocument(params); requireDocument(params);
Function function = resolveProcedure(requireString(params, "procedure")); String selector = requireString(params, "procedure");
String value = decompiledText(decompile(function)); JsonObject selection = new JsonObject();
return functionObservation( Function function = resolveDecompileProcedure(selector, selection);
value == null ? JsonNull.INSTANCE : GSON.toJsonTree(value), try {
noReturnCallLimitations( String value = decompiledText(decompile(function));
currentProgram.getListing().getInstructions(function.getBody(), true) JsonObject result = functionObservation(
) value == null ? JsonNull.INSTANCE : GSON.toJsonTree(value),
); noReturnCallLimitations(
currentProgram.getListing().getInstructions(function.getBody(), true)
)
);
if (selection.size() > 0) result.add("selection", selection);
return result;
} catch (RequestFailure failure) {
if (selection.size() == 0) throw failure;
// Preserve completed resolution facts without changing the backend's
// failure code or suggesting that the successful mapping was invalid.
throw new RequestFailure(failure.code, "Selector " + selector +
" mapped to analysis address " + selection.get("mapped_address").getAsString() +
" and procedure " + canonicalAddress(function.getEntryPoint()) +
"; " + failure.getMessage());
}
}
private Function resolveDecompileProcedure(String selector, JsonObject selection) throws Exception {
String prefix = "file-offset:";
boolean explicitOffset = selector.startsWith(prefix);
if (!explicitOffset) {
try {
return resolveProcedure(selector);
} catch (RequestFailure failure) {
// Existing names/addresses win. Only an unresolved numeric input
// can fall back to source coordinates; ambiguity never falls back.
if (!failure.code.equals("not_found") || !selector.matches("0x[0-9a-fA-F]+"))
throw failure;
}
}
String coordinate = explicitOffset ? selector.substring(prefix.length()) : selector;
if (!coordinate.matches("0x[0-9a-fA-F]+"))
throw new RequestFailure("invalid_request", "File offsets require hexadecimal file-offset:0x... syntax");
BigInteger parsed = new BigInteger(coordinate.substring(2), 16);
if (parsed.compareTo(BigInteger.valueOf(9007199254740991L)) > 0)
throw new RequestFailure("invalid_request", "File offset exceeds the exact JSON integer range");
long offset = parsed.longValueExact();
monitor.checkCancelled();
// Ghidra's convenience lookup spans every FileBytes and uses coordinates
// local to each object. Only a complete, digest-matched target source
// establishes that the caller's offset belongs to the admitted file.
Set<Address> mappedAddresses = new java.util.LinkedHashSet<>();
for (MemoryBlock block : currentProgram.getMemory().getBlocks()) {
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
monitor.checkCancelled();
FileBytes backing = source.getFileBytes().orElse(null);
if (backing == null || !isTargetSource(backing)) continue;
Address mapped = source.locateAddressForFileOffset(offset);
if (mapped != null) mappedAddresses.add(mapped);
}
}
List<Address> candidates = new ArrayList<>(mappedAddresses);
if (candidates.size() > 1)
throw new RequestFailure("ambiguous", "File offset " + coordinate +
" has multiple loaded mappings: " + candidates.stream().map(ReaGhidraBridge::canonicalAddress).toList() +
"; select an explicit analysis address");
if (candidates.isEmpty())
throw new RequestFailure("not_found", "File offset " + coordinate +
" has no loaded source mapping for the admitted target file; Ghidra image base: " + canonicalAddress(currentProgram.getImageBase()) +
". Inspect mappings with rea inspect-native-load-image <path> --provider ghidra --json or MCP inspect_native_load_image.");
Address mapped = candidates.get(0);
Function function = functionAtOrContaining(mapped);
if (function == null)
throw new RequestFailure("not_found", "File offset " + coordinate +
" maps to analysis address " + canonicalAddress(mapped) +
", but no procedure starts at or contains that address in the current Ghidra analysis. " +
"Choose an observed procedure with CLI rea search <path> . --kind procedures --mode regex --provider ghidra --json or MCP list_procedures.");
selection.addProperty("file_offset", offset);
selection.addProperty("mapped_address", canonicalAddress(mapped));
selection.addProperty("procedure_address", canonicalAddress(function.getEntryPoint()));
return function;
}
private boolean isTargetSource(FileBytes source) throws Exception {
Boolean observed = targetSourceIdentity.get(source);
if (observed != null) return observed;
// Original FileBytes are immutable even when analysis memory is edited.
// Partial/auxiliary sources remain inspectable but cannot establish an
// original-target offset from their object-local coordinates alone.
boolean matched = source.getFileOffset() == 0 &&
constantTimeEquals(hashFileBytes(source, false), admittedTargetSha256);
targetSourceIdentity.put(source, matched);
return matched;
} }
private JsonElement procedureAssembly(JsonObject params) throws Exception { private JsonElement procedureAssembly(JsonObject params) throws Exception {
@@ -1833,14 +1919,25 @@ public final class ReaGhidraBridge extends HeadlessScript {
Address address = explicitAddress ? parseReaAddress(value) : tryParseAddress(value); Address address = explicitAddress ? parseReaAddress(value) : tryParseAddress(value);
if (address == null) if (address == null)
throw new RequestFailure("not_found", "Unknown Ghidra procedure name or address: " + value); throw new RequestFailure("not_found", "Unknown Ghidra procedure name or address: " + value);
Function function = currentProgram.getFunctionManager().getFunctionAt(address); Function function = functionAtOrContaining(address);
if (function == null && address.isMemoryAddress())
function = currentProgram.getFunctionManager().getFunctionContaining(address);
if (function == null) if (function == null)
throw new RequestFailure("not_found", "No procedure exists at the requested address: " + value); // Other procedure operations retain their analysis-address semantics.
throw new RequestFailure("not_found", "No procedure exists at the requested address: " + value +
"; Ghidra image base: " + canonicalAddress(currentProgram.getImageBase()) +
". Choose an observed analysis address. CLI: rea search <path> . --kind procedures --mode regex --provider ghidra --json; " +
"MCP: list_procedures. Inspect the loaded base and source mappings with CLI rea analyze <path> --provider ghidra --json " +
"and rea inspect-native-load-image <path> --provider ghidra --json, or MCP binary_overview and inspect_native_load_image. " +
"Repeat the original target options. For decompile, select source coordinates explicitly with --file-offset=0x... or MCP procedure file-offset:0x....");
return function; return function;
} }
private Function functionAtOrContaining(Address address) {
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
return function == null && address.isMemoryAddress()
? currentProgram.getFunctionManager().getFunctionContaining(address)
: function;
}
private JsonObject nativeApiBoundary( private JsonObject nativeApiBoundary(
Function function, Function function,
DecompileResults decompilation DecompileResults decompilation
+1 -1
View File
@@ -93,7 +93,7 @@ def project_record(state, secrets):
safe_bytes, credential_url = transport_value(value, path, transport_url) safe_bytes, credential_url = transport_value(value, path, transport_url)
content_base64 = base64.b64encode(value).decode("ascii") content_base64 = base64.b64encode(value).decode("ascii")
digest = hashlib.sha256(value).hexdigest() digest = hashlib.sha256(value).hexdigest()
hidden = any(secret in value or secret in safe_bytes for secret in byte_secrets) or any(secret in content_base64 or secret in digest for secret in secrets) hidden = any(secret in value or secret in safe_bytes for secret in byte_secrets) or any(secret in content_base64 or secret in digest or secret in str(len(value)) for secret in secrets)
safe_text = None safe_text = None
try: try:
safe_text = safe_bytes.decode("utf-8", errors="strict") safe_text = safe_bytes.decode("utf-8", errors="strict")
+1 -1
View File
@@ -174,7 +174,7 @@ Resolution follows dyld:
- `@rpath` tries each `LC_RPATH` of the loading image first, then those of the - `@rpath` tries each `LC_RPATH` of the loading image first, then those of the
image that loaded it, up to the process root. image that loaded it, up to the process root.
- Dependencies load dependents-first in load-command order, and each image - Dependencies load dependents-first in load-command order, and each image
loads once per process. A later request whose install name matches an loads once per process. A later @rpath request whose install name matches an
already loaded image reuses it. already loaded image reuses it.
- Symlinks such as `Versions/Current` are followed inside the analyzed root. - Symlinks such as `Versions/Current` are followed inside the analyzed root.
An escape through `..` or an absolute link is `escapes-target`. An escape through `..` or an absolute link is `escapes-target`.
+31 -2
View File
@@ -298,13 +298,42 @@ subsequent wire recovery and clean shutdown. Caller timeout does not interrupt
Hopper's synchronous native operation. Socket boundary tests deterministically Hopper's synchronous native operation. Socket boundary tests deterministically
cover active and queued expiry, late replies and timer cleanup. cover active and queued expiry, late replies and timer cleanup.
The Linux demo lane remains a separate `verify:hopper:linux` command. Its The Linux demo lane remains a separate `verify:hopper:linux` command. Hopper
lifecycle checks attempt competing CLI launches for the active target and a 6.4.2's ELF loader did not recognize the original 4,119-byte literal or three
non-ASCII byte strings in the measured fixtures. The lane retains complete raw
byte checks for those literals through source-owned symbols. Recognition of the
original full-length literal is bound to its exact source address; its decoded
object coverage remains explicitly unverified on Linux even when recognized.
Absent non-ASCII recognition also reports decoded-object coverage as unverified.
Recognized non-ASCII and control-text objects must still match their source
bytes, encoding, filters and searches.
A separate 548-byte ASCII literal requires native fragmentation, shortened
provider display, complete decoded fragments and dossier provenance on Linux;
the macOS lane retains the original full-length recognition requirement. These
are observed fixture/build capabilities, not universal Hopper length limits.
Its lifecycle checks attempt competing CLI launches for the active target and a
different target, requiring rejection before either can change the owning different target, requiring rejection before either can change the owning
MCP session's documents or procedures. Lease boundary tests also preserve MCP session's documents or procedures. Lease boundary tests also preserve
unresponsive or malformed live endpoints while allowing confirmed stale unresponsive or malformed live endpoints while allowing confirmed stale
sockets to recover. sockets to recover.
`verify:setup:linux` exercises installation on a clean supported Linux runner as
root. Use a disposable VM: this lane installs Hopper and its Linux runtime
dependencies through the system package manager. Set `REA_VERIFY_SERVER_COMMAND`
to the CLI entrypoint being verified. The command builds and checks source-owned
ELF fixtures before testing the read-only Hopper setup plan, approved
installation, scoped doctor, real CLI/MCP analysis, rejection of a modified
launcher, idempotent setup without requesting a reinstall, and owned child
cleanup. Before installation and after teardown, a clean-runner process scan
also rejects surviving Hopper demo helpers or Xvfb groups, including orphans
whose launcher exited. This scan observes processes without signaling them.
Hopper detection compares executable file identity through `/proc`, covering
alternate or configured launcher paths even after an invoked symlink is removed.
The dedicated runner must have no unrelated Hopper/Xvfb sessions. It requires
the repository's pinned development toolchain and an
existing host C compiler, `file`, `nm`, and `strings`. Use `verify:hopper:linux`
instead when Hopper is already installed.
`verify:hopper:fat` is a separate macOS lane requiring installed Hopper and the `verify:hopper:fat` is a separate macOS lane requiring installed Hopper and the
existing Xcode clang/lipo toolchain. It compiles arm64/x86-64 thin executables and existing Xcode clang/lipo toolchain. It compiles arm64/x86-64 thin executables and
one- and two-slice FAT32 containers, verifies exact/interior address mappings one- and two-slice FAT32 containers, verifies exact/interior address mappings
+74
View File
@@ -41,6 +41,80 @@ does not prove text is absent. Both list and search Evidence carry this
limitation. When an address is known, use `read_bytes` and `xrefs` to inspect limitation. When an address is known, use `read_bytes` and `xrefs` to inspect
bytes and references. bytes and references.
## Loaded addresses and file offsets
`rea analyze <path> --provider ghidra --json` and MCP `binary_overview` report
the observed loaded `image_base` in `normalized_result`. They retain each
segment's reported `image_base` and `address_space`. A provider that does not
report a base, or whose segments disagree, omits the overview field, following
the existing contract on main. REA does not substitute zero or the first
segment's address. All reported bases and address spaces remain visible in the
complete segment inventory, including conflicting observations.
`function` and most MCP procedure selectors use procedure names or
provider analysis addresses. Ghidra `decompile` / MCP `procedure_pseudo_code`
also accept source-file byte offsets. Existing symbols and analysis addresses
take precedence; a `0x`-prefixed number that resolves to no procedure falls back
to its observed source mapping. Use `--file-offset=0x...` on the CLI or
`procedure: "file-offset:0x..."` through MCP to select file coordinates explicitly.
Multiple mapping candidates fail as ambiguous rather than selecting a bank or
address space. A successful conversion retains the file offset, mapped address
and procedure entry in raw Evidence and `locations`.
If an offset maps to loaded target bytes but no procedure starts at or contains
the mapped address, the failure preserves both the original file offset and
analysis address. This differs from an unmapped offset and does not recommend
retrying the same offset syntax.
If the function resolves but the backend fails or cancels decompilation, the
failure retains the selector, mapped address and procedure entry without
changing the backend failure code.
Both mapping directions require a complete original `FileBytes` source whose
SHA-256 matches the admitted target. Auxiliary files and partial source objects
remain visible in the load-image inventory but do not establish offsets in the
original target. Ghidra's object-local offsets are not silently treated as
original-file coordinates. Original-source identity is checked once per source
object per session; analysis-memory edits do not change the original bytes.
ELF virtual addresses and file offsets are
different coordinates. Ghidra can rebase a PIE ELF during import: in the
source-owned verification fixture, ELF entry `0x1013` becomes Ghidra procedure
`0x101013` with image base `0x100000`. That example is not a general conversion
rule.
Use `list_procedures` to select an observed procedure address. To relate file
bytes to loaded memory, call MCP `inspect_native_load_image` or:
```sh
rea search <path> . --kind procedures --mode regex --provider ghidra --json
rea inspect-native-load-image <path> --provider ghidra --json
rea decompile <path> <observed-procedure-address> --provider ghidra --json
rea decompile <path> --file-offset=0x1013 --provider ghidra --json
rea address-to-file-offset <path> <observed-procedure-address> --provider ghidra --json
```
The load-image result preserves source-file indexes, file offsets, address
spaces, and inclusive mapping ranges. Follow the mapping for the selected
source file and region; adding `image_base` to an arbitrary file offset is not
valid. For a linear byte-addressed, file-backed mapping, a file offset `F` in
`[mapping.file_offset, mapping.file_offset + mapping.length)` maps to
`mapping.start + (F - mapping.file_offset)` in the reported `address_space`.
If several mappings cover the same file offset, keep those candidates distinct
and select the procedure from the observed inventory. Uninitialized regions may
have no file backing. ELF mappings are observed
without independent format-specific load-image verification, as the result's
status and limitations state.
An unsuccessful numeric procedure lookup reports the current Ghidra image base
and gives the CLI commands above alongside the corresponding MCP operations.
Repeat any target-format, architecture, and entry-point options used to open the
target. Other procedure operations keep their analysis-address semantics;
file-offset fallback applies only to decompilation.
Overview workflow revision 3 binds this output to snapshot identity. Older
serialized overview Evidence remains readable; it is not replayed as a current
overview merely because its source artifact is unchanged.
## Admitted function-analysis semantics ## Admitted function-analysis semantics
| Concern | Ghidra contract | | Concern | Ghidra contract |
+4
View File
@@ -57,6 +57,10 @@ Hopper installation or any file write. Declining or pressing Ctrl-C makes no
changes. Agent configuration writes preserve unrelated entries and comments, changes. Agent configuration writes preserve unrelated entries and comments,
create backups, use atomic replacement, and verify their result. Setup and create backups, use atomic replacement, and verify their result. Setup and
uninstall retain an existing `.rea.backup` rather than replacing the first snapshot. uninstall retain an existing `.rea.backup` rather than replacing the first snapshot.
If that snapshot differs from the current configuration, setup previews and
preserves a verified `.rea.backup.<sha256>.recovery` copy of the current bytes.
Both snapshots remain available; a failed rollback reports the current recovery
copy's path and the restore failure.
Setup also retains valid environment overrides in REA's existing server entry, Setup also retains valid environment overrides in REA's existing server entry,
such as `REA_MCP_INPUT_SCHEMA_PROFILE` and `REA_MCP_MAX_RESPONSE_BYTES`. such as `REA_MCP_INPUT_SCHEMA_PROFILE` and `REA_MCP_MAX_RESPONSE_BYTES`.
Detected provider settings replace only matching environment keys. Detected provider settings replace only matching environment keys.
+9
View File
@@ -104,6 +104,15 @@ static inferences. Dynamic or ambiguous IPC is retained without a pairing edge.
Runtime entities retain passive-CDP authority, while a successful static/runtime Runtime entities retain passive-CDP authority, while a successful static/runtime
correspondence is a separately identified cross-layer inference. correspondence is a separately identified cross-layer inference.
Source module links retain their resolution mode. Static imports in `.ts` and
`.tsx` are `compiler-dependent` when compiler options have not been retained.
REA compares Node import and require resolution: a shared outcome remains
available with a limitation; divergent outcomes remain unavailable with both
conditional branches and their limitations. This models source relationships,
not verified emitted code or execution. A declaration file found only through
an emitted-extension fallback is retained as a candidate, not a runtime target;
an explicitly selected path keeps its original identity.
## Evidence and authority ## Evidence and authority
Every observation and edge carries all of the following: Every observation and edge carries all of the following:
+8
View File
@@ -23,6 +23,14 @@ artifact digest and structural graph ID. Semantic tracing requires the semantic
relation graph and reports when it is unavailable rather than treating missing relation graph and reports when it is unavailable rather than treating missing
data as an empty graph. data as an empty graph.
Explicit TypeScript `import type` and `export type` declarations remain source
facts and do not create runtime import or export relationships. Type export names
are reported separately from value export names. Inline `type` specifiers can
leave an empty import or re-export with side effects under
[`verbatimModuleSyntax`](https://www.typescriptlang.org/tsconfig/verbatimModuleSyntax.html);
REA retains those dependency candidates and reports the unavailable compiler
options rather than assuming the dependency is erased.
The semantic graph wire format stores shared non-location provenance once in The semantic graph wire format stores shared non-location provenance once in
its required `evidence_contexts` table. Each node, relation, fingerprint, and its required `evidence_contexts` table. Each node, relation, fingerprint, and
unknown retains its exact `evidence.location` and names the owning context with unknown retains its exact `evidence.location` and names the owning context with
+15 -2
View File
@@ -147,8 +147,8 @@ after target content was read.
## Retained application Evidence inputs ## Retained application Evidence inputs
`analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`, and `analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`,
`inspect_wasm_artifact` accept `detail`: `inspect_wasm_artifact`, and `inspect_macho` accept `detail`:
`complete` (default) returns the complete analysis Evidence; `summary` records `complete` (default) returns the complete analysis Evidence; `summary` records
that complete Evidence in the session and returns the derived that complete Evidence in the session and returns the derived
`inspect_analysis_view` summary Evidence instead. Its `inspect_analysis_view` summary Evidence instead. Its
@@ -218,6 +218,19 @@ attempting oversized delivery, and returns the same summary with an exact
`parent_evidence_id` for later export or facet reads. Delivery detail does not `parent_evidence_id` for later export or facet reads. Delivery detail does not
reach WABT or change the original source Evidence ID. reach WABT or change the original source Evidence ID.
Completed `inspect_macho` Evidence supports a summary and
`view: {kind: "macho", facet: "exports", offset: 0, limit: 64}`. Facets are
`entrypoints`, `architectures`, `build_metadata`, `load_commands`,
`dependencies`, `imports`, `exports`, `segments`, and `provenance`; pages return
the original rows. The summary keeps the selected slice, header fields and UUID,
and replaces each collection with its returned count, reported total and
`exhaustive` flag. Each page repeats the paged collection's `source_total` and
`source_exhaustive`; exhausting a non-exhaustive collection does not establish
that unreturned symbols are absent. Large frameworks return megabytes of
symbols (Electron Framework: 2.5 MB of imports and exports, 3.3 KB as a
summary), so select `detail: "summary"` on the first call. Delivery detail
never reaches the native tools or changes the source Evidence ID.
`trace_application_feature`, `trace_javascript_semantics`, `trace_application_feature`, `trace_javascript_semantics`,
`compare_application_versions`, `compare_source_to_bundle`, and `compare_application_versions`, `compare_source_to_bundle`, and
`compare_javascript_export_shapes` accept complete inline application Evidence `compare_javascript_export_shapes` accept complete inline application Evidence
+46
View File
@@ -128,3 +128,49 @@ Each provider admits one inspection at a time, including acquisition and cleanup
An occupied provider returns a retryable capacity error before acquiring another An occupied provider returns a retryable capacity error before acquiring another
file or worker. Closing the provider stops new admission and drains accepted work; file or worker. Closing the provider stops new admission and drains accepted work;
failed cleanup remains owned and can be retried. failed cleanup remains owned and can be retried.
## Discover protocols and field names
`inspect_packet_fields` / `inspect-packet-fields` queries the installed TShark
registration glossary before selecting fields for capture inspection:
```sh
rea inspect-packet-fields --query usb
rea inspect-packet-fields --kind fields --protocol usb --query idVendor
rea inspect-packet-fields --kind fields --protocol udp --all
```
`kind` defaults to `protocols`; `fields` selects field definitions. `protocol`
selects an exact protocol abbreviation (the protocol itself or its child fields).
`query` is a case-insensitive literal substring in name, abbreviation or field
description. `offset` skips matching records; `limit` defaults to 50. MCP
`limit: null` or CLI `--all` requests all remaining matches within the resource
budget. `--all` and `--limit` conflict. The window reports examined records of
the selected kind before filters, matching records, returned records and
`has_more`; it never counts capture packets.
Returned records preserve field type, parent protocol, display base, bitmask,
description and original glossary line number. Empty producer strings remain
empty; protocol records have null field-only properties. Repeated abbreviations
remain separate registrations. A listed field is an installation capability, not
proof that the field appears in a selected recording.
The adapter uses the documented `tshark -G fields` grammar under its existing
private empty profile and Linux process limits. TShark 4.6.9's full glossary was
measured at 22,303,197 bytes; discovery admits at most **32 MiB** combined command
output (capture inspection remains at **4 MiB**). Parsing walks lines once and
constructs objects only for the selected page. Returned strings are copied so
retained pages do not retain the complete glossary string. Before constructing a selected
record it charges six times the source UTF-8 bytes plus 512 bytes against an
**8 MiB** representation budget, covering JSON escaping and record bookkeeping.
If that budget would be exceeded, narrow the query or page size; results are not
silently truncated. This is a conservative admission budget, not measured heap
usage. Parent pipe buffers and producer per-process limits remain distinct.
Tool/limiter identity, version, stderr and selected arguments are retained in
Evidence. No capture, interface or device is opened. Discovery shares capture
analysis's admission slot and cleanup owner. Each call rereads the glossary;
installed plugins, libraries and global configuration remain trusted and are not
fully fingerprinted. There is no cache or snapshot-consistent pagination claim.
The real Linux lane checks public CLI/MCP queries against the direct glossary,
including USB vendor-ID typing, negative queries and paging.
+12 -3
View File
@@ -6,7 +6,10 @@ checks to pass. Merging that PR into main starts publication automatically.
The Release workflow validates the merged release metadata and ancestry before The Release workflow validates the merged release metadata and ancestry before
creating a tag. npm and MCP Registry publication use the exact reviewed merge creating a tag. npm and MCP Registry publication use the exact reviewed merge
SHA returned by Release Please. Later main commits cannot change that source. 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.
Ordinary main pushes and closed, unmerged PRs cannot publish packages. Check Ordinary main pushes and closed, unmerged PRs cannot publish packages. Check
both publication jobs and the published-package canary; a merged PR or GitHub 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 tag alone does not establish that publication finished. Retry failed jobs in
@@ -196,8 +199,14 @@ both registry publishes.
See [npm's provenance implementation](https://github.com/npm/cli/blob/v11.16.0/workspaces/libnpmpublish/lib/provenance.js) See [npm's provenance implementation](https://github.com/npm/cli/blob/v11.16.0/workspaces/libnpmpublish/lib/provenance.js)
for the use of GitHub's workflow ref and commit SHA. for the use of GitHub's workflow ref and commit SHA.
The workflow builds the bundled Windows controls and verifies the packaged Full qualification builds the bundled Windows controls and verifies the packaged
artifact before npm publication. It then verifies the published CLI, the artifact on its native hosts. The Linux x64 verifier retains the exact tarball
it installed and tested, after its workspace cleanup completes. The npm job
downloads that archive from the same workflow run and checks its source SHA,
run identity/attempt, package and Node versions, host identity, and SHA-256.
It publishes that archive without rebuilding or repacking it. Re-running failed
publisher jobs can reuse a prior successful attempt from the same run.
The workflow then verifies the published CLI, the
capability-scoped MCP catalog, and the isolated package update path before capability-scoped MCP catalog, and the isolated package update path before
publishing MCP Registry metadata. publishing MCP Registry metadata.
+34
View File
@@ -106,3 +106,37 @@ One decode is admitted at a time, including acquisition and cleanup. Excess
requests receive a retryable capacity error before acquiring another file or requests receive a retryable capacity error before acquiring another file or
worker. Shutdown closes admission and drains accepted work; failed cleanup worker. Shutdown closes admission and drains accepted work; failed cleanup
remains owned and retryable. remains owned and retryable.
## Discover decoders before choosing settings
`inspect_signal_decoders` / `inspect-signal-decoders` queries the **installed**
sigrok catalog without opening a recording or scanning devices:
```sh
rea inspect-signal-decoders --query uart
rea inspect-signal-decoders --decoder uart --query baud
rea inspect-signal-decoders --decoder i2c --offset 0 --limit 50
```
Omit `decoder` to search decoder IDs and names. Supply one exact decoder ID to
read its literal help, including required/optional channels, options, annotation
classes and documentation. `query` is a case-insensitive literal substring;
matching help retains original one-based line numbers. Empty query selects all.
`offset` skips matches; `limit` defaults to 50. MCP `limit: null` or CLI `--all`
returns all remaining matches; CLI `--all` and `--limit` conflict.
The window reports examined, matching and returned records and `has_more`.
A filtered empty page is not evidence that the installation has no decoders.
Discovery refuses more than 65,535 line separators before constructing record
objects (65,536-line representation budget), in addition to the 4 MiB output cap.
Exceeding that budget is a resource failure with captured output. Returned text
is copied so a retained page does not keep the complete producer output alive.
Discovery shares decoding's owned process, admission slot, resource limits,
cleanup retries and Evidence delivery. It records the executable and limiter
identities, version banner and warnings. Failed version checks and malformed
producer output preserve diagnostics. Help is producer text, not an independently
validated parameter schema. Each request probes again; it does not cache or claim
stable pages across installation changes. Libraries/decoder modules remain
trusted and are not fully fingerprinted. Catalog output includes a SHA-256 of
its executable, not a digest of the complete installation.
+211 -34
View File
@@ -263,17 +263,62 @@ CI owns coverage; aggregate and domain/contract thresholds are maintained in
Coverage does not replace named boundary or real-provider scenarios. Coverage does not replace named boundary or real-provider scenarios.
CI runs six native Vitest shards without retries. Each shard emits a blob CI runs six native Vitest shards without retries. Each shard emits a blob
report; the merge job produces aggregate coverage plus JUnit and JSON timing report; the merge job produces JUnit and JSON diagnostics, a module timing
report, plus aggregate coverage during full qualification. It retains available
reports even when a shard fails, uploads available diagnostics, and writes the reports even when a shard fails, uploads available diagnostics, and writes the
slowest files to the workflow summary. Partial reports are marked diagnostic slowest files to the workflow summary. Partial reports are marked diagnostic
only. Failed, cancelled, or missing selected shards still fail the required gate, only. Failed, cancelled, or missing selected shards still fail the required gate,
and aggregate coverage thresholds remain enforced by the merge command. and aggregate coverage thresholds remain enforced in full qualification.
Only successful complete merges publish the small `test-timings` artifact, with Only successful complete full-tier merges publish the small `test-timings` artifact, with
checkout-relative paths; the build job downloads that artifact for the sequencer checkout-relative paths; the build job downloads that artifact for the sequencer
instead of the coverage/JUnit bundle. Missing history falls back to native instead of the coverage/JUnit bundle. Timing weights come from Vitest module
diagnostics and include tests and hooks, worker preparation, environment,
imports, and setup. Import timings already include transform waits, which are
not added again. The weights describe file costs under the measured worker and
coverage settings; their sum is not workflow elapsed time. The diagnostic
artifact retains the phase breakdown, while shard history keeps only each
file's total. Legacy test-body-only history and missing history fall back to native
sharding. Static checks, documentation, build, package verification, and sharding. Static checks, documentation, build, package verification, and
real-system lanes remain separate jobs so one kind of evidence cannot stand in real-system lanes remain separate jobs so one kind of evidence cannot stand in
for another. for another. Workflow syntax and expression validation runs inside the required
static-check job when workflow changes select it, avoiding a separate runner.
Native, provider, fixture, SQLite compatibility, and website lanes wait for
static checks to succeed before allocating their runners. This avoids spending
those runner slots on revisions that already fail static validation. Portable
test shards and documentation checks remain independent of static results, so
they can still provide diagnostic feedback. The required gate rejects skipped
selected lanes when static checks fail; passing revisions retain all selected
coverage. This ordering reduces peak demand on invalid revisions but can add
static-check waiting to otherwise passing runs; it is not a measured reduction
in end-to-end latency.
Core jobs have execution limits of five minutes for classification, ten minutes
for static checks, builds, docs, and report merging, and thirty minutes per
portable test shard. These limits leave headroom for cold setup and full coverage
while preventing a hung job from holding the active main run for hours. They do
not bound runner queue waiting. Timeouts still fail the required gate.
The separate `CI timings` workflow batches recent completed CI and Release runs
hourly or on manual dispatch. It uploads `ci-job-timings-batch` with raw API pages and
a normalized point-in-time report for each collected workflow attempt, plus a
batch manifest describing examined coverage, failures, and limits. Overlapping
lookback windows may collect an attempt again; identify records by run ID and
attempt. Each batch examines at most 100 completed runs per workflow created in
the preceding 24 hours, with a four-minute API collection budget. It collects the
latest attempt of each selected run and does not claim an exhaustive archive.
It runs the reporter from the trusted default branch with read-only permissions,
treating workflow runs as API data. It does not allocate a new reporter job for
every completed, cancelled, or skipped workflow. Reports separate queue
waiting (`created_at` to `started_at`) from completed execution and report
percentiles plus matched `npm ci` and artifact-step durations.
Queue excludes dependency waiting before job creation and does not establish
whether account concurrency or runner availability caused the wait. Queued jobs with
placeholder start times, skipped jobs, and missing or invalid timestamps remain
unknown. The reporter targets each selected run and attempt explicitly.
Summed job elapsed time is parallel work, not workflow wall time, CPU time,
memory usage, or billing. Step names identify samples, not complete transfer
costs. Its queue, execution, and failures are independent of the required gate
and publication. CPU time and peak process-family memory are not measured by this API.
The build job saves portable Turbo outputs across runs. Eight Linux real-system The build job saves portable Turbo outputs across runs. Eight Linux real-system
lanes restore this cache without waiting for the build job. The shared action lanes restore this cache without waiting for the build job. The shared action
@@ -697,6 +742,44 @@ specifications, independent mappings, unchanged bytes, and decompilation of a
known return value. AArch64 remains covered by deterministic tests rather than known return value. AArch64 remains covered by deterministic tests rather than
this real-provider lane. See [raw binary analysis](raw-binary.md). this real-provider lane. See [raw binary analysis](raw-binary.md).
`npm run verify:ghidra:address-context` runs on Linux with a caller-installed
Ghidra, full JDK, and an x86-64 ELF GCC toolchain (`CC` selects another compatible
compiler). It builds
`tests/conformance/c/pie-address-context.c` as a PIE ELF without executing it.
The compiled CLI and real stdio MCP workflow must expose the measured image
base, retain segment address spaces, agree on the overview, resolve explicit and
numeric file offsets, and reject unmapped analysis addresses with actionable
guidance. It checks the reverse `address-to-file-offset` mapping, file-coordinate
Evidence locations, and the suggested CLI search and load-image commands.
The verifier compares mapped bytes with the source file and checks source
identity, session preservation after failed lookup, owned runtime cleanup, and
verified empty process lineage. This lane does not establish Windows or macOS
real-provider coverage.
For source-identity boundaries that a normal single-file import cannot create,
`tests/conformance/ghidra/ReaFileOffsetSourceProbe.java` exercises the production
bridge against real Ghidra `FileBytes`. It rejects auxiliary and partial sources,
checks the original target's round trip, and retains ambiguity when duplicate
target bytes have distinct loaded mappings. Injected decompiler failures and
cancellation verify that completed source/function resolution facts and backend
failure codes survive; these injections do not establish natural engine failures.
On Windows, after creating the
source-owned PE fixture, run from the repository root:
```powershell
node scripts/create-ghidra-windows-fixture.mjs
$proofRoot = Join-Path $env:TEMP 'rea-file-offset-source-proof'
New-Item -ItemType Directory -Path $proofRoot -Force | Out-Null
Copy-Item bridge/ghidra/ReaGhidraBridge.java, bridge/ghidra/ReaGhidraExtensions.java, tests/conformance/ghidra/ReaFileOffsetSourceProbe.java -Destination $proofRoot
& "$env:GHIDRA_INSTALL_DIR/support/analyzeHeadless.bat" $proofRoot source-proof -import "$PWD/build/fixtures/rea-ghidra-windows.exe" -scriptPath $proofRoot -postScript ReaFileOffsetSourceProbe.java -deleteProject
```
Require the `REA_FILE_OFFSET_SOURCE_OK` marker and no script errors; Ghidra may
exit successfully even when a post-script fails. The probe edits only its
temporary analysis database and does not execute the fixture. This producer
regression supplements public CLI/MCP coverage and does not establish Linux
host behavior or native process-supervision controls.
## Apple Interface Builder archives ## Apple Interface Builder archives
`npm run verify:interface-builder` compiles the source-owned AppKit XIB into a `npm run verify:interface-builder` compiles the source-owned AppKit XIB into a
@@ -717,11 +800,32 @@ or the GitHub issue and pull-request templates run formatting and
generated-document validation, including the small onboarding example consistency generated-document validation, including the small onboarding example consistency
suite, without the source-test shards or native package suite, without the source-test shards or native package
lanes. Classification compares the PR head with its merge base, so later lanes. Classification compares the PR head with its merge base, so later
base-branch changes do not expand that scope. Static and coverage aggregate jobs base-branch changes do not expand that scope. Static and portable-test aggregate jobs
remain present and fail if classification fails. Every code change retains the remain present and fail if classification fails. Every code change retains the
complete six-shard Linux suite; individual test files are not selected by complete six-shard Linux suite; individual test files are not selected by
imports or filenames. imports or filenames.
Ordinary PRs also produce an **observational** test-selection plan. The candidate
leaf-adapter ownership table lives in `scripts/ci/test-selection.mjs`, separately
from provider-job scopes. It proposes keeping affected owners plus every unowned
test, including all CLI/MCP, acceptance, composition, conformance, and process
tests. Shared inputs, unknown paths, missing owner tests, and removed tests fall
back to the complete inventory. Renames account for both paths. Main, scheduled,
manual, and `ci:full` runs always propose the full suite.
All six shards still execute the complete suite. The existing report merge
compares the proposal with actual module outcomes and retains
`.cache/ci-test-selection/report.json` in `test-results`, including selected and
omitted paths, changed input paths, missed failures, unobserved files, scheduling weights, PR head and
executed merge SHA. This adds no test reruns or runner jobs. Missing, duplicate,
skipped, interrupted, or unhandled results cannot establish a complete comparison;
even a complete passing comparison is not proof of ownership completeness.
Before enabling omissions, review representative full-run comparisons and
cross-owner consumers, including failures, and measure completed CI feedback and
runner demand. There is no automatic promotion or switch to skip tests. The
initial proposal measures only leaf producer-test omissions; it does not promise
the savings of a broader affected-test planner.
`scripts/ci/plan.mjs` selects lanes using the ownership table in `scripts/ci/plan.mjs` selects lanes using the ownership table in
`scripts/ci/scopes.mjs`. Provider implementation changes run their real-provider `scripts/ci/scopes.mjs`. Provider implementation changes run their real-provider
verifiers and the Linux x64 installed-package/Inspector baseline. The `apple` verifiers and the Linux x64 installed-package/Inspector baseline. The `apple`
@@ -736,9 +840,19 @@ lane. Edits to verifier entrypoints run only by manual, licensed or self-hosted
workflows run static checks and the Linux suite. Website changes run website workflows run static checks and the Linux suite. Website changes run website
checks. Provider workflow changes validate the workflow and run that provider's checks. Provider workflow changes validate the workflow and run that provider's
verifier. Manual-provider/Android workflow changes run workflow validation only. verifier. Manual-provider/Android workflow changes run workflow validation only.
The four-version SQLite compatibility matrix has its own `sqlite` scope for
SQLite implementation, contracts, CLI/MCP registration, fixtures, and shared
runtime boundaries. SQLite and server-lifecycle test edits and the SQLite npm
scripts also select it. Unrelated code edits retain the complete portable suite
on the pinned Node version without starting four additional compatibility jobs.
Full qualification retains every SQLite version.
Shared process/contract/CLI boundaries, dependencies, other packaging inputs, Shared process/contract/CLI boundaries, dependencies, other packaging inputs,
unknown paths, and changes to the planner select the full baseline. Package unknown paths, and changes to the planner select the full baseline.
script-only edits select their owning verifier when its entrypoint is CI timing collection, timing history, and test-report helper edits retain static
checks and the complete portable suite without selecting unrelated host/provider
lanes. Classification, qualification, gate, release receipt, and unknown CI
scripts keep the broad fallback.
Package script-only edits select their owning verifier when its entrypoint is
identifiable; unknown or shared script changes select everything. Android identifiable; unknown or shared script changes select everything. Android
build-script edits retain the Linux suite and workflow validation; real Android build-script edits retain the Linux suite and workflow validation; real Android
verification stays manual. Renames account for both paths. verification stays manual. Renames account for both paths.
@@ -746,38 +860,84 @@ verification stays manual. Renames account for both paths.
This narrower selection trades PR-time coverage for runner capacity. A change to This narrower selection trades PR-time coverage for runner capacity. A change to
unowned shared runtime code that fails only on arm64, macOS, Windows, the Apple unowned shared runtime code that fails only on arm64, macOS, Windows, the Apple
artifact lane, or a provider lane that does not own the file is not caught on artifact lane, or a provider lane that does not own the file is not caught on
the PR; the main-push full baseline reports it after merge. Ownership globs do the PR; scheduled full qualification reports it after merge. Ownership globs do
not follow imports. Use the `ci:full` label when a change may have native-host not follow imports. Use the `ci:full` label when a change may have native-host
or provider effects outside its owners. or provider effects outside its owners.
Main pushes and manual dispatch retain the full baseline, including all portable Ordinary main pushes use the same owner scopes as PRs. Their diff starts at a
provider lanes and the native matrix below. A `ci:full` PR label selects the full successful ancestor push run of this CI workflow, rather than the preceding
baseline on the next PR run; manual dispatch can request it immediately. Each push: changes from failed or cancelled intermediate runs remain in scope.
Missing, untrusted, or non-ancestor history selects every lane. The lookup is
bounded to the last 100 successful push runs; lookup failures broaden selection.
Every code change still runs all six portable shards, without coverage
instrumentation in this fast tier. This deliberately avoids relying on an
import graph for generated files, filesystem inputs, subprocesses, or bridges.
Scheduled qualification is admitted hourly at minute 23. A forced full run at
03:23 UTC refreshes runner and dependency drift every day. Other hourly runs
can reuse a successful full-qualification receipt for the exact source SHA
from the same repository's trusted CI workflow on its default branch. Both the
receipt and original workflow run must be less than 24 hours old, so retrying an
old gate cannot renew stale proof. Missing, expired, failed, foreign, or uncertain proof runs full
validation; API lookup failure also runs full. Lookups examine at most 100 named
receipts and three candidate runs within a 20-second budget. Reused runs select
no validation lanes, identify the original run, and produce no new receipt, so
reuse cannot indefinitely extend proof freshness. They make no fresh provider
observation. Manual dispatch, `ci:full`, and exact release qualification always
run full validation.
Full qualification includes aggregate coverage,
every portable provider lane, and all native hosts below. Adding `ci:full`
immediately starts the separate `Full PR qualification` workflow against the
label event's merge revision; subsequent PR revisions also select the full tier
in normal CI while that label remains. Unrelated labels do not start validation
jobs or replace the main CI checks. Manual dispatch also requests full validation.
Fast-tier broad fallbacks retain all owner lanes and the three-host
package matrix; Intel macOS and coverage remain full-tier obligations. Each
classification job records its selection and broad-fallback reasons in the run classification job records its selection and broad-fallback reasons in the run
summary. Representative Git-diff cases live in summary. Representative Git-diff cases live in
`tests/boundary/filesystem/ciChangeScope.test.ts`. `tests/boundary/filesystem/ciChangeScope.test.ts`.
`CI required` checks classification and every selected lane, rejecting failed, `CI required` always runs and checks classification and every selected lane,
cancelled, or unexpectedly skipped work. Existing required check names are rejecting failed, cancelled, or unexpectedly skipped work. It rejects missing or
preserved; changing branch protection to require the new aggregate is a separate unknown tiers and requires every canonical scope to be explicitly `true` or
repository-settings operation. Newer commits cancel superseded CI for the same `false`; incomplete classification cannot silently omit verification lanes.
PR or main branch. Release publication has its own concurrency group and retains Branch protection
its package verification and public-registry canary. Licensed/self-hosted should require this aggregate, `Static checks`, `Portable tests`, and
`Conventional PR title`. Title validation runs separately on PR opening,
reopening, commits, and edits; correcting a title refreshes only this lightweight
check. It reads title text through an environment variable, executes no PR code,
and has no token permissions. Its separate concurrency group cannot cancel
test runs. Fast PR runs cancel superseded feedback for the same PR. Main push
runs keep one active run and the latest pending run for that branch.
Scheduled/manual qualification has a separate concurrency group: active work
finishes and only the latest pending run is retained. Release qualification uses
its exact source SHA and cannot be cancelled by newer main pushes. Provider and
website lanes inherit their caller's scheduling; reusable
workflows have no independent cancellation group. Direct manual lane dispatches
run independently on disposable hosted runners. Publication
requires that qualification to succeed and consumes its exact verified tarball,
checking source SHA, workflow run, run attempt, package/Node version and SHA-256
before npm publication. Earlier attempts from the same workflow run are accepted
when rerunning failed publisher jobs; future attempts and other runs are rejected.
The public-registry canary still verifies the published package. Licensed/self-hosted
provider workflows and the Android smoke test retain their explicit triggers. provider workflows and the Android smoke test retain their explicit triggers.
CI exercises the pinned Node.js runtime on native hosted runners: CI exercises the pinned Node.js runtime on native hosted runners:
| Host | Runner | Baseline checks | | Host | Runner | Baseline checks |
| --------------------- | ------------------ | ---------------------------------------------------------------------------------------- | | --------------------- | ------------------ | ------------------------------------------------------------------------------------------- |
| Linux x64 | `ubuntu-latest` | Installed package and real Node Inspector CLI/MCP | | Linux x64 | `ubuntu-latest` | Installed package and real Node Inspector CLI/MCP |
| Linux arm64 (aarch64) | `ubuntu-24.04-arm` | Installed package and real Node Inspector CLI/MCP | | Linux arm64 (aarch64) | `ubuntu-24.04-arm` | Installed package and real Node Inspector CLI/MCP |
| macOS 15 arm64 | `macos-15` | Installed package and real Node Inspector CLI/MCP | | macOS 15 arm64 | `macos-15` | Installed package and real Node Inspector CLI/MCP |
| macOS 15 x64 | `macos-15-intel` | Installed package and real Node Inspector CLI/MCP (main, dispatch and `ci:full` only) | | macOS 15 x64 | `macos-15-intel` | Installed package and real Node Inspector CLI/MCP (hourly, dispatch, release and `ci:full`) |
| Windows x64 | `windows-latest` | Curated capabilities, native controls, installed package and real Node Inspector CLI/MCP | | Windows x64 | `windows-latest` | Curated capabilities, native controls, installed package and real Node Inspector CLI/MCP |
macOS x64 is the slowest package host. Pull requests without the `ci:full` label macOS x64 is the slowest package host. Ordinary PRs and main pushes omit it,
omit it, so darwin x64 installed-package and Inspector regressions surface on so darwin x64 installed-package and Inspector regressions surface in full
the main-push full baseline rather than on the PR. qualification. Schedule admission can be delayed by GitHub; the hourly trigger
does not guarantee an hourly completed result. Release publication waits for
its own complete result at the exact released revision.
Package lanes assert the actual Node platform/architecture before verification Package lanes assert the actual Node platform/architecture before verification
and record those values with the Node version. The package matrix runs up to and record those values with the Node version. The package matrix runs up to
@@ -803,17 +963,34 @@ The Linux source-test shards download a shared runtime and generated test
metadata snapshot produced by the build job with `npm run test:prepare`. The metadata snapshot produced by the build job with `npm run test:prepare`. The
snapshot includes `dist/`, generated skills, the MCP tool catalog, product snapshot includes `dist/`, generated skills, the MCP tool catalog, product
catalog, and managed-conformance metadata. Each shard still installs the locked catalog, and managed-conformance metadata. Each shard still installs the locked
dependencies and runs its assigned coverage-enabled tests; it no longer dependencies and runs its assigned tests; full qualification enables coverage. It no longer
regenerates the same metadata independently. regenerates the same metadata independently.
The build job also retrieves the merged timing report from the last successful The build job also finds an available `test-timings` artifact from a successful
default-branch push and includes that same report in each shard's snapshot. trusted default-branch scheduled or manual qualification and includes that same
report in each shard's snapshot. The bounded lookup verifies the artifact's
producer; newer schedules that reuse qualification without producing timings do
not hide the last full run's artifact.
CI assigns discovered files by historical duration, with a deterministic CI assigns discovered files by historical duration, with a deterministic
fallback weight for new files, rather than dividing only by file count. Every fallback weight for new files, rather than dividing only by file count. Every
discovered file still belongs to exactly one shard; worker limits, project discovered file still belongs to exactly one shard; worker limits and project
ordering, coverage instrumentation, and aggregate thresholds remain unchanged. ordering remain unchanged. The merge job rejects successful reports with missing,
unexpected, or duplicate portable files. Coverage instrumentation and aggregate
thresholds remain enforced in full qualification.
Missing or unusable history falls back to Vitest's default assignment. Only Missing or unusable history falls back to Vitest's default assignment. Only
timing data is reused; persistent compile caches remain disabled. timing data is reused; live provider results are never cached. Turbo outputs are
restored by task hash, with trusted default-branch runs as cache writers and PRs
as restore-only consumers. npm caches hold downloads, not `node_modules`.
The eight automatic browser, binary-layout, EVM, JavaScript-recovery,
recorded-crash, and web provider lanes download `rea-build-cache` from their own
run after the build and static jobs succeed. Their existing build commands still
validate task hashes and rebuild mismatched inputs; provider observations always
run. Provider-workflow-only edits also admit the build without enabling source
test shards. Direct provider dispatches retain the cross-run restore-only cache.
This removes repeated compilation on fresh source revisions, but adds the build
as a prerequisite: a delayed build can delay provider admission. Compare queue
and execution times before claiming an end-to-end speedup.
These native baseline checks complement the Linux source-test shards and the These native baseline checks complement the Linux source-test shards and the
separate Apple-artifact and real-provider lanes. Actual Hopper, Ghidra, IDA, separate Apple-artifact and real-provider lanes. Actual Hopper, Ghidra, IDA,
+6 -2
View File
@@ -153,8 +153,12 @@ the private runtime, isolating Ghidra preferences, logs, and caches.
Process creation assigns a suspended child atomically to an owned Job Object. Process creation assigns a suspended child atomically to an owned Job Object.
Membership and kill-on-owner-close policy are verified before resume. Membership and kill-on-owner-close policy are verified before resume.
Breakaway is disabled. Only the child's default file owner is normalized; Breakaway is disabled. Only the child's default file owner is normalized;
the caller token stays unchanged. Caller CPU affinity and priority are the caller token stays unchanged. Caller priority is preserved, and caller CPU
preserved. Close, cancellation, timeout, startup failure, and provider failure affinity whenever the caller and the child share a processor group. A child that
Windows places in another group keeps that group's default when the caller asked
for no bound of its own; a caller pinned to fewer processors than its group
holds is refused there rather than widened.
Close, cancellation, timeout, startup failure, and provider failure
terminate the job and verify all members have settled before runtime cleanup. terminate the job and verify all members have settled before runtime cleanup.
Cleanup removes admitted objects by handle and unlinks reparse entries without Cleanup removes admitted objects by handle and unlinks reparse entries without
following their targets. Abrupt owner death kills the job but can leave following their targets. Abrupt owner death kills the job but can leave
+16 -4
View File
@@ -69,10 +69,22 @@ without this lane reports Windows native controls as unavailable.
limits only for its JavaScript helpers when running on Bun, which ignores limits only for its JavaScript helpers when running on Bun, which ignores
their V8 heap flags; Node.js launches are unchanged. their V8 heap flags; Node.js launches are unchanged.
- Only the suspended child's default file owner is normalized. Caller token - Only the suspended child's default file owner is normalized. Caller token
privileges and ownership remain unchanged. Caller CPU affinity and process privileges and ownership remain unchanged. Caller process priority is
priority are preserved in the child. Environment names are sorted and preserved in the child, and caller CPU affinity whenever both occupy the same
deduplicated using Windows ordinal comparison, preserving their native Unicode processor group. Windows chooses a new process's group itself and rejects a
semantics and caller-selected values. mask belonging to another group, so a child placed elsewhere keeps that
group's default when the caller asked for no bound of its own. A caller
deliberately pinned to fewer processors than its group holds is a constraint
such a child cannot express, and is refused rather than widened to that
group's default. A mask covering its group's whole active set does not
distinguish a caller Windows placed in that group from one that selected the
group deliberately, so such a child can still be placed elsewhere: its
processor count stays bounded, its group placement is not preserved, and
neither that case nor the refusal above has a verification lane, because both
need a host whose processors span more than one group.
Environment names are sorted and deduplicated using Windows
ordinal comparison, preserving their native Unicode semantics and
caller-selected values.
- Cleanup walks locked directory objects, removes files by handle, and unlinks - Cleanup walks locked directory objects, removes files by handle, and unlinks
reparse entries without traversing their targets. Failed removal retains the reparse entries without traversing their targets. Failed removal retains the
root and ancestor leases for retry; a partially removed runtime accepts only root and ancestor leases for retry; a partially removed runtime accepts only
+29 -1
View File
@@ -68,6 +68,18 @@ static ULONGLONG jobMemoryLimit(napi_env env, napi_value value) {
return limit; return limit;
} }
// An affinity mask is meaningful only inside one processor group, so only a
// process occupying exactly one group has a group a child can inherit. A
// process whose threads span groups, or whose membership cannot be read, has no
// single group.
static bool singleProcessorGroup(HANDLE process, USHORT& group) {
USHORT count = 1;
std::array<USHORT, 1> groups{};
if (!GetProcessGroupAffinity(process, &count, groups.data()) || count != 1) return false;
group = groups[0];
return true;
}
static void pipe(Handle& read, Handle& write, bool parentReads = true) { static void pipe(Handle& read, Handle& write, bool parentReads = true) {
SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE}; SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
HANDLE rawRead, rawWrite; HANDLE rawRead, rawWrite;
@@ -168,11 +180,27 @@ std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::ws
TOKEN_OWNER owner{reinterpret_cast<TOKEN_USER*>(tokenUser.data())->User.Sid}; TOKEN_OWNER owner{reinterpret_cast<TOKEN_USER*>(tokenUser.data())->User.Sid};
require(SetTokenInformation(token.get(), TokenOwner, &owner, sizeof(owner)), require(SetTokenInformation(token.get(), TokenOwner, &owner, sizeof(owner)),
"Set suspended process file owner failed", command); "Set suspended process file owner failed", command);
// A mask names processors inside one processor group, and Windows assigns a
// new process to a group of its own choosing, so a mask belonging to another
// group is rejected (ERROR_INVALID_PARAMETER). An unconstrained caller, whose
// mask is its group's whole active set, asks for no bound the child's own
// group cannot give, so such a child keeps that group's default and the spawn
// proceeds. A narrower mask is a deliberate CPU constraint that a child in
// another group cannot express, and silently widening it would hand the
// provider tree the whole group, so that spawn fails naming the constraint.
DWORD_PTR affinity = 0, systemAffinity = 0; DWORD_PTR affinity = 0, systemAffinity = 0;
require(GetProcessAffinityMask(GetCurrentProcess(), &affinity, &systemAffinity), require(GetProcessAffinityMask(GetCurrentProcess(), &affinity, &systemAffinity),
"Read caller process affinity failed", command); "Read caller process affinity failed", command);
if (affinity != 0) USHORT callerGroup = 0, childGroup = 0;
const bool sharedGroup = singleProcessorGroup(GetCurrentProcess(), callerGroup) &&
singleProcessorGroup(result->process.get(), childGroup) &&
callerGroup == childGroup;
if (affinity != 0 && sharedGroup)
require(SetProcessAffinityMask(result->process.get(), affinity), "Preserve caller process affinity failed", command); require(SetProcessAffinityMask(result->process.get(), affinity), "Preserve caller process affinity failed", command);
else
require(affinity == 0 || affinity == systemAffinity,
"Caller CPU affinity cannot bound an owned process in another processor group", command,
ERROR_INVALID_PARAMETER);
const DWORD priority = GetPriorityClass(GetCurrentProcess()); const DWORD priority = GetPriorityClass(GetCurrentProcess());
require(priority != 0 && SetPriorityClass(result->process.get(), priority), require(priority != 0 && SetPriorityClass(result->process.get(), priority),
"Preserve caller process priority failed", command); "Preserve caller process priority failed", command);
+2 -1
View File
@@ -182,6 +182,7 @@
"verify:ghidra:dos": "npm run build:cached && node scripts/verify-real-ghidra-dos.mjs", "verify:ghidra:dos": "npm run build:cached && node scripts/verify-real-ghidra-dos.mjs",
"verify:ghidra:com": "npm run build:cached && node scripts/verify-real-ghidra-com.mjs", "verify:ghidra:com": "npm run build:cached && node scripts/verify-real-ghidra-com.mjs",
"verify:ghidra:raw": "npm run build:cached && node scripts/verify-real-ghidra-raw.mjs", "verify:ghidra:raw": "npm run build:cached && node scripts/verify-real-ghidra-raw.mjs",
"verify:ghidra:address-context": "npm run build:cached && node scripts/verify-real-ghidra-address-context.mjs",
"verify:ghidra:aarch64-jump-table": "npm run build:cached && node scripts/verify-real-ghidra.mjs --aarch64-jump-table", "verify:ghidra:aarch64-jump-table": "npm run build:cached && node scripts/verify-real-ghidra.mjs --aarch64-jump-table",
"verify:interface-builder": "npm run build:cached && node scripts/verify/apple/interface-builder.mjs", "verify:interface-builder": "npm run build:cached && node scripts/verify/apple/interface-builder.mjs",
"verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format", "verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format",
@@ -207,7 +208,7 @@
"fixtures:harmony": "node scripts/fixtures/harmony/fetch.mjs", "fixtures:harmony": "node scripts/fixtures/harmony/fetch.mjs",
"verify:agent": "npm run build:cached && node scripts/verify-agent-experience.mjs", "verify:agent": "npm run build:cached && node scripts/verify-agent-experience.mjs",
"verify:hopper:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper-linux.mjs", "verify:hopper:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper-linux.mjs",
"verify:setup:linux": "npm run build:cached && node scripts/verify-linux-setup-e2e.mjs", "verify:setup:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-linux-setup-e2e.mjs",
"build:fixtures": "node scripts/build-conformance-fixtures.mjs", "build:fixtures": "node scripts/build-conformance-fixtures.mjs",
"build:ghidra-windows-fixture": "node scripts/create-ghidra-windows-fixture.mjs", "build:ghidra-windows-fixture": "node scripts/create-ghidra-windows-fixture.mjs",
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs", "verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",
+525
View File
@@ -0,0 +1,525 @@
:robot: I have created a release *beep* *boop*
---
## [6.5.0](https://github.com/morluto/rea/compare/rea-agents-6.4.0...rea-agents-6.5.0) (2026-10-13)
###   BREAKING CHANGES
* **runtime:** attached fields in JavaScript runtime, browser, and Electron target discovery and inspection results may be null when discovery does not report attachment state.
* **sourcemaps:** Browser source-map original and mapping rows expose null for unavailable source names instead of inventing an unknown-source string. New index fields are optional when reading older retained evidence.
* **analysis:** get_call_graph normalized_result now contains { graph, limitations }; traversal-depth groups are under graph.
* **mcp:** binary_session no longer includes server_identity.catalog.tools; tool_availability and provider capabilities use grouped facts documented in docs/mcp-contracts.md.
### Features
* add Frida instrumentation support ([#1359](https://github.com/morluto/rea/issues/1359)) ([4a4ae9b](https://github.com/morluto/rea/commit/4a4ae9b2b9712f7ac16f2d23c71b5c29b0c23d2f))
* **analysis:** add opt-in retained inventory and WASM views ([#2122](https://github.com/morluto/rea/issues/2122)) ([58fb372](https://github.com/morluto/rea/commit/58fb372071e56da9c5df0e37593ee3f0da6b64bc)), closes [#2016](https://github.com/morluto/rea/issues/2016)
* **android:** add opt-in package summaries and retained manifest views ([f254f5a](https://github.com/morluto/rea/commit/f254f5ac2b804b353a711264bb178f43fdf07867))
* **browser:** inspect retained aioquic qlog JSON captures ([bb154d1](https://github.com/morluto/rea/commit/bb154d1067b0fc250452b7d468d0b0523031a16f))
* **captures:** discover installed protocol and field definitions ([#2496](https://github.com/morluto/rea/issues/2496)) ([9c34c09](https://github.com/morluto/rea/commit/9c34c091b3b8c47f8bd18d6f0321efde2dedb92a))
* **captures:** inspect recorded packets with bounded TShark delivery ([#2318](https://github.com/morluto/rea/issues/2318)) ([f611d8c](https://github.com/morluto/rea/commit/f611d8ce722cd36345e9ff83b9669ef2e7fe57a9))
* **clients:** register Qoder CLI during setup ([#1572](https://github.com/morluto/rea/issues/1572)) ([1097693](https://github.com/morluto/rea/commit/109769319a4a3a5f85e8a43bb419d04aefff66a4))
* **clients:** register Trae during setup ([#1573](https://github.com/morluto/rea/issues/1573)) ([bfa805d](https://github.com/morluto/rea/commit/bfa805decf83744bd3ecb0d1402667cf3ed5c3b2))
* **clients:** register ZCode during setup ([#1564](https://github.com/morluto/rea/issues/1564)) ([51d6c87](https://github.com/morluto/rea/commit/51d6c87cdbf5f11eaf3cb1862befe704a81827c5))
* **evm:** add retained interface summary delivery ([#2308](https://github.com/morluto/rea/issues/2308)) ([f76163c](https://github.com/morluto/rea/commit/f76163c2467db623f9de0bda757c9d8610ca9565)), closes [#2016](https://github.com/morluto/rea/issues/2016)
* **fiddler:** inspect native SAZ captures ([a2d8b94](https://github.com/morluto/rea/commit/a2d8b94dd50dab8d966239dd63ab84718aa8b4b4))
* **flutter:** identify Dart snapshot builds and inspect AOT payloads by pure APK parsing ([#1582](https://github.com/morluto/rea/issues/1582)) ([e4d3e13](https://github.com/morluto/rea/commit/e4d3e1399d9cd4932653e7015a1bf8589e56029d))
* **ghidra:** admit bounded PSP ELF through installed Allegrex ([#1599](https://github.com/morluto/rea/issues/1599)) ([e4407db](https://github.com/morluto/rea/commit/e4407dbed5dc24851f47532f5e3d75d5627d3e7d))
* **ghidra:** admit relocatable MIPS32r2 ELF objects ([#1950](https://github.com/morluto/rea/issues/1950)) ([290d301](https://github.com/morluto/rea/commit/290d301ec770955f313c2d48678ed0ae3b9f8a73))
* **ghidra:** admit RISC-V ELF analysis through a pinned generic lane ([#2151](https://github.com/morluto/rea/issues/2151)) ([0baad58](https://github.com/morluto/rea/commit/0baad58e606f12a8dc6cef06253f85350378e5b1))
* **ghidra:** admit soft-float MIPS32r2 ELF with integer-register float storage ([#1949](https://github.com/morluto/rea/issues/1949)) ([5e219de](https://github.com/morluto/rea/commit/5e219de44134d34f5a07a9cd1911fb2295bf1d88))
* **ghidra:** admit verified MIPS16 shared ELF images ([e9fe643](https://github.com/morluto/rea/commit/e9fe6437a7da381f0aa5aa77571dcf03317122e7)), closes [#1851](https://github.com/morluto/rea/issues/1851)
* **ghidra:** apply validated analyzer overrides before analysis ([9878bb7](https://github.com/morluto/rea/commit/9878bb7296c13c2b4838be76f6128e5fc31eda3c))
* **go:** inspect embedded binary build metadata ([#1363](https://github.com/morluto/rea/issues/1363)) ([a1b5d51](https://github.com/morluto/rea/commit/a1b5d51a8dfb7fff6272d91270c16b95555c2643))
* **harmony:** project HarmonyOS package inventory evidence ([#1447](https://github.com/morluto/rea/issues/1447)) ([f2150eb](https://github.com/morluto/rea/commit/f2150eb9a7ed76748238009ca6666efa52de5271))
* **ida:** support optional native IDA Free MCP in attached mode ([#1544](https://github.com/morluto/rea/issues/1544)) ([d1aaeee](https://github.com/morluto/rea/commit/d1aaeee400e0d9911d5c53339ca3ac42dccbbba3))
* **javascript:** analyze selected application sources ([85c1d25](https://github.com/morluto/rea/commit/85c1d2587a7d42c6e2108405c802fe7f7b046c15)), closes [#1848](https://github.com/morluto/rea/issues/1848)
* **javascript:** recover code-split bundles from multiple inputs ([#1686](https://github.com/morluto/rea/issues/1686)) ([12c4160](https://github.com/morluto/rea/commit/12c416005f9d2abf438f4df4447972424d781b65))
* **javascript:** resolve exact package import aliases ([#1862](https://github.com/morluto/rea/issues/1862)) ([d28b41e](https://github.com/morluto/rea/commit/d28b41ea4e93bb4c4d360a3bf2291ca83bb2b378))
* **managed:** add retained metadata overview and pages ([#2413](https://github.com/morluto/rea/issues/2413)) ([82cfcee](https://github.com/morluto/rea/commit/82cfcee19f18b0eeae9a330ba04e0411cdf85225))
* **managed:** select declaring types in inspect_managed_members ([a149d18](https://github.com/morluto/rea/commit/a149d183efd08b0ad7c2a35c99f2394d905621d5)), closes [#1878](https://github.com/morluto/rea/issues/1878)
* **mcp:** add opt-in paging for retained unknown heads ([9a18251](https://github.com/morluto/rea/commit/9a18251426f891e6a24856b715ef8002ab8b9610))
* **mcp:** derive an API surface view from retained web network captures ([#1891](https://github.com/morluto/rea/issues/1891)) ([880a603](https://github.com/morluto/rea/commit/880a603fe89db2132fd05a35b33a8c459673beda))
* **native:** admit raw binaries with caller-supplied architecture and entry ([#1868](https://github.com/morluto/rea/issues/1868)) ([7c207c7](https://github.com/morluto/rea/commit/7c207c79512f6baa70f4ed9d7d5334ab2d62f3ac))
* **native:** expose typed incoming references at exact addresses ([#1715](https://github.com/morluto/rea/issues/1715)) ([cdd9fe7](https://github.com/morluto/rea/commit/cdd9fe716774c9e5abd121b70e9fd5e1eb91d2b8))
* **pcap:** add bounded capture summaries through CLI and MCP ([9581e06](https://github.com/morluto/rea/commit/9581e067ad306aab842024e47f2dd18ac8056859))
* **server:** frame analysis-target content as untrusted data ([#1908](https://github.com/morluto/rea/issues/1908)) ([85e1242](https://github.com/morluto/rea/commit/85e124253ac93dd443be6bbcee0939d525b90d4e))
* **setup:** register DeepSeek Harness through setup, doctor and uninstall ([#1929](https://github.com/morluto/rea/issues/1929)) ([d9d2a17](https://github.com/morluto/rea/commit/d9d2a173819ce8e2e7f09eb584ec6ff7b4ee3480))
* **signals:** decode recordings with bounded sigrok output ([#2339](https://github.com/morluto/rea/issues/2339)) ([91b8f58](https://github.com/morluto/rea/commit/91b8f587cd495c509946e69076525eb19dc1ca65))
* **signals:** discover installed decoders and option help ([#2487](https://github.com/morluto/rea/issues/2487)) ([4276aaf](https://github.com/morluto/rea/commit/4276aaf18bb607cdd0182280f76cf50fcd110c3c))
* **sqlite:** inspect offline snapshots with selective evidence ([#2271](https://github.com/morluto/rea/issues/2271)) ([aeee507](https://github.com/morluto/rea/commit/aeee5074d124414239b6dd440dc79c26bcbd58a4))
* **target:** explain static WASM inspection after native rejection ([1f17ac8](https://github.com/morluto/rea/commit/1f17ac82282dfda1ae2dc87d99e66a8a039c0259)), closes [#1852](https://github.com/morluto/rea/issues/1852)
* **update:** update Bun global installations ([#1735](https://github.com/morluto/rea/issues/1735)) ([e4027e4](https://github.com/morluto/rea/commit/e4027e4afccc6316cd9cfc27c7b0427e66ceb91f))
* **wasm:** add opt-in first-call summary delivery ([#2193](https://github.com/morluto/rea/issues/2193)) ([0b71453](https://github.com/morluto/rea/commit/0b7145361ec02d27260eb1f5bc0be955c9374a96))
* **wasm:** inspect local artifacts with WABT ([#1533](https://github.com/morluto/rea/issues/1533)) ([ffaf2c1](https://github.com/morluto/rea/commit/ffaf2c12f1793c7c451e66dc09c3074544a4c086))
* **website:** add a persistent light and dark theme switch ([4773c15](https://github.com/morluto/rea/commit/4773c152bc5f299fa682575bb8d1efea7587c7bc))
* **website:** add multilingual editions and translation workflow ([a2d3338](https://github.com/morluto/rea/commit/a2d3338e90a3a11385e2f63bedaa9750612f8719))
* **website:** follow the system dark mode and fix small-screen text ([#1894](https://github.com/morluto/rea/issues/1894)) ([12ee785](https://github.com/morluto/rea/commit/12ee785e5e61132898adecf9424ce6706a2e754a))
### Bug Fixes
* **adb:** admit canonical device states and preserve malformed output ([e1cd292](https://github.com/morluto/rea/commit/e1cd292aa6e8f5f13441d408c66126782c8dbb0d))
* **adb:** count requested permissions that the device marks restricted ([#2359](https://github.com/morluto/rea/issues/2359)) ([15463bd](https://github.com/morluto/rea/commit/15463bdbe605b9dd11da5dbba031f99cd1ec9190))
* **adb:** count UTF-8 bytes in logcat total_bytes ([#2212](https://github.com/morluto/rea/issues/2212)) ([ec2d251](https://github.com/morluto/rea/commit/ec2d251c0ad8382e0665546be5ec8f5f1c175962))
* **adb:** evaluate the full activity start report ([c89972c](https://github.com/morluto/rea/commit/c89972c8777d0114566fe76a060849a18bdd2659)), closes [#2225](https://github.com/morluto/rea/issues/2225)
* **adb:** preserve device paths and prevent destination collisions ([55b6148](https://github.com/morluto/rea/commit/55b61485e9f7568b69597eef8696ff6abfe53cfd))
* **adb:** preserve directory identities and share byte capture ([3bdc873](https://github.com/morluto/rea/commit/3bdc8730bd98e073522bcb8ba86527be402c0cbe))
* **adb:** preserve existing bugreport archives ([#2221](https://github.com/morluto/rea/issues/2221)) ([d80b55e](https://github.com/morluto/rea/commit/d80b55e11bc47c6f999dac7dcb8382219d65739a))
* **adb:** reject unsafe host file names when pulling one device file ([#1962](https://github.com/morluto/rea/issues/1962)) ([1d4c4eb](https://github.com/morluto/rea/commit/1d4c4eb0308b4cb5761a91533edeb33fcfb442f2))
* **adb:** share actionable provider configuration refusals ([c42232f](https://github.com/morluto/rea/commit/c42232f566d55fe7191763649755148c768eef9f))
* **adb:** share host configuration refusals with apktool ([#1954](https://github.com/morluto/rea/issues/1954)) ([39418ea](https://github.com/morluto/rea/commit/39418ea173763ca55d4f116992ee903bcaeb1898))
* align MCP archive contracts and documentation ([#2478](https://github.com/morluto/rea/issues/2478)) ([a86deec](https://github.com/morluto/rea/commit/a86deecac5fa8434a4f50482f86b0c6ab486a936)), closes [#2454](https://github.com/morluto/rea/issues/2454)
* **analysis:** expose provider image base ([#2083](https://github.com/morluto/rea/issues/2083)) ([81cca2e](https://github.com/morluto/rea/commit/81cca2e0e3ebd98c21f41d7ad63d6a00e4cbc3f9))
* **analysis:** preserve producer identity and partial evidence ([6fb3a34](https://github.com/morluto/rea/commit/6fb3a345fe952b8e8f59221aec993acc37ccbf31))
* **analysis:** preserve relationship coverage across evidence and replay ([9c85114](https://github.com/morluto/rea/commit/9c8511409c0706f9979c2b68dd56aaaf9f21e191))
* **analysis:** scope call-graph caveats to their observed relationships ([5dfb9d0](https://github.com/morluto/rea/commit/5dfb9d050c2cc44a61b183dae765bef4d22a53b7))
* **android:** bound inline search Evidence with retained inventory provenance ([#1890](https://github.com/morluto/rea/issues/1890)) ([7a0140b](https://github.com/morluto/rea/commit/7a0140b4aa43276368058ab98826b77e040ea14f))
* **android:** preserve decoded values and duplicate class inventories ([0677468](https://github.com/morluto/rea/commit/0677468877465c8dfbe25c72f142a7b5bc8a3212)), closes [#1978](https://github.com/morluto/rea/issues/1978)
* **android:** preserve executable static initializer evidence ([#1876](https://github.com/morluto/rea/issues/1876)) ([efbc25f](https://github.com/morluto/rea/commit/efbc25fbea1b6ade2a284a4916644c791f83bf49)), closes [#1846](https://github.com/morluto/rea/issues/1846)
* **android:** reject malformed UTF-8 at the JADX protocol boundary ([fe8d0bd](https://github.com/morluto/rea/commit/fe8d0bdd36fe12bc0836468d1a702a34fbdc219b))
* **apktool:** accept --no-strings CLI option ([#2707](https://github.com/morluto/rea/issues/2707)) ([11f8b76](https://github.com/morluto/rea/commit/11f8b7625788d747eaadd59af6efdf88587ed058))
* **apktool:** name host requirements in configuration refusals ([#1946](https://github.com/morluto/rea/issues/1946)) ([7cdb798](https://github.com/morluto/rea/commit/7cdb7981c3612bc42bd0153fddd9c33b639918ed))
* **apktool:** run Windows batch launchers through cmd.exe ([#1777](https://github.com/morluto/rea/issues/1777)) ([ae95e68](https://github.com/morluto/rea/commit/ae95e68ba5fa95cf2ec0453b50ffd217cabf5359)), closes [#1774](https://github.com/morluto/rea/issues/1774)
* **apple:** decode UIKit keyed-archive outlet connections ([8d4d631](https://github.com/morluto/rea/commit/8d4d631606c701aac150cf009d3b80ae4a7ae7d5))
* **apple:** derive dyld environment findings from each traced slice ([#2733](https://github.com/morluto/rea/issues/2733)) ([52d4ce6](https://github.com/morluto/rea/commit/52d4ce6ced1c3b6ae7570a5df7f4499cd65a1182)), closes [#2661](https://github.com/morluto/rea/issues/2661)
* **apple:** join recognized keys from NIBArchive field maps ([#2248](https://github.com/morluto/rea/issues/2248)) ([fd985db](https://github.com/morluto/rea/commit/fd985dbc5b9435918732c0cc1825060af2a3a47e))
* **apple:** limit already-loaded reuse to rpath dependencies ([#2292](https://github.com/morluto/rea/issues/2292)) ([60f771a](https://github.com/morluto/rea/commit/60f771a11e9889ce69cce6ad631b6b186ac742dd))
* **apple:** preflight XML plist nesting before keyed archive decoding ([#2161](https://github.com/morluto/rea/issues/2161)) ([7711b00](https://github.com/morluto/rea/commit/7711b0000592aa80c7b0412004150313f78feabc))
* **apple:** preserve archive signatures and metadata byte identity ([dc038d1](https://github.com/morluto/rea/commit/dc038d16bba9046afd28970de9c47f6259a43524))
* **apple:** preserve ARM64E X1 architecture identity ([f598bab](https://github.com/morluto/rea/commit/f598bab488e38c378d69346fa3c106f9e18529ed))
* **apple:** preserve slice identity and macOS boundary diagnostics ([#1772](https://github.com/morluto/rea/issues/1772)) ([5c29565](https://github.com/morluto/rea/commit/5c2956587a1b0d431f72e10df07a1a6363fa066c))
* **apple:** validate build-version tool records within cmdsize ([#1784](https://github.com/morluto/rea/issues/1784)) ([817aef4](https://github.com/morluto/rea/commit/817aef406861c2ce3f87ad3c912ea6c235bcdf12))
* **artifacts:** authenticate file subjects across provider and view boundaries ([3c57e1a](https://github.com/morluto/rea/commit/3c57e1a8b21b379fca55032d177078e1d8426e64))
* **artifacts:** bind directory reads to captured file state ([918713a](https://github.com/morluto/rea/commit/918713af376e307ecf24d75e6a0dd9d1b544545b))
* **artifacts:** bound archive decompression by source size ([#2144](https://github.com/morluto/rea/issues/2144)) ([c59fbf5](https://github.com/morluto/rea/commit/c59fbf5e9c3b856d042f02cb18ad4de077eaeab5))
* **artifacts:** close mounted directory resources before DMG detach ([2260887](https://github.com/morluto/rea/commit/2260887f2092be6a3d9c79509e4ae4a3e52fce7f))
* **artifacts:** compare leaf inventories using explicit coverage ([ed485a3](https://github.com/morluto/rea/commit/ed485a3dc8cc3a4baf8cabe01acb7aa0490b73b3))
* **artifacts:** compare package signatures without ASCII folding ([#2069](https://github.com/morluto/rea/issues/2069)) ([75a4a14](https://github.com/morluto/rea/commit/75a4a14fa05d352762ffaefd3cd2e16515842b03))
* **artifacts:** count a changed partial-inventory root once ([#1854](https://github.com/morluto/rea/issues/1854)) ([ee5275c](https://github.com/morluto/rea/commit/ee5275cd9fae8db64f945a0c8b59e1c19ac402c3)), closes [#1844](https://github.com/morluto/rea/issues/1844)
* **artifacts:** distinguish Java classes from universal Mach-O ([921bb95](https://github.com/morluto/rea/commit/921bb9575505065f6cfd57b14d97d80d0f238546)), closes [#2317](https://github.com/morluto/rea/issues/2317)
* **artifacts:** honor cancellation at the output publication boundary ([e7882b3](https://github.com/morluto/rea/commit/e7882b34f4ac4a26f903c5f49264acc2c19bb562))
* **artifacts:** mark Windows host executables by extension ([#1823](https://github.com/morluto/rea/issues/1823)) ([91f843b](https://github.com/morluto/rea/commit/91f843bf38b0e4ec8576f41f3140488b4ee8ff32)), closes [#1819](https://github.com/morluto/rea/issues/1819)
* **artifacts:** mark Windows launchable files as host executables ([#1824](https://github.com/morluto/rea/issues/1824)) ([e8491e0](https://github.com/morluto/rea/commit/e8491e023a255f790ae9850225084d7495e9ade0)), closes [#1819](https://github.com/morluto/rea/issues/1819)
* **artifacts:** preserve decode coverage and cleanup ownership ([a73a55f](https://github.com/morluto/rea/commit/a73a55f53cc2b2e5b7618effe6ecb5cee6e0cee2))
* **artifacts:** preserve emitted ZIP entry identity ([e36c82b](https://github.com/morluto/rea/commit/e36c82becdd613787bcc59903f8b5fe14eb2f105))
* **artifacts:** preserve inventory uncertainty in application projections ([d8ceb3f](https://github.com/morluto/rea/commit/d8ceb3f276a65c181b146f68c0729ff4b6f9fa61))
* **artifacts:** preserve NIB field and class identities ([#2106](https://github.com/morluto/rea/issues/2106)) ([f8e9579](https://github.com/morluto/rea/commit/f8e95799f941e9e55e0d47841896b401bab67d29))
* **artifacts:** preserve physical ZIP member identities ([#1865](https://github.com/morluto/rea/issues/1865)) ([bcd350c](https://github.com/morluto/rea/commit/bcd350c365030a5d210208c0a8dc238302c43303)), closes [#1860](https://github.com/morluto/rea/issues/1860)
* **artifacts:** preserve ZIP identities and partial managed metadata ([74d7da6](https://github.com/morluto/rea/commit/74d7da6ced0f869936ee0316b00616931b088066))
* **artifacts:** read ASCII control bytes in legacy ZIP names as ASCII ([#2365](https://github.com/morluto/rea/issues/2365)) ([9928f35](https://github.com/morluto/rea/commit/9928f357fb9c420e6384e155d74586dfe37d4533))
* **artifacts:** read every ZIP package family extraction is asked to open ([#2493](https://github.com/morluto/rea/issues/2493)) ([7017c4d](https://github.com/morluto/rea/commit/7017c4d68cab49aed998283e09069d9d791e1b0b))
* **artifacts:** reject malformed serialized keys and integer values ([9435c7a](https://github.com/morluto/rea/commit/9435c7aae279ba184937c0dfeb713fd574ba3f29))
* **artifacts:** retain ASAR resources through cleanup failures ([eb97da3](https://github.com/morluto/rea/commit/eb97da33881e3d1472d97a7d788ee35944fb6b07))
* **artifacts:** retain read cleanup ownership through CLI and MCP shutdown ([b955b82](https://github.com/morluto/rea/commit/b955b825fc002a4bb408f2910b78768c566c2cd1))
* **artifacts:** retry transient DMG verification refusals ([bb54082](https://github.com/morluto/rea/commit/bb540826d65316c164d2de8ba4ef10f81a8ad073))
* **browser:** bound CDP discovery response size and lifetime ([951b095](https://github.com/morluto/rea/commit/951b095e11adf9faa9c4a27e212260060aa6b095))
* **browser:** compare only observed HTTP response facts ([486b567](https://github.com/morluto/rea/commit/486b56724c4eaba50719177bcb9eae84d6907297))
* **browser:** compare PNG IHDR bytes without ASCII folding ([#2099](https://github.com/morluto/rea/issues/2099)) ([fdd8f82](https://github.com/morluto/rea/commit/fdd8f826023c568937d237c2f97d855de283a9b8))
* **browser:** exclude sensitive binary lengths from historical captures ([#2477](https://github.com/morluto/rea/issues/2477)) ([68ecf3d](https://github.com/morluto/rea/commit/68ecf3df8c666fdbe1ced8089c64340af614d1c3))
* **browser:** isolate script URLs with empty query or fragment ([#2074](https://github.com/morluto/rea/issues/2074)) ([9b780c4](https://github.com/morluto/rea/commit/9b780c47bca039939dd5f73b78541f3ea566a7dd))
* **browser:** keep elapsed capture timing independent of wall-clock changes ([6bd2b28](https://github.com/morluto/rea/commit/6bd2b28161411f70ed5dc72e42c2736a1aab758b))
* **browser:** preserve attached service-worker policies and ownership ([221c856](https://github.com/morluto/rea/commit/221c85648108e9846d9920b0964dafa7f108d581))
* **browser:** preserve body API provenance through script export ([966f7cc](https://github.com/morluto/rea/commit/966f7cc84b4a8bb0043641cb64d7a7aaf8715e05))
* **browser:** preserve uncertainty in malformed network observations ([a8d4984](https://github.com/morluto/rea/commit/a8d4984404b611e60b88bc42289c0ba07eee4578))
* **browser:** redact encoded declared secrets consistently ([f09bb8a](https://github.com/morluto/rea/commit/f09bb8a99773f79c7306b8799c86f856fe2c0d4b))
* **browser:** report mismatched event family selection as unknown in capture comparison ([#2239](https://github.com/morluto/rea/issues/2239)) ([0043d9b](https://github.com/morluto/rea/commit/0043d9b134c0a353e8bf85c0bde4492275f1f720))
* **browser:** retain effective source-map response urls ([062723e](https://github.com/morluto/rea/commit/062723e44e937c1e1df865e9ae16184b41e70ae8))
* **browser:** retain module resolver cleanup ownership ([a82e37c](https://github.com/morluto/rea/commit/a82e37c8065cc200a0ca3e14e7c4f85e4e468580))
* **build:** preserve LF text checkouts on Windows ([#1807](https://github.com/morluto/rea/issues/1807)) ([031d778](https://github.com/morluto/rea/commit/031d7781f0f6c99e5f14e67f56cb89a88d6af3eb))
* **build:** preserve resource controls in strict Turbo tasks ([#1809](https://github.com/morluto/rea/issues/1809)) ([2e22330](https://github.com/morluto/rea/commit/2e223300375101558f90b6e78ef9612c55626673))
* **build:** resolve rolldown from the npm registry in package-lock.json ([#1986](https://github.com/morluto/rea/issues/1986)) ([f889a69](https://github.com/morluto/rea/commit/f889a69fb9efe42dd0120c96ff44b4298c1c7c6c))
* **bun:** report the real runtime and enforce worker memory budgets on Windows ([#1730](https://github.com/morluto/rea/issues/1730)) ([66c2157](https://github.com/morluto/rea/commit/66c21572defc97fe277cad49d3173bd4073d1f58))
* **call-path:** preserve relationship coverage from analysis ([#2001](https://github.com/morluto/rea/issues/2001)) ([ed6af7e](https://github.com/morluto/rea/commit/ed6af7eb8cf052c52fec8bfdc5d509377cb30a1a))
* **captures:** name the violated HAR schema constraint ([#2708](https://github.com/morluto/rea/issues/2708)) ([b69d20a](https://github.com/morluto/rea/commit/b69d20a300dba57783a810146f25dacb69f9b8a4))
* **checks:** order artifact-backed lint after compilation ([0d5033a](https://github.com/morluto/rea/commit/0d5033ad12e12abe78fc3c0ba81622b410d22aa1))
* **ci:** align FAT fixtures and consolidate duplicated workflow helpers ([a0317cf](https://github.com/morluto/rea/commit/a0317cf732b3addefad5ac3b234db1e044e3351d))
* **ci:** allow cold Chrome startup in runtime fixtures ([f941968](https://github.com/morluto/rea/commit/f941968eb1860dd41efc2a5201b35cecca42301d))
* **ci:** ignore generated website translation staging ([#2511](https://github.com/morluto/rea/issues/2511)) ([1883149](https://github.com/morluto/rea/commit/18831491cfb5cb98abbffce0cb04dae12a90fe28)), closes [#2508](https://github.com/morluto/rea/issues/2508)
* **ci:** keep cancellation ownership in the calling workflow ([db3c9f0](https://github.com/morluto/rea/commit/db3c9f084bacad34f620ba678924564e20f441f9))
* **ci:** reject incomplete verification plans ([bd2c202](https://github.com/morluto/rea/commit/bd2c202dd930548194cdbc55c27a797f22279db9))
* **ci:** stop aggregate jobs when verification is cancelled ([2e45b4d](https://github.com/morluto/rea/commit/2e45b4d7a5b3228fe8b283def6f047797561bee1))
* **cli:** await browser scenario cleanup on interrupts ([aaeadf2](https://github.com/morluto/rea/commit/aaeadf2d4c4e3f0d0581af2dca33c5bfdc2bcb0e))
* **cli:** normalize empty Windows profile before imports ([#2482](https://github.com/morluto/rea/issues/2482)) ([fa4b828](https://github.com/morluto/rea/commit/fa4b8288655188426222773ead2ccbdaf76334fd))
* **cli:** preserve failure semantics across full-output formats ([570c40c](https://github.com/morluto/rea/commit/570c40cde55d31e455ef9bc484b7b908658ded58))
* **cli:** report an invalid REA_LOG_LEVEL as configuration ([#2140](https://github.com/morluto/rea/issues/2140)) ([3159732](https://github.com/morluto/rea/commit/315973259732544a597b8b17d25c69735910e72e)), closes [#2135](https://github.com/morluto/rea/issues/2135)
* **cli:** retain browser and Inspector input correction details ([#2230](https://github.com/morluto/rea/issues/2230)) ([916ac97](https://github.com/morluto/rea/commit/916ac97bf9f7b976982e438ceb856a9692576cb1))
* **cli:** retain correction details across adjacent browser commands ([#2294](https://github.com/morluto/rea/issues/2294)) ([bb26622](https://github.com/morluto/rea/commit/bb266226707135ad9f181e6c3afd6af0ef62fb22))
* **cli:** retry retained browser scenario cleanup ([4daea94](https://github.com/morluto/rea/commit/4daea941bfeeb426886f2d048aed7f3cb2dbf97b))
* **cli:** reuse firmware and web module input constraints ([48c352b](https://github.com/morluto/rea/commit/48c352b6bfa34c820a530272cd36600158c37b1e))
* **config:** reject NUL bytes in Hopper loader arguments ([#2474](https://github.com/morluto/rea/issues/2474)) ([a07ceae](https://github.com/morluto/rea/commit/a07ceaec36346a7c73e66efe35c8af4654029c84))
* **contracts:** mark foreign execution and device mutation destructive ([#1904](https://github.com/morluto/rea/issues/1904)) ([0b7fc40](https://github.com/morluto/rea/commit/0b7fc40eeef03b06de08d886572c2862331d8947))
* **contracts:** reject OS-invalid local paths in binary and artifact tools ([#1925](https://github.com/morluto/rea/issues/1925)) ([976c2c0](https://github.com/morluto/rea/commit/976c2c083eee1fdd9e89a1a720f768a6ee6df277))
* **dev:** preserve command arguments in exclusive runners ([fd95880](https://github.com/morluto/rea/commit/fd9588089d089e7cb02d12178e34407d6f889f22)), closes [#2319](https://github.com/morluto/rea/issues/2319)
* **docs:** synchronize disclaimer links across translations ([d864ad5](https://github.com/morluto/rea/commit/d864ad54717d92fe4b512ce1170717d9904c1f00))
* **docs:** validate translated README website destinations ([c9136ee](https://github.com/morluto/rea/commit/c9136eef716aa8d32e0301189fbf5a511ff62d5d))
* **domain:** preserve derived byte counts and uncertain identity ([7a2cd1e](https://github.com/morluto/rea/commit/7a2cd1ea0a63e8c44166860b7ee9c7a1aa43492b))
* **domain:** preserve retryable capability failures through redaction ([#2494](https://github.com/morluto/rea/issues/2494)) ([d73ed6f](https://github.com/morluto/rea/commit/d73ed6fce24f6d1372c80319cd41d29d66666159))
* **electron:** attribute unresolved paths to selected inputs ([b7169c8](https://github.com/morluto/rea/commit/b7169c8b747cbc032913b31f008a3b81c77b6219)), closes [#2272](https://github.com/morluto/rea/issues/2272)
* **elf:** centralize fixed header validation across consumers ([34a4bb6](https://github.com/morluto/rea/commit/34a4bb6508006fa113463ef20ed799a2994e9ee8))
* **errors:** stop recommending retries for non-retryable failures ([#1869](https://github.com/morluto/rea/issues/1869)) ([fb94d9d](https://github.com/morluto/rea/commit/fb94d9dde0ccda83ae520ac6fb22b1d283562cf3))
* **evaluation:** authenticate delivered Evidence citations ([844fa52](https://github.com/morluto/rea/commit/844fa52772c1533f7358a71a764da780503775a3))
* **evaluation:** share completed transcript facts across graders ([1447fd2](https://github.com/morluto/rea/commit/1447fd2f990c5d9820337dfd4b8a170f8cad17cd))
* **evidence:** admit bundle imports across session transitions ([#2303](https://github.com/morluto/rea/issues/2303)) ([1230381](https://github.com/morluto/rea/commit/12303812a8d70f198c99c7aeb0df3aaa8166543c))
* **evidence:** authenticate identities and retain extraction provenance ([6bae774](https://github.com/morluto/rea/commit/6bae774328724403255590aa2ddad4d7a2aa727f))
* **evidence:** enforce managed identity and cancel bundle reads ([3bcc68c](https://github.com/morluto/rea/commit/3bcc68c037e9415c78093cb286069c1fcca6a2f8))
* **evidence:** normalize unknown revisions and preserve mutation intent ([#1733](https://github.com/morluto/rea/issues/1733)) ([2165045](https://github.com/morluto/rea/commit/21650455a9c90a26e725c3505a3c17054cc4516c)), closes [#1723](https://github.com/morluto/rea/issues/1723) [#1724](https://github.com/morluto/rea/issues/1724)
* **evidence:** preserve call coverage in function comparisons ([049132a](https://github.com/morluto/rea/commit/049132ab43fa962d31774f2b258c15a71217148b)), closes [#2222](https://github.com/morluto/rea/issues/2222)
* **evidence:** preserve literal keys and response body ownership ([d1dcebc](https://github.com/morluto/rea/commit/d1dcebce2c0820469ed63e33497a14258971b710))
* **evidence:** preserve trace uncertainty and partial provider observations ([e997085](https://github.com/morluto/rea/commit/e9970855ba16ba711816ac30212c5ca17ae085cd))
* **evidence:** reject NUL bytes in subject and snapshot paths ([#1873](https://github.com/morluto/rea/issues/1873)) ([fa9a7e5](https://github.com/morluto/rea/commit/fa9a7e5ec026c5a4e8426f0f36c2c4555be9c9f0))
* **evidence:** reject unsupported metadata before authentication ([acf75e6](https://github.com/morluto/rea/commit/acf75e6700ace28bb1db25e54e56673ac15e0ed3))
* **evidence:** retain unreadable PE and Darwin process facts ([4a4d36f](https://github.com/morluto/rea/commit/4a4d36f881861bbab3c734b0dd459f805766caec))
* **evidence:** validate closure projections and browser body coverage ([aada0ca](https://github.com/morluto/rea/commit/aada0ca8126bf14c60750a4ccd4a79667770a1e4))
* **filesystem:** enforce symlink and decoded-text admission ([ec0b081](https://github.com/morluto/rea/commit/ec0b081371f118e6e5e042fbef3479359c672ddb))
* **firmware:** name missing extractor dependencies in partial extraction ([#1879](https://github.com/morluto/rea/issues/1879)) ([1769417](https://github.com/morluto/rea/commit/176941700118cc33c86e07373cd6950f30548386))
* **formats:** preserve high bits in Windows signature checks ([ac57c88](https://github.com/morluto/rea/commit/ac57c88efe64c3125850b54d3d833227e6b8ec51))
* **frida:** name the argument each Frida input error rejects ([#2217](https://github.com/morluto/rea/issues/2217)) ([86de439](https://github.com/morluto/rea/commit/86de439ba4c9297f721abc60c715fe30fb6ff3df)), closes [#2213](https://github.com/morluto/rea/issues/2213)
* **frida:** reject an unreadable script file before starting a session ([#2211](https://github.com/morluto/rea/issues/2211)) ([c9aecde](https://github.com/morluto/rea/commit/c9aecde580d26d4e7745c2623a4efa327a27367e))
* **ghidra:** derive installation admission from Java property semantics ([2187e16](https://github.com/morluto/rea/commit/2187e16f149a7a7b512a4cb45d5f468cc04091fe))
* **ghidra:** encode Java probe output as UTF-8 ([#2206](https://github.com/morluto/rea/issues/2206)) ([59be866](https://github.com/morluto/rea/commit/59be866719e8fe8ec060515044d427a3b0a57649))
* **ghidra:** keep JVM warnings out of LaunchSupport output ([#1698](https://github.com/morluto/rea/issues/1698)) ([e20994a](https://github.com/morluto/rea/commit/e20994a8ea10609d420e958bd8a1268b0bcd8899))
* **ghidra:** recover stale clients on same-target reopen ([#2014](https://github.com/morluto/rea/issues/2014)) ([4573e8c](https://github.com/morluto/rea/commit/4573e8cf35de822bfc5feabcc89ec3717c247c8c))
* **ghidra:** report progress while startup is pending ([#1877](https://github.com/morluto/rea/issues/1877)) ([29d2624](https://github.com/morluto/rea/commit/29d2624b3475586356c27f518bf6b823030268fb))
* **ghidra:** resolve file offsets in decompile ([#2101](https://github.com/morluto/rea/issues/2101)) ([2995ec6](https://github.com/morluto/rea/commit/2995ec6910ead327829378f5ab1b9172ecce33fc))
* **ghidra:** retain launcher command outcome in launch failure diagnostics ([#2011](https://github.com/morluto/rea/issues/2011)) ([d4e8ef7](https://github.com/morluto/rea/commit/d4e8ef74999666cdb735b49eb8fea367f8f86489))
* **ghidra:** share Java properties semantics with PSP extension admission ([b953f96](https://github.com/morluto/rea/commit/b953f96c0aae27e1876c317a8f7db1d6f292b8ff))
* **go:** accept file-backed unmapped Mach-O debug segments ([7fb097d](https://github.com/morluto/rea/commit/7fb097d72c9931dd5dfae481ad2b9d3bb6029f15))
* honor empty URL query and fragment markers ([#2091](https://github.com/morluto/rea/issues/2091)) ([af29247](https://github.com/morluto/rea/commit/af29247a7873ba8badec204a4a59f9a2b3a880b6))
* **hopper:** let failed one-shot commands exit while retaining a lease ([#1763](https://github.com/morluto/rea/issues/1763)) ([e8528ce](https://github.com/morluto/rea/commit/e8528cedcdfb1ca1d769304afdc353936e8e0d70))
* **hopper:** preserve observed process failures across requests ([#1927](https://github.com/morluto/rea/issues/1927)) ([601814e](https://github.com/morluto/rea/commit/601814ed0e4a0c57d6e045da0e6cf8ee2f29bb7f))
* **hopper:** preserve startup failures and recover denied launches ([069308b](https://github.com/morluto/rea/commit/069308bbe6d73b1d87038b43c9121658325042a9)), closes [#1808](https://github.com/morluto/rea/issues/1808)
* **hopper:** stop Linux demo children when the REA owner exits ([#2499](https://github.com/morluto/rea/issues/2499)) ([c105391](https://github.com/morluto/rea/commit/c105391db53e2e2ab8b8624d5f3870c14aae3637))
* **hopper:** wait for late bridges during cleanup ([#1866](https://github.com/morluto/rea/issues/1866)) ([cf7477e](https://github.com/morluto/rea/commit/cf7477eb4642bc07228fa33ccc4c87656e6e3003))
* **ida:** declare GUI navigation for attached legacy analysis ([#1764](https://github.com/morluto/rea/issues/1764)) ([cf69207](https://github.com/morluto/rea/commit/cf692072b56903b60426e4b301b3c48fd31ee0fd)), closes [#1712](https://github.com/morluto/rea/issues/1712)
* **ida:** preserve native coverage and verify returned values ([#2324](https://github.com/morluto/rea/issues/2324)) ([37b7550](https://github.com/morluto/rea/commit/37b7550ecc916acc8619fa72e5c05af45702733f))
* **ida:** preserve non-x86 call edges and workflow uncertainty ([#1906](https://github.com/morluto/rea/issues/1906)) ([0ae38e1](https://github.com/morluto/rea/commit/0ae38e1e31414c67a3be09231d208f0726c035f8))
* **ida:** recover cleanup after worker sessions are reaped ([12fa8fd](https://github.com/morluto/rea/commit/12fa8fd9f986dbd4ff58165c3ec71a46dc8a8a3b))
* **ida:** report a procedure IDA cannot resolve as invalid input ([#1785](https://github.com/morluto/rea/issues/1785)) ([d3e9ee5](https://github.com/morluto/rea/commit/d3e9ee5505251f760666bb8e1e6c295b6d6aedb2))
* **ida:** stop the private input of a universal Mach-O at the end of the selected slice ([#2291](https://github.com/morluto/rea/issues/2291)) ([c6acd19](https://github.com/morluto/rea/commit/c6acd19b6d842699ab807d9526dc762c743686f4))
* **ida:** thin the selected FAT slice into the private headless input ([#2201](https://github.com/morluto/rea/issues/2201)) ([10400fd](https://github.com/morluto/rea/commit/10400fdb57e4226c5ce15af17968a664f35498ce)), closes [#2139](https://github.com/morluto/rea/issues/2139)
* **ida:** wait out a transient busy worker health in headless sessions ([#1778](https://github.com/morluto/rea/issues/1778)) ([0bdc7a4](https://github.com/morluto/rea/commit/0bdc7a40ac171a05971b287f2aab0376e2f14358))
* **install:** describe automatic setup and guard truncated downloads ([#1902](https://github.com/morluto/rea/issues/1902)) ([d802304](https://github.com/morluto/rea/commit/d802304c542b20bc86e22cc492718e89a8a5579b))
* **install:** honor redirected setup diagnostics ([8b18171](https://github.com/morluto/rea/commit/8b181716aca2cb467be499cba226abfb5ae19141))
* **io:** consolidate managed snapshots and retain cleanup ownership ([98d8470](https://github.com/morluto/rea/commit/98d8470d36cfbf7cd25820a4ed430aebae20c0c5))
* **io:** retain cleanup ownership and honor cancellation ([d8c76b6](https://github.com/morluto/rea/commit/d8c76b629639bda4405afa9ae2f7676b694f2f10))
* **javascript:** apply loader-owned module lookup rules ([c0904b4](https://github.com/morluto/rea/commit/c0904b4c1aabfccf2f0ab12f44960a0a1fbbbfae))
* **javascript:** bound self-alias mutation paths that never prune ([e365f37](https://github.com/morluto/rea/commit/e365f3715f0e9856218f0b2878bfe2144c821d9a))
* **javascript:** bound value evaluation through nested closure aliases ([#1941](https://github.com/morluto/rea/issues/1941)) ([d59ab6d](https://github.com/morluto/rea/commit/d59ab6d977d5b9376af2fdd141b4f65460bb9a9f))
* **javascript:** centralize module syntax and separate erased type dependencies ([1b95302](https://github.com/morluto/rea/commit/1b953021eadf4cac09e20be388d79536903633b3))
* **javascript:** distinguish file URL paths from query identity ([f43fc02](https://github.com/morluto/rea/commit/f43fc0247527aebb8efc7d9db04276fe2d489323))
* **javascript:** follow actual receivers in nested intrinsic calls ([#1912](https://github.com/morluto/rea/issues/1912)) ([9d8e42b](https://github.com/morluto/rea/commit/9d8e42bdd6f1bf8a984da1e06e1d76a1dbb9a7ed))
* **javascript:** follow bundler namespace interop for Electron APIs ([#1827](https://github.com/morluto/rea/issues/1827)) ([6ac5c36](https://github.com/morluto/rea/commit/6ac5c36503492d3b6da4fcf2a5dee87aff60fc8c))
* **javascript:** honor package exports before sibling files ([3ad5782](https://github.com/morluto/rea/commit/3ad5782dfdc8864879a887dc63fef682ffeb5839))
* **javascript:** index repeated graph observations before final sorting ([6d5d71f](https://github.com/morluto/rea/commit/6d5d71fab5e135c687f7aa2f797da19b4c2a1c1e)), closes [#1748](https://github.com/morluto/rea/issues/1748)
* **javascript:** keep TypeScript substitution out of runtime paths ([f8513a4](https://github.com/morluto/rea/commit/f8513a42dc44451a621127f06edde5d38bc93f69))
* **javascript:** normalize package URLs before array selection ([#1916](https://github.com/morluto/rea/issues/1916)) ([369cd3d](https://github.com/morluto/rea/commit/369cd3dfdc47709cc34d01c50f645a885c70ded7))
* **javascript:** parse explicit CommonJS wrapper syntax ([#2475](https://github.com/morluto/rea/issues/2475)) ([6a04f92](https://github.com/morluto/rea/commit/6a04f922e16ad4d171ba0c07e32238efdaf43e2d))
* **javascript:** preserve and resolve complete source-map URLs ([#1788](https://github.com/morluto/rea/issues/1788)) ([ce845ba](https://github.com/morluto/rea/commit/ce845ba428e9b23d6b74eae1e25e17c37532adac))
* **javascript:** preserve cancellation through recovery publication cleanup ([eaa9616](https://github.com/morluto/rea/commit/eaa961658578522cecfbba7c7be1acf1c64da65a))
* **javascript:** preserve execution and invocation provenance ([b8fab74](https://github.com/morluto/rea/commit/b8fab7429771bb17a7ca0297661de3c9935212af))
* **javascript:** preserve graphs within parent capacity ([#1921](https://github.com/morluto/rea/issues/1921)) ([0485cd3](https://github.com/morluto/rea/commit/0485cd3bce29c977bae05a4fdce5436f3e3d7913)), closes [#1911](https://github.com/morluto/rea/issues/1911)
* **javascript:** preserve import-equals declaration and export evidence ([#2062](https://github.com/morluto/rea/issues/2062)) ([86fab11](https://github.com/morluto/rea/commit/86fab11fbe000a04c0810699419e99e50476e424))
* **javascript:** preserve loader-specific package ancestry ([9f67e44](https://github.com/morluto/rea/commit/9f67e44e5d451d705bae9a579ad9802d5c9f097c))
* **javascript:** preserve namespace re-export relationships ([#1833](https://github.com/morluto/rea/issues/1833)) ([37a9eb9](https://github.com/morluto/rea/commit/37a9eb9d65a088209f962e5094f6bc67eacbbaf9))
* **javascript:** preserve package and source-map boundary identity ([#1939](https://github.com/morluto/rea/issues/1939)) ([14935e4](https://github.com/morluto/rea/commit/14935e4d470be318fdee442d279d6acb7d441119))
* **javascript:** preserve POSIX runtime path case on Windows ([#2422](https://github.com/morluto/rea/issues/2422)) ([1a1b67c](https://github.com/morluto/rea/commit/1a1b67c8885e9ff33f7a06ed2e027ebfdb83e084))
* **javascript:** preserve TypeScript loader and dependency semantics ([e4532b5](https://github.com/morluto/rea/commit/e4532b537663026c3e5b784380eecd009c959a36))
* **javascript:** preserve unknown module-alias provenance ([c7a890d](https://github.com/morluto/rea/commit/c7a890d12987d9b2d496119c1fbf1a88c0fc298e))
* **javascript:** prove namespace interop before attributing Electron APIs ([#1857](https://github.com/morluto/rea/issues/1857)) ([9b3e598](https://github.com/morluto/rea/commit/9b3e59804db71072ed023f109342f96063c57b45))
* **javascript:** report available parent result budget in coverage limits ([#1915](https://github.com/morluto/rea/issues/1915)) ([72489a9](https://github.com/morluto/rea/commit/72489a9445299f695d793f004fed88e1bac0a557))
* **javascript:** report JSON status for imported package manifests ([914ba83](https://github.com/morluto/rea/commit/914ba833ded5f8604345de4e4f6d306de0a38c4e))
* **javascript:** resolve bundled exports by binding identity ([5ab0cbb](https://github.com/morluto/rea/commit/5ab0cbb48ea5a4f11542eac44479b9af48f1dabd)), closes [#2171](https://github.com/morluto/rea/issues/2171)
* **javascript:** resolve module-relative Worker URL references ([#2104](https://github.com/morluto/rea/issues/2104)) ([653067d](https://github.com/morluto/rea/commit/653067d799591ce2b8fdc84e99c160ac17e2860f))
* **javascript:** resolve relative ESM URLs as exact files ([783fcb0](https://github.com/morluto/rea/commit/783fcb06a4bd0444be214822fed60a3786932782))
* **javascript:** retain compiler-dependent module resolution facts ([c923fb0](https://github.com/morluto/rea/commit/c923fb0dc9462e302b84a15770131ff899f89455))
* **javascript:** retain completed facts through cancellation ([#1934](https://github.com/morluto/rea/issues/1934)) ([b6651cb](https://github.com/morluto/rea/commit/b6651cb99fba8fec3fd3efcb6794ae32b8a1525d))
* **javascript:** retain distinct chunk registration callback metadata ([#2737](https://github.com/morluto/rea/issues/2737)) ([0cf224e](https://github.com/morluto/rea/commit/0cf224e76214abbcf47274aa936d1b5cad28db2f))
* **javascript:** retain guards without duplicating observed IPC calls ([42b0ed1](https://github.com/morluto/rea/commit/42b0ed126df7d5b908ac369715378d5e519051b7))
* **javascript:** retain unbound CommonJS semantic imports ([3a17882](https://github.com/morluto/rea/commit/3a17882cc469515e562df9ecc7d154f30a5a9205))
* **javascript:** retain wrapped isolated-world bridge exposures ([1c37914](https://github.com/morluto/rea/commit/1c37914306b3ff257d7a0360f11c9bf2a3f2cac7))
* **javascript:** share source-map resolution and preserve path uncertainty ([3ae826c](https://github.com/morluto/rea/commit/3ae826ceba6f25b08bce50bcb51001209bc16b57))
* **javascript:** share TypeScript emitted import resolution ([1dfb933](https://github.com/morluto/rea/commit/1dfb933e47db0156cf1cb61f28715b81e740c57b))
* **javascript:** trace wrapped Electron IPC paths in large bundles ([#1874](https://github.com/morluto/rea/issues/1874)) ([87b9793](https://github.com/morluto/rea/commit/87b9793c53caf70a9f3a516214856899ca2d7cf8))
* **jeb:** confine an invalid REA_JEB_MCP_URL to the JEB tools ([#2145](https://github.com/morluto/rea/issues/2145)) ([df2c08b](https://github.com/morluto/rea/commit/df2c08b546d4f40103e11b8b31dbfc3d41ee687b)), closes [#2143](https://github.com/morluto/rea/issues/2143)
* **managed:** admit ReadyToRun images built for non-Windows hosts ([#1985](https://github.com/morluto/rea/issues/1985)) ([0d11f71](https://github.com/morluto/rea/commit/0d11f71e0dc5cf95d3bea093355f7edc09089a68))
* **managed:** bind row budget charges to metadata identities ([#1897](https://github.com/morluto/rea/issues/1897)) ([23d50ea](https://github.com/morluto/rea/commit/23d50ea3bec186b4459d36c04854a92276accdf1)), closes [#1878](https://github.com/morluto/rea/issues/1878)
* **managed:** bound transient CIL by decoded instruction count ([#1822](https://github.com/morluto/rea/issues/1822)) ([554cb45](https://github.com/morluto/rea/commit/554cb45f7ff967884c9d1fdf706c20803d76a6df))
* **managed:** charge retained member facts instead of decoding bounds ([#1768](https://github.com/morluto/rea/issues/1768)) ([3078729](https://github.com/morluto/rea/commit/30787297d49bf93e67f62a9ec42f896188d57a20))
* **managed:** charge transient CIL by decoded instructions ([554cb45](https://github.com/morluto/rea/commit/554cb45f7ff967884c9d1fdf706c20803d76a6df))
* **managed:** decode generic, array, fnptr, and modifier signatures ([#1835](https://github.com/morluto/rea/issues/1835)) ([1f531fc](https://github.com/morluto/rea/commit/1f531fc257f71731624cbd6b595208adacf59108))
* **managed:** distinguish .NET Standard targets from runtimes ([81dc64e](https://github.com/morluto/rea/commit/81dc64e3cd13dc8bfcac8898386717ece9721da1))
* **managed:** honor metadata ExtraData before table decoding ([#1893](https://github.com/morluto/rea/issues/1893)) ([fb43dd9](https://github.com/morluto/rea/commit/fb43dd9716fe113effa14a03b603b72ca7d9097c))
* **managed:** index attribute constructor owners once per layout ([#1943](https://github.com/morluto/rea/issues/1943)) ([c2cf7dc](https://github.com/morluto/rea/commit/c2cf7dcb86e11350214f82e5c51de7ca6ee3d27a))
* **managed:** preserve negative zero in CIL operands ([#2437](https://github.com/morluto/rea/issues/2437)) ([6c816af](https://github.com/morluto/rea/commit/6c816af11975e8e801adb2924c4e3c116c72ebba))
* **managed:** report ExportedType rows in managed member inspection ([#1828](https://github.com/morluto/rea/issues/1828)) ([ed1519d](https://github.com/morluto/rea/commit/ed1519ddee02739f57136e356786f64c7f74fed0))
* **managed:** report malformed PE layouts as invalid input ([#2194](https://github.com/morluto/rea/issues/2194)) ([90e37f4](https://github.com/morluto/rea/commit/90e37f4152990be721784006a820a3794920213c))
* **managed:** resolve member ownership through pointer tables ([#1886](https://github.com/morluto/rea/issues/1886)) ([a95795c](https://github.com/morluto/rea/commit/a95795c6ed3cb149e03460f4c818ad12e0c670d9))
* **managed:** share first-match member list ownership ([#1955](https://github.com/morluto/rea/issues/1955)) ([7d92d93](https://github.com/morluto/rea/commit/7d92d93a550f3be221d7cf1da07c37ea1770e922))
* **managed:** share first-match member ownership indexes ([52e76c5](https://github.com/morluto/rea/commit/52e76c5d39b58d1685bb5e22bd40d19524dfa2cb))
* **managed:** validate CLI type selections with the member contract ([#2125](https://github.com/morluto/rea/issues/2125)) ([60259a9](https://github.com/morluto/rea/commit/60259a91d9a58cbd313ad7f3d321acc2deb65d2e)), closes [#2121](https://github.com/morluto/rea/issues/2121)
* **mcp:** cancel evidence imports before session mutation ([e5699d2](https://github.com/morluto/rea/commit/e5699d23b173bbd0b68e3ab177d0b7583d6df345)), closes [#1746](https://github.com/morluto/rea/issues/1746)
* **mcp:** express inspect_analysis_view selector constraints in the contract schema ([#1705](https://github.com/morluto/rea/issues/1705)) ([a8a9c4d](https://github.com/morluto/rea/commit/a8a9c4d8173b63fb42d343a8ca043927970ea4ce)), closes [#1695](https://github.com/morluto/rea/issues/1695)
* **mcp:** join owned doctor shutdown without signaling saved PIDs ([7fc0b5e](https://github.com/morluto/rea/commit/7fc0b5ec9c2532bbf789d7783b7a0cab6fc054d8))
* **mcp:** keep prompt completion within ready provider sessions ([07b36c1](https://github.com/morluto/rea/commit/07b36c148946f46b99a4ce04e55726e97d0ee03a))
* **mcp:** keep shared optional providers open across discarded probes ([#2205](https://github.com/morluto/rea/issues/2205)) ([7c9c1eb](https://github.com/morluto/rea/commit/7c9c1ebdb77662f6276ef2f7250e7df414bd03ca)), closes [#2195](https://github.com/morluto/rea/issues/2195)
* **mcp:** preserve target provenance on retained partial errors ([8fc2043](https://github.com/morluto/rea/commit/8fc2043eb8bcedbf34dce064f977e455438635d6))
* **mcp:** provide native evidence paging continuation ([1449e8e](https://github.com/morluto/rea/commit/1449e8e05138bf0c86d5fe6f4c93122b4b93b2b1)), closes [#1849](https://github.com/morluto/rea/issues/1849)
* **native:** accept universal binaries with LIB64 x86_64 slices ([#1982](https://github.com/morluto/rea/issues/1982)) ([4b653fe](https://github.com/morluto/rea/commit/4b653fe6ad1d25a2d9a69d5e93610380c0c09a34))
* **native:** clarify Mach-O offset origins and reject partial ELF tables ([8bd5ba2](https://github.com/morluto/rea/commit/8bd5ba2d234cbd9a5bb9dd5daeaa191c3a1cad24)), closes [#1973](https://github.com/morluto/rea/issues/1973)
* **native:** honor Mach-O chained pointer locations and library ordinals ([#1709](https://github.com/morluto/rea/issues/1709)) ([3b0ea59](https://github.com/morluto/rea/commit/3b0ea590fcc876fa017946f4cec009d653f02b38))
* **native:** name the ownership helper prerequisite in command failures ([#2506](https://github.com/morluto/rea/issues/2506)) ([20f51d7](https://github.com/morluto/rea/commit/20f51d7286b1759e42f99c0512c73230052dda86))
* **native:** name the retained pwntools process group in cleanup resources ([#2281](https://github.com/morluto/rea/issues/2281)) ([4303dcd](https://github.com/morluto/rea/commit/4303dcdcf68af4d71d777ffff75cbd4f17a8877d)), closes [#2274](https://github.com/morluto/rea/issues/2274)
* **native:** preserve literal line breaks in signing identifiers ([#2107](https://github.com/morluto/rea/issues/2107)) ([ec3db37](https://github.com/morluto/rea/commit/ec3db374574060fcc4fa6b215fceaea1bd4ca3f3))
* **native:** preserve universal Mach-O container identity ([b38429c](https://github.com/morluto/rea/commit/b38429cb6a0de8f4bb5ae01633e24abe2d7cde46))
* **native:** raise default PE resource entry budget to 16384 ([#1703](https://github.com/morluto/rea/issues/1703)) ([951c2aa](https://github.com/morluto/rea/commit/951c2aae3be1c357df9f23558d8c09680e64c2cb))
* **native:** rank Mach-O slices by parsed CPU subtype ([c02fb0e](https://github.com/morluto/rea/commit/c02fb0ea8055f98def2b12ad557aed0faefd3639))
* **native:** reject ambiguous otool load-command framing ([108b5f9](https://github.com/morluto/rea/commit/108b5f94254281639badd25b1f448cca35dc89a2))
* **native:** reject required otool fields truncated before their value ([#1995](https://github.com/morluto/rea/issues/1995)) ([adde8a8](https://github.com/morluto/rea/commit/adde8a80921b199ddd7105998f3760d345b468fc))
* **native:** retain failed command cleanup owners ([d9b4ae1](https://github.com/morluto/rea/commit/d9b4ae182d60dfd7fd5ba5bb539716413cbfdca6))
* **native:** retain failed pwntools process and runtime cleanup ([#1708](https://github.com/morluto/rea/issues/1708)) ([11fe5a1](https://github.com/morluto/rea/commit/11fe5a13f8aee87d0c0e036a9f497407e085fe48))
* **native:** retain signature snapshot cleanup ownership ([ebc22d9](https://github.com/morluto/rea/commit/ebc22d99d62225505c0a84f6e39823ba4ef2f829))
* **native:** select one slice when universal Mach-O slices share a CPU type ([#2040](https://github.com/morluto/rea/issues/2040)) ([96fc84f](https://github.com/morluto/rea/commit/96fc84f1fb7c8bc69f66cd899896f4de9061fead))
* order remaining outputs independently of host locale ([#1867](https://github.com/morluto/rea/issues/1867)) ([e4ac7bb](https://github.com/morluto/rea/commit/e4ac7bbfc2f8c631ba1c60f507d85253e122d4c8))
* **pcap:** name the host tool to configure in capability refusals ([#2507](https://github.com/morluto/rea/issues/2507)) ([bb96afd](https://github.com/morluto/rea/commit/bb96afdbe32f5318d57fcb00099191c5a7712f53))
* **pcap:** order evidence ties by code point instead of host locale ([#2509](https://github.com/morluto/rea/issues/2509)) ([f6556ad](https://github.com/morluto/rea/commit/f6556ad2bc102d80223f5b434136f546dbd2bbab))
* **plist:** decode formatted XML dictionary keys ([f1924cf](https://github.com/morluto/rea/commit/f1924cf9c1910d5d29d11e7bf634b51af8d738cf))
* **process:** accept a trace identifier the comparison result can represent ([#2488](https://github.com/morluto/rea/issues/2488)) ([2c6a4fa](https://github.com/morluto/rea/commit/2c6a4fac4ff3477ac14f3d780894c5b390880607))
* **process:** add an opt-in Swift module cache ([#2252](https://github.com/morluto/rea/issues/2252)) ([47a51b0](https://github.com/morluto/rea/commit/47a51b08f4d79f808c1b74de79c3852aa853df53))
* **process:** aggregate incomplete trace-spec diagnostics ([857a1b7](https://github.com/morluto/rea/commit/857a1b7b04da4488db2b224c270ad78998c6b6c7))
* **process:** align capture Evidence provider admission ([713db4d](https://github.com/morluto/rea/commit/713db4dc2665c964707cb0e937e8794cbe73534e))
* **process:** bound retained trace ordering state ([63e796d](https://github.com/morluto/rea/commit/63e796d50ea323ae8dcfef80f86c8154bb106790))
* **process:** cancel run-token reads during sampling ([09b6828](https://github.com/morluto/rea/commit/09b68285166bef2b7633402c8adf7eb25823b47e))
* **process:** observe already-due trailing callbacks before settling the capture journal ([#1700](https://github.com/morluto/rea/issues/1700)) ([ed084b3](https://github.com/morluto/rea/commit/ed084b3dbb13ec9dee1adc8f5b76e784b52c00bb)), closes [#1697](https://github.com/morluto/rea/issues/1697)
* **process:** preserve invalid runtime path selections as input errors ([cecc6ec](https://github.com/morluto/rea/commit/cecc6ecbffe84689a173d1634f342e046b0c8ceb))
* **process:** preserve monotonic capture timing and observed wall dates ([9049dba](https://github.com/morluto/rea/commit/9049dbaf0c3df32357419f9899965df2b611f493))
* **process:** preserve nested path identities during normalization ([#2468](https://github.com/morluto/rea/issues/2468)) ([b9ee921](https://github.com/morluto/rea/commit/b9ee921560dcf4590c2b13a1cc92c49c93e10675))
* **process:** reject deep trace cycles without recursive expansion ([10935b7](https://github.com/morluto/rea/commit/10935b7b1136accab2a43b308ab380749da0f7ae))
* **process:** report single empty settlement observation as unverifiable ([#2232](https://github.com/morluto/rea/issues/2232)) ([7724edb](https://github.com/morluto/rea/commit/7724edbdd3fa0acd94c8210a30dceba501fcdbe8))
* **process:** report the Swift compiler outcome with bounded diagnostics ([#1999](https://github.com/morluto/rea/issues/1999)) ([091cb81](https://github.com/morluto/rea/commit/091cb81fc3ca2b8ec96df7a534934388a6b882cd))
* **process:** retain owned command cleanup diagnostics ([#1907](https://github.com/morluto/rea/issues/1907)) ([38539a2](https://github.com/morluto/rea/commit/38539a2f4a522243d6fc369ae34ef30e2a2f80cc)), closes [#1856](https://github.com/morluto/rea/issues/1856)
* **process:** return private runtime ownership at allocation ([c22111e](https://github.com/morluto/rea/commit/c22111eca2c4bc84a084f12711f405b3e7422a40))
* **process:** use monotonic time for provider execution budgets ([d9e9fb2](https://github.com/morluto/rea/commit/d9e9fb2273543caab4eb21d896c33d462ada6971))
* **progress:** measure delivery intervals with a monotonic clock ([ed881c6](https://github.com/morluto/rea/commit/ed881c6d2ef6a8adba0c2a241ebc9ab4603b7868))
* **providers:** retain native parsing and compiler failure diagnostics ([b5ededd](https://github.com/morluto/rea/commit/b5ededd0f9c28d0f696a205103066928117368ee))
* **readiness:** validate stage evidence references once ([02cedc3](https://github.com/morluto/rea/commit/02cedc37961a5d230b134f330c148322cd04bf37))
* **reconstruction:** resolve latest verifier observations without input-order bias ([1f7f808](https://github.com/morluto/rea/commit/1f7f808c4f3bb5eca6b13bcc71d5cceb2ace6614))
* **reference:** report sloppy scripts, PHP, C# and enclosing Git facts ([#1770](https://github.com/morluto/rea/issues/1770)) ([7b15762](https://github.com/morluto/rea/commit/7b15762d09acf3d4ffb937f55ec1b54e7db5fbfa))
* **reference:** verify symlink identity around target reads ([3d83135](https://github.com/morluto/rea/commit/3d8313598785359c424d7305f503cb69fc8639a6))
* **runtime:** preserve unknown debugger attachment state ([e55ef72](https://github.com/morluto/rea/commit/e55ef7213515ba204c3d88a4487b4ee6a2773b06))
* **screenshot:** reject invalid PNG header dimensions ([#2472](https://github.com/morluto/rea/issues/2472)) ([e74e4e9](https://github.com/morluto/rea/commit/e74e4e951bf59c42b25779b45d1c7b8dde6b618f))
* **scripts, install:** honor test:fast "without build" promise and orient Node.js engine mismatches ([#2026](https://github.com/morluto/rea/issues/2026)) ([69e097a](https://github.com/morluto/rea/commit/69e097a8ca256ab97ee4b2883739447784a5f90e))
* **scripts:** pass MCP callTool timeouts as request options ([#2387](https://github.com/morluto/rea/issues/2387)) ([c462a4e](https://github.com/morluto/rea/commit/c462a4ebd3a10e59253bcf38353a0914b176738b))
* **session:** attribute selected-provider capabilities to the resolved identity ([#2013](https://github.com/morluto/rea/issues/2013)) ([2ff850d](https://github.com/morluto/rea/commit/2ff850de68dc9b05c6faf2bbfd26d56bc2d5d27c)), closes [#2009](https://github.com/morluto/rea/issues/2009)
* **session:** invalidate snapshots before metadata write dispatch ([1c64fe3](https://github.com/morluto/rea/commit/1c64fe3eff48679c6880f42d2f0a4af46686ade7))
* **session:** verify retained client liveness on open fast path ([e59db2f](https://github.com/morluto/rea/commit/e59db2f5deefb1c5e74f01e19631f6d75cfaa25a))
* **setup:** configure an empty Hermes config.yaml ([#1983](https://github.com/morluto/rea/issues/1983)) ([43f9c39](https://github.com/morluto/rea/commit/43f9c39dfbdfaa6e5bc260b037a506cd1f56fe6d))
* **setup:** honor config roots and display terminal dry-run plans ([2397137](https://github.com/morluto/rea/commit/23971376158e7815edcbe6c04a8df7c9504650dc))
* **setup:** ignore empty home directory selections ([c339f9e](https://github.com/morluto/rea/commit/c339f9ebe7bd1f30608d6ccb7175da523dd3c485)), closes [#2325](https://github.com/morluto/rea/issues/2325)
* **setup:** name each skill directory in the uninstall summary ([#2118](https://github.com/morluto/rea/issues/2118)) ([5190843](https://github.com/morluto/rea/commit/5190843f8f09ac17cbf79561bd1f708c509cda78)), closes [#2111](https://github.com/morluto/rea/issues/2111)
* **setup:** name invalid client config shape errors clearly ([#2157](https://github.com/morluto/rea/issues/2157)) ([b1bf4a7](https://github.com/morluto/rea/commit/b1bf4a70d44191348ca20a91a52fb663da4daed0))
* **setup:** name structural problems in client configurations ([#2154](https://github.com/morluto/rea/issues/2154)) ([51367ca](https://github.com/morluto/rea/commit/51367cae6f4e6ef9b9dcb28dc21fe6c92d1dd6f8)), closes [#2152](https://github.com/morluto/rea/issues/2152)
* **setup:** pin Hopper's macOS publisher identity before install ([#2481](https://github.com/morluto/rea/issues/2481)) ([650237e](https://github.com/morluto/rea/commit/650237efb01b3c4a154799dbbedb2af1084f2312))
* **setup:** preserve Cordis ownership across uncertain patch structure ([21eff3b](https://github.com/morluto/rea/commit/21eff3bae10f58711a35ad44f86e9f96f9bb4790))
* **setup:** preserve current recovery snapshots and decoding failures ([f6775a3](https://github.com/morluto/rea/commit/f6775a36694a6a9c620f4248638e8056a6cbcb66))
* **setup:** preserve skill files and report rollback failures ([c240768](https://github.com/morluto/rea/commit/c2407689cb04df082e8db0b6afa8f8d022f6bcd1))
* **setup:** preserve unrelated Hermes YAML line formatting ([58e91e1](https://github.com/morluto/rea/commit/58e91e14bd5659dd57acbb91e56b5fc6a6963c80))
* **setup:** register a stable launcher when run through bunx ([#1731](https://github.com/morluto/rea/issues/1731)) ([d412def](https://github.com/morluto/rea/commit/d412def7225a556e6d2140176dc9ab4bd34af0f3))
* **setup:** remove emptied skill directories on uninstall ([#2182](https://github.com/morluto/rea/issues/2182)) ([d350357](https://github.com/morluto/rea/commit/d35035780a643e38301b8eaea03f01bb506e9df9))
* **setup:** retain regular-file cleanup ownership through workflows ([dcfc5d4](https://github.com/morluto/rea/commit/dcfc5d403ac997c2b08bee4753e57a1a4a8a8538))
* **setup:** retain symlinked data roots during uninstall ([#1821](https://github.com/morluto/rea/issues/1821)) ([7e2f79a](https://github.com/morluto/rea/commit/7e2f79ab45a23ab844f89fb93c9cdf202230c73f))
* **setup:** verify independently selected skill destinations ([bb50813](https://github.com/morluto/rea/commit/bb508134efbba392a404b651d6ec05be8b7eac1e))
* **snapshot:** bind replayed subjects to the selected target path ([c002b89](https://github.com/morluto/rea/commit/c002b89e2d8130a3c07e25e5fd604876be18c703))
* **snapshot:** classify non-object roots as invalid content ([#2685](https://github.com/morluto/rea/issues/2685)) ([9cf49c7](https://github.com/morluto/rea/commit/9cf49c7a784ace9b79e4b4c1022c9fbf2b3f77e1))
* **sourcemap:** retain named originals beside unknown source names ([83a4445](https://github.com/morluto/rea/commit/83a4445f440fab978ad9376b4260acae3f5fb189))
* **sourcemaps:** preserve nullable source declarations ([ee50ed1](https://github.com/morluto/rea/commit/ee50ed121e38cc6e1a2ff0dc8b789f15f71c5402))
* **target:** recognize binary plists by magic under any extension ([#2133](https://github.com/morluto/rea/issues/2133)) ([672bdea](https://github.com/morluto/rea/commit/672bdea21fd943688e081d209a993e234c45507f)), closes [#2129](https://github.com/morluto/rea/issues/2129)
* **targets:** project invalid raw-binary loads as caller input ([#2004](https://github.com/morluto/rea/issues/2004)) ([edf4624](https://github.com/morluto/rea/commit/edf46248f2a44b4e5776518489fee09d0fc4516b))
* **targets:** recognize Windows headers after short DOS stubs ([c9fed50](https://github.com/morluto/rea/commit/c9fed50691ae014890e91a72534773d2fb58ce44))
* **targets:** remove path advice for format refusals ([f28f338](https://github.com/morluto/rea/commit/f28f3382836f8dd8629c25b1bf5455d29b28d41d)), closes [#1979](https://github.com/morluto/rea/issues/1979) [#1852](https://github.com/morluto/rea/issues/1852)
* **test:** audit static Wasm and Harmony tool wording ([#1917](https://github.com/morluto/rea/issues/1917)) ([dfe3117](https://github.com/morluto/rea/commit/dfe31172ca0a090be3f40bc134f54d69c08e31d8))
* **test:** wait for Hopper reconnect listener readiness ([#1885](https://github.com/morluto/rea/issues/1885)) ([d3e83b0](https://github.com/morluto/rea/commit/d3e83b089943e2941dace3546a068a59bd0a335e))
* **uninstall:** label YAML configuration syntax failures correctly ([94153f1](https://github.com/morluto/rea/commit/94153f1c757e07a6d092e00903aa8b125d4a0afa))
* **update:** resolve Bun global directories before changing cwd ([e90acae](https://github.com/morluto/rea/commit/e90acaea674f5f7f9f17ea472104afdc1422ada6))
* **update:** share parsed client activation with maintenance ([b045df1](https://github.com/morluto/rea/commit/b045df100e1489e08a36d6ff04cea7feecf1332c))
* **wasm:** accept unpadded WABT section rows ([#1963](https://github.com/morluto/rea/issues/1963)) ([9eee709](https://github.com/morluto/rea/commit/9eee709f0cc4064808579c225814898a475ca388))
* **wasm:** inspect modules whose custom section names contain NUL ([#1981](https://github.com/morluto/rea/issues/1981)) ([1febc22](https://github.com/morluto/rea/commit/1febc22907f1cc8035dd1450616233121bd7f19d))
* **wasm:** preserve debug module names in WABT output ([#1967](https://github.com/morluto/rea/issues/1967)) ([b777e5d](https://github.com/morluto/rea/commit/b777e5d4d8a7b60edad7d5c0eaa56408a4b3f610))
* **wasm:** preserve leading BOM in custom section names ([#2102](https://github.com/morluto/rea/issues/2102)) ([abc8c6d](https://github.com/morluto/rea/commit/abc8c6dadf4f11b67de1cc2ee96bb55047416d71))
* **wasm:** stop appending WABT remediation to unrelated runtime-root failures ([#2084](https://github.com/morluto/rea/issues/2084)) ([ea2f94d](https://github.com/morluto/rea/commit/ea2f94d4de2241306158fecdd75f96cd34566694))
* **web:** reject NUL bytes in web tool local path inputs ([499ac18](https://github.com/morluto/rea/commit/499ac180115de7c9de3f1adc55159ffa6e9bb40e))
* **website:** clarify translated evidence and runtime observations ([f1f5582](https://github.com/morluto/rea/commit/f1f5582ce84f54e529826fef7af3e201f52248d9))
* **website:** clarify translated terminology and RTL flows ([a956275](https://github.com/morluto/rea/commit/a956275d8d0249c16dad282a3fb5d7da48fb2d6f))
* **website:** incorporate multilingual contributor refinements ([d981a58](https://github.com/morluto/rea/commit/d981a58d7f5344c3ab2117b6384aece4d1c35480))
* **website:** keep omitted language selectors free of whitespace ([8583a19](https://github.com/morluto/rea/commit/8583a190c0178374132c888622d693fed1f88a18))
* **website:** prioritize getting started across language editions ([8cbd57c](https://github.com/morluto/rea/commit/8cbd57cafaf1cfacbe832bae593589cf211442ef))
* **windows:** bring the real Windows Ghidra verifiers up to date ([#1863](https://github.com/morluto/rea/issues/1863)) ([ea40f71](https://github.com/morluto/rea/commit/ea40f717b26392000f989a8b500f6774b5e85091))
* **windows:** preserve caller affinity only within one processor group ([#2202](https://github.com/morluto/rea/issues/2202)) ([ab62ee9](https://github.com/morluto/rea/commit/ab62ee9dcf98e4717d0b3445330f7cfdfe50c9f4))
* **windows:** preserve transient runtime admission failures ([#2467](https://github.com/morluto/rea/issues/2467)) ([1724077](https://github.com/morluto/rea/commit/172407787f1842dedbcd27b5385177595f786ce4))
* **windows:** retain failed native inspection ownership ([#1826](https://github.com/morluto/rea/issues/1826)) ([0b4ce83](https://github.com/morluto/rea/commit/0b4ce83618bb800627264ae6221e2e35140417b5))
* **workflows:** retain staged output ownership and cancellation semantics ([7285e15](https://github.com/morluto/rea/commit/7285e15839a4fecf9940683699b781a60ccc4775)), closes [#1968](https://github.com/morluto/rea/issues/1968)
### Performance Improvements
* **browser:** parse captured module URLs once per lookup ([#1829](https://github.com/morluto/rea/issues/1829)) ([ca11162](https://github.com/morluto/rea/commit/ca11162d027a0d3e3b5a65a54825b00d5689fb80))
* **ci:** share bytecode across fresh Apple artifact subprocesses ([ba7df3f](https://github.com/morluto/rea/commit/ba7df3f0f7ee63e6350d5fbdddc8ed945a6b141d))
* **cli:** bundle runtime entries to reduce Windows startup I/O ([#1996](https://github.com/morluto/rea/issues/1996)) ([d93fbda](https://github.com/morluto/rea/commit/d93fbda390c6b8b2741c552bd194b50c3e08e0c1))
* **cli:** defer command-specific analysis implementations ([a4dd4e8](https://github.com/morluto/rea/commit/a4dd4e8e8700ce350e1a0c612ebd61b91ca5e585))
* **cli:** defer command-specific modules until dispatch ([a82d14e](https://github.com/morluto/rea/commit/a82d14e4db4dca989ffa2e7dbe78fd43551da256))
* **domain:** avoid duplicate readiness bundle validation ([29ff887](https://github.com/morluto/rea/commit/29ff887f6558cf00c0d6392889b890ed8a702a6f))
* **evidence:** authenticate and seal imported bundles in one pass ([b123b2e](https://github.com/morluto/rea/commit/b123b2e51cc7d6d2157525664be2df88fb1ba3bd))
* **evidence:** fuse streamed JSON token handling ([#1694](https://github.com/morluto/rea/issues/1694)) ([13e03e9](https://github.com/morluto/rea/commit/13e03e94363a007353fcb5dfedec764de12fc927))
* **evidence:** reuse authenticated comparisons through aggregation ([26bffb9](https://github.com/morluto/rea/commit/26bffb94bf80355c35e22a98a8fc629c6210307e))
* **evidence:** reuse authenticated file snapshots during session import ([#1721](https://github.com/morluto/rea/issues/1721)) ([966dc3a](https://github.com/morluto/rea/commit/966dc3acde2880a0f94b2658e2a02180d505cafa))
* **evidence:** reuse authenticated unknown mutation snapshots ([042dcaa](https://github.com/morluto/rea/commit/042dcaa1ee66f1b8668f08b193f38ab01bceaacb))
* **flutter:** add opt-in retained AOT summary and string pages ([23de4f7](https://github.com/morluto/rea/commit/23de4f77c74dd299d45cab880c36321d802b21fe))
* **frida:** evict captured messages without copying the whole queue ([#2628](https://github.com/morluto/rea/issues/2628)) ([6a05248](https://github.com/morluto/rea/commit/6a05248d464387c0ce4ec1577b4c1b24d3d355c7))
* **ghidra:** compile bundle activator with bridge scripts ([#1931](https://github.com/morluto/rea/issues/1931)) ([0f4992f](https://github.com/morluto/rea/commit/0f4992fb9e5bfe1fafe75c35b7f20ad411595573))
* **javascript:** index source-map ignore lists per leaf ([#1905](https://github.com/morluto/rea/issues/1905)) ([898d509](https://github.com/morluto/rea/commit/898d5096f94fe1ed3ea7d091455e638cc7d23bfd))
* **javascript:** reuse collected callable identities for returns ([d3088c3](https://github.com/morluto/rea/commit/d3088c3795f6c37cb3b6aec0bb0c371dd0640ee8))
* **javascript:** skip nested callables during fingerprint traversal ([#1871](https://github.com/morluto/rea/issues/1871)) ([097f0d5](https://github.com/morluto/rea/commit/097f0d54afceb3ce61dbaa24dd6736658d1fce06))
* **javascript:** skip the repeated-prefix scan on first capture visits ([#1789](https://github.com/morluto/rea/issues/1789)) ([41544fb](https://github.com/morluto/rea/commit/41544fb952aef84fcf6ff5d9a5284cf2b2985053))
* **mcp:** bundle internal modules to reduce Windows startup I/O ([76e4153](https://github.com/morluto/rea/commit/76e4153d346b14ef194222bbeef240cf8a16dc99))
* **mcp:** compact repeated session discovery facts ([#1988](https://github.com/morluto/rea/issues/1988)) ([6395e8d](https://github.com/morluto/rea/commit/6395e8d3007d5ee453477460e7ee1c51cff76be0))
* **mcp:** keep Playwright and type-only imports off MCP startup ([#1691](https://github.com/morluto/rea/issues/1691)) ([0ecf3f2](https://github.com/morluto/rea/commit/0ecf3f2438ec96a6b543942869808018e8d84452))
* **mcp:** reduce Windows startup module resolution ([#1814](https://github.com/morluto/rea/issues/1814)) ([76e4153](https://github.com/morluto/rea/commit/76e4153d346b14ef194222bbeef240cf8a16dc99))
* **native:** add opt-in Mach-O summary delivery and retained pages ([#2634](https://github.com/morluto/rea/issues/2634)) ([bf34d0d](https://github.com/morluto/rea/commit/bf34d0d2fc3cc22076f49d682e74a0b71aa1db18))
* **native:** run independent Mach-O slice commands concurrently ([#2466](https://github.com/morluto/rea/issues/2466)) ([2c7240c](https://github.com/morluto/rea/commit/2c7240cf9d8231e16ad01de7678af9dbd01bab46))
* **snapshot:** restore large legacy caches within live memory bounds ([092de8f](https://github.com/morluto/rea/commit/092de8f4b336b537579bd48d7d30135b7114371d))
* **test:** batch compatible client profile lifecycles ([0153b4d](https://github.com/morluto/rea/commit/0153b4d95bdf38eddee91d5ce08181030fb955b4))
* **test:** reuse CLI boundary worker module graphs ([f178bf1](https://github.com/morluto/rea/commit/f178bf1c00139e1b7fe2d14fcfe1f558fe08fbe2))
* **test:** reuse the CI scope Git baseline ([ef5f9aa](https://github.com/morluto/rea/commit/ef5f9aa2d7769b32d9f88211812ae11456a88267))
* **tests:** consolidate TypeScript dialect producer checks ([01762b1](https://github.com/morluto/rea/commit/01762b10c6a40370996e6dfa3a96ffcb27148f97))
* **test:** share stdio analysis for comparison fixtures ([43dafb5](https://github.com/morluto/rea/commit/43dafb5abe60ee21be4f6a6b6de482cd7a5bab0a))
* **tests:** remove timed output and synchronize browser fixtures ([693ed33](https://github.com/morluto/rea/commit/693ed332b58d05192f55b8192d2c0c4bc4f4824a))
* **tests:** reuse stateless workflows and immutable fixtures ([5f4c7ff](https://github.com/morluto/rea/commit/5f4c7ff6df0735d5a4461b5d0e6f2e466b888be0))
### Code Refactoring
* **evm:** bind offline analysis through a static descriptor ([ad08510](https://github.com/morluto/rea/commit/ad08510a113d7a9b8d92fb1e385757880e469869))
* **javascript:** reuse validated source-map decoding ([f64790b](https://github.com/morluto/rea/commit/f64790bfc32db330b2be5bdff61af555a9d26067))
* **mcp:** trust validated inputs and tighten contract fixtures ([2646150](https://github.com/morluto/rea/commit/264615059ce6c89badcc7288e8c67bb8dd1ce0dd))
* prune obsolete provider and verification code ([e198793](https://github.com/morluto/rea/commit/e19879328a11fe38b49b6b133a8ebde908ea980e))
### Documentation
* add responsible-use disclaimer ([4a5aec8](https://github.com/morluto/rea/commit/4a5aec8ad6d54444b2a2c2313fbc506b17ff91aa))
* **agents:** require brittleness review before resolving threads ([0365454](https://github.com/morluto/rea/commit/0365454a573eb52c7089320fd74bfd4a673b78ae))
* **agents:** require related brittleness cleanup in bug fixes ([0d8b728](https://github.com/morluto/rea/commit/0d8b728d2470a9b51239d61986944bacaf9a8708))
* celebrate 110,000 GitHub stars <‰ ([#2042](https://github.com/morluto/rea/issues/2042)) ([0d78c99](https://github.com/morluto/rea/commit/0d78c99c11efee66570772efe6eb88dfd0e1c694))
* celebrate 170,000 GitHub stars <‰ ([#2462](https://github.com/morluto/rea/issues/2462)) ([9188698](https://github.com/morluto/rea/commit/91886982fccc50a9f7f31d4f320b25ffbcff35af))
* celebrate 80,000 GitHub stars <‰ ([#1752](https://github.com/morluto/rea/issues/1752)) ([6ff4d4a](https://github.com/morluto/rea/commit/6ff4d4a0500e5f9fb7601e1c5ae5dbbd85796d91))
* celebrate 90,000 GitHub stars <‰ ([#1858](https://github.com/morluto/rea/issues/1858)) ([5876779](https://github.com/morluto/rea/commit/5876779013726964deef748120c48f5fa0155546))
* clarify ownership and interpretation boundaries ([64b71b1](https://github.com/morluto/rea/commit/64b71b180a88a2df1b0b61e803c1c322ee59c5e1))
* clarify responsible use and preserve third-party notices ([#2082](https://github.com/morluto/rea/issues/2082)) ([974f11d](https://github.com/morluto/rea/commit/974f11d010570c0a18423cd1cc93e9a5203df1b6))
* **cli:** add a managed assembly inspection workflow ([#2037](https://github.com/morluto/rea/issues/2037)) ([538f8cc](https://github.com/morluto/rea/commit/538f8cc7224dba44b728cf6c000920f911957fc4))
* correct drift and reorganize English guides ([#2207](https://github.com/morluto/rea/issues/2207)) ([7ae3c50](https://github.com/morluto/rea/commit/7ae3c50a9d49c480035855de9fcc4484befacd92))
* define contribution scope and simplify proposal forms ([ac6ef95](https://github.com/morluto/rea/commit/ac6ef95e12335bdc054ccaaa96c32474cf8789ea))
* define performance priorities for analysis workflows ([#2245](https://github.com/morluto/rea/issues/2245)) ([8e00452](https://github.com/morluto/rea/commit/8e004522f232ec698c77536235853088a851e6b5))
* **ghidra:** verified macOS arm64 native decompiler build recipe ([#2112](https://github.com/morluto/rea/issues/2112)) ([5f79c85](https://github.com/morluto/rea/commit/5f79c852af8e53a56f58266a9dcf84314c197164))
* **ghidra:** verify and document the macOS arm64 native decompiler build ([5f79c85](https://github.com/morluto/rea/commit/5f79c852af8e53a56f58266a9dcf84314c197164))
* **i18n:** celebrate 80,000 GitHub stars in translated READMEs ([#1758](https://github.com/morluto/rea/issues/1758)) ([953a0ce](https://github.com/morluto/rea/commit/953a0ce5bd3a8b44051eb9c16d8c9a20cad29e7c))
* **install:** explain Claude Code upfront tool loading on third-party endpoints ([#2029](https://github.com/morluto/rea/issues/2029)) ([d024928](https://github.com/morluto/rea/commit/d0249282c64882329a6acab8f3b477552da53499)), closes [#2019](https://github.com/morluto/rea/issues/2019)
* preserve the native guide title anchor ([#2224](https://github.com/morluto/rea/issues/2224)) ([ae41265](https://github.com/morluto/rea/commit/ae412654d5947f3841c191e92f3ad6b73d6f894b))
* **readiness:** align required stage identifier and checks with evaluator ([#2200](https://github.com/morluto/rea/issues/2200)) ([85c5477](https://github.com/morluto/rea/commit/85c5477930d504cfd8b72334b48255d8e746a85e))
* **readme:** remove CI badges from translations ([3298219](https://github.com/morluto/rea/commit/3298219d8cd905897ec9a96bf639602a273d4a9b))
* **readme:** remove CI status badge ([b07e54a](https://github.com/morluto/rea/commit/b07e54a10e66319d90815313fae93350257e1655))
* **setup:** confirm published 6.4.0 client support ([57c5aca](https://github.com/morluto/rea/commit/57c5acac2e13fd7f0e3832bc147c7ceda8c314f2))
* shorten responsible-use notices across languages ([fa87dd2](https://github.com/morluto/rea/commit/fa87dd2aac3eaeb49875c54e93ec18c98cc92e4d))
* sync French, Indonesian and Italian READMEs with English ([#1861](https://github.com/morluto/rea/issues/1861)) ([96547d3](https://github.com/morluto/rea/commit/96547d373fcdc672058b29774d6c634f4e9cc1bd))
* **website:** add essay on reverse engineering models ([fe496bd](https://github.com/morluto/rea/commit/fe496bd1cf589938c372646ddef8ca9701417bf0))
* **website:** highlight the setup guide on the homepage ([#1714](https://github.com/morluto/rea/issues/1714)) ([8be7ad4](https://github.com/morluto/rea/commit/8be7ad496c9a52a9986c4d3f6596de279b52df7e))
* **website:** improve model essay transitions and narrative flow ([ca07373](https://github.com/morluto/rea/commit/ca07373e44e19e0c8357ac49e460c90d87fcc22c))
* **website:** publish model essay in all supported languages ([3d78a57](https://github.com/morluto/rea/commit/3d78a57043eb6b92812c71bcb6d83753c3d48036))
* **website:** simplify reconstruction evidence in model essay ([b06751b](https://github.com/morluto/rea/commit/b06751b703181527a320226c4ab7e4cecd5dcea0))
* **website:** use straight quotes in model essay ([806ff57](https://github.com/morluto/rea/commit/806ff575676e447c7ba349b6fd185e6e8e02ee5d))
### Tests
* **analysis:** verify real preset snapshot and cleanup boundaries ([#2349](https://github.com/morluto/rea/issues/2349)) ([1dd20ce](https://github.com/morluto/rea/commit/1dd20ce1ff4ab66c74350929577e753aa17a9006)), closes [#2016](https://github.com/morluto/rea/issues/2016)
* **android:** verify duplicate class inventory behavior on empty discovery queries ([#1984](https://github.com/morluto/rea/issues/1984)) ([6611bcf](https://github.com/morluto/rea/commit/6611bcf6e86630c775c24e6afa00d7239c64e70e))
* **apple:** consolidate keyed archive integer verification ([#1933](https://github.com/morluto/rea/issues/1933)) ([3b490a7](https://github.com/morluto/rea/commit/3b490a743fd56a8a27b1356f7b2d1284db8848ef))
* **apple:** keep the same-named slice dyld fixture openable on x64 hosts ([#2755](https://github.com/morluto/rea/issues/2755)) ([fb28ec1](https://github.com/morluto/rea/commit/fb28ec1537e2d02aaca96325483c849c5c8ade6f))
* **apple:** skip ibtool-compiled NIB tests when Xcode ibtool is unavailable ([#2070](https://github.com/morluto/rea/issues/2070)) ([7bc57e1](https://github.com/morluto/rea/commit/7bc57e1e64915a26c32f89fb53d0321193a37160))
* **architecture:** consolidate import boundary verification ([2976fac](https://github.com/morluto/rea/commit/2976fac30d4088c1f1ba383261eb3eff5e95bce6))
* bound CLI validation subprocess concurrency ([ff57b75](https://github.com/morluto/rea/commit/ff57b75e226028767b86aa3d4271c2c2f56342db))
* **ci:** stabilize JADX recovery and ignore generated reports ([#1668](https://github.com/morluto/rea/issues/1668)) ([0a6b46d](https://github.com/morluto/rea/commit/0a6b46d5b0299f76c0d268f7b70395f9b6fbd68e))
* classify filesystem and process workflows by boundary ([c2b56e3](https://github.com/morluto/rea/commit/c2b56e3e1c9619beab15de8e1f200422473caf30))
* classify filesystem application workflows as boundaries ([804c287](https://github.com/morluto/rea/commit/804c287e441dcaa3876429f026ac89f6482f9699))
* **cli:** align reference import failure envelopes ([8224aeb](https://github.com/morluto/rea/commit/8224aeba9dd41f29ea6f8ef7ad7efa3f45fd4a6e))
* **cli:** cache subprocess compilation within each worker ([6bcfeb0](https://github.com/morluto/rea/commit/6bcfeb0e55a6fcc3ed586e5744f2aa1c270cac31))
* **cli:** consolidate development runner workflow coverage ([16f5ded](https://github.com/morluto/rea/commit/16f5ded4468d7100d3640f874f33ddbaa953a787))
* **cli:** consolidate schema coverage and assert input refusals ([beb80a0](https://github.com/morluto/rea/commit/beb80a07bafc0847600eb635e0323068a386c811))
* **clients:** verify native Claude Code integration on Windows ([#1926](https://github.com/morluto/rea/issues/1926)) ([c32d846](https://github.com/morluto/rea/commit/c32d84622fb3952823c6cfc25c557aec08c1788c)), closes [#1923](https://github.com/morluto/rea/issues/1923)
* **clients:** verify native Gemini CLI integration on Windows ([#1936](https://github.com/morluto/rea/issues/1936)) ([04089d5](https://github.com/morluto/rea/commit/04089d5bec11d6e0411e7c35d0168bbae63d7e7d))
* **cli:** preserve Windows runtime paths and failure diagnostics ([79156e5](https://github.com/morluto/rea/commit/79156e5bed1d72b83ade0483246f457b337b7e59))
* **docs:** type README drift scenarios as fixed pairs ([6e38529](https://github.com/morluto/rea/commit/6e38529333a7a0704690a087c7cb054fb7385d25))
* **evidence:** cover workflow question whitespace normalization ([#1739](https://github.com/morluto/rea/issues/1739)) ([b2da21a](https://github.com/morluto/rea/commit/b2da21abe11ac43a4cc24b233bd52ce10995e4a1))
* **evm:** verify real worker cancellation and cleanup recovery ([#2404](https://github.com/morluto/rea/issues/2404)) ([ae80513](https://github.com/morluto/rea/commit/ae8051302b5834322fa00cfa31c968ae74ab6ed9))
* **evm:** verify static descriptor bindings before use ([#2427](https://github.com/morluto/rea/issues/2427)) ([4f4f6cd](https://github.com/morluto/rea/commit/4f4f6cd7b5d7a400b90772a138d7b9f942c2a556))
* **ghidra:** require pending multi-HI16 coverage in the real MIPS relocatable lane ([#1974](https://github.com/morluto/rea/issues/1974)) ([eaf88ac](https://github.com/morluto/rea/commit/eaf88aca2af2b8ad7a277c09d0b45f6e2de8ac4a))
* **ghidra:** restore complete installation fixtures across CI boundaries ([18d56f5](https://github.com/morluto/rea/commit/18d56f5321c3c5d5be40c19bcb0a0f2b25c2d459))
* **hopper:** align startup fixtures with cleanup and lease contracts ([b3dec00](https://github.com/morluto/rea/commit/b3dec005bcb89b5afe6e0aa41e5251f8a3a31de7))
* **hopper:** repair Linux demo setup and native verification ([#2638](https://github.com/morluto/rea/issues/2638)) ([141d209](https://github.com/morluto/rea/commit/141d2095f5c6faaf2d3c9b81b0f0f992cacec79c))
* **ida:** expect direction in combined call coverage ([4dd44dd](https://github.com/morluto/rea/commit/4dd44ddf16c810c4bcaa16e0bdb3b09bfcb4e33a))
* **javascript:** classify artifact workflows as boundary tests ([dc303a8](https://github.com/morluto/rea/commit/dc303a8ee8a2ceaae37e4da4ac4ed64035793d59))
* **javascript:** consolidate module lookup regressions ([#2031](https://github.com/morluto/rea/issues/2031)) ([783fcb0](https://github.com/morluto/rea/commit/783fcb06a4bd0444be214822fed60a3786932782))
* **javascript:** expect unknown parameter provenance ([e05d339](https://github.com/morluto/rea/commit/e05d3399f19f25fdf04285a02b293418256b26dc))
* **javascript:** verify CommonJS file URL boundaries via CLI and MCP ([#2061](https://github.com/morluto/rea/issues/2061)) ([6ce5903](https://github.com/morluto/rea/commit/6ce5903e7a37252156ec847c4c8874410a4d8f3f))
* **managed:** use the current attribute fixture contract ([6407718](https://github.com/morluto/rea/commit/6407718f3020a34cc087163492e3f9a465e2e841))
* **mcp:** align completion checks with provider readiness ([fe4b94a](https://github.com/morluto/rea/commit/fe4b94a661054cef7603a3f07aae71f1df0a701a))
* **mcp:** fix three MCP boundary suites on native Windows ([#2312](https://github.com/morluto/rea/issues/2312)) ([a97b713](https://github.com/morluto/rea/commit/a97b713f740932339a5f1036ad2213168c421270))
* **mcp:** preserve coverage in unchanged function fixtures ([3ee6ff4](https://github.com/morluto/rea/commit/3ee6ff43c1696a5a5269bd4aa5de72f7e5f40627))
* **mcp:** ready providers before live prompt completion checks ([#2137](https://github.com/morluto/rea/issues/2137)) ([621cee1](https://github.com/morluto/rea/commit/621cee192d582c87ca3d254d4cb22c9d209d3aea)), closes [#2123](https://github.com/morluto/rea/issues/2123)
* **mcp:** reuse advertised schema in inventory check ([#2469](https://github.com/morluto/rea/issues/2469)) ([a6efa4f](https://github.com/morluto/rea/commit/a6efa4fcefeffcf5ff5310b8154988b4be30c349))
* **native:** replay complete dylib-use provider output ([89f5e48](https://github.com/morluto/rea/commit/89f5e489adac69af2937fabc1a5ba283191d630a)), closes [#1970](https://github.com/morluto/rea/issues/1970)
* **native:** skip the DMG EACCES diagnostic as root ([#1584](https://github.com/morluto/rea/issues/1584)) ([262efe6](https://github.com/morluto/rea/commit/262efe6c54d8f179e9f9515d96025871ae45806a))
* **native:** skip the EACCES directory diagnostic as root ([#1394](https://github.com/morluto/rea/issues/1394)) ([40d9c7b](https://github.com/morluto/rea/commit/40d9c7b836a3aca5d589c0a65c7d02b3ea5cd36b))
* **native:** skip the inaccessible-directory diagnostic as root ([#1900](https://github.com/morluto/rea/issues/1900)) ([d0edeec](https://github.com/morluto/rea/commit/d0edeec1009f1149956ee2a95dc5941aff8f78b5))
* **native:** verify real PE and DOS entry views ([1770f38](https://github.com/morluto/rea/commit/1770f38cfc6cbe87491d0de8b8730c305c5cf8ea))
* **pcap:** run the missing-path refusal only where captures are admitted ([#2510](https://github.com/morluto/rea/issues/2510)) ([1991542](https://github.com/morluto/rea/commit/1991542e59f64d46269b3ff942be77a5ff06e88f))
* **perf:** distinguish Codex tool context and token accounting ([#2416](https://github.com/morluto/rea/issues/2416)) ([ee87111](https://github.com/morluto/rea/commit/ee87111009ace2ca4ef82c9aef09c82a37ca55f7))
* **perf:** observe owned counters at payload completion ([ca2d15b](https://github.com/morluto/rea/commit/ca2d15b3ee965bd7ab27204cdf4b96b117d148c1))
* **perf:** verify existing SQLite column selection on QGIS data ([#2489](https://github.com/morluto/rea/issues/2489)) ([45984a4](https://github.com/morluto/rea/commit/45984a4319c8b6dcf2b03e969fbd4330c8432394))
* **reference:** scope cleanup faults to the owned source file ([#2430](https://github.com/morluto/rea/issues/2430)) ([eeba618](https://github.com/morluto/rea/commit/eeba61826c1cd1c7be70f011fda0537baadd4179))
* **release:** validate only the selected preflight job ([747e729](https://github.com/morluto/rea/commit/747e7299141cec0b03774c427f579889d5088d20))
* reuse application analysis across comparison directions ([044c0a9](https://github.com/morluto/rea/commit/044c0a92d5c86faa9965669680935d4f552977b1))
* reuse identical JavaScript analysis fixtures ([42951bb](https://github.com/morluto/rea/commit/42951bbbbe8d98ebde1783450d130fdddb5ba889))
* reuse validators for identical MCP schemas ([85ccef3](https://github.com/morluto/rea/commit/85ccef34de1cddcf71037ccea93acdf709f94965))
* share native keyed archive integer fixture generation ([847a1a3](https://github.com/morluto/rea/commit/847a1a3f25f69e1b3808fb0a53e5f1415a7ed058))
* **sourcemap:** consolidate source map regressions ([#2034](https://github.com/morluto/rea/issues/2034)) ([83a4445](https://github.com/morluto/rea/commit/83a4445f440fab978ad9376b4260acae3f5fb189))
* **sourcemaps:** assert semantic evidence and actual output admission ([bd74c7a](https://github.com/morluto/rea/commit/bd74c7af2bec952c2eea7a17feedd05e6675b719))
* strengthen archive and resource cleanup assertions ([5ba1642](https://github.com/morluto/rea/commit/5ba1642a7d891a60493b2195e55149b0f3197fb4))
* strengthen public input and module lookup controls ([b7f563e](https://github.com/morluto/rea/commit/b7f563e15f854061dc1507896181b7b24ac3b5f0))
* **web:** remove duplicate path schema cases ([b5b7301](https://github.com/morluto/rea/commit/b5b73019f946e5edc0df506c9f8f4bb28afa6590))
### Continuous Integration
* balance test shards and reuse package download caches ([40b37ae](https://github.com/morluto/rea/commit/40b37aeee10e89d85ee8b7747cbdcd3ad1a34db4))
* bound core job execution time ([9f3f392](https://github.com/morluto/rea/commit/9f3f392eb8fe81060b44941ed754521984def3bd))
* finish main validation and balance complete test costs ([f0f0995](https://github.com/morluto/rea/commit/f0f09950f34d96e3b1c62fd04821af5e301f80c7))
* fix PR metadata triggers and defer expensive runner work ([3af8f84](https://github.com/morluto/rea/commit/3af8f847f0e35843f51622a136165dd6ee12cdd0))
* keep validation fresh and preserve failed-shard reports ([#2210](https://github.com/morluto/rea/issues/2210)) ([428eee0](https://github.com/morluto/rea/commit/428eee00fdd0406cdf2b734c9ea14a2bb02da279))
* narrow native-host lane selection and reuse build caches ([#2131](https://github.com/morluto/rea/issues/2131)) ([4f46eac](https://github.com/morluto/rea/commit/4f46eac453965efde3bc782aebfeec198551afc4))
* observe affected-owner test plans against full results ([fa0a9b2](https://github.com/morluto/rea/commit/fa0a9b2d328a07d8ec2924dc224d51c620981dcc))
* parallelize native verification and share test artifacts ([91f6840](https://github.com/morluto/rea/commit/91f6840c35e99e5f23612920d790a30d324ac115))
* raise duplication threshold to 3.5 percent ([11f1d95](https://github.com/morluto/rea/commit/11f1d952e79241b507e47c690eed00e0d5ffda30))
* reduce macOS runner contention ([3f4ea32](https://github.com/morluto/rea/commit/3f4ea3218929baba0c30c2cb7eb7e7a829a1fc21))
* reduce redundant compatibility and qualification work ([d13c9f8](https://github.com/morluto/rea/commit/d13c9f840d36a321a56917e75c48afcb8d72920e))
* reduce reporter fan-out and preserve shard history ([ab02f3c](https://github.com/morluto/rea/commit/ab02f3c8d665ead35391c94db74cd0fb0cfed0ed))
* reuse current-run builds across provider lanes ([490a285](https://github.com/morluto/rea/commit/490a285070268aee0ecd50001b9a9a83c1e313f0))
* reuse portable build cache in native package jobs ([8fa1175](https://github.com/morluto/rea/commit/8fa11751eaab94eaa5b8de8c1ecd143ef0066cd6))
* reuse portable Turbo outputs across verification runs ([a00c34d](https://github.com/morluto/rea/commit/a00c34dcb155e5993bda8ad0f51bf3b13e289760))
* scope timing helpers to portable verification ([c8d861f](https://github.com/morluto/rea/commit/c8d861f9764f8c07f0925dae0c6451de85fffd6a))
* separate fast feedback from full qualification ([0c7f4df](https://github.com/morluto/rea/commit/0c7f4dfbd9c1975eeedfecd96debb2e4263012d0))
* summarize static checks and retain complete reports ([#2242](https://github.com/morluto/rea/issues/2242)) ([d96550f](https://github.com/morluto/rea/commit/d96550f2f61647ffa150597de73363fa90c4e169))
---
This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
+76
View File
@@ -0,0 +1,76 @@
import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs";
const repository = process.env.CI_REPOSITORY;
const branch = process.env.CI_DEFAULT_BRANCH;
const head = process.env.HEAD_SHA;
const shaPattern = /^[0-9a-f]{40}$/i;
function ancestor(base) {
try {
execFileSync("git", ["merge-base", "--is-ancestor", base, head], {
stdio: "ignore",
});
return true;
} catch {
return false;
}
}
function write(base, reason) {
const output = { base, reason };
if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
`base=${base}\nreason=${reason.replace(/[\r\n]/g, " ")}\n`,
);
}
console.log(JSON.stringify(output));
}
if (!repository || !branch || !head || !shaPattern.test(head)) {
write("", "Repository, default branch, or checked-out head is invalid");
} else {
try {
const response = execFileSync(
"gh",
[
"api",
"--method",
"GET",
`repos/${repository}/actions/workflows/ci.yml/runs`,
"-f",
`branch=${branch}`,
"-f",
"event=push",
"-f",
"status=success",
"-F",
"per_page=100",
],
{ encoding: "utf8", timeout: 15_000, maxBuffer: 4 * 1024 * 1024 },
);
const runs = JSON.parse(response).workflow_runs;
if (!Array.isArray(runs))
throw new Error("GitHub returned no workflow run list");
const base = runs.find(
(run) =>
run?.event === "push" &&
run?.conclusion === "success" &&
run?.head_branch === branch &&
run?.head_repository?.full_name === repository &&
typeof run?.head_sha === "string" &&
shaPattern.test(run.head_sha) &&
run.head_sha.toLowerCase() !== head.toLowerCase() &&
ancestor(run.head_sha),
)?.head_sha;
if (base) write(base, "");
else
write(
"",
"No trusted successful CI push baseline is an ancestor of this head",
);
} catch {
write("", "Could not read a successful CI push baseline from GitHub");
}
}
+268
View File
@@ -0,0 +1,268 @@
import { execFile } from "node:child_process";
import { appendFile, mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { collectJobTimings } from "./job-timings.mjs";
const execFileAsync = promisify(execFile);
const OUTPUT_DIRECTORY = join(".cache", "ci-job-timings");
const MAX_BUFFER = 16 * 1024 * 1024;
const API_TIMEOUT_MS = 30_000;
const WORKFLOWS = [
{ id: "ci.yml", name: "CI" },
{ id: "release.yml", name: "Release" },
];
const LOOKBACK_MS = 24 * 60 * 60 * 1_000;
const MAX_RUNS_PER_WORKFLOW = 100;
const TIME_BUDGET_MS = 240_000;
/** Collect a bounded recent sample of completed CI and Release attempts. */
export async function collectJobTimingsBatch({
repository,
outputDirectory = OUTPUT_DIRECTORY,
observedAt = new Date().toISOString(),
env = process.env,
now = Date.now,
collect = collectJobTimings,
} = {}) {
const identity = validateRepository(repository);
const started = now();
const cutoff = new Date(started - LOOKBACK_MS).toISOString();
const manifest = {
schemaVersion: 1,
observedAt,
repository: identity,
selection: {
workflows: WORKFLOWS,
status: "completed",
createdAfter: cutoff,
lookbackHours: 24,
overlap: "Each hourly run re-reads the preceding 24 hours.",
pagination:
"Run listings use the first 100; jobs are paginated within the remaining time and 16 MiB response limits.",
maxRunsPerWorkflow: MAX_RUNS_PER_WORKFLOW,
timeBudgetMs: TIME_BUDGET_MS,
exhaustive: false,
},
coverage: {
workflowsQueried: [],
runsListed: 0,
selectedRuns: 0,
collectedRuns: 0,
},
truncation: [],
errors: [],
runs: [],
elapsedMs: 0,
};
const budgetExpired = () => now() - started >= TIME_BUDGET_MS;
const selected = [];
for (const workflow of WORKFLOWS) {
if (budgetExpired()) {
manifest.truncation.push("Time budget reached while listing workflows.");
break;
}
try {
const response = await apiJson(
`repos/${identity}/actions/workflows/${workflow.id}/runs?status=completed&created=%3E%3D${encodeURIComponent(cutoff)}&per_page=${String(MAX_RUNS_PER_WORKFLOW)}&page=1`,
env,
started,
now,
);
if (!Array.isArray(response.workflow_runs))
throw new Error("run listing must contain a workflow_runs array");
manifest.coverage.workflowsQueried.push(workflow.name);
manifest.coverage.runsListed += response.workflow_runs.length;
if (response.workflow_runs.length >= MAX_RUNS_PER_WORKFLOW)
manifest.truncation.push(
`${workflow.name} run listing reached the 100-run first-page limit.`,
);
for (const run of response.workflow_runs.slice(
0,
MAX_RUNS_PER_WORKFLOW,
)) {
if (
run.status !== "completed" ||
!Number.isSafeInteger(run.id) ||
!Number.isSafeInteger(run.run_attempt)
) {
manifest.errors.push({
scope: `workflow:${workflow.name}`,
runId: run.id ?? null,
message:
"Run record missing completed status, numeric id, or numeric latest attempt.",
});
continue;
}
if (run.created_at && Date.parse(run.created_at) < Date.parse(cutoff))
continue;
selected.push({
id: run.id,
runAttempt: run.run_attempt,
workflow: workflow.name,
conclusion: run.conclusion ?? null,
createdAt: run.created_at ?? null,
});
}
} catch (cause) {
manifest.errors.push({
scope: `workflow:${workflow.name}`,
message: errorMessage(cause),
});
}
}
selected.sort(
(left, right) =>
(Date.parse(right.createdAt ?? "") || 0) -
(Date.parse(left.createdAt ?? "") || 0),
);
manifest.coverage.selectedRuns = selected.length;
for (const run of selected) {
if (budgetExpired()) {
manifest.truncation.push(
"Time budget reached before all selected runs were collected.",
);
break;
}
const runRecord = {
...run,
status: "pending",
rawPath: null,
reportPath: null,
error: null,
};
manifest.runs.push(runRecord);
const runDirectory = join(
outputDirectory,
"runs",
`${String(run.id)}-${String(run.runAttempt)}`,
);
try {
const result = await collect({
repository: identity,
runId: String(run.id),
runAttempt: String(run.runAttempt),
outputDirectory: runDirectory,
env,
timeoutMs: Math.max(
1,
Math.min(API_TIMEOUT_MS, TIME_BUDGET_MS - (now() - started)),
),
});
runRecord.status = "collected";
runRecord.rawPath = result.rawPath;
runRecord.reportPath = result.reportPath;
manifest.coverage.collectedRuns += 1;
} catch (cause) {
runRecord.status = "error";
runRecord.error = errorMessage(cause);
manifest.errors.push({
scope: "run",
runId: run.id,
runAttempt: run.runAttempt,
message: runRecord.error,
});
}
}
if (budgetExpired() && manifest.runs.length < manifest.coverage.selectedRuns)
manifest.truncation.push(
"Time budget reached; selected runs remain unattempted.",
);
return await writeManifest(outputDirectory, manifest, started, now, env);
}
function validateRepository(repository) {
if (
typeof repository !== "string" ||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
)
throw new Error(
"CI_REPOSITORY must be an owner/name repository identifier",
);
return repository;
}
async function apiJson(endpoint, env, started, now) {
const timeout = Math.max(
1,
Math.min(API_TIMEOUT_MS, TIME_BUDGET_MS - (now() - started)),
);
const { stdout } = await execFileAsync("gh", ["api", endpoint], {
env,
encoding: "utf8",
maxBuffer: MAX_BUFFER,
timeout,
});
try {
return JSON.parse(stdout);
} catch (cause) {
throw new Error("GitHub Actions API returned malformed JSON", { cause });
}
}
async function writeManifest(outputDirectory, manifest, started, now, env) {
manifest.elapsedMs = Math.max(0, now() - started);
if (
manifest.elapsedMs >= TIME_BUDGET_MS &&
!manifest.truncation.some((item) => item.includes("Time budget"))
)
manifest.truncation.push("Time budget reached.");
await mkdir(outputDirectory, { recursive: true });
const manifestPath = join(outputDirectory, "batch-manifest.json");
await writeFile(
manifestPath,
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
if (env.GITHUB_STEP_SUMMARY)
await appendBatchSummary(env.GITHUB_STEP_SUMMARY, manifest, manifestPath);
return { manifestPath, manifest };
}
async function appendBatchSummary(summaryPath, manifest, manifestPath) {
const collected = manifest.runs.filter(
({ status }) => status === "collected",
).length;
const failed = manifest.runs.filter(
({ status }) => status === "error",
).length;
const lines = [
"### Batched CI timing snapshot",
"",
`Observed ${manifest.observedAt}; collected ${String(collected)} of ${String(manifest.coverage.selectedRuns)} selected recent completed runs (${String(failed)} run errors).`,
`Coverage uses the first ${String(MAX_RUNS_PER_WORKFLOW)} runs per workflow created in the preceding 24 hours and a ${String(TIME_BUDGET_MS / 1_000)} second total budget. This sample is bounded and is not exhaustive.`,
`Manifest: \`${manifestPath}\`.`,
...manifest.truncation.map((message) => `- Truncated: ${message}`),
...manifest.errors.map(
({ scope, message }) => `- Error (${scope}): ${message}`,
),
"",
];
await appendFile(summaryPath, lines.join("\n"), "utf8");
}
function errorMessage(cause) {
return cause instanceof Error ? cause.message : String(cause);
}
if (
process.argv[1] !== undefined &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
const result = await collectJobTimingsBatch({
repository: process.env.CI_REPOSITORY,
outputDirectory: process.env.CI_JOB_TIMINGS_DIRECTORY ?? OUTPUT_DIRECTORY,
});
process.stdout.write(
`Wrote batch manifest for ${String(result.manifest.coverage.collectedRuns)} collected runs to ${result.manifestPath}\n`,
);
} catch (cause) {
process.stderr.write(`CI timing batch failed: ${errorMessage(cause)}\n`);
process.exitCode = 1;
}
}
+354
View File
@@ -0,0 +1,354 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
const execFileAsync = promisify(execFile);
const MAX_BUFFER = 16 * 1024 * 1024;
const TIMEOUT_MS = 30_000;
const OUTPUT_DIRECTORY = join(".cache", "ci-job-timings");
const STEP_COSTS = [
{ key: "npmCi", pattern: /\bnpm ci\b/iu },
{ key: "artifactUpload", pattern: /upload.*artifact/iu },
{ key: "artifactDownload", pattern: /download.*artifact/iu },
];
/** Fetch and summarize one workflow attempt without turning telemetry into a gate. */
export async function collectJobTimings({
repository,
runId,
runAttempt,
outputDirectory = OUTPUT_DIRECTORY,
summaryPath,
observedAt = new Date().toISOString(),
env = process.env,
timeoutMs = TIMEOUT_MS,
}) {
const identity = validateIdentity({ repository, runId, runAttempt });
const endpoint = `repos/${identity.repository}/actions/runs/${identity.runId}/attempts/${identity.runAttempt}/jobs?per_page=100`;
const { stdout } = await execFileAsync(
"gh",
["api", "--paginate", "--slurp", endpoint],
{ env, encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: timeoutMs },
);
const rawPath = join(outputDirectory, "jobs.json");
const reportPath = join(outputDirectory, "report.json");
await mkdir(outputDirectory, { recursive: true });
await writeFile(rawPath, stdout, "utf8");
const pages = parsePages(stdout);
const jobs = pages.flatMap(({ jobs: pageJobs }, index) =>
validateJobs(pageJobs, index),
);
const report = normalizeReport({ identity, jobs, observedAt });
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
if (summaryPath !== undefined)
await appendSummary(summaryPath, report, rawPath, reportPath);
return { rawPath, reportPath, report };
}
function validateIdentity({ repository, runId, runAttempt }) {
if (
typeof repository !== "string" ||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
)
throw new Error(
"CI_REPOSITORY must be an owner/name repository identifier",
);
return {
repository,
runId: parsePositiveInteger(runId, "CI_TIMING_RUN_ID"),
runAttempt: parsePositiveInteger(runAttempt, "CI_TIMING_RUN_ATTEMPT"),
};
}
function parsePositiveInteger(value, name) {
if (typeof value === "string" && /^[1-9]\d*$/u.test(value)) {
const parsed = Number(value);
if (Number.isSafeInteger(parsed)) return parsed;
}
throw new Error(`${name} must be a positive safe integer`);
}
function parsePages(stdout) {
let pages;
try {
pages = JSON.parse(stdout);
} catch (cause) {
throw new Error("GitHub jobs API returned malformed JSON", { cause });
}
if (!Array.isArray(pages) || pages.length === 0)
throw new Error("GitHub jobs API slurp must contain a nonempty page array");
for (const page of pages)
if (
typeof page !== "object" ||
page === null ||
Array.isArray(page) ||
!Array.isArray(page.jobs)
)
throw new Error("GitHub jobs API page must contain a jobs array");
return pages;
}
function validateJobs(jobs, pageIndex) {
return jobs.map((job, jobIndex) => {
if (
typeof job !== "object" ||
job === null ||
Array.isArray(job) ||
typeof job.name !== "string" ||
job.name.length === 0 ||
typeof job.status !== "string"
)
throw new Error(
`GitHub jobs API page ${String(pageIndex + 1)} job ${String(jobIndex + 1)} is malformed`,
);
return job;
});
}
function normalizeReport({ identity, jobs, observedAt }) {
const normalizedJobs = jobs.map(normalizeJob);
const queues = normalizedJobs
.map(({ queue }) => queue.seconds)
.filter((value) => value !== null);
const completedExecutions = normalizedJobs
.filter(
({ status, conclusion }) =>
status === "completed" && conclusion !== "skipped",
)
.map(({ execution }) => execution.seconds)
.filter((value) => value !== null);
const completedDurationSumSeconds = completedExecutions.reduce(
(sum, seconds) => sum + seconds,
0,
);
const completedExecutionUnknown = normalizedJobs.filter(
({ status, conclusion, execution }) =>
status === "completed" &&
conclusion !== "skipped" &&
execution.seconds === null,
).length;
const skipped = normalizedJobs.filter(
({ conclusion }) => conclusion === "skipped",
).length;
const costs = Object.fromEntries(STEP_COSTS.map(({ key }) => [key, []]));
for (const job of normalizedJobs)
for (const step of job.steps)
if (step.costKey !== null)
costs[step.costKey].push({ job: job.name, ...step });
return {
schemaVersion: 1,
run: {
...identity,
observedAt,
snapshot:
normalizedJobs.length > 0 &&
normalizedJobs.every(({ status }) => status === "completed")
? "completed job status snapshot; unknown timestamps remain unmeasured"
: "partial; job statuses may still be queued or in progress",
},
counts: {
jobs: normalizedJobs.length,
completed: normalizedJobs.filter(({ status }) => status === "completed")
.length,
skipped,
queueUnknown: normalizedJobs.filter(({ queue }) => queue.seconds === null)
.length,
executionUnknown: normalizedJobs.filter(
({ execution }) => execution.seconds === null,
).length,
completedExecutionUnknown,
},
aggregates: {
queueSeconds: percentileSummary(queues),
completedExecutionSeconds: percentileSummary(completedExecutions),
summedCompletedJobElapsedSeconds: {
value:
completedExecutions.length === 0
? null
: round(completedDurationSumSeconds),
knownCount: completedExecutions.length,
unknownCount: completedExecutionUnknown,
label:
"sum of known completed job elapsed durations; incomplete when timestamps are unknown; not CPU time, billing, or workflow wall time",
},
},
jobs: normalizedJobs,
stepCostInterpretation:
"Matched install/artifact steps by step name; these are not total artifact transfer costs.",
stepCosts: costs,
};
}
function normalizeJob(job) {
const skipped = job.conclusion === "skipped";
const hasStarted = ["in_progress", "completed"].includes(job.status);
const queue = skipped
? unknownDuration("job was skipped")
: !hasStarted
? unknownDuration("job start is not established by its status")
: duration(job.created_at, job.started_at);
const execution = skipped
? unknownDuration("job was skipped")
: job.status === "completed"
? duration(job.started_at, job.completed_at)
: unknownDuration("job is not completed");
const steps = Array.isArray(job.steps) ? job.steps.map(normalizeStep) : [];
return {
id: Number.isSafeInteger(job.id) && job.id > 0 ? job.id : null,
url: stringOrNull(job.html_url),
name: job.name,
workflowName: stringOrNull(job.workflow_name),
status: job.status,
conclusion: stringOrNull(job.conclusion),
createdAt: stringOrNull(job.created_at),
startedAt: stringOrNull(job.started_at),
completedAt: stringOrNull(job.completed_at),
queue,
execution,
steps,
};
}
function normalizeStep(step) {
if (
typeof step !== "object" ||
step === null ||
Array.isArray(step) ||
typeof step.name !== "string"
)
throw new Error("GitHub jobs API contains a malformed job step");
const cost = STEP_COSTS.find(({ pattern }) => pattern.test(step.name));
return {
name: step.name,
costKey: cost?.key ?? null,
duration: duration(step.started_at, step.completed_at),
};
}
function duration(startValue, endValue) {
if (typeof startValue !== "string" || typeof endValue !== "string")
return unknownDuration("timestamp unavailable");
const start = Date.parse(startValue);
const end = Date.parse(endValue);
if (!Number.isFinite(start) || !Number.isFinite(end))
return unknownDuration("invalid timestamp");
const milliseconds = end - start;
if (!Number.isFinite(milliseconds) || milliseconds < 0)
return unknownDuration("timestamps out of order");
return { seconds: round(milliseconds / 1_000), reason: null };
}
function unknownDuration(reason) {
return { seconds: null, reason };
}
function percentileSummary(values) {
return {
count: values.length,
p50: nearestRank(values, 0.5),
p95: nearestRank(values, 0.95),
unit: "seconds",
};
}
function nearestRank(values, percentile) {
if (values.length === 0) return null;
const sorted = [...values].sort((left, right) => left - right);
return round(sorted[Math.ceil(percentile * sorted.length) - 1]);
}
function round(value) {
return Math.round(value * 1_000) / 1_000;
}
function stringOrNull(value) {
return typeof value === "string" ? value : null;
}
async function appendSummary(summaryPath, report, rawPath, reportPath) {
await mkdir(dirname(summaryPath), { recursive: true });
const rows = [
["Queue", report.aggregates.queueSeconds],
["Completed execution", report.aggregates.completedExecutionSeconds],
]
.map(
([label, item]) =>
`| ${label} | ${display(item.p50)} | ${display(item.p95)} | ${String(item.count)} |`,
)
.join("\n");
const jobRows = report.jobs
.map(
(job) =>
`| ${escapeCell(job.name)} | ${job.status} | ${job.conclusion ?? "unknown"} | ${display(job.queue.seconds)} | ${job.queue.seconds === null ? job.queue.reason : ""} | ${display(job.execution.seconds)} | ${job.execution.seconds === null ? job.execution.reason : ""} |`,
)
.join("\n");
const costRows = STEP_COSTS.map(({ key }) => {
const samples = report.stepCosts[key];
const values = samples
.map(({ duration: item }) => item.seconds)
.filter((value) => value !== null);
return `| ${key} | ${String(samples.length)} | ${display(nearestRank(values, 0.5))} | ${samples.filter(({ duration: item }) => item.seconds === null).length} |`;
}).join("\n");
const text = [
"### CI job timing snapshot",
"",
`Observed at ${report.run.observedAt} for run ${String(report.run.runId)}, attempt ${String(report.run.runAttempt)}. This is a point-in-time snapshot of fetched jobs. Queue starts at job creation and excludes preceding dependency waiting. Missing, queued, in-progress, invalid, or out-of-order timestamps are reported as unknown. Elapsed durations are not CPU time or billing data.`,
"",
`Fetched ${String(report.counts.jobs)} jobs; ${String(report.counts.completed)} are completed (${String(report.counts.skipped)} skipped); ${String(report.counts.queueUnknown)} queue durations and ${String(report.counts.executionUnknown)} execution durations (${String(report.counts.completedExecutionUnknown)} for non-skipped completed jobs) are unknown.`,
"",
"| Metric | p50 (s) | p95 (s) | Samples |",
"| --- | ---: | ---: | ---: |",
rows,
"",
"| Job | Status | Conclusion | Queue (s) | Queue detail | Execution (s) | Execution detail |",
"| --- | --- | --- | ---: | --- | ---: | --- |",
jobRows,
"",
`Sum of known completed job elapsed durations: **${display(report.aggregates.summedCompletedJobElapsedSeconds.value)} s** across ${String(report.aggregates.summedCompletedJobElapsedSeconds.knownCount)} known and ${String(report.aggregates.summedCompletedJobElapsedSeconds.unknownCount)} unknown jobs; this is not CPU time, billing, or workflow wall time.`,
"",
"Matched install/artifact steps by name; these are not total artifact transfer costs.",
"",
"| Matched step group | Samples | p50 (s) | Unknown |",
"| --- | ---: | ---: | ---: |",
costRows,
"",
`Reports: \`${rawPath}\`, \`${reportPath}\`.`,
"",
].join("\n");
await writeFile(summaryPath, text, { encoding: "utf8", flag: "a" });
}
function display(value) {
return value === null ? "unknown" : String(value);
}
function escapeCell(value) {
return value.replaceAll("|", "\\|").replaceAll("\n", " ");
}
if (
process.argv[1] !== undefined &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
const result = await collectJobTimings({
repository: process.env.CI_REPOSITORY,
runId: process.env.CI_TIMING_RUN_ID,
runAttempt: process.env.CI_TIMING_RUN_ATTEMPT,
outputDirectory: process.env.CI_JOB_TIMINGS_DIRECTORY ?? OUTPUT_DIRECTORY,
summaryPath: process.env.GITHUB_STEP_SUMMARY,
});
process.stdout.write(
`Wrote ${result.report.jobs.length} job timing records to ${result.reportPath}\n`,
);
} catch (cause) {
process.stderr.write(
`CI timing telemetry failed: ${cause instanceof Error ? cause.message : String(cause)}\n`,
);
process.exitCode = 1;
}
}
+148 -103
View File
@@ -1,30 +1,20 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs"; import { appendFileSync } from "node:fs";
import { matchesGlob } from "node:path"; import { matchesGlob } from "node:path";
import { MANUAL_VERIFIERS, SCOPES } from "./scopes.mjs"; import { CI_FLAGS as flags, MANUAL_VERIFIERS, SCOPES } from "./scopes.mjs";
import { planTestSelection } from "./test-selection.mjs";
const flags = [ // Test files select the host/runtime lanes that execute their focused suites.
...new Set([ const HOST_TEST_OWNERS = ["apple", "package", "sqlite"];
"code",
"runtime",
"package",
"apple",
"fixtures",
"readiness",
"docs",
"workflows",
...Object.keys(SCOPES),
]),
];
// Test files select the native-host lanes that run them explicitly. Provider
// scopes remain implementation-owned, so test-only edits stay off those lanes.
const NATIVE_TEST_OWNERS = ["apple", "package"];
// Actionlint validates workflow syntax only. The semantic suite asserts these // Actionlint validates workflow syntax only. The semantic suite asserts these
// workflows' release, publication and self-hosted runner safeguards. // workflows' release, publication and self-hosted runner safeguards.
const GUARDED_WORKFLOWS = new Set([ const GUARDED_WORKFLOWS = new Set([
".github/workflows/pr-title.yml",
".github/workflows/ci-full-pr.yml",
".github/workflows/release.yml", ".github/workflows/release.yml",
".github/workflows/real-ghidra-windows.yml", ".github/workflows/real-ghidra-windows.yml",
".github/workflows/sqlite-runtime.yml", ".github/workflows/sqlite-runtime.yml",
".github/workflows/ci-timings.yml",
]); ]);
// Sources compiled by build-conformance-fixtures.mjs. The readiness journey // Sources compiled by build-conformance-fixtures.mjs. The readiness journey
// analyzes the built C fixture as well as its own applications. // analyzes the built C fixture as well as its own applications.
@@ -36,6 +26,15 @@ const FIXTURE_SOURCES = [
"tests/conformance/versions/**", "tests/conformance/versions/**",
]; ];
const READINESS_SOURCES = ["tests/conformance/readiness/**"]; const READINESS_SOURCES = ["tests/conformance/readiness/**"];
// These reporters affect CI output only; they do not select a host or provider
// verification lane when changed on their own.
const PORTABLE_CI_REPORTERS = new Set([
"scripts/ci/job-timings.mjs",
"scripts/ci/job-timings-batch.mjs",
"scripts/ci/test-report.mjs",
"scripts/ci/test-timing-reporter.mjs",
"scripts/ci/test-history.mjs",
]);
const matchesAny = (file, patterns) => const matchesAny = (file, patterns) =>
patterns.some((pattern) => matchesGlob(file, pattern)); patterns.some((pattern) => matchesGlob(file, pattern));
const selected = Object.fromEntries(flags.map((flag) => [flag, false])); const selected = Object.fromEntries(flags.map((flag) => [flag, false]));
@@ -63,6 +62,10 @@ function classifyPackage(base, head) {
]), ]),
].filter((key) => beforeScripts?.[key] !== afterScripts?.[key]); ].filter((key) => beforeScripts?.[key] !== afterScripts?.[key]);
for (const script of changed) { for (const script of changed) {
if (script === "test:sqlite" || script === "test:sqlite:run") {
selected.sqlite = true;
continue;
}
if ( if (
/^(test(?::|$)|check(?::|$)|lint(?::|$)|format(?::|$)|knip$|jscpd$)/.test( /^(test(?::|$)|check(?::|$)|lint(?::|$)|format(?::|$)|knip$|jscpd$)/.test(
script, script,
@@ -108,98 +111,134 @@ function classifyPackage(base, head) {
} }
const event = process.env.GITHUB_EVENT_NAME; const event = process.env.GITHUB_EVENT_NAME;
let files = []; if (!["pull_request", "push", "schedule", "workflow_dispatch"].includes(event))
if ( throw new Error(`Unsupported CI event: ${event}`);
event === "push" || const requestedFull =
event === "schedule" || event === "schedule" ||
event === "workflow_dispatch" || event === "workflow_dispatch" ||
process.env.CI_FULL === "true" process.env.CI_FULL === "true";
) { const tier =
event === "schedule" &&
process.env.CI_SKIP_FULL === "true" &&
process.env.CI_FULL !== "true"
? "reused"
: requestedFull
? "full"
: "fast";
let files = [];
if (tier === "reused") {
reasons.push(
"Recent exact-SHA full qualification reused; no fresh provider observation",
);
} else if (tier === "full") {
full("Full baseline requested by event or ci:full label"); full("Full baseline requested by event or ci:full label");
} else if (event === "pull_request") { } else if (event === "pull_request" || event === "push") {
const base = git("merge-base", process.env.BASE_SHA, process.env.HEAD_SHA);
const head = process.env.HEAD_SHA; const head = process.env.HEAD_SHA;
// Disable rename detection so both the removed and added paths participate. let base;
files = git("diff", "--name-only", "--no-renames", "-z", base, head) if (event === "pull_request") {
.split("\0") base = git("merge-base", process.env.BASE_SHA, head);
.filter(Boolean); } else {
for (const file of files) { const candidate = process.env.BASE_SHA;
if ( const baselineReason = process.env.BASELINE_REASON;
/^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test( try {
file, if (!candidate || !/^[0-9a-f]{40}$/i.test(candidate))
) || throw new Error(
file.startsWith("docs/") || baselineReason || "No valid successful push baseline was found",
file.startsWith(".github/ISSUE_TEMPLATE/") );
) { execFileSync("git", ["merge-base", "--is-ancestor", candidate, head], {
selected.docs = true; stdio: "ignore",
selected.website ||= matchesAny(file, SCOPES.website); });
continue; base = candidate;
} } catch {
if (/^(src|tests)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) { full(
selected.code = true; baselineReason ||
for (const owner of NATIVE_TEST_OWNERS) "No valid successful push baseline ancestor; selecting all lanes",
selected[owner] ||= matchesAny(file, SCOPES[owner]); );
continue;
}
if (matchesAny(file, MANUAL_VERIFIERS)) {
selected.code = true;
continue;
}
const owners = Object.entries(SCOPES)
.filter(([, patterns]) =>
patterns.some((pattern) => matchesGlob(file, pattern)),
)
.map(([owner]) => owner);
for (const owner of owners) selected[owner] = true;
if (
file.startsWith(".github/workflows/") ||
file === ".github/actionlint.yaml"
) {
selected.workflows = true;
if (GUARDED_WORKFLOWS.has(file)) selected.code = true;
if (file !== ".github/workflows/ci.yml") continue;
}
if (owners.length === 1 && owners[0] === "website") continue;
if (/^src\/(contracts|domain)\//.test(file)) selected.docs = true;
if (file === "package.json") {
classifyPackage(base, head);
continue;
}
selected.code = true;
if (matchesAny(file, FIXTURE_SOURCES)) {
selected.fixtures = true;
selected.readiness = true;
}
if (matchesAny(file, READINESS_SOURCES)) selected.readiness = true;
if (file.startsWith("tests/")) continue;
if (
/^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test(
file,
)
) {
full(`Shared boundary: ${file}`);
} else if (owners.length) {
if (file.startsWith("src/") || file.startsWith("bridge/"))
selected.runtime = true;
} else if (/^(src|bridge)\//.test(file)) {
// Unowned runtime sources keep the Linux x64 installed-package baseline;
// ci:full, main pushes and manual dispatch retain every native host.
selected.runtime = true;
reasons.push(`Unowned runtime source: ${file}`);
} else if (file.startsWith(".agents/skills/")) {
selected.runtime = true;
selected.docs = true;
} else {
full(`Unclassified input: ${file}`);
} }
} }
} else throw new Error(`Unsupported CI event: ${event}`); if (base) {
// Disable rename detection so both the removed and added paths participate.
files = git("diff", "--name-only", "--no-renames", "-z", base, head)
.split("\0")
.filter(Boolean);
for (const file of files) {
if (
/^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test(
file,
) ||
file.startsWith("docs/") ||
file.startsWith(".github/ISSUE_TEMPLATE/")
) {
selected.docs = true;
selected.website ||= matchesAny(file, SCOPES.website);
continue;
}
if (/^(src|tests)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) {
selected.code = true;
for (const owner of HOST_TEST_OWNERS)
selected[owner] ||= matchesAny(file, SCOPES[owner]);
continue;
}
if (matchesAny(file, MANUAL_VERIFIERS)) {
selected.code = true;
continue;
}
const owners = Object.entries(SCOPES)
.filter(([, patterns]) =>
patterns.some((pattern) => matchesGlob(file, pattern)),
)
.map(([owner]) => owner);
for (const owner of owners) selected[owner] = true;
if (
file.startsWith(".github/workflows/") ||
file === ".github/actionlint.yaml"
) {
selected.workflows = true;
if (GUARDED_WORKFLOWS.has(file)) selected.code = true;
if (file !== ".github/workflows/ci.yml") continue;
}
if (owners.length === 1 && owners[0] === "website") continue;
if (/^src\/(contracts|domain)\//.test(file)) selected.docs = true;
if (file === "package.json") {
classifyPackage(base, head);
continue;
}
selected.code = true;
if (matchesAny(file, FIXTURE_SOURCES)) {
selected.fixtures = true;
selected.readiness = true;
}
if (matchesAny(file, READINESS_SOURCES)) selected.readiness = true;
if (file.startsWith("tests/")) continue;
if (PORTABLE_CI_REPORTERS.has(file)) continue;
if (
/^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test(
file,
)
) {
full(`Shared boundary: ${file}`);
} else if (owners.length) {
if (file.startsWith("src/") || file.startsWith("bridge/"))
selected.runtime = true;
} else if (/^(src|bridge)\//.test(file)) {
// Unowned runtime sources keep the Linux x64 installed-package baseline;
// full qualification retains every native host.
selected.runtime = true;
reasons.push(`Unowned runtime source: ${file}`);
} else if (file.startsWith(".agents/skills/")) {
selected.runtime = true;
selected.docs = true;
} else {
full(`Unclassified input: ${file}`);
}
}
}
}
selected.runtime ||= selected.package; selected.runtime ||= selected.package;
const linux = { os: "ubuntu-latest", platform: "linux", architecture: "x64" }; const linux = { os: "ubuntu-latest", platform: "linux", architecture: "x64" };
// macOS x64 is the slowest package host. Pull requests run it only with ci:full; // macOS x64 is the slowest package host. Fast qualification omits it.
// main pushes, schedules and manual dispatch always include it.
const intel = const intel =
event !== "pull_request" || process.env.CI_FULL === "true" tier === "full"
? [{ os: "macos-15-intel", platform: "darwin", architecture: "x64" }] ? [{ os: "macos-15-intel", platform: "darwin", architecture: "x64" }]
: []; : [];
const matrix = { const matrix = {
@@ -212,17 +251,23 @@ const matrix = {
] ]
: [linux], : [linux],
}; };
const plan = { ...selected, matrix, files, reasons }; const testSelection = planTestSelection(files, event, tier);
const plan = { ...selected, tier, matrix, files, reasons, testSelection };
if (process.env.GITHUB_OUTPUT) if (process.env.GITHUB_OUTPUT)
appendFileSync( appendFileSync(
process.env.GITHUB_OUTPUT, process.env.GITHUB_OUTPUT,
Object.entries({ ...selected, matrix: JSON.stringify(matrix) }) Object.entries({
...selected,
tier,
matrix: JSON.stringify(matrix),
test_selection: JSON.stringify(testSelection),
})
.map(([key, value]) => `${key}=${value}\n`) .map(([key, value]) => `${key}=${value}\n`)
.join(""), .join(""),
); );
if (process.env.GITHUB_STEP_SUMMARY) if (process.env.GITHUB_STEP_SUMMARY)
appendFileSync( appendFileSync(
process.env.GITHUB_STEP_SUMMARY, process.env.GITHUB_STEP_SUMMARY,
`### Selected CI lanes\n\n${flags.filter((flag) => selected[flag]).join(", ") || "Formatting only"}\n\n${reasons.join("\n\n")}\n`, `### Selected CI lanes (${tier})\n\n${flags.filter((flag) => selected[flag]).join(", ") || "Formatting only"}\n\n${reasons.join("\n\n")}\n`,
); );
console.log(JSON.stringify(plan)); console.log(JSON.stringify(plan));
+176
View File
@@ -0,0 +1,176 @@
import { execFileSync } from "node:child_process";
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ARTIFACT_NAME = "ci-full-qualification-v1";
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
const positiveId = (value) =>
(typeof value === "string" || typeof value === "number") &&
/^[1-9]\d*$/.test(String(value)) &&
Number.isSafeInteger(Number(value));
const commitSha = (value) =>
typeof value === "string" && /^[0-9a-f]{40}$/.test(value);
/** Record only after the required gate has verified every full-tier obligation. */
export function recordQualification(directory) {
const sourceSha = process.env.GITHUB_SHA;
const runId = process.env.GITHUB_RUN_ID;
const runAttempt = process.env.GITHUB_RUN_ATTEMPT;
const repository = process.env.GITHUB_REPOSITORY;
if (
!commitSha(sourceSha) ||
!positiveId(runId) ||
!positiveId(runAttempt) ||
!repository
)
throw new Error(
"Full qualification receipt requires source and workflow identity",
);
const checkoutSha = execFileSync("git", ["rev-parse", "HEAD"], {
encoding: "utf8",
}).trim();
if (checkoutSha !== sourceSha)
throw new Error("Qualification checkout differs from workflow source");
mkdirSync(directory, { recursive: true });
const path = resolve(directory, "receipt.json");
writeFileSync(
path,
`${JSON.stringify(
{
schemaVersion: 1,
tier: "full",
sourceSha,
repository,
runId: Number(runId),
runAttempt: Number(runAttempt),
qualifiedAt: new Date().toISOString(),
},
null,
2,
)}\n`,
);
if (process.env.GITHUB_OUTPUT)
appendFileSync(
process.env.GITHUB_OUTPUT,
`receipt_name=${ARTIFACT_NAME}\nreceipt_path=${path}\n`,
);
}
function admit() {
if (process.env.CI_FORCE_FULL === "true")
return {
run_full: true,
reason: "Forced daily runner and dependency refresh",
};
const repository = process.env.CI_REPOSITORY;
const repositoryId = Number(process.env.CI_REPOSITORY_ID);
const branch = process.env.CI_DEFAULT_BRANCH;
const head = process.env.HEAD_SHA;
if (!repository || !positiveId(repositoryId) || !branch || !commitSha(head))
return {
run_full: true,
reason: "Qualification lookup identity is incomplete",
};
const now = Date.now();
const deadline = now + 20_000;
const api = (...arguments_) => {
const remaining = deadline - Date.now();
if (remaining <= 0)
throw new Error("Qualification lookup budget exhausted");
return JSON.parse(
execFileSync("gh", ["api", "--method", "GET", ...arguments_], {
encoding: "utf8",
timeout: Math.min(15_000, remaining),
maxBuffer: 8 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
}),
);
};
try {
const { artifacts } = api(
`repos/${repository}/actions/artifacts`,
"-f",
`name=${ARTIFACT_NAME}`,
"-F",
"per_page=100",
);
if (!Array.isArray(artifacts))
throw new Error("Missing qualification artifact inventory");
const candidates = artifacts.filter((artifact) => {
const lineage = artifact?.workflow_run;
const age = now - Date.parse(artifact?.created_at);
return (
artifact?.name === ARTIFACT_NAME &&
artifact?.expired === false &&
typeof artifact?.created_at === "string" &&
Number.isFinite(age) &&
age >= 0 &&
age < MAX_AGE_MS &&
positiveId(lineage?.id) &&
String(lineage.id) !== process.env.GITHUB_RUN_ID &&
lineage?.repository_id === repositoryId &&
lineage?.head_repository_id === repositoryId &&
lineage?.head_branch === branch &&
lineage?.head_sha === head
);
});
// Artifact metadata narrows the search before run lookups. Never scan an
// unbounded history or treat a missing receipt as a successful qualification.
for (const artifact of candidates.slice(0, 3)) {
const run = api(
`repos/${repository}/actions/runs/${artifact.workflow_run.id}`,
);
// An old run can upload a new artifact when only its gate is rerun.
// Bound the original run age as well so retries cannot renew stale proof.
const runAge = Date.now() - Date.parse(run.created_at);
if (
typeof run.created_at === "string" &&
Number.isFinite(runAge) &&
runAge >= 0 &&
runAge < MAX_AGE_MS &&
run.id === artifact.workflow_run.id &&
run.status === "completed" &&
run.conclusion === "success" &&
run.path === ".github/workflows/ci.yml" &&
["schedule", "workflow_dispatch"].includes(run.event) &&
run.head_sha === head &&
run.head_branch === branch &&
run.head_repository?.full_name === repository
) {
return {
run_full: false,
run_id: run.id,
reason: `Reusing full qualification from run ${run.id}; no fresh provider observation`,
};
}
}
return {
run_full: true,
reason: "No trusted full qualification for this SHA within 24 hours",
};
} catch {
return {
run_full: true,
reason: "Qualification lookup unavailable; running full validation",
};
}
}
if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
const decision = admit();
if (process.env.GITHUB_OUTPUT)
appendFileSync(
process.env.GITHUB_OUTPUT,
Object.entries(decision)
.map(([key, value]) => `${key}=${value}\n`)
.join(""),
);
if (process.env.GITHUB_STEP_SUMMARY)
appendFileSync(
process.env.GITHUB_STEP_SUMMARY,
`### Scheduled qualification\n\n${decision.reason}.\n`,
);
console.log(JSON.stringify(decision));
}
+40 -6
View File
@@ -1,18 +1,46 @@
import { CI_FLAGS } from "./scopes.mjs";
import { recordQualification } from "./qualification.mjs";
const jobs = JSON.parse(process.env.CI_JOBS); const jobs = JSON.parse(process.env.CI_JOBS);
const outputs = jobs.changes.outputs; const outputs = jobs?.changes?.outputs;
const required = ["changes", "static", "test"]; if (!["fast", "full", "reused"].includes(outputs?.tier))
throw new Error(
"CI classification requires a valid tier: fast, full, or reused",
);
const invalidFlags = CI_FLAGS.filter(
(flag) => outputs[flag] !== "true" && outputs[flag] !== "false",
);
if (invalidFlags.length)
throw new Error(
`CI classification requires explicit true/false scope outputs: ${invalidFlags.join(", ")}`,
);
const selectedScopes = CI_FLAGS.filter((flag) => outputs[flag] === "true");
const reused = outputs.tier === "reused";
if (
reused &&
(process.env.GITHUB_EVENT_NAME !== "schedule" ||
!/^[1-9]\d*$/.test(outputs.qualification_run_id ?? "") ||
!Number.isSafeInteger(Number(outputs.qualification_run_id)) ||
selectedScopes.length !== 0)
)
throw new Error(
"Reused qualification requires a scheduled admission receipt and no selected lanes",
);
if (outputs.tier === "full" && selectedScopes.length !== CI_FLAGS.length)
throw new Error("Full qualification requires every canonical CI scope");
const required = reused ? ["changes"] : ["changes", "static", "test"];
const lanes = { const lanes = {
code: ["build", "test-shard", "sqlite-runtime"], code: ["build", "test-shard"],
runtime: ["windows-native-build", "package-e2e"], runtime: ["windows-native-build", "package-e2e"],
package: ["windows-curated"], package: ["windows-curated"],
sqlite: ["sqlite-runtime"],
apple: ["apple-artifacts"], apple: ["apple-artifacts"],
fixtures: ["conformance-fixtures"], fixtures: ["conformance-fixtures"],
readiness: ["reconstruction-readiness"], readiness: ["reconstruction-readiness"],
docs: ["docs"], docs: ["docs"],
workflows: ["static"],
}; };
for (const [scope, value] of Object.entries(outputs)) { for (const scope of selectedScopes) required.push(...(lanes[scope] ?? [scope]));
if (value === "true") required.push(...(lanes[scope] ?? [scope]));
}
const failed = [...new Set(required)].filter( const failed = [...new Set(required)].filter(
(name) => jobs[name]?.result !== "success", (name) => jobs[name]?.result !== "success",
); );
@@ -23,3 +51,9 @@ for (const [name, job] of Object.entries(jobs)) {
if (failed.length) if (failed.length)
throw new Error(`CI lanes did not pass: ${failed.join(", ")}`); throw new Error(`CI lanes did not pass: ${failed.join(", ")}`);
console.log("Every selected CI lane passed"); console.log("Every selected CI lane passed");
if (reused)
console.log(
`Qualification reused from run ${outputs.qualification_run_id}; no fresh provider observation`,
);
if (outputs.tier === "full" && process.env.CI_QUALIFICATION_DIRECTORY)
recordQualification(process.env.CI_QUALIFICATION_DIRECTORY);
+64
View File
@@ -85,6 +85,55 @@ export const SCOPES = {
"tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts", "tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts",
"tests/boundary/process/sourceMapUncHosts.test.ts", "tests/boundary/process/sourceMapUncHosts.test.ts",
], ],
// Native SQLite runtime compatibility for the producer, public adapters,
// fixtures, shared lifecycle, and the package script that defines its suite.
sqlite: [
"src/sqlite/**",
"src/domain/sqlite/**",
"src/contracts/sqlite/**",
"src/application/sqlite/**",
"src/composition/sqlite.ts",
"src/cli/sqliteCommands.ts",
"src/server/registerSqliteTools.ts",
"src/application/DirectAnalysisStatus*.ts",
"src/application/CapabilityInventory*.ts",
"src/application/AnalysisProvider*.ts",
"src/contracts/toolContracts.ts",
"src/contracts/toolContractTypes.ts",
"src/contracts/toolEffects*.ts",
"src/contracts/toolOutputSchemaPrimitives.ts",
"src/domain/analysisError*.ts",
"src/domain/evidence*.ts",
"src/domain/inputIssueProjection*.ts",
"src/domain/jsonSerialization*.ts",
"src/domain/jsonValue*.ts",
"src/domain/localPath*.ts",
"src/domain/providerAdapterError*.ts",
"src/domain/providerCleanupError*.ts",
"src/domain/result*.ts",
"src/process/OwnedCommand*.ts",
"src/process/ProviderProcess*.ts",
"src/process/PrivateRuntimeRoot*.ts",
"src/artifacts/ArtifactReader*.ts",
"src/artifacts/ArtifactResourceScope*.ts",
"src/artifacts/ArtifactPaths*.ts",
"src/artifacts/readStableArtifact*.ts",
"src/filesystem/OwnedFileHandle*.ts",
"src/server/EvidenceMcpServer*.ts",
"src/server/analysisAdmission*.ts",
"src/server/contractToolHandler*.ts",
"src/server/toolRegistrationOptions*.ts",
"src/server/createServer*.ts",
"src/cli.ts",
"src/cliCommandNames.ts",
"src/cliLogging.ts",
"src/cli/commandCancellation.ts",
"tests/boundary/sqlite/**",
"tests/fixtures/sqlite/**",
"tests/acceptance/sqliteDatabaseCli.test.ts",
"tests/boundary/mcp/serverLifecycleMcp.test.ts",
".github/workflows/sqlite-runtime.yml",
],
browser: [ browser: [
".github/workflows/real-browser.yml", ".github/workflows/real-browser.yml",
"scripts/verify-real-browser.mjs", "scripts/verify-real-browser.mjs",
@@ -300,6 +349,7 @@ export const SCOPES = {
nativeaot: [ nativeaot: [
"bridge/nativeaot/**", "bridge/nativeaot/**",
"scripts/generate-nativeaot-fixtures.mjs", "scripts/generate-nativeaot-fixtures.mjs",
"scripts/lib/nativeaot-build.*",
"tests/fixtures/nativeaot/**", "tests/fixtures/nativeaot/**",
".github/workflows/nativeaot-fixtures.yml", ".github/workflows/nativeaot-fixtures.yml",
], ],
@@ -312,6 +362,20 @@ export const SCOPES = {
], ],
}; };
export const CI_FLAGS = [
...new Set([
"code",
"runtime",
"package",
"apple",
"fixtures",
"readiness",
"docs",
"workflows",
...Object.keys(SCOPES),
]),
];
// Verifier entrypoints that only manual, licensed, or self-hosted workflows // Verifier entrypoints that only manual, licensed, or self-hosted workflows
// run. No selected CI lane executes them, so their edits need only the static // run. No selected CI lane executes them, so their edits need only the static
// checks and the source-test suite. Shared helpers under scripts/lib remain // checks and the source-test suite. Shared helpers under scripts/lib remain
+114
View File
@@ -0,0 +1,114 @@
import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ARTIFACT_NAME = "test-timings";
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const MAX_CANDIDATES = 5;
const positiveId = (value) =>
(typeof value === "string" || typeof value === "number") &&
/^[1-9]\d*$/.test(String(value)) &&
Number.isSafeInteger(Number(value));
function findTimingRun({ repository, branch, repositoryId, now = Date.now() }) {
if (
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository ?? "") ||
!branch ||
!positiveId(repositoryId)
)
return "";
const deadline = Date.now() + 20_000;
const api = (...arguments_) => {
const remaining = deadline - Date.now();
if (remaining <= 0)
throw new Error("Timing history lookup budget exhausted");
return JSON.parse(
execFileSync("gh", ["api", "--method", "GET", ...arguments_], {
encoding: "utf8",
timeout: Math.min(10_000, remaining),
maxBuffer: 4 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
}),
);
};
const { artifacts } = api(
`repos/${repository}/actions/artifacts`,
"-f",
`name=${ARTIFACT_NAME}`,
"-F",
"per_page=100",
);
if (!Array.isArray(artifacts))
throw new Error("Missing timing artifact inventory");
const candidates = artifacts
.filter((artifact) => {
const lineage = artifact?.workflow_run;
const age = now - Date.parse(artifact?.created_at);
return (
artifact?.name === ARTIFACT_NAME &&
artifact?.expired === false &&
typeof artifact?.created_at === "string" &&
Number.isFinite(age) &&
age >= 0 &&
age < MAX_AGE_MS &&
positiveId(lineage?.id) &&
positiveId(lineage?.repository_id) &&
positiveId(lineage?.head_repository_id) &&
lineage.repository_id === lineage.head_repository_id &&
lineage.repository_id === Number(repositoryId) &&
lineage.head_branch === branch
);
})
.sort(
(left, right) =>
Date.parse(right.created_at) - Date.parse(left.created_at),
)
.slice(0, MAX_CANDIDATES);
for (const artifact of candidates) {
const run = api(
`repos/${repository}/actions/runs/${artifact.workflow_run.id}`,
);
const runAge = now - Date.parse(run?.created_at);
if (
typeof run?.created_at === "string" &&
Number.isFinite(runAge) &&
runAge >= 0 &&
runAge < MAX_AGE_MS &&
run.id === artifact.workflow_run.id &&
run.status === "completed" &&
run.conclusion === "success" &&
run.path === ".github/workflows/ci.yml" &&
["schedule", "workflow_dispatch"].includes(run.event) &&
run.head_branch === branch &&
run.head_repository?.full_name === repository
)
return String(run.id);
}
return "";
}
function write(runId) {
if (process.env.GITHUB_OUTPUT)
appendFileSync(process.env.GITHUB_OUTPUT, `run-id=${runId}\n`);
console.log(JSON.stringify({ run_id: runId }));
}
if (
process.argv[1] &&
fileURLToPath(import.meta.url) === resolve(process.argv[1])
) {
let runId = "";
try {
runId = findTimingRun({
repository: process.env.CI_REPOSITORY,
branch: process.env.CI_DEFAULT_BRANCH,
repositoryId: process.env.CI_REPOSITORY_ID,
});
} catch {
// History improves shard ordering but must never block CI.
}
write(runId);
}
+82 -17
View File
@@ -1,28 +1,36 @@
import { execFileSync } from "node:child_process";
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { z } from "zod"; import { z } from "zod";
import { compareTestSelection } from "./test-selection.mjs";
const root = process.cwd(); const root = process.cwd();
const historyDirectory = join(root, ".cache/ci-test-history"); const historyDirectory = join(root, ".cache/ci-test-history");
const historyPath = join(historyDirectory, "test-results.json"); const historyPath = join(historyDirectory, "test-results.json");
const selectionDirectory = join(root, ".cache/ci-test-selection");
const selectionPath = join(selectionDirectory, "report.json");
// Never let a failed or partial rerun leave a previous successful history file. // Never let a failed or partial rerun leave a previous successful history file.
await rm(historyPath, { force: true }); await rm(historyPath, { force: true });
await rm(selectionPath, { force: true });
const report = z const report = z
.object({ .object({
version: z.literal(1),
success: z.boolean(), success: z.boolean(),
numFailedTests: z.number().int().nonnegative(), reason: z.string().optional(),
testResults: z.array( unhandledErrors: z.number().int().nonnegative().optional(),
files: z.array(
z.object({ z.object({
name: z.string().min(1), name: z.string().min(1),
startTime: z.number().finite(), durationMs: z.number().finite().nonnegative(),
endTime: z.number().finite(), state: z.string().optional(),
}), }),
), ),
}) })
.parse(JSON.parse(await readFile("test-results.json", "utf8"))); .parse(JSON.parse(await readFile("test-timings.json", "utf8")));
const files = report.testResults.map(({ name, startTime, endTime }) => { const files = report.files.map(({ name, durationMs, state }) => {
const path = relative(root, resolve(root, name)); const path = relative(root, resolve(root, name));
if ( if (
path === "" || path === "" ||
@@ -31,32 +39,85 @@ const files = report.testResults.map(({ name, startTime, endTime }) => {
isAbsolute(path) isAbsolute(path)
) )
throw new Error(`Test report path is outside the checkout: ${name}`); throw new Error(`Test report path is outside the checkout: ${name}`);
const duration = endTime - startTime; return { name: path.split(sep).join("/"), durationMs, state };
if (!Number.isFinite(duration) || duration < 0)
throw new Error(`Test report has an invalid duration: ${name}`);
return { name: path.split(sep).join("/"), startTime: 0, endTime: duration };
}); });
const complete = process.env.SHARD_RESULT === "success"; const complete = process.env.SHARD_RESULT === "success";
const passed = const passed =
complete && complete && process.env.MERGE_RESULT === "success" && report.success;
process.env.MERGE_RESULT === "success" && // Successful shards and merged reports must account for the complete portable
report.success && // inventory. Coverage thresholds alone cannot prove that every file ran.
report.numFailedTests === 0; const verifyInventory = process.env.CI_VERIFY_TEST_INVENTORY === "true";
const expected =
verifyInventory || process.env.CI_TEST_SELECTION
? execFileSync("git", ["ls-files", "-z", "--", "src", "tests"], {
encoding: "utf8",
maxBuffer: 8 * 1024 * 1024,
})
.split("\0")
.filter((path) => path.endsWith(".test.ts"))
: [];
let selection;
if (process.env.CI_TEST_SELECTION) {
const comparison = compareTestSelection(
JSON.parse(process.env.CI_TEST_SELECTION),
expected,
files,
);
selection = {
...comparison,
complete:
comparison.complete &&
report.unhandledErrors === 0 &&
["passed", "failed"].includes(report.reason) &&
["success", "failure"].includes(process.env.SHARD_RESULT) &&
["success", "failure"].includes(process.env.MERGE_RESULT),
executionSha: process.env.GITHUB_SHA ?? null,
prHeadSha: process.env.CI_PR_HEAD_SHA ?? null,
runId: process.env.GITHUB_RUN_ID ?? null,
runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? null,
};
await mkdir(selectionDirectory, { recursive: true });
await writeFile(selectionPath, JSON.stringify(selection));
}
if (passed && verifyInventory) {
const actual = files.map(({ name }) => name);
const expectedSet = new Set(expected);
const actualSet = new Set(actual);
const missing = expected.filter((path) => !actualSet.has(path));
const unexpected = actual.filter((path) => !expectedSet.has(path));
if (missing.length || unexpected.length || actualSet.size !== actual.length)
throw new Error(
`Incomplete portable test inventory: ${JSON.stringify({ missing, unexpected, duplicateFiles: actual.length - actualSet.size })}`,
);
}
const summary = [ const summary = [
complete ? "## Merged test diagnostics" : "## Partial test diagnostics", complete ? "## Merged test diagnostics" : "## Partial test diagnostics",
"", "",
...(selection
? [
"### Observational PR test selection",
"",
`Plan: ${selection.plan.mode}. Proposed files: ${selection.selected.length}; omitted: ${selection.omitted.length}.`,
`Full-run comparison: ${selection.complete ? "complete" : "incomplete"}; failures outside proposed selection: ${selection.missedFailures.length}.`,
"The complete suite still ran. This report cannot enable omissions or qualify a revision. Skipped, missing, and interrupted observations do not establish parity.",
"",
]
: []),
`Shard result: ${process.env.SHARD_RESULT ?? "unknown"}; merge result: ${process.env.MERGE_RESULT ?? "unknown"}.`, `Shard result: ${process.env.SHARD_RESULT ?? "unknown"}; merge result: ${process.env.MERGE_RESULT ?? "unknown"}.`,
passed passed
? "Successful timing history is available." ? "Successful timing history is available."
: "This report is diagnostic only and is not a successful timing baseline.", : "This report is diagnostic only and is not a successful timing baseline.",
"", "",
"Durations include tests and hooks, worker preparation, environment, imports, and setup. They are scheduling weights, not summed workflow wall time.",
"",
"| Slowest file | Duration (ms) |", "| Slowest file | Duration (ms) |",
"| --- | ---: |", "| --- | ---: |",
...files ...files
.toSorted((left, right) => right.endTime - left.endTime) .toSorted((left, right) => right.durationMs - left.durationMs)
.slice(0, 20) .slice(0, 20)
.map( .map(
({ name, endTime }) => `| ${name.replaceAll("|", "\\|")} | ${endTime} |`, ({ name, durationMs }) =>
`| ${name.replaceAll("|", "\\|")} | ${durationMs} |`,
), ),
"", "",
].join("\n"); ].join("\n");
@@ -68,6 +129,10 @@ if (passed) {
await mkdir(historyDirectory, { recursive: true }); await mkdir(historyDirectory, { recursive: true });
await writeFile( await writeFile(
historyPath, historyPath,
JSON.stringify({ success: true, numFailedTests: 0, testResults: files }), JSON.stringify({
version: 1,
success: true,
files: files.map(({ name, durationMs }) => ({ name, durationMs })),
}),
); );
} }
+35
View File
@@ -0,0 +1,35 @@
export interface TestSelectionPlan {
readonly version: 1;
readonly mode: "full" | "candidate";
readonly owners: readonly string[];
readonly changedTests: readonly string[];
readonly changedPaths: readonly string[];
readonly reason: string;
}
export function planTestSelection(
files: readonly string[],
event: string,
tier: string,
): TestSelectionPlan;
export function compareTestSelection(
plan: unknown,
inventory: readonly string[],
results: readonly {
readonly name: string;
readonly durationMs: number;
readonly state?: string;
}[],
): {
readonly version: 1;
readonly observational: true;
readonly plan: TestSelectionPlan;
readonly selected: readonly string[];
readonly omitted: readonly string[];
readonly missing: readonly string[];
readonly unexpected: readonly string[];
readonly duplicateFiles: number;
readonly unobserved: readonly string[];
readonly missedFailures: readonly string[];
readonly complete: boolean;
readonly omittedDurationMs: number;
};
+151
View File
@@ -0,0 +1,151 @@
import { matchesGlob } from "node:path";
// Candidate ownership only: these leaf adapters and their producer tests can be
// compared with full-suite outcomes. This table never changes test execution.
// Shared application/domain code and dynamic CLI/MCP consumers are not leaf owners.
const OWNERS = {
adb: ["src/adb/**", "tests/boundary/providers/adb/**"],
apktool: ["src/apktool/**"],
dotnet: ["src/dotnet/**"],
evm: ["src/evm/**", "tests/boundary/evm/**"],
firmware: ["src/firmware/**", "tests/boundary/firmware/**"],
flutter: ["src/flutter/**"],
ghidra: ["src/ghidra/**", "tests/boundary/providers/ghidra/**"],
hopper: ["src/hopper/**", "tests/boundary/providers/hopper/**"],
ida: ["src/ida/**", "tests/boundary/providers/ida/**"],
jeb: ["src/jeb/**"],
pcap: ["src/pcap/**", "tests/boundary/pcap/**"],
sqlite: ["src/sqlite/**", "tests/boundary/sqlite/**"],
wasm: ["src/wasm/**"],
};
const ownersOf = (path) =>
Object.entries(OWNERS)
.filter(([, patterns]) => patterns.some((glob) => matchesGlob(path, glob)))
.map(([owner]) => owner);
const validPath = (path) =>
typeof path === "string" &&
/^(src|tests)\//.test(path) &&
!/[\\\0\r\n]/.test(path) &&
!path.split("/").some((part) => ["", ".", ".."].includes(part));
const full = (reason, changedPaths = []) => ({
version: 1,
mode: "full",
owners: [],
changedTests: [],
changedPaths,
reason,
});
/** Plan an observational PR subset from the classifier's existing Git diff. */
export function planTestSelection(files, event, tier) {
if (event !== "pull_request" || tier !== "fast")
return full("Only ordinary PRs are candidates", files);
if (!files.length) return full("No changed paths available");
const owners = new Set();
const changedTests = [];
for (const path of files) {
if (!validPath(path))
return full(`Shared or unknown input: ${path}`, files);
const matches = ownersOf(path);
if (!matches.length) return full(`Shared or unknown input: ${path}`, files);
for (const owner of matches) owners.add(owner);
if (path.endsWith(".test.ts")) changedTests.push(path);
}
return {
version: 1,
mode: "candidate",
owners: [...owners].sort(),
changedTests: [...new Set(changedTests)].sort(),
changedPaths: files,
reason:
"Observe affected leaf owners; retain integration and unowned tests",
};
}
/** Compare a proposal with the full run; missing observations never prove parity. */
export function compareTestSelection(plan, inventory, results) {
if (
plan?.version !== 1 ||
!["full", "candidate"].includes(plan.mode) ||
!Array.isArray(plan.owners) ||
plan.owners.some((owner) => !Object.hasOwn(OWNERS, owner)) ||
!Array.isArray(plan.changedTests) ||
!Array.isArray(plan.changedPaths) ||
plan.changedPaths.some((path) => typeof path !== "string") ||
plan.changedTests.some(
(path) => !validPath(path) || !path.endsWith(".test.ts"),
) ||
typeof plan.reason !== "string" ||
(plan.mode === "candidate" && !plan.owners.length) ||
(plan.mode === "full" && (plan.owners.length || plan.changedTests.length))
)
throw new Error("Invalid observational test selection plan");
const expected = new Set(inventory);
if (!expected.size || expected.size !== inventory.length)
throw new Error("Test selection requires a nonempty unique inventory");
const ownersByFile = new Map(
plan.mode === "candidate"
? inventory.map((path) => [path, ownersOf(path)])
: [],
);
// Removed/renamed tests have no current executable identity. Keep all tests.
const removed = plan.changedTests.filter((path) => !expected.has(path));
const missingOwners = plan.owners.filter(
(owner) =>
![...ownersByFile.values()].some((owners) => owners.includes(owner)),
);
const effective = removed.length
? full(
`Changed tests absent from current inventory: ${removed.join(", ")}`,
plan.changedPaths,
)
: missingOwners.length
? full(
`Owners without executable tests: ${missingOwners.join(", ")}`,
plan.changedPaths,
)
: plan;
const selected = [];
const omitted = [];
for (const path of inventory.toSorted()) {
const owners = ownersByFile.get(path) ?? [];
const keep =
effective.mode === "full" ||
!owners.length ||
effective.changedTests.includes(path) ||
owners.some((owner) => effective.owners.includes(owner));
(keep ? selected : omitted).push(path);
}
const actual = new Set(results.map((file) => file.name));
const missing = inventory.filter((path) => !actual.has(path));
const unexpected = [...actual].filter((path) => !expected.has(path));
const duplicateFiles = results.length - actual.size;
const unobserved = results
.filter((file) => !["passed", "failed"].includes(file.state))
.map((file) => file.name);
const omittedSet = new Set(omitted);
const missedFailures = results
.filter((file) => omittedSet.has(file.name) && file.state === "failed")
.map((file) => file.name);
return {
version: 1,
observational: true,
plan: effective,
selected,
omitted,
missing,
unexpected,
duplicateFiles,
unobserved,
missedFailures,
complete:
!missing.length &&
!unexpected.length &&
!duplicateFiles &&
!unobserved.length,
// Phase totals are scheduling weights, not an estimate of wall-time savings.
omittedDurationMs: results
.filter((file) => omittedSet.has(file.name))
.reduce((sum, file) => sum + file.durationMs, 0),
};
}
+43
View File
@@ -0,0 +1,43 @@
import { writeFile } from "node:fs/promises";
/** @implements {import("vitest/node").Reporter} */
export default class TestTimingReporter {
/**
* @param {ReadonlyArray<import("vitest/node").TestModule>} modules
* @param {ReadonlyArray<import("vitest/node").SerializedError>} errors
* @param {import("vitest/node").TestRunEndReason} reason
*/
async onTestRunEnd(modules, errors, reason) {
const files = modules.map((module) => {
const diagnostic = module.diagnostic();
// These disjoint phases match Vitest's per-file wall-time accounting.
// Import timings already include transform waits; do not add them again.
const phases = {
execution: diagnostic.duration,
preparation: diagnostic.prepareDuration,
environment: diagnostic.environmentSetupDuration,
imports: diagnostic.collectDuration,
setup: diagnostic.setupDuration,
};
return {
name: module.moduleId,
state: module.state(),
durationMs: Object.values(phases).reduce(
(total, value) => total + value,
0,
),
phases,
};
});
await writeFile(
"test-timings.json",
JSON.stringify({
version: 1,
success: reason === "passed" && errors.length === 0,
reason,
unhandledErrors: errors.length,
files,
}),
);
}
}
+235
View File
@@ -0,0 +1,235 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { execFile } from "node:child_process";
import { copyFile, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const MANIFEST_NAME = "manifest.json";
const TARBALL_NAME = "package.tgz";
const MANIFEST_KEYS = [
"schemaVersion",
"sourceSha",
"runId",
"runAttempt",
"platform",
"architecture",
"packageVersion",
"nodeVersion",
"sha256",
].sort();
const execFileAsync = promisify(execFile);
/** Normalize workflow identity and host facts into the manifest-owned fields. */
export function createVerifiedPackageMetadata({
sourceSha,
runId,
runAttempt,
platform,
architecture,
packageVersion,
nodeVersion,
}) {
if (!/^[a-f0-9]{40}$/u.test(sourceSha))
throw new Error("Source SHA must be a full commit SHA");
return {
sourceSha,
runId: parsePositiveInteger(runId, "GITHUB_RUN_ID"),
runAttempt: parsePositiveInteger(runAttempt, "GITHUB_RUN_ATTEMPT"),
platform,
architecture,
packageVersion,
nodeVersion,
};
}
/** Build the single supported manifest representation for a verified tarball. */
function createVerifiedPackageManifest(metadata) {
const manifest = { schemaVersion: 1, ...metadata };
validateManifestShape(manifest);
return manifest;
}
/** Write a manifest after the caller has completed qualification and cleanup. */
export async function writeVerifiedPackageManifest(directory, metadata) {
const manifest = createVerifiedPackageManifest(metadata);
await writeFile(
join(directory, MANIFEST_NAME),
`${JSON.stringify(manifest, null, 2)}\n`,
{ encoding: "utf8" },
);
return manifest;
}
/** Copy the qualified archive under its fixed name and prepare its manifest. */
export async function retainVerifiedPackage({
sourceTarball,
directory,
metadata,
}) {
const tarball = join(directory, TARBALL_NAME);
await copyFile(sourceTarball, tarball);
const sha256 = await sha256File(tarball);
return {
directory,
metadata: createVerifiedPackageManifest({ ...metadata, sha256 }),
};
}
/** Validate a retained package against the current checkout and workflow attempt. */
async function validateVerifiedPackage({
directory,
sourceSha,
runId,
runAttempt,
packageVersion,
nodeVersion,
}) {
if (!/^[a-f0-9]{40}$/u.test(sourceSha))
throw new Error("Expected source SHA must be a full commit SHA");
if (!Number.isSafeInteger(runId) || runId <= 0)
throw new Error("Expected workflow run ID must be a positive integer");
if (!Number.isSafeInteger(runAttempt) || runAttempt <= 0)
throw new Error("Expected workflow run attempt must be a positive integer");
const manifestPath = join(directory, MANIFEST_NAME);
let manifest;
try {
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
} catch (cause) {
throw new Error(`Could not read verified package manifest: ${cause}`, {
cause,
});
}
validateManifestShape(manifest);
assertEqual(manifest.sourceSha, sourceSha, "source SHA");
assertEqual(manifest.runId, runId, "workflow run ID");
// A successful producer may belong to an earlier attempt when only failed
// publisher jobs are rerun. The workflow still requires same-run qualification.
if (manifest.runAttempt > runAttempt)
throw new Error("Verified package workflow run attempt is from the future");
assertEqual(manifest.platform, "linux", "verifier platform");
assertEqual(manifest.architecture, "x64", "verifier architecture");
assertEqual(manifest.packageVersion, packageVersion, "package version");
assertEqual(manifest.nodeVersion, nodeVersion, "Node.js version");
// The artifact format deliberately fixes the name; no manifest value can
// redirect validation to another path.
const tarball = resolve(directory, TARBALL_NAME);
const digest = await sha256File(tarball);
assertEqual(manifest.sha256, digest, "tarball SHA-256");
return tarball;
}
function validateManifestShape(manifest) {
if (
typeof manifest !== "object" ||
manifest === null ||
Array.isArray(manifest)
)
throw new Error("Verified package manifest must be a JSON object");
const keys = Object.keys(manifest).sort();
if (
keys.length !== MANIFEST_KEYS.length ||
keys.some((key, index) => key !== MANIFEST_KEYS[index])
)
throw new Error("Verified package manifest has missing or unknown fields");
if (manifest.schemaVersion !== 1)
throw new Error("Unsupported verified package manifest schema version");
if (
typeof manifest.sourceSha !== "string" ||
!/^[a-f0-9]{40}$/u.test(manifest.sourceSha)
)
throw new Error("Verified package manifest has an invalid source SHA");
if (!Number.isSafeInteger(manifest.runId) || manifest.runId <= 0)
throw new Error("Verified package manifest has an invalid workflow run ID");
if (!Number.isSafeInteger(manifest.runAttempt) || manifest.runAttempt <= 0)
throw new Error(
"Verified package manifest has an invalid workflow run attempt",
);
for (const key of [
"platform",
"architecture",
"packageVersion",
"nodeVersion",
])
if (typeof manifest[key] !== "string" || manifest[key].length === 0)
throw new Error(`Verified package manifest has an invalid ${key}`);
if (
typeof manifest.sha256 !== "string" ||
!/^[a-f0-9]{64}$/u.test(manifest.sha256)
)
throw new Error("Verified package manifest has an invalid tarball SHA-256");
}
function assertEqual(actual, expected, label) {
if (actual !== expected)
throw new Error(`Verified package ${label} mismatch`);
}
async function sha256File(path) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
}
async function validateFromCli(directory) {
const sourceSha = process.env.GITHUB_SHA;
const runId = parsePositiveInteger(
process.env.GITHUB_RUN_ID,
"GITHUB_RUN_ID",
);
const runAttempt = parsePositiveInteger(
process.env.GITHUB_RUN_ATTEMPT,
"GITHUB_RUN_ATTEMPT",
);
if (sourceSha === undefined || !/^[a-f0-9]{40}$/u.test(sourceSha))
throw new Error("GITHUB_SHA must be a full commit SHA");
const checkoutSha = (
await execFileAsync("git", ["rev-parse", "HEAD"])
).stdout.trim();
assertEqual(checkoutSha, sourceSha, "checked out source SHA");
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const tarball = await validateVerifiedPackage({
directory,
sourceSha,
runId,
runAttempt,
packageVersion: packageJson.version,
nodeVersion: process.version,
});
if (process.env.GITHUB_OUTPUT === undefined)
throw new Error(
"GITHUB_OUTPUT is required to publish the validated tarball path",
);
await writeFile(process.env.GITHUB_OUTPUT, `tarball=${tarball}\n`, {
encoding: "utf8",
flag: "a",
});
process.stdout.write(`${tarball}\n`);
}
function parsePositiveInteger(value, name) {
if (value === undefined || !/^[1-9]\d*$/u.test(value))
throw new Error(`${name} must be a positive integer`);
const parsed = Number(value);
if (!Number.isSafeInteger(parsed))
throw new Error(`${name} must be a safe positive integer`);
return parsed;
}
const invokedPath = process.argv[1];
if (
invokedPath !== undefined &&
resolve(invokedPath) === fileURLToPath(import.meta.url)
) {
const [command, directory, ...extra] = process.argv.slice(2);
if (command !== "validate" || directory === undefined || extra.length > 0) {
process.stderr.write(
"Usage: node scripts/ci/verified-package.mjs validate <artifact-directory>\n",
);
process.exitCode = 2;
} else {
await validateFromCli(directory);
}
}
@@ -45,6 +45,20 @@ class Generator:
assert "ows-secret" not in json.dumps(projected), projected assert "ows-secret" not in json.dumps(projected), projected
if isinstance(value, bytes): if isinstance(value, bytes):
assert projected["binary_fields"][-1]["state"] == "redacted", projected assert projected["binary_fields"][-1]["state"] == "redacted", projected
body = b"A" * 12345
state = {"response": {"content": body}}
control = adapter.project_record(state, [])
field = control["binary_fields"][0]
assert field["state"] == "retained" and field["bytes"] == len(body), control
for literal in ["12345", "2345"]:
# Only the generated byte count matches this exclusion.
assert literal not in control["reported"]["response"]["content"]
assert literal not in field["content_base64"] and literal not in field["sha256"]
projected = adapter.project_record(state, [literal])
assert projected["reported"]["response"]["content"] is None, projected
assert projected["binary_fields"] == [{"pointer": "/response/content", "representation": "producer-bytes", "state": "redacted", "content_base64": None, "bytes": None, "sha256": None}], projected
assert {"pointer": "/response/content", "reason": "explicit-sensitive-value"} in projected["redactions"], projected
assert literal not in json.dumps(projected), projected
(root / "native-error-classifications.json").write_text(json.dumps({"passed": len(classifications) + 2})) (root / "native-error-classifications.json").write_text(json.dumps({"passed": len(classifications) + 2}))
first = http.HTTPFlow(connection.Client(peername=("127.0.0.1", 1), sockname=("127.0.0.1", 2)), connection.Server(address=("example.test", 80))) first = http.HTTPFlow(connection.Client(peername=("127.0.0.1", 1), sockname=("127.0.0.1", 2)), connection.Server(address=("example.test", 80)))
first.id = "original-producer-id" first.id = "original-producer-id"
+12 -17
View File
@@ -4,9 +4,14 @@ import { createHash } from "node:crypto";
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import {
buildNativeAotFixture,
nativeAotCommandError,
supportsNativeAotFixtureHost,
} from "./lib/nativeaot-build.mjs";
const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
if (process.arch !== "x64" || !["linux", "win32"].includes(process.platform)) if (!supportsNativeAotFixtureHost(process.platform, process.arch))
throw new Error( throw new Error(
"NativeAOT fixture lane requires Linux or Windows x64; it does not cross-compile NativeAOT.", "NativeAOT fixture lane requires Linux or Windows x64; it does not cross-compile NativeAOT.",
); );
@@ -40,9 +45,7 @@ const run = (command, args) => {
maxBuffer: 8 * 1024 * 1024, maxBuffer: 8 * 1024 * 1024,
}); });
if (result.error || result.status !== 0) if (result.error || result.status !== 0)
throw new Error( throw nativeAotCommandError(command, args[0], result);
`NativeAOT fixture lane requires/action failed ${command}: ${result.error?.message ?? ""}\n${result.stdout ?? ""}${result.stderr ?? ""}`,
);
return result.stdout; return result.stdout;
}; };
const dotnet = process.env.REA_NATIVEAOT_DOTNET ?? "dotnet"; const dotnet = process.env.REA_NATIVEAOT_DOTNET ?? "dotnet";
@@ -57,20 +60,12 @@ if (process.platform === "linux") {
run("clang", ["--version"]); run("clang", ["--version"]);
} }
const map = join(output, "NativeAotFixture.map"); const map = join(output, "NativeAotFixture.map");
run(dotnet, [ await buildNativeAotFixture(run, {
"publish", dotnet,
join(root, "tests/fixtures/nativeaot/NativeAotFixture.csproj"), project: join(root, "tests/fixtures/nativeaot/NativeAotFixture.csproj"),
"-r",
runtime, runtime,
"-c", output,
"Release", });
"-o",
join(output, "symbols"),
"--maxcpucount:1",
`-p:BaseIntermediateOutputPath=${join(output, "obj")}/`,
`-p:BaseOutputPath=${join(output, "bin")}/`,
`-p:NativeAotMapPath=${map}`,
]);
const name = const name =
process.platform === "win32" ? "NativeAotFixture.exe" : "NativeAotFixture"; process.platform === "win32" ? "NativeAotFixture.exe" : "NativeAotFixture";
const binary = join(output, "symbols", name); const binary = join(output, "symbols", name);
@@ -0,0 +1,3 @@
export function assertCleanLinuxHopperRuntime(
launcherPaths?: readonly string[],
): Promise<void>;
@@ -0,0 +1,64 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { readdir, stat } from "node:fs/promises";
const execFileAsync = promisify(execFile);
/**
* Observe leftover demo processes on a dedicated clean Linux runner, including
* groups reparented after their launcher exits. This is not ownership proof and
* never authorizes signaling; unrelated runtime processes also block this lane.
*/
export const assertCleanLinuxHopperRuntime = async (
launcherPaths = [
"/opt/hopper/bin/Hopper",
...(process.env.HOPPER_LAUNCHER_PATH === undefined
? []
: [process.env.HOPPER_LAUNCHER_PATH]),
],
) => {
const { stdout } = await execFileAsync("ps", ["-ax", "-o", "command="]);
if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(stdout))
throw new Error("Clean Linux runner retained a Hopper demo process");
const launchers = [];
for (const path of launcherPaths) {
try {
launchers.push(await stat(path, { bigint: true }));
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
if (launchers.length === 0) return;
// /proc exposes the executed file even when argv[0] names a removed symlink.
// Compare file identity, not a display path or a process name.
let inspectionError;
for (const entry of await readdir("/proc", { withFileTypes: true })) {
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue;
let executable;
try {
executable = await stat(`/proc/${entry.name}/exe`, { bigint: true });
} catch (error) {
if (error?.code === "ENOENT" || error?.code === "ESRCH") continue;
// Other users' executables may be unreadable. The setup lane launches
// providers as the caller; unreadable caller-owned processes fail closed.
if (error?.code === "EACCES") {
const owner = await stat(`/proc/${entry.name}`).catch((cause) => {
if (cause?.code === "ENOENT") return undefined;
throw cause;
});
if (owner === undefined || owner.uid !== process.getuid()) continue;
}
// Finish the inventory so an unrelated restricted process cannot mask a
// positively identified leak. Unknown caller processes still fail closed.
inspectionError ??= error;
continue;
}
if (
launchers.some(
({ dev, ino }) => dev === executable.dev && ino === executable.ino,
)
)
throw new Error("Clean Linux runner retained a Hopper demo process");
}
if (inspectionError !== undefined) throw inspectionError;
};
+25
View File
@@ -0,0 +1,25 @@
export function supportsNativeAotFixtureHost(
platform: string,
architecture: string,
): boolean;
export function buildNativeAotFixture(
run: (command: string, args: string[]) => unknown,
options: {
dotnet: string;
project: string;
runtime: string;
output: string;
},
): Promise<void>;
export function nativeAotCommandError(
command: string,
operation: string,
result: {
error?: { message: string };
status: number | null;
stdout?: string | null;
stderr?: string | null;
},
): Error;
+60
View File
@@ -0,0 +1,60 @@
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
/** NativeAOT fixtures use native toolchains, not cross-compilation. */
export function supportsNativeAotFixtureHost(platform, architecture) {
return architecture === "x64" && ["linux", "win32"].includes(platform);
}
/** Keep network recovery separate from compilation and preserve restore failures. */
export async function buildNativeAotFixture(run, options) {
const { dotnet, project, runtime, output } = options;
const common = [
project,
"-r",
runtime,
"--maxcpucount:1",
"-p:Configuration=Release",
`-p:BaseIntermediateOutputPath=${join(output, "obj")}/`,
`-p:BaseOutputPath=${join(output, "bin")}/`,
`-p:NativeAotMapPath=${join(output, "NativeAotFixture.map")}`,
];
for (let attempt = 1; attempt <= 2; attempt++) {
console.error(`NativeAOT dependency restore: attempt ${attempt}/2`);
try {
run(dotnet, ["restore", ...common, "--verbosity", "normal"]);
break;
} catch (cause) {
if (attempt === 2)
throw new Error(
"NativeAOT dependency restore failed after two attempts",
{
cause,
},
);
// Child diagnostics can contain authenticated feed URLs; keep retry logs static.
console.error(
"NativeAOT dependency restore failed; retrying in 1 second",
);
await delay(1_000);
}
}
console.error("NativeAOT compilation (dependencies already restored)");
run(dotnet, [
"publish",
...common,
"--no-restore",
"-o",
join(output, "symbols"),
]);
}
/** Sanitize child diagnostics before any retry, cause chain, or uncaught output. */
export function nativeAotCommandError(command, operation, result) {
const diagnostic = `NativeAOT fixture lane requires/action failed ${command} ${operation}: ${result.error?.message ?? `exit ${result.status}`}\n${result.stdout ?? ""}${result.stderr ?? ""}`;
// Only URL authority userinfo is authentication; preserve host, path, query,
// and unrelated diagnostic values. Never retain the raw result as a cause.
return new Error(
diagnostic.replace(/\b(https?:\/\/)[^\\\s/?#]*@/giu, "$1[redacted]@"),
);
}
+264
View File
@@ -0,0 +1,264 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import {
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../../dist/domain/evidence.js";
import {
requireMcpOperationResult,
requireMcpToolError,
mcpTextValue,
} from "./mcp-verifier-results.mjs";
/** Exercise file coordinates through public CLI/MCP using the source-owned PE fixture. */
export async function verifyGhidraFileOffsets(
targetPath,
entrypoint,
procedure,
) {
const original = await readFile(targetPath);
const workspace = await mkdtemp(join(tmpdir(), "rea-file-offsets-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const env = {
...process.env,
TEMP: runtime,
TMP: runtime,
TMPDIR: runtime,
REA_ANALYSIS_PROVIDER: "ghidra",
REA_LOG_LEVEL: "silent",
};
const client = new Client({ name: "rea-file-offsets", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
let connected = false;
let complete = false;
let primaryFailure;
const call = async (name, arguments_ = {}) => {
const result = await client.callTool(
{ name, arguments: arguments_ },
{ timeout: 240_000 },
);
const projected = requireMcpOperationResult(result, name);
return ["open_binary", "binary_session", "close_binary"].includes(name)
? projected
: (result.structuredContent ?? JSON.parse(mcpTextValue(result)));
};
try {
await client.connect(transport);
connected = true;
await call("open_binary", { path: targetPath, provider_id: "ghidra" });
const session = await call("binary_session");
const mapping = parseEvidence(
await call("address_to_file_offset", { address: procedure }),
);
const offset = mapping.normalized_result.file_offset;
const coordinate = `0x${offset.toString(16)}`;
const expected = parseEvidence(
await call("procedure_pseudo_code", { procedure }),
);
const explicit = parseEvidence(
await call("procedure_pseudo_code", {
procedure: `file-offset:${coordinate}`,
}),
);
const automatic = parseEvidence(
await call("procedure_pseudo_code", { procedure: coordinate }),
);
for (const actual of [explicit, automatic]) {
assert.equal(actual.normalized_result, expected.normalized_result);
assert.equal(actual.raw_result.selection.file_offset, offset);
assert.equal(actual.raw_result.selection.mapped_address, procedure);
assert.ok(
actual.locations.some(
(location) =>
location.kind === "file-offset" && location.offset === offset,
),
);
}
const invalid = await client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "file-offset:0x20000000000000" },
});
assert.equal(requireMcpToolError(invalid).code, "invalid_request");
const unmapped = await client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "file-offset:0x100000" },
});
assert.ok(
JSON.stringify(requireMcpToolError(unmapped)).includes(
"no loaded source mapping",
),
);
assert.equal(
(await call("binary_session")).analysis_run.run_id,
session.analysis_run.run_id,
);
const image = parseEvidence(await call("inspect_native_load_image"));
const headerMapping = image.normalized_result.observations.mappings.find(
(item) => item.initialized && item.file_offset === 0,
);
assert.ok(
headerMapping,
"Fixture header must have an observed source mapping",
);
const headerOffset = parseEvidence(
await call("address_to_file_offset", { address: headerMapping.start }),
);
assert.equal(headerOffset.normalized_result.file_offset, 0);
for (const selector of ["file-offset:0x0", "0x0"]) {
const missing = requireMcpToolError(
await client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: selector },
}),
);
assertMissingMappedProcedure(missing, headerMapping.start);
}
assert.equal(
(await call("binary_session")).analysis_run.run_id,
session.analysis_run.run_id,
);
await call("close_binary");
const cli = JSON.parse(
(
await promisify(execFile)(
process.execPath,
[
entrypoint,
"decompile",
targetPath,
`--file-offset=${coordinate}`,
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240_000, maxBuffer: 16 * 1024 * 1024 },
)
).stdout,
);
assert.equal(cli.normalized_result, explicit.normalized_result);
assert.deepEqual(cli.locations, explicit.locations);
const missingCli = await promisify(execFile)(
process.execPath,
[
entrypoint,
"decompile",
targetPath,
"--file-offset=0x0",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240_000, maxBuffer: 16 * 1024 * 1024 },
).then(
() => {
throw new Error(
"Mapped header bytes must not decompile as a procedure",
);
},
(cause) => JSON.parse(cause.stdout),
);
assertMissingMappedProcedure(missingCli, headerMapping.start);
const alias = Buffer.from(original);
const pe = alias.readUInt32LE(0x3c);
if (alias.readUInt16LE(pe + 6) >= 2) {
const secondSection = pe + 24 + alias.readUInt16LE(pe + 20) + 40;
// Both sections now carry the same original bytes at distinct virtual addresses.
alias.writeUInt32LE(0x200, secondSection + 20);
const ambiguousPath = join(workspace, "ambiguous.exe");
await writeFile(ambiguousPath, alias);
await call("open_binary", { path: ambiguousPath, provider_id: "ghidra" });
const ambiguous = await client.callTool(
{
name: "procedure_pseudo_code",
arguments: { procedure: `file-offset:${coordinate}` },
},
{ timeout: 240_000 },
);
assert.ok(
JSON.stringify(requireMcpToolError(ambiguous)).includes(
"multiple loaded mappings",
),
);
await call("close_binary");
}
assert.deepEqual(await readFile(targetPath), original);
await client.close();
connected = false;
assert.deepEqual(await readdir(runtime), []);
complete = true;
return {
cli_mcp_parity: true,
explicit_and_numeric_offsets: true,
invalid_and_unmapped_offsets: true,
mapped_offset_without_procedure: true,
ambiguous_mappings:
alias.readUInt16LE(pe + 6) >= 2
? "verified"
: "unavailable-single-section",
source_unchanged: true,
cleanup: "complete",
};
} catch (cause) {
primaryFailure = cause;
throw cause;
} finally {
const cleanupFailures = [];
if (connected) {
try {
await call("close_binary");
} catch (cause) {
cleanupFailures.push(cause);
}
}
try {
await client.close();
} catch (cause) {
cleanupFailures.push(cause);
}
if (cleanupFailures.length > 0) {
process.stderr.write(
`Retained file-offset verifier workspace: ${workspace}\n`,
);
throw new AggregateError(
primaryFailure === undefined
? cleanupFailures
: [primaryFailure, ...cleanupFailures],
"File-offset verifier cleanup failed",
);
}
if (complete) await rm(workspace, { recursive: true, force: true });
else
process.stderr.write(
`Retained file-offset verifier workspace: ${workspace}\n`,
);
}
}
function assertMissingMappedProcedure(error, mappedAddress) {
assert.equal(error.code, "invalid_request");
const message = error.details?.issues?.[0]?.message;
assert.equal(typeof message, "string");
assert.ok(
message.includes("File offset 0x0 maps to analysis address"),
message,
);
assert.ok(message.includes(mappedAddress), message);
assert.ok(message.includes("no procedure starts at or contains"), message);
assert.ok(!message.includes("--file-offset"), message);
}
+30
View File
@@ -0,0 +1,30 @@
type VerifierCall = (
name: string,
arguments_?: Record<string, unknown>,
) => Promise<unknown>;
interface StringExpectation {
readonly value: string;
readonly encoding: string;
readonly symbol?: string;
readonly recognitionOptional?: boolean;
}
/** Verify source bytes, observed decoding, address filters and literal searches. */
export function verifyHopperStringObjects(
call: VerifierCall,
expectations: readonly StringExpectation[],
inventory?: readonly z.output<typeof analysisStringSchema>[] | null,
): Promise<{
readonly sourceBytesVerified: true;
readonly recognition: readonly {
readonly value: string;
readonly address: string;
readonly state: "observed" | "not_observed";
readonly decodedObject: "verified" | "unverified";
}[];
readonly displayEscapesExcludedFromMatching: true;
}>;
import type { z } from "zod";
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";
+132 -28
View File
@@ -1,13 +1,16 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mcpTextValue } from "./mcp-verifier-results.mjs"; import { mcpTextValue } from "./mcp-verifier-results.mjs";
const fixtureSymbolPrefix = process.platform === "darwin" ? "_" : "";
/** Exercise literal/regex search, Unicode identities and cache invalidation in Hopper. */ /** Exercise literal/regex search, Unicode identities and cache invalidation in Hopper. */
export async function verifyHopperSearch(call, invalid, procedure) { export async function verifyHopperSearch(call, invalid, procedure) {
const address = procedure.address; const address = procedure.address;
const original = await call("address_name", { address }); const original = await call("address_name", { address });
let stringCoverage;
try { try {
await verifySymbolNames(call, invalid, address); await verifySymbolNames(call, invalid, address);
await verifySearchModes(call, address); stringCoverage = await verifySearchModes(call, address);
} finally { } finally {
await call("set_address_name", { address, name: original }); await call("set_address_name", { address, name: original });
} }
@@ -26,7 +29,7 @@ export async function verifyHopperSearch(call, invalid, procedure) {
symbolUtf16LimitValidatedBeforeMutation: true, symbolUtf16LimitValidatedBeforeMutation: true,
regexAndCaseModes: true, regexAndCaseModes: true,
annotationCacheInvalidation: true, annotationCacheInvalidation: true,
escapedUtf8AndByteStrings: true, stringCoverage,
}; };
} }
@@ -103,11 +106,26 @@ async function verifySearchModes(call, address) {
[{ address, value: "REA_Search_Upper" }], [{ address, value: "REA_Search_Upper" }],
); );
const strings = await call("list_strings"); const strings = await call("list_strings");
const long = "REA_LONG_LITERAL_" + "x".repeat(4096) + "needle"; const linux = process.platform === "linux";
const longItem = strings.find((item) => const fullLiteral = "REA_LONG_LITERAL_" + "x".repeat(4096) + "needle";
item.value.startsWith("REA_LONG_LITERAL_"), // Recognition is a provider/loader capability, not an adapter guarantee.
// Always retain the original size in raw-byte coverage; Linux additionally
// requires an extent observed to produce native fragmented string objects.
const fullBytes = Buffer.from(fullLiteral + "\0");
const raw = await call("read_bytes", {
address: `${fixtureSymbolPrefix}rea_long_literal`,
length: fullBytes.length,
});
assert.equal(raw.complete, true);
assert.equal(raw.bytes_hex, fullBytes.toString("hex"));
const long = linux
? "REA_FRAGMENTED_LITERAL_" + "x".repeat(512) + "fragment_tail"
: fullLiteral;
const longItem = strings.find(
(item) => long.startsWith(item.value) && item.value.startsWith("REA_"),
); );
assert.ok(longItem, "fixture omitted its real long literal"); assert.ok(longItem, "fixture omitted its real long literal");
if (!linux) assert.equal(longItem.address, raw.address);
assert.ok(long.startsWith(longItem.value)); assert.ok(long.startsWith(longItem.value));
assert.ok(longItem.provider_value.endsWith("…")); assert.ok(longItem.provider_value.endsWith("…"));
assert.equal(longItem.string.termination, "missing"); assert.equal(longItem.string.termination, "missing");
@@ -158,7 +176,7 @@ async function verifySearchModes(call, address) {
} }
assert.deepEqual( assert.deepEqual(
await call("search_strings", { await call("search_strings", {
pattern: "needle", pattern: linux ? "fragment_tail" : "needle",
mode: "literal", mode: "literal",
case_sensitive: true, case_sensitive: true,
}), }),
@@ -176,36 +194,122 @@ async function verifySearchModes(call, address) {
}), }),
expected, expected,
); );
await verifyHopperStringObjects(call, [ const objects = await verifyHopperStringObjects(
{ value: "REA_UTF8_é_😀", encoding: "utf-8" }, call,
{ value: 'REA_ESCAPED_"\\line\nend\t\r', encoding: "utf-8" }, [
{ value: "REA_LITERAL_BACKSLASH_\\n", encoding: "utf-8" }, {
{ value: "REA_LITERAL_…", encoding: "utf-8" }, value: "REA_UTF8_é_😀",
{ value: "REA_LATIN1_ÿ", encoding: "latin-1" }, encoding: "utf-8",
]); symbol: `${fixtureSymbolPrefix}rea_utf8_literal`,
recognitionOptional: linux,
},
{ value: 'REA_ESCAPED_"\\line\nend\t\r', encoding: "utf-8" },
{ value: "REA_LITERAL_BACKSLASH_\\n", encoding: "utf-8" },
{
value: "REA_LITERAL_…",
encoding: "utf-8",
symbol: `${fixtureSymbolPrefix}rea_ellipsis_literal`,
recognitionOptional: linux,
},
{
value: "REA_LATIN1_ÿ",
encoding: "latin-1",
symbol: `${fixtureSymbolPrefix}rea_latin1_literal`,
recognitionOptional: linux,
},
],
strings,
);
return {
fullLiteralSourceBytes: fullBytes.length,
fragmentedLiteralSourceBytes: Buffer.byteLength(long),
fullLiteralRecognition: {
address: raw.address,
state: strings.some((item) => item.address === raw.address)
? "observed"
: "not_observed",
// Linux validates the original through raw bytes; its required decoded
// fragment/dossier checks exercise the separate supported-size literal.
decodedObject: linux ? "unverified" : "verified",
},
...objects,
};
} }
/** Compare native string identities and values with the source fixture's bytes. */ /** Compare native string identities and values with the source fixture's bytes. */
export async function verifyHopperStringObjects(call, expectations) { export async function verifyHopperStringObjects(
const strings = await call("list_strings"); call,
for (const { value, encoding } of expectations) { expectations,
const item = strings.find((candidate) => candidate.value === value); inventory = null,
assert.ok( ) {
item, // Reuse only the caller's inventory for this unchanged document, never live
`Hopper omitted the decoded string ${JSON.stringify(value)}`, // state from an earlier session. Address-filter and search checks remain real.
); const strings = inventory ?? (await call("list_strings"));
assert.equal(item.string.encoding, encoding); const recognition = [];
assert.equal(item.string.encoding_status, "inferred"); for (const {
assert.equal(item.string.termination, "present_or_not_required"); value,
encoding,
symbol,
recognitionOptional = false,
} of expectations) {
const expectedBytes = Buffer.from( const expectedBytes = Buffer.from(
value + "\0", value + "\0",
encoding.replaceAll("-", ""), encoding.replaceAll("-", ""),
); );
assert.equal(item.string.byte_length, expectedBytes.length); let sourceBytes;
const bytes = await call("read_bytes", { if (symbol !== undefined) {
const raw = await call("read_bytes", {
address: symbol,
length: expectedBytes.length,
});
sourceBytes = raw;
assert.equal(raw.complete, true);
assert.equal(raw.bytes_hex, expectedBytes.toString("hex"));
}
const item = strings.find((candidate) =>
sourceBytes === undefined
? candidate.value === value
: candidate.address === sourceBytes.address,
);
if (item === undefined && recognitionOptional) {
assert.ok(
symbol,
"optional recognition requires independent source-byte coverage",
);
recognition.push({
value,
address: sourceBytes.address,
state: "not_observed",
decodedObject: "unverified",
});
continue;
}
assert.ok(
item,
`Hopper omitted the decoded string ${JSON.stringify(value)}`,
);
assert.equal(
item.value,
value,
"native string decoding changed the source value",
);
recognition.push({
value,
address: item.address, address: item.address,
length: expectedBytes.length, state: "observed",
decodedObject: "verified",
}); });
assert.equal(item.string.encoding, encoding);
assert.equal(item.string.encoding_status, "inferred");
assert.equal(item.string.termination, "present_or_not_required");
assert.equal(item.string.byte_length, expectedBytes.length);
const bytes =
sourceBytes ??
(await call("read_bytes", {
address: item.address,
length: expectedBytes.length,
}));
assert.equal(bytes.complete, true);
assert.equal(bytes.bytes_hex, expectedBytes.toString("hex")); assert.equal(bytes.bytes_hex, expectedBytes.toString("hex"));
assert.deepEqual(await call("list_strings", { address: item.address }), [ assert.deepEqual(await call("list_strings", { address: item.address }), [
item, item,
@@ -225,7 +329,7 @@ export async function verifyHopperStringObjects(call, expectations) {
); );
return { return {
sourceBytesVerified: true, sourceBytesVerified: true,
inferredEncodingReported: true, recognition,
displayEscapesExcludedFromMatching: true, displayEscapesExcludedFromMatching: true,
}; };
} }
+30 -24
View File
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { assertCleanLinuxHopperRuntime } from "./lib/clean-linux-hopper-runtime.mjs";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const verifierRun = createVerifierRun(); const verifierRun = createVerifierRun();
@@ -19,12 +20,12 @@ const command = process.env.REA_VERIFY_SERVER_COMMAND;
if (process.platform !== "linux" || process.getuid?.() !== 0) if (process.platform !== "linux" || process.getuid?.() !== 0)
throw new Error("Linux setup verification requires a root Linux runner"); throw new Error("Linux setup verification requires a root Linux runner");
if (!command) throw new Error("REA_VERIFY_SERVER_COMMAND is required"); if (!command) throw new Error("REA_VERIFY_SERVER_COMMAND is required");
await assertCleanLinuxHopperRuntime();
try { try {
await access("/opt/hopper/bin/Hopper"); await access("/opt/hopper/bin/Hopper");
throw new Error("Linux setup verification requires a clean runner"); throw new Error("Linux setup verification requires a clean runner");
} catch (error) { } catch (error) {
if (error instanceof Error && error.message.includes("clean runner")) if (error?.code !== "ENOENT") throw error;
throw error;
} }
const runJson = async (args) => { const runJson = async (args) => {
@@ -35,14 +36,25 @@ const runJson = async (args) => {
return JSON.parse(stdout); return JSON.parse(stdout);
}; };
const plan = await runJson(["setup", "--json"]); const plan = await runJson([
if (plan.status !== "needs_confirmation") "setup",
throw new Error("setup did not produce a read-only confirmation plan"); "--install-hopper",
"--dry-run",
"--json",
]);
if (
plan.status !== "planned" ||
!plan.plannedActions.some(({ kind }) => kind === "install_hopper") ||
plan.appliedActions.length !== 0
)
throw new Error("setup did not produce a read-only Hopper installation plan");
await access("/opt/hopper/bin/Hopper").then( await access("/opt/hopper/bin/Hopper").then(
() => { () => {
throw new Error("setup plan mutated the clean runner"); throw new Error("setup plan mutated the clean runner");
}, },
() => undefined, (error) => {
if (error?.code !== "ENOENT") throw error;
},
); );
const installed = await runJson([ const installed = await runJson([
@@ -58,7 +70,7 @@ if (
throw new Error( throw new Error(
`approved setup did not become ready: ${JSON.stringify(installed)}`, `approved setup did not become ready: ${JSON.stringify(installed)}`,
); );
const doctor = await runJson(["doctor", "--json"]); const doctor = await runJson(["doctor", "--provider", "hopper", "--json"]);
if (doctor.healthy !== true) if (doctor.healthy !== true)
throw new Error(`doctor is unhealthy: ${JSON.stringify(doctor)}`); throw new Error(`doctor is unhealthy: ${JSON.stringify(doctor)}`);
@@ -67,8 +79,6 @@ await execFileAsync(command, ["analyze", "/usr/bin/true"], {
timeout: 180_000, timeout: 180_000,
maxBuffer: 10 * 1024 * 1024, maxBuffer: 10 * 1024 * 1024,
}); });
process.env.HOPPER_TARGET_PATH = "/usr/bin/true";
process.env.HOPPER_SECOND_TARGET_PATH = "/usr/bin/false";
process.env.REA_VERIFY_SERVER_ARGS_JSON = '["mcp"]'; process.env.REA_VERIFY_SERVER_ARGS_JSON = '["mcp"]';
await import("./verify-real-hopper-linux.mjs"); await import("./verify-real-hopper-linux.mjs");
@@ -95,28 +105,24 @@ try {
await rm(modifiedDirectory, { recursive: true, force: true }); await rm(modifiedDirectory, { recursive: true, force: true });
} }
const repeated = await runJson([ const repeated = await runJson(["setup", "--yes", "--json"]);
"setup",
"--yes",
"--install-hopper",
"--json",
]);
if ( if (
repeated.status !== "ready" || repeated.status !== "ready" ||
repeated.appliedActions.includes("installed_hopper") repeated.plannedActions.length !== 0 ||
repeated.appliedActions.length !== 0
) )
throw new Error("a second setup was not idempotent"); throw new Error("a second setup was not idempotent");
const { stdout: processes } = await execFileAsync("ps", [ await assertCleanLinuxHopperRuntime();
"-ax", const completedVerifierRun = await completeVerifierRun(verifierRun);
"-o", if (
"command=", completedVerifierRun.process_lineage.status !== "verified" ||
]); completedVerifierRun.process_lineage.descendants.length !== 0
if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(processes)) )
throw new Error("Linux setup verification leaked a Hopper demo session"); throw new Error("Linux setup verification did not prove owned child cleanup");
console.log( console.log(
JSON.stringify({ JSON.stringify({
verifier_run: await completeVerifierRun(verifierRun), verifier_run: completedVerifierRun,
status: "verified", status: "verified",
setup: true, setup: true,
cli: true, cli: true,
+45 -1
View File
@@ -1,6 +1,8 @@
import { mkdtemp, rm } from "node:fs/promises"; import { mkdir, mkdtemp, rm, unlink } from "node:fs/promises";
import { execFile } from "node:child_process";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { promisify } from "node:util";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js"; import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
import { PRODUCT_IDENTITY } from "../dist/identity.js"; import { PRODUCT_IDENTITY } from "../dist/identity.js";
@@ -21,12 +23,19 @@ import { verifyRepositorySkill } from "./verify-repository-skill.mjs";
import { verifyManaged } from "./verify-package-managed.mjs"; import { verifyManaged } from "./verify-package-managed.mjs";
import { verifyUnknownProvider } from "./verify-package-unknown-provider.mjs"; import { verifyUnknownProvider } from "./verify-package-unknown-provider.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import {
createVerifiedPackageMetadata,
retainVerifiedPackage,
writeVerifiedPackageManifest,
} from "./ci/verified-package.mjs";
import { import {
measureMcpStartup, measureMcpStartup,
profileMcpModuleLoading, profileMcpModuleLoading,
} from "./lib/mcp-startup-probe.mjs"; } from "./lib/mcp-startup-probe.mjs";
const root = process.cwd(); const root = process.cwd();
const execFileAsync = promisify(execFile);
const retentionDirectory = process.env.REA_VERIFIED_PACKAGE_DIRECTORY;
const verifierRun = createVerifierRun(); const verifierRun = createVerifierRun();
const workspace = await mkdtemp(join(tmpdir(), "rea-package-")); const workspace = await mkdtemp(join(tmpdir(), "rea-package-"));
const evidenceRoot = join(workspace, "evidence"); const evidenceRoot = join(workspace, "evidence");
@@ -37,6 +46,8 @@ let mcpModuleLoading;
let update; let update;
let repositorySkill; let repositorySkill;
let report; let report;
let retainedPackage;
let retentionMetadata;
const runStage = async (name, action) => { const runStage = async (name, action) => {
const started = performance.now(); const started = performance.now();
@@ -49,6 +60,27 @@ const runStage = async (name, action) => {
}; };
try { try {
if (retentionDirectory !== undefined && retentionDirectory !== "") {
const sourceSha = (
await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root })
).stdout.trim();
retentionMetadata = createVerifiedPackageMetadata({
sourceSha,
runId: process.env.GITHUB_RUN_ID,
runAttempt: process.env.GITHUB_RUN_ATTEMPT,
platform: process.platform,
architecture: process.arch,
packageVersion: PRODUCT_IDENTITY.packageVersion,
nodeVersion: process.version,
});
await mkdir(retentionDirectory, { recursive: true });
await unlink(join(retentionDirectory, "manifest.json")).catch((cause) => {
if (
!(cause instanceof Error && "code" in cause && cause.code === "ENOENT")
)
throw cause;
});
}
repositorySkill = await runStage("repository skill", () => repositorySkill = await runStage("repository skill", () =>
verifyRepositorySkill({ root, workspace }), verifyRepositorySkill({ root, workspace }),
); );
@@ -215,6 +247,13 @@ try {
mcpStartup, mcpStartup,
mcpModuleLoading, mcpModuleLoading,
}; };
if (retentionMetadata !== undefined) {
retainedPackage = await retainVerifiedPackage({
sourceTarball: tarball,
directory: retentionDirectory,
metadata: retentionMetadata,
});
}
} finally { } finally {
await runStage("workspace cleanup", () => await runStage("workspace cleanup", () =>
rm(workspace, { recursive: true, force: true }), rm(workspace, { recursive: true, force: true }),
@@ -223,6 +262,11 @@ try {
const verifierLineage = await runStage("verifier lineage", () => const verifierLineage = await runStage("verifier lineage", () =>
completeVerifierRun(verifierRun), completeVerifierRun(verifierRun),
); );
if (retainedPackage !== undefined)
await writeVerifiedPackageManifest(
retainedPackage.directory,
retainedPackage.metadata,
);
process.stdout.write( process.stdout.write(
`${JSON.stringify({ verifier_run: verifierLineage, ...report })}\n`, `${JSON.stringify({ verifier_run: verifierLineage, ...report })}\n`,
); );
+150 -3
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env node #!/usr/bin/env node
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { estimateTokenCount } from "tokenx";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { readFile, writeFile, readdir } from "node:fs/promises"; import { readFile, writeFile, readdir, mkdir, rm } from "node:fs/promises";
import { isAbsolute, join } from "node:path"; import { isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { promisify } from "node:util"; import { promisify } from "node:util";
@@ -91,6 +92,20 @@ const source = pcap(1, [
// LINKTYPE_BLUETOOTH_HCI_H4 = 187; H4 command packet, HCI Reset opcode 0x0c03, no parameters. // LINKTYPE_BLUETOOTH_HCI_H4 = 187; H4 command packet, HCI Reset opcode 0x0c03, no parameters.
const bluetoothSource = pcap(187, [Buffer.from("01030c00", "hex")]); const bluetoothSource = pcap(187, [Buffer.from("01030c00", "hex")]);
let cases = 0; let cases = 0;
const discoveryMeasurements = [];
const inheritedAddressSpace = (await readFile("/proc/self/limits", "utf8"))
.split("\n")
.find((line) => line.startsWith("Max address space "))
?.trim()
.match(/^Max address space\s+(\S+)\s+(\S+)\s+bytes$/u);
assert.ok(inheritedAddressSpace);
const expectedAddressSpace = Math.min(
1073741824,
...inheritedAddressSpace
.slice(1)
.map((value) => (value === "unlimited" ? Infinity : Number(value))),
);
try { try {
await writeFile(path, source); await writeFile(path, source);
await writeFile(bluetooth, bluetoothSource); await writeFile(bluetooth, bluetoothSource);
@@ -121,14 +136,16 @@ try {
.digest("hex"), .digest("hex"),
); );
assert.equal(report.hardware_acquisition, "not-performed"); assert.equal(report.hardware_acquisition, "not-performed");
assert.equal(report.limits.address_space_bytes, 1073741824); assert.equal(report.limits.address_space_bytes, expectedAddressSpace);
assert.equal(report.tool.configured_path, process.env.REA_TSHARK_COMMAND); assert.equal(report.tool.configured_path, process.env.REA_TSHARK_COMMAND);
assert.equal( assert.equal(
report.resource_limiter.configured_path, report.resource_limiter.configured_path,
process.env.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit", process.env.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
); );
assert.ok( assert.ok(
report.resource_limiter.arguments.includes("--as=1073741824:1073741824"), report.resource_limiter.arguments.includes(
`--as=${expectedAddressSpace}:${expectedAddressSpace}`,
),
); );
assert.equal(report.configuration, "private-empty-personal-profile"); assert.equal(report.configuration, "private-empty-personal-profile");
const flags = Object.entries(input) const flags = Object.entries(input)
@@ -313,6 +330,133 @@ try {
true, true,
); );
cases++; cases++;
const discovery = (await client.listTools()).tools.find(
({ name }) => name === "inspect_packet_fields",
);
assert.ok(discovery?.outputSchema);
assert.ok(ajv.validateSchema(discovery.inputSchema));
assert.ok(ajv.validateSchema(discovery.outputSchema));
const nativeProfile = join(root.path, "native-profile");
await mkdir(nativeProfile);
const native = (
await execute(process.env.REA_TSHARK_COMMAND, ["-G", "fields"], {
env: { ...environment, WIRESHARK_CONFIG_DIR: nativeProfile },
timeout: 30000,
maxBuffer: 32 * 1024 * 1024,
})
).stdout;
await rm(nativeProfile, { recursive: true });
const nativeLines = native.split(/\r?\n/);
if (nativeLines.at(-1) === "") nativeLines.pop();
for (const input of [
{ query: "usb" },
{ limit: 1 },
{ offset: 1, limit: 2 },
{ kind: "fields", protocol: "usb", query: "idVendor" },
{ kind: "fields", protocol: "udp", limit: null },
{ kind: "fields", query: "rea_no_such_field" },
]) {
const started = performance.now();
const response = await client.callTool({
name: discovery.name,
arguments: input,
});
assert.notEqual(response.isError, true, JSON.stringify(response));
assert.ok(
ajv.validate(discovery.outputSchema, response.structuredContent),
JSON.stringify(ajv.errors),
);
const evidence = parseEvidence(response.structuredContent);
const report = evidence.normalized_result;
const kind = input.kind ?? "protocols";
const query = (input.query ?? "").toLowerCase();
const expectedMatches = nativeLines.flatMap((line, index) => {
const columns = line.split("\t");
if ((kind === "protocols") !== (columns[0] === "P")) return [];
if (
input.protocol !== undefined &&
(columns[0] === "P" ? columns[2] : columns[4]) !== input.protocol
)
return [];
if (
![
columns[1],
columns[2],
...(columns[0] === "F" ? [columns[7]] : []),
].some((value) => value.toLowerCase().includes(query))
)
return [];
return [{ sourceLine: index + 1, columns }];
});
const offset = input.offset ?? 0;
const limit = input.limit === null ? undefined : (input.limit ?? 50);
const expectedPage = expectedMatches.slice(
offset,
limit === undefined ? undefined : offset + limit,
);
assert.deepEqual(
report.records.map((record) => record.source_line),
expectedPage.map((record) => record.sourceLine),
);
assert.deepEqual(report.output_window, {
examined: nativeLines.filter((line) =>
line.startsWith(kind === "protocols" ? "P\t" : "F\t"),
).length,
matching: expectedMatches.length,
returned: expectedPage.length,
has_more:
Math.max(0, expectedMatches.length - offset) > expectedPage.length,
});
discoveryMeasurements.push({
input,
elapsed_ms: +(performance.now() - started).toFixed(2),
response_bytes: Buffer.byteLength(JSON.stringify(response)),
estimated_tokens_one_tool_request_response: estimateTokenCount(
JSON.stringify(discovery) +
JSON.stringify(input) +
JSON.stringify(response),
),
});
for (const record of report.records) {
const columns = nativeLines[record.source_line - 1].split("\t");
assert.equal(record.name, columns[1]);
assert.equal(record.abbreviation, columns[2]);
assert.equal(record.field_type, columns[0] === "F" ? columns[3] : null);
assert.equal(
record.parent_protocol,
columns[0] === "F" ? columns[4] : null,
);
assert.equal(record.description, columns[0] === "F" ? columns[7] : null);
assert.equal(record.display_base, columns[0] === "F" ? columns[5] : null);
assert.equal(record.bitmask, columns[0] === "F" ? columns[6] : null);
}
if (input.query === "idVendor")
assert.ok(
report.records.some(
(row) =>
row.abbreviation === "usb.idVendor" &&
row.field_type === "FT_UINT16",
),
);
if (input.query === "usb")
assert.ok(report.records.some((row) => row.abbreviation === "usb"));
if (input.query === "rea_no_such_field")
assert.equal(report.output_window.matching, 0);
const flags = Object.entries(input).flatMap(([key, value]) =>
value === null ? ["--all"] : [`--${key}`, String(value)],
);
const cli = JSON.parse(
(
await execute(
process.execPath,
[entrypoint, "inspect-packet-fields", ...flags, "--format", "json"],
{ env: environment, timeout: 60000, maxBuffer: 16 * 1024 * 1024 },
)
).stdout,
);
assert.deepEqual(cli.normalized_result, report);
cases++;
}
await client.close(); await client.close();
assert.deepEqual((await readdir(root.path)).sort(), [ assert.deepEqual((await readdir(root.path)).sort(), [
"bluetooth.pcap", "bluetooth.pcap",
@@ -329,6 +473,9 @@ console.log(
{ {
status: "passed", status: "passed",
cases, cases,
discovery_measurements: discoveryMeasurements,
measurement_scope:
"Warm MCP server, fresh TShark processes; tokenx estimates include one advertised tool/request/response, not billed usage. Model-driven selection, CPU and peak process-family memory are unmeasured; no cache/speedup claim.",
host: `${process.platform}/${process.arch}`, host: `${process.platform}/${process.arch}`,
scope: scope:
"real TShark CLI/MCP; synthetic Ethernet/IPv4/UDP/DNS and Bluetooth HCI PCAP; no hardware acquisition", "real TShark CLI/MCP; synthetic Ethernet/IPv4/UDP/DNS and Bluetooth HCI PCAP; no hardware acquisition",
@@ -0,0 +1,313 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../dist/domain/evidence.js";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
if (process.argv.length !== 2 || process.platform !== "linux")
throw new Error("Run this ELF verifier on Linux without arguments.");
if (!process.env.GHIDRA_INSTALL_DIR)
throw new Error("Set GHIDRA_INSTALL_DIR to a caller-installed Ghidra.");
const exec = promisify(execFile);
const run = createVerifierRun();
const root = fileURLToPath(new URL("..", import.meta.url));
const workspace = await mkdtemp(join(tmpdir(), "rea-address-context-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const path = join(workspace, "pie.elf");
const entrypoint = join(root, "scripts/rea.mjs");
const env = {
...process.env,
TMPDIR: runtime,
REA_ANALYSIS_PROVIDER: "ghidra",
REA_LOG_LEVEL: "silent",
};
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({ name: "rea-real-address-context", version: "1" });
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
let report;
let connected = false;
let primaryFailure;
let verifierRun;
try {
const compiler = process.env.CC ?? "gcc";
const version = await exec(compiler, ["--version"], { timeout: 10000 });
await exec(
compiler,
[
"-O0",
"-g",
"-fPIE",
"-pie",
"-nostdlib",
"-Wl,-e,rea_pie_entry",
"-Wl,--build-id=none",
join(root, "tests/conformance/c/pie-address-context.c"),
"-o",
path,
],
{ timeout: 30000 },
);
const bytes = await readFile(path);
assert.equal(bytes.subarray(0, 6).toString("hex"), "7f454c460201");
assert.equal(bytes.readUInt16LE(16), 3); // ET_DYN: source-owned x86-64 PIE.
assert.equal(bytes.readUInt16LE(18), 62);
const declaredEntry = `0x${bytes.readBigUInt64LE(24).toString(16)}`;
const sha256 = createHash("sha256").update(bytes).digest("hex");
await client.connect(transport);
connected = true;
await call("open_binary", { path, provider_id: "ghidra" });
const overview = parseEvidence(await call("binary_overview"));
assert.equal(overview.subject.digest.sha256, sha256);
const base = overview.normalized_result.image_base;
assert.match(base, /^0x[0-9a-f]+$/u);
assert.notEqual(base, "0x0");
const active = await call("binary_session");
const image = parseEvidence(await call("inspect_native_load_image"));
assert.equal(base, image.normalized_result.observations.image_base);
assert.ok(overview.normalized_result.segments.length > 0);
assert.ok(
overview.normalized_result.segments.every(
(segment) =>
segment.image_base === base &&
typeof segment.address_space === "string",
),
);
const procedures = parseEvidence(await call("list_procedures"));
const entry = procedures.normalized_result.find(
(item) => item.value === "rea_pie_entry",
);
const addition = procedures.normalized_result.find(
(item) => item.value === "rea_pie_add",
);
assert.ok(entry && addition);
assert.notEqual(entry.address, declaredEntry);
assert.ok(
image.normalized_result.observations.entry_points.includes(entry.address),
);
const mapping = image.normalized_result.observations.mappings.find(
(item) =>
item.source_file_index === 0 &&
item.file_offset !== null &&
item.address_space === "ram" &&
BigInt(item.start) <= BigInt(entry.address) &&
BigInt(item.end) >= BigInt(entry.address),
);
assert.ok(mapping);
const offset =
mapping.file_offset + Number(BigInt(entry.address) - BigInt(mapping.start));
const reverseMapping = parseEvidence(
await call("address_to_file_offset", { address: entry.address }),
);
assert.equal(reverseMapping.normalized_result.file_offset, offset);
const memory = parseEvidence(
await call("read_bytes", { address: entry.address, length: 8 }),
);
assert.equal(
memory.normalized_result.bytes_hex,
bytes.subarray(offset, offset + 8).toString("hex"),
);
const fromOffset = parseEvidence(
await call("procedure_pseudo_code", {
procedure: `file-offset:0x${offset.toString(16)}`,
}),
);
assert.ok(
fromOffset.locations.some(
(location) =>
location.kind === "file-offset" && location.offset === offset,
),
);
assert.equal(fromOffset.raw_result.selection.mapped_address, entry.address);
const fromNumericOffset = parseEvidence(
await call("procedure_pseudo_code", {
procedure: `0x${offset.toString(16)}`,
}),
);
assert.equal(
fromNumericOffset.normalized_result,
fromOffset.normalized_result,
);
const missingAddress = "ram:0xffffffff";
const rejected = await client.callTool(
{ name: "procedure_pseudo_code", arguments: { procedure: missingAddress } },
{ timeout: 240000 },
);
assert.equal(rejected.isError, true);
assert.ok(
JSON.stringify(rejected.content).includes(`Ghidra image base: ${base}`),
);
assert.ok(JSON.stringify(rejected.content).includes("list_procedures"));
assert.ok(
JSON.stringify(rejected.content).includes("inspect_native_load_image"),
);
const pseudo = parseEvidence(
await call("procedure_pseudo_code", { procedure: addition.address }),
);
assert.match(pseudo.normalized_result, /\+\s*(?:7|0x7)\b/u);
const retained = await call("binary_session");
assert.equal(retained.analysis_run.run_id, active.analysis_run.run_id);
await call("close_binary");
const cli = async (args) =>
exec(
process.execPath,
[entrypoint, ...args, "--provider", "ghidra", "--json"],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const cliOverview = parseEvidence(
JSON.parse((await cli(["analyze", path])).stdout),
);
assert.deepEqual(cliOverview.normalized_result, overview.normalized_result);
let cliFailure;
try {
await cli(["decompile", path, missingAddress]);
} catch (cause) {
cliFailure = cause;
}
assert.ok(cliFailure && typeof cliFailure.stdout === "string");
const error = JSON.parse(cliFailure.stdout);
assert.equal(error.code, "invalid_request");
assert.ok(JSON.stringify(error).includes(`Ghidra image base: ${base}`));
for (const command of [
"rea search <path> . --kind procedures --mode regex",
"rea analyze <path>",
"rea inspect-native-load-image <path>",
])
assert.ok(JSON.stringify(error).includes(command));
const cliProcedures = parseEvidence(
JSON.parse(
(
await cli([
"search",
path,
".",
"--kind",
"procedures",
"--mode",
"regex",
])
).stdout,
),
);
const cliEntry = cliProcedures.normalized_result.find(
(item) => item.value === "rea_pie_entry",
);
assert.ok(cliEntry);
assert.equal(cliEntry.address, entry.address);
const cliImage = parseEvidence(
JSON.parse((await cli(["inspect-native-load-image", path])).stdout),
);
assert.equal(cliImage.normalized_result.observations.image_base, base);
const cliPseudo = parseEvidence(
JSON.parse((await cli(["decompile", path, cliEntry.address])).stdout),
);
const cliOffset = parseEvidence(
JSON.parse(
(await cli(["decompile", path, `--file-offset=0x${offset.toString(16)}`]))
.stdout,
),
);
assert.equal(cliOffset.normalized_result, fromOffset.normalized_result);
assert.deepEqual(cliOffset.locations, fromOffset.locations);
assert.match(cliPseudo.normalized_result, /rea_pie_add/u);
assert.deepEqual(await readFile(path), bytes);
assert.deepEqual(await readdir(runtime), []);
report = {
ok: true,
mocked: false,
provider: image.provider,
compiler: version.stdout.split("\n")[0],
sha256,
declared_elf_entry: declaredEntry,
loaded_entry: entry.address,
observed_image_base: base,
cli_mcp_overview_parity: true,
failed_lookup_guidance: true,
cli_recovery_commands: true,
loaded_decompilation: true,
file_offset_decompilation: true,
address_file_offset_round_trip: true,
source_unchanged: true,
runtime_cleanup: "complete",
};
} catch (cause) {
primaryFailure = { cause };
if (stderr) process.stderr.write(stderr);
}
const cleanupFailures = [];
// Await provider-owned cleanup before the SDK can terminate its MCP parent.
if (connected) {
try {
await call("close_binary");
} catch (cause) {
cleanupFailures.push(cause);
}
}
try {
await client.close();
} catch (cause) {
cleanupFailures.push(cause);
}
try {
await transport.close();
} catch (cause) {
cleanupFailures.push(cause);
}
try {
assert.deepEqual(await readdir(runtime), []);
verifierRun = await completeVerifierRun(run);
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
} catch (cause) {
cleanupFailures.push(cause);
}
if (cleanupFailures.length === 0) {
try {
await rm(workspace, { recursive: true, force: true });
} catch (cause) {
cleanupFailures.push(cause);
}
}
if (cleanupFailures.length > 0) {
process.stderr.write(
`Retained verifier workspace after cleanup failure: ${workspace}\n`,
);
throw new AggregateError(
primaryFailure === undefined
? cleanupFailures
: [primaryFailure.cause, ...cleanupFailures],
"Ghidra address-context verifier cleanup failed",
);
}
if (primaryFailure !== undefined) throw primaryFailure.cause;
process.stdout.write(
JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n",
);
async function call(name, args = {}) {
const result = await client.callTool(
{ name, arguments: args },
{ timeout: 240000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
return ["open_binary", "binary_session", "close_binary"].includes(name)
? requireMcpOperationResult(result, name)
: result.structuredContent;
}
+6
View File
@@ -28,6 +28,7 @@ import {
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs"; import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
import { verifyGhidraFileOffsets } from "./lib/real-ghidra-file-offsets.mjs";
const verifierRun = createVerifierRun(); const verifierRun = createVerifierRun();
const SESSION_CAPABILITIES = GHIDRA_SESSION_CAPABILITIES.filter( const SESSION_CAPABILITIES = GHIDRA_SESSION_CAPABILITIES.filter(
@@ -300,6 +301,11 @@ try {
} }
if (report === undefined) if (report === undefined)
throw new Error("Windows Ghidra report was not produced"); throw new Error("Windows Ghidra report was not produced");
report.file_offsets = await verifyGhidraFileOffsets(
targetPath,
resolve(root, "scripts", "rea.mjs"),
architecture === "x86" ? "0x401010" : "0x140001010",
);
process.stdout.write( process.stdout.write(
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), ...report })}\n`, `${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), ...report })}\n`,
); );
+4 -1
View File
@@ -11,7 +11,8 @@ createVerifierRun();
if (process.platform !== "linux") if (process.platform !== "linux")
throw new Error("Linux real-Hopper verification must run on Linux"); throw new Error("Linux real-Hopper verification must run on Linux");
const launcher = process.env.HOPPER_LAUNCHER_PATH ?? "/opt/hopper/bin/Hopper"; const configuredLauncher = process.env.HOPPER_LAUNCHER_PATH;
const launcher = configuredLauncher ?? "/opt/hopper/bin/Hopper";
await access(launcher); await access(launcher);
await access("/usr/bin/Xvfb"); await access("/usr/bin/Xvfb");
await access("/usr/bin/xauth"); await access("/usr/bin/xauth");
@@ -28,5 +29,7 @@ try {
process.env.HOPPER_LAUNCHER_PATH = alternateLauncher; process.env.HOPPER_LAUNCHER_PATH = alternateLauncher;
await import("./verify-real-hopper.mjs"); await import("./verify-real-hopper.mjs");
} finally { } finally {
if (configuredLauncher === undefined) delete process.env.HOPPER_LAUNCHER_PATH;
else process.env.HOPPER_LAUNCHER_PATH = configuredLauncher;
await rm(alternateDirectory, { recursive: true, force: true }); await rm(alternateDirectory, { recursive: true, force: true });
} }
+104 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { estimateTokenCount } from "tokenx";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { readFile, writeFile, readdir } from "node:fs/promises"; import { readFile, writeFile, readdir } from "node:fs/promises";
@@ -68,6 +69,20 @@ for (const byte of Buffer.from("REA")) {
lines.push(`#${tick + 1000}`, "1!"); lines.push(`#${tick + 1000}`, "1!");
const source = lines.join("\n") + "\n"; const source = lines.join("\n") + "\n";
let cases = 0; let cases = 0;
const discoveryMeasurements = [];
const inheritedAddressSpace = (await readFile("/proc/self/limits", "utf8"))
.split("\n")
.find((line) => line.startsWith("Max address space "))
?.trim()
.match(/^Max address space\s+(\S+)\s+(\S+)\s+bytes$/u);
assert.ok(inheritedAddressSpace);
const expectedAddressSpace = Math.min(
1073741824,
...inheritedAddressSpace
.slice(1)
.map((value) => (value === "unlimited" ? Infinity : Number(value))),
);
try { try {
await writeFile(path, source); await writeFile(path, source);
await client.connect(transport); await client.connect(transport);
@@ -104,14 +119,16 @@ try {
createHash("sha256").update(source).digest("hex"), createHash("sha256").update(source).digest("hex"),
); );
assert.equal(report.hardware_acquisition, "not-performed"); assert.equal(report.hardware_acquisition, "not-performed");
assert.equal(report.limits.address_space_bytes, 1073741824); assert.equal(report.limits.address_space_bytes, expectedAddressSpace);
assert.equal(report.tool.configured_path, process.env.REA_SIGROK_COMMAND); assert.equal(report.tool.configured_path, process.env.REA_SIGROK_COMMAND);
assert.equal( assert.equal(
report.resource_limiter.configured_path, report.resource_limiter.configured_path,
process.env.REA_SIGROK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit", process.env.REA_SIGROK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
); );
assert.ok( assert.ok(
report.resource_limiter.arguments.includes("--as=1073741824:1073741824"), report.resource_limiter.arguments.includes(
`--as=${expectedAddressSpace}:${expectedAddressSpace}`,
),
); );
assert.equal(report.diagnostics, ""); assert.equal(report.diagnostics, "");
const flags = Object.entries(input) const flags = Object.entries(input)
@@ -245,6 +262,88 @@ try {
}); });
assert.equal(invalid.isError, true); assert.equal(invalid.isError, true);
cases++; cases++;
const discoveryTool = (await client.listTools()).tools.find(
({ name }) => name === "inspect_signal_decoders",
);
assert.ok(discoveryTool?.outputSchema);
assert.ok(ajv.validateSchema(discoveryTool.inputSchema));
assert.ok(ajv.validateSchema(discoveryTool.outputSchema));
const nativeHelp = (
await execute(
process.env.REA_SIGROK_COMMAND,
["--dont-scan", "--protocol-decoders=uart", "--show"],
{ env: environment, timeout: 30000, maxBuffer: 4 * 1024 * 1024 },
)
).stdout.split(/\r?\n/);
if (nativeHelp.at(-1) === "") nativeHelp.pop();
for (const input of [
{ query: "uart" },
{ limit: 1 },
{ offset: 1, limit: 2 },
{ decoder: "uart", query: "BaUd" },
{ decoder: "uart", offset: 50, limit: null },
{ query: "rea_no_such_decoder" },
]) {
const started = performance.now();
const response = await client.callTool({
name: discoveryTool.name,
arguments: input,
});
assert.notEqual(response.isError, true, JSON.stringify(response));
assert.ok(
ajv.validate(discoveryTool.outputSchema, response.structuredContent),
JSON.stringify(ajv.errors),
);
const evidence = parseEvidence(response.structuredContent);
const report = evidence.normalized_result;
discoveryMeasurements.push({
input,
elapsed_ms: +(performance.now() - started).toFixed(2),
response_bytes: Buffer.byteLength(JSON.stringify(response)),
estimated_tokens_one_tool_request_response: estimateTokenCount(
JSON.stringify(discoveryTool) +
JSON.stringify(input) +
JSON.stringify(response),
),
});
if (input.decoder) {
const matched = nativeHelp
.map((text, i) => ({ line_number: i + 1, text }))
.filter(({ text }) =>
text.toLowerCase().includes((input.query ?? "").toLowerCase()),
);
assert.deepEqual(
report.help_lines,
matched.slice(
input.offset ?? 0,
input.limit === null
? undefined
: (input.offset ?? 0) + (input.limit ?? 50),
),
);
} else if (input.query === "uart")
assert.ok(report.decoders.some(({ id }) => id === "uart"));
const flags = Object.entries(input).flatMap(([key, value]) =>
value === null ? ["--all"] : [`--${key}`, String(value)],
);
const cli = JSON.parse(
(
await execute(
process.execPath,
[entrypoint, "inspect-signal-decoders", ...flags, "--format", "json"],
{ env: environment, timeout: 60000, maxBuffer: 8 * 1024 * 1024 },
)
).stdout,
);
assert.deepEqual(cli.normalized_result, report);
cases++;
}
const unavailable = await client.callTool({
name: discoveryTool.name,
arguments: { decoder: "rea_no_such_decoder" },
});
assert.equal(unavailable.isError, true);
cases++;
await client.close(); await client.close();
assert.deepEqual((await readdir(root.path)).sort(), [ assert.deepEqual((await readdir(root.path)).sort(), [
"paged.vcd", "paged.vcd",
@@ -260,6 +359,9 @@ console.log(
{ {
status: "passed", status: "passed",
cases, cases,
discovery_measurements: discoveryMeasurements,
measurement_scope:
"Warm MCP server, fresh sigrok processes; tokenx estimates include one advertised tool/request/response, not billed model usage. No model-driven selection benchmark, CPU or peak process-family memory measurement; no cache or speedup claim.",
host: `${process.platform}/${process.arch}`, host: `${process.platform}/${process.arch}`,
scope: scope:
"real sigrok UART VCD through compiled CLI/MCP; no physical acquisition", "real sigrok UART VCD through compiled CLI/MCP; no physical acquisition",
+36
View File
@@ -662,6 +662,42 @@ setInterval(()=>{},1000);\n`,
unicodeNamesPreserved: true, unicodeNamesPreserved: true,
}); });
report.processes.windowsEnvironmentSemantics = true; report.processes.windowsEnvironmentSemantics = true;
// Observed through this process's own id: a separate probe process would
// get its own processor group, so it cannot stand in for the process that
// spawns the child.
const callerAffinity = JSON.parse(
execFileSync(boundaryFixture, ["affinity", String(process.pid)], {
encoding: "utf8",
}),
);
const affinityChild = native.call("process_spawn", [
boundaryFixture,
`${quote(boundaryFixture)} affinity`,
workspace,
environment,
]);
const observedAffinity = await wait(affinityChild);
native.call("process_close", [affinityChild.handle]);
assert.equal(observedAffinity.exitCode, 0);
const childAffinity = JSON.parse(observedAffinity.output);
// Masks cross as hexadecimal strings because a mask can exceed the
// safe-integer range. Sharing one processor group makes the caller's mask
// applicable, so it must be inherited exactly. Windows placing the child in
// another group is only permitted when the caller asked for no bound of its
// own, which its group's whole active set establishes; a narrower mask has
// to fail the spawn rather than widen to that group's default.
const sharedGroup =
callerAffinity.singleProcessorGroup &&
childAffinity.singleProcessorGroup &&
callerAffinity.processorGroup === childAffinity.processorGroup;
if (sharedGroup)
assert.equal(childAffinity.affinityMask, callerAffinity.affinityMask);
else assert.equal(callerAffinity.affinityMask, callerAffinity.systemMask);
report.processes.callerAffinityBoundsOwnedProcessOrRefusesToWiden = true;
report.callerAffinity = {
...callerAffinity,
sharedGroupWithChild: sharedGroup,
};
} }
const ownerFixture = join(workspace, "owner.mjs"); const ownerFixture = join(workspace, "owner.mjs");
const ownerPidFile = join(workspace, "crash-child.pid"); const ownerPidFile = join(workspace, "crash-child.pid");
+1 -1
View File
@@ -114,7 +114,7 @@ def shared_blocks(root, path, source):
} }
from website_i18n import language_markup from website_i18n import language_markup
selector, alternates = language_markup(root, path.relative_to(site).as_posix()) selector, alternates = language_markup(root, path.relative_to(site).as_posix())
values["language_picker"] = selector if not metadata.noindex else "" values["language_picker"] = indent(selector, " ") if not metadata.noindex else ""
values["alternates"] = alternates if not metadata.noindex else "" values["alternates"] = alternates if not metadata.noindex else ""
templates = root / "website/templates" templates = root / "website/templates"
return { return {
+20 -2
View File
@@ -84,6 +84,24 @@ describe("parseAdbDevicesOutput", () => {
]); ]);
}); });
it("accepts complete multiword states and rejects state prefixes", () => {
const output = [
"serial-no-permission no permissions usb:1-1",
"serial-bad-device deviceXYZ ",
"serial-bad-offline offline-ish",
"serial-bad-auth unauthorizedXYZ",
].join("\n");
const { devices, unparsedLines } = parseAdbDevicesOutput(output);
expect(devices.map(({ serial, state }) => [serial, state])).toEqual([
["serial-no-permission", "no permissions"],
]);
expect(unparsedLines).toEqual([
"serial-bad-device deviceXYZ ",
"serial-bad-offline offline-ish",
"serial-bad-auth unauthorizedXYZ",
]);
});
it("returns unrecognized lines instead of dropping them", () => { it("returns unrecognized lines instead of dropping them", () => {
const output = [ const output = [
"List of devices attached", "List of devices attached",
@@ -194,10 +212,10 @@ describe("parsePackageListOutput", () => {
it("keeps unrecognized lines for partial coverage reporting", () => { it("keeps unrecognized lines for partial coverage reporting", () => {
const { packages, unparsedLines } = parsePackageListOutput( const { packages, unparsedLines } = parsePackageListOutput(
"package:/data/app/x/base.apk=com.ok\ngarbage line\n", "package:/data/app/x/base.apk=com.ok\n garbage line \n",
); );
expect(packages).toHaveLength(1); expect(packages).toHaveLength(1);
expect(unparsedLines).toEqual(["garbage line"]); expect(unparsedLines).toEqual([" garbage line "]);
}); });
}); });
+14 -25
View File
@@ -10,11 +10,15 @@ import {
adbHostFileName, adbHostFileName,
type AdbHostFileNameReason, type AdbHostFileNameReason,
} from "./AdbHostFileName.js"; } from "./AdbHostFileName.js";
import {
ADB_DEVICE_STATES,
type AdbDeviceState,
} from "../domain/adb/adbDeviceAnalysis.js";
/** One device exactly as `adb devices -l` reports it. */ /** One device exactly as `adb devices -l` reports it. */
export interface AdbDeviceEntry { export interface AdbDeviceEntry {
readonly serial: string; readonly serial: string;
readonly state: string; readonly state: AdbDeviceState;
readonly transport: "usb" | "tcp" | "unknown"; readonly transport: "usb" | "tcp" | "unknown";
readonly product: string | null; readonly product: string | null;
readonly model: string | null; readonly model: string | null;
@@ -24,25 +28,6 @@ export interface AdbDeviceEntry {
readonly kind_basis: "emulator_serial_prefix" | "usb_transport" | "none"; readonly kind_basis: "emulator_serial_prefix" | "usb_transport" | "none";
} }
/**
* Device states adb prints after the serial, longest first so two-word
* states match before their single-word prefixes. A line whose remainder
* starts with none of them is not a device line.
*/
const DEVICE_STATES = [
"no permissions",
"unauthorized",
"authorizing",
"offline",
"recovery",
"sideload",
"connecting",
"bootloader",
"device",
"unknown",
"host",
] as const;
const DEVICE_LINE = /^(\S+)\s+(.*)$/u; const DEVICE_LINE = /^(\S+)\s+(.*)$/u;
const metadataValue = (remainder: string, key: string): string | null => { const metadataValue = (remainder: string, key: string): string | null => {
@@ -73,11 +58,15 @@ export const parseAdbDevicesOutput = (
continue; continue;
} }
const match = DEVICE_LINE.exec(line); const match = DEVICE_LINE.exec(line);
const state = DEVICE_STATES.find((candidate) => const state = ADB_DEVICE_STATES.find((candidate) => {
match?.[2]?.startsWith(candidate), const remainder = match?.[2];
); if (remainder === undefined || !remainder.startsWith(candidate))
return false;
const next = remainder[candidate.length];
return next === undefined || /\s/u.test(next);
});
if (match === null || state === undefined) { if (match === null || state === undefined) {
if (line.trim() !== "") unparsedLines.push(line); if (rawLine.trim() !== "") unparsedLines.push(rawLine);
continue; continue;
} }
const serial = match[1]!; const serial = match[1]!;
@@ -192,7 +181,7 @@ export const parsePackageListOutput = (
if (line === "") continue; if (line === "") continue;
const match = PACKAGE_LINE.exec(line); const match = PACKAGE_LINE.exec(line);
if (match === null) { if (match === null) {
unparsedLines.push(line); unparsedLines.push(rawLine);
continue; continue;
} }
packages.push({ packages.push({
+74
View File
@@ -121,6 +121,52 @@ fi`,
]); ]);
}); });
it("refuses device listings with an invalid state token", async () => {
const malformedLine = "serial deviceXYZ ";
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "devices" ]; then
printf 'List of devices attached\\n${malformedLine}\\n'
exit 0
fi`,
);
const result = await providerOver(binary).execute({
operation: "list_adb_devices",
input: {},
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error._tag).toBe("AnalysisProtocolError");
expect(result.error.message).toContain(malformedLine);
});
it("lists rescue and detached transports alongside online devices", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "devices" ]; then
printf 'List of devices attached\\n'
printf 'serial-online device transport_id:1\\n'
printf 'serial-rescue rescue transport_id:2\\n'
printf 'serial-detached detached transport_id:3\\n'
exit 0
fi`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "list_adb_devices",
input: {},
});
expect(result.devices).toEqual(
expect.arrayContaining([
expect.objectContaining({ serial: "serial-online", state: "device" }),
expect.objectContaining({ serial: "serial-rescue", state: "rescue" }),
expect.objectContaining({
serial: "serial-detached",
state: "detached",
}),
]),
);
});
it("inspects a device through the getprop whitelist", async () => { it("inspects a device through the getprop whitelist", async () => {
const { binary } = await writeAdbStub( const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
@@ -580,6 +626,34 @@ it("projects package details from dumpsys package output", async () => {
}); });
}); });
// Android 10 and 11 append ": restricted=true" to hard- and soft-restricted
// entries of the requested-permissions list (Settings.dumpPackageLPr).
it("counts requested permissions that the device marks as restricted", async () => {
const { binary } = await writeAdbStub(
shellStub({
"dumpsys:package": `Packages:
Package [com.example] (abcdef):
userId=10234
versionName=1.2.3
requested permissions:
android.permission.INTERNET
android.permission.WRITE_EXTERNAL_STORAGE: restricted=true
android.permission.CAMERA
install permissions:
android.permission.INTERNET: granted=true
`,
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_package",
input: { serial: "emulator-5554", package: "com.example" },
});
expect(result).toMatchObject({
requested_permissions_count: 3,
coverage: "complete",
});
});
it("reports an uninstalled package from dumpsys refusal output", async () => { it("reports an uninstalled package from dumpsys refusal output", async () => {
const { binary } = await writeAdbStub( const { binary } = await writeAdbStub(
shellStub({ "dumpsys:package": "Unable to find package: com.absent\n" }), shellStub({ "dumpsys:package": "Unable to find package: com.absent\n" }),
+5 -1
View File
@@ -236,7 +236,11 @@ export const parseDumpsysPackageOutput = (
inPermissions = true; inPermissions = true;
continue; continue;
} }
if (inPermissions && /^\s{2,}[A-Za-z0-9._]+$/.test(line)) { // Android 10 and 11 mark restricted entries as "NAME: restricted=true".
if (
inPermissions &&
/^\s{2,}[A-Za-z0-9._]+(?:: restricted=true)?$/u.test(line)
) {
permissions += 1; permissions += 1;
continue; continue;
} }
+5 -3
View File
@@ -199,9 +199,11 @@ export class JadxMcpTransport implements Transport {
throw new Error(`JADX MCP frame exceeds ${MAX_FRAME_BYTES} bytes`); throw new Error(`JADX MCP frame exceeds ${MAX_FRAME_BYTES} bytes`);
this.#appendFrame(fragment, frameBytes); this.#appendFrame(fragment, frameBytes);
if (newline === -1) return; if (newline === -1) return;
const line = this.#frameBuffer const line = new TextDecoder("utf-8", {
.subarray(0, this.#frameLength) fatal: true,
.toString("utf8"); // Preserve a leading BOM so the JSON-RPC parser can reject it.
ignoreBOM: true,
}).decode(this.#frameBuffer.subarray(0, this.#frameLength));
this.#frameBuffer = Buffer.alloc(0); this.#frameBuffer = Buffer.alloc(0);
this.#frameLength = 0; this.#frameLength = 0;
this.onmessage?.(deserializeMessage(line)); this.onmessage?.(deserializeMessage(line));
+2 -2
View File
@@ -397,7 +397,7 @@ const workflowAvailabilityFor = ({
remediation: remediation:
"On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.", "On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.",
}; };
if (name === "inspect_packet_capture") if (name === "inspect_packet_capture" || name === "inspect_packet_fields")
return policy.packetCaptureEnabled === true return policy.packetCaptureEnabled === true
? { reason: "available", remediation: null } ? { reason: "available", remediation: null }
: { : {
@@ -405,7 +405,7 @@ const workflowAvailabilityFor = ({
remediation: remediation:
"On Linux, supply TShark 4.x via absolute REA_TSHARK_COMMAND and util-linux prlimit (REA_TSHARK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.", "On Linux, supply TShark 4.x via absolute REA_TSHARK_COMMAND and util-linux prlimit (REA_TSHARK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.",
}; };
if (name === "decode_signal_recording") if (name === "decode_signal_recording" || name === "inspect_signal_decoders")
return policy.signalRecordingEnabled === true return policy.signalRecordingEnabled === true
? { reason: "available", remediation: null } ? { reason: "available", remediation: null }
: { : {
+15 -9
View File
@@ -21,8 +21,8 @@ import {
parseFunctionDossier, parseFunctionDossier,
parseListCount, parseListCount,
parseRelatedAddresses, parseRelatedAddresses,
parseSegmentImageBase,
parseSegments, parseSegments,
parseSegmentImageBase,
} from "../domain/hopperValues.js"; } from "../domain/hopperValues.js";
import { err, ok, type Result } from "../domain/result.js"; import { err, ok, type Result } from "../domain/result.js";
import { import {
@@ -46,6 +46,7 @@ import { traceCallPath } from "./CallPathTracing.js";
import { resolveProcedureAddress } from "./ProcedureAddressResolution.js"; import { resolveProcedureAddress } from "./ProcedureAddressResolution.js";
import { traceLiteralFeature } from "./EnhancedLiteralTracing.js"; import { traceLiteralFeature } from "./EnhancedLiteralTracing.js";
import { projectNativeApiInspection } from "./native/NativeApiInspection.js"; import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
/** /**
* Composes direct provider operations into bounded reverse-engineering tools. * Composes direct provider operations into bounded reverse-engineering tools.
* Direct callers use `execute`; adapters that own validation use * Direct callers use `execute`; adapters that own validation use
@@ -528,24 +529,29 @@ export class EnhancedTools {
const segments = parseSegments(segmentsResult.value); const segments = parseSegments(segmentsResult.value);
if (!segments.ok) return segments; if (!segments.ok) return segments;
const imageBase = parseSegmentImageBase(segmentsResult.value);
const procedureCount = parseListCount(proceduresResult.value, "procedures"); const procedureCount = parseListCount(proceduresResult.value, "procedures");
if (!procedureCount.ok) return procedureCount; if (!procedureCount.ok) return procedureCount;
const stringCount = parseListCount(stringsResult.value, "strings"); const stringCount = parseListCount(stringsResult.value, "strings");
if (!stringCount.ok) return stringCount; if (!stringCount.ok) return stringCount;
const imageBase = parseSegmentImageBase(segments.value);
return ok({ return ok({
document: document.value, document: document.value,
segments: segments.value.map(({ name, start, end }) => ({ ...(imageBase === undefined ? {} : { image_base: imageBase }),
name, segments: segments.value.map(
start, ({ name, start, end, image_base, address_space }) => ({
end, name,
length: addressDistance(start, end), start,
})), end,
length: addressDistance(start, end),
...(image_base === undefined ? {} : { image_base }),
...(address_space === undefined ? {} : { address_space }),
}),
),
segment_count: segments.value.length, segment_count: segments.value.length,
procedure_count: procedureCount.value, procedure_count: procedureCount.value,
string_count: stringCount.value, string_count: stringCount.value,
...(imageBase === undefined ? {} : { image_base: imageBase }),
}); });
} }
+5 -5
View File
@@ -18,11 +18,11 @@ export const workflowAnalysisProfile = (
): AnalysisProfileCommitment => ): AnalysisProfileCommitment =>
createAnalysisProfile(REA_WORKFLOW_PROVIDER, { createAnalysisProfile(REA_WORKFLOW_PROVIDER, {
upstream_analysis_profile: upstream, upstream_analysis_profile: upstream,
...(operation === "binary_overview" || ...(operation === "binary_overview"
operation === "batch_decompile" || ? { workflow_revision: 3 }
operation === "get_call_graph" : operation === "batch_decompile" || operation === "get_call_graph"
? { workflow_revision: 2 } ? { workflow_revision: 2 }
: {}), : {}),
}); });
/** Provider identity for deterministic artifact inventories. */ /** Provider identity for deterministic artifact inventories. */
+6 -2
View File
@@ -78,7 +78,9 @@ export const readJsonObjectFile = (
value === null || value === null ||
Array.isArray(value) Array.isArray(value)
) )
throw new TypeError("JSON document root must be an object"); return err(
invalidJson(operation, "JSON document root must be an object"),
);
for (const members of scans) for (const members of scans)
for (const [key, member] of members) { for (const [key, member] of members) {
if (!Object.hasOwn(value, key)) continue; if (!Object.hasOwn(value, key)) continue;
@@ -177,7 +179,9 @@ export const readJsonObjectFile = (
signal === undefined ? {} : { signal }, signal === undefined ? {} : { signal },
); );
if (!rootObject) if (!rootObject)
throw new TypeError("JSON document root must be an object"); return err(
invalidJson(operation, "JSON document root must be an object"),
);
const digest = hash.digest("hex"); const digest = hash.digest("hex");
if (previousDigest !== undefined && digest !== previousDigest) if (previousDigest !== undefined && digest !== previousDigest)
throw new RegularFileChangedError(path); throw new RegularFileChangedError(path);
+23 -4
View File
@@ -2,12 +2,16 @@ import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { installMacHopper, type MacHopperInstallHost } from "./MacHopper.js"; import {
installMacHopper,
type MacHopperBundleVerification,
type MacHopperInstallHost,
} from "./MacHopper.js";
class FakeMacHopperHost implements MacHopperInstallHost { class FakeMacHopperHost implements MacHopperInstallHost {
readonly archive = new TextEncoder().encode("hopper-dmg"); readonly archive = new TextEncoder().encode("hopper-dmg");
archiveOk = true; archiveOk = true;
valid = true; verification: MacHopperBundleVerification = "verified";
existing = false; existing = false;
mountSucceeds = true; mountSucceeds = true;
copySucceeds = true; copySucceeds = true;
@@ -15,6 +19,7 @@ class FakeMacHopperHost implements MacHopperInstallHost {
badHash = false; badHash = false;
unmounts = 0; unmounts = 0;
cleanups = 0; cleanups = 0;
installs = 0;
opens = 0; opens = 0;
replaces = false; replaces = false;
@@ -47,7 +52,7 @@ class FakeMacHopperHost implements MacHopperInstallHost {
return Promise.resolve(); return Promise.resolve();
}; };
appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app"); appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app");
validBundle = () => Promise.resolve(this.valid); validBundle = () => Promise.resolve(this.verification);
destinationExists = () => Promise.resolve(this.existing); destinationExists = () => Promise.resolve(this.existing);
installBundle = ( installBundle = (
_source: string, _source: string,
@@ -55,6 +60,7 @@ class FakeMacHopperHost implements MacHopperInstallHost {
_stage: string, _stage: string,
replaceExisting: boolean, replaceExisting: boolean,
) => { ) => {
this.installs += 1;
this.replaces = replaceExisting; this.replaces = replaceExisting;
return Promise.resolve(this.copySucceeds); return Promise.resolve(this.copySucceeds);
}; };
@@ -115,7 +121,7 @@ describe("macOS Hopper installation", () => {
it("cleans and detaches after bundle validation fails", async () => { it("cleans and detaches after bundle validation fails", async () => {
const host = new FakeMacHopperHost(); const host = new FakeMacHopperHost();
host.valid = false; host.verification = "invalid_signature";
await expect(installMacHopper({}, host)).resolves.toEqual({ await expect(installMacHopper({}, host)).resolves.toEqual({
status: "failed", status: "failed",
reason: "bundle", reason: "bundle",
@@ -123,4 +129,17 @@ describe("macOS Hopper installation", () => {
expect(host.unmounts).toBe(1); expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1); expect(host.cleanups).toBe(1);
}); });
it("reports a publisher mismatch without copying or launching the bundle", async () => {
const host = new FakeMacHopperHost();
host.verification = "unverified_publisher";
await expect(installMacHopper({}, host)).resolves.toEqual({
status: "failed",
reason: "publisher_verification_failed",
});
expect(host.installs).toBe(0);
expect(host.opens).toBe(0);
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
}); });
+52 -22
View File
@@ -24,7 +24,35 @@ const execFileAsync = promisify(execFile);
const RELEASES_URL = const RELEASES_URL =
"https://www.hopperapp.com/include/files-api.php?request=releases&public=true"; "https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/"; const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
const BUNDLE_IDENTIFIER = "com.cryptic-apps.hopper-web-4";
/**
* Apple publisher identity pinned for the official Hopper macOS bundle. The
* team identifier was captured from `Hopper-6.6.0-demo.dmg` at
* `https://www.hopperapp.com:443/downloader/public/Hopper-6.6.0-demo.dmg`
* (34846271 bytes, SHA-1 0be530f038249742590992199c7b042ea20ca955) with
* `codesign -dvvv`, which reported
* `Authority=Developer ID Application: Cryptic Apps (2AMA2753NF)`.
*/
export const HOPPER_PUBLISHER = Object.freeze({
bundleIdentifier: "com.cryptic-apps.hopper-web-4",
teamIdentifier: "2AMA2753NF",
});
/**
* Code requirement enforcing the bundle identifier and signing team together.
* The identifier is read from the sealed CodeDirectory, so it cannot be changed
* by editing `Info.plist` the way the previous string comparison could.
*/
const HOPPER_PUBLISHER_REQUIREMENT =
`= identifier "${HOPPER_PUBLISHER.bundleIdentifier}"` +
` and anchor apple generic` +
` and certificate leaf[subject.OU] = "${HOPPER_PUBLISHER.teamIdentifier}"`;
/** Outcome of reading a mounted bundle's signature and publisher identity. */
export type MacHopperBundleVerification =
| "verified"
| "invalid_signature"
| "unverified_publisher";
/** Stable network and command effects disclosed before macOS Hopper setup. */ /** Stable network and command effects disclosed before macOS Hopper setup. */
export const macHopperInstallDisclosure = { export const macHopperInstallDisclosure = {
@@ -32,6 +60,7 @@ export const macHopperInstallDisclosure = {
commands: [ commands: [
"hdiutil attach -readonly -nobrowse -mountpoint <private-mount> <verified-hopper.dmg>", "hdiutil attach -readonly -nobrowse -mountpoint <private-mount> <verified-hopper.dmg>",
"codesign --verify --deep --strict <mounted-hopper.app>", "codesign --verify --deep --strict <mounted-hopper.app>",
`codesign --verify --strict '-R${HOPPER_PUBLISHER_REQUIREMENT}' <mounted-hopper.app>`,
"ditto <mounted-hopper.app> <user-Applications-stage>", "ditto <mounted-hopper.app> <user-Applications-stage>",
"open <installed-hopper.app>", "open <installed-hopper.app>",
] as const, ] as const,
@@ -52,6 +81,7 @@ export type MacHopperInstallResult =
| "integrity" | "integrity"
| "mount" | "mount"
| "bundle" | "bundle"
| "publisher_verification_failed"
| "destination_exists" | "destination_exists"
| "copy" | "copy"
| "launcher_missing"; | "launcher_missing";
@@ -69,7 +99,7 @@ export interface MacHopperInstallHost {
mount(packagePath: string, mountPath: string): Promise<boolean>; mount(packagePath: string, mountPath: string): Promise<boolean>;
unmount(mountPath: string): Promise<void>; unmount(mountPath: string): Promise<void>;
appBundle(mountPath: string): Promise<string | undefined>; appBundle(mountPath: string): Promise<string | undefined>;
validBundle(appPath: string): Promise<boolean>; validBundle(appPath: string): Promise<MacHopperBundleVerification>;
destinationExists(path: string): Promise<boolean>; destinationExists(path: string): Promise<boolean>;
installBundle( installBundle(
source: string, source: string,
@@ -124,8 +154,12 @@ export const installMacHopper = async (
return { status: "failed", reason: "mount" }; return { status: "failed", reason: "mount" };
mounted = true; mounted = true;
const source = await host.appBundle(mountPath); const source = await host.appBundle(mountPath);
if (source === undefined || !(await host.validBundle(source))) if (source === undefined) return { status: "failed", reason: "bundle" };
const verification = await host.validBundle(source);
if (verification === "invalid_signature")
return { status: "failed", reason: "bundle" }; return { status: "failed", reason: "bundle" };
if (verification === "unverified_publisher")
return { status: "failed", reason: "publisher_verification_failed" };
const stage = `${destination}.rea-stage`; const stage = `${destination}.rea-stage`;
if ( if (
!(await host.installBundle( !(await host.installBundle(
@@ -222,25 +256,21 @@ export const systemMacHopperInstallHost = (
environment, environment,
)) ))
) )
return false; return "invalid_signature";
try { if (
const identifier = ( !(await commandSucceeds(
await execFileAsync( "codesign",
"/usr/libexec/PlistBuddy", [
[ "--verify",
"-c", "--strict",
"Print :CFBundleIdentifier", `-R${HOPPER_PUBLISHER_REQUIREMENT}`,
join(appPath, "Contents/Info.plist"), appPath,
], ],
{ env: environment }, environment,
) ))
).stdout.trim(); )
return identifier === BUNDLE_IDENTIFIER; return "unverified_publisher";
} catch (cause: unknown) { return "verified";
// best-effort cleanup: optional bundle probing; unreadable means invalid.
void cause;
return false;
}
}, },
async destinationExists(path) { async destinationExists(path) {
try { try {
+1 -1
View File
@@ -23,7 +23,7 @@ export const createProgressReporter = (
} = {}, } = {},
): ProgressReporter => { ): ProgressReporter => {
const minimumIntervalMs = options.minimumIntervalMs ?? 100; const minimumIntervalMs = options.minimumIntervalMs ?? 100;
const now = options.now ?? Date.now; const now = options.now ?? (() => performance.now());
let lastCompleted = -1; let lastCompleted = -1;
let lastSentAt = Number.NEGATIVE_INFINITY; let lastSentAt = Number.NEGATIVE_INFINITY;
let sequence = 0; let sequence = 0;
@@ -12,12 +12,14 @@ import {
detectReferenceSourceLanguage, detectReferenceSourceLanguage,
} from "../domain/referenceSourceClassification.js"; } from "../domain/referenceSourceClassification.js";
import type { HistoricalSourceGraphInput } from "../domain/referenceSourceGraph.js"; import type { HistoricalSourceGraphInput } from "../domain/referenceSourceGraph.js";
import { parseReferenceSourceImports } from "../domain/referenceSourceImportParsing.js"; import {
parseReferenceSourceImports,
PARSEABLE_REFERENCE_SOURCE_LANGUAGES,
} from "../domain/referenceSourceImportParsing.js";
import type { import type {
ReferenceSourceEntry, ReferenceSourceEntry,
ReferenceSourceRead, ReferenceSourceRead,
} from "../reference/ReferenceSourceReaderTypes.js"; } from "../reference/ReferenceSourceReaderTypes.js";
import { PARSEABLE_REFERENCE_SOURCE_LANGUAGES } from "./ReferenceSourceImportTypes.js";
import { typescriptEmittedSourceCandidates } from "./TypeScriptEmittedSourceCandidates.js"; import { typescriptEmittedSourceCandidates } from "./TypeScriptEmittedSourceCandidates.js";
export interface ParsedReferenceSourceEntries { export interface ParsedReferenceSourceEntries {
@@ -102,7 +104,11 @@ const resolveInternalSpecifier = (
]); ]);
// Retain present runtime files first; substitute only a missing counterpart. // Retain present runtime files first; substitute only a missing counterpart.
// Counterparts cover explicit filenames, not directory/index resolution. // Counterparts cover explicit filenames, not directory/index resolution.
candidates.push(...typescriptEmittedSourceCandidates(fromPath, normalized)); candidates.push(
...typescriptEmittedSourceCandidates(fromPath, normalized).map(
({ path }) => path,
),
);
const match = lookupPath.resolveCandidates(candidates); const match = lookupPath.resolveCandidates(candidates);
return match === undefined return match === undefined
? { to: normalized, resolution: "unresolved" } ? { to: normalized, resolution: "unresolved" }
@@ -87,6 +87,3 @@ export const DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS = [
".coverage", ".coverage",
"*.log", "*.log",
] as const; ] as const;
export const PARSEABLE_REFERENCE_SOURCE_LANGUAGES: ReadonlySet<string> =
new Set(["JavaScript", "TypeScript", "JSX", "TSX"]);
+40 -25
View File
@@ -19,6 +19,17 @@ export interface RegularFileReadOptions {
readonly resources?: ArtifactResourceScope; readonly resources?: ArtifactResourceScope;
} }
export class InvalidUtf8FileError extends TypeError {
readonly _tag = "InvalidUtf8FileError";
constructor(
readonly path: string,
cause: unknown,
) {
super(`File is not valid UTF-8: ${path}`, { cause });
this.name = this._tag;
}
}
export type RegularFileReadOutcome<Value> = export type RegularFileReadOutcome<Value> =
| { readonly kind: "completed"; readonly value: Value } | { readonly kind: "completed"; readonly value: Value }
| { readonly kind: "failed"; readonly cause: unknown }; | { readonly kind: "failed"; readonly cause: unknown };
@@ -142,7 +153,34 @@ const withRegularFileOwned = async <Value>(
export const readRegularFile = ( export const readRegularFile = (
path: string, path: string,
options: RegularFileReadOptions = {}, options: RegularFileReadOptions = {},
): Promise<Buffer> => ): Promise<Buffer> => readRegularFileContent(path, (bytes) => bytes, options);
/** Read valid UTF-8 without replacing source bytes or stripping an initial BOM. */
export const readRegularFileText = (
path: string,
options: RegularFileReadOptions = {},
): Promise<string> =>
readRegularFileContent(
path,
(bytes) => {
try {
return new TextDecoder("utf-8", {
fatal: true,
ignoreBOM: true,
}).decode(bytes);
} catch (cause: unknown) {
if (!(cause instanceof TypeError)) throw cause;
throw new InvalidUtf8FileError(path, cause);
}
},
options,
);
const readRegularFileContent = <Value>(
path: string,
decode: (bytes: Buffer) => Value,
options: RegularFileReadOptions,
): Promise<Value> =>
withRegularFile( withRegularFile(
path, path,
async (handle, stats) => { async (handle, stats) => {
@@ -153,30 +191,7 @@ export const readRegularFile = (
); );
if (bytes === undefined || bytes.length !== stats.size) if (bytes === undefined || bytes.length !== stats.size)
throw new RegularFileChangedError(path); throw new RegularFileChangedError(path);
return bytes; return decode(bytes);
}, },
options, options,
); );
/** Read one admitted regular file as UTF-8 without waiting on a pipe. */
export const readRegularFileText = async (
path: string,
options: RegularFileReadOptions = {},
): Promise<string> => {
try {
return (await readRegularFile(path, options)).toString("utf8");
} catch (cause: unknown) {
if (
cause instanceof RegularFileCleanupFailure &&
cause.outcome.kind === "completed" &&
Buffer.isBuffer(cause.outcome.value)
)
throw new RegularFileCleanupFailure(
cause.path,
cause.owner,
{ kind: "completed", value: cause.outcome.value.toString("utf8") },
cause.cleanupCause,
);
throw cause;
}
};
+34
View File
@@ -54,6 +54,40 @@ describe("setup workflow", () => {
expect(result.remediation).toContain("could not be updated"); expect(result.remediation).toContain("could not be updated");
}); });
it("reports a failed config rollback with its destination and recovery backup", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [{ name: "codex", configPath: "/fixture/codex.json" }];
host.clientResults.set("codex", {
status: "failed",
reason: "rollback",
backupPath: "/fixture/codex.json.rea.backup",
failure: {
kind: "restore",
path: "/fixture/codex.json",
detail: "permission denied",
},
});
const result = await runSetup(
{ ...options(true), clientIds: ["codex"] },
host,
);
expect(result.clients.codex).toEqual({
status: "failed",
reason: "rollback",
backupPath: "/fixture/codex.json.rea.backup",
failure: {
kind: "restore",
path: "/fixture/codex.json",
detail: "permission denied",
},
});
expect(result.remediation).toContain("permission denied");
expect(result.remediation).toContain("/fixture/codex.json.rea.backup");
});
it("explains skill installation recovery", async () => { it("explains skill installation recovery", async () => {
const host = new FakeSetupHost(); const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper"; host.hopper = "/Applications/Hopper";
+160 -15
View File
@@ -19,6 +19,7 @@ import {
} from "./ClientConfigurationDocument.js"; } from "./ClientConfigurationDocument.js";
import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js"; import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
import { constants as fsConstants } from "node:fs"; import { constants as fsConstants } from "node:fs";
import { createHash } from "node:crypto";
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import { dirname } from "node:path"; import { dirname } from "node:path";
import writeFileAtomic from "write-file-atomic"; import writeFileAtomic from "write-file-atomic";
@@ -29,6 +30,7 @@ import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js"; import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { import {
readRegularFile,
readRegularFileText, readRegularFileText,
RegularFileCleanupFailure, RegularFileCleanupFailure,
} from "./RegularFileRead.js"; } from "./RegularFileRead.js";
@@ -40,6 +42,26 @@ import type {
} from "./SetupTypes.js"; } from "./SetupTypes.js";
import type { SetupClient } from "./SupportedClients.js"; import type { SetupClient } from "./SupportedClients.js";
export class ClientConfigurationCleanupRollbackFailure extends RegularFileCleanupFailure<string> {
constructor(
failure: RegularFileCleanupFailure<string>,
readonly configurationFailure: Extract<
ClientConfigurationResult,
{ readonly status: "failed"; readonly reason: "rollback" }
>,
) {
super(
failure.path,
failure.owner,
failure.outcome,
new AggregateError(
[failure.cleanupCause, new Error(configurationFailure.failure.detail)],
`Configuration readback cleanup and rollback failed: ${configurationFailure.failure.path}`,
),
);
}
}
/** Configure one supported client's native stdio MCP registration shape. */ /** Configure one supported client's native stdio MCP registration shape. */
export const configureClientConfiguration = ( export const configureClientConfiguration = (
client: SetupClient, client: SetupClient,
@@ -167,11 +189,23 @@ const persistClientConfiguration = async ({
}: ClientConfigurationUpdate): Promise<ClientConfigurationResult> => { }: ClientConfigurationUpdate): Promise<ClientConfigurationResult> => {
const backupPath = const backupPath =
original === undefined ? undefined : `${client.configPath}.rea.backup`; original === undefined ? undefined : `${client.configPath}.rea.backup`;
if ( let recoveryBackupPath: string | undefined;
backupPath !== undefined && if (backupPath !== undefined && original !== undefined) {
!(await preserveConfigBackup(transactionPath, backupPath)) const selection = await selectCurrentRecoveryBackup(backupPath, original);
) if (!selection.ok) return { status: "failed", reason: "backup" };
return { status: "failed", reason: "backup" }; if (
!(await preserveConfigBackup(transactionPath, selection.value.backupPath))
)
return { status: "failed", reason: "backup" };
const verified = await selectCurrentRecoveryBackup(backupPath, original);
if (
!verified.ok ||
verified.value.backupPath !== selection.value.backupPath ||
!verified.value.present
)
return { status: "failed", reason: "backup" };
recoveryBackupPath = verified.value.backupPath;
}
// A native OpenCode V2 entry replaces REA's legacy V1 entry, which would // A native OpenCode V2 entry replaces REA's legacy V1 entry, which would
// otherwise conflict with it. // otherwise conflict with it.
const legacyPath = legacyClientServerPath( const legacyPath = legacyClientServerPath(
@@ -239,19 +273,39 @@ const persistClientConfiguration = async ({
!clientConfigurationValuesEqual(readback.servers[name], entry), !clientConfigurationValuesEqual(readback.servers[name], entry),
) )
) { ) {
await restoreConfig(transactionPath, original); return rollbackOrReadbackFailure(
return { status: "failed", reason: "readback" }; transactionPath,
original,
recoveryBackupPath,
);
} }
} catch (cause: unknown) { } catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause; if (cause instanceof RegularFileCleanupFailure) {
const rollback = await restoreConfig(transactionPath, original);
if (!rollback.ok)
throw new ClientConfigurationCleanupRollbackFailure(cause, {
status: "failed",
reason: "rollback",
...(recoveryBackupPath === undefined
? {}
: { backupPath: recoveryBackupPath }),
failure: rollback.error,
});
throw cause;
}
// Readback failure restores the transaction before reporting. // Readback failure restores the transaction before reporting.
void cause; void cause;
await restoreConfig(transactionPath, original); return rollbackOrReadbackFailure(
return { status: "failed", reason: "readback" }; transactionPath,
original,
recoveryBackupPath,
);
} }
return { return {
status: "configured", status: "configured",
...(backupPath === undefined ? {} : { backupPath }), ...(recoveryBackupPath === undefined
? {}
: { backupPath: recoveryBackupPath }),
}; };
}; };
@@ -406,9 +460,18 @@ export const inspectClientConfiguration = async (
"The existing configuration is malformed. Repair it before setup applies any other changes.", "The existing configuration is malformed. Repair it before setup applies any other changes.",
}; };
} }
const backupSelection = await selectCurrentRecoveryBackup(
`${client.configPath}.rea.backup`,
original,
);
if (!backupSelection.ok)
return {
status: "invalid",
remediation: backupSelection.error.detail,
};
return { return {
status: "update", status: "update",
backupPath: `${client.configPath}.rea.backup`, backupPath: backupSelection.value.backupPath,
}; };
}; };
@@ -473,19 +536,101 @@ const preserveConfigBackup = async (
} }
}; };
interface RecoveryBackupSelection {
readonly backupPath: string;
readonly present: boolean;
}
interface RecoveryBackupSelectionFailure {
readonly kind: "unreadable" | "occupied";
readonly path: string;
readonly detail: string;
}
const selectCurrentRecoveryBackup = async (
firstBackupPath: string,
original: string,
): Promise<Result<RecoveryBackupSelection, RecoveryBackupSelectionFailure>> => {
const expected = Buffer.from(original, "utf8");
let firstBackup: Buffer | undefined;
try {
firstBackup = await readRegularFile(firstBackupPath, {
symlinks: "reject",
});
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
if (!isMissing(cause))
return err({
kind: "unreadable",
path: firstBackupPath,
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
});
}
if (firstBackup === undefined)
return ok({ backupPath: firstBackupPath, present: false });
if (firstBackup.equals(expected))
return ok({ backupPath: firstBackupPath, present: true });
const digest = createHash("sha256").update(original, "utf8").digest("hex");
const recoveryPath = `${firstBackupPath}.${digest}.recovery`;
let recovery: Buffer | undefined;
try {
recovery = await readRegularFile(recoveryPath, { symlinks: "reject" });
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
if (!isMissing(cause))
return err({
kind: "unreadable",
path: recoveryPath,
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
});
}
if (recovery !== undefined && !recovery.equals(expected))
return err({
kind: "occupied",
path: recoveryPath,
detail: `The recovery path already contains different data: ${recoveryPath}`,
});
return ok({ backupPath: recoveryPath, present: recovery !== undefined });
};
const restoreConfig = async ( const restoreConfig = async (
path: string, path: string,
original: string | undefined, original: string | undefined,
): Promise<void> => { ): Promise<
Result<
void,
{ readonly kind: "restore"; readonly path: string; readonly detail: string }
>
> => {
try { try {
if (original === undefined) await rm(path, { force: true }); if (original === undefined) await rm(path, { force: true });
else await writeFile(path, original, { encoding: "utf8", mode: 0o600 }); else await writeFile(path, original, { encoding: "utf8", mode: 0o600 });
return ok(undefined);
} catch (cause: unknown) { } catch (cause: unknown) {
// The backup remains available for the remediation reported by setup. return err({ kind: "restore", path, detail: errorDetail(cause) });
void cause;
} }
}; };
const rollbackOrReadbackFailure = async (
targetPath: string,
original: string | undefined,
backupPath: string | undefined,
): Promise<ClientConfigurationResult> => {
const rollback = await restoreConfig(targetPath, original);
return rollback.ok
? { status: "failed", reason: "readback" }
: {
status: "failed",
reason: "rollback",
...(backupPath === undefined ? {} : { backupPath }),
failure: rollback.error,
};
};
const errorDetail = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
/** Whether REA's entry matches, no legacy entry remains, and no client disable list suppresses it. */ /** Whether REA's entry matches, no legacy entry remains, and no client disable list suppresses it. */
const registrationCurrent = ( const registrationCurrent = (
parsed: ClientConfigurationDocument, parsed: ClientConfigurationDocument,
+11 -7
View File
@@ -6,15 +6,19 @@ import type {
} from "./SetupTypes.js"; } from "./SetupTypes.js";
import type { SetupClient } from "./SupportedClients.js"; import type { SetupClient } from "./SupportedClients.js";
const failedConfigurationMessage = ( export const failedConfigurationMessage = (
reason: "path" | "backup" | "write" | "readback", result: Extract<ClientConfigurationResult, { readonly status: "failed" }>,
): string => { ): string => {
if (reason === "path") if (result.reason === "path")
return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup."; return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.";
if (reason === "backup") if (result.reason === "backup")
return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup."; return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
if (reason === "write") if (result.reason === "write")
return "Agent configuration could not be updated. Check file permissions, then rerun setup."; return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
if (result.reason === "rollback")
return result.backupPath === undefined
? `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}; the failed first-run file may remain. Remove or repair it before rerunning setup.`
: `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}. Inspect the preserved backup at ${result.backupPath} and restore the intended configuration, then rerun setup.`;
return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup."; return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.";
}; };
@@ -40,12 +44,12 @@ export const configureDetectedClients = async (options: {
); );
options.clients[client.name] = result; options.clients[client.name] = result;
if (result.status === "failed") { if (result.status === "failed") {
failure ??= failedConfigurationMessage(result.reason); failure ??= failedConfigurationMessage(result);
options.onProgress?.({ options.onProgress?.({
actionId, actionId,
label, label,
state: "failed", state: "failed",
detail: failedConfigurationMessage(result.reason), detail: failedConfigurationMessage(result),
}); });
} else if (result.status === "configured") { } else if (result.status === "configured") {
options.appliedActions.push(`configured_${client.name}`); options.appliedActions.push(`configured_${client.name}`);
+7 -1
View File
@@ -33,9 +33,11 @@ import {
} from "./SetupSkill.js"; } from "./SetupSkill.js";
import { import {
clientConfigurationAligned, clientConfigurationAligned,
ClientConfigurationCleanupRollbackFailure,
configureClientConfiguration, configureClientConfiguration,
inspectClientConfiguration, inspectClientConfiguration,
} from "./SetupClientConfiguration.js"; } from "./SetupClientConfiguration.js";
import { failedConfigurationMessage } from "./SetupClients.js";
import { setupInstallFailure } from "./SetupInstallFailure.js"; import { setupInstallFailure } from "./SetupInstallFailure.js";
import { import {
RegularFileCleanupFailure, RegularFileCleanupFailure,
@@ -275,8 +277,12 @@ const runClientConfigurationOperation = async <Value>(
cleanup.outcome.kind === "failed" cleanup.outcome.kind === "failed"
? cleanup.outcome.cause ? cleanup.outcome.cause
: cause.cleanupCause; : cause.cleanupCause;
const rollbackMessage =
cause instanceof ClientConfigurationCleanupRollbackFailure
? ` ${failedConfigurationMessage(cause.configurationFailure)}`
: "";
throw configurationCleanupError( throw configurationCleanupError(
`Could not safely access client configuration at ${cause.path}: ${errorMessage(primary)}`, `Could not safely access client configuration at ${cause.path}: ${errorMessage(primary)}.${rollbackMessage}`,
cause, cause,
cleanup.cleanup, cleanup.cleanup,
); );
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { HOPPER_PUBLISHER } from "./MacHopper.js";
import { setupInstallFailure } from "./SetupInstallFailure.js";
describe("macOS Hopper setup failure projection", () => {
it("projects a publisher mismatch onto its own code and recovery", () => {
expect(setupInstallFailure("publisher_verification_failed")).toEqual({
status: "failed",
code: "publisher_verification_failed",
remediation: `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`,
});
});
it("keeps a broken bundle distinct from a publisher mismatch", () => {
expect(setupInstallFailure("bundle")).toEqual({
status: "failed",
code: "bundle_invalid",
remediation:
"The mounted package did not contain the expected Hopper application bundle. Retry setup; if it repeats, update REA before installing.",
});
});
});
+10
View File
@@ -1,3 +1,5 @@
import { HOPPER_PUBLISHER } from "./MacHopper.js";
export type SetupFailureCode = export type SetupFailureCode =
| "unsupported_host" | "unsupported_host"
| "download_failed" | "download_failed"
@@ -10,6 +12,7 @@ export type SetupFailureCode =
| "destination_exists" | "destination_exists"
| "mount_failed" | "mount_failed"
| "bundle_invalid" | "bundle_invalid"
| "publisher_verification_failed"
| "copy_failed" | "copy_failed"
| "skill_install_failed" | "skill_install_failed"
| "cleanup_failed"; | "cleanup_failed";
@@ -39,6 +42,7 @@ export type HopperInstallFailureReason =
| "destination_exists" | "destination_exists"
| "mount" | "mount"
| "bundle" | "bundle"
| "publisher_verification_failed"
| "copy"; | "copy";
/** Map provider installer failures to stable setup codes and safe recovery. */ /** Map provider installer failures to stable setup codes and safe recovery. */
@@ -115,6 +119,12 @@ export const setupInstallFailure = (
remediation: remediation:
"The mounted package did not contain the expected Hopper application bundle. Retry setup; if it repeats, update REA before installing.", "The mounted package did not contain the expected Hopper application bundle. Retry setup; if it repeats, update REA before installing.",
}; };
case "publisher_verification_failed":
return {
status: "failed",
code: "publisher_verification_failed",
remediation: `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`,
};
case "copy": case "copy":
return { return {
status: "failed", status: "failed",
+1 -1
View File
@@ -320,5 +320,5 @@ const clientConfigurationDetail = (
? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation." ? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation."
: " The HOPPER_LAUNCHER_PATH value shown above is the current fallback; if this plan installs Hopper, REA will replace it with the launcher path reported by that installation." : " The HOPPER_LAUNCHER_PATH value shown above is the current fallback; if this plan installs Hopper, REA will replace it with the launcher path reported by that installation."
: ""; : "";
return `Add the REA MCP registration for ${client}; preserve unrelated configuration. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}`; return `Add the REA MCP registration for ${client}; preserve unrelated configuration. The backup path reflects the configuration inspected for this plan; if that file changes before execution, setup will select a recovery path for its current contents. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}`;
}; };
+10
View File
@@ -26,6 +26,16 @@ export type ClientConfigurationResult =
| { | {
readonly status: "failed"; readonly status: "failed";
readonly reason: "path" | "backup" | "write" | "readback"; readonly reason: "path" | "backup" | "write" | "readback";
}
| {
readonly status: "failed";
readonly reason: "rollback";
readonly backupPath?: string;
readonly failure: {
readonly kind: "restore";
readonly path: string;
readonly detail: string;
};
}; };
/** Read-only state used to block unsafe setup plans before any mutation. */ /** Read-only state used to block unsafe setup plans before any mutation. */
@@ -8,6 +8,11 @@ const EMITTED_SUFFIXES: ReadonlyArray<
[".jsx", [".tsx", ".d.ts"]], [".jsx", [".tsx", ".d.ts"]],
]; ];
export interface TypeScriptEmittedSourceCandidate {
readonly path: string;
readonly kind: "implementation" | "declaration";
}
/** /**
* Return TypeScript source counterparts for an explicit emitted filename. * Return TypeScript source counterparts for an explicit emitted filename.
* Callers retain exact runtime-file precedence and must not use these for * Callers retain exact runtime-file precedence and must not use these for
@@ -16,7 +21,7 @@ const EMITTED_SUFFIXES: ReadonlyArray<
export const typescriptEmittedSourceCandidates = ( export const typescriptEmittedSourceCandidates = (
sourcePath: string, sourcePath: string,
candidate: string, candidate: string,
): readonly string[] => { ): readonly TypeScriptEmittedSourceCandidate[] => {
if (!/\.(?:ts|tsx|mts|cts)$/u.test(sourcePath)) return []; if (!/\.(?:ts|tsx|mts|cts)$/u.test(sourcePath)) return [];
const emitted = EMITTED_SUFFIXES.find(([suffix]) => const emitted = EMITTED_SUFFIXES.find(([suffix]) =>
candidate.endsWith(suffix), candidate.endsWith(suffix),
@@ -25,5 +30,8 @@ export const typescriptEmittedSourceCandidates = (
// Strip the matched suffix by length. posix.extname would miss dotfile // Strip the matched suffix by length. posix.extname would miss dotfile
// names such as ".js", whose whole basename is the emitted suffix itself. // names such as ".js", whose whole basename is the emitted suffix itself.
const stem = candidate.slice(0, candidate.length - emitted[0].length); const stem = candidate.slice(0, candidate.length - emitted[0].length);
return emitted[1].map((suffix) => `${stem}${suffix}`); return emitted[1].map((suffix) => ({
path: `${stem}${suffix}`,
kind: suffix.startsWith(".d.") ? "declaration" : "implementation",
}));
}; };
+10 -2
View File
@@ -25,7 +25,11 @@ import {
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { dshDocument } from "./DshCordisPatch.js"; import { dshDocument } from "./DshCordisPatch.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { readRegularFileText } from "./RegularFileRead.js"; import {
InvalidUtf8FileError,
RegularFileCleanupFailure,
readRegularFileText,
} from "./RegularFileRead.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { import {
manualRegistrationRemediation, manualRegistrationRemediation,
@@ -269,13 +273,17 @@ const readClientConfiguration = async (
try { try {
original = await fileSystem.readText(transactionPath); original = await fileSystem.readText(transactionPath);
} catch (cause: unknown) { } catch (cause: unknown) {
const readFailure =
cause instanceof RegularFileCleanupFailure ? cause.cause : cause;
return itemRead( return itemRead(
isMissing(cause) isMissing(cause)
? item(client.name, "skipped", "Configuration does not exist.") ? item(client.name, "skipped", "Configuration does not exist.")
: item( : item(
client.name, client.name,
"failed", "failed",
"Configuration could not be read. Check file permissions, then rerun uninstall.", readFailure instanceof InvalidUtf8FileError
? "Configuration is not valid UTF-8 and was not changed. Repair it, then rerun uninstall."
: "Configuration could not be read. Check file permissions, then rerun uninstall.",
), ),
); );
} }
+1
View File
@@ -22,6 +22,7 @@ export interface WebModuleArtifacts extends SelectedWebScriptArtifacts {
/** Read selected local artifacts without refetching website assets. */ /** Read selected local artifacts without refetching website assets. */
export interface WebModuleArtifactPort { export interface WebModuleArtifactPort {
close?(): Promise<void>;
load( load(
input: WebModuleTraceInput, input: WebModuleTraceInput,
options?: ExecutionOptions, options?: ExecutionOptions,
+8 -1
View File
@@ -1,3 +1,4 @@
import { closeOwnedWebResources } from "./closeOwnedWebResources.js";
import { isAbsolute } from "node:path"; import { isAbsolute } from "node:path";
import { z } from "zod"; import { z } from "zod";
import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js";
@@ -70,7 +71,13 @@ export class WebModuleTraceService {
) {} ) {}
close(): Promise<void> { close(): Promise<void> {
return this.resolver.close?.() ?? Promise.resolve(); return closeOwnedWebResources(
[
this.artifacts.close?.bind(this.artifacts),
this.resolver.close?.bind(this.resolver),
],
"Web module trace cleanup failed",
);
} }
async trace( async trace(
+2
View File
@@ -12,4 +12,6 @@ export interface WebNetworkCapturePort {
input: InspectWebNetworkCaptureInput, input: InspectWebNetworkCaptureInput,
options?: ExecutionOptions, options?: ExecutionOptions,
): Promise<Result<WebNetworkCapture, AnalysisError>>; ): Promise<Result<WebNetworkCapture, AnalysisError>>;
/** Retry retained reader/runtime cleanup when the owning service shuts down. */
close?(): Promise<void>;
} }
@@ -27,6 +27,11 @@ const OPERATION = "inspect_web_network_capture";
export class WebNetworkCaptureService { export class WebNetworkCaptureService {
constructor(readonly port: WebNetworkCapturePort) {} constructor(readonly port: WebNetworkCapturePort) {}
/** Release resources retained by the selected capture adapter. */
close(): Promise<void> {
return this.port.close?.() ?? Promise.resolve();
}
/** Return selected complete records inline, without inventing live browser attribution. */ /** Return selected complete records inline, without inventing live browser attribution. */
async inspect( async inspect(
rawInput: unknown, rawInput: unknown,

Some files were not shown because too many files have changed in this diff Show More