Compare commits

..
Author SHA1 Message Date
gensokyo-devandClaude Opus 5.5 5b158b2d1d fix(setup): stop treating Antigravity's .gemini storage as Gemini CLI
Antigravity keeps its MCP configuration and skills under
~/.gemini/config, and Gemini CLI was detected by the bare ~/.gemini
directory. Setting up Antigravity therefore made broad setup select
Gemini CLI and write a registration nobody asked for.

Gemini CLI's applicability now follows its own evidence: settings.json,
its trusted-folder records (honoring GEMINI_CLI_TRUSTED_FOLDERS_PATH) and
its .gemini/tmp session state, through the existing discoveryPaths
owner that detection, status and uninstall share. Selecting
--client gemini_cli explicitly is unchanged.

Fixes #7567

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:32 +01:00
gensokyo-devandClaude Opus 5.5 899f77d840 fix(setup): say when an authentic Hopper package mismatches its feed hash
Linux Hopper setup verifies the package signature, then compares the
launcher it contains with the release feed's main_exe_hash. When only
that comparison failed, as with the Fedora 6.6.1 RPM, setup reported the
generic integrity failure and advised retrying or updating REA, which
cannot change a stale vendor feed.

The publisher now reports that case as launcher_digest_mismatch. Setup
keeps the public integrity_mismatch code and the refusal, and its
recovery says the download is authentic, the feed describes another
launcher build, and the mismatch needs a corrected feed. The two digests
stay in the detail. Every other integrity failure keeps its recovery.

Fixes #8138

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:32 +01:00
gensokyo-devandClaude Opus 5.5 aa848cf1d2 fix(android): name the JADX JAR download and checksum in recovery
Doctor and configuration failures for a missing or rejected
REA_JADX_MCP_JAR asked for a "caller-supplied" jadx-headless-mcp 0.7.1
JAR without saying where it comes from. The upstream project has since
renamed its packages, so users concluded the JAR could not be obtained.

The shared JADX JAR remediation now names the audited release URL and
SHA-256 that REA already pins. The two capability-availability texts
point to rea doctor --provider jadx for them instead of repeating the
release data outside its owner.

Fixes #7768

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:32 +01:00
gensokyo-devandClaude Opus 5.5 82f3bfb6e8 fix(doctor): refuse an empty --target like other target inputs
`rea doctor --target ""` passed an empty string to target readiness,
which checked the working directory instead of reporting that no app
was named. Use the shared local path schema, so an empty target fails
validation before any readiness check, as every other target does.

Fixes #7561

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:32 +01:00
gensokyo-devandClaude Opus 5.5 5557d9b3eb fix(doctor): count installations by resolved launcher directory
On Windows `where rea` lists the npm shims rea, rea.cmd and rea.ps1 that
one global install writes side by side, and doctor counted each listed
path as an installation, so a single install reported "multiple".

The system host now pairs each PATH launcher with the installation it
resolves to: its directory after symlink resolution, compared
case-insensitively on Windows. Doctor counts distinct installations and
still lists every launcher path. Two POSIX launchers that resolve to one
package also count once; an unresolvable launcher keeps its own path.

Fixes #8122

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:32 +01:00
3a13b088e6 fix(mcp): explain skipped doctor identity probes
Report identity as unobserved when the selected tool families omit
binary_session, preserving the current health decision and exit status.
Cover scoped production sessions and retain validation of successful
and schema-invalid probe responses.

Fixes #7438

[gensokyo-dev: rebased onto main; report "identity not observed" for every
request-flow reply that was not received (skipped scope, transport failure or
rejected output schema), not only skipped scopes; corrected the replayed
inventory-mode expectation, whose identity is observed and matches.]

Fixes #7438

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:32 +01:00
gensokyo-devandClaude Opus 5.5 d412569293 fix(mcp-doctor): close only through the owned transport
The owned transport is the doctor's only cleanup owner, and Client.close
only delegates to it. Close it once and report its typed failure instead
of aggregating a duplicate SDK close and clearing that failure after a
retry. Pass the filtered caller environment as the launcher's base only,
and name the doctor operation in transport cleanup errors.

Cover a descendant in its own session, which only the token sweep can
reach, and a late cleanup denial that keeps the startup diagnostic and
reports the retained run. Bound the fixture descendant's lifetime so a
failing run cannot leak it. Document the doctor's process-family
ownership and output bound.

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:23 +01:00
2SinceJuly 1b019a73d8 fix(mcp-doctor): keep the diagnostic child target-free and its stderr visible
The owned launcher merges its host base back under the explicit overrides, so
filtering only the override object let an ambient HOPPER_TARGET_* variable
through and the diagnostic child could open the target the doctor promises to
skip. Pass the filtered environment as the host base too, and opt into the
token sweep so a detached descendant that outlives the launcher is still
reached by cleanup.

Also keep the stderr observer attached until bounded cleanup and draining have
settled, report the typed cleanup resources instead of a generic close_binary
message, and drop a first-attempt cleanup failure once the owned retry proves
release.
2026-11-14 05:36:23 +01:00
2SinceJuly d427fc05c3 fix(mcp-doctor): stop the owned process family on every exit path
runProductionMcpDoctor wrapped the SDK StdioClientTransport and only joined its
close calls. That transport clears its direct-child reference, ends stdin and
signals only that child, so a server whose launcher exited after forking an
inherited-stdio descendant left the descendant running while the doctor
reported nothing but the transport deadline.

Run the doctor on OwnedMcpStdioTransport, which spawns the child in a dedicated
POSIX process group or Windows job and verifies group and token-owned
descendant cleanup before close returns. Feed stderr through a sink on
acquisition so diagnostics survive an early child exit and still honor the
64 KiB retained prefix and full byte count.

Covered by a fixture that forks a descendant, exits, and is timed out: the
regression asserts the descendant is gone after the doctor returns.
2026-11-14 05:36:23 +01:00
gensokyo-devandClaude Opus 5.5 3f7d0d2d21 test(process): drive kernel thread exclusion through Linux procfs
Replace the simulated-host sweep test with one that parses PF_KTHREAD
from a controlled /proc/<pid>/stat record through the system ownership
host, with root-matching credentials and an ESRCH environ read. The
user-process control with identical facts stays fail-closed. Name the
task flag constants and correct the comments: the reported kernel
threads were live, not exiting.

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:23 +01:00
2SinceJuly 5bb6591d58 fix(process): exonerate kernel threads from ownership token checks
An unrelated kernel thread that appeared during a provider run has no
readable /proc/<pid>/environ, so the ownership sweep recorded it as a live
candidate with an unreadable token and failed cleanup with
cleanup_incomplete. Kernel threads exit quickly, so the read usually
reports ESRCH and is not attributable to environment withholding, which
meant a cleanly finished provider run discarded its result.

Kernel threads are forked by the kernel, never by a provider, so they can
never carry that run's token. Read PF_KTHREAD from the same /proc/<pid>/stat
identity observation and treat a kernel thread with no readable token as
unverified evidence, like an unrelated process whose environment the OS
withholds. The candidate still has to be otherwise unrelated and holds no
signal authority.
2026-11-14 05:36:23 +01:00
gensokyo-devandClaude Opus 5.5 7db1b61790 fix(ghidra): qualify inferred no-return cuts by the excluded fall-through
A terminal call on one branch does not end the whole function: other
branches can still reach the following code, which stays in the body and
pseudocode. The function-level limitation now says Ghidra excluded that
call's fall-through and that code reachable only through it is missing
if the callee returns. It is listed before the call-specific facts so a
bounded composed summary that admits limitations in order keeps it.

The body-coverage tests now check the counted records and their reasons
instead of freezing the explanatory sentence.

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:34:39 +01:00
gensokyo-devandClaude Opus 5.5 ad2111fac6 fix(ghidra): say when an inferred no-return call cuts a function
Ghidra's Non-Returning Functions - Discovered analyzer can mark a
returning callee, such as an imported operator new thunk, as
non-returning. Ghidra then clears the fallthrough at each call site and
refits the callers, so a constructor decompiles to a few plausible
lines. REA reported the terminal call with the FunctionManager flag but
did not say the result omits the code after it or how to compare.

That analyzer bookmarks each entry it marks with the Analysis comment
"Non-Returning Function Found" (Ghidra 11.0 through 12.1.4 source); the
name-list Known analyzer writes "Non-Returning Function Identified".
When a terminal call's callee, or the function a thunk forwards to,
carries the Discovered bookmark, the call's limitation now says the
flag was inferred, and the function adds one address-free limitation
that its body and pseudocode end at that call and omit the following
code if the callee returns, with the REA_GHIDRA_DISABLED_ANALYZERS
comparison. A flag without the bookmark keeps an unknown origin.

The function-boundary observation contract moves to v5 so snapshots
from earlier bridges are not replayed without the qualification.

Refs #10365

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:34:39 +01:00
gensokyo-devandClaude Opus 5.5 9d082b144a fix(decompile): derive the body-coverage caveat from retained records
decompile_all set its top-level "procedure_info is unavailable" caveat
from a per-call flag that only a newly attempted function could raise.
A resume that skipped every verified file, or selected nothing, rewrote
the manifest and decompile-index.json with an empty limitations list
while every retained record still reported an unavailable body.

The export owner now answers from the records it retains: the summary
counts every non-external record whose body is unavailable, including
records an earlier run wrote, and repeats the reason when all share one.
The per-call flag is removed. A fresh export whose procedure_info works
but leaves some bodies unavailable now reports that caveat too; a later
attempt that records a known body clears it.

Fixes #8351

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:34:39 +01:00
gensokyo-devandClaude Opus 5.5 e8c838e8bd test(mcp): keep the surrogate path handler check within its deadline
The handler regression added in #10390 listed tools under the full input
profile and matched every advertised schema before calling each tool. It
took about 14 s locally and passed the 30 s deadline on loaded CI runners.
advertisedPatternsVMode.test.ts already proves that no input or output
profile advertises the surrogate escape, so this case now checks only the
runtime refusal through the default-profile handlers, as the
relative-path case does.

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:06:09 +01:00
gensokyo-devandClaude Opus 5.5 809f0d69db fix(mcp): report HTTP listener recovery from its parsed failure
rea mcp --http printed "Choose another --port or stop the process using
it" for every listener failure except a missing host allowlist, so a host
lookup failure, an unassigned address or a permission denial got port
advice. StreamableHttpListenError already classifies the native failure
and owns a recovery for each kind.

Startup now always prints that owned recovery behind one shared lead-in,
and the fixed message is removed. The native cause text stays out of
stderr as before.

Fixes #7346

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 04:57:03 +01:00
360 changed files with 4472 additions and 2588 deletions

No files matched your search

+1 -24
View File
@@ -63,18 +63,6 @@
"rea/no-shared-module-mock": "error"
},
"overrides": [
{
"files": ["src/domain/**/*.ts"],
"rules": { "rea/no-locale-compare": "error" }
},
{
"files": [
"src/domain/**/*.test.ts",
"src/domain/**/*.spec.ts",
"src/domain/**/*.fixture.ts"
],
"rules": { "rea/no-locale-compare": "off" }
},
{
"files": [
"scripts/electron-active-hook*.cjs",
@@ -88,19 +76,12 @@
{
"files": ["src/**/*.ts"],
"rules": {
"rea/no-babel-visitor-keys": "error",
"typescript/no-misused-promises": "error"
}
},
{
"files": [
"tests/**/*.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.fixture.ts"
],
"files": ["tests/**/*.ts", "src/**/*.test.ts", "src/**/*.fixture.ts"],
"rules": {
"rea/no-babel-visitor-keys": "off",
"max-lines": "off",
"max-lines-per-function": [
"error",
@@ -113,10 +94,6 @@
"typescript/no-misused-promises": "warn"
}
},
{
"files": ["src/domain/javascript/javascriptSemanticTraversal.ts"],
"rules": { "rea/no-babel-visitor-keys": "off" }
},
{
"files": ["src/application/**/*.test.ts"],
"rules": {
+49 -6
View File
@@ -77,6 +77,8 @@ import ghidra.program.model.data.StringDataInstance;
import ghidra.program.model.data.DataType;
import ghidra.program.model.lang.Register;
import ghidra.program.model.scalar.Scalar;
import ghidra.program.model.listing.Bookmark;
import ghidra.program.model.listing.BookmarkType;
import ghidra.program.model.listing.CommentType;
import ghidra.program.model.listing.Data;
import ghidra.program.model.listing.DataIterator;
@@ -3987,6 +3989,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
private final class FunctionBoundaryLimitations {
private final Function function;
private final List<String> limitations = new ArrayList<>();
private boolean inferredNoReturnCall;
private Instruction beforePrevious;
private Instruction previous;
@@ -3996,8 +3999,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
private void observe(Instruction instruction) throws Exception {
monitor.checkCancelled();
String limitation = terminalCallLimitation(instruction);
if (limitation != null) limitations.add(limitation);
TerminalCall terminal = terminalCall(instruction);
if (terminal != null) {
limitations.add(terminal.limitation());
if (terminal.inferredNoReturn()) inferredNoReturnCall = true;
}
if (beforePrevious != null && previous != null) {
String seh = borlandSehHandlerLimitation(
function, beforePrevious, previous, instruction);
@@ -4008,10 +4014,42 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
private List<String> values() {
return List.copyOf(limitations);
if (!inferredNoReturnCall) return List.copyOf(limitations);
List<String> result = new ArrayList<>();
// One consequence and recovery per function, ahead of the call-specific facts so a
// bounded summary that admits limitations in order keeps it. The text names no
// address, so those summaries also deduplicate it across functions.
result.add("A terminal call in this function targets a callee that " +
"Ghidra's Non-Returning Functions - Discovered analyzer inferred cannot return, so Ghidra " +
"excluded that call's fall-through from the analyzed body and from the pseudocode and facts " +
"derived from it. If the callee returns, code reachable only through that fall-through is " +
"missing from this result. To compare, analyze a fresh import with " +
"REA_GHIDRA_DISABLED_ANALYZERS='Non-Returning Functions - Discovered'.");
result.addAll(limitations);
return result;
}
}
/** A terminal call's limitation and whether its no-return flag was analyzer-inferred. */
private record TerminalCall(String limitation, boolean inferredNoReturn) {}
/**
* Ghidra's Non-Returning Functions - Discovered analyzer bookmarks each entry it marks with
* this Analysis comment; the name-list Known analyzer uses "Non-Returning Function Identified".
* A missing bookmark leaves the flag's origin unknown, never declared.
*/
private boolean discoveredNoReturn(Function target) {
Function thunked = target.isThunk() ? target.getThunkedFunction(true) : null;
return hasDiscoveredNoReturnBookmark(target) ||
(thunked != null && hasDiscoveredNoReturnBookmark(thunked));
}
private boolean hasDiscoveredNoReturnBookmark(Function function) {
Bookmark bookmark = currentProgram.getBookmarkManager().getBookmark(
function.getEntryPoint(), BookmarkType.ANALYSIS, "Non-Returning Function");
return bookmark != null && "Non-Returning Function Found".equals(bookmark.getComment());
}
private String borlandSehHandlerLimitation(
Function function, Instruction handlerPush, Instruction framePush,
Instruction frameLink) {
@@ -4104,9 +4142,10 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
}
private String terminalCallLimitation(Instruction instruction) {
private TerminalCall terminalCall(Instruction instruction) {
if (!instruction.getFlowType().isCall() || !instruction.getFlowType().isTerminal())
return null;
boolean inferred = false;
Address[] destinations = instruction.getFlows();
StringBuilder limitation = new StringBuilder();
limitation.append("Ghidra Listing reports a terminal call at ")
@@ -4135,14 +4174,18 @@ public final class ReaGhidraBridge extends HeadlessScript {
Function target = destinations.length == 1
? currentProgram.getFunctionManager().getFunctionAt(destinations[0])
: null;
if (target != null && target.hasNoReturn()) {
if (target != null && target.hasNoReturn() && discoveredNoReturn(target)) {
inferred = true;
limitation.append(" Ghidra's Non-Returning Functions - Discovered analyzer bookmarked this callee as a non-returning function it found; the flag is an analysis inference, not an independent verification that the callee cannot return.");
}
else if (target != null && target.hasNoReturn()) {
limitation.append(" This records Ghidra's FunctionManager flag, not an independent verification that the callee cannot return.");
}
else {
limitation.append(" The reason for the terminal flow is not established by this observation.");
}
}
return limitation.toString();
return new TerminalCall(limitation.toString(), inferred);
}
private static String renderAssembly(List<Instruction> instructions) {
+9 -1
View File
@@ -1339,7 +1339,15 @@ bytes as text, omitting a final UTF-8 code point if it crosses that byte limit.
`stderr_bytes` counts all observed bytes, including omitted output;
`stderr_truncated` indicates that output exceeded the retention limit. The same
diagnostics appear inside the report when `--full-output` is selected. Log
truncation alone does not change the diagnostic health or exit status.
truncation alone does not change the diagnostic health or exit status. More than
32 MiB of combined child stdout and stderr stops the child and fails the check
in progress.
Doctor owns the server's process group on POSIX hosts, or its Job Object on
Windows. Before reporting, it stops that process family, including POSIX
descendants in other process groups that inherited its run token. If that
cleanup cannot be confirmed, the report keeps its earlier checks and adds a
failed `cleanup` check with the reason and retained run identifier.
The default terminal format is TOON. Use `--json` when saving results for a
JSON consumer. Valued output flags (`--format`, `--filter-output`,
+5 -1
View File
@@ -515,7 +515,11 @@ Setup installs its selected skill copy there.
Gemini CLI uses `$GEMINI_CLI_HOME/.gemini/settings.json` when that home override
is nonempty, including its shared personal skills at
`$GEMINI_CLI_HOME/.agents/skills`. Setup and doctor check MCP allow/exclude
`$GEMINI_CLI_HOME/.agents/skills`. Automatic detection uses Gemini CLI's own
settings, trusted-folder records or `.gemini/tmp` session state; the `.gemini`
directory alone does not identify it, because Antigravity keeps its
configuration there too. Select `--client gemini_cli` explicitly before those
files exist. Setup and doctor check MCP allow/exclude
policies from system defaults, user settings, trusted workspace settings, and
system overrides. Workspace trust follows `GEMINI_CLI_TRUST_WORKSPACE`,
`GEMINI_RESTRICTED_MODE`, the effective `security.folderTrust.enabled` setting,
+6 -1
View File
@@ -321,7 +321,12 @@ the others. A function that `procedure_info` classifies as external, such as an
import, is counted as `external` and recorded with that status and the body
fact as observed. It gets no file, no pseudocode is requested, and a resume
skips it. A function whose classification is unknown is decompiled as usual;
a provider that returns null pseudocode for it is reported as `no_pseudocode`. Inventory rows without an address are counted in the limitations. `--layout dwarf`
a provider that returns null pseudocode for it is reported as `no_pseudocode`. Inventory rows without an address are counted in the limitations.
The top-level `limitations` of the manifest and index count every retained
non-external function record whose body is unavailable, including records an
earlier run wrote, and repeat the reason when all of them share one. A resume
that skips every file therefore keeps the caveat until a later attempt records
a known body. `--layout dwarf`
reports that compilation-unit paths are unavailable and still names files by
address. Filters are a case-sensitive name substring, a case-sensitive name
prefix, an inclusive address range (including uppercase hexadecimal input), and a positive safe-integer attempt limit. The limit does not allocate space in proportion to its value. Cancellation leaves
+1 -3
View File
@@ -174,9 +174,7 @@ Official references: [mitmproxy protocol support](https://github.com/mitmproxy/m
## Verifier regressions
The built process-boundary test suite runs the Node verifier regressions on
POSIX hosts. To run them directly after building REA, and to run the Python
laboratory regressions with its pinned dependencies, use the isolated guest:
After building REA, run these inside the same isolated guest:
```sh
node --test scripts/lib/http3-verifier-lifecycle.test.mjs scripts/lib/http3-capture-assertions.test.mjs
+12 -1
View File
@@ -338,7 +338,18 @@ handler execution or change the body. Instruction windows and dossiers also
retain these warnings in their result limitations. An unresolved callee or
unestablished terminal-flow basis remains explicit. These observations qualify
the analysis; they do not prove that a no-return flag is incorrect or recover
excluded code. Analysis profiles version this observation contract so older
excluded code. When the callee's entry, or the function a thunk forwards to,
carries the `Non-Returning Function Found` analysis bookmark that Ghidra's
`Non-Returning Functions - Discovered` analyzer writes, the call's limitation
says the flag was inferred, and the function's limitations add one statement
that Ghidra excluded that call's fall-through from the body and pseudocode, so
code reachable only through it is missing if the callee returns. That analyzer can mark returning callees, such as an
imported `operator new` thunk, so a constructor may decompile to a few lines. To
compare, open a fresh import with
`REA_GHIDRA_DISABLED_ANALYZERS='Non-Returning Functions - Discovered'`; an
existing project (`REA_GHIDRA_PROJECT_PATH`) keeps the flags its analysis
already stored. A flag without that bookmark keeps an unknown origin; REA does
not treat it as declared. Analysis profiles version this observation contract so older
snapshots cannot replay unqualified results.
On non-Windows hosts, the ephemeral import repair clears no-return flags on the
+1 -1
View File
@@ -14,7 +14,7 @@ const directDependencies = (rootPackage) => ({
const installedPackageKey = (name) => `node_modules/${name}`;
/** Compare root direct dependencies with npm's hidden installed lockfile. */
const dependencyInstallProblems = (rootLock, installedLock) => {
export const dependencyInstallProblems = (rootLock, installedLock) => {
const rootPackage = rootLock.packages?.[""];
if (rootPackage === undefined)
return ["package-lock.json does not contain a root package entry"];
+2
View File
@@ -26,6 +26,8 @@ export type CacheRequest = (
endpoint: string,
) => Promise<unknown>;
export function assertRetentionPrefix(prefix: string): void;
export function selectPrefixDeletions(
caches: readonly unknown[],
family?: RetentionFamily,
+1 -1
View File
@@ -22,7 +22,7 @@ const PREFIX_PATTERNS = [
];
/** Reject prefixes that could match npm downloads or another platform. */
function assertRetentionPrefix(prefix) {
export function assertRetentionPrefix(prefix) {
if (
typeof prefix !== "string" ||
!PREFIX_PATTERNS.some((pattern) => pattern.test(prefix))
+6
View File
@@ -99,3 +99,9 @@ export function createGitHubApi(options?: {
budget: { requests: number; startedAt: number; limitMs: number },
) => Promise<unknown>;
}): FailureReportApi;
export function writeFailureReport(
report: CiFailureReport,
outputDirectory?: string,
summaryPath?: string,
): Promise<{ jsonPath: string; markdownPath: string }>;
+1 -1
View File
@@ -467,7 +467,7 @@ function errorCode(error) {
return error instanceof Error ? error.message.slice(0, 160) : "unknown_error";
}
async function writeFailureReport(
export async function writeFailureReport(
report,
outputDirectory = OUTPUT_DIRECTORY,
summaryPath,
+1
View File
@@ -91,4 +91,5 @@ export function writePrCiHealthReport(
outputDirectory?: string,
): Promise<{ jsonPath: string; markdownPath: string }>;
export function renderMarkdownSummary(report: PrCiHealthReport): string;
import type { GitHubApiRequest } from "./github-api.mjs";
+1 -1
View File
@@ -656,7 +656,7 @@ export async function writePrCiHealthReport(
return { jsonPath, markdownPath };
}
function renderMarkdownSummary(report) {
export function renderMarkdownSummary(report) {
const rows = report.pullRequests.map(
(pull) =>
`| #${String(pull.number ?? "?")} | ${escapeCell(pull.head.repository ?? "unknown")} | ${escapeCell(pull.head.ref ?? "unknown")} | \`${pull.head.sha ?? "unknown"}\` | ${pull.ci.category} | ${escapeCell(pull.ci.reason)} |`,
@@ -28,3 +28,6 @@ export interface ProductionReachabilityReport {
export function parseProductionReachabilityReport(
text: string,
): ProductionReachabilityReport;
export function recordProductionReachabilityReport(
root?: string,
): Promise<ProductionReachabilityReport>;
@@ -34,7 +34,9 @@ export const parseProductionReachabilityReport = (text) =>
reportSchema.parse(JSON.parse(text));
/** Run production reachability without turning candidates into failures. */
const recordProductionReachabilityReport = async (root = process.cwd()) => {
export const recordProductionReachabilityReport = async (
root = process.cwd(),
) => {
const reportDirectory = join(root, ".cache", "static-checks");
const reportPath = join(reportDirectory, "production-reachability.json");
+1 -1
View File
@@ -6,7 +6,7 @@ import { promisify } from "node:util";
const exec = promisify(execFile);
/** Commands the macOS bundle fixture needs from Command Line Tools. */
const MACOS_BUNDLE_FIXTURE_COMMANDS = [
export const MACOS_BUNDLE_FIXTURE_COMMANDS = [
["/usr/bin/xcrun", ["--find", "clang"]],
["/usr/bin/xcrun", ["--find", "codesign"]],
["/usr/bin/ditto", ["-h"]],
+1 -1
View File
@@ -1,4 +1,4 @@
function sourceMapSummary(sourceMaps) {
export function sourceMapSummary(sourceMaps) {
return {
status: sourceMaps.status,
requested: sourceMaps.requested,
+13
View File
@@ -1,3 +1,11 @@
export const CURSOR_PLUGIN_MANIFEST: ".cursor-plugin/plugin.json";
export const CURSOR_PLUGIN_MCP: "mcp.json";
export const AUTHORED_SKILL_DIRECTORY: ".agents/skills/reverse-engineer-anything";
export const CURSOR_PLUGIN_SKILLS: ".cursor-plugin/skills";
export const CURSOR_PLUGIN_SKILL: "reverse-engineer-anything";
export const CURSOR_PLUGIN_LOGO: "assets/rea-plugin.png";
export const CURSOR_PLUGIN_REPOSITORY: "https://github.com/morluto/rea";
export interface CursorPluginBundleIssue {
readonly field: string;
}
@@ -48,6 +56,11 @@ export interface CursorPluginInspection {
readonly issues: readonly CursorPluginBundleIssue[];
}
export function cursorPluginSkillPaths(
directory: string,
prefix?: string,
): Promise<readonly string[] | null>;
export function cursorPluginBundleIssues(
input: CursorPluginBundleInput,
): readonly CursorPluginBundleIssue[];
+9 -8
View File
@@ -7,13 +7,14 @@ import { ensureGeneratedFile } from "./generated-file.mjs";
// publication (name, release version, repository, skill, and icon). The
// Marketplace checkout has no dist/, so the server is the pinned npm command
// instead of the bundled launcher.
const CURSOR_PLUGIN_MANIFEST = ".cursor-plugin/plugin.json";
const CURSOR_PLUGIN_MCP = "mcp.json";
const AUTHORED_SKILL_DIRECTORY = ".agents/skills/reverse-engineer-anything";
const CURSOR_PLUGIN_SKILLS = ".cursor-plugin/skills";
const CURSOR_PLUGIN_SKILL = "reverse-engineer-anything";
const CURSOR_PLUGIN_LOGO = "assets/rea-plugin.png";
const CURSOR_PLUGIN_REPOSITORY = "https://github.com/morluto/rea";
export const CURSOR_PLUGIN_MANIFEST = ".cursor-plugin/plugin.json";
export const CURSOR_PLUGIN_MCP = "mcp.json";
export const AUTHORED_SKILL_DIRECTORY =
".agents/skills/reverse-engineer-anything";
export const CURSOR_PLUGIN_SKILLS = ".cursor-plugin/skills";
export const CURSOR_PLUGIN_SKILL = "reverse-engineer-anything";
export const CURSOR_PLUGIN_LOGO = "assets/rea-plugin.png";
export const CURSOR_PLUGIN_REPOSITORY = "https://github.com/morluto/rea";
const GENERATE_COMMAND = "node scripts/generate-cursor-plugin-skill.mjs";
const INSTALL_LINK_MARKERS = ["install-mcp", "deeplink/mcp", "mcp-install"];
@@ -26,7 +27,7 @@ const failure = (code, detail) => {
const cursorServerArgs = (version) => ["-y", `rea-agents@${version}`, "mcp"];
/** Relative file inventory for one skill directory, in stable order. */
const cursorPluginSkillPaths = async (directory, prefix = "") => {
export const cursorPluginSkillPaths = async (directory, prefix = "") => {
const paths = [];
let entries;
try {
+1 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
const WINDOWS_GHIDRA_BYTE_SEARCH_FIXTURE = Object.freeze({
export const WINDOWS_GHIDRA_BYTE_SEARCH_FIXTURE = Object.freeze({
pattern: "a0 ?? c0",
mask: "f0 ff f0",
startFileOffset: 0x380,
-16
View File
@@ -1,16 +0,0 @@
import type { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
export class HopperDeadlineCleanupFailure extends AggregateError {
readonly _tag: "HopperDeadlineCleanupFailure";
readonly cleanupOwner: HopperDeadlineCleanup;
constructor(owner: HopperDeadlineCleanup, failures: unknown[]);
}
export class HopperDeadlineCleanup {
readonly workspace: string;
constructor(workspace: string, profile: ArtifactResourceScope);
setClient(client: {
close(): Promise<{ ok: true } | { ok: false; error: unknown }>;
}): void;
close(): Promise<void>;
}
-71
View File
@@ -1,71 +0,0 @@
import { rm } from "node:fs/promises";
export class HopperDeadlineCleanupFailure extends AggregateError {
constructor(owner, failures) {
super(
failures,
`Hopper cleanup incomplete; workspace may remain at ${owner.workspace}`,
);
this._tag = "HopperDeadlineCleanupFailure";
this.cleanupOwner = owner;
}
}
/** Own the client, profile scope, and workspace until all cleanup completes. */
export class HopperDeadlineCleanup {
#client;
#profile;
#pending;
#removed = false;
constructor(workspace, profile) {
this.workspace = workspace;
this.#profile = profile;
}
setClient(client) {
this.#client = client;
}
close() {
if (this.#pending !== undefined) return this.#pending;
const attempt = this.#close();
const pending = attempt.finally(() => {
if (this.#pending === pending) this.#pending = undefined;
});
this.#pending = pending;
return pending;
}
async #close() {
if (this.#removed) return;
const failures = [];
if (this.#client !== undefined) {
try {
const result = await this.#client.close();
if (!result.ok) throw result.error;
this.#client = undefined;
} catch (cause) {
failures.push(cause);
}
}
if (this.#profile !== undefined) {
try {
await this.#profile.close();
this.#profile = undefined;
} catch (cause) {
failures.push(cause);
}
}
if (failures.length === 0) {
try {
await rm(this.workspace, { recursive: true, force: true });
this.#removed = true;
return;
} catch (cause) {
failures.push(cause);
}
}
throw new HopperDeadlineCleanupFailure(this, failures);
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ function assertHeaders(record, side, headers) {
}
}
function assertEndpointHeaders(actual, expected) {
export function assertEndpointHeaders(actual, expected) {
// The proxy rebuilds pseudo fields from its HTTP state. Compare their values
// by name while retaining the exact order and duplicates of regular headers.
const partition = (headers) => {
@@ -1,10 +1,28 @@
import { analyzeJavaScriptApplication } from "../../dist/application/javascript/JavaScriptApplicationService.js";
import { withVerifierArtifactScope } from "./verifier-artifact-scope.mjs";
import { ArtifactResourceScope } from "../../dist/artifacts/ArtifactResourceScope.js";
/** Analyze one JavaScript application and close its caller-owned resources. */
export const analyzeJavaScriptApplicationWithOwnedScope = (input) =>
withVerifierArtifactScope(async (scope) => {
export const analyzeJavaScriptApplicationWithOwnedScope = async (input) => {
const scope = new ArtifactResourceScope();
let outcome;
try {
const result = await analyzeJavaScriptApplication(input, scope);
if (!result.ok) throw result.error;
return result.value;
});
outcome = result.ok
? { kind: "completed", value: result.value }
: { kind: "failed", cause: result.error };
} catch (cause) {
outcome = { kind: "failed", cause };
}
try {
await scope.close();
} catch (cleanupCause) {
if (outcome.kind === "failed")
throw new AggregateError(
[outcome.cause, cleanupCause],
"JavaScript analysis and artifact cleanup both failed",
);
throw cleanupCause;
}
if (outcome.kind === "failed") throw outcome.cause;
return outcome.value;
};
+1 -1
View File
@@ -78,7 +78,7 @@ export const expandMcpSessionStatus = (status) => ({
});
/** Expand provider capability groups while retaining candidate identity/state. */
const expandProviderCandidates = (candidates) =>
export const expandProviderCandidates = (candidates) =>
candidates.map((candidate) => ({
...candidate,
capabilities: expandProviderCapabilities(
+176
View File
@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { test } from "node:test";
import { inspectModuleBoundaries } from "./module-boundaries.mjs";
const fixture = async () => {
const root = await mkdtemp(join(tmpdir(), "rea-module-boundaries-"));
await mkdir(join(root, "src/domain"), { recursive: true });
await mkdir(join(root, "src/composition"), { recursive: true });
await writeFile(
join(root, "src/composition/worker.ts"),
"export default 1;\n",
);
await writeFile(
join(root, "package.json"),
JSON.stringify({ imports: { "#worker": "./dist/composition/worker.js" } }),
);
return root;
};
await test("reports a pure-layer composition import written relatively", async () => {
const root = await fixture();
try {
const violations = inspectModuleBoundaries(
"src/domain/probe.ts",
'import worker from "../composition/worker.js";\nexport default worker;\n',
root,
);
assert.deepEqual(
violations.map(({ boundary, target }) => ({ boundary, target })),
[{ boundary: "pure-layer", target: "src/composition/worker.js" }],
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
await test("reports a pure-layer composition import written through the package map", async () => {
const root = await fixture();
try {
const violations = inspectModuleBoundaries(
"src/domain/probe.ts",
'import worker from "#worker";\nexport default worker;\n',
root,
);
assert.deepEqual(
violations.map(({ boundary, target }) => ({ boundary, target })),
[{ boundary: "pure-layer", target: "src/composition/worker.ts" }],
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
for (const [name, specifier, imports] of [
[
"conditional",
"#worker",
{
"#worker": {
import: "./dist/composition/worker.js",
default: "./dist/composition/other.js",
},
},
],
["array", "#worker", { "#worker": ["./dist/composition/worker.js"] }],
["pattern", "#workers/worker", { "#workers/*": "./dist/composition/*.js" }],
]) {
await test(`refuses an unsupported valid ${name} package map rather than skipping its edge`, async () => {
const root = await fixture();
try {
await writeFile(join(root, "package.json"), JSON.stringify({ imports }));
const file = "src/domain/probe.ts";
await writeFile(
join(root, file),
`import ${JSON.stringify(specifier)};\n`,
);
// Resolve only: no worker module or authored target source is executed.
const resolved = spawnSync(
process.execPath,
[
"--experimental-import-meta-resolve",
"--input-type=module",
"-e",
"console.log(import.meta.resolve(process.argv[1], process.argv[2]))",
specifier,
pathToFileURL(join(root, file)).href,
],
{ encoding: "utf8" },
);
if (resolved.error !== undefined) throw resolved.error;
assert.equal(resolved.status, 0, resolved.stderr);
assert.equal(
resolved.stdout.trim(),
pathToFileURL(join(root, "dist/composition/worker.js")).href,
);
const checked = spawnSync(
process.execPath,
[
fileURLToPath(
new URL("../verify-module-boundaries.mjs", import.meta.url),
),
file,
],
{ cwd: root, encoding: "utf8" },
);
if (checked.error !== undefined) throw checked.error;
assert.equal(checked.status, 1, checked.stdout);
assert.match(checked.stderr, /Could not inspect src\/domain\/probe.ts/u);
assert.match(checked.stderr, /Cannot resolve package import/u);
assert.match(checked.stderr, /relative source import/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}
for (const target of [
"./dist/composition/worker.js",
"./dist/%63omposition/worker.js",
"./%64ist/composition/worker.js",
]) {
await test(`checks the URL-normalized source owner of ${target}`, async () => {
const root = await fixture();
try {
await writeFile(
join(root, "package.json"),
JSON.stringify({ imports: { "#worker": target } }),
);
await mkdir(join(root, "src/application"), { recursive: true });
const file = "src/application/probe.ts";
await writeFile(join(root, file), 'import "#worker";\n');
const resolved = spawnSync(
process.execPath,
[
"--experimental-import-meta-resolve",
"--input-type=module",
"-e",
"console.log(import.meta.resolve(process.argv[1], process.argv[2]))",
"#worker",
pathToFileURL(join(root, file)).href,
],
{ encoding: "utf8" },
);
if (resolved.error !== undefined) throw resolved.error;
assert.equal(resolved.status, 0, resolved.stderr);
assert.equal(
fileURLToPath(resolved.stdout.trim()),
join(root, "dist/composition/worker.js"),
);
const checked = spawnSync(
process.execPath,
[
fileURLToPath(
new URL("../verify-module-boundaries.mjs", import.meta.url),
),
file,
],
{ cwd: root, encoding: "utf8" },
);
if (checked.error !== undefined) throw checked.error;
assert.equal(checked.status, 1, checked.stdout);
assert.match(
checked.stderr,
/application-composition: #worker resolves to src\/composition\/worker.ts/u,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}
@@ -1,5 +1,8 @@
export { runOwnedVerifierTerminal } from "./verifier-terminal-owner.mjs";
export type {
VerifierTerminalOptions,
VerifierTerminalDependencies,
VerifierTerminalResult,
VerifierTerminalFailure,
VerifierTerminalCleanupFailure,
} from "./verifier-terminal-owner.mjs";
+3 -7
View File
@@ -1,10 +1,6 @@
/** Observe possible Hopper resources, including entries with unresolved ownership. */
/** Observe concrete Hopper runtime resources for a before/after cleanup check. */
export function snapshotHopperRuntime(
parent: string,
targetLeaseDirectory: string,
expectedRunId?: string,
): Promise<{
readonly paths: Set<string>;
readonly confirmedSessions: Set<string>;
readonly unresolvedSessions: Set<string>;
}>;
ownedProcessIds?: ReadonlySet<number>,
): Promise<Set<string>>;
+22 -30
View File
@@ -1,38 +1,35 @@
import { lstat, readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
/** Observe possible sessions and target leases without discarding uncertainty. */
/** Observe session paths and target leases without treating their shared parent as a session. */
export async function snapshotHopperRuntime(
parent,
targetLeaseDirectory,
expectedRunId,
ownedProcessIds,
) {
const paths = new Set();
const confirmedSessions = new Set();
const unresolvedSessions = new Set();
const resources = new Set();
for (const entry of await readdir(parent, { withFileTypes: true })) {
const path = join(parent, entry.name);
if (!entry.name.startsWith("rea-") || path === targetLeaseDirectory)
continue;
const confirmed =
typeof expectedRunId === "string" &&
expectedRunId.length > 0 &&
entry.isDirectory() &&
(await hasRunIdentity(path, expectedRunId));
(confirmed ? confirmedSessions : unresolvedSessions).add(path);
paths.add(path);
if (
ownedProcessIds !== undefined &&
(!entry.isDirectory() ||
!(await belongsToOwnedProcess(path, ownedProcessIds)))
)
continue;
resources.add(path);
}
const leaseRoot = await lstat(targetLeaseDirectory).catch(missingIsAbsent);
if (leaseRoot !== undefined) {
if (!leaseRoot.isDirectory() || leaseRoot.isSymbolicLink())
paths.add(targetLeaseDirectory);
else {
const leases = await readdir(targetLeaseDirectory).catch(missingIsAbsent);
for (const name of leases ?? [])
paths.add(join(targetLeaseDirectory, name));
}
if (leaseRoot === undefined) return resources;
if (!leaseRoot.isDirectory() || leaseRoot.isSymbolicLink()) {
resources.add(targetLeaseDirectory);
return resources;
}
return { paths, confirmedSessions, unresolvedSessions };
const leases = await readdir(targetLeaseDirectory).catch(missingIsAbsent);
for (const name of leases ?? [])
resources.add(join(targetLeaseDirectory, name));
return resources;
}
const missingIsAbsent = (cause) => {
@@ -40,21 +37,16 @@ const missingIsAbsent = (cause) => {
throw cause;
};
const hasRunIdentity = async (path, expectedRunId) => {
const belongsToOwnedProcess = async (path, ownedProcessIds) => {
const encoded = await readFile(join(path, "ownership.json"), "utf8").catch(
missingIsAbsent,
);
if (encoded === undefined) return false;
let ownership;
try {
ownership = JSON.parse(encoded);
} catch (cause) {
if (cause instanceof SyntaxError) return false;
throw cause;
}
const ownership = JSON.parse(encoded);
return (
ownership !== null &&
typeof ownership === "object" &&
ownership.run_id === expectedRunId
Number.isSafeInteger(ownership.parent_pid) &&
ownedProcessIds.has(ownership.parent_pid)
);
};
+8 -1
View File
@@ -1,4 +1,4 @@
interface FixtureProcedure {
export interface FixtureProcedure {
readonly address: string;
readonly name: string;
}
@@ -26,6 +26,13 @@ export function requireCurrentDocument(
normalizedResult: (value: unknown, operation: string) => unknown,
): Promise<string>;
export function analyzeFixtureProcedure(
client: ToolClient,
options: unknown,
procedure: FixtureProcedure,
normalizedResult: (value: unknown, operation: string) => unknown,
): Promise<unknown>;
export function verifyRealHopperFixture(options: {
readonly client: ToolClient;
readonly options: unknown;
+1 -1
View File
@@ -310,7 +310,7 @@ export async function resolveFixtureProcedure(
}
/** Request the assembly evidence required by the fixture oracle. */
const analyzeFixtureProcedure = async (
export const analyzeFixtureProcedure = async (
client,
options,
procedure,
+3
View File
@@ -6,3 +6,6 @@ export function unsupportedRuntimeMessage(
range: string,
version: string,
): string | undefined;
/** Human-readable release families for an `engines.node` range. */
export function supportedReleaseProse(range: string): string;
+1 -1
View File
@@ -21,7 +21,7 @@ export const unsupportedRuntimeMessage = (range, version) => {
};
/** Human-readable release families for an `engines.node` range. */
const supportedReleaseProse = (range) =>
export const supportedReleaseProse = (range) =>
`Node.js ${range
.split("||")
.map((clause) => {
-15
View File
@@ -1,15 +0,0 @@
import type { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
export class VerifierArtifactCleanupFailure extends AggregateError {
readonly _tag: "VerifierArtifactCleanupFailure";
readonly cleanupOwner: ArtifactResourceScope;
constructor(
scope: ArtifactResourceScope,
failures: unknown[],
cause: unknown,
);
}
export function withVerifierArtifactScope<T>(
operation: (scope: ArtifactResourceScope) => Promise<T>,
): Promise<T>;
-32
View File
@@ -1,32 +0,0 @@
import { ArtifactResourceScope } from "../../dist/artifacts/ArtifactResourceScope.js";
/** A failed close retains the scope that can retry all of its owners. */
export class VerifierArtifactCleanupFailure extends AggregateError {
constructor(scope, failures, cause) {
super(failures, "Verifier cleanup failed", { cause });
this._tag = "VerifierArtifactCleanupFailure";
this.cleanupOwner = scope;
}
}
/** Run one verifier operation and retain its exact scope if close fails. */
export async function withVerifierArtifactScope(operation) {
const scope = new ArtifactResourceScope();
let outcome;
try {
outcome = { ok: true, value: await operation(scope) };
} catch (cause) {
outcome = { ok: false, cause };
}
try {
await scope.close();
} catch (cleanupCause) {
throw new VerifierArtifactCleanupFailure(
scope,
outcome.ok ? [cleanupCause] : [outcome.cause, cleanupCause],
outcome.ok ? cleanupCause : outcome.cause,
);
}
if (!outcome.ok) throw outcome.cause;
return outcome.value;
}
+3 -3
View File
@@ -40,7 +40,7 @@ export interface VerifierTerminalOptions {
readonly signal?: AbortSignal;
}
interface VerifierTerminalDependencies {
export interface VerifierTerminalDependencies {
readonly loadPty?: () => Promise<typeof import("@lydell/node-pty")>;
readonly cleanupProcessGroup?: typeof cleanupOwnedProcessGroup;
}
@@ -59,7 +59,7 @@ type TerminalFailureReason =
| "unsupported_platform";
/** Expected terminal failures retain the decoded output observed at failure. */
class VerifierTerminalFailure extends Error {
export class VerifierTerminalFailure extends Error {
readonly _tag = "VerifierTerminalFailure";
constructor(
@@ -74,7 +74,7 @@ class VerifierTerminalFailure extends Error {
}
/** The adopted owner remains the exact authority for retrying this release. */
class VerifierTerminalCleanupFailure extends Error {
export class VerifierTerminalCleanupFailure extends Error {
readonly _tag = "VerifierTerminalCleanupFailure";
readonly reason = "cleanup_incomplete";
-4
View File
@@ -5,8 +5,6 @@ import noEvidenceJsonPreadmission from "./rules/no-evidence-json-preadmission.mj
import noFixtureRootCleanup from "./rules/no-fixture-root-cleanup.mjs";
import noPositionalToolContract from "./rules/no-positional-tool-contract.mjs";
import noSharedModuleMock from "./rules/no-shared-module-mock.mjs";
import noLocaleCompare from "./rules/no-locale-compare.mjs";
import noBabelVisitorKeys from "./rules/no-babel-visitor-keys.mjs";
export default {
meta: { name: "rea" },
@@ -17,7 +15,5 @@ export default {
"no-fixture-root-cleanup": noFixtureRootCleanup,
"no-positional-tool-contract": noPositionalToolContract,
"no-shared-module-mock": noSharedModuleMock,
"no-locale-compare": noLocaleCompare,
"no-babel-visitor-keys": noBabelVisitorKeys,
},
};
@@ -1,4 +0,0 @@
import type { RuleTester } from "oxlint/plugins-dev";
declare const rule: Parameters<RuleTester["run"]>[1];
export default rule;
@@ -1,73 +0,0 @@
import { findBinding } from "../bindings.mjs";
const nameOf = (node) =>
node.type === "Identifier"
? node.name
: node.type === "Literal"
? node.value
: node.type === "TemplateLiteral" && node.expressions.length === 0
? node.quasis[0]?.value.cooked
: undefined;
export default {
meta: {
type: "problem",
docs: { description: "Keep Babel child expansion in its shared owner." },
schema: [],
messages: {
owner:
"Use childNodes or traverseJavaScriptAst instead of Babel VISITOR_KEYS.",
},
},
create(context) {
const source = context.sourceCode;
const isBabelNamespace = (node) => {
if (node?.type !== "Identifier") return false;
const definition = findBinding(source, node)?.defs[0];
if (
definition?.type !== "ImportBinding" ||
definition.node.type !== "ImportNamespaceSpecifier"
)
return false;
const declaration = definition.parent ?? definition.node.parent;
return declaration?.source?.value === "@babel/types";
};
return {
ImportSpecifier(node) {
if (
node.parent.source.value === "@babel/types" &&
(node.imported.name ?? node.imported.value) === "VISITOR_KEYS"
)
context.report({ node, messageId: "owner" });
},
ExportNamedDeclaration(node) {
if (node.source?.value !== "@babel/types") return;
for (const specifier of node.specifiers)
if (
specifier.type === "ExportSpecifier" &&
nameOf(specifier.local) === "VISITOR_KEYS"
)
context.report({ node: specifier, messageId: "owner" });
},
MemberExpression(node) {
if (
(!node.computed || node.property.type !== "Identifier") &&
nameOf(node.property) === "VISITOR_KEYS" &&
isBabelNamespace(node.object)
)
context.report({ node, messageId: "owner" });
},
VariableDeclarator(node) {
if (node.id.type !== "ObjectPattern" || !isBabelNamespace(node.init))
return;
for (const property of node.id.properties)
if (
property.type === "Property" &&
(!property.computed || property.key.type !== "Identifier") &&
nameOf(property.key) === "VISITOR_KEYS"
)
context.report({ node: property, messageId: "owner" });
},
};
},
};
@@ -1,4 +0,0 @@
import type { RuleTester } from "oxlint/plugins-dev";
declare const rule: Parameters<RuleTester["run"]>[1];
export default rule;
-34
View File
@@ -1,34 +0,0 @@
export default {
meta: {
type: "problem",
docs: {
description: "Keep domain ordering independent of the host locale.",
},
schema: [],
messages: {
locale:
"Use compareUnicodeCodePoints instead of localeCompare for domain ordering.",
},
},
create(context) {
return {
CallExpression(node) {
const member = node.callee;
if (member.type !== "MemberExpression") return;
if (
(!member.computed &&
member.property.type === "Identifier" &&
member.property.name === "localeCompare") ||
(member.computed &&
member.property.type === "Literal" &&
member.property.value === "localeCompare") ||
(member.computed &&
member.property.type === "TemplateLiteral" &&
member.property.expressions.length === 0 &&
member.property.quasis[0]?.value.cooked === "localeCompare")
)
context.report({ node, messageId: "locale" });
},
};
},
};
+10
View File
@@ -1,3 +1,13 @@
export interface PackageHopperEnvironment {
readonly HOPPER_LAUNCHER_PATH: string;
readonly HOPPER_LOADER_ARGS_JSON: string;
}
export function packageHopperEnvironment(
root: string,
platform?: string,
): PackageHopperEnvironment;
export function verifyPackageEnvironment(input: {
readonly root: string;
readonly workspace: string;
+1 -1
View File
@@ -117,7 +117,7 @@ export async function verifyPackageEnvironment({
};
}
const packageHopperEnvironment = (root, platform = process.platform) => {
export const packageHopperEnvironment = (root, platform = process.platform) => {
const fakeLauncher = join(root, "tests/fixtures/fakeLauncher.mjs");
if (platform !== "linux")
return {
+1 -1
View File
@@ -414,7 +414,7 @@ export function matchesSymbol(value, expected) {
return tail === expected || tail === `_${expected}`;
}
function symbolTail(value) {
export function symbolTail(value) {
if (typeof value !== "string") return undefined;
return value.split("::").at(-1);
}
+6 -3
View File
@@ -9,7 +9,7 @@ import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { withVerifierArtifactScope } from "./lib/verifier-artifact-scope.mjs";
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
import { readGhidraLanguageCatalog } from "../dist/ghidra/GhidraLanguageCatalog.js";
import { parseEvidence } from "../dist/domain/evidence.js";
import { functionDossierSchema } from "../dist/domain/native/functionDossier.js";
@@ -82,13 +82,16 @@ for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) {
* installation catalog reader it uses. User extensions are not in that catalog.
*/
async function languageInstalled(languageId) {
return withVerifierArtifactScope(async (scope) => {
const scope = new ArtifactResourceScope();
try {
const catalog = await readGhidraLanguageCatalog(
installation.installDir,
scope,
);
return catalog.some((definition) => definition.id === languageId);
});
} finally {
await scope.close();
}
}
/** One MCP session plus CLI check. `languageOverride` is set only for the Gekko pass. */
+26 -40
View File
@@ -11,10 +11,6 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { artifactMcpCall } from "./lib/artifact-e2e.mjs";
import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs";
import {
closeVerifierResources,
withVerifierCleanup,
} from "./lib/verifier-cleanup.mjs";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js";
assert.equal(process.platform, "darwin", "requires macOS");
@@ -52,9 +48,11 @@ const digest = async (path) =>
createHash("sha256")
.update(await readFile(path))
.digest("hex");
const sessionsBefore = (
await snapshotHopperRuntime("/tmp", HOPPER_TARGET_LEASE_DIRECTORY)
).paths;
const sessionsBefore = await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
);
const ownedProcessIds = new Set();
const client = new Client({ name: "hopper-callers-verifier", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
@@ -95,7 +93,7 @@ const matchingRef = (ref, site, from, to) =>
ref.source_procedure?.address === from &&
ref.target_procedure?.address === to &&
ref.call?.classification === "direct";
await withVerifierCleanup(async () => {
try {
await run(clang, [
"-isysroot",
sdk,
@@ -131,6 +129,7 @@ await withVerifierCleanup(async () => {
await client.connect(transport);
if (transport.pid === null)
throw new Error("MCP process identity unavailable");
ownedProcessIds.add(transport.pid);
let notifications = 0;
const original = transport.onmessage;
transport.onmessage = (message) => {
@@ -288,36 +287,23 @@ await withVerifierCleanup(async () => {
sha256: beforeLarge,
}),
);
}, [
async () => {
try {
await closeVerifierResources([
() => client.close(),
() => transport.close(),
async () => {
await new Promise((resolve) => setTimeout(resolve, 500));
const leaked = [
...(
await snapshotHopperRuntime("/tmp", HOPPER_TARGET_LEASE_DIRECTORY)
).paths,
].filter((path) => !sessionsBefore.has(path));
assert.deepEqual(
leaked,
[],
`Hopper resources remain: ${leaked.join(", ")}`,
);
},
]);
} catch (cause) {
throw new Error(
`Hopper cleanup failed; fixture retained at ${directory}`,
{
cause,
},
);
} finally {
if (stderr) process.stderr.write(stderr);
}
} finally {
await client.close();
await transport.close();
await new Promise((resolve) => setTimeout(resolve, 500));
const leaked = [
...(await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
ownedProcessIds,
)),
].filter((path) => !sessionsBefore.has(path));
if (leaked.length === 0)
await rm(directory, { recursive: true, force: true });
},
]);
if (stderr) process.stderr.write(stderr);
assert.deepEqual(
leaked,
[],
`Hopper resources remain: ${leaked.join(", ")}; fixture retained at ${directory}`,
);
}
+22 -13
View File
@@ -1,11 +1,10 @@
import assert from "node:assert/strict";
import { copyFile, mkdtemp, realpath } from "node:fs/promises";
import { copyFile, mkdtemp, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { firstProcedureAddress } from "./lib/real-hopper-assertions.mjs";
import { HopperDeadlineCleanup } from "./lib/hopper-deadline-cleanup.mjs";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js";
@@ -30,7 +29,6 @@ const root = await realpath(
);
const target = join(root, "deadline-fixture");
const profileResources = new ArtifactResourceScope();
const cleanupOwner = new HopperDeadlineCleanup(root, profileResources);
const profileResourceScope = {
retain: (owner, resource) =>
profileResources.retain({
@@ -42,7 +40,6 @@ const profileResourceScope = {
};
let client;
let primaryFailure;
let operationFailed = false;
try {
await copyFile(fixtures.primary.path, target);
const parsed = await parseBinaryTarget(target);
@@ -151,19 +148,31 @@ try {
),
);
} catch (cause) {
operationFailed = true;
primaryFailure = cause;
throw cause;
} finally {
let cleanupFailure;
if (client !== undefined) {
const closed = await client.close();
if (!closed.ok) cleanupFailure = closed.error;
}
try {
if (client !== undefined) cleanupOwner.setClient(client);
await cleanupOwner.close();
} catch (cleanupFailure) {
throw operationFailed
? new AggregateError(
await profileResources.close();
} catch (cause) {
cleanupFailure =
cleanupFailure === undefined
? cause
: new AggregateError(
[cleanupFailure, cause],
"Hopper profile and client cleanup failed",
);
}
await rm(root, { recursive: true, force: true });
if (cleanupFailure !== undefined)
throw primaryFailure === undefined
? cleanupFailure
: new AggregateError(
[primaryFailure, cleanupFailure],
"Hopper verification and cleanup failed",
)
: cleanupFailure;
}
);
}
+37 -79
View File
@@ -23,10 +23,6 @@ import {
requireMcpOperationResult,
} from "./lib/mcp-verifier-results.mjs";
import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs";
import {
closeVerifierResources,
withVerifierCleanup,
} from "./lib/verifier-cleanup.mjs";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js";
import { parseConfig } from "../dist/config/parseConfig.js";
import {
@@ -74,12 +70,14 @@ const intel = join(directory, "c-x86_64");
const fat = join(directory, "c-fat");
const fat64 = join(directory, "c-fat64");
const singleFat = join(directory, "c-single-fat");
const owned = new Set();
const extraFat64 = [];
const invalidFat64 = [];
let preparedImagePath;
const before = (
await snapshotHopperRuntime("/tmp", HOPPER_TARGET_LEASE_DIRECTORY)
).paths;
const before = await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
);
const client = new Client({ name: "hopper-fat-verifier", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
@@ -96,7 +94,7 @@ const ajv = new Ajv({ strict: false });
addFormats(ajv);
const observations = [];
const sourceBinding = [];
await withVerifierCleanup(async () => {
try {
await run(toolPaths.clang, [
"-isysroot",
sdk,
@@ -185,17 +183,9 @@ await withVerifierCleanup(async () => {
"overlap",
/slices overlap/u,
(bytes) => {
const firstOffset = bytes.readBigUInt64BE(16);
const secondOffset = bytes.readBigUInt64BE(48);
const earlier = firstOffset < secondOffset ? 8 : 40;
const laterOffset =
firstOffset < secondOffset ? secondOffset : firstOffset;
const earlierOffset =
firstOffset < secondOffset ? firstOffset : secondOffset;
bytes.writeBigUInt64BE(
laterOffset - earlierOffset + 16n,
earlier + 16,
);
const cpu = process.arch === "arm64" ? 0x100000c : 0x1000007;
const base = bytes.readUInt32BE(8) === cpu ? 8 : 40;
bytes.copy(bytes, base === 8 ? 40 : 8, base, base + 32);
},
],
]) {
@@ -221,14 +211,11 @@ await withVerifierCleanup(async () => {
}
const path = join(directory, "c-fat64-ambiguous");
await writeFile(path, ambiguous);
invalidFat64.push({
path,
reason:
/FAT binary declares several (?:arm64|x86_64) slices; no slice was selected/u,
});
invalidFat64.push({ path, reason: /selection.*ambiguous/u });
}
await client.connect(transport);
assert.ok(transport.pid !== null);
owned.add(transport.pid);
const catalog = await client.listTools();
const call = async (name, args = {}) => {
const reply = await client.callTool(
@@ -372,27 +359,14 @@ await withVerifierCleanup(async () => {
if (verifyFat64) {
await call("open_binary", { path: fat64 });
await call("list_procedures");
const session = await call("binary_session");
const runId = session.analysis_run?.run_id;
assert.ok(
typeof runId === "string" && runId.length > 0,
"Missing analysis run identity",
);
const resources = await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
runId,
);
const unresolvedNew = [...resources.unresolvedSessions].filter(
(path) => !before.has(path),
);
assert.deepEqual(
unresolvedNew,
[],
"Cannot identify a prepared image while new Hopper resource ownership is unresolved",
);
for (const root of resources.confirmedSessions) {
if (before.has(root)) continue;
const resources = [
...(await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
owned,
)),
].filter((path) => !before.has(path));
for (const root of resources) {
const image = join(root, "image.macho");
if (
await access(image).then(
@@ -429,43 +403,27 @@ await withVerifierCleanup(async () => {
},
);
assert.ok(pending.child.pid !== undefined);
owned.add(pending.child.pid);
const cli = JSON.parse((await pending).stdout);
assert.deepEqual(cli.normalized_result, expected.mapping);
}
}, [
async () => {
try {
await closeVerifierResources([
() => client.close(),
() => transport.close(),
]);
} catch (cause) {
throw new Error(
`Hopper cleanup failed; fixture retained at ${directory}`,
{
cause,
},
);
}
try {
const retained = [
...(await snapshotHopperRuntime("/tmp", HOPPER_TARGET_LEASE_DIRECTORY))
.paths,
].filter((path) => !before.has(path));
assert.deepEqual(
retained,
[],
"Hopper resources remain or are unresolved",
);
} catch (cause) {
throw new Error(
`Hopper resource verification failed; fixture retained at ${directory}`,
{ cause },
);
}
await rm(directory, { recursive: true, force: true });
},
]);
} finally {
await client.close();
await transport.close();
await rm(directory, { recursive: true, force: true });
}
const retained = [
...(await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
owned,
)),
].filter((path) => !before.has(path));
assert.deepEqual(
retained,
[],
"FAT verification retained owned Hopper sessions or leases",
);
console.log(
JSON.stringify(
{
+10 -6
View File
@@ -62,9 +62,10 @@ const parseServerArgs = (encoded) => {
};
const runtimeParent = process.platform === "darwin" ? "/tmp" : tmpdir();
const sessionsBefore = (
await snapshotHopperRuntime(runtimeParent, HOPPER_TARGET_LEASE_DIRECTORY)
).paths;
const sessionsBefore = await snapshotHopperRuntime(
runtimeParent,
HOPPER_TARGET_LEASE_DIRECTORY,
);
const ownedProcessIds = new Set();
const textValue = mcpTextValue;
@@ -556,8 +557,11 @@ try {
await new Promise((resolve) => setTimeout(resolve, 500));
const sessionsAfter = [
...(await snapshotHopperRuntime(runtimeParent, HOPPER_TARGET_LEASE_DIRECTORY))
.paths,
...(await snapshotHopperRuntime(
runtimeParent,
HOPPER_TARGET_LEASE_DIRECTORY,
ownedProcessIds,
)),
]
.filter((path) => !sessionsBefore.has(path))
.sort();
@@ -569,7 +573,7 @@ if (sessionsAfter.length > 0) {
retainedRuntimePaths: sessionsAfter,
});
throw new Error(
`Hopper resources remain or have unresolved ownership: ${sessionsAfter.join(", ")}`,
`The MCP runtime retained Hopper resources: ${sessionsAfter.join(", ")}`,
);
}
if (summary === undefined)
+2 -4
View File
@@ -40,7 +40,7 @@ try {
const expected = [];
for (const path of files.stdout
.split("\0")
.filter((path) => /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(path))) {
.filter((path) => path.endsWith(".test.ts"))) {
try {
if ((await stat(join(root, path))).isFile()) expected.push(path);
} catch (cause) {
@@ -65,6 +65,4 @@ try {
await rm(temporary, { recursive: true, force: true });
}
if (discovered !== undefined)
console.log(
`Discovered all ${discovered} test and spec files in src and tests exactly once.`,
);
console.log(`Discovered all ${discovered} test files exactly once.`);
+7 -3
View File
@@ -6,7 +6,7 @@ import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { parseBinaryTarget } from "../../../dist/application/BinaryTargetResolver.js";
import { withVerifierArtifactScope } from "../../lib/verifier-artifact-scope.mjs";
import { ArtifactResourceScope } from "../../../dist/artifacts/ArtifactResourceScope.js";
import { inspectAppleDispatchMetadata } from "../../../dist/native/AppleDispatchMetadata.js";
import {
artifactCliEvidence,
@@ -57,7 +57,8 @@ const fixupCoverage = (metadata) =>
const inspect = async (path) => {
const target = await parseBinaryTarget(path);
if (!target.ok) throw target.error;
return withVerifierArtifactScope(async (resources) => {
const resources = new ArtifactResourceScope();
try {
return {
target: target.value,
metadata: (
@@ -66,7 +67,10 @@ const inspect = async (path) => {
)
).result,
};
});
} finally {
// Retry any descriptor whose close failed, surfacing unresolved cleanup.
await resources.close();
}
};
const checkObjc = (metadata, mode) => {
+32 -3
View File
@@ -5,12 +5,17 @@ import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
// Architecture guards for the strict-identity / best-effort-inference split.
// - Evidence ordering must be locale-independent (ICU varies by host), so
// src/domain orders with compareUnicodeCodePoints, never localeCompare.
// - Composite dedup keys must not NUL-join variable-length or free-form
// MIDDLE fields: ["a\0b"] collapses with ["a", "b"] and drops a finding.
// The text check below only flags one spelling; it is not a complete audit.
// compositeKey (JSON tuple) is the single owner for composite keys.
// - Transparent wrappers unwrap in exactly one place
// (javascriptAstValues.unwrapJavaScriptExpression); direct isTS* unwrapping
// elsewhere drifts (the `satisfies` class of bug).
// - AST walks go through traverseJavaScriptAst/childNodes: bespoke
// Object.values walkers diverge on loc/comment fields and overflow the
// stack on deep generated chains.
const root = fileURLToPath(new URL("../../", import.meta.url));
@@ -22,7 +27,6 @@ const listFiles = async (dir) => {
else if (
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".spec.ts") &&
!entry.name.endsWith(".fixture.ts")
)
output.push(path);
@@ -44,6 +48,8 @@ const files = await Promise.all(
content: stripComments(await readFile(path, "utf8")),
})),
);
const inDir = (dir) => files.filter((file) => file.path.startsWith(dir));
const failures = [];
const check = (label, selected, pattern, allow = []) => {
const offenders = selected.filter(
@@ -62,7 +68,21 @@ check(
/providers\/ProviderInspectionScope\.js/,
);
check("NUL-joined list key (use compositeKey)", files, /\.join\(["']\\0["']\)/);
check(
"src/domain uses localeCompare (use compareUnicodeCodePoints)",
inDir("src/domain/"),
/\.localeCompare\(/,
);
check(
"NUL-joined list key (use compositeKey)",
files,
/\.join\(["']\\0["']\)/,
[
// Sorted pair of constrained trace IDs; documented in plan.
"src/domain/process/processTraceSpecification.ts",
],
);
check(
"direct transparent-wrapper unwrap outside javascriptAstValues (use unwrapJavaScriptExpression)",
@@ -71,6 +91,13 @@ check(
["src/domain/javascript/javascriptAstValues.ts"],
);
check(
"bespoke AST child walker (use traverseJavaScriptAst/childNodes)",
files,
/VISITOR_KEYS/,
["src/domain/javascript/javascriptSemanticTraversal.ts"],
);
// A producer that pins both the soft and hard ceiling of every resource takes
// the vector from one owner. prlimitCeilingArguments is the command-less prefix
// of prlimitArgumentVector; both live in src/process/LinuxResourceLimits.ts.
@@ -136,6 +163,7 @@ check(
"src/application/GuidanceAccess.ts",
"src/application/javascript/ElectronBoundaryAnalysis.ts",
"src/application/javascript/ElectronBoundaryGraphContext.ts",
"src/application/javascript/JavaScriptArtifactGraphContext.ts",
"src/browser/CdpCaptureCompleteness.ts",
"src/browser/CdpCaptureEventHandlers.ts",
"src/browser/CdpPageCapture.ts",
@@ -151,6 +179,7 @@ check(
"src/domain/javascript/javascriptNativeHandoff.ts",
"src/domain/javascript/javascriptRuntimeReconciliationMatching.ts",
"src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts",
"src/domain/javascript/javascriptSemanticQuery.ts",
"src/domain/javascript/javascriptStaticAnalysisBundler.ts",
"src/domain/javascript/javascriptStaticAnalysisCalls.ts",
"src/domain/javascript/javascriptStaticAnalysisFindings.ts",
@@ -5,6 +5,7 @@ import { parseArgs } from "node:util";
import { readStableArtifact } from "../../../dist/artifacts/readStableArtifact.js";
import { ArtifactReaderFailure } from "../../../dist/artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../../../dist/artifacts/ArtifactResourceScope.js";
import {
ArtifactFileCleanupFailure,
artifactFileCompletion,
@@ -13,7 +14,6 @@ import {
import { reconcileJavaScriptRuntimeEvidence } from "../../../dist/application/javascript/JavaScriptRuntimeReconciliationService.js";
import { analyzeJavaScriptApplicationWithOwnedScope } from "../../lib/javascript-application-analysis.mjs";
import { withVerifierArtifactScope } from "../../lib/verifier-artifact-scope.mjs";
import {
completeVerifierRun,
createVerifierRun,
@@ -106,23 +106,33 @@ process.stdout.write(
);
async function readEvidenceFile(path) {
const scope = new ArtifactResourceScope();
let outcome;
try {
outcome = {
ok: true,
value: await withVerifierArtifactScope(async (scope) =>
readStableArtifact(
await realpath(path),
64 * 1024 * 1024,
undefined,
undefined,
scope,
),
value: await readStableArtifact(
await realpath(path),
64 * 1024 * 1024,
undefined,
undefined,
scope,
),
};
} catch (cause) {
outcome = { ok: false, cause };
}
try {
await scope.close();
} catch (cleanupCause) {
if (!outcome.ok)
throw new AggregateError(
[outcome.cause, cleanupCause],
"Verifier cleanup failed",
{ cause: outcome.cause },
);
throw cleanupCause;
}
if (!outcome.ok && outcome.cause instanceof ArtifactFileCleanupFailure) {
const completion = artifactFileCompletion(outcome.cause);
outcome =
+9 -5
View File
@@ -1,9 +1,10 @@
import { basename, dirname, isAbsolute, resolve } from "node:path";
import { ArtifactResourceScope } from "../../../dist/artifacts/ArtifactResourceScope.js";
import {
readManagedSnapshot,
verifyManagedSourceIdentity,
} from "../../../dist/dotnet/ManagedSnapshot.js";
import { withVerifierArtifactScope } from "../../lib/verifier-artifact-scope.mjs";
import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs";
import { parseBinaryTarget } from "../../../dist/application/BinaryTargetResolver.js";
import { inspectManagedArtifactBytes } from "../../../dist/dotnet/ManagedArtifactInspector.js";
@@ -23,10 +24,13 @@ import {
const READ_FILE_MAX_BYTES = 0x7fff_ffff;
export async function verifyManagedAppManifest(rawManifest, manifestPath) {
return withVerifierArtifactScope((resources) =>
resources.run(() =>
verifyManifestSnapshot(rawManifest, manifestPath, resources),
),
const resources = new ArtifactResourceScope();
return withVerifierCleanup(
() =>
resources.run(() =>
verifyManifestSnapshot(rawManifest, manifestPath, resources),
),
[() => resources.close()],
);
}
+7 -2
View File
@@ -1,6 +1,13 @@
/** Relative path of the registry. Its text is a declaration, not a producer. */
export const NUMERIC_LIMIT_DECLARATION: "src/domain/numericLimitRegistry.ts";
/** Limits that must become analysis settings and cannot be registry exceptions. */
export const REQUIRED_RUNTIME_LIMITS: readonly [
"REA_NTFS_MFT_MAX_FILE_BYTES",
"REA_REGF_HIVE_MAX_FILE_BYTES",
"REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES",
];
/** True for a numeric `REA_` limit, including the documented endpoint-candidate count. */
export function isNumericLimit(name: string): boolean;
@@ -48,8 +55,6 @@ export function discoverNumericLimitUses(
readonly unresolved: readonly {
readonly path: string;
readonly offset: number;
readonly line: number;
readonly column: number;
readonly reason: string;
}[];
};
+4 -13
View File
@@ -25,7 +25,6 @@ const listFiles = async (dir) => {
} else if (
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".spec.ts") &&
!entry.name.endsWith(".fixture.ts")
)
output.push(path);
@@ -40,7 +39,7 @@ export const NUMERIC_LIMIT_DECLARATION = "src/domain/numericLimitRegistry.ts";
* These limits are moving onto runtime analysis settings on their own PRs.
* A producer or a registry entry cannot satisfy that requirement.
*/
const REQUIRED_RUNTIME_LIMITS = [
export const REQUIRED_RUNTIME_LIMITS = [
"REA_NTFS_MFT_MAX_FILE_BYTES",
"REA_REGF_HIVE_MAX_FILE_BYTES",
"REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES",
@@ -171,16 +170,8 @@ const stringBindings = (sourceFile, symbols, unresolved) => {
item.offset === offset &&
item.reason === reason,
)
) {
const location = sourceFile.getLineAndCharacterOfPosition(offset);
unresolved.push({
path,
offset,
line: location.line + 1,
column: location.character + 1,
reason,
});
}
)
unresolved.push({ path, offset, reason });
};
const initializer = (identifier) => {
if (!candidates.has(identifier.text)) return undefined;
@@ -958,7 +949,7 @@ if (direct) {
for (const issue of discovered.unresolved)
console.log(
`${issue.path}:${issue.line}:${issue.column}: numeric limit candidate unresolved (${issue.reason})`,
`${issue.path}:${issue.offset}: numeric limit candidate unresolved (${issue.reason})`,
);
for (const [name, file] of [...discovered].sort(([left], [right]) =>
+3 -5
View File
@@ -1,8 +1,6 @@
import type { JadxToolPort } from "./JadxAnalysis.js";
import {
androidNativeDeclarationsSchema,
type AndroidNativeDeclarations,
} from "../domain/android/androidNativeDeclarations.js";
import type { AndroidNativeDeclarations } from "../domain/android/androidNativeDeclarations.js";
import { jadxNativeDeclarationsSchema } from "./JadxProtocol.js";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
/** Validate the native-only batch at its producing boundary, preserving raw facts. */
@@ -11,7 +9,7 @@ export const readAndroidNativeInventory = async (
snapshot: string,
): Promise<AndroidNativeDeclarations> => {
const raw = await tools.json("rea_native_declarations", { path: snapshot });
const parsed = androidNativeDeclarationsSchema.safeParse(raw);
const parsed = jadxNativeDeclarationsSchema.safeParse(raw);
if (!parsed.success)
throw new AnalysisOutputError(
"inspect_android_jni_bindings",
+5
View File
@@ -10,6 +10,11 @@ import {
dexDescriptorSchema,
dexTypeSchema,
} from "../domain/android/dexTypes.js";
import { androidNativeDeclarationsSchema } from "../domain/android/androidNativeDeclarations.js";
/** Native-only raw DEX facts authored before decompiler deduplication. */
export const jadxNativeDeclarationsSchema = androidNativeDeclarationsSchema;
const count = z.int().nonnegative();
/** Typed allocation failures authored before upstream erases the JVM exception. */
const jadxMemoryFailureSchema = z.strictObject({
+1 -1
View File
@@ -14,7 +14,7 @@ export const JADX_BRIDGE_IDENTITY = Object.freeze({
});
/** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */
export const JADX_JAR_CONFIGURATION_REMEDIATION = `Set REA_JADX_MCP_JAR to the absolute path of a caller-supplied jadx-headless-mcp ${JADX_RELEASE.version} JAR. REA does not download or install it.`;
export const JADX_JAR_CONFIGURATION_REMEDIATION = `Download the jadx-headless-mcp ${JADX_RELEASE.version} release JAR from ${JADX_RELEASE.url}, verify SHA-256 ${JADX_RELEASE.sha256}, and set REA_JADX_MCP_JAR to its absolute path. REA does not download or install it.`;
/** Remediation when the selected Java runtime cannot satisfy the JADX bridge. */
export const JADX_JAVA_RUNTIME_REMEDIATION =
+6 -2
View File
@@ -1,6 +1,6 @@
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { stableArtifactInputFailure } from "../artifacts/StableArtifactFailure.js";
import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js";
import {
artifactFilePrimaryFacts,
type ArtifactFilePrimaryFacts,
@@ -231,7 +231,11 @@ const artifactReaderFailure = (
},
);
case "path":
return stableArtifactInputFailure(cause, OPERATION);
return new AnalysisInputError(
OPERATION,
{ cause, ...stableArtifactInputRecovery(cause) },
[{ path: ["path"], reason: "invalid_format", message: cause.message }],
);
default:
return adapterFailure(cause, path);
}
+1 -1
View File
@@ -748,7 +748,7 @@ const workflowAvailabilityFor = (
: "provider_missing",
remediation:
policy.androidAnalysisAvailability?.remediation ??
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
"Set REA_JADX_MCP_JAR to the jadx-headless-mcp 0.7.1 release JAR (rea doctor --provider jadx names its download URL and SHA-256) and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
};
if (kind === "adb-provider")
return ownerProbeAvailability(
+1 -1
View File
@@ -36,7 +36,7 @@ const buildHost = (
createDoctorHostFixture({
platform: "linux",
architecture: "x64",
installationPaths: () => Promise.resolve([]),
installationLaunchers: () => Promise.resolve([]),
clientRegistrations: () => Promise.resolve([]),
desktopBuildFacts: () => Promise.resolve({ ...ready, ...facts }),
...overrides,
+30 -4
View File
@@ -17,7 +17,7 @@ const configurationHost = (
): DoctorHost => ({
...systemDoctorHost({ environment: { ...environment } }),
...host({
installationPaths: () => Promise.resolve([]),
installationLaunchers: () => Promise.resolve([]),
clientRegistrations: () => Promise.resolve([]),
}),
});
@@ -32,7 +32,7 @@ describe("doctor Hopper startup timeout", () => {
value === undefined ? {} : { REA_HOPPER_STARTUP_TIMEOUT_MS: value },
}),
...host({
installationPaths: () => Promise.resolve([]),
installationLaunchers: () => Promise.resolve([]),
clientRegistrations: () => Promise.resolve([]),
...overrides,
}),
@@ -725,8 +725,13 @@ describe("doctor installation identity", () => {
const result = await runDoctor(
undefined,
host({
installationPaths: () =>
Promise.resolve(["/usr/local/bin/rea", "/home/user/bin/rea"]),
installationLaunchers: () =>
Promise.resolve(
["/usr/local/bin", "/home/user/bin"].map((installation) => ({
path: `${installation}/rea`,
installation,
})),
),
installedSkillIdentity: () =>
Promise.resolve({
version: "10",
@@ -1390,3 +1395,24 @@ describe("doctor target admission diagnostics", () => {
expect(result.checks).toContainEqual(targetCheck);
});
});
describe("doctor installation count", () => {
it("reports one installation when several launchers resolve to it", async () => {
const result = await runDoctor(
undefined,
host({
installationLaunchers: () =>
Promise.resolve(
["rea", "rea.cmd"].map((name) => ({
path: `C:\\npm\\${name}`,
installation: "c:\\npm",
})),
),
}),
);
expect(result.identity?.installations).toEqual({
paths: ["C:\\npm\\rea", "C:\\npm\\rea.cmd"],
state: "single",
});
});
});
+49 -10
View File
@@ -8,7 +8,8 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { STARTUP_OWNER_CLOSED } from "../process/SharedStartupAttempt.js";
import { constants } from "node:fs";
import { access, stat } from "node:fs/promises";
import { access, realpath, stat } from "node:fs/promises";
import { posix, win32 } from "node:path";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import type { RetainedResourceScope } from "./AnalysisProvider.js";
import {
@@ -151,7 +152,8 @@ export interface DoctorHost {
): Promise<readonly DoctorProviderInspection[]>;
/** Resolve a parsed upstream request budget from the effective client environment. */
resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
installationPaths?(): Promise<readonly string[]>;
/** `rea` launchers in PATH order, each with the installation it resolves to. */
installationLaunchers?(): Promise<readonly DoctorInstallationLauncher[]>;
/** Undefined checks installed skill roots; explicit selections stay scoped. */
installedSkillIdentity?(
clientIds: readonly string[] | undefined,
@@ -486,7 +488,7 @@ const collectDoctorIdentity = async (
readonly checks: readonly DoctorCheck[];
readonly value: DoctorIdentity;
}> => {
const installationPaths = (await host.installationPaths?.()) ?? [];
const launchers = (await host.installationLaunchers?.()) ?? [];
const registrations =
(await host.clientRegistrations?.(
scope.clients,
@@ -528,8 +530,8 @@ const collectDoctorIdentity = async (
"Call binary_session in the active client to inspect the running server identity; a registration whose saved scope omits the session family cannot report it, so include session through rea setup --tool-families and restart first.",
},
installations: {
paths: installationPaths,
state: installationState(installationPaths),
paths: launchers.map(({ path }) => path),
state: installationState(launchers),
},
skill: {
installed_version: installedSkillIdentity?.version ?? null,
@@ -626,10 +628,41 @@ const registrationCheck = (
remediation: registration.remediation,
});
/** A PATH launcher and the installation identity its host resolved it to. */
export interface DoctorInstallationLauncher {
readonly path: string;
readonly installation: string;
}
const installationState = (
paths: readonly string[],
): DoctorIdentity["installations"]["state"] =>
paths.length === 0 ? "unknown" : paths.length === 1 ? "single" : "multiple";
launchers: readonly DoctorInstallationLauncher[],
): DoctorIdentity["installations"]["state"] => {
const installations = new Set(
launchers.map(({ installation }) => installation),
);
return installations.size === 0
? "unknown"
: installations.size === 1
? "single"
: "multiple";
};
/**
* One installation is one launcher directory after symlink resolution. npm
* writes `rea`, `rea.cmd` and `rea.ps1` side by side on Windows, where `where`
* lists each and directory spelling is case-insensitive; PATH can also reach
* one launcher through a symlinked directory. An unresolvable launcher keeps
* its listed path rather than joining another installation.
*/
const launcherInstallation = async (
path: string,
platform: NodeJS.Platform,
): Promise<string> => {
const resolved = await realpath(path).catch(() => path);
return platform === "win32"
? win32.dirname(resolved).toLowerCase()
: posix.dirname(resolved);
};
/** Optional outer-adapter diagnostics composed without reversing dependencies. */
export interface SystemDoctorHostOptions {
@@ -870,14 +903,20 @@ export const systemDoctorHost = (
return undefined;
}
},
async installationPaths() {
async installationLaunchers() {
try {
const command = platform === "win32" ? "where" : "which";
const arguments_ = platform === "win32" ? ["rea"] : ["-a", "rea"];
return uniqueLines(
const paths = uniqueLines(
(await hostExecFileOutput(command, arguments_, commandEnvironment))
.stdout,
);
return await Promise.all(
paths.map(async (path) => ({
path,
installation: await launcherInstallation(path, platform),
})),
);
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) throw cause;
// best-effort cleanup: optional path probing; failure means none found.
@@ -18,123 +18,6 @@ const extension = {
timeout: 300,
};
describe("Goose YAML source key retention", () => {
it("updates and removes an extension selected through aliased path keys", () => {
const source =
"table: &table extensions\nname: &name rea\n? *table\n:\n ? *name\n : {cmd: old, type: stdio}\n";
const updated = editGooseConfigurationYaml(source, ["extensions", "rea"], {
cmd: "rea",
type: "stdio",
});
expect(updated).toContain("*table");
expect(updated).toContain("*name");
expect(parseGooseConfigurationYaml(updated)).toMatchObject({
extensions: { rea: { cmd: "rea", type: "stdio" } },
});
const removed = editGooseConfigurationYaml(
updated,
["extensions", "rea"],
undefined,
);
expect(removed).not.toContain("*name");
expect(parseGooseConfigurationYaml(removed)).toMatchObject({
extensions: {},
});
const ambiguous =
"name: &name rea\nextensions:\n ? *name\n : {cmd: first}\n rea: {cmd: second}\n";
expect(() =>
editGooseConfigurationYaml(ambiguous, ["extensions", "rea"], {
cmd: "rea",
}),
).toThrow(/more than one key resolving to "rea"/);
});
it("retains a collection mapping key when an adjacent field changes", () => {
const source =
"extensions:\n rea:\n cmd: old\n ? [one, two]\n : retained\n";
const parsed = parseGooseConfigurationYaml(source) as {
extensions: { rea: Record<string, unknown> };
};
const updated = editGooseConfigurationYaml(source, ["extensions", "rea"], {
...parsed.extensions.rea,
cmd: "rea",
});
const document = parseDocument(updated);
expect(document.errors).toEqual([]);
const native = document.toJS({ mapAsMap: true }) as Map<string, unknown>;
const extensions = native.get("extensions") as Map<string, unknown>;
const entry = extensions.get("rea") as Map<unknown, unknown>;
expect([...entry.entries()]).toContainEqual([["one", "two"], "retained"]);
expect(entry.get("cmd")).toBe("rea");
});
it("retains an aliased mapping key when an adjacent launcher field changes", () => {
const source =
'label: &label 1\nextensions:\n rea:\n cmd: old\n ? *label\n : numeric\n "1": string\n';
const parsed = parseGooseConfigurationYaml(source) as {
extensions: { rea: Record<string, unknown> };
};
const updated = editGooseConfigurationYaml(source, ["extensions", "rea"], {
...parsed.extensions.rea,
cmd: "rea",
});
expect(updated).toContain("*label");
expect(updated).toContain("numeric");
expect(updated).toContain('"1": string');
});
it("preserves the order of an anchor and a retained non-string key alias", () => {
const source =
'extensions:\n rea:\n cmd: old\n shared: &seed value\n 1: *seed\n "1": string\n';
const parsed = parseGooseConfigurationYaml(source) as {
extensions: { rea: Record<string, unknown> };
};
const updated = editGooseConfigurationYaml(source, ["extensions", "rea"], {
...parsed.extensions.rea,
cmd: "rea",
});
expect(updated.indexOf("&seed")).toBeLessThan(updated.indexOf("*seed"));
expect(parseGooseConfigurationYaml(updated)).toMatchObject({
extensions: { rea: { shared: "value", cmd: "rea" } },
});
});
it("retains distinct keys beside an edited field and rejects an ambiguous edit", () => {
const source =
'extensions:\n rea:\n cmd: old\n 1: numeric\n "1": string\n';
const parsed = parseGooseConfigurationYaml(source) as {
extensions: { rea: Record<string, unknown> };
};
const entry = parsed.extensions.rea;
const updated = editGooseConfigurationYaml(source, ["extensions", "rea"], {
...entry,
cmd: "rea",
});
expect(updated).toContain(' 1: numeric\n "1": string');
expect(updated).toContain("cmd: rea");
expect(() =>
editGooseConfigurationYaml(source, ["extensions", "rea"], {
...entry,
"1": "changed",
}),
).toThrow(/source identity is not a plain string key/);
});
it("keeps distinct native mapping keys in unchanged extension settings", () => {
const source =
'extensions:\n rea:\n cmd: old\n metadata:\n 1: numeric\n "1": string\n';
const parsed = parseGooseConfigurationYaml(source) as {
extensions: { rea: Record<string, unknown> };
};
const updated = editGooseConfigurationYaml(source, ["extensions", "rea"], {
...parsed.extensions.rea,
cmd: "rea",
});
expect(updated).toContain(' 1: numeric\n "1": string');
expect(updated).toContain("cmd: rea");
});
});
describe("Goose YAML 1.2 source facts", () => {
it("keeps native strings and exact large integer facts distinct", () => {
const source =
+9 -10
View File
@@ -13,7 +13,6 @@ import { ConfigurationError } from "../domain/configurationErrors.js";
import {
copyYamlAliasSource,
preserveYamlAliasValues,
resolveYamlEditPath,
} from "./YamlConfigurationAliases.js";
import {
retainYamlConfigurationSource,
@@ -252,7 +251,7 @@ export const editGooseConfigurationYaml = (
if (!isMap(document.contents))
throw new ConfigurationError("Goose configuration root must be a mapping");
for (let length = 1; length < path.length; length++) {
const parent = resolveYamlEditPath(document, path.slice(0, length));
const parent = path.slice(0, length);
let previous = document.getIn(parent, true);
if (isAlias(previous)) {
previous = copyYamlAliasSource(previous, document);
@@ -263,28 +262,28 @@ export const editGooseConfigurationYaml = (
document.setIn(parent, document.createNode({}));
} else if (!isMap(previous))
throw new ConfigurationError(
`Goose configuration ${path.slice(0, length).join(".")} must be a mapping`,
`Goose configuration ${parent.join(".")} must be a mapping`,
);
}
const sourcePath = resolveYamlEditPath(document, path);
const previous = document.getIn(sourcePath, true);
const parent = document.getIn(sourcePath.slice(0, -1), true);
const previous = document.getIn(path, true);
const parent = document.getIn(path.slice(0, -1), true);
const removedKey =
value === undefined && isMap(parent)
? parent.items.find((pair) => pair.key === sourcePath.at(-1))?.key
? parent.items.find(
(pair) => isScalar(pair.key) && pair.key.value === path.at(-1),
)?.key
: undefined;
const retained = preserveYamlAliasValues(document, [previous, removedKey]);
if (value === undefined) {
if (!document.deleteIn(sourcePath)) return text;
if (!document.deleteIn(path)) return text;
} else
document.setIn(
sourcePath,
path,
isMap(previous) || isSeq(previous)
? retainYamlConfigurationSource(
previous,
document.createNode(value),
retained,
document,
)
: value,
);
@@ -12,69 +12,6 @@ import {
stringifyHermesConfigurationYaml,
} from "./HermesConfigurationYaml.js";
it("uses the first inherited mapping when creating an override", () => {
const source =
"first: &first\n mcp_servers:\n one: {command: first}\nsecond: &second\n mcp_servers:\n two: {command: second}\n<<: [*first, *second]\n";
const updated = editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
command: "rea",
});
const parsed = parseHermesConfigurationYaml(updated) as {
mcp_servers: Record<string, unknown>;
};
expect(parsed.mcp_servers).toEqual({
one: { command: "first" },
rea: { command: "rea" },
});
expect(
parseHermesConfigurationYaml(
updated.replace("command: first", "command: changed"),
),
).toMatchObject({
mcp_servers: { one: { command: "changed" } },
});
});
it("refuses an inherited override when an existing anchor name is shadowed", () => {
const source =
"shared: &shared\n mcp_servers: &table\n other: {command: first}\nshadow: &table {other: {command: second}}\n<<: *shared\n";
expect(() =>
editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
command: "rea",
}),
).toThrow(/anchor is shadowed/);
});
it("retains inherited YAML settings when updating a registration", () => {
const source =
"defaults: &defaults\n enabled: false\nmcp_servers:\n rea:\n <<: *defaults\n command: old\n";
const parsed = parseHermesConfigurationYaml(source) as {
mcp_servers: { rea: Record<string, unknown> };
};
const updated = editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
...parsed.mcp_servers.rea,
command: "rea",
});
expect(updated).toContain("<<: *defaults");
const changedDefault = updated.replace("enabled: false", "enabled: true");
expect(parseHermesConfigurationYaml(changedDefault)).toMatchObject({
mcp_servers: { rea: { command: "rea", enabled: true } },
});
const overridden = editHermesConfigurationYaml(
source,
["mcp_servers", "rea"],
{ ...parsed.mcp_servers.rea, command: "rea", enabled: true },
);
expect(overridden).toContain("<<: *defaults");
expect(parseHermesConfigurationYaml(overridden)).toMatchObject({
mcp_servers: { rea: { command: "rea", enabled: true } },
});
expect(() =>
editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
command: "rea",
}),
).toThrow(/Cannot remove inherited YAML setting/);
});
it("preserves annotations on changed registration mappings and scalar fields", () => {
const source =
"mcp_servers:\n rea: # registration note\n command: old # command note\n env: # environment note\n MODE: old # mode note\n args: # arguments note\n - old # argument note\n";
+8 -123
View File
@@ -3,21 +3,16 @@ import {
isMap,
isScalar,
isSeq,
Pair,
parseDocument,
Document,
Schema,
YAMLMap,
type Node,
type ParsedNode,
type ScalarTag,
type Tags,
} from "yaml";
import { ConfigurationError } from "../domain/configurationErrors.js";
import {
preserveYamlAliasValues,
copyYamlAliasSource,
resolveYamlEditPath,
} from "./YamlConfigurationAliases.js";
import {
retainYamlConfigurationSource,
@@ -273,102 +268,6 @@ const parseHermesDocument = (text: string) => {
return document;
};
const projectedYamlPathExists = (
document: Document,
path: readonly string[],
): boolean => {
let current: unknown = document.toJS();
for (const segment of path) {
if (
typeof current !== "object" ||
current === null ||
!Object.hasOwn(current, segment)
)
return false;
current = (current as Record<string, unknown>)[segment];
}
return true;
};
const inheritedRegistrationRemovalError = () =>
new ConfigurationError(
"The registration is inherited from a YAML merge. Remove it at the shared source before rerunning uninstall",
);
/** Find the source node selected by YAML merge precedence. */
const inheritedYamlValue = (
document: Document,
source: unknown,
name: string,
visiting = new Set<object>(),
): unknown => {
const node = isAlias(source) ? source.resolve(document) : source;
if (!isMap(node) || visiting.has(node)) return undefined;
visiting.add(node);
try {
const matches = node.items.filter((pair) => {
const key = isAlias(pair.key) ? pair.key.resolve(document) : pair.key;
return isScalar(key) && key.value === name;
});
if (matches.length > 1)
throw new ConfigurationError(
`YAML mapping contains more than one key resolving to ${JSON.stringify(name)}`,
);
if (matches[0] !== undefined) return matches[0].value;
for (const pair of node.items) {
if (
!isScalar(pair.key) ||
typeof pair.key.value !== "symbol" ||
pair.key.source !== "<<"
)
continue;
const merged = isAlias(pair.value)
? pair.value.resolve(document)
: pair.value;
const sources = isSeq(merged) ? merged.items : [merged];
for (const candidate of sources) {
const found = inheritedYamlValue(document, candidate, name, visiting);
if (found !== undefined) return found;
}
}
return undefined;
} finally {
visiting.delete(node);
}
};
/** Create a local override that continues to inherit the shared mapping. */
const inheritedYamlMapOverride = (
document: Document,
parent: readonly (string | Node)[],
name: string,
): void => {
const owner =
parent.length === 1
? document.contents
: document.getIn(parent.slice(0, -1), true);
let inherited = inheritedYamlValue(document, owner, name);
if (isAlias(inherited)) inherited = inherited.resolve(document);
if (!isMap(inherited))
throw new ConfigurationError(
`Inherited YAML setting ${JSON.stringify(name)} is not a mapping`,
);
const mergeDocument = parseHermesDocument("<<: {}\n");
if (
!isMap(mergeDocument.contents) ||
mergeDocument.contents.items[0] === undefined
)
throw new ConfigurationError("Cannot create a YAML merge key");
const alias = document.createAlias(inherited, "rea_inherited");
const override = new YAMLMap(document.schema);
override.items.push(new Pair(mergeDocument.contents.items[0].key, alias));
document.setIn(parent, override);
if (alias.resolve(document) !== inherited)
throw new ConfigurationError(
"The inherited YAML anchor is shadowed; edit its shared source before rerunning setup",
);
};
/** Parse a Hermes `config.yaml`, preserving the distinction between no root and explicit null. */
export const parseHermesConfigurationYaml = (text: string): unknown => {
const document = parseHermesDocument(text);
@@ -400,7 +299,7 @@ export const editHermesConfigurationYaml = (
document.contents = map;
}
for (let length = 1; length < path.length; length++) {
const parent = resolveYamlEditPath(document, path.slice(0, length));
const parent = path.slice(0, length);
const previous = document.getIn(parent, true);
if (isAlias(previous)) {
const copied = copyYamlAliasSource(previous, document);
@@ -409,49 +308,35 @@ export const editHermesConfigurationYaml = (
document.setIn(parent, copied);
}
} else if (value !== undefined && !document.hasIn(parent)) {
if (projectedYamlPathExists(document, path.slice(0, length))) {
inheritedYamlMapOverride(document, parent, path[length - 1]!);
continue;
}
// Implicit JS Map parents serialize as !!omap in YAML 1.1.
document.setIn(parent, document.createNode({}));
}
}
const sourcePath = resolveYamlEditPath(document, path);
const previous = document.getIn(sourcePath, true);
const previous = document.getIn(path, true);
// Deletion removes the pair's key too; replacement retains that key node.
const parent =
value === undefined
? document.getIn(sourcePath.slice(0, -1), true)
: undefined;
if (
value === undefined &&
!document.hasIn(sourcePath) &&
projectedYamlPathExists(document, path)
)
throw inheritedRegistrationRemovalError();
value === undefined ? document.getIn(path.slice(0, -1), true) : undefined;
const removedKey = isMap(parent)
? parent.items.find((pair) => pair.key === sourcePath.at(-1))?.key
? parent.items.find(
(pair) => isScalar(pair.key) && pair.key.value === path.at(-1),
)?.key
: undefined;
const retainedSources = preserveYamlAliasValues(document, [
previous,
removedKey,
]);
if (value === undefined) {
if (!document.deleteIn(sourcePath)) return text;
if (projectedYamlPathExists(document, path))
throw inheritedRegistrationRemovalError();
if (!document.deleteIn(path)) return text;
} else {
// Let YAML's scalar update preserve numeric formats for a direct edit.
// Replacing containers needs explicit retention of unchanged child nodes.
document.setIn(
sourcePath,
path,
isMap(previous) || isSeq(previous)
? retainYamlConfigurationSource(
previous,
document.createNode(value),
retainedSources,
document,
)
: value,
);
+2
View File
@@ -123,6 +123,8 @@ export class HopperReleaseError extends Error {
| "release_metadata"
| "download"
| "integrity"
/** The authenticated package's launcher differs from the feed's main_exe_hash. */
| "launcher_digest_mismatch"
| "publisher_verification_failed"
| "verification_unavailable"
| "unsupported_hopper_build"
+1
View File
@@ -226,6 +226,7 @@ export type LinuxHopperInstallResult =
| "archive_write"
| "launcher_inspection"
| "integrity"
| "launcher_digest_mismatch"
| "authorization_or_package_manager"
| "launcher_missing"
| "runtime_dependencies"
+1 -1
View File
@@ -261,7 +261,7 @@ export class LinuxHopperPublisher {
if (mainExeHash !== release.main_exe_hash)
return err(
new HopperReleaseError(
"integrity",
"launcher_digest_mismatch",
`The signed ${family} package launcher SHA-256 ${mainExeHash} differs from the release main_exe_hash ${release.main_exe_hash}.`,
),
);
@@ -41,6 +41,20 @@ describe("macOS Hopper setup failure projection", () => {
});
});
describe("Linux Hopper launcher digest projection", () => {
it("keeps the public integrity code without advising a retry", () => {
const failure = setupInstallFailure("launcher_digest_mismatch", {
platform: "linux",
});
expect(failure.code).toBe("integrity_mismatch");
expect(failure.remediation).toMatch(/signature verified/u);
expect(failure.remediation).not.toMatch(/^Retry setup/u);
expect(failure.remediation).not.toBe(
setupInstallFailure("integrity", { platform: "linux" }).remediation,
);
});
});
describe("Linux package-manager setup failure projection", () => {
it("quotes the failing command and a sanitized bounded diagnostic", () => {
const result = setupInstallFailure("authorization_or_package_manager", {
+8
View File
@@ -54,6 +54,7 @@ export type HopperInstallFailureReason =
| "release_metadata"
| "download"
| "integrity"
| "launcher_digest_mismatch"
| "authorization_or_package_manager"
| "launcher_missing"
| "runtime_dependencies"
@@ -166,6 +167,13 @@ const stageFailure = (
remediation:
"The Hopper download failed integrity verification. Retry setup; if it repeats, update REA before installing.",
};
case "launcher_digest_mismatch":
return {
status: "failed",
code: "integrity_mismatch",
remediation:
"The package signature verified, so the download is authentic, but the vendor release feed describes a different launcher build, so REA cannot identify this Hopper build and did not install it. Retrying setup or updating REA does not change the feed; report the mismatch to the Hopper vendor and rerun setup after the feed is corrected.",
};
case "authorization_or_package_manager":
return {
status: "failed",
+11
View File
@@ -689,6 +689,17 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
join(env.GEMINI_CLI_HOME || home, ".gemini", "settings.json"),
markerPath: ({ home, env }: ClientPathContext) =>
join(env.GEMINI_CLI_HOME || home, ".gemini"),
// Antigravity keeps its configuration under the same .gemini directory,
// so the directory alone does not show Gemini CLI. Its own settings,
// trusted-folder records and session state do.
discoveryPaths: (context: ClientPathContext) => {
const root = join(context.env.GEMINI_CLI_HOME || context.home, ".gemini");
return [
join(root, "settings.json"),
geminiSettings(context).trustedFoldersPath,
join(root, "tmp"),
];
},
skillPath: ({ home, env }: ClientPathContext) =>
join(env.GEMINI_CLI_HOME || home, ".agents", "skills"),
format: "json",
+2 -4
View File
@@ -1089,13 +1089,11 @@ const removeClient = async (
// clientRegistrationRemoval admitted only names whose repeats REA owns.
{ removeShadowedMembersAt: editedPaths },
);
} catch (cause: unknown) {
} catch {
return item(
client.name,
"failed",
cause instanceof ConfigurationError
? `Configuration could not be updated, so no change was made. ${cause.message}.`
: "Configuration could not be updated, so no change was made. Repair the configuration, then rerun uninstall.",
"Configuration could not be updated, so no change was made. Repair the configuration, then rerun uninstall.",
);
}
if (!serialized.ok)
@@ -9,36 +9,6 @@ import {
type Document,
type Node,
} from "yaml";
import { ConfigurationError } from "../domain/configurationErrors.js";
/** Resolve literal and aliased string keys to their source nodes for editing. */
export const resolveYamlEditPath = (
document: Document,
path: readonly string[],
): (string | Node)[] => {
const resolved: (string | Node)[] = [];
let current: unknown = document.contents;
for (const name of path) {
if (isAlias(current)) current = current.resolve(document);
if (!isMap(current)) {
resolved.push(name);
current = undefined;
continue;
}
const matches = current.items.filter((pair) => {
const key = isAlias(pair.key) ? pair.key.resolve(document) : pair.key;
return isScalar(key) && key.value === name;
});
if (matches.length > 1)
throw new ConfigurationError(
`YAML mapping contains more than one key resolving to ${JSON.stringify(name)}.`,
);
const pair = matches[0];
resolved.push(pair !== undefined && isNode(pair.key) ? pair.key : name);
current = pair?.value;
}
return resolved;
};
interface AliasCopies {
readonly targets: ReadonlyMap<Alias, Node | undefined>;
+43 -270
View File
@@ -3,17 +3,10 @@ import {
isNode,
isScalar,
isSeq,
Pair,
visit,
YAMLMap,
type Document,
type Node,
type ScalarTag,
type Tags,
type YAMLSeq,
} from "yaml";
import { isDeepStrictEqual } from "node:util";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { retainYamlSequenceAnnotations } from "./YamlSequenceAnnotations.js";
export const yamlNumericValuesEqual = (
@@ -33,50 +26,12 @@ export const yamlNumericValuesEqual = (
);
};
const normalizeYamlInteger = (_key: unknown, value: unknown): unknown =>
typeof value === "bigint" && Number.isSafeInteger(Number(value))
? Number(value)
: value;
const containsRetainedSource = (
roots: readonly unknown[],
retainedSources: ReadonlySet<Node>,
): boolean => {
if (retainedSources.size === 0) return false;
let found = false;
for (const root of roots) {
if (!isNode(root)) continue;
visit(root, {
Value: (_key, node) => {
if (retainedSources.has(node)) found = true;
},
});
}
return found;
};
/** Keep source nodes and annotations when updating a value. */
/** Keep source annotations and unchanged scalars when updating a value. */
export const retainYamlConfigurationSource = (
previous: unknown,
next: Node,
retainedSources: ReadonlySet<Node>,
document: Document,
): Node => {
if (
(isMap(previous) || isSeq(previous)) &&
((isMap(previous) && isMap(next)) || (isSeq(previous) && isSeq(next)))
) {
// Object projection can collapse distinct YAML keys. Reuse an unchanged
// collection before reconstructing its children from that projection.
if (
!containsRetainedSource([previous], retainedSources) &&
isDeepStrictEqual(
previous.toJS(document, { reviver: normalizeYamlInteger }),
next.toJS(document, { reviver: normalizeYamlInteger }),
)
)
return previous;
}
if (
isScalar(previous) &&
isScalar(next) &&
@@ -105,235 +60,53 @@ export const retainYamlConfigurationSource = (
next.spaceBefore = previous.spaceBefore;
}
if (isMap(previous) && isMap(next)) {
retainYamlMapSource(previous, next, retainedSources, document);
const entries = new Map(
previous.items.flatMap((pair) =>
isScalar(pair.key) && typeof pair.key.value === "string"
? [[pair.key.value, pair] as const]
: [],
),
);
for (const pair of next.items) {
const key = isScalar(pair.key) ? pair.key.value : undefined;
const prior = typeof key === "string" ? entries.get(key) : undefined;
if (prior === undefined) continue;
pair.key = isNode(pair.key)
? retainYamlConfigurationSource(prior.key, pair.key, retainedSources)
: prior.key;
if (isNode(pair.value))
pair.value = retainYamlConfigurationSource(
prior.value,
pair.value,
retainedSources,
);
}
} else if (isSeq(previous) && isSeq(next)) {
retainYamlSequenceSource(previous, next, retainedSources, document);
const stringItems = (items: readonly unknown[]) =>
items.every((item) => isScalar(item) && typeof item.value === "string");
if (stringItems(previous.items) && stringItems(next.items))
retainYamlSequenceAnnotations(
previous,
next,
(item) => (isScalar(item) ? item.value : undefined),
(source, target) =>
retainYamlConfigurationSource(source, target, retainedSources),
(source) => !retainedSources.has(source),
);
else
next.items = next.items.map((item, index) =>
isNode(item)
? retainYamlConfigurationSource(
previous.items[index],
item,
retainedSources,
)
: item,
);
}
return next;
};
const retainYamlSequenceSource = (
previous: YAMLSeq,
next: YAMLSeq,
retainedSources: ReadonlySet<Node>,
document: Document,
): void => {
const stringItems = (items: readonly unknown[]) =>
items.every((item) => isScalar(item) && typeof item.value === "string");
if (stringItems(previous.items) && stringItems(next.items)) {
retainYamlSequenceAnnotations(
previous,
next,
(item) => (isScalar(item) ? item.value : undefined),
(source, target) =>
retainYamlConfigurationSource(
source,
target,
retainedSources,
document,
),
(source) => !retainedSources.has(source),
);
return;
}
next.items = next.items.map((item, index) =>
isNode(item)
? retainYamlConfigurationSource(
previous.items[index],
item,
retainedSources,
document,
)
: item,
);
};
const retainYamlMapSource = (
previous: YAMLMap,
next: YAMLMap,
retainedSources: ReadonlySet<Node>,
document: Document,
): void => {
const hasMerges = retainYamlMergeSources(previous, next, document);
const hasSourceOnlyKeys = retainProjectedYamlKeys(
previous,
next,
retainedSources,
document,
);
const entries = new Map(
previous.items.flatMap((pair) =>
isScalar(pair.key) && typeof pair.key.value === "string"
? [[pair.key.value, pair] as const]
: [],
),
);
for (const pair of next.items) {
if (previous.items.includes(pair)) continue;
const key = isScalar(pair.key) ? pair.key.value : undefined;
const prior = typeof key === "string" ? entries.get(key) : undefined;
if (prior === undefined) continue;
pair.key = isNode(pair.key)
? retainYamlConfigurationSource(
prior.key,
pair.key,
retainedSources,
document,
)
: prior.key;
if (isNode(pair.value))
pair.value = retainYamlConfigurationSource(
prior.value,
pair.value,
retainedSources,
document,
);
}
if (hasSourceOnlyKeys || hasMerges) {
// Object property ordering moves integer-like keys ahead of their
// source neighbors. Restore source order so aliases stay after anchors.
const positions = new Map(
previous.items.map((pair, index) => [pair, index] as const),
);
const position = (pair: (typeof next.items)[number]): number => {
const key = isScalar(pair.key) ? pair.key.value : undefined;
const original =
positions.has(pair) || typeof key !== "string"
? pair
: entries.get(key);
return original === undefined
? Infinity
: (positions.get(original) ?? Infinity);
};
next.items.sort((left, right) => position(left) - position(right));
}
};
const isYamlMergeKey = (key: unknown): boolean =>
isScalar(key) && typeof key.value === "symbol" && key.source === "<<";
/** Retain inheritance while materializing only explicit changes to it. */
const retainYamlMergeSources = (
previous: YAMLMap,
next: YAMLMap,
document: Document,
): boolean => {
const merges = previous.items.filter((pair) => isYamlMergeKey(pair.key));
if (merges.length === 0) return false;
const inherited = new YAMLMap(document.schema);
inherited.items = merges;
const direct = new YAMLMap(document.schema);
direct.items = previous.items.filter((pair) => !isYamlMergeKey(pair.key));
const project = (map: YAMLMap): Record<string, unknown> =>
map.toJS(document, { reviver: normalizeYamlInteger }) as Record<
string,
unknown
>;
const inheritedValues = project(inherited);
const directValues = project(direct);
const desired = project(next);
const inheritedUnchanged = new Set<string>();
for (const [key, value] of Object.entries(inheritedValues)) {
if (Object.hasOwn(directValues, key)) continue;
if (!Object.hasOwn(desired, key))
throw new ConfigurationError(
`Cannot remove inherited YAML setting ${JSON.stringify(key)} while retaining its merge.`,
);
if (isDeepStrictEqual(value, desired[key])) inheritedUnchanged.add(key);
}
next.items = [
...merges,
...next.items.filter((pair) => {
const key = isScalar(pair.key) ? pair.key.value : undefined;
return typeof key !== "string" || !inheritedUnchanged.has(key);
}),
];
return true;
};
const retainProjectedYamlKeys = (
previous: YAMLMap,
next: YAMLMap,
retainedSources: ReadonlySet<Node>,
document: Document,
): boolean => {
// A JS property can represent several distinct native YAML keys. Keep
// those source pairs together when the property was not edited.
const groups = new Map<string, typeof previous.items>();
const sourceOnlyKeys = new Set<string>();
for (const pair of previous.items) {
if (isYamlMergeKey(pair.key)) continue;
let key: string;
if (isScalar(pair.key) && typeof pair.key.value === "string")
key = pair.key.value;
else {
const singleton = new YAMLMap(document.schema);
singleton.items.push(new Pair(pair.key, null));
const projected: unknown = singleton.toJS(document);
if (typeof projected !== "object" || projected === null)
throw new ConfigurationError(
"YAML mapping key could not be resolved safely",
);
const [projectedKey] = Object.keys(projected);
if (projectedKey === undefined || Object.keys(projected).length !== 1)
throw new ConfigurationError(
"YAML mapping key could not be resolved safely",
);
key = projectedKey;
sourceOnlyKeys.add(key);
}
const group = groups.get(key) ?? [];
group.push(pair);
groups.set(key, group);
}
if (sourceOnlyKeys.size > 0) {
const oldValue: unknown = previous.toJS(document, {
reviver: normalizeYamlInteger,
});
const newValue: unknown = next.toJS(document, {
reviver: normalizeYamlInteger,
});
if (
typeof oldValue !== "object" ||
oldValue === null ||
typeof newValue !== "object" ||
newValue === null
)
throw new ConfigurationError("YAML mapping could not be compared safely");
const oldProperties = oldValue as Record<string, unknown>;
const newProperties = newValue as Record<string, unknown>;
const replacements = new Map<string, typeof previous.items>();
for (const key of sourceOnlyKeys) {
const group = groups.get(key);
if (group === undefined) continue;
if (
!Object.hasOwn(newProperties, key) ||
!isDeepStrictEqual(oldProperties[key], newProperties[key])
)
throw new ConfigurationError(
`Cannot safely change YAML mapping key ${JSON.stringify(key)} because its source identity is not a plain string key.`,
);
if (
containsRetainedSource(
group.flatMap((pair) => [pair.key, pair.value]),
retainedSources,
)
)
throw new ConfigurationError(
`Cannot safely preserve YAML mapping key ${JSON.stringify(key)} while its anchor is moved.`,
);
replacements.set(key, group);
}
next.items = next.items.flatMap((pair) => {
const key = isScalar(pair.key) ? pair.key.value : undefined;
const replacement =
typeof key === "string" ? replacements.get(key) : undefined;
return replacement ?? [pair];
});
}
return sourceOnlyKeys.size > 0;
};
/** Keep a source spelling only while the selected schema resolves its value unchanged. */
const sameResolvedScalarValue = (left: unknown, right: unknown): boolean =>
Object.is(left, right) ||
@@ -21,7 +21,7 @@ import {
parseEvidence,
rebindEvidenceToTargetPath,
} from "../../domain/evidence.js";
import { inspectAnalysisViewInputSchema } from "../../domain/analysisView/analysisViewRequest.js";
import { inspectAnalysisViewInputSchema } from "../../domain/analysisView/analysisView.js";
import { functionDossierSchema } from "../../domain/native/functionDossier.js";
import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js";
import {
@@ -28,11 +28,11 @@ import {
} from "../../domain/evidence.js";
import {
inspectAnalysisViewInputSchema,
prepareAnalysisViewProjection,
type AnalysisViewResult,
type AnalysisViewRequest,
type InspectAnalysisViewInput,
} from "../../domain/analysisView/analysisViewRequest.js";
import { prepareAnalysisViewProjection } from "../../domain/analysisView/analysisView.js";
import type { AnalysisViewResult } from "../../domain/analysisView/analysisViewResult.js";
} from "../../domain/analysisView/analysisView.js";
import {
immutableJsonSnapshotMember,
isImmutableJsonSnapshot,
@@ -15,7 +15,7 @@ import { inspectAnalysisView } from "./AnalysisViewService.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { sanitizeBrowserUrl } from "../../domain/browserObservation.js";
import type { BrowserScenarioCapture } from "../../domain/browserScenarioCapture.js";
import type { AnalysisViewRequest } from "../../domain/analysisView/analysisViewRequest.js";
import type { AnalysisViewRequest } from "../../domain/analysisView/analysisView.js";
import { projectBrowserScenarioView } from "../../domain/analysisView/browserScenarioView.js";
const tree = (text: string) => ({
@@ -6,7 +6,7 @@ import {
analysisViewJavaScriptEvidence,
} from "../../../tests/fixtures/analysisView.js";
import { SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE } from "../../contracts/javascript/javascriptApplicationWorkflowExamples.js";
import { analysisViewResultSchema } from "../../domain/analysisView/analysisViewResult.js";
import { analysisViewResultSchema } from "../../domain/analysisView/analysisView.js";
import type { Evidence } from "../../domain/evidence.js";
import { jsonValueValidationIssue } from "../../domain/jsonValue.js";
import { compareSourceToBundle } from "../../domain/javascript/sourceToBundleComparison.js";
@@ -1,7 +1,9 @@
import { expect, it } from "vitest";
import { createEvidence } from "../../domain/evidence.js";
import { inspectAnalysisViewInputSchema } from "../../domain/analysisView/analysisViewRequest.js";
import { analysisViewResultSchema } from "../../domain/analysisView/analysisViewResult.js";
import {
analysisViewResultSchema,
inspectAnalysisViewInputSchema,
} from "../../domain/analysisView/analysisView.js";
import {
RELOCATION_SYMBOL_UNRECORDED,
projectNativeLoadImageView,
@@ -13,11 +13,11 @@ import {
nativeCallGraphSchema,
type NativeCallGraph,
} from "../../domain/native/nativeCallGraph.js";
import type { AnalysisViewRequest } from "../../domain/analysisView/analysisViewRequest.js";
import {
analysisViewResultSchema,
type AnalysisViewRequest,
type AnalysisViewResult,
} from "../../domain/analysisView/analysisViewResult.js";
} from "../../domain/analysisView/analysisView.js";
import {
inspectAnalysisView,
projectRetainedAnalysis,
@@ -2,8 +2,10 @@ import { expect, it } from "vitest";
import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js";
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../contracts/artifactComparisonExample.js";
import { sealAnalysisView } from "../../domain/analysisView/analysisView.js";
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisViewResult.js";
import {
artifactInventorySummaryViewResultSchema,
sealAnalysisView,
} from "../../domain/analysisView/analysisView.js";
import {
createEvidence,
parseEvidence,
@@ -9,7 +9,7 @@ import { inspectAnalysisViewValidated } from "../analysisView/AnalysisViewServic
import type { EvidenceFileReadOptions } from "../analysisView/evidenceFileSource.js";
import { artifactComparisonInputSchema } from "../../contracts/artifactComparisonContracts.js";
import { parseAnalysisInput } from "../AnalysisInput.js";
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisViewResult.js";
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
import {
compareArtifacts,
type ArtifactComparisonResult,
+2 -4
View File
@@ -7,10 +7,7 @@ import {
} from "../EvidenceBundleFiles.js";
import type { ResetInvestigationReceipt } from "../../domain/investigationLifecycle.js";
import {
retainSavedAnalysisSnapshot,
type SavedAnalysisSnapshot,
} from "../../domain/savedAnalysisSnapshot.js";
import { retainSavedAnalysisSnapshot } from "../../domain/savedAnalysisSnapshot.js";
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import {
@@ -42,6 +39,7 @@ import type {
BinarySessionPort,
BinaryOpenResult,
OpenSelectionReceipt,
SavedAnalysisSnapshot,
} from "./BinarySessionPort.js";
import { binaryTargetSummary } from "./BinaryTargetSummary.js";
import {
@@ -30,6 +30,7 @@ import type {
} from "../AnalysisProvider.js";
import type { SavedAnalysisSnapshot } from "../../domain/savedAnalysisSnapshot.js";
export type { SavedAnalysisSnapshot } from "../../domain/savedAnalysisSnapshot.js";
/** Selection observed at the end of one serialized open attempt. */
export type OpenSelectionReceipt =
@@ -735,6 +735,97 @@ describe("decompile_all resume", () => {
});
});
describe("decompile_all retained body coverage", () => {
const directory = useOutputDirectory();
const limitationsOf = (value: JsonValue): readonly string[] =>
(value as { limitations: string[] }).limitations;
const unavailableReasons = (index: {
readonly functions: readonly {
readonly body: { readonly available: boolean; readonly reason?: string };
}[];
}): string[] =>
index.functions.flatMap(({ body }) =>
!body.available && body.reason !== undefined ? [body.reason] : [],
);
const indexOf = async () =>
decompileDirectoryIndexSchema.parse(
JSON.parse(
await readFile(
join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME),
"utf8",
),
),
);
it("keeps the caveat of retained records through no-op and empty resumes", async () => {
const created = await run(
harness({ info: "unavailable" }).tools,
directory(),
);
expect(created.ok).toBe(true);
if (!created.ok) return;
const createdLimitations = limitationsOf(created.value);
// One caveat counts the retained records and repeats their shared reason.
expect(createdLimitations).toHaveLength(1);
const createdReason = unavailableReasons(await indexOf())[0];
expect(createdReason).toBeDefined();
expect(createdLimitations[0]).toMatch(/^3 exported function record/u);
expect(createdLimitations[0]).toContain(createdReason);
const file = join(directory(), "functions/0x1000.c");
const before = await readFile(file, "utf8");
for (const filter of [{}, { name: "NoSuchFunction" }]) {
const resumed = harness();
const result = await run(resumed.tools, directory(), {
resume: true,
...filter,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toMatchObject({ counts: { requested: 0 } });
expect(limitationsOf(result.value)).toEqual(createdLimitations);
expect((await indexOf()).limitations).toEqual(createdLimitations);
expect(of(resumed.calls, "procedure_info")).toHaveLength(0);
}
expect(await readFile(file, "utf8")).toBe(before);
expect(
(await indexOf()).functions.map((record) => record.body.available),
).toEqual([false, false, false]);
});
it("counts retained and new unknown bodies and drops the caveat once every body is known", async () => {
expect(
(await run(harness({ info: "unavailable" }).tools, directory())).ok,
).toBe(true);
await rm(join(directory(), "functions/0x1000.c"));
const mixed = await run(harness({ info: "error" }).tools, directory(), {
resume: true,
});
expect(mixed.ok).toBe(true);
if (!mixed.ok) return;
expect(mixed.value).toMatchObject({
counts: { written: 1, skipped: 2 },
});
// Differing reasons are counted together without naming either one.
const reasons = new Set(unavailableReasons(await indexOf()));
expect(reasons.size).toBe(2);
const mixedCaveats = limitationsOf(mixed.value).filter((limitation) =>
limitation.startsWith("3 exported function record"),
);
expect(mixedCaveats).toHaveLength(1);
for (const reason of reasons) expect(mixedCaveats[0]).not.toContain(reason);
for (const address of ["0x1000", "0x2000", "0x3000"])
await rm(join(directory(), `functions/${address}.c`));
const known = await run(harness().tools, directory(), { resume: true });
expect(known.ok).toBe(true);
if (!known.ok) return;
expect(known.value).toMatchObject({ counts: { written: 3 } });
expect(limitationsOf(known.value).join("\n")).not.toContain("body");
expect((await indexOf()).limitations).toEqual([]);
});
});
describe("decompile_all filters and layout", () => {
const directory = useOutputDirectory();
@@ -38,6 +38,7 @@ import {
DecompileExport,
exportOutputFailure,
type ExportIdentity,
type UnknownBodies,
} from "./exportTree.js";
import {
bodyRecord,
@@ -64,6 +65,13 @@ const DUPLICATE_ADDRESSES =
export const OUTPUT_IO_STREAK_LIMIT = 5;
const unaddressedRows = (count: number): string =>
`${count.toString()} inventory row(s) had no address and were not exported.`;
/** Summarizes every retained record, including those earlier runs wrote. */
const unknownBodyRecords = (unknown: UnknownBodies): string =>
`${unknown.count.toString()} exported function record(s) report an unavailable body${
unknown.sharedReason === undefined
? ` for differing reasons; see each record's body.reason in ${DECOMPILE_DIRECTORY_INDEX_NAME}.`
: `: ${unknown.sharedReason}`
}`;
interface FailureReason {
readonly code: string;
@@ -84,7 +92,6 @@ interface PublishState {
readonly failures: InlineFailure[];
inlineFailureBytes: number;
readonly recentFailures: PublishState["failures"];
bodyUnavailable: boolean;
/** Failure code of the function being attempted, if it failed. */
lastFailureCode: string | undefined;
/**
@@ -137,7 +144,6 @@ export const decompileAll = async (
failures: [],
inlineFailureBytes: 2,
recentFailures: [],
bodyUnavailable: false,
lastFailureCode: undefined,
outputIoStreak: 0,
};
@@ -161,7 +167,7 @@ export const decompileAll = async (
input.layout,
loaded.value.identity,
selection.collapsedDuplicates,
state.bodyUnavailable,
session.unknownBodies(),
loaded.value.unaddressed,
);
indexed = await session.writeIndex({
@@ -520,7 +526,6 @@ const publishOne = async (input: {
);
if (body.kind === "error")
return { result: err(body.error), bodySupport: input.bodySupport };
if (body.kind === "unavailable") input.state.bodyUnavailable = true;
// Caveats of the observations this attempt used. The journal record keeps
// the complete list, written or failed, so the bounded Evidence summary and
// the length-capped file header never hold the only copy.
@@ -1084,7 +1089,7 @@ const exportLimitations = (
layout: DecompileDirectoryInput["layout"],
identity: ExportIdentity,
collapsedDuplicates: boolean,
bodyUnavailable: boolean,
unknownBodies: UnknownBodies,
unaddressed: number,
): readonly string[] => [
...(layout === "dwarf" ? [DWARF_LAYOUT_UNAVAILABLE] : []),
@@ -1092,7 +1097,7 @@ const exportLimitations = (
...(identity.analysisProfileDigest === null ? [PROFILE_UNAVAILABLE] : []),
...(collapsedDuplicates ? [DUPLICATE_ADDRESSES] : []),
...(unaddressed > 0 ? [unaddressedRows(unaddressed)] : []),
...(bodyUnavailable ? [BODY_UNAVAILABLE] : []),
...(unknownBodies.count > 0 ? [unknownBodyRecords(unknownBodies)] : []),
];
const layoutStatus = (
@@ -105,6 +105,13 @@ export const classifyOutputPath = async (
);
};
/** Retained records whose body ranges are unknown. */
export interface UnknownBodies {
readonly count: number;
/** The reason every such record gives, or undefined when they differ. */
readonly sharedReason: string | undefined;
}
/** Owns one export directory and its append-only journal. */
export class DecompileExport {
private constructor(
@@ -148,6 +155,27 @@ export class DecompileExport {
return this.written.get(address);
}
/**
* Retained non-external records whose body ranges are unknown, with their
* reason when every such record shares one. The index lists every retained
* record, so its summary must come from these records rather than from the
* functions the latest call attempted. External records have no file for a
* body caveat to qualify.
*/
unknownBodies(): UnknownBodies {
let count = 0;
let sharedReason: string | undefined;
for (const record of this.records.values()) {
if (record.status === "external" || record.body.available) continue;
sharedReason =
count === 0 || sharedReason === record.body.reason
? record.body.reason
: undefined;
count += 1;
}
return { count, sharedReason };
}
/**
* Stable relative path for an address. The first path the journal gave this
* address wins, even when a later failure record names no path.
@@ -28,11 +28,7 @@ import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { err, ok, type Result } from "../../domain/result.js";
import { AGENTENV_PROVIDER } from "../../desktop/AgentEnvComputerHelper.js";
import type { AgentEnvComputer } from "../../desktop/AgentEnvComputer.js";
import {
controller,
type Answer,
type ControlledComputer,
} from "../../desktop/BuildComputerController.js";
import type { ControlledComputer } from "../../desktop/BuildComputerController.js";
import { BuildComputerLongRuns } from "../../desktop/BuildComputerLongRuns.js";
import {
endedCapture,
@@ -41,10 +37,12 @@ import {
} from "./DesktopBuildCaptureFailure.js";
import {
adapterError,
controller,
errorText,
refused,
runRea,
WORK,
type Answer,
} from "./DesktopBuildCommands.js";
import {
exportBuild,
@@ -7,14 +7,12 @@ import {
} from "../../domain/desktop/desktopCapture.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { AGENTENV_PROVIDER } from "../../desktop/AgentEnvComputerHelper.js";
import type {
Answer,
ControlledComputer,
} from "../../desktop/BuildComputerController.js";
import type { ControlledComputer } from "../../desktop/BuildComputerController.js";
import {
adapterError,
readOutput,
reportedFailure,
type Answer,
} from "./DesktopBuildCommands.js";
/** The most lines, and characters, a message quotes from the end of the app's stderr. */
@@ -22,6 +22,11 @@ import {
type ControlledComputer,
} from "../../desktop/BuildComputerController.js";
export {
controller,
type Answer,
} from "../../desktop/BuildComputerController.js";
/** The work folder REA keeps under the computer user's home; every capture leaves it out. */
export const WORK = ".rea-build";
/** The checkpoint options the draft and the export read the seed and the end with. */
@@ -8,7 +8,7 @@ import {
import {
analysisViewResultSchema,
type AnalysisViewResult,
} from "../../domain/analysisView/analysisViewResult.js";
} from "../../domain/analysisView/analysisView.js";
import { scenarioCaptureRecords } from "../../domain/analysisView/scenarioCaptureRecords.js";
import {
readRecordedResponseBody,
@@ -1,4 +1,4 @@
import type { AnalysisViewResult } from "../../domain/analysisView/analysisViewResult.js";
import type { AnalysisViewResult } from "../../domain/analysisView/analysisView.js";
import {
changesState,
type StateDocument,
@@ -1,5 +1,7 @@
import { projectAnalysisView } from "../../domain/analysisView/analysisView.js";
import { analysisViewResultSchema } from "../../domain/analysisView/analysisViewResult.js";
import {
projectAnalysisView,
analysisViewResultSchema,
} from "../../domain/analysisView/analysisView.js";
import { nativeFunctionMatchingSchema } from "../../domain/native/functionMatching.js";
import { expect, it } from "vitest";
import { matchNativeFunctions } from "./NativeFunctionMatchingService.js";
+1 -42
View File
@@ -3,43 +3,14 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import {
artifactFileCancellationCause,
bindArtifactFileCleanup,
type ArtifactFilePrimaryFacts,
} from "./ArtifactFileOperation.js";
import { bindArtifactFileCleanup } from "./ArtifactFileOperation.js";
import { completedStableArtifactObservation } from "./StableArtifactObservation.js";
import { isSelectedRegularFileError } from "../filesystem/RegularFile.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisInputError,
} from "../domain/analysisErrorCore.js";
/** Preserve ownership classification for stable-file identity and cancellation. */
export const projectOwnedArtifactIdentityFailure = (
facts: ArtifactFilePrimaryFacts,
operation: string,
path: string,
): AnalysisError | undefined => {
const { cause, origin } = facts;
if (
origin !== "owned-or-classified" ||
!(cause instanceof ArtifactReaderFailure)
)
return undefined;
if (cause.reason === "integrity")
return new AnalysisArtifactChangedError(operation, path, cause.message, {
cause,
});
if (cause.reason === "cancelled")
return new AnalysisCancelledError(operation, {
cause: artifactFileCancellationCause(cause),
});
return undefined;
};
/** Project filesystem errors that identify a refused or unreadable selected file. */
export const projectSelectedArtifactFileFailure = (
cause: unknown,
@@ -86,18 +57,6 @@ export const stableArtifactInputRecovery = (cause: ArtifactReaderFailure) =>
}
: {};
/** Report a rejected artifact path or format with its source-provided recovery. */
export const stableArtifactInputFailure = (
cause: ArtifactReaderFailure,
operation: string,
field = "path",
): AnalysisInputError =>
new AnalysisInputError(
operation,
{ cause, ...stableArtifactInputRecovery(cause) },
[{ path: [field], reason: "invalid_format", message: cause.message }],
);
const artifactCleanupFailure = (
cause: unknown,
providerId: string,
+2
View File
@@ -5,6 +5,8 @@ import {
type ArtifactReaderConstruction,
} from "./artifactFormats.js";
export type { ArtifactReaderConstruction } from "./artifactFormats.js";
/** Formats with an artifact entry reader; availability policy belongs to the caller. */
export type ArtifactReaderFormat = {
[Id in keyof typeof ARTIFACT_FORMATS]: (typeof ARTIFACT_FORMATS)[Id] extends {
@@ -11,7 +11,7 @@ import { PLAYWRIGHT_TRACE_OPERATION as operation } from "../../../domain/playwri
import { ArtifactReaderFailure } from "../../../artifacts/ArtifactReader.js";
import { artifactFileCancellationCause } from "../../../artifacts/ArtifactFileOperation.js";
import { isSelectedRegularFileError } from "../../../filesystem/RegularFile.js";
import { stableArtifactInputFailure } from "../../../artifacts/StableArtifactFailure.js";
import { stableArtifactInputRecovery } from "../../../artifacts/StableArtifactFailure.js";
/** Classify the archive owner's facts without interpreting opaque cancellation reasons. */
export const traceFailure = (cause: unknown, path: string): AnalysisError => {
@@ -38,7 +38,11 @@ export const traceFailure = (cause: unknown, path: string): AnalysisError => {
},
);
if (cause.reason === "format" || cause.reason === "path")
return stableArtifactInputFailure(cause, operation);
return new AnalysisInputError(
operation,
{ cause, ...stableArtifactInputRecovery(cause) },
[{ path: ["path"], reason: "invalid_format", message: cause.message }],
);
return new ArtifactOperationError(
operation,
cause.reason,
Loaded 100 of 360 files, more files were not shown because too many files have changed in this diff. Show more