Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
655b3eff6f | ||
|
|
e78455338f | ||
|
|
b6d5479ff0 | ||
|
|
5f6342db46 | ||
|
|
02d92e9830 | ||
|
|
c4c9a23bef | ||
|
|
8fd495bf13 | ||
|
|
760233c0b3 | ||
|
|
bde139c5d9 | ||
|
|
8b96354497 | ||
|
|
c83de52762 | ||
|
|
0ccc7572c7 | ||
|
|
98f22358e6 |
No files matched your search
@@ -108,13 +108,6 @@ The `replaced` result is `null`: the successful install response does not say
|
||||
whether the package was installed fresh or replaced, and REA does not query the
|
||||
device first. The install verdict and host APK digest remain available.
|
||||
|
||||
Push and install consume a private snapshot of the selected local input. The
|
||||
reported size and SHA-256 identify those consumed bytes; `local_path` and
|
||||
`apk_path` retain the original input path, whose contents may change later.
|
||||
Snapshot creation needs temporary disk space proportional to the input size.
|
||||
Failed snapshot cleanup remains owned and is retried before later operations
|
||||
and during shutdown.
|
||||
|
||||
## Interpret the result
|
||||
|
||||
- **Directory names retain whitespace and newlines.** Symlink targets do too,
|
||||
@@ -175,9 +168,6 @@ and during shutdown.
|
||||
- Cancellation during a package pull preserves caller-selected output. The
|
||||
error includes partial observations identifying completed artifacts and the
|
||||
interrupted local path; remaining APKs were not attempted.
|
||||
- Screen capture checks cancellation before publishing its exclusive output.
|
||||
If cancellation arrives after publication, the error retains the committed
|
||||
file's path, digest and dimensions; the file remains available as evidence.
|
||||
- Bugreports collect into isolated staging and publish with exclusive creation;
|
||||
filename collisions select the next numeric suffix. If publication fails, the
|
||||
completed archive remains in staging. The error's partial observation gives
|
||||
|
||||
@@ -147,24 +147,6 @@ received signal alone does not establish its exact cause. Dynamic tags require
|
||||
a complete DT_NULL inside PT_DYNAMIC; interpreter names and ranges end at the
|
||||
first NUL, with any padding still represented by the original segment range.
|
||||
|
||||
## Ghidra process failures
|
||||
|
||||
When output from the failing Ghidra startup or operation reports
|
||||
`java.lang.OutOfMemoryError`, REA returns `resource_constraint`. Java heap or GC
|
||||
overhead exhaustion includes heap-specific recovery advice. Metaspace and array
|
||||
size failures do not recommend increasing the heap; native-thread exhaustion is
|
||||
reported as a capacity constraint with unknown limits.
|
||||
|
||||
`configured_jvm_max_heap` records the explicit `-Xmx` value selected by REA's
|
||||
launch builder. It is a configuration fact, not measured memory use or proof of
|
||||
the effective JVM limit; JVM environment options may affect that limit.
|
||||
When a Windows script wrapper selects the JVM, this value remains unknown.
|
||||
Historical stdout and stderr remain available, but classification uses only the
|
||||
failing operation's `failure_output`. Output draining waits at most one second,
|
||||
subject to earlier cancellation or the startup deadline. `output_incomplete`
|
||||
marks a snapshot taken before stream closure; cleanup ownership remains with
|
||||
the session until release succeeds.
|
||||
|
||||
## Portable PE resources
|
||||
|
||||
`inspect_pe_resources` / `inspect-pe-resources` inspects an explicit PE32 or
|
||||
|
||||
@@ -66,25 +66,14 @@ Electron `file://` pages use a separate provider and target boundary; see [elect
|
||||
|
||||
## Provider authorities
|
||||
|
||||
CDP page capture and V8 Inspector observation attach to an already-running
|
||||
target without launching it, driving actions, or evaluating target code.
|
||||
Playwright scenarios drive actions and capture step snapshots in either a
|
||||
provider-owned browser or a caller-selected external browser. Separate providers
|
||||
preserve these authority and lifecycle differences. They share one exclusion
|
||||
vocabulary (`BrowserExclusionReason`): every denial reason maps onto it, even
|
||||
where a wire schema keeps a narrower historical bucket.
|
||||
|
||||
CDP attachment is not a guarantee that the browser preserves all state.
|
||||
Chromium can clear another client's `navigator.platform` emulation when REA's
|
||||
page session disconnects, even if that session sent no emulation commands.
|
||||
This affects page inspection, bundle capture, session observation, screenshots,
|
||||
WebMCP discovery, and Electron page inspection; target listing alone does not
|
||||
attach a page session. REA cannot read the other client's complete
|
||||
user-agent/client-hint configuration and does not replay guessed settings.
|
||||
Use a disposable browser when preserving externally configured emulation is
|
||||
required, or have the owning client reapply its complete configuration afterward.
|
||||
See [the reproduction and Chromium ownership limitation](https://github.com/morluto/rea/issues/4089).
|
||||
|
||||
CDP page capture, V8 Inspector observation, and Playwright scenarios stay
|
||||
separate providers because their authorities differ: CDP and V8 attach to an
|
||||
already-running target and never launch, drive, evaluate, or mutate it, while
|
||||
Playwright providers own and drive the runtime (launch browsers, run actions,
|
||||
capture step snapshots). Merging them would mix attach-only and owned-process
|
||||
lifecycles in one contract. What they share instead is one exclusion
|
||||
vocabulary (`BrowserExclusionReason`): every denial reason in every stack maps
|
||||
onto it, even where a wire schema keeps a narrower historical bucket.
|
||||
Existing static application Evidence and passive web/Electron captures can be
|
||||
combined later through
|
||||
[JavaScript static/runtime reconciliation](javascript-runtime-reconciliation.md).
|
||||
|
||||
@@ -144,15 +144,6 @@ distinction: it closes and deletes provider-owned profiles, but only disconnects
|
||||
from an external CDP browser. Real-browser verification checks both outcomes and
|
||||
confirms that an attached external browser remains alive.
|
||||
|
||||
Keeping that browser alive does not guarantee its emulation state survives.
|
||||
Chromium can clear caller-owned `navigator.platform` emulation when a page CDP
|
||||
session disconnects, including sessions that sent no emulation commands. REA
|
||||
cannot snapshot the other client's complete user-agent/client-hint settings and
|
||||
does not replay a partial reconstruction. Use launch mode to keep this effect
|
||||
inside a provider-owned browser, or have the external owning client reapply its
|
||||
complete configuration after capture. Connected captures report this limitation;
|
||||
see [issue #4089](https://github.com/morluto/rea/issues/4089).
|
||||
|
||||
`environment.locale` sets Intl formatting in both modes. Launch also sets
|
||||
`navigator.language`, `navigator.languages`, and the request `Accept-Language`.
|
||||
Connect preserves the caller's page and request languages; every attached
|
||||
|
||||
@@ -168,12 +168,6 @@ be verified. See [MCP contracts](mcp-contracts.md) for session lifecycle and
|
||||
[Ghidra first-query deadlines](mcp-contracts.md#ghidra-first-query-deadlines-and-recovery)
|
||||
for import, client timeouts and recovery.
|
||||
|
||||
If one-shot analysis fails and closing its session also fails, the original
|
||||
error details remain inline and `details.cleanup_error` describes the later
|
||||
close failure. Each diagnostic retains its own reported resources; the later
|
||||
failure does not establish whether an earlier resource was released. Captured
|
||||
output and partial observations remain in the original details.
|
||||
|
||||
## Save and reuse analysis snapshots
|
||||
|
||||
A snapshot retains successful analysis results for later queries. REA reuses
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
|
||||
REA can attach to an Electron/Chromium CDP endpoint and inspect existing `file://` renderer pages without evaluating JavaScript or invoking Electron APIs. The request supplies the endpoint directly; REA accepts only literal-port loopback HTTP endpoints. Selecting an endpoint exposes every Electron target it serves, including local file paths and page metadata.
|
||||
|
||||
Disconnecting the inspection's page session can clear caller-owned
|
||||
`navigator.platform` emulation inside Chromium. REA cannot restore another
|
||||
client's complete user-agent/client-hint configuration. Use a disposable target
|
||||
or let the owning client reapply its configuration; see the shared
|
||||
[CDP state limitation](browser-observation.md#provider-authorities).
|
||||
|
||||
This passive runtime surface is distinct from the target-free static
|
||||
[`analyze_javascript_application`](javascript-artifact-reconstruction.md)
|
||||
workflow. Static analysis reads a local ASAR or extracted directory without
|
||||
|
||||
@@ -139,12 +139,6 @@ overview merely because its source artifact is unchanged.
|
||||
|
||||
## Admitted function-analysis semantics
|
||||
|
||||
Concurrent callers share one startup while retaining independent cancellation.
|
||||
Only the final departing caller abandons startup and waits for its cleanup.
|
||||
New attempts wait for that cleanup; failed cleanup retains its original error
|
||||
and owned directories until a successful close. Closing the provider reports a
|
||||
provider-state failure to live callers rather than cancelling their signals.
|
||||
|
||||
| Concern | Ghidra contract |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Decompiler lifetime | One persistent `DecompInterface` is opened for the imported Program and disposed during bridge shutdown. Native decompilation has no fixed per-function deadline. External functions or functions without bodies return `null`; cancellation and native failure remain distinct. |
|
||||
|
||||
+2
-9
@@ -39,8 +39,8 @@ container format, architecture, word size and byte order. A recognized
|
||||
- `module.dependencies`, with the same fields and their original order;
|
||||
- `module.settings`, retaining ordered key/value entries and duplicate keys;
|
||||
- `module.unparsed_lines`, `module.unparsed_line_bytes_base64` and
|
||||
`module.complete`, which says whether every present module-text line was
|
||||
parsed. It does not establish that the dependency inventory is complete.
|
||||
`module.complete`, so unfamiliar, malformed, conflicting or undecodable records
|
||||
do not disappear behind a complete result.
|
||||
|
||||
Missing checksums are `null`; empty fields and development/local replacement
|
||||
versions remain unchanged. Build settings such as `GOOS`, `GOARCH` or `vcs.revision`
|
||||
@@ -50,13 +50,6 @@ base64 while other decodable module records are still reported; the reader does
|
||||
not insert replacement characters or reject the entire artifact for those
|
||||
strings.
|
||||
|
||||
When no main-module (`mod`) record was decoded, `module.main` is `null` and
|
||||
dependency coverage is unknown. Successfully decoded dependency records remain
|
||||
visible. GOPATH-mode builds may contain only a package `path` and build settings;
|
||||
their empty dependency array does not establish the absence of dependencies.
|
||||
The result's limitations state this explicitly. A module-mode build with a main
|
||||
module record and no dependency records has an observed empty dependency list.
|
||||
|
||||
## Supported boundary
|
||||
|
||||
The built-in TypeScript reader supports ELF, PE and thin Mach-O images with Go's
|
||||
|
||||
@@ -179,8 +179,6 @@ as well, so it is broader than this Claude-only migration.
|
||||
history under `CLAUDE_CONFIG_DIR`. Setup follows the same
|
||||
variable: it writes the registration to `$CLAUDE_CONFIG_DIR/.claude.json` and
|
||||
the skill to `$CLAUDE_CONFIG_DIR/skills`.
|
||||
For REA setup, an empty `CLAUDE_CONFIG_DIR` is treated as unset: registration
|
||||
goes to `~/.claude.json` and the skill goes to `~/.claude/skills`.
|
||||
|
||||
```bash
|
||||
CLAUDE_CONFIG_DIR=~/.claude-rea npx rea-agents setup --client claude_code
|
||||
|
||||
@@ -68,17 +68,6 @@ The node vocabulary covers:
|
||||
- runtime script instance;
|
||||
- an explicit `unknown` kind when classification is not justified.
|
||||
|
||||
Static network endpoints can have `resolution: "partial"` when the analyzer
|
||||
recognizes a request call but cannot resolve its target to one exact value.
|
||||
The observation's `value` then preserves the source expression, and its
|
||||
evidence points to the call site. Partial and exact endpoints have distinct
|
||||
identities even when their displayed values match. Partial identities also retain
|
||||
the source site: matching expression text at different sites does not establish
|
||||
the same runtime target. This records observed source syntax; it does not
|
||||
establish that the call ran or contacted that endpoint.
|
||||
Graph coverage describes the analyzed artifact scope and resource limits, not
|
||||
runtime execution. A partial target alone does not imply omitted source.
|
||||
|
||||
The directed relation vocabulary is `contains`, `loads`, `imports`, `maps_to`,
|
||||
`exposes`, `sends`, `invokes`, `handles`, `calls`, `persists_to`, `observed_as`,
|
||||
and `changed_from`. Provider names are deliberately absent from both
|
||||
|
||||
@@ -174,10 +174,8 @@ graph and read literal values from `properties.value`, rather than decoding
|
||||
role keys or labels. Previously stored graphs and their original IDs remain
|
||||
valid inputs.
|
||||
|
||||
Endpoint observations keep their value in `properties.value`. When
|
||||
`properties.resolution` is `partial`, that value is the unresolved source
|
||||
expression, with an identity distinct from an exact endpoint of the same text.
|
||||
Semantic request and response nodes keep their endpoint in `properties.endpoint`. Endpoint
|
||||
Endpoint nodes keep the exact endpoint in each observation's `properties.value`;
|
||||
semantic request and response nodes keep it in `properties.endpoint`. Endpoint
|
||||
lookup identities are preserved independently of the primitive-value lattice's
|
||||
byte budget. An oversized normalized literal can become unknown while the same
|
||||
string remains an exact endpoint for tracing and comparison. Endpoint
|
||||
@@ -261,13 +259,6 @@ Direct return expressions, including expression-bodied arrows, are evaluated
|
||||
through an execution-free value lattice. Literal object fields and direct
|
||||
return sites are represented in the result. Calls, dynamic spreads, computed
|
||||
keys, and parser recovery remain partial or unknown.
|
||||
Rebound values are evaluated at their read position: saving an incoming parameter
|
||||
before a later assignment preserves caller-dependent uncertainty, while a proven
|
||||
preceding assignment can supply a constant. Unresolved ordering remains unknown.
|
||||
Supported reads within object and array literals follow left-to-right evaluation;
|
||||
an unproved relative order remains unknown. A later write in a separate conditional
|
||||
block can still leave an earlier capture unknown because ordering across those
|
||||
blocks is not proven.
|
||||
Objects and arrays passed to calls, constructors, or tagged templates, used
|
||||
as method receivers, or stored through property targets are not assumed
|
||||
unchanged afterward. Property stores conservatively retain reference escape
|
||||
|
||||
@@ -182,18 +182,6 @@ partial Evidence. Cancellation after source analysis begins can also retain
|
||||
completed facts, including when interrupted during cleanup, graph construction,
|
||||
or Evidence sealing. Unexamined relationships remain unknown.
|
||||
|
||||
Per-file gaps identify the applicable processing phase: JavaScript source analysis
|
||||
uses AST authority, while package/JSON, HTML-reference and source-map processing
|
||||
use artifact-byte authority. File-analysis interruption gaps carry `analysis_status`:
|
||||
`interrupted` means the phase was entered (including source-worker startup),
|
||||
and `not-started` means interruption prevented that phase from beginning.
|
||||
Neither state establishes that parsing completed. Remaining parsers are not run
|
||||
after interruption just to populate a result.
|
||||
|
||||
Native add-ons remain metadata-only and acquire no JavaScript parse or analysis
|
||||
gap. This also applies when their paths are outside `source_paths`; their inventory
|
||||
and caller selection remain available without implying applicable AST work.
|
||||
|
||||
MCP returns a `partial_observation` retained reference after recording succeeds;
|
||||
open it with `inspect_analysis_view` on the same connection. If the SDK stopped
|
||||
waiting before receiving the reply, follow [MCP cancellation recovery](mcp-contracts.md#progress-and-cancellation)
|
||||
@@ -311,25 +299,13 @@ suffix. Utility calls such as `useUtils().invoice.list.fetch()`, `useQueries`,
|
||||
and links whose callee was renamed by a minifier are not recognized.
|
||||
|
||||
Each recovered bundle module retains the exact factory-source digest. A complete
|
||||
bounded AST also receives a `babel-ast-v3` structural fingerprint. It ignores
|
||||
ordinary identifier spellings while retaining literal and directive values,
|
||||
operators, fixed property names, and selected syntax flags such as computed
|
||||
keys, declaration kind, function async/generator state, update-expression
|
||||
prefix, method kind, and class static state. Local names used in computed keys
|
||||
remain rename-resistant; private member names are also ignored while their
|
||||
method kind and static state remain represented.
|
||||
|
||||
This fingerprint is structural evidence for candidate matching, not proof that
|
||||
two modules behave equivalently. Its scope follows the AST facts listed above;
|
||||
unrepresented syntax can still collide. The exact source digest remains
|
||||
available to show byte-level change, and comparison reports content changes even
|
||||
when structural matching succeeds.
|
||||
|
||||
Version comparison includes the algorithm in its structural matching key.
|
||||
Retained `babel-ast-v1` results can still match other v1 results, and v2 results
|
||||
can still match v2 results, but v1/v2 results cannot structurally match v3
|
||||
results. Other matching tiers remain available across algorithm versions.
|
||||
Re-analyze both snapshots to compare using the v3 rules.
|
||||
bounded AST also receives a `babel-ast-v2` structural fingerprint that ignores
|
||||
ordinary identifier names while retaining syntax, literals, operators, object
|
||||
keys, and member properties. Computed member identifiers retain their access
|
||||
syntax without treating a local variable's spelling as a fixed property; template
|
||||
literal contents remain part of the fingerprint. Version comparison keeps the
|
||||
algorithm in its matching key, so retained `babel-ast-v1` fingerprints cannot match
|
||||
these new fingerprints. Re-analyze both snapshots to compare their new structure.
|
||||
If fingerprint construction reaches its structural
|
||||
bound, the fingerprint is unavailable and its status is `truncated`; a bounded
|
||||
prefix is never presented as a complete stable fingerprint.
|
||||
@@ -426,13 +402,10 @@ For `.node` bindings, member names mean “requested by JavaScript syntax.” A
|
||||
resolved add-on path does not convert them into verified native exports. Native
|
||||
symbol verification remains a separate deep-analysis claim.
|
||||
|
||||
Exact endpoint values preserve useful local diagnostics, including query values
|
||||
and fragments, while removing URL username/password credentials when the value
|
||||
parses as a URL. When a recognized request target cannot be resolved exactly,
|
||||
the endpoint's partial resolution retains the original source expression with
|
||||
its call-site provenance. This does not establish that the request executed.
|
||||
Artifact paths, digests, parse locations, and analysis metadata remain
|
||||
actionable because REA is local-only.
|
||||
Endpoint observations preserve useful local diagnostics, including query values
|
||||
and fragments, while removing only URL username/password credentials. Artifact
|
||||
paths, digests, parse locations, and analysis metadata remain actionable
|
||||
because REA is local-only.
|
||||
|
||||
## Safety boundary
|
||||
|
||||
|
||||
+8
-12
@@ -393,10 +393,9 @@ concrete version, selection source, and complete analysis profile through
|
||||
choices return typed selection details. A selected provider is never replaced
|
||||
automatically after a runtime failure.
|
||||
|
||||
Each provider client receives `binary_session.analysis_run.run_id` before any
|
||||
provider startup, including clients whose startup fails. `process_lineage` is
|
||||
`not_observed` until a dynamic provider starts, then becomes `snapshots` with
|
||||
every started provider's identity
|
||||
Every successful target transition allocates `binary_session.analysis_run.run_id`
|
||||
before any provider startup. `process_lineage` is `not_observed` until a dynamic
|
||||
provider starts, then becomes `snapshots` with every started provider's identity
|
||||
and retained ownership observation. Each observation records `observed_at` and
|
||||
is `unavailable` with a reason when ownership could not be revalidated, or
|
||||
`verified` with launcher PID, parent PID, process group, and descendants observed
|
||||
@@ -404,14 +403,11 @@ at that bounded check. These are historical snapshots, not live process
|
||||
inventories, and do not claim that no short-lived descendant existed.
|
||||
|
||||
`binary_session.cleanup_pending` reports whether the session retains a provider
|
||||
client, session file resource, or provider-candidate resource whose cleanup has
|
||||
not succeeded. It can be true with `open: false`; target retirement does not
|
||||
establish resource release. Health, activity, and lineage remain available
|
||||
under a retained client's original `analysis_run.run_id` where the provider
|
||||
supplies them. Successful client cleanup clears the retained run; a replacement
|
||||
client receives a fresh run ID. Follow the cleanup error's reported resources
|
||||
and recovery action before reopening; a missing health observation does not
|
||||
establish an idle provider.
|
||||
client whose cleanup has not succeeded. It can be true with `open: false`;
|
||||
target retirement does not establish resource release. Health, activity, and
|
||||
lineage remain available from that retained client where the provider supplies
|
||||
them. Follow the cleanup error's reported resources and recovery action before
|
||||
reopening; a missing health observation does not establish an idle provider.
|
||||
|
||||
## Progress and cancellation
|
||||
|
||||
|
||||
@@ -486,11 +486,6 @@ with partial coverage. Available output prefixes remain inline; their byte
|
||||
counts are observed lower bounds when final drain counters are unavailable.
|
||||
The original failure classification and cleanup uncertainty remain separate.
|
||||
An interrupted journal row is ignored with an explicit limitation.
|
||||
If the trace completed before cancellation or runtime-directory cleanup failed,
|
||||
the retained observation also keeps its exit status, termination proof, launch
|
||||
identity, breakpoint results and final stream counters. Those fields remain
|
||||
absent when the interrupted producer did not report them; partial coverage alone
|
||||
does not mean that every retained fact is incomplete.
|
||||
|
||||
Only entries are observed: return values, floating-point and stack arguments,
|
||||
and inlined or `objc_direct` calls are not. The target runs with the current
|
||||
@@ -587,18 +582,6 @@ Exact and ambiguous value counts describe emitted root/object value instances;
|
||||
a root and an object may contain the same value. Archive digests identify the
|
||||
original bytes.
|
||||
|
||||
Interface Builder's XML and binary plist archives use the same number metadata
|
||||
and exact-decimal representation for emitted node attributes. Projection follows
|
||||
object and connection classification, so archive IDs, UID links and graph counts
|
||||
keep their meaning. Precision limitations name the source document. Unsafe
|
||||
integer-literal counts cover the archive, including fields not emitted as node
|
||||
attributes; exact and ambiguous projection counts cover emitted attribute values.
|
||||
Integral reals stay numbers, and ambiguous or incompletely observed values retain
|
||||
explicit precision limitations. Before plist decoding, the existing aggregate
|
||||
decode budget reserves estimated room for number metadata and attribute
|
||||
projection; these estimates are not a hard process-memory bound. Native
|
||||
NIBArchive decoding is unchanged by this plist-specific representation.
|
||||
|
||||
Dates are reported as `{ "$plist_type": "date", "iso8601": "<timestamp>" }`. A
|
||||
date the decoder cannot represent, such as an XML date with a year beyond 9999
|
||||
or a negative year, is reported as `{ "$plist_type": "date", "iso8601": null }`
|
||||
|
||||
@@ -113,12 +113,6 @@ through the application's supported export workflow. A generic backup or copy
|
||||
does not guarantee a smaller result; REA does not truncate or modify the
|
||||
selected source.
|
||||
|
||||
On hosts that cannot address the derived WAL or rollback-journal path for a
|
||||
long selected database name, REA reports that the SQLite capability is
|
||||
unavailable. It does not assume an inaccessible sidecar is absent. Use the
|
||||
application's supported consistent offline export or copy workflow to place
|
||||
the complete known file set at a shorter path.
|
||||
|
||||
MCP also applies its configured response budget to the complete Evidence
|
||||
envelope. If delivery exceeds that budget, it reports a transport constraint
|
||||
with guidance for exporting retained Evidence, requesting a matching larger
|
||||
|
||||
@@ -83,11 +83,6 @@ providers need not expose identical state machines.
|
||||
recovery handshakes. Bound abandoned unauthenticated listeners where the
|
||||
protocol permits it; that deadline must not expire authenticated idle sessions
|
||||
or long-running native work. Listener expiry does not prove target closure.
|
||||
- Shared startup has one producer and independently cancellable caller waits.
|
||||
Keep it running while any caller still needs it. The final departing caller
|
||||
waits for owned cleanup and retains its failure. Provider closure is distinct
|
||||
from caller cancellation. Retained cleanup capabilities block replacement;
|
||||
derive readiness from the current startup's completion and live connection.
|
||||
- For cancellable observations, carry cancellation through result consumption
|
||||
and awaited cleanup. Before returning success, check the signal after the
|
||||
last awaited finalization step.
|
||||
@@ -103,10 +98,6 @@ providers need not expose identical state machines.
|
||||
when close options change. Preserve observed child failure or activity when
|
||||
another child's health is unavailable; idle plus missing coverage stays
|
||||
unknown.
|
||||
Bind cleanup failures to their exact resource owner. Resolve current cleanup
|
||||
uncertainty only after that owner confirms release, while preserving the
|
||||
original failure and completed observations. Later failure composition must
|
||||
report only resources whose cleanup remains unresolved.
|
||||
- Await one-shot cleanup without discarding the completed result or primary
|
||||
diagnostic. Preserve partial observations, remaining resources, and the cleanup
|
||||
owner's recovery instructions. CLI adapters should reuse
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,245 +0,0 @@
|
||||
# Interrupted application processing: applicability and Evidence
|
||||
|
||||
Tracks #3948 under #4027. Measured baseline:
|
||||
`c7f6b8bad1a34d5efb30ff2a017672dea1fcdabb`.
|
||||
|
||||
The question is which processing phases remain unavailable after a source
|
||||
interruption, while keeping native inventory and already collected facts.
|
||||
Native identity and provider linkage must stay independent of application-wide
|
||||
partial coverage. This is static artifact analysis; no target application or
|
||||
native provider runs.
|
||||
|
||||
## Observed failure and correction
|
||||
|
||||
The interruption producer previously attached one generic failure to every
|
||||
remaining relevant file. Graph projection interpreted it as JavaScript AST work.
|
||||
Real Joplin reproduced an `analyze-javascript-source` / `ast-static-analysis`
|
||||
unavailable scope on keytar's native binary and on unvisited package, HTML and
|
||||
source-map files. Public CLI/MCP controls reproduced the same applicability
|
||||
problem. Deterministic cancellation before native metadata additionally produced
|
||||
a phantom JavaScript semantic module for the binary.
|
||||
|
||||
The producer now records the applicable operation/authority and whether the
|
||||
phase was entered. `analysis_status: interrupted` includes worker startup and
|
||||
does not imply parsing completed; `not-started` identifies unvisited phases.
|
||||
Native add-ons need no post-inventory source-processing phase, so neither interrupted
|
||||
nor caller-excluded native files acquire AST scopes or semantic modules.
|
||||
Actual JavaScript failures, structured-file unknowns, caller selection and byte
|
||||
identity remain available. The graph consumes these produced facts.
|
||||
|
||||
The AST-only unavailable Evidence helper is replaced by an explicitly typed
|
||||
static helper. Normal package/JSON/map gaps also retain their artifact-byte
|
||||
authority. Per-file progress uses the same applicability owner: JavaScript keeps
|
||||
its source phase, and metadata uses `process_javascript_artifact_metadata`.
|
||||
Remaining parsers do not run after interruption. Failure sharing is limited to
|
||||
five applicable phases during one interruption; no persistent cache is added.
|
||||
|
||||
A second review found the alternate full-analysis semantic builder still
|
||||
created modules for every excluded file kind. An actual filesystem-producer
|
||||
regression on revision `323fae8cbf3e36677bcb7b22ba31fa1a7e3c5cd4` reproduced
|
||||
phantom native/JSON/HTML/package/source-map modules, while selected and excluded
|
||||
JavaScript were positive controls. Semantic applicability now belongs to the
|
||||
shared `recordUnavailableFile` projection entry point, before retaining limits,
|
||||
nodes, roots or unknowns. Three duplicate caller checks are removed. Both full
|
||||
and incremental construction use this boundary; actual excluded JavaScript
|
||||
continues to carry source-bound unknowns and partial semantic coverage.
|
||||
|
||||
## Real input and reproduction
|
||||
|
||||
Use the [official Joplin v3.7.21 acquisition and extraction instructions](https://github.com/morluto/rea/blob/c7f6b8bad1a34d5efb30ff2a017672dea1fcdabb/docs/verification/javascript-native-handoff-identity-2026-10-15.md).
|
||||
The AppImage SHA-256 is
|
||||
`5d202dcaca31e14206826aa3f04125e7c218dbc3dc3d98479d811efbd6b856c1`;
|
||||
ASAR SHA-256 is
|
||||
`767df409b18effe60421fbefcca873de237c7e813eac744ecdeebbc5e825a33a`.
|
||||
The 6,324-file inventory includes 46 unpacked entries. Both cohorts stage all
|
||||
three native companions; the other 43 unpacked entries remain unavailable.
|
||||
|
||||
Build with pinned Node 24.18.0/npm 11.16.0 and locked dependencies. The extended
|
||||
existing verifier also runs against the baseline runtime; copy the verifier and
|
||||
`scripts/lib/javascript-file-scope-oracle.mjs` from the fixed revision into a
|
||||
built baseline checkout when reproducing the failure.
|
||||
|
||||
```bash
|
||||
artifact_path="/absolute/path/to/cases/available/app.asar"
|
||||
common=(
|
||||
--artifact "$artifact_path"
|
||||
--addon-path node_modules/keytar/build/Release/keytar.node
|
||||
--native-sha256 0700016bcd761cbe02d275f2046c7cc42f39f730a50d66e3d3307aa9d413373a
|
||||
--source-path ElectronAppWrapper.js
|
||||
--source-path ElectronAppWrapper.js.map
|
||||
--source-path index.html
|
||||
--source-path package.json
|
||||
--source-path node_modules/keytar/build/Release/keytar.node
|
||||
--structured-path ElectronAppWrapper.js.map
|
||||
--structured-path index.html
|
||||
--structured-path package.json
|
||||
--structured-expectations '{"ElectronAppWrapper.js.map":{"operation":"parse-local-source-map","analysis_status":"not-started"},"index.html":{"operation":"parse-html-script-references","analysis_status":"not-started"},"package.json":{"operation":"parse-package-json","analysis_status":"not-started"}}'
|
||||
--rounds 3 --cli-parity
|
||||
--tokenizer-python /absolute/path/to/python-with-tiktoken-0.12.0
|
||||
)
|
||||
node scripts/verify/javascript/native-handoffs.mjs "${common[@]}" \
|
||||
--analysis-timeout-ms 1 --expect-timeout --require-safe
|
||||
node scripts/verify/javascript/native-handoffs.mjs "${common[@]}" \
|
||||
--validate-structured-from-export --require-safe
|
||||
```
|
||||
|
||||
Omit `--require-safe` on the baseline timeout run to record its incorrect
|
||||
applicability. Its diagnostic exit 0 is not a passing analyst task. A supported
|
||||
1 ms deadline includes worker startup; the verifier requires a typed timeout
|
||||
and retained partial reference for that cohort. Each structured path supplies an
|
||||
independent expected operation and phase-entry status; the safety oracle matches
|
||||
all gaps exactly, including unavailable state and unknown confidence. Missing or
|
||||
conflicting expectations do not pass.
|
||||
|
||||
The native path is explicitly selected. An earlier structured-only run excluded
|
||||
it and therefore did not reproduce the native interruption. Input selections
|
||||
and that preliminary result remain recorded. Both final cohorts preserve
|
||||
partial coverage for unselected/unavailable scopes.
|
||||
|
||||
The supplied native Evidence is simulated solely to check exact subject linkage.
|
||||
The verifier checks physical companion bytes and the exported parent's root
|
||||
digest, validates public CLI/MCP native-trace parity, and reports only after
|
||||
owned transport/command/private-file cleanup. Applications and large Evidence
|
||||
are regenerable and are not archived.
|
||||
|
||||
## Two distinct task projections
|
||||
|
||||
The **timeout task** includes the expected typed-timeout preparation, one inline
|
||||
native trace and three inline structured-path traces. It checks that native has
|
||||
no AST failure and that each unvisited structured phase remains unavailable with
|
||||
the appropriate artifact-byte authority and `not-started` status.
|
||||
|
||||
The **default-deadline control** models the summary plus one inline native trace.
|
||||
Its named structured paths are additionally validated from the already exported
|
||||
complete parent, explicitly labelled `exported-parent-validation`. Those checks
|
||||
are diagnostic overhead outside the modeled native-task tokens and inside total
|
||||
verifier resources. They are not presented as inline metadata answers.
|
||||
|
||||
A control that also requested the complete outgoing `package.json` feature trace
|
||||
hit stdio admission twice: at least 10,587,373 bytes against a 10,484,736-byte
|
||||
result budget, with identical retained parent/trace identities. It is not a
|
||||
completed equivalent task or an efficiency baseline. [#4190](https://github.com/morluto/rea/issues/4190)
|
||||
records the separate need for exact retained metadata/native fact views. Complete
|
||||
export remains available. Do not compare costs across these different tasks.
|
||||
|
||||
## Measurements
|
||||
|
||||
| Cohort | Revision | Wall s | CPU s | Peak cgroup bytes | Applicability |
|
||||
| ------------------------------ | -------- | ------: | ---------: | ----------------: | ------------- |
|
||||
| Timeout, four inline traces | baseline | 60.738 | 60.289464 | 3,092,045,824 | wrong scope |
|
||||
| Timeout, four inline traces | final | 57.515 | 57.089883 | 2,763,276,288 | pass |
|
||||
| Default control, native inline | baseline | 61.542 | 61.135522 | 2,808,336,384 | pass |
|
||||
| Default control, native inline | final | 186.085 | 142.371100 | 2,959,585,280 | pass |
|
||||
|
||||
| Defined task | Selected catalog + parsed/structured replies BPE | Selected catalog + complete tool replies BPE |
|
||||
| -------------- | -----------------------------------------------: | -------------------------------------------: |
|
||||
| Timeout | 21,136 → 20,161 | 36,468 → 34,471 |
|
||||
| Native control | 11,776 → 11,720 | 17,738 → 17,632 |
|
||||
|
||||
The full-catalog alternatives and per-query samples are preserved in JSON.
|
||||
Native content identity, observed byte provenance, source selection and handoff
|
||||
provider Evidence remain equal. Corrected global provenance changes derived
|
||||
parent/trace identities; null partial-Evidence subjects remain null. Both final
|
||||
runs have exact CLI/MCP native-trace parity and match the earlier post-fix
|
||||
controls in every recorded non-timing field.
|
||||
|
||||
The whole verifier includes input hashing, analysis, three native trace rounds,
|
||||
applicable structured queries, export, CLI parity and tokenization. Every heavy
|
||||
run uses the preserved [resource observer](https://github.com/morluto/rea/blob/b3bdabab694af1f9801eff7caf75001b92314a58/experiments/analysis-efficiency/resource-run.py):
|
||||
one CPU, 8 GiB hard cap, 6 GiB early stop and no swap. Cgroup peaks include charged
|
||||
file cache, not summed RSS. Counters are observed at payload exit while the
|
||||
observer remains alive, not final whole-scope counters. Completed runs verify
|
||||
owned cleanup and report no OOM.
|
||||
|
||||
Each MCP process is fresh, but input hashing warms its file cache. Build/OS caches
|
||||
and shared-host scheduling are uncontrolled. First and repeated retained-query
|
||||
wall samples remain separate; isolated query CPU/memory are unmeasured. An
|
||||
earlier fixed timeout took 150.173 s wall / 119.468013 s CPU; its exact unchanged
|
||||
runtime repeat took 58.827 s / 58.482996 s, with identical non-timing facts,
|
||||
Evidence and BPE. Both receipts are retained. The final default control also took 186.085 s wall / 142.371100 s CPU; its
|
||||
unchanged-runtime repeat took 62.651 s / 62.240964 s with identical non-timing
|
||||
results. The slow final row remains reported. Attribution remains unresolved;
|
||||
these runs establish no speedup or performance-equivalence bound.
|
||||
|
||||
The stricter post-review verifier reruns also passed without changing production
|
||||
code: timeout 162.645 s wall / 125.876114 s CPU / 2,809,643,008 B peak; default
|
||||
control 61.367 s / 60.970077 s / 2,526,949,376 B. Both preserve the original final
|
||||
analytical fields, identities, coverage and BPE exactly; only timings and the new
|
||||
explicit oracle-input field differ. These additional receipts do not replace the
|
||||
slower rows above.
|
||||
|
||||
After centralizing semantic applicability, both real public controls passed again:
|
||||
timeout 60.443 s wall / 59.970344 s CPU / 3,078,295,552 B peak; default control
|
||||
63.574 s / 63.177884 s / 3,189,719,040 B. Analytical results, identities, coverage,
|
||||
BPE and exact CLI/MCP parity remain unchanged. JSON identifies the runtime and
|
||||
verifier source manifests for each cohort and keeps all earlier receipts.
|
||||
|
||||
Static `cl100k_base`/tiktoken 0.12.0 projections include the selected or complete
|
||||
name/description/inputSchema catalog, requests and structured Evidence or parsed
|
||||
text errors. Complete-tool-reply alternatives include MCP text/structured
|
||||
duplication. The selected two-tool catalog is 5,570 BPE; the complete projection
|
||||
is 195,522. Output schemas, host rendering/replay, notifications, model reasoning,
|
||||
final answers and billing are unmeasured. Repeated rounds, export, CLI parity,
|
||||
tokenization and exported-parent diagnostics are validation overhead outside the
|
||||
defined task tokens but inside resource receipts. Removing unsupported claims
|
||||
from the incorrect timeout baseline earns no efficiency credit.
|
||||
|
||||
## Verification and limitations
|
||||
|
||||
The initial production fix had 49 distinct passing cases across eight files. The initial
|
||||
pre-progress-cleanup seven-file selection passed 44/44. After progress cleanup,
|
||||
the same selection passed 43 and hit the existing 30-second test deadline in one
|
||||
native identity case; an unchanged two-file rerun passed both native cases and
|
||||
five public CLI cancellation cases. The original deadline was not increased.
|
||||
The runtime verifier collects interruption-bearing scopes; separate regressions
|
||||
cover native caller exclusion and malformed structured data. Source selection,
|
||||
malformed structures, actual worker resource constraints,
|
||||
retained large partial observations, cancellation/publication/cleanup, native
|
||||
identity and both public adapters remain covered.
|
||||
|
||||
The first review found that the real verifier previously accepted any non-JavaScript
|
||||
operation with artifact-byte authority and ignored phase-entry status. The
|
||||
scenario now supplies exact operation/status expectations independently of the
|
||||
production phase map. Six additional oracle cases use the actual retained
|
||||
Joplin snapshots and reject wrong metadata operations, entered or missing
|
||||
statuses, and absent or conflicting gaps. They passed, bringing the distinct
|
||||
coverage to 55 cases across nine files. The production implementation did not
|
||||
change during this review correction.
|
||||
|
||||
After the shared semantic-boundary correction from the second review, 78
|
||||
distinct cases passed across ten files: the nine-file selection passed 65/65,
|
||||
and the original root reconstruction file passed 13/13. This includes the
|
||||
alternate-builder owning test file's 23 cases. The new producer-boundary
|
||||
regression checks both construction paths, all five excluded metadata/native
|
||||
kinds, and selected/excluded JavaScript controls. Real Joplin controls exercise
|
||||
the public incremental workflow; the alternate full-builder path has filesystem
|
||||
boundary verification rather than a separately measured real-app workflow.
|
||||
|
||||
Recorded attempts also include the verifier's initial non-null-subject assumption
|
||||
and an invalid top-level semantic-module-path absence check. Both were corrected
|
||||
before their assertions were used as proof. Corrected cancellation reproduction
|
||||
shows the actual native AST scope and phantom module on the baseline. Final
|
||||
source and execution-driver versions, source pins, input digests, prior attempts
|
||||
and resource receipts are in the [compact JSON record](javascript-interruption-applicability-2026-10-15.json).
|
||||
|
||||
Before review, `npm run check && npm run docs:check` passed, including five consistency
|
||||
tests: 165.553 s wall / 150.247257 s CPU /
|
||||
4,232,491,008 B peak cgroup memory, clean owned exit and no OOM.
|
||||
The first full check stopped on a 172-line test callback exceeding the 150-line
|
||||
rule. A named assertion helper now shares scope lookup across all four public
|
||||
results; the strengthened six-case file passed again before the final gates.
|
||||
After the review correction, the full static/docs gate passed again, including
|
||||
the five consistency tests: 111.395 s wall / 110.979710 s CPU /
|
||||
4,347,125,760 B peak, with clean owned exit and no OOM.
|
||||
After centralizing semantic applicability, the final full static/docs gate also
|
||||
passed, including five consistency tests: 117.636 s wall / 117.194118 s CPU /
|
||||
4,263,153,664 B peak, clean owned exit and no OOM.
|
||||
|
||||
At the initial PR revision, 36 CI checks passed; macOS `hdiutil create` failed
|
||||
while creating its DMG fixture before REA verification, also failing the aggregate
|
||||
check. No Apple fixture/workflow code changed. That attempt provides no passing
|
||||
Apple runtime evidence.
|
||||
|
||||
Live Electron/application execution, real native-provider behavior, other-platform
|
||||
runtime coverage and hosted-agent costs are **NOT_ENOUGH_INFO**. The verification
|
||||
does not claim them.
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
export interface StructuredScopeExpectation {
|
||||
readonly operation:
|
||||
| "parse-package-json"
|
||||
| "parse-json-module"
|
||||
| "parse-html-script-references"
|
||||
| "parse-local-source-map";
|
||||
readonly analysis_status: "interrupted" | "not-started";
|
||||
}
|
||||
|
||||
export interface StructuredProcessingScope {
|
||||
readonly properties: Readonly<Record<string, unknown>>;
|
||||
readonly evidence: {
|
||||
readonly authority: string;
|
||||
readonly state: string;
|
||||
readonly confidence: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const structuredScopeExpectationsSchema: z.ZodType<
|
||||
Record<string, StructuredScopeExpectation>
|
||||
>;
|
||||
|
||||
export function matchesStructuredScopeExpectation(
|
||||
scopes: readonly StructuredProcessingScope[],
|
||||
expected: StructuredScopeExpectation | undefined,
|
||||
): boolean;
|
||||
@@ -1,28 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Independent expected outcomes supplied by the verification scenario. */
|
||||
export const structuredScopeExpectationsSchema = z.record(
|
||||
z.string().min(1),
|
||||
z.strictObject({
|
||||
operation: z.enum([
|
||||
"parse-package-json",
|
||||
"parse-json-module",
|
||||
"parse-html-script-references",
|
||||
"parse-local-source-map",
|
||||
]),
|
||||
analysis_status: z.enum(["interrupted", "not-started"]),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Every retained gap must match the named phase and its unavailable status. */
|
||||
export const matchesStructuredScopeExpectation = (scopes, expected) =>
|
||||
expected !== undefined &&
|
||||
scopes.length > 0 &&
|
||||
scopes.every(
|
||||
({ evidence, properties }) =>
|
||||
evidence.authority === "artifact-bytes" &&
|
||||
evidence.state === "unavailable" &&
|
||||
evidence.confidence === "unknown" &&
|
||||
properties.operation === expected.operation &&
|
||||
properties.analysis_status === expected.analysis_status,
|
||||
);
|
||||
@@ -76,30 +76,13 @@ function runCommand() {
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
let spawned = false;
|
||||
let childError;
|
||||
let hasChildError = false;
|
||||
child.once("spawn", () => {
|
||||
spawned = true;
|
||||
});
|
||||
const rememberChildError = (cause) => {
|
||||
if (!hasChildError) {
|
||||
hasChildError = true;
|
||||
childError = cause;
|
||||
}
|
||||
if (!spawned) {
|
||||
reject(childError);
|
||||
}
|
||||
};
|
||||
child.on("error", rememberChildError);
|
||||
child.once("error", reject);
|
||||
child.once("close", (code) => {
|
||||
child.off("error", rememberChildError);
|
||||
// During POSIX cancellation the group leader must stay alive, retaining
|
||||
// both its group identity and SQLite ownership until the entire group is
|
||||
// killed. A completed command can still have uncooperative descendants.
|
||||
if (shutdownSignal !== undefined && process.platform !== "win32") return;
|
||||
if (hasChildError) reject(childError);
|
||||
else resolveResult({ code });
|
||||
if (shutdownSignal === undefined || process.platform === "win32")
|
||||
resolveResult({ code });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -116,13 +99,7 @@ function handleDisconnect() {
|
||||
function handleSignal(signal) {
|
||||
if (shutdownSignal !== undefined) return;
|
||||
shutdownSignal = signal;
|
||||
if (child === undefined) return;
|
||||
if (process.platform === "win32") {
|
||||
// Windows has no POSIX process groups. Stop only the command owned by this
|
||||
// supervisor, and keep the SQLite transaction until its close event.
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
if (child === undefined || process.platform === "win32") return;
|
||||
// Establish cancellation before signalling the group. This supervisor is
|
||||
// its live leader, so the group identifier cannot be reused during cleanup.
|
||||
process.kill(-process.pid, signal);
|
||||
|
||||
@@ -322,47 +322,6 @@ class WebsiteMaintenanceChecks(unittest.TestCase):
|
||||
self.assertTrue(any("missing Markdown fragment" in error for error in errors))
|
||||
self.assertTrue(any("missing repository target" in error for error in errors))
|
||||
|
||||
def test_repository_links_accept_aliased_roots_and_reject_escaping_links(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = (Path(directory) / "repository").resolve()
|
||||
(root / "website/public").mkdir(parents=True)
|
||||
(root / "docs").mkdir()
|
||||
(root / "docs/guide.md").write_text("# Guide\n")
|
||||
page = root / "website/README.md"
|
||||
page.write_text("[Guide](../docs/guide.md#guide)\n")
|
||||
alias = Path(directory) / "repository-alias"
|
||||
alias.symlink_to(root, target_is_directory=True)
|
||||
self.assertEqual(VERIFY.check_repository_links(root), [])
|
||||
self.assertEqual(VERIFY.check_repository_links(alias), [])
|
||||
(root.parent / "outside.md").write_text("# Outside\n")
|
||||
page.write_text("[Outside](../../outside.md)\n")
|
||||
for boundary in (root, alias):
|
||||
with self.subTest(boundary=boundary):
|
||||
self.assertEqual(VERIFY.check_repository_links(boundary), [
|
||||
"website/README.md: missing repository target: ../../outside.md"
|
||||
])
|
||||
|
||||
def test_site_links_preserve_containment_through_aliased_roots(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
site = (Path(directory) / "site").resolve()
|
||||
site.mkdir()
|
||||
page = site / "index.html"
|
||||
page.write_text('<html lang="en"><head><title>Fixture</title><meta name="robots" content="noindex"></head><body><h1>Fixture</h1><a href="guide.html#answer">Guide</a></body></html>')
|
||||
(site / "guide.html").write_text('<html lang="en"><head><title>Guide</title><meta name="robots" content="noindex"></head><body><h1 id="answer">Answer</h1></body></html>')
|
||||
(site / "sitemap.xml").write_text('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"/>')
|
||||
(site / "robots.txt").write_text("User-agent: *\nAllow: /\nSitemap: https://rea.tools/sitemap.xml\n")
|
||||
alias = Path(directory) / "site-alias"
|
||||
alias.symlink_to(site, target_is_directory=True)
|
||||
# The tiny link fixture intentionally omits the social preview.
|
||||
expected = ["Social preview PNG is missing; run scripts/prepare-website.py"]
|
||||
self.assertEqual(VERIFY.check_site(site)[3], expected)
|
||||
self.assertEqual(VERIFY.check_site(alias)[3], expected)
|
||||
(site.parent / "outside.html").write_text("<p>Outside</p>")
|
||||
page.write_text(page.read_text().replace("guide.html#answer", "../outside.html"))
|
||||
for boundary in (site, alias):
|
||||
with self.subTest(boundary=boundary):
|
||||
self.assertIn("index.html: reference leaves the website: ../outside.html", VERIFY.check_site(boundary)[3])
|
||||
|
||||
def test_preview_preserves_pages_prefix_for_assets_and_directory_redirects(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
site = Path(directory)
|
||||
|
||||
+305
-328
@@ -5,7 +5,6 @@ import { join } from "node:path";
|
||||
|
||||
import { AdbDeviceAnalysisService } from "../dist/application/adb/AdbDeviceAnalysisService.js";
|
||||
import { createAdbDeviceAnalysisProvider } from "../dist/composition/adb.js";
|
||||
import { closeVerifierResources } from "./lib/verifier-cleanup.mjs";
|
||||
|
||||
const options = parseArguments(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
@@ -40,365 +39,343 @@ const run = async (operation, input) => {
|
||||
return result.value.normalized_result;
|
||||
};
|
||||
|
||||
let primaryFailure;
|
||||
try {
|
||||
const client = await run("inspect_adb_client", {});
|
||||
if (client !== null) {
|
||||
const client = await run("inspect_adb_client", {});
|
||||
if (client !== null) {
|
||||
check(
|
||||
"adb client reports a version",
|
||||
typeof client.client.version === "string" && client.client.version !== "",
|
||||
`binary=${client.client.binary_path} version=${client.client.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
const listing = await run("list_adb_devices", {});
|
||||
let serial = options.serial ?? null;
|
||||
if (listing !== null) {
|
||||
check(
|
||||
"device listing parsed",
|
||||
Array.isArray(listing.devices),
|
||||
`${listing.devices.length} device(s)`,
|
||||
);
|
||||
const usable = listing.devices.filter((device) => device.state === "device");
|
||||
if (serial === null && usable.length > 0) serial = usable[0].serial;
|
||||
if (serial === null) {
|
||||
process.stdout.write(
|
||||
"[FAIL] no usable device is attached; start an emulator or connect a phone\n",
|
||||
);
|
||||
failures += 1;
|
||||
} else {
|
||||
const selected = listing.devices.find((device) => device.serial === serial);
|
||||
check(
|
||||
"adb client reports a version",
|
||||
typeof client.client.version === "string" && client.client.version !== "",
|
||||
`binary=${client.client.binary_path} version=${client.client.version}`,
|
||||
"selected device present with a kind inference",
|
||||
selected !== undefined &&
|
||||
["emulator", "physical", "unknown"].includes(selected.kind),
|
||||
`serial=${serial} kind=${selected?.kind} basis=${selected?.kind_basis}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (serial !== null) {
|
||||
const device = await run("inspect_adb_device", { serial });
|
||||
if (device !== null) {
|
||||
check(
|
||||
"device build identity projected",
|
||||
device.properties.some(
|
||||
(property) => property.name === "ro.build.version.sdk",
|
||||
),
|
||||
`sdk=${
|
||||
device.properties.find((p) => p.name === "ro.build.version.sdk")?.value
|
||||
} emulator_observed=${String(device.emulator_observed)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const listing = await run("list_adb_devices", {});
|
||||
let serial = options.serial ?? null;
|
||||
if (listing !== null) {
|
||||
// Observation tools: processes, window, display, settings, logcat.
|
||||
const processes = await run("list_adb_processes", { serial });
|
||||
if (processes !== null) {
|
||||
check(
|
||||
"device listing parsed",
|
||||
Array.isArray(listing.devices),
|
||||
`${listing.devices.length} device(s)`,
|
||||
"process listing is non-empty with a header",
|
||||
processes.processes.length > 0 && typeof processes.columns === "string",
|
||||
`${processes.processes.length} process(es)`,
|
||||
);
|
||||
const usable = listing.devices.filter(
|
||||
(device) => device.state === "device",
|
||||
}
|
||||
const window = await run("inspect_adb_window", { serial });
|
||||
if (window !== null)
|
||||
check(
|
||||
"window focus observation completed",
|
||||
typeof window.focused_window === "string" ||
|
||||
window.focused_window === null,
|
||||
`focused_window=${String(window.focused_window).slice(0, 60)} (null is legitimate on headless devices)`,
|
||||
);
|
||||
if (serial === null && usable.length > 0) serial = usable[0].serial;
|
||||
if (serial === null) {
|
||||
process.stdout.write(
|
||||
"[FAIL] no usable device is attached; start an emulator or connect a phone\n",
|
||||
);
|
||||
failures += 1;
|
||||
} else {
|
||||
const selected = listing.devices.find(
|
||||
(device) => device.serial === serial,
|
||||
);
|
||||
const display = await run("inspect_adb_display", { serial });
|
||||
if (display !== null)
|
||||
check(
|
||||
"display reports a physical size",
|
||||
display.physical_size !== null &&
|
||||
display.physical_size.width > 0 &&
|
||||
display.physical_size.height > 0,
|
||||
`${display.physical_size?.width}x${display.physical_size?.height} @${String(display.physical_density)}`,
|
||||
);
|
||||
const setting = await run("read_adb_setting", {
|
||||
serial,
|
||||
namespace: "global",
|
||||
key: "adb_enabled",
|
||||
});
|
||||
if (setting !== null)
|
||||
check(
|
||||
"settings read returns the device's own value",
|
||||
setting.value === "1",
|
||||
`adb_enabled=${String(setting.value)}`,
|
||||
);
|
||||
const logcat = await run("read_adb_logcat", {
|
||||
serial,
|
||||
count: 20,
|
||||
buffer: "main",
|
||||
});
|
||||
if (logcat !== null)
|
||||
check(
|
||||
"logcat dump returns bounded lines",
|
||||
logcat.lines.length <= 20,
|
||||
`${logcat.lines.length} line(s), coverage=${logcat.coverage}`,
|
||||
);
|
||||
const features = await run("list_adb_features", { serial });
|
||||
if (features !== null)
|
||||
check(
|
||||
"feature inventory is non-empty",
|
||||
features.coverage === "complete" && features.features.length > 0,
|
||||
`${features.features.length} feature(s)`,
|
||||
);
|
||||
const services = await run("list_adb_services", { serial });
|
||||
if (services !== null)
|
||||
check(
|
||||
"service inventory is non-empty",
|
||||
services.coverage === "complete" && services.services.length > 0,
|
||||
`${services.services.length} service(s)`,
|
||||
);
|
||||
const directory = await run("list_adb_directory", {
|
||||
serial,
|
||||
device_path: "/data/local/tmp",
|
||||
});
|
||||
if (directory !== null)
|
||||
check(
|
||||
"directory listing completed",
|
||||
directory.coverage === "complete",
|
||||
`${directory.entries.length} entr(ies)`,
|
||||
);
|
||||
|
||||
const packages = await run("list_adb_packages", {
|
||||
serial,
|
||||
scope: "third_party",
|
||||
});
|
||||
let pullCandidate = options.pull ?? null;
|
||||
if (packages !== null) {
|
||||
// A third-party scope may legitimately be empty (clean emulator images);
|
||||
// completeness is about unparsed lines, not row count.
|
||||
check(
|
||||
"third-party package listing reports complete coverage",
|
||||
packages.coverage === "complete",
|
||||
`${packages.packages.length} package(s)`,
|
||||
);
|
||||
const allPackages = await run("list_adb_packages", {
|
||||
serial,
|
||||
scope: "all",
|
||||
});
|
||||
if (allPackages !== null) {
|
||||
check(
|
||||
"selected device present with a kind inference",
|
||||
selected !== undefined &&
|
||||
["emulator", "physical", "unknown"].includes(selected.kind),
|
||||
`serial=${serial} kind=${selected?.kind} basis=${selected?.kind_basis}`,
|
||||
"full package inventory is non-empty",
|
||||
allPackages.coverage === "complete" && allPackages.packages.length > 0,
|
||||
`${allPackages.packages.length} package(s)`,
|
||||
);
|
||||
}
|
||||
if (pullCandidate === null && packages.packages.length > 0)
|
||||
pullCandidate = packages.packages[0].package_name;
|
||||
}
|
||||
|
||||
if (serial !== null) {
|
||||
const device = await run("inspect_adb_device", { serial });
|
||||
if (device !== null) {
|
||||
check(
|
||||
"device build identity projected",
|
||||
device.properties.some(
|
||||
(property) => property.name === "ro.build.version.sdk",
|
||||
),
|
||||
`sdk=${
|
||||
device.properties.find((p) => p.name === "ro.build.version.sdk")
|
||||
?.value
|
||||
} emulator_observed=${String(device.emulator_observed)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Observation tools: processes, window, display, settings, logcat.
|
||||
const processes = await run("list_adb_processes", { serial });
|
||||
if (processes !== null) {
|
||||
check(
|
||||
"process listing is non-empty with a header",
|
||||
processes.processes.length > 0 && typeof processes.columns === "string",
|
||||
`${processes.processes.length} process(es)`,
|
||||
);
|
||||
}
|
||||
const window = await run("inspect_adb_window", { serial });
|
||||
if (window !== null)
|
||||
check(
|
||||
"window focus observation completed",
|
||||
typeof window.focused_window === "string" ||
|
||||
window.focused_window === null,
|
||||
`focused_window=${String(window.focused_window).slice(0, 60)} (null is legitimate on headless devices)`,
|
||||
);
|
||||
const display = await run("inspect_adb_display", { serial });
|
||||
if (display !== null)
|
||||
check(
|
||||
"display reports a physical size",
|
||||
display.physical_size !== null &&
|
||||
display.physical_size.width > 0 &&
|
||||
display.physical_size.height > 0,
|
||||
`${display.physical_size?.width}x${display.physical_size?.height} @${String(display.physical_density)}`,
|
||||
);
|
||||
const setting = await run("read_adb_setting", {
|
||||
serial,
|
||||
namespace: "global",
|
||||
key: "adb_enabled",
|
||||
});
|
||||
if (setting !== null)
|
||||
check(
|
||||
"settings read returns the device's own value",
|
||||
setting.value === "1",
|
||||
`adb_enabled=${String(setting.value)}`,
|
||||
);
|
||||
const logcat = await run("read_adb_logcat", {
|
||||
serial,
|
||||
count: 20,
|
||||
buffer: "main",
|
||||
});
|
||||
if (logcat !== null)
|
||||
check(
|
||||
"logcat dump returns bounded lines",
|
||||
logcat.lines.length <= 20,
|
||||
`${logcat.lines.length} line(s), coverage=${logcat.coverage}`,
|
||||
);
|
||||
const features = await run("list_adb_features", { serial });
|
||||
if (features !== null)
|
||||
check(
|
||||
"feature inventory is non-empty",
|
||||
features.coverage === "complete" && features.features.length > 0,
|
||||
`${features.features.length} feature(s)`,
|
||||
);
|
||||
const services = await run("list_adb_services", { serial });
|
||||
if (services !== null)
|
||||
check(
|
||||
"service inventory is non-empty",
|
||||
services.coverage === "complete" && services.services.length > 0,
|
||||
`${services.services.length} service(s)`,
|
||||
);
|
||||
const directory = await run("list_adb_directory", {
|
||||
serial,
|
||||
device_path: "/data/local/tmp",
|
||||
});
|
||||
if (directory !== null)
|
||||
check(
|
||||
"directory listing completed",
|
||||
directory.coverage === "complete",
|
||||
`${directory.entries.length} entr(ies)`,
|
||||
);
|
||||
|
||||
const packages = await run("list_adb_packages", {
|
||||
serial,
|
||||
scope: "third_party",
|
||||
});
|
||||
let pullCandidate = options.pull ?? null;
|
||||
if (packages !== null) {
|
||||
// A third-party scope may legitimately be empty (clean emulator images);
|
||||
// completeness is about unparsed lines, not row count.
|
||||
check(
|
||||
"third-party package listing reports complete coverage",
|
||||
packages.coverage === "complete",
|
||||
`${packages.packages.length} package(s)`,
|
||||
);
|
||||
const allPackages = await run("list_adb_packages", {
|
||||
serial,
|
||||
scope: "all",
|
||||
});
|
||||
if (allPackages !== null) {
|
||||
check(
|
||||
"full package inventory is non-empty",
|
||||
allPackages.coverage === "complete" &&
|
||||
allPackages.packages.length > 0,
|
||||
`${allPackages.packages.length} package(s)`,
|
||||
);
|
||||
}
|
||||
if (pullCandidate === null && packages.packages.length > 0)
|
||||
pullCandidate = packages.packages[0].package_name;
|
||||
}
|
||||
|
||||
if (pullCandidate !== null) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-adb-verify-"));
|
||||
try {
|
||||
const pull = await run("pull_adb_package", {
|
||||
serial,
|
||||
package: pullCandidate,
|
||||
output_directory: directory,
|
||||
});
|
||||
if (pull !== null) {
|
||||
// Installed /data/app packages name their APKs base.apk and
|
||||
// split_*.apk; system packages keep arbitrary names, so the lane
|
||||
// checks a non-empty artifact set and verifies each file on disk.
|
||||
check(
|
||||
"pull returned a non-empty artifact set",
|
||||
pull.artifacts.length > 0,
|
||||
`${pull.artifacts.length} artifact(s), coverage=${pull.coverage}, roles=${pull.artifacts.map((a) => a.role).join(",")}`,
|
||||
);
|
||||
for (const artifact of pull.artifacts) {
|
||||
const bytes = await stat(artifact.local_path).then(
|
||||
(info) => info.size,
|
||||
);
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(artifact.local_path))
|
||||
.digest("hex");
|
||||
check(
|
||||
`artifact digest matches on disk: ${artifact.file_name}`,
|
||||
bytes === artifact.bytes && digest === artifact.sha256,
|
||||
`${bytes} bytes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// File transfer: push one lane-owned file, verify both digests, pull it
|
||||
// back, then clean up the device copy through the same fixed argv surface.
|
||||
const transferRoot = await mkdtemp(join(tmpdir(), "rea-adb-transfer-"));
|
||||
if (pullCandidate !== null) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-adb-verify-"));
|
||||
try {
|
||||
const payload = join(transferRoot, "rea-verify.txt");
|
||||
const payloadBytes = "rea adb transfer verification\n";
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.writeFile(payload, payloadBytes),
|
||||
);
|
||||
const deviceTarget = `/data/local/tmp/rea-verify-${Date.now()}.txt`;
|
||||
const pushed = await run("push_adb_file", {
|
||||
const pull = await run("pull_adb_package", {
|
||||
serial,
|
||||
local_path: payload,
|
||||
device_path: deviceTarget,
|
||||
overwrite: false,
|
||||
package: pullCandidate,
|
||||
output_directory: directory,
|
||||
});
|
||||
if (pushed !== null) {
|
||||
if (pull !== null) {
|
||||
// Installed /data/app packages name their APKs base.apk and
|
||||
// split_*.apk; system packages keep arbitrary names, so the lane
|
||||
// checks a non-empty artifact set and verifies each file on disk.
|
||||
check(
|
||||
"push reports matching device-side digest",
|
||||
pushed.device_digest_source === "device_sha256sum" &&
|
||||
pushed.device_sha256 === pushed.sha256,
|
||||
`${pushed.bytes} bytes`,
|
||||
"pull returned a non-empty artifact set",
|
||||
pull.artifacts.length > 0,
|
||||
`${pull.artifacts.length} artifact(s), coverage=${pull.coverage}, roles=${pull.artifacts.map((a) => a.role).join(",")}`,
|
||||
);
|
||||
const pulled = await run("pull_adb_file", {
|
||||
serial,
|
||||
device_path: deviceTarget,
|
||||
output_directory: transferRoot,
|
||||
});
|
||||
if (pulled !== null)
|
||||
check(
|
||||
"pulled file digest matches the pushed digest",
|
||||
pulled.sha256 === pushed.sha256,
|
||||
`${pulled.bytes} bytes`,
|
||||
for (const artifact of pull.artifacts) {
|
||||
const bytes = await stat(artifact.local_path).then(
|
||||
(info) => info.size,
|
||||
);
|
||||
}
|
||||
const screenRoot = await mkdtemp(join(tmpdir(), "rea-adb-screen-"));
|
||||
try {
|
||||
const screen = await run("capture_adb_screen", {
|
||||
serial,
|
||||
output_directory: screenRoot,
|
||||
});
|
||||
if (screen !== null)
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(artifact.local_path))
|
||||
.digest("hex");
|
||||
check(
|
||||
"screen capture is a sized PNG",
|
||||
screen.width !== null && screen.height !== null && screen.bytes > 0,
|
||||
`${screen.width}x${screen.height}, ${screen.bytes} bytes`,
|
||||
`artifact digest matches on disk: ${artifact.file_name}`,
|
||||
bytes === artifact.bytes && digest === artifact.sha256,
|
||||
`${bytes} bytes`,
|
||||
);
|
||||
} finally {
|
||||
await rm(screenRoot, { recursive: true, force: true });
|
||||
}
|
||||
if (options.pull !== null) {
|
||||
const details = await run("inspect_adb_package", {
|
||||
serial,
|
||||
package: options.pull,
|
||||
});
|
||||
if (details !== null)
|
||||
check(
|
||||
"dumpsys package projection completed",
|
||||
details.coverage === "complete",
|
||||
`versionCode=${String(details.version_code)}`,
|
||||
);
|
||||
}
|
||||
if (options.installApk !== null && options.installApk !== undefined) {
|
||||
const probePackage = "com.rea.adb.probe";
|
||||
const installedProbe = await run("install_adb_package", {
|
||||
serial,
|
||||
apk_path: options.installApk,
|
||||
replace: false,
|
||||
});
|
||||
if (installedProbe !== null) {
|
||||
check(
|
||||
"install reports the APK digest",
|
||||
typeof installedProbe.sha256 === "string" &&
|
||||
installedProbe.sha256.length === 64,
|
||||
`${installedProbe.bytes} bytes`,
|
||||
);
|
||||
const resolved = await run("resolve_adb_packages", {
|
||||
serial,
|
||||
query: "rea.adb.probe",
|
||||
});
|
||||
if (resolved !== null)
|
||||
check(
|
||||
"resolve finds the installed probe uniquely",
|
||||
resolved.matches.length === 1 &&
|
||||
resolved.matches[0]?.package_name === probePackage,
|
||||
`${resolved.matches.length} match(es)`,
|
||||
);
|
||||
const started = await run("start_adb_app", {
|
||||
serial,
|
||||
package: probePackage,
|
||||
});
|
||||
if (started !== null)
|
||||
check(
|
||||
"app start resolves and launches the launcher activity",
|
||||
typeof started.component === "string" &&
|
||||
started.component.startsWith(probePackage),
|
||||
String(started.component),
|
||||
);
|
||||
const observedProcesses = await run("list_adb_processes", { serial });
|
||||
if (observedProcesses !== null)
|
||||
check(
|
||||
"started app appears in the process listing",
|
||||
observedProcesses.processes.some(
|
||||
(process) => process.name === probePackage,
|
||||
),
|
||||
);
|
||||
const stopped = await run("stop_adb_app", {
|
||||
serial,
|
||||
package: probePackage,
|
||||
});
|
||||
if (stopped !== null)
|
||||
check(
|
||||
"force-stop completes the dynamic-analysis run",
|
||||
stopped.package_name === probePackage,
|
||||
);
|
||||
const removed = await run("uninstall_adb_package", {
|
||||
serial,
|
||||
package: probePackage,
|
||||
});
|
||||
if (removed !== null)
|
||||
check(
|
||||
"uninstall completes the lifecycle",
|
||||
removed.package_name === probePackage,
|
||||
);
|
||||
const afterRemoval = await run("resolve_adb_packages", {
|
||||
serial,
|
||||
query: "rea.adb.probe",
|
||||
});
|
||||
if (afterRemoval !== null)
|
||||
check(
|
||||
"resolve reports zero matches after removal",
|
||||
afterRemoval.matches.length === 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(transferRoot, { recursive: true, force: true });
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
} catch (cause) {
|
||||
primaryFailure = cause;
|
||||
|
||||
// File transfer: push one lane-owned file, verify both digests, pull it
|
||||
// back, then clean up the device copy through the same fixed argv surface.
|
||||
const transferRoot = await mkdtemp(join(tmpdir(), "rea-adb-transfer-"));
|
||||
try {
|
||||
const payload = join(transferRoot, "rea-verify.txt");
|
||||
const payloadBytes = "rea adb transfer verification\n";
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.writeFile(payload, payloadBytes),
|
||||
);
|
||||
const deviceTarget = `/data/local/tmp/rea-verify-${Date.now()}.txt`;
|
||||
const pushed = await run("push_adb_file", {
|
||||
serial,
|
||||
local_path: payload,
|
||||
device_path: deviceTarget,
|
||||
overwrite: false,
|
||||
});
|
||||
if (pushed !== null) {
|
||||
check(
|
||||
"push reports matching device-side digest",
|
||||
pushed.device_digest_source === "device_sha256sum" &&
|
||||
pushed.device_sha256 === pushed.sha256,
|
||||
`${pushed.bytes} bytes`,
|
||||
);
|
||||
const pulled = await run("pull_adb_file", {
|
||||
serial,
|
||||
device_path: deviceTarget,
|
||||
output_directory: transferRoot,
|
||||
});
|
||||
if (pulled !== null)
|
||||
check(
|
||||
"pulled file digest matches the pushed digest",
|
||||
pulled.sha256 === pushed.sha256,
|
||||
`${pulled.bytes} bytes`,
|
||||
);
|
||||
}
|
||||
const screenRoot = await mkdtemp(join(tmpdir(), "rea-adb-screen-"));
|
||||
try {
|
||||
const screen = await run("capture_adb_screen", {
|
||||
serial,
|
||||
output_directory: screenRoot,
|
||||
});
|
||||
if (screen !== null)
|
||||
check(
|
||||
"screen capture is a sized PNG",
|
||||
screen.width !== null && screen.height !== null && screen.bytes > 0,
|
||||
`${screen.width}x${screen.height}, ${screen.bytes} bytes`,
|
||||
);
|
||||
} finally {
|
||||
await rm(screenRoot, { recursive: true, force: true });
|
||||
}
|
||||
if (options.pull !== null) {
|
||||
const details = await run("inspect_adb_package", {
|
||||
serial,
|
||||
package: options.pull,
|
||||
});
|
||||
if (details !== null)
|
||||
check(
|
||||
"dumpsys package projection completed",
|
||||
details.coverage === "complete",
|
||||
`versionCode=${String(details.version_code)}`,
|
||||
);
|
||||
}
|
||||
if (options.installApk !== null && options.installApk !== undefined) {
|
||||
const probePackage = "com.rea.adb.probe";
|
||||
const installedProbe = await run("install_adb_package", {
|
||||
serial,
|
||||
apk_path: options.installApk,
|
||||
replace: false,
|
||||
});
|
||||
if (installedProbe !== null) {
|
||||
check(
|
||||
"install reports the APK digest",
|
||||
typeof installedProbe.sha256 === "string" &&
|
||||
installedProbe.sha256.length === 64,
|
||||
`${installedProbe.bytes} bytes`,
|
||||
);
|
||||
const resolved = await run("resolve_adb_packages", {
|
||||
serial,
|
||||
query: "rea.adb.probe",
|
||||
});
|
||||
if (resolved !== null)
|
||||
check(
|
||||
"resolve finds the installed probe uniquely",
|
||||
resolved.matches.length === 1 &&
|
||||
resolved.matches[0]?.package_name === probePackage,
|
||||
`${resolved.matches.length} match(es)`,
|
||||
);
|
||||
const started = await run("start_adb_app", {
|
||||
serial,
|
||||
package: probePackage,
|
||||
});
|
||||
if (started !== null)
|
||||
check(
|
||||
"app start resolves and launches the launcher activity",
|
||||
typeof started.component === "string" &&
|
||||
started.component.startsWith(probePackage),
|
||||
String(started.component),
|
||||
);
|
||||
const observedProcesses = await run("list_adb_processes", { serial });
|
||||
if (observedProcesses !== null)
|
||||
check(
|
||||
"started app appears in the process listing",
|
||||
observedProcesses.processes.some(
|
||||
(process) => process.name === probePackage,
|
||||
),
|
||||
);
|
||||
const stopped = await run("stop_adb_app", {
|
||||
serial,
|
||||
package: probePackage,
|
||||
});
|
||||
if (stopped !== null)
|
||||
check(
|
||||
"force-stop completes the dynamic-analysis run",
|
||||
stopped.package_name === probePackage,
|
||||
);
|
||||
const removed = await run("uninstall_adb_package", {
|
||||
serial,
|
||||
package: probePackage,
|
||||
});
|
||||
if (removed !== null)
|
||||
check(
|
||||
"uninstall completes the lifecycle",
|
||||
removed.package_name === probePackage,
|
||||
);
|
||||
const afterRemoval = await run("resolve_adb_packages", {
|
||||
serial,
|
||||
query: "rea.adb.probe",
|
||||
});
|
||||
if (afterRemoval !== null)
|
||||
check(
|
||||
"resolve reports zero matches after removal",
|
||||
afterRemoval.matches.length === 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(transferRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
let cleanupFailure;
|
||||
try {
|
||||
await closeVerifierResources([() => provider.close()], primaryFailure);
|
||||
} catch (cause) {
|
||||
cleanupFailure = cause;
|
||||
await provider.close();
|
||||
} catch {
|
||||
// The provider owns nothing long-lived; closing is best-effort.
|
||||
}
|
||||
|
||||
if (primaryFailure !== undefined || cleanupFailure !== undefined) {
|
||||
const causes =
|
||||
cleanupFailure instanceof AggregateError
|
||||
? cleanupFailure.errors
|
||||
: [cleanupFailure ?? primaryFailure];
|
||||
process.stderr.write(
|
||||
`ADB device verification failed: ${causes.map((cause) => (cause instanceof Error ? cause.message : String(cause))).join("; ")}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else if (failures > 0) {
|
||||
if (failures > 0) {
|
||||
process.stdout.write(`\n${failures} check(s) failed\n`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
process.stdout.write("\nall adb device checks passed\n");
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write("\nall adb device checks passed\n");
|
||||
|
||||
function parseArguments(argv) {
|
||||
const parsed = { help: false, serial: null, pull: null, installApk: null };
|
||||
|
||||
+14
-41
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readdir, readFile, rmdir } from "node:fs/promises";
|
||||
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
@@ -8,9 +8,7 @@ import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { toolContract } from "../dist/contracts/toolContracts.js";
|
||||
import { hashStableFile } from "../dist/filesystem/StableFileHash.js";
|
||||
import { OwnedRegularFileCleanupFailure } from "../dist/filesystem/RegularFile.js";
|
||||
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
|
||||
import { closeVerifierResources } from "./lib/verifier-cleanup.mjs";
|
||||
|
||||
const options = parseArguments(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
@@ -41,17 +39,6 @@ Object.assign(environment, {
|
||||
TEMP: root,
|
||||
});
|
||||
const client = new Client({ name: "flutter-verifier", version: "1" });
|
||||
let primaryError;
|
||||
const hashOwners = new Map();
|
||||
const hashOwned = async (...args) => {
|
||||
try {
|
||||
return await hashStableFile(...args);
|
||||
} catch (cause) {
|
||||
if (cause instanceof OwnedRegularFileCleanupFailure)
|
||||
hashOwners.set(cause.owner, cause.path);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
@@ -62,7 +49,7 @@ const execute = promisify(execFile);
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const apk = resolve(options.apk);
|
||||
const identity = await hashOwned(apk);
|
||||
const identity = await hashStableFile(apk);
|
||||
const identified = await run("identify_flutter_build", { path: apk });
|
||||
assert.deepEqual(identified.target, { path: apk, ...identity });
|
||||
assert.equal(identified.flutter_detected, true);
|
||||
@@ -189,46 +176,32 @@ try {
|
||||
const negative = await run("identify_flutter_build", { path: plain });
|
||||
assert.deepEqual(negative.target, {
|
||||
path: plain,
|
||||
...(await hashOwned(plain)),
|
||||
...(await hashStableFile(plain)),
|
||||
});
|
||||
assert.equal(negative.flutter_detected, false);
|
||||
assert.equal(negative.coverage, "complete");
|
||||
}
|
||||
assert.deepEqual(
|
||||
await hashOwned(apk),
|
||||
await hashStableFile(apk),
|
||||
identity,
|
||||
"Verification must preserve its input",
|
||||
);
|
||||
} catch (cause) {
|
||||
primaryError = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
const cleanup = [];
|
||||
for (const [owner, path] of hashOwners) {
|
||||
cleanup.push(async () => {
|
||||
try {
|
||||
await owner.close();
|
||||
hashOwners.delete(owner);
|
||||
} catch (cause) {
|
||||
throw new Error(`Could not close hash file handle for ${path}`, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
cleanup.push(async () => {
|
||||
await closeVerifierResources([
|
||||
() => client.close(),
|
||||
() => transport.close(),
|
||||
]);
|
||||
// Server shutdown retries retained provider cleanup and must settle before success.
|
||||
try {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
assert.deepEqual(
|
||||
await readdir(root),
|
||||
[],
|
||||
`Provider snapshots remain under ${root}`,
|
||||
);
|
||||
await rmdir(root);
|
||||
});
|
||||
await closeVerifierResources(cleanup, primaryError);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
process.stdout.write(
|
||||
"all asserted Flutter CLI/MCP checks passed; unverified facets are reported above\n",
|
||||
|
||||
@@ -265,27 +265,6 @@ try {
|
||||
assert.ok(pcapAjv.validateSchema(pcapTool.inputSchema));
|
||||
assert.ok(pcapAjv.validateSchema(pcapTool.outputSchema));
|
||||
for (const capturePath of [path, bluetooth]) {
|
||||
const capinfosOutput = (
|
||||
await execute(
|
||||
process.env.REA_CAPINFOS_COMMAND ?? "capinfos",
|
||||
["-M", "--", capturePath],
|
||||
{
|
||||
env: { ...environment, LC_ALL: "C", LANG: "C" },
|
||||
timeout: 60000,
|
||||
maxBuffer: 64 * 1024,
|
||||
},
|
||||
)
|
||||
).stdout;
|
||||
const expectedCaptureStart =
|
||||
/^(?:Earliest|First) packet time:[ \t]*(.+)$/mu
|
||||
.exec(capinfosOutput)?.[1]
|
||||
?.trim() ?? null;
|
||||
const expectedCaptureEnd =
|
||||
/^(?:Latest|Last) packet time:[ \t]*(.+)$/mu
|
||||
.exec(capinfosOutput)?.[1]
|
||||
?.trim() ?? null;
|
||||
assert.ok(expectedCaptureStart, capinfosOutput);
|
||||
assert.ok(expectedCaptureEnd, capinfosOutput);
|
||||
const response = await client.callTool({
|
||||
name: pcapTool.name,
|
||||
arguments: { path: capturePath, detail: "summary" },
|
||||
@@ -296,27 +275,12 @@ try {
|
||||
pcapAjv.errorsText(),
|
||||
);
|
||||
const summary = parseEvidence(response.structuredContent).normalized_result;
|
||||
assert.equal(summary.summary.capture_start, expectedCaptureStart);
|
||||
assert.equal(summary.summary.capture_end, expectedCaptureEnd);
|
||||
assert.equal(
|
||||
summary.summary.target.sha256,
|
||||
createHash("sha256")
|
||||
.update(await readFile(capturePath))
|
||||
.digest("hex"),
|
||||
);
|
||||
const cli = parseEvidence(
|
||||
JSON.parse(
|
||||
(
|
||||
await execute(
|
||||
process.execPath,
|
||||
[entrypoint, "inspect-pcap-capture", capturePath, "--json"],
|
||||
{ env: environment, timeout: 60000, maxBuffer: 16 * 1024 * 1024 },
|
||||
)
|
||||
).stdout,
|
||||
),
|
||||
);
|
||||
assert.equal(cli.normalized_result.capture_start, expectedCaptureStart);
|
||||
assert.equal(cli.normalized_result.capture_end, expectedCaptureEnd);
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: summary.parent_evidence_id,
|
||||
|
||||
+12
-43
@@ -8,9 +8,7 @@ import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { toolContract } from "../dist/contracts/toolContracts.js";
|
||||
import { hashStableFile } from "../dist/filesystem/StableFileHash.js";
|
||||
import { OwnedRegularFileCleanupFailure } from "../dist/filesystem/RegularFile.js";
|
||||
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
|
||||
import { closeVerifierResources } from "./lib/verifier-cleanup.mjs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
@@ -36,23 +34,12 @@ Object.assign(environment, {
|
||||
const entrypoint = resolve("scripts/rea.mjs");
|
||||
const execute = promisify(execFile);
|
||||
const client = new Client({ name: "pcap-real-verifier", version: "1" });
|
||||
let primaryError;
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const hashOwners = new Map();
|
||||
const hashOwned = async (...args) => {
|
||||
try {
|
||||
return await hashStableFile(...args);
|
||||
} catch (cause) {
|
||||
if (cause instanceof OwnedRegularFileCleanupFailure)
|
||||
hashOwners.set(cause.owner, cause.path);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
transport.stderr?.resume();
|
||||
try {
|
||||
await client.connect(transport);
|
||||
@@ -61,7 +48,7 @@ try {
|
||||
if (path === null)
|
||||
console.log("[unverified] Capture inspection: no --pcap was supplied");
|
||||
else {
|
||||
const identity = await hashOwned(path);
|
||||
const identity = await hashStableFile(path);
|
||||
const result = await run("inspect_pcap_capture", { path });
|
||||
assert.deepEqual(result.target, { path, ...identity });
|
||||
assert(result.packet_count >= 0);
|
||||
@@ -88,7 +75,7 @@ try {
|
||||
);
|
||||
assert.equal(result.coverage, partial ? "partial" : "complete");
|
||||
assert.deepEqual(
|
||||
await hashOwned(path),
|
||||
await hashStableFile(path),
|
||||
identity,
|
||||
"Input capture must remain unchanged",
|
||||
);
|
||||
@@ -96,36 +83,18 @@ try {
|
||||
`[ok] ${result.packet_count} packets; ${result.endpoint_count} addresses; coverage=${result.coverage}`,
|
||||
);
|
||||
}
|
||||
} catch (cause) {
|
||||
primaryError = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
const cleanup = [];
|
||||
for (const [owner, path] of hashOwners) {
|
||||
cleanup.push(async () => {
|
||||
try {
|
||||
await owner.close();
|
||||
hashOwners.delete(owner);
|
||||
} catch (cause) {
|
||||
throw new Error(`Could not close hash file handle for ${path}`, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
});
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
cleanup.push(async () => {
|
||||
await closeVerifierResources([
|
||||
() => client.close(),
|
||||
() => transport.close(),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
await readdir(root),
|
||||
[],
|
||||
`Owned capture resources remain under ${root}`,
|
||||
);
|
||||
await rmdir(root);
|
||||
});
|
||||
await closeVerifierResources(cleanup, primaryError);
|
||||
assert.deepEqual(
|
||||
await readdir(root),
|
||||
[],
|
||||
`Owned capture resources remain under ${root}`,
|
||||
);
|
||||
await rmdir(root);
|
||||
}
|
||||
console.log("All asserted pcap CLI/MCP checks passed");
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ def markdown_ids(path):
|
||||
|
||||
def check_repository_links(root):
|
||||
"""Check local links in website Markdown and current-repository source links."""
|
||||
root = root.resolve()
|
||||
errors = []
|
||||
sources = {}
|
||||
for path in authored_markdown(root):
|
||||
@@ -319,7 +318,6 @@ def check_search_metadata(site, pages):
|
||||
|
||||
def check_site(site):
|
||||
"""Check assets, HTML fragment destinations and SVG syntax."""
|
||||
site = site.resolve()
|
||||
pages = {}
|
||||
errors = []
|
||||
references = 0
|
||||
|
||||
@@ -17,10 +17,6 @@ import {
|
||||
runOwnedCommand,
|
||||
} from "../../../dist/process/OwnedCommand.js";
|
||||
import { closeVerifierResources } from "../../lib/verifier-cleanup.mjs";
|
||||
import {
|
||||
matchesStructuredScopeExpectation,
|
||||
structuredScopeExpectationsSchema,
|
||||
} from "../../lib/javascript-file-scope-oracle.mjs";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
@@ -28,11 +24,6 @@ const { values } = parseArgs({
|
||||
"addon-path": { type: "string" },
|
||||
"native-sha256": { type: "string" },
|
||||
"source-path": { type: "string", multiple: true },
|
||||
"structured-path": { type: "string", multiple: true },
|
||||
"structured-expectations": { type: "string" },
|
||||
"validate-structured-from-export": { type: "boolean", default: false },
|
||||
"analysis-timeout-ms": { type: "string" },
|
||||
"expect-timeout": { type: "boolean", default: false },
|
||||
rounds: { type: "string", default: "3" },
|
||||
"cli-parity": { type: "boolean", default: false },
|
||||
"tokenizer-python": { type: "string" },
|
||||
@@ -59,30 +50,6 @@ assert.match(
|
||||
);
|
||||
const rounds = Number(values.rounds);
|
||||
assert.ok(Number.isSafeInteger(rounds) && rounds > 0);
|
||||
const analysisTimeoutMs =
|
||||
values["analysis-timeout-ms"] === undefined
|
||||
? undefined
|
||||
: Number(values["analysis-timeout-ms"]);
|
||||
assert.ok(
|
||||
analysisTimeoutMs === undefined ||
|
||||
(Number.isSafeInteger(analysisTimeoutMs) && analysisTimeoutMs > 0),
|
||||
);
|
||||
assert.ok(!values["expect-timeout"] || analysisTimeoutMs !== undefined);
|
||||
assert.ok(!values["validate-structured-from-export"] || values["cli-parity"]);
|
||||
const structuredExpectations = structuredScopeExpectationsSchema.parse(
|
||||
JSON.parse(values["structured-expectations"] ?? "{}"),
|
||||
);
|
||||
for (const path of Object.keys(structuredExpectations))
|
||||
assert.ok(
|
||||
values["structured-path"]?.includes(path),
|
||||
`Expected structured path was not selected for verification: ${path}`,
|
||||
);
|
||||
if (values["expect-timeout"])
|
||||
for (const path of values["structured-path"] ?? [])
|
||||
assert.ok(
|
||||
Object.hasOwn(structuredExpectations, path),
|
||||
`--structured-expectations must name the expected operation/status for ${path}`,
|
||||
);
|
||||
const environment = {
|
||||
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
||||
REA_LOG_LEVEL: "silent",
|
||||
@@ -172,55 +139,30 @@ try {
|
||||
),
|
||||
);
|
||||
assert.equal(selected.length, 2);
|
||||
const call = async (name, arguments_, allowTimeout = false) => {
|
||||
const call = async (name, arguments_) => {
|
||||
const response = await client.callTool(
|
||||
{ name, arguments: arguments_ },
|
||||
{ timeout: 360_000 },
|
||||
);
|
||||
let projection = response.structuredContent;
|
||||
if (response.isError === true) {
|
||||
assert.ok(
|
||||
allowTimeout,
|
||||
serialize({ name, arguments: arguments_, response }),
|
||||
);
|
||||
const text = response.content.find(({ type }) => type === "text");
|
||||
assert.ok(text?.type === "text");
|
||||
projection = JSON.parse(text.text);
|
||||
assert.equal(projection.error.code, "provider_timeout");
|
||||
assert.equal(
|
||||
projection.error.details.partial_observation.kind,
|
||||
"retained-evidence",
|
||||
);
|
||||
} else assert.ok(projection?.evidence_id);
|
||||
assert.notEqual(response.isError, true, serialize(response));
|
||||
assert.ok(response.structuredContent?.evidence_id);
|
||||
return {
|
||||
request: { name, arguments: arguments_ },
|
||||
response: projection,
|
||||
response: response.structuredContent,
|
||||
wire_response: response,
|
||||
};
|
||||
};
|
||||
const prepStart = performance.now();
|
||||
const preparation = await call(
|
||||
"analyze_javascript_application",
|
||||
{
|
||||
input_path: values.artifact,
|
||||
detail: "summary",
|
||||
...(analysisTimeoutMs === undefined
|
||||
? {}
|
||||
: { analysis_timeout_ms: analysisTimeoutMs }),
|
||||
...(values["source-path"] === undefined
|
||||
? {}
|
||||
: { source_paths: values["source-path"] }),
|
||||
},
|
||||
values["expect-timeout"],
|
||||
);
|
||||
const preparation = await call("analyze_javascript_application", {
|
||||
input_path: values.artifact,
|
||||
detail: "summary",
|
||||
...(values["source-path"] === undefined
|
||||
? {}
|
||||
: { source_paths: values["source-path"] }),
|
||||
});
|
||||
const preparationMs = performance.now() - prepStart;
|
||||
const timedOut = preparation.wire_response.isError === true;
|
||||
assert.equal(timedOut, values["expect-timeout"]);
|
||||
if (!timedOut)
|
||||
assert.equal(preparation.response.subject.digest.sha256, archiveSha256);
|
||||
const sourceId = timedOut
|
||||
? preparation.response.error.details.partial_observation.evidence_id
|
||||
: preparation.response.normalized_result.parent_evidence_id;
|
||||
assert.equal(preparation.response.subject.digest.sha256, archiveSha256);
|
||||
const sourceId = preparation.response.normalized_result.parent_evidence_id;
|
||||
assert.match(sourceId, /^ev_[a-f0-9]{64}$/);
|
||||
const query = {
|
||||
application: { kind: "retained-evidence", evidence_id: sourceId },
|
||||
@@ -279,38 +221,6 @@ try {
|
||||
trace.native_handoffs[0].status === "evidence-linked" &&
|
||||
serialize(trace.native_handoffs[0].evidence_ids) ===
|
||||
serialize([native.evidence_id]));
|
||||
const analysisScopes = (result, path) =>
|
||||
result.graph.nodes.flatMap((node) =>
|
||||
node.kind !== "unknown"
|
||||
? []
|
||||
: node.observations.flatMap((observation) =>
|
||||
observation.properties.path === path &&
|
||||
observation.properties.analysis_failure !== undefined
|
||||
? [{ node_id: node.node_id, ...observation }]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const nativeScopes = analysisScopes(trace, values["addon-path"]);
|
||||
const structuredQueries = [];
|
||||
const structuredScopes = [];
|
||||
for (const path of values["validate-structured-from-export"]
|
||||
? []
|
||||
: (values["structured-path"] ?? [])) {
|
||||
const result = await call("trace_application_feature", {
|
||||
...query,
|
||||
native_observations: [],
|
||||
seed: { ...query.seed, value: path },
|
||||
});
|
||||
structuredQueries.push(result);
|
||||
structuredScopes.push({
|
||||
source: "retained-feature-trace",
|
||||
path,
|
||||
scopes: analysisScopes(result.response.normalized_result, path),
|
||||
evidence_id: result.response.evidence_id,
|
||||
source_coverage: result.response.normalized_result.coverage,
|
||||
graph_coverage: result.response.normalized_result.graph.coverage,
|
||||
});
|
||||
}
|
||||
let cliParity = null;
|
||||
if (values["cli-parity"]) {
|
||||
temporary = await PrivateRuntimeRoot.create({
|
||||
@@ -327,25 +237,6 @@ try {
|
||||
({ evidence_id }) => evidence_id === sourceId,
|
||||
);
|
||||
assert.ok(parent, "Export omitted the retained parent");
|
||||
assert.equal(parent.normalized_result.root_artifact_sha256, archiveSha256);
|
||||
if (values["validate-structured-from-export"])
|
||||
for (const path of values["structured-path"] ?? []) {
|
||||
const retained = parent.normalized_result;
|
||||
const matchingNodes = retained.graph.nodes.filter((node) =>
|
||||
node.observations.some(({ properties }) => properties.path === path),
|
||||
);
|
||||
assert.ok(matchingNodes.length > 0, `Missing retained path: ${path}`);
|
||||
// Diagnostic validation of the complete backend record is distinct
|
||||
// from the inline native analyst task used by this control's tokens.
|
||||
structuredScopes.push({
|
||||
source: "exported-parent-validation",
|
||||
path,
|
||||
scopes: analysisScopes(retained, path),
|
||||
source_evidence_id: parent.evidence_id,
|
||||
retained_matching_nodes: matchingNodes.length,
|
||||
graph_coverage: retained.graph.coverage,
|
||||
});
|
||||
}
|
||||
const request = join(temporary.path, "trace.json");
|
||||
// The CLI's portable file contract takes complete Evidence; MCP resolves a
|
||||
// same-session reference into the same shared application workflow.
|
||||
@@ -368,16 +259,6 @@ try {
|
||||
evidence_id: evidence.evidence_id,
|
||||
};
|
||||
}
|
||||
const safeApplicability =
|
||||
nativeScopes.length === 0 &&
|
||||
structuredScopes.every(({ path, scopes }) =>
|
||||
timedOut
|
||||
? matchesStructuredScopeExpectation(
|
||||
scopes,
|
||||
structuredExpectations[path],
|
||||
)
|
||||
: scopes.length === 0,
|
||||
);
|
||||
let tokenCosts = null;
|
||||
if (values["tokenizer-python"]) {
|
||||
temporary ??= await PrivateRuntimeRoot.create({
|
||||
@@ -386,12 +267,7 @@ try {
|
||||
const input = join(temporary.path, "token-input.json");
|
||||
await writeFile(
|
||||
input,
|
||||
serialize({
|
||||
catalog,
|
||||
selected,
|
||||
preparation,
|
||||
queries: [first, ...structuredQueries],
|
||||
}),
|
||||
serialize({ catalog, selected, preparation, query: first }),
|
||||
);
|
||||
const result = await ownedCommand(values["tokenizer-python"], [
|
||||
"-c",
|
||||
@@ -399,9 +275,9 @@ try {
|
||||
with open(sys.argv[1],encoding='utf-8') as f:d=json.load(f)
|
||||
enc=tiktoken.get_encoding('cl100k_base')
|
||||
def n(v):return len(enc.encode(json.dumps(v,ensure_ascii=False,separators=(',',':')),disallowed_special=()))
|
||||
requests=n(d['preparation']['request'])+sum(n(q['request']) for q in d['queries'])
|
||||
structured=n(d['preparation']['response'])+sum(n(q['response']) for q in d['queries'])
|
||||
full_reply=n(d['preparation']['wire_response'])+sum(n(q['wire_response']) for q in d['queries'])
|
||||
requests=n(d['preparation']['request'])+n(d['query']['request'])
|
||||
structured=n(d['preparation']['response'])+n(d['query']['response'])
|
||||
full_reply=n(d['preparation']['wire_response'])+n(d['query']['wire_response'])
|
||||
s=n(d['selected']);a=n(d['catalog'])
|
||||
print(json.dumps(dict(tokenizer='cl100k_base',tiktoken_version=tiktoken.__version__,selected_catalog=s,full_catalog=a,requests=requests,structured_replies=structured,full_tool_replies=full_reply,selected_catalog_structured_task=s+requests+structured,full_catalog_structured_task=a+requests+structured,selected_catalog_full_reply_task=s+requests+full_reply,full_catalog_full_reply_task=a+requests+full_reply)))`,
|
||||
input,
|
||||
@@ -416,19 +292,11 @@ print(json.dumps(dict(tokenizer='cl100k_base',tiktoken_version=tiktoken.__versio
|
||||
companion_sha256_observed: companionSha256,
|
||||
prior_native_sha256_supplied: values["native-sha256"],
|
||||
source_paths: values["source-path"] ?? null,
|
||||
preparation_outcome: timedOut ? "provider_timeout" : "completed",
|
||||
analysis_timeout_ms: analysisTimeoutMs ?? null,
|
||||
parent_evidence_id: sourceId,
|
||||
trace_evidence_id: first.response.evidence_id,
|
||||
trace_subject: first.response.subject,
|
||||
preparation_ms: preparationMs,
|
||||
samples,
|
||||
safe_identity: safeIdentity,
|
||||
safe_applicability: safeApplicability,
|
||||
native_analysis_scopes: nativeScopes,
|
||||
structured_scopes: structuredScopes,
|
||||
structured_expectations: structuredExpectations,
|
||||
modeled_structured_trace_count: structuredQueries.length,
|
||||
node: {
|
||||
node_id: addon.node_id,
|
||||
identity: addon.identity,
|
||||
@@ -454,12 +322,10 @@ print(json.dumps(dict(tokenizer='cl100k_base',tiktoken_version=tiktoken.__versio
|
||||
cli_parity: cliParity,
|
||||
token_costs: tokenCosts,
|
||||
conditions:
|
||||
"Fresh MCP session, selected analysis followed by retained traces. Task token counts include one analysis summary or parsed timeout error, one native trace and each requested structured-path trace, with name/description/inputSchema catalog projections. Structured Evidence/parsed error and complete-tool-reply projections are alternatives, not billing. Repeated native rounds, export, CLI parity and tokenizer are validation overhead outside task tokens but inside total resource receipts. Filesystem/build caches uncontrolled. Incorrect identity/applicability is not a successful equivalent task or efficiency credit.",
|
||||
"Fresh MCP session, one selected analysis followed by repeated identical retained traces. Filesystem/build caches uncontrolled. Task token counts include one analysis summary and one trace with name/description/inputSchema catalog projections; structured and complete-tool-reply projections are alternatives, not billing. Repeated rounds, export, CLI parity and tokenizer are validation overhead outside that one-task token count but inside total resource receipts. A wrong-identity baseline is not a successful equivalent task or efficiency credit.",
|
||||
};
|
||||
if (values["require-safe"]) {
|
||||
if (values["require-safe"])
|
||||
assert.ok(safeIdentity, "Native identity/linkage oracle failed");
|
||||
assert.ok(safeApplicability, "File analysis applicability oracle failed");
|
||||
}
|
||||
} catch (cause) {
|
||||
primaryError = cause;
|
||||
throw cause;
|
||||
|
||||
@@ -880,42 +880,6 @@ exit 1`,
|
||||
});
|
||||
});
|
||||
|
||||
it("binds a push digest to the bytes adb consumes after caller-path replacement", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
(stubRoot) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 1; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "push" ]; then
|
||||
printf 'replacement-payload' > "${stubRoot}/payload.bin"
|
||||
cat "$4" > "${stubRoot}/consumed.bin" || exit 1
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "-s" ] && [ "$4" = "sha256sum" ]; then exit 1; fi
|
||||
exit 1`,
|
||||
);
|
||||
const localFile = join(root, "payload.bin");
|
||||
await writeFile(localFile, "original-payload");
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "push_adb_file",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
local_path: localFile,
|
||||
device_path: "/data/local/tmp/payload.bin",
|
||||
overwrite: false,
|
||||
},
|
||||
});
|
||||
const consumedDigest = createHash("sha256")
|
||||
.update(await readFile(join(root, "consumed.bin")))
|
||||
.digest("hex");
|
||||
expect(result).toMatchObject({
|
||||
local_path: localFile,
|
||||
sha256: consumedDigest,
|
||||
});
|
||||
expect(await readFile(localFile, "utf8")).toBe("replacement-payload");
|
||||
expect(await readFile(join(root, "consumed.bin"), "utf8")).toBe(
|
||||
"original-payload",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a digest mismatch after push as a changed artifact", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
@@ -1315,33 +1279,6 @@ exit 1`,
|
||||
expect(projected.message).toContain("INSTALL_FAILED_ALREADY_EXISTS");
|
||||
});
|
||||
|
||||
it("binds an install digest to the bytes adb consumes after caller-path replacement", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
(stubRoot) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then
|
||||
printf 'replacement-apk' > "${stubRoot}/app.apk"
|
||||
cat "$4" > "${stubRoot}/consumed.bin" || exit 1
|
||||
printf 'Success\\n'
|
||||
exit 0
|
||||
fi
|
||||
exit 1`,
|
||||
);
|
||||
const apk = join(root, "app.apk");
|
||||
await writeFile(apk, "original-apk");
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "install_adb_package",
|
||||
input: { serial: "emulator-5554", apk_path: apk, replace: false },
|
||||
});
|
||||
const consumedDigest = createHash("sha256")
|
||||
.update(await readFile(join(root, "consumed.bin")))
|
||||
.digest("hex");
|
||||
expect(result).toMatchObject({ apk_path: apk, sha256: consumedDigest });
|
||||
expect(await readFile(apk, "utf8")).toBe("replacement-apk");
|
||||
expect(await readFile(join(root, "consumed.bin"), "utf8")).toBe(
|
||||
"original-apk",
|
||||
);
|
||||
});
|
||||
|
||||
it("uninstalls a package and reports the device's refusal for absent ones", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
|
||||
+269
-568
File diff suppressed because it is too large.
Load diff
+32
-93
@@ -14,7 +14,6 @@ import {
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
@@ -27,15 +26,8 @@ import {
|
||||
snapshotAndroidEngine,
|
||||
snapshotAndroidTarget,
|
||||
} from "./AndroidTargetSnapshot.js";
|
||||
import {
|
||||
hashStableFile,
|
||||
StableFileHashCleanupFailure,
|
||||
} from "../filesystem/StableFileHash.js";
|
||||
import {
|
||||
OwnedRegularFileCleanupFailure,
|
||||
RegularFileChangedError,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
|
||||
import {
|
||||
inspectJadxAvailability,
|
||||
resolveJadxConfigurationInputs,
|
||||
@@ -74,38 +66,8 @@ const executionError = (context: {
|
||||
session: JadxSession | undefined;
|
||||
}): AnalysisError => {
|
||||
const { cause, request, timeout, signal, session } = context;
|
||||
if (cause instanceof OwnedRegularFileCleanupFailure) {
|
||||
const primary =
|
||||
signal?.aborted === true
|
||||
? new AnalysisCancelledError(request.operation, { cause: cause.cause })
|
||||
: timeout.aborted
|
||||
? new AnalysisTimeoutError(request.operation, OPERATION_TIMEOUT_MS)
|
||||
: new ProviderAdapterError("jadx", request.operation, {
|
||||
cause: cause.cause,
|
||||
userMessage:
|
||||
cause.cause instanceof Error
|
||||
? cause.cause.message
|
||||
: String(cause.cause),
|
||||
});
|
||||
const cleanup = new ProviderCleanupError(
|
||||
"jadx",
|
||||
[cause.path],
|
||||
{
|
||||
reason:
|
||||
cause.cleanupCause instanceof Error
|
||||
? cause.cleanupCause.message
|
||||
: String(cause.cleanupCause),
|
||||
...(cause instanceof StableFileHashCleanupFailure &&
|
||||
cause.completedHash !== undefined
|
||||
? { completed_hash: cause.completedHash }
|
||||
: {}),
|
||||
},
|
||||
{ operation: request.operation, cause: cause.cleanupCause },
|
||||
);
|
||||
return analysisErrorWithCleanupFailure(primary, cleanup, request.operation);
|
||||
}
|
||||
if (signal?.aborted === true)
|
||||
return new AnalysisCancelledError(request.operation, { cause });
|
||||
return new AnalysisCancelledError(request.operation);
|
||||
if (timeout.aborted)
|
||||
return new AnalysisTimeoutError(request.operation, OPERATION_TIMEOUT_MS);
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
@@ -194,73 +156,57 @@ const hashAndroidSource = async (
|
||||
interface PendingCleanup {
|
||||
session: JadxSession | undefined;
|
||||
root: JadxWorkspaceRoot | undefined;
|
||||
readonly fileHandles: Map<
|
||||
OwnedFileHandle,
|
||||
{ readonly path: string; readonly error: AnalysisError }
|
||||
>;
|
||||
readonly previousError: AnalysisError | undefined;
|
||||
}
|
||||
|
||||
const cleanup = async (
|
||||
resources: PendingCleanup,
|
||||
): Promise<Result<void, AnalysisError>> => {
|
||||
const { session, root, previousError, fileHandles } = resources;
|
||||
const { session, root, previousError } = resources;
|
||||
const diagnostics = {
|
||||
previous_error:
|
||||
previousError === undefined ? null : projectAnalysisError(previousError),
|
||||
};
|
||||
const failures: { readonly resource: string; readonly cause: unknown }[] = [];
|
||||
for (const [owner, { path, error }] of fileHandles) {
|
||||
try {
|
||||
await owner.close();
|
||||
error.resolveCleanup();
|
||||
fileHandles.delete(owner);
|
||||
} catch (cause: unknown) {
|
||||
failures.push({ resource: path, cause });
|
||||
}
|
||||
}
|
||||
try {
|
||||
await session?.close();
|
||||
resources.session = undefined;
|
||||
} catch (cause) {
|
||||
failures.push({ resource: session?.transport.runId ?? "unknown", cause });
|
||||
}
|
||||
if (resources.session === undefined && failures.length === 0) {
|
||||
try {
|
||||
await root?.close();
|
||||
resources.root = undefined;
|
||||
} catch (cause: unknown) {
|
||||
failures.push({ resource: root?.path ?? "unknown", cause });
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
const cleanupCause = new AggregateError(
|
||||
failures.map(({ cause }) => cause),
|
||||
"JADX file, session, or workspace cleanup failed",
|
||||
);
|
||||
// Retain an uncertain workspace until the caller can resolve process ownership.
|
||||
const error = new ProviderCleanupError(
|
||||
"jadx",
|
||||
failures.map(({ resource }) => resource),
|
||||
[
|
||||
session?.transport.runId ?? "unknown",
|
||||
...(root === undefined ? [] : [root.path]),
|
||||
],
|
||||
{
|
||||
...diagnostics,
|
||||
reason: failures
|
||||
.map(({ cause }) =>
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
)
|
||||
.join("; "),
|
||||
provider_cleanup: failures.map(({ cause }) =>
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
provider_cleanup:
|
||||
cause instanceof ProviderCleanupError
|
||||
? (cause.diagnostics ?? null)
|
||||
: null,
|
||||
),
|
||||
},
|
||||
{
|
||||
cause: previousError ?? cleanupCause,
|
||||
...(previousError?.partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: previousError.partialObservation }),
|
||||
},
|
||||
{ cause: previousError ?? cause },
|
||||
);
|
||||
if (previousError?.partialObservation !== undefined)
|
||||
error.retainPartialObservation(previousError.partialObservation);
|
||||
return err(error);
|
||||
}
|
||||
try {
|
||||
await root?.close();
|
||||
resources.root = undefined;
|
||||
} catch (cause) {
|
||||
const error = new ProviderCleanupError(
|
||||
"jadx",
|
||||
[root?.path ?? "unknown"],
|
||||
{
|
||||
...diagnostics,
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
{ cause: previousError ?? cause },
|
||||
);
|
||||
if (previousError?.partialObservation !== undefined)
|
||||
error.retainPartialObservation(previousError.partialObservation);
|
||||
return err(error);
|
||||
}
|
||||
return ok(undefined);
|
||||
@@ -353,7 +299,6 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
this.#pendingCleanup = {
|
||||
session: this.#retained.session,
|
||||
root: this.#retained.root,
|
||||
fileHandles: new Map(),
|
||||
previousError: undefined,
|
||||
};
|
||||
this.#retained = undefined;
|
||||
@@ -533,12 +478,6 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
signal: options?.signal,
|
||||
session,
|
||||
});
|
||||
const fileHandles: PendingCleanup["fileHandles"] = new Map();
|
||||
if (cause instanceof OwnedRegularFileCleanupFailure)
|
||||
fileHandles.set(cause.owner, {
|
||||
path: cause.path,
|
||||
error: previousError,
|
||||
});
|
||||
const partialObservation = session?.partialObservation();
|
||||
if (partialObservation !== undefined)
|
||||
previousError.retainPartialObservation(partialObservation);
|
||||
@@ -546,7 +485,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
root ??= this.#retained?.root;
|
||||
session ??= this.#retained?.session;
|
||||
this.#retained = undefined;
|
||||
this.#pendingCleanup = { session, root, fileHandles, previousError };
|
||||
this.#pendingCleanup = { session, root, previousError };
|
||||
const retired = await this.#retire();
|
||||
return retired.ok ? err(previousError) : retired;
|
||||
}
|
||||
|
||||
@@ -290,8 +290,4 @@ export interface AnalysisProviderCandidate extends AnalysisProvider {
|
||||
inspectAvailability(): ProviderAvailability;
|
||||
inspectReadinessSnapshot?(): AnalysisProviderReadinessSnapshot;
|
||||
inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport;
|
||||
/** Report retained candidate-owned cleanup without starting work or retrying it. */
|
||||
hasPendingOwnedResources?(): boolean;
|
||||
/** Release candidate-owned resources that may exist without a client. */
|
||||
closeOwnedResources?(): Promise<Result<null, AnalysisError>>;
|
||||
}
|
||||
@@ -35,8 +35,6 @@ import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
|
||||
/** Provider-neutral application boundary for passive browser observation. */
|
||||
export interface BrowserObservationPort {
|
||||
/** Release provider-owned transports during command or server shutdown. */
|
||||
close?(): Promise<void>;
|
||||
identity(): ProviderIdentity;
|
||||
listTargets(
|
||||
input: ListBrowserTargetsInput,
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
import type { BrowserObservationPort } from "./BrowserObservationPort.js";
|
||||
import { listBrowserTargets } from "./BrowserObservationService.js";
|
||||
import type { BrowserTargetList } from "../domain/browserObservationSchemas.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { passiveNetworkCapture } from "../../tests/fixtures/passiveNetworkCapture.js";
|
||||
import { inspectWebPage } from "./BrowserObservationService.js";
|
||||
import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js";
|
||||
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
|
||||
import { analyzeWebBundle } from "./BrowserObservationService.js";
|
||||
|
||||
const input = {
|
||||
cdp_endpoint: {
|
||||
transport: "http_discovery" as const,
|
||||
url: "http://127.0.0.1:9222",
|
||||
recorded: "http://127.0.0.1:9222",
|
||||
},
|
||||
allowed_origins: [],
|
||||
};
|
||||
|
||||
const report: BrowserTargetList = {
|
||||
browser: {
|
||||
product: "Chrome",
|
||||
protocol_version: "1.3",
|
||||
revision: "1",
|
||||
user_agent: "Chrome fixture",
|
||||
js_version: "V8 fixture",
|
||||
},
|
||||
targets: [],
|
||||
excluded: { disallowed_origin: 0, unsupported_url: 0, non_page: 0 },
|
||||
limitations: [],
|
||||
};
|
||||
|
||||
const provider = (
|
||||
listTargets: BrowserObservationPort["listTargets"],
|
||||
): BrowserObservationPort =>
|
||||
({
|
||||
identity: () => ({ id: "browser-fixture", name: "Fixture", version: "1" }),
|
||||
listTargets,
|
||||
}) as unknown as BrowserObservationPort;
|
||||
|
||||
it("retains completed browser inventory Evidence when the provider completes after abort", async () => {
|
||||
const controller = new AbortController();
|
||||
const result = await listBrowserTargets(
|
||||
provider(async () => {
|
||||
controller.abort();
|
||||
return ok(report);
|
||||
}),
|
||||
input,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "list_browser_targets",
|
||||
normalized_result: report,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let late cancellation hide invalid completed browser output", async () => {
|
||||
const malformed = {
|
||||
...report,
|
||||
limitations: [() => undefined],
|
||||
} as unknown as BrowserTargetList;
|
||||
const output = await listBrowserTargets(
|
||||
provider(async () => ok(malformed)),
|
||||
input,
|
||||
);
|
||||
expect(output).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
if (!output.ok) expect(output.error).toBeInstanceOf(AnalysisOutputError);
|
||||
|
||||
const controller = new AbortController();
|
||||
const late = await listBrowserTargets(
|
||||
provider(async () => {
|
||||
controller.abort();
|
||||
return ok(malformed);
|
||||
}),
|
||||
input,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(late).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
if (!late.ok) expect(late.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
|
||||
it("converts a raw page inspection retained after browser cleanup failure", async () => {
|
||||
const inspection = passiveNetworkCapture();
|
||||
const failure = new BrowserObservationError(
|
||||
"inspect_web_page",
|
||||
"cleanup_failed",
|
||||
{
|
||||
cleanup: {
|
||||
reason: "target detach failed",
|
||||
resources: ["browser target"],
|
||||
},
|
||||
partialObservation: inspection,
|
||||
},
|
||||
);
|
||||
const browser: BrowserObservationPort = {
|
||||
identity: () => ({ id: "browser-fixture", name: "Fixture", version: "1" }),
|
||||
listTargets: async () => ok(report),
|
||||
inspectPage: async () => ({ ok: false, error: failure }),
|
||||
} as unknown as BrowserObservationPort;
|
||||
const pageInput = inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: input.cdp_endpoint.recorded,
|
||||
allowed_origins: [],
|
||||
target_id: "page",
|
||||
observation_ms: 0,
|
||||
include_script_sources: false,
|
||||
});
|
||||
const result = await inspectWebPage(browser, pageInput);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "inspect_web_page",
|
||||
normalized_result: inspection,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("retains captured page Evidence when bundle analysis fails after capture", async () => {
|
||||
const inspection = passiveNetworkCapture();
|
||||
const failure = new BrowserObservationError(
|
||||
"analyze_web_bundle",
|
||||
"cancelled",
|
||||
{ partialObservation: inspection },
|
||||
);
|
||||
const browser = {
|
||||
identity: () => ({ id: "browser-fixture", name: "Fixture", version: "1" }),
|
||||
analyzeBundle: async () => ({ ok: false as const, error: failure }),
|
||||
} as unknown as BrowserObservationPort;
|
||||
const bundleInput = analyzeWebBundleInputSchema.parse({
|
||||
...input,
|
||||
cdp_endpoint: input.cdp_endpoint.recorded,
|
||||
target_id: "page",
|
||||
observation_ms: 0,
|
||||
include_script_sources: true,
|
||||
});
|
||||
const result = await analyzeWebBundle(browser, bundleInput);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
expect(failure.operation).toBe("analyze_web_bundle");
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "inspect_web_page",
|
||||
normalized_result: inspection,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -14,16 +14,12 @@ import type {
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { BrowserObservationPort } from "./BrowserObservationPort.js";
|
||||
import { createBrowserEvidence } from "./BrowserEvidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
|
||||
import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js";
|
||||
|
||||
/** List browser targets within the endpoint and origin scope in the request. */
|
||||
export const listBrowserTargets = async (
|
||||
@@ -33,16 +29,17 @@ export const listBrowserTargets = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireBrowser(browser, "list_browser_targets");
|
||||
if (!ready.ok) return ready;
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("list_browser_targets"));
|
||||
const result = await ready.value.listTargets(input, options);
|
||||
if (result.ok && options.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("list_browser_targets"));
|
||||
return result.ok
|
||||
? browserEvidence(
|
||||
"list_browser_targets",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
options,
|
||||
? ok(
|
||||
createBrowserEvidence(
|
||||
"list_browser_targets",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: result;
|
||||
};
|
||||
@@ -55,23 +52,17 @@ export const inspectWebPage = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireBrowser(browser, "inspect_web_page");
|
||||
if (!ready.ok) return ready;
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("inspect_web_page"));
|
||||
const result = await ready.value.inspectPage(input, options);
|
||||
if (!result.ok)
|
||||
return retainPageInspection(
|
||||
result.error,
|
||||
input,
|
||||
ready.value,
|
||||
"inspect_web_page",
|
||||
);
|
||||
if (result.ok && options.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("inspect_web_page"));
|
||||
return result.ok
|
||||
? browserEvidence(
|
||||
"inspect_web_page",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
options,
|
||||
? ok(
|
||||
createBrowserEvidence(
|
||||
"inspect_web_page",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: result;
|
||||
};
|
||||
@@ -84,22 +75,17 @@ export const analyzeWebBundle = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireBrowser(browser, "analyze_web_bundle");
|
||||
if (!ready.ok) return ready;
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("analyze_web_bundle"));
|
||||
const analyzed = await ready.value.analyzeBundle(input, options);
|
||||
if (!analyzed.ok)
|
||||
return retainPageInspection(
|
||||
analyzed.error,
|
||||
input,
|
||||
ready.value,
|
||||
if (analyzed.ok && options.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("analyze_web_bundle"));
|
||||
if (!analyzed.ok) return analyzed;
|
||||
return ok(
|
||||
createBrowserEvidence(
|
||||
"analyze_web_bundle",
|
||||
);
|
||||
return browserEvidence(
|
||||
"analyze_web_bundle",
|
||||
input,
|
||||
analyzed.value,
|
||||
ready.value.identity(),
|
||||
options,
|
||||
input,
|
||||
analyzed.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -111,16 +97,17 @@ export const observeWebSession = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireBrowser(browser, "observe_web_session");
|
||||
if (!ready.ok) return ready;
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("observe_web_session"));
|
||||
const observed = await ready.value.observeSession(input, options);
|
||||
if (observed.ok && options.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("observe_web_session"));
|
||||
return observed.ok
|
||||
? browserEvidence(
|
||||
"observe_web_session",
|
||||
input,
|
||||
observed.value,
|
||||
ready.value.identity(),
|
||||
options,
|
||||
? ok(
|
||||
createBrowserEvidence(
|
||||
"observe_web_session",
|
||||
input,
|
||||
observed.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: observed;
|
||||
};
|
||||
@@ -133,16 +120,17 @@ export const discoverWebMcpTools = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireBrowser(browser, "discover_webmcp_tools");
|
||||
if (!ready.ok) return ready;
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("discover_webmcp_tools"));
|
||||
const discovered = await ready.value.discoverWebMcpTools(input, options);
|
||||
if (discovered.ok && options.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("discover_webmcp_tools"));
|
||||
return discovered.ok
|
||||
? browserEvidence(
|
||||
"discover_webmcp_tools",
|
||||
input,
|
||||
discovered.value,
|
||||
ready.value.identity(),
|
||||
options,
|
||||
? ok(
|
||||
createBrowserEvidence(
|
||||
"discover_webmcp_tools",
|
||||
input,
|
||||
discovered.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: discovered;
|
||||
};
|
||||
@@ -175,16 +163,17 @@ export const captureWebScreenshot = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireBrowser(browser, "capture_web_screenshot");
|
||||
if (!ready.ok) return ready;
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("capture_web_screenshot"));
|
||||
const captured = await ready.value.captureScreenshot(input, options);
|
||||
if (captured.ok && options.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("capture_web_screenshot"));
|
||||
return captured.ok
|
||||
? browserEvidence(
|
||||
"capture_web_screenshot",
|
||||
input,
|
||||
captured.value,
|
||||
ready.value.identity(),
|
||||
options,
|
||||
? ok(
|
||||
createBrowserEvidence(
|
||||
"capture_web_screenshot",
|
||||
input,
|
||||
captured.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: captured;
|
||||
};
|
||||
@@ -222,69 +211,3 @@ const requireBrowser = (
|
||||
),
|
||||
)
|
||||
: ok(browser);
|
||||
|
||||
const browserEvidence = (
|
||||
operation: Parameters<typeof createBrowserEvidence>[0],
|
||||
input: Parameters<typeof createBrowserEvidence>[1],
|
||||
result: Parameters<typeof createBrowserEvidence>[2],
|
||||
provider: Parameters<typeof createBrowserEvidence>[3],
|
||||
options: ExecutionOptions,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
const json = jsonValueSchema.safeParse(result);
|
||||
if (!json.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Browser provider returned non-JSON observation data.",
|
||||
{
|
||||
cause: json.error,
|
||||
},
|
||||
),
|
||||
);
|
||||
return completeProviderEvidence(
|
||||
ok(createBrowserEvidence(operation, input, result, provider)),
|
||||
operation,
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
/** Convert a provider-retained page capture without claiming bundle analysis completed. */
|
||||
const retainPageInspection = (
|
||||
error: AnalysisError,
|
||||
input: InspectWebPageInput | AnalyzeWebBundleInput,
|
||||
provider: BrowserObservationPort,
|
||||
errorOperation: "inspect_web_page" | "analyze_web_bundle",
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
const partial = error.partialObservation;
|
||||
if (
|
||||
partial === undefined ||
|
||||
"evidence_id" in partial ||
|
||||
!("target" in partial) ||
|
||||
!("network" in partial) ||
|
||||
!("capture_window" in partial)
|
||||
)
|
||||
return err(error);
|
||||
const inspection = webPageInspectionSchema.safeParse(partial);
|
||||
if (!inspection.success) {
|
||||
if (error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
errorOperation,
|
||||
"Browser provider retained a malformed completed page inspection.",
|
||||
{ cause: inspection.error },
|
||||
),
|
||||
);
|
||||
return err(error);
|
||||
}
|
||||
const pageInput: InspectWebPageInput =
|
||||
"fetch_source_maps" in input
|
||||
? (({ fetch_source_maps: _fetchSourceMaps, ...page }) => page)(input)
|
||||
: input;
|
||||
error.partialObservation = createBrowserEvidence(
|
||||
"inspect_web_page",
|
||||
pageInput,
|
||||
inspection.data,
|
||||
provider.identity(),
|
||||
);
|
||||
return err(error);
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import { browserScenarioInputSchema } from "../domain/browserScenario.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import { scriptScenarioFixture } from "../../tests/fixtures/webScriptCapture.js";
|
||||
import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js";
|
||||
import { captureBrowserScenario } from "./BrowserScenarioCaptureService.js";
|
||||
|
||||
const scenario = browserScenarioInputSchema.parse({
|
||||
browser: { mode: "launch", executable_path: "/fixture/chrome" },
|
||||
start_url: { url: "https://fixture.test/", query: [] },
|
||||
actions: [{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 }],
|
||||
});
|
||||
const capture = scriptScenarioFixture();
|
||||
|
||||
it("converts a retained raw scenario capture into partial Evidence", async () => {
|
||||
const failure = new AnalysisCancelledError("capture_browser_scenario", {
|
||||
partialObservation: {
|
||||
kind: "browser-scenario-observation",
|
||||
capture,
|
||||
},
|
||||
});
|
||||
const provider: BrowserScenarioCapturePort = {
|
||||
identity: () => ({
|
||||
id: "scenario-fixture",
|
||||
name: "Scenario fixture",
|
||||
version: "1",
|
||||
}),
|
||||
captureScenario: async () => err(failure),
|
||||
};
|
||||
const result = await captureBrowserScenario(provider, scenario);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "capture_browser_scenario",
|
||||
normalized_result: capture,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("converts a raw scenario capture retained after cleanup failure", async () => {
|
||||
const failure = new BrowserObservationError(
|
||||
"capture_browser_scenario",
|
||||
"cleanup_failed",
|
||||
{
|
||||
cleanup: {
|
||||
reason: "browser close failed",
|
||||
resources: ["browser process"],
|
||||
},
|
||||
partialObservation: {
|
||||
kind: "browser-scenario-observation",
|
||||
capture,
|
||||
},
|
||||
},
|
||||
);
|
||||
const provider: BrowserScenarioCapturePort = {
|
||||
identity: () => ({
|
||||
id: "scenario-fixture",
|
||||
name: "Scenario fixture",
|
||||
version: "1",
|
||||
}),
|
||||
captureScenario: async () => err(failure),
|
||||
};
|
||||
const result = await captureBrowserScenario(provider, scenario);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "capture_browser_scenario",
|
||||
normalized_result: capture,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,17 +1,11 @@
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js";
|
||||
import { createBrowserScenarioEvidence } from "./BrowserScenarioEvidence.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
import { browserScenarioCaptureSchema } from "../domain/browserScenarioCapture.js";
|
||||
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
const PROVIDER_ID = "rea-playwright-browser-scenario";
|
||||
@@ -30,55 +24,15 @@ export const captureBrowserScenario = async (
|
||||
"browser scenario provider is not configured",
|
||||
),
|
||||
);
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const captured = await provider.captureScenario(scenario, options);
|
||||
if (!captured.ok) {
|
||||
const partial = captured.error.partialObservation;
|
||||
if (
|
||||
partial !== undefined &&
|
||||
"kind" in partial &&
|
||||
partial.kind === "browser-scenario-observation"
|
||||
) {
|
||||
const report = browserScenarioCaptureSchema.safeParse(partial.capture);
|
||||
if (!report.success) {
|
||||
if (captured.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Browser scenario provider retained a malformed completed capture.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
return captured;
|
||||
return captured.ok
|
||||
? {
|
||||
ok: true,
|
||||
value: createBrowserScenarioEvidence(
|
||||
scenario,
|
||||
captured.value,
|
||||
provider.identity(),
|
||||
),
|
||||
}
|
||||
captured.error.partialObservation = createBrowserScenarioEvidence(
|
||||
scenario,
|
||||
report.data,
|
||||
provider.identity(),
|
||||
);
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
const report = browserScenarioCaptureSchema.safeParse(captured.value);
|
||||
if (!report.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Browser scenario provider returned a malformed completed capture.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
return completeProviderEvidence(
|
||||
{
|
||||
ok: true,
|
||||
value: createBrowserScenarioEvidence(
|
||||
scenario,
|
||||
report.data,
|
||||
provider.identity(),
|
||||
),
|
||||
},
|
||||
OPERATION,
|
||||
options,
|
||||
);
|
||||
: captured;
|
||||
};
|
||||
@@ -14,14 +14,12 @@ import {
|
||||
} from "./HermesConfigurationYaml.js";
|
||||
import {
|
||||
applyEdits,
|
||||
createScanner,
|
||||
findNodeAtLocation,
|
||||
modify,
|
||||
parse as parseJsonc,
|
||||
parseTree,
|
||||
printParseErrorCode,
|
||||
type ParseError,
|
||||
type SyntaxKind,
|
||||
} from "jsonc-parser";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -1249,27 +1247,44 @@ const tomlTextHolds = (
|
||||
}
|
||||
};
|
||||
|
||||
// jsonc-parser declares SyntaxKind as an ambient const enum, so keep its token
|
||||
// code local and type-checked as in the JSON scanner used by IDA diagnostics.
|
||||
const JSON_COMMA = 5 satisfies SyntaxKind;
|
||||
|
||||
/** Find the next non-trivia JSONC token at or after `offset`. */
|
||||
const nextJsoncToken = (
|
||||
text: string,
|
||||
offset: number,
|
||||
): { readonly kind: SyntaxKind; readonly offset: number } => {
|
||||
const scanner = createScanner(text, true);
|
||||
scanner.setPosition(offset);
|
||||
const kind = scanner.scan();
|
||||
return { kind, offset: scanner.getTokenOffset() };
|
||||
/** Comments in the trivia between two properties. `undefined` when there are none. */
|
||||
const commentsBetweenProperties = (gap: string): string | undefined => {
|
||||
const pieces: string[] = [];
|
||||
let index = 0;
|
||||
while (index < gap.length) {
|
||||
const line = gap.indexOf("//", index);
|
||||
const block = gap.indexOf("/*", index);
|
||||
if (line < 0 && block < 0) break;
|
||||
const start = line < 0 ? block : block < 0 ? line : Math.min(line, block);
|
||||
const lineComment = start === line;
|
||||
const close = lineComment
|
||||
? gap.indexOf("\n", start)
|
||||
: gap.indexOf("*/", start + 2);
|
||||
if (!lineComment && close < 0) return undefined;
|
||||
const end = lineComment ? (close < 0 ? gap.length : close) : close + 2;
|
||||
const comment = gap.slice(start, end);
|
||||
const lineBreak = gap.lastIndexOf("\n", start - 1);
|
||||
if (lineBreak < 0) pieces.push(` ${comment}`);
|
||||
else {
|
||||
const prefix = gap.slice(lineBreak + 1, start);
|
||||
let indentEnd = 0;
|
||||
while (prefix[indentEnd] === " " || prefix[indentEnd] === "\t")
|
||||
indentEnd += 1;
|
||||
const newline =
|
||||
lineBreak > 0 && gap[lineBreak - 1] === "\r" ? "\r\n" : "\n";
|
||||
pieces.push(`${newline}${prefix.slice(0, indentEnd)}${comment}`);
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
return pieces.length === 0 ? undefined : pieces.join("");
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a property by its JSONC AST span and the separator needed to keep its
|
||||
* parent object valid. All whitespace and comments outside those tokens stay
|
||||
* byte-for-byte in place.
|
||||
* Delete an object property without taking a comment from the gap before it.
|
||||
* jsonc-parser's removal span includes that gap, so a trailing comment on the
|
||||
* previous property would disappear. Returns `undefined` to keep the stock edit.
|
||||
*/
|
||||
const deleteJsonProperty = (
|
||||
const deleteJsonPropertyPreservingComments = (
|
||||
text: string,
|
||||
path: readonly string[],
|
||||
): string | undefined => {
|
||||
@@ -1286,26 +1301,54 @@ const deleteJsonProperty = (
|
||||
)
|
||||
return undefined;
|
||||
const propertyIndex = parent.children.indexOf(property);
|
||||
if (propertyIndex < 0) return undefined;
|
||||
const previous = parent.children[propertyIndex - 1];
|
||||
const next = parent.children[propertyIndex + 1];
|
||||
let commaOffset: number | undefined;
|
||||
if (previous !== undefined) {
|
||||
const comma = nextJsoncToken(text, previous.offset + previous.length);
|
||||
if (comma.kind !== JSON_COMMA) return undefined;
|
||||
commaOffset = comma.offset;
|
||||
} else {
|
||||
const token = nextJsoncToken(text, property.offset + property.length);
|
||||
if (token.kind === JSON_COMMA) commaOffset = token.offset;
|
||||
else if (next !== undefined) return undefined;
|
||||
if (propertyIndex <= 0 || previous === undefined) return undefined;
|
||||
const comments = commentsBetweenProperties(
|
||||
text.slice(previous.offset + previous.length, property.offset),
|
||||
);
|
||||
if (comments === undefined) return undefined;
|
||||
let removeEnd = property.offset + property.length;
|
||||
const hasNext = parent.children[propertyIndex + 1] !== undefined;
|
||||
if (hasNext) {
|
||||
let comma = removeEnd;
|
||||
while (comma < text.length) {
|
||||
const char = text[comma];
|
||||
if (char === " " || char === "\t" || char === "\r" || char === "\n") {
|
||||
comma += 1;
|
||||
continue;
|
||||
}
|
||||
if (text.startsWith("/*", comma)) {
|
||||
const close = text.indexOf("*/", comma + 2);
|
||||
if (close < 0) return undefined;
|
||||
comma = close + 2;
|
||||
continue;
|
||||
}
|
||||
if (text.startsWith("//", comma)) {
|
||||
while (
|
||||
comma < text.length &&
|
||||
text[comma] !== "\n" &&
|
||||
text[comma] !== "\r"
|
||||
)
|
||||
comma += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (text[comma] !== ",") return undefined;
|
||||
removeEnd = comma + 1;
|
||||
}
|
||||
const edits = [
|
||||
{ offset: property.offset, length: property.length, content: "" },
|
||||
...(commaOffset === undefined
|
||||
? []
|
||||
: [{ offset: commaOffset, length: 1, content: "" }]),
|
||||
];
|
||||
return applyEdits(text, edits);
|
||||
// A line comment runs to the next break. Keep the following token outside it.
|
||||
const remainder = text.slice(removeEnd);
|
||||
let suffix = `${hasNext ? "," : ""}${comments}`;
|
||||
if (
|
||||
suffix.includes("//") &&
|
||||
!suffix.endsWith("\n") &&
|
||||
!suffix.endsWith("\r") &&
|
||||
!remainder.startsWith("\n") &&
|
||||
!remainder.startsWith("\r")
|
||||
)
|
||||
suffix += "\n";
|
||||
return `${text.slice(0, previous.offset + previous.length)}${suffix}${remainder}`;
|
||||
};
|
||||
|
||||
/** Serialize a validated client document, retaining JSONC and TOML comments. */
|
||||
@@ -1334,7 +1377,7 @@ export const serializeClientConfiguration = (
|
||||
return editedPaths.reduce((text, path) => {
|
||||
const value = valueAt(document, path);
|
||||
if (value === undefined) {
|
||||
const preserved = deleteJsonProperty(text, path);
|
||||
const preserved = deleteJsonPropertyPreservingComments(text, path);
|
||||
if (preserved !== undefined) return preserved;
|
||||
}
|
||||
return applyEdits(
|
||||
|
||||
@@ -48,11 +48,9 @@ export const directAnalysisCleanupFailure = (
|
||||
retryable: false,
|
||||
remediation: cleanup.remediation,
|
||||
details: {
|
||||
// Primary fields describe the original failure; the later close
|
||||
// assessment, including its resources, belongs to cleanup_error.
|
||||
resources: error.cleanupResources.slice(),
|
||||
...primary.data.details,
|
||||
cleanup: "incomplete",
|
||||
resources: error.cleanupResources.slice(),
|
||||
execution_failure:
|
||||
primary.data.details !== undefined &&
|
||||
"execution_failure" in primary.data.details
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, readdir, stat } from "node:fs/promises";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -434,12 +433,9 @@ export interface SystemDoctorHostOptions {
|
||||
readonly architecture?: NodeJS.Architecture;
|
||||
readonly environment?: NodeJS.ProcessEnv;
|
||||
readonly execFileOutput?: typeof execFileOutput;
|
||||
readonly providerInspections?: (
|
||||
adoptFileHandle: (
|
||||
resource: string,
|
||||
handle: OwnedFileHandle,
|
||||
) => Promise<boolean>,
|
||||
) => Promise<readonly DoctorProviderInspection[]>;
|
||||
readonly providerInspections?: () => Promise<
|
||||
readonly DoctorProviderInspection[]
|
||||
>;
|
||||
readonly providerRegistrationEnvironment?: () => Readonly<
|
||||
Record<string, string>
|
||||
>;
|
||||
@@ -553,19 +549,7 @@ export const systemDoctorHost = (
|
||||
manualHopperPaths: () => manualHopperPaths(homeDirectory),
|
||||
...(options.providerInspections === undefined
|
||||
? {}
|
||||
: {
|
||||
providerInspections: () =>
|
||||
options.providerInspections!(
|
||||
async (resource, handle) =>
|
||||
(
|
||||
await resources.release({
|
||||
kind: "file-handle",
|
||||
resource,
|
||||
handle,
|
||||
})
|
||||
).kind === "released",
|
||||
),
|
||||
}),
|
||||
: { providerInspections: options.providerInspections }),
|
||||
...(options.providerRegistrationEnvironment === undefined
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
||||
import { execFileBoundedOutput } from "../process/ExecFileBoundedOutput.js";
|
||||
import { execFileOutput } from "../process/ExecFileOutput.js";
|
||||
import { downloadHopperPackage } from "./HopperDownload.js";
|
||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
import {
|
||||
HOPPER_RELEASES_URL,
|
||||
@@ -431,15 +432,10 @@ export const systemLinuxHopperInstallHost = (
|
||||
!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`)
|
||||
)
|
||||
return "runtime_dependencies";
|
||||
return (await publisher.hashLauncher(path)).sha256 === expectedSha256
|
||||
return (await hashStableFile(path)).sha256 === expectedSha256
|
||||
? "ready"
|
||||
: "unsupported_hopper_build";
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof HopperReleaseError &&
|
||||
cause.reason === "cleanup_failed"
|
||||
)
|
||||
throw cause;
|
||||
// best-effort cleanup: optional launcher probing; unreadable means missing.
|
||||
void cause;
|
||||
return "missing";
|
||||
|
||||
@@ -11,11 +11,6 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import {
|
||||
StableFileHashCleanupFailure,
|
||||
type StableFileHashResult,
|
||||
} from "../filesystem/StableFileHash.js";
|
||||
import { OwnedRegularFileCleanupFailure } from "../filesystem/RegularFile.js";
|
||||
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||
import { verifyDetachedPgpSignature } from "../process/DetachedPgpSignature.js";
|
||||
import { execFileOutput } from "../process/ExecFileOutput.js";
|
||||
@@ -129,46 +124,6 @@ export class LinuxHopperPublisher {
|
||||
readonly packageFamily: () => Promise<LinuxPackageFamily | undefined>,
|
||||
) {}
|
||||
|
||||
/** Hash a selected launcher while retaining any descriptor cleanup owner. */
|
||||
hashLauncher(path: string): Promise<StableFileHashResult> {
|
||||
return this.#hashStableFile(path);
|
||||
}
|
||||
|
||||
async #hashStableFile(
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StableFileHashResult> {
|
||||
try {
|
||||
return await hashStableFile(path, signal);
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof OwnedRegularFileCleanupFailure)) throw cause;
|
||||
const cleanup = await this.#resources.release({
|
||||
kind: "file-handle",
|
||||
resource: path,
|
||||
handle: cause.owner,
|
||||
});
|
||||
if (cleanup.kind === "failed") {
|
||||
pendingCleanup.add(this.#resources);
|
||||
throw new HopperReleaseError(
|
||||
"cleanup_failed",
|
||||
`Hopper publisher retained file descriptor cleanup for ${path}.`,
|
||||
{
|
||||
cause: new AggregateError(
|
||||
[cause, cleanup.cause],
|
||||
"Stable file hash and descriptor cleanup failed",
|
||||
{ cause },
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (cause instanceof StableFileHashCleanupFailure) {
|
||||
if (cause.hasPrimaryCause) throw cause.cause;
|
||||
if (cause.completedHash !== undefined) return cause.completedHash;
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
get cacheRoot(): string {
|
||||
const location = linuxHopperReleaseCacheDirectory(this.environment);
|
||||
if (!location.ok) throw location.error;
|
||||
@@ -338,8 +293,8 @@ export class LinuxHopperPublisher {
|
||||
await this.#withTemporary(async (stage) => {
|
||||
await copyFile(archive, join(stage, "archive"));
|
||||
if (
|
||||
(await this.#hashStableFile(join(stage, "archive"), signal))
|
||||
.sha256 !== verified.packageSha256
|
||||
(await hashStableFile(join(stage, "archive"), signal)).sha256 !==
|
||||
verified.packageSha256
|
||||
)
|
||||
throw new HopperReleaseError(
|
||||
"integrity",
|
||||
@@ -480,12 +435,10 @@ export class LinuxHopperPublisher {
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<VerifiedLinuxHopperLauncher, HopperReleaseError>> {
|
||||
try {
|
||||
const observed = await this.#hashStableFile(path, signal);
|
||||
const observed = await hashStableFile(path, signal);
|
||||
const cached = await this.#cachedPackage(observed.sha256, signal);
|
||||
if (cached.ok) {
|
||||
if (
|
||||
(await this.#hashStableFile(path, signal)).sha256 !== observed.sha256
|
||||
)
|
||||
if ((await hashStableFile(path, signal)).sha256 !== observed.sha256)
|
||||
return err(
|
||||
new HopperReleaseError(
|
||||
"integrity",
|
||||
@@ -537,7 +490,7 @@ export class LinuxHopperPublisher {
|
||||
if (!verified.ok) throw verified.error;
|
||||
return verified.value;
|
||||
});
|
||||
if ((await this.#hashStableFile(path, signal)).sha256 !== observed.sha256)
|
||||
if ((await hashStableFile(path, signal)).sha256 !== observed.sha256)
|
||||
return err(
|
||||
new HopperReleaseError(
|
||||
"integrity",
|
||||
@@ -730,7 +683,7 @@ export class LinuxHopperPublisher {
|
||||
commandOptions,
|
||||
);
|
||||
return (
|
||||
await this.#hashStableFile(
|
||||
await hashStableFile(
|
||||
join(extracted, "opt", "hopper", "bin", "Hopper"),
|
||||
signal,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
AnalysisError,
|
||||
AnalysisErrorOptions,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
@@ -30,24 +27,6 @@ export interface ProviderOperationPort<
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||
}
|
||||
|
||||
/** Preserve valid completed Evidence when cancellation arrives at completion. */
|
||||
export const completeProviderEvidence = (
|
||||
completed: Result<Evidence, AnalysisError>,
|
||||
operation: string,
|
||||
options?: ExecutionOptions,
|
||||
errorOptions?: Omit<AnalysisErrorOptions, "partialObservation">,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
if (!completed.ok) return completed;
|
||||
return options?.signal?.aborted
|
||||
? err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
...errorOptions,
|
||||
partialObservation: completed.value,
|
||||
}),
|
||||
)
|
||||
: completed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared CLI/MCP admission, execution, and Evidence composition for
|
||||
* single-request providers. The caller retains its operation vocabulary and
|
||||
@@ -90,5 +69,9 @@ export const executeProviderOperation = async <
|
||||
// the caller no longer wants. The observation it completed is still this
|
||||
// provider's Evidence, so the cancellation carries it as the partial
|
||||
// observation instead of discarding a finished result.
|
||||
return completeProviderEvidence(ok(evidence), operation, options);
|
||||
if (options?.signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { partialObservation: evidence }),
|
||||
);
|
||||
return ok(evidence);
|
||||
};
|
||||
@@ -116,140 +116,3 @@ it("retains Kotlin source identity and language across the inventory projection"
|
||||
expect(parsed.parseFailures).toEqual([]);
|
||||
expect(parsed.relationships).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves runtime require package mains without an index and before typed indexes", () => {
|
||||
const files = [
|
||||
["main.cjs", 'require("./pkg");\nrequire("./dep");\n'],
|
||||
[
|
||||
"main.ts",
|
||||
'import dep = require("./typed");\n' +
|
||||
'require("./typed");\n' +
|
||||
'import data = require("./json-package");\n' +
|
||||
'require("./emitted.js");\n',
|
||||
],
|
||||
["pkg/package.json", '{"main":"actual.js"}\n'],
|
||||
["pkg/actual.js", 'module.exports = "package main";\n'],
|
||||
["dep.js", 'module.exports = "file";\n'],
|
||||
["dep/index.ts", 'export const value = "index";\n'],
|
||||
["typed.js", 'module.exports = "runtime file";\n'],
|
||||
["typed/index.ts", 'export const value = "source index";\n'],
|
||||
["json-package/package.json", '{"main":"data"}\n'],
|
||||
["json-package/data.json", '{"value":"package main"}\n'],
|
||||
["json-package/index.js", 'module.exports = "fallback index";\n'],
|
||||
["emitted.ts", 'export const value = "source counterpart";\n'],
|
||||
] as const;
|
||||
const read: ReferenceSourceRead = {
|
||||
root: "/selected/source",
|
||||
entries: files.map(([path, content]) => ({
|
||||
status: "read",
|
||||
kind: "file",
|
||||
path,
|
||||
bytes: Buffer.from(content),
|
||||
size: Buffer.byteLength(content),
|
||||
})),
|
||||
bytesRead: files.reduce(
|
||||
(sum, [, content]) => sum + Buffer.byteLength(content),
|
||||
0,
|
||||
),
|
||||
limitations: [],
|
||||
};
|
||||
|
||||
const parsed = parseReferenceSourceEntries(
|
||||
read,
|
||||
new Set(files.map(([path]) => path)),
|
||||
);
|
||||
|
||||
expect(parsed.relationships).toContainEqual({
|
||||
from_path: "main.cjs",
|
||||
to: "pkg/actual.js",
|
||||
kind: "requires",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
});
|
||||
expect(parsed.relationships).toContainEqual({
|
||||
from_path: "main.cjs",
|
||||
to: "dep.js",
|
||||
kind: "requires",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
});
|
||||
expect(parsed.relationships).toContainEqual({
|
||||
from_path: "main.ts",
|
||||
to: "typed/index.ts",
|
||||
kind: "requires",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
});
|
||||
expect(parsed.relationships).toContainEqual({
|
||||
from_path: "main.ts",
|
||||
to: "typed.js",
|
||||
kind: "requires",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
});
|
||||
expect(parsed.relationships).toContainEqual({
|
||||
from_path: "main.ts",
|
||||
to: "json-package/data.json",
|
||||
kind: "requires",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
});
|
||||
expect(parsed.relationships).toContainEqual({
|
||||
from_path: "main.ts",
|
||||
to: "emitted.ts",
|
||||
kind: "requires",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches package manifests by exact or unique canonical directory identity", () => {
|
||||
const composed = "caf\u00e9";
|
||||
const decomposed = "cafe\u0301";
|
||||
const composedTwin = "r\u00e9sum\u00e9";
|
||||
const decomposedTwin = "re\u0301sume\u0301";
|
||||
const files = [
|
||||
[
|
||||
"main.cjs",
|
||||
[composed, composedTwin, decomposedTwin]
|
||||
.map((name) => `require(${JSON.stringify(`./${name}`)});`)
|
||||
.join("\n"),
|
||||
],
|
||||
[`${decomposed}/package.json`, '{"main":"unique.js"}\n'],
|
||||
[`${decomposed}/unique.js`, 'module.exports = "unique";\n'],
|
||||
[`${composed}/unique.js`, 'module.exports = "unrelated exact spelling";\n'],
|
||||
[`${composedTwin}/package.json`, '{"main":"exact-composed.js"}\n'],
|
||||
[`${composedTwin}/exact-composed.js`, 'module.exports = "composed";\n'],
|
||||
[`${decomposedTwin}/package.json`, '{"main":"exact-decomposed.js"}\n'],
|
||||
[
|
||||
`${decomposedTwin}/exact-decomposed.js`,
|
||||
'module.exports = "decomposed";\n',
|
||||
],
|
||||
] as const;
|
||||
const read: ReferenceSourceRead = {
|
||||
root: "/selected/source",
|
||||
entries: files.map(([path, content]) => ({
|
||||
status: "read",
|
||||
kind: "file",
|
||||
path,
|
||||
bytes: Buffer.from(content),
|
||||
size: Buffer.byteLength(content),
|
||||
})),
|
||||
bytesRead: files.reduce(
|
||||
(sum, [, content]) => sum + Buffer.byteLength(content),
|
||||
0,
|
||||
),
|
||||
limitations: [],
|
||||
};
|
||||
|
||||
const parsed = parseReferenceSourceEntries(
|
||||
read,
|
||||
new Set(files.map(([path]) => path)),
|
||||
);
|
||||
|
||||
expect(parsed.relationships.map(({ to }) => to)).toEqual([
|
||||
`${decomposed}/unique.js`,
|
||||
`${composedTwin}/exact-composed.js`,
|
||||
`${decomposedTwin}/exact-decomposed.js`,
|
||||
]);
|
||||
});
|
||||
@@ -107,20 +107,11 @@ type PackageMain =
|
||||
*/
|
||||
const readPackageMains = (
|
||||
read: ReferenceSourceRead,
|
||||
): {
|
||||
readonly mains: ReadonlyMap<string, PackageMain>;
|
||||
readonly pathLookup: ReferenceSourcePathLookup;
|
||||
} => {
|
||||
): ReadonlyMap<string, PackageMain> => {
|
||||
const mains = new Map<string, PackageMain>();
|
||||
const manifestPaths: string[] = [];
|
||||
for (const entry of read.entries) {
|
||||
if (posix.basename(entry.path) !== "package.json") continue;
|
||||
manifestPaths.push(entry.path);
|
||||
const directory = posix.dirname(entry.path);
|
||||
if (mains.has(directory)) {
|
||||
mains.set(directory, { state: "unknown" });
|
||||
continue;
|
||||
}
|
||||
if (entry.status !== "read" || entry.kind !== "file") {
|
||||
mains.set(directory, { state: "unknown" });
|
||||
continue;
|
||||
@@ -143,10 +134,7 @@ const readPackageMains = (
|
||||
mains.set(directory, { state: "unknown" });
|
||||
}
|
||||
}
|
||||
return {
|
||||
mains,
|
||||
pathLookup: createReferenceSourcePathLookup(manifestPaths),
|
||||
};
|
||||
return mains;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -166,109 +154,54 @@ const RESOLUTION_SUFFIXES: readonly (readonly string[])[] = [
|
||||
const REQUIRE_RESOLUTION_SUFFIXES = RESOLUTION_SUFFIXES.map((slot) =>
|
||||
slot.includes(".js") ? [...slot, ".json"] : slot,
|
||||
);
|
||||
const NODE_REQUIRE_SUFFIXES = ["", ".js", ".json", ".node"] as const;
|
||||
|
||||
type InternalSpecifierResolutionMode =
|
||||
| "source-import"
|
||||
| "source-require"
|
||||
| "runtime-require";
|
||||
|
||||
const resolutionSuffixes = (
|
||||
mode: InternalSpecifierResolutionMode,
|
||||
): readonly (readonly string[])[] =>
|
||||
mode === "source-require" ? REQUIRE_RESOLUTION_SUFFIXES : RESOLUTION_SUFFIXES;
|
||||
|
||||
const nodeRequireFileCandidates = (path: string): string[] =>
|
||||
NODE_REQUIRE_SUFFIXES.map((suffix) => `${path}${suffix}`);
|
||||
|
||||
const nodeRequireIndexCandidates = (directory: string): string[] =>
|
||||
NODE_REQUIRE_SUFFIXES.filter((suffix) => suffix !== "").map((suffix) =>
|
||||
posix.join(directory, `index${suffix}`),
|
||||
);
|
||||
|
||||
/** Inventory facts an internal specifier resolves against. */
|
||||
interface SpecifierContext {
|
||||
readonly lookupPath: ReferenceSourcePathLookup;
|
||||
readonly packageMains: ReadonlyMap<string, PackageMain>;
|
||||
readonly packageManifestPathLookup: ReferenceSourcePathLookup;
|
||||
}
|
||||
|
||||
const packageMainForDirectory = (
|
||||
directory: string,
|
||||
context: SpecifierContext,
|
||||
): { readonly directory: string; readonly main: PackageMain } | undefined => {
|
||||
const manifestPath = context.packageManifestPathLookup.resolve(
|
||||
posix.join(directory, "package.json"),
|
||||
);
|
||||
if (manifestPath !== undefined) {
|
||||
const manifestDirectory = posix.dirname(manifestPath);
|
||||
const main = context.packageMains.get(manifestDirectory);
|
||||
return main === undefined
|
||||
? undefined
|
||||
: { directory: manifestDirectory, main };
|
||||
}
|
||||
return context.packageManifestPathLookup.has(
|
||||
posix.join(directory, "package.json"),
|
||||
)
|
||||
? { directory, main: { state: "unknown" } }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Apply a package main, retaining an existing index as the fallback target. */
|
||||
/**
|
||||
* Apply a package `main` to a directory resolved through its index. Node loads
|
||||
* the declared entry as a file, then as a directory index, and falls back to
|
||||
* the package's own index only when neither exists.
|
||||
*/
|
||||
const selectPackageMain = (
|
||||
directory: string,
|
||||
indexFallback: string | undefined,
|
||||
index: string,
|
||||
context: SpecifierContext,
|
||||
mode: InternalSpecifierResolutionMode,
|
||||
slots: readonly (readonly string[])[],
|
||||
): { to: string; resolution: "internal" | "unresolved" } => {
|
||||
const packageSelection = packageMainForDirectory(directory, context);
|
||||
const packageDirectory = packageSelection?.directory ?? directory;
|
||||
const manifest = packageSelection?.main;
|
||||
const directory = posix.dirname(index);
|
||||
const manifest = context.packageMains.get(directory);
|
||||
if (manifest === undefined || manifest.state === "none")
|
||||
return indexFallback === undefined
|
||||
? { to: directory, resolution: "unresolved" }
|
||||
: { to: indexFallback, resolution: "internal" };
|
||||
return { to: index, resolution: "internal" };
|
||||
if (manifest.state === "unknown")
|
||||
return { to: packageDirectory, resolution: "unresolved" };
|
||||
return { to: directory, resolution: "unresolved" };
|
||||
// Node resolves `main` with path.resolve: an absolute main leaves the
|
||||
// package, and a trailing separator does not make the entry a directory.
|
||||
if (posix.isAbsolute(manifest.main))
|
||||
return { to: posix.normalize(manifest.main), resolution: "unresolved" };
|
||||
const target = posix
|
||||
.normalize(posix.join(packageDirectory, manifest.main))
|
||||
.normalize(posix.join(directory, manifest.main))
|
||||
.replace(/(?<=.)\/+$/u, "");
|
||||
// Node would load a declared entry outside the imported root.
|
||||
if (logicalPathEscapesRoot(target))
|
||||
return { to: target, resolution: "unresolved" };
|
||||
const slots = resolutionSuffixes(mode);
|
||||
const candidates =
|
||||
mode === "runtime-require"
|
||||
? [
|
||||
...nodeRequireFileCandidates(target),
|
||||
...nodeRequireIndexCandidates(target),
|
||||
]
|
||||
: [
|
||||
...slots.flatMap((slot) =>
|
||||
slot.map((suffix) => `${target}${suffix}`),
|
||||
),
|
||||
...slots
|
||||
.flat()
|
||||
.filter((suffix) => suffix !== "")
|
||||
.map((suffix) => posix.join(target, `index${suffix}`)),
|
||||
];
|
||||
const selected = context.lookupPath.resolveCandidates(candidates);
|
||||
return selected === undefined
|
||||
? indexFallback === undefined
|
||||
? { to: packageDirectory, resolution: "unresolved" }
|
||||
: { to: indexFallback, resolution: "internal" }
|
||||
: { to: selected, resolution: "internal" };
|
||||
const suffixes = slots.flat();
|
||||
const selected = context.lookupPath.resolveCandidates([
|
||||
...suffixes.map((suffix) => `${target}${suffix}`),
|
||||
...suffixes
|
||||
.filter((suffix) => suffix !== "")
|
||||
.map((suffix) => posix.join(target, `index${suffix}`)),
|
||||
]);
|
||||
return { to: selected ?? index, resolution: "internal" };
|
||||
};
|
||||
|
||||
const resolveInternalSpecifier = (
|
||||
fromPath: string,
|
||||
specifier: string,
|
||||
context: SpecifierContext,
|
||||
mode: InternalSpecifierResolutionMode,
|
||||
slots: readonly (readonly string[])[],
|
||||
): { to: string; resolution: "internal" | "unresolved" } => {
|
||||
if (posix.isAbsolute(specifier))
|
||||
return { to: specifier, resolution: "unresolved" };
|
||||
@@ -282,21 +215,14 @@ const resolveInternalSpecifier = (
|
||||
specifier.endsWith("/") || [".", ".."].includes(posix.basename(specifier));
|
||||
// A directory resolves only to an index with an extension; neither Node nor
|
||||
// TypeScript selects an extensionless `index` file.
|
||||
const slots = resolutionSuffixes(mode);
|
||||
const candidates =
|
||||
mode === "runtime-require"
|
||||
? [
|
||||
...(directoryReference ? [] : nodeRequireFileCandidates(normalized)),
|
||||
...nodeRequireIndexCandidates(normalized),
|
||||
]
|
||||
: slots.flatMap((slot) => [
|
||||
...(directoryReference
|
||||
? []
|
||||
: slot.map((suffix) => `${normalized}${suffix}`)),
|
||||
...slot
|
||||
.filter((suffix) => suffix !== "")
|
||||
.map((suffix) => posix.join(normalized, `index${suffix}`)),
|
||||
]);
|
||||
const candidates = slots.flatMap((slot) => [
|
||||
...(directoryReference
|
||||
? []
|
||||
: slot.map((suffix) => `${normalized}${suffix}`)),
|
||||
...slot
|
||||
.filter((suffix) => suffix !== "")
|
||||
.map((suffix) => posix.join(normalized, `index${suffix}`)),
|
||||
]);
|
||||
// Retain present runtime files first; substitute only a missing counterpart.
|
||||
// Counterparts cover explicit filenames, not directory/index resolution.
|
||||
if (!directoryReference)
|
||||
@@ -306,15 +232,12 @@ const resolveInternalSpecifier = (
|
||||
),
|
||||
);
|
||||
const match = context.lookupPath.resolveCandidates(candidates);
|
||||
if (match === undefined)
|
||||
return packageMainForDirectory(normalized, context) !== undefined
|
||||
? selectPackageMain(normalized, undefined, context, mode)
|
||||
: { to: normalized, resolution: "unresolved" };
|
||||
if (match === undefined) return { to: normalized, resolution: "unresolved" };
|
||||
const resolvedThroughIndex =
|
||||
posix.basename(match).startsWith("index.") &&
|
||||
posix.dirname(match).normalize("NFC") === normalized.normalize("NFC");
|
||||
return resolvedThroughIndex
|
||||
? selectPackageMain(posix.dirname(match), match, context, mode)
|
||||
? selectPackageMain(match, context, slots)
|
||||
: { to: match, resolution: "internal" };
|
||||
};
|
||||
|
||||
@@ -342,25 +265,20 @@ const appendFile = (
|
||||
});
|
||||
if (parsed === undefined) return;
|
||||
for (const relationship of parsed.relationships) {
|
||||
const { resolutionStrategy, ...graphRelationship } = relationship;
|
||||
if (graphRelationship.resolution !== "internal") {
|
||||
output.relationships.push(graphRelationship);
|
||||
if (relationship.resolution !== "internal") {
|
||||
output.relationships.push(relationship);
|
||||
continue;
|
||||
}
|
||||
const mode: InternalSpecifierResolutionMode =
|
||||
resolutionStrategy === "runtime-require"
|
||||
? "runtime-require"
|
||||
: graphRelationship.kind === "requires"
|
||||
? "source-require"
|
||||
: "source-import";
|
||||
const resolved = resolveInternalSpecifier(
|
||||
graphRelationship.from_path,
|
||||
graphRelationship.to,
|
||||
relationship.from_path,
|
||||
relationship.to,
|
||||
context,
|
||||
mode,
|
||||
relationship.kind === "requires"
|
||||
? REQUIRE_RESOLUTION_SUFFIXES
|
||||
: RESOLUTION_SUFFIXES,
|
||||
);
|
||||
output.relationships.push({
|
||||
...graphRelationship,
|
||||
...relationship,
|
||||
to: resolved.to,
|
||||
resolution: resolved.resolution,
|
||||
});
|
||||
@@ -417,11 +335,9 @@ export const parseReferenceSourceEntries = (
|
||||
parseFailures: [],
|
||||
limitations: [...read.limitations],
|
||||
};
|
||||
const packageMainInventory = readPackageMains(read);
|
||||
const context: SpecifierContext = {
|
||||
lookupPath: createReferenceSourcePathLookup(filePaths),
|
||||
packageMains: packageMainInventory.mains,
|
||||
packageManifestPathLookup: packageMainInventory.pathLookup,
|
||||
packageMains: readPackageMains(read),
|
||||
};
|
||||
for (const entry of read.entries) {
|
||||
if (signal?.aborted === true) break;
|
||||
|
||||
@@ -159,10 +159,10 @@ const claudeDesktopLocation = (
|
||||
};
|
||||
|
||||
const claudeCodeConfigDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.CLAUDE_CONFIG_DIR || home;
|
||||
env.CLAUDE_CONFIG_DIR ?? home;
|
||||
|
||||
const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.CLAUDE_CONFIG_DIR || join(home, ".claude");
|
||||
env.CLAUDE_CONFIG_DIR ?? join(home, ".claude");
|
||||
|
||||
const codexDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.CODEX_HOME || join(home, ".codex");
|
||||
@@ -486,8 +486,8 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
||||
{
|
||||
name: "claude_code",
|
||||
displayName: "Claude Code",
|
||||
skillPath: (context: ClientPathContext) =>
|
||||
join(claudeCodeMarkerDirectory(context), "skills"),
|
||||
skillPath: ({ home, env }: ClientPathContext) =>
|
||||
join(env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"), "skills"),
|
||||
configPath: (context: ClientPathContext) =>
|
||||
join(claudeCodeConfigDirectory(context), ".claude.json"),
|
||||
markerPath: claudeCodeMarkerDirectory,
|
||||
|
||||
@@ -4,10 +4,6 @@ import { historicalHar } from "../../tests/fixtures/historicalHar.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
webNetworkCaptureSchema,
|
||||
type WebNetworkCapture,
|
||||
@@ -129,43 +125,6 @@ it("rejects an adapter omitting its original observed digest before caller proje
|
||||
if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError");
|
||||
});
|
||||
|
||||
it("retains valid completed capture Evidence on late cancellation and preserves invalid output", async () => {
|
||||
const controller = new AbortController();
|
||||
const report = fixture();
|
||||
const late = await new WebNetworkCaptureService({
|
||||
inspect: () => {
|
||||
controller.abort();
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
}).inspect(
|
||||
{ capture_path: report.artifact.path, format: "har" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(late.ok).toBe(false);
|
||||
if (!late.ok) {
|
||||
expect(late.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(parseEvidence(late.error.partialObservation)).toMatchObject({
|
||||
operation: "inspect_web_network_capture",
|
||||
normalized_result: expect.objectContaining({ total_records: 1 }),
|
||||
});
|
||||
}
|
||||
|
||||
const malformed = fixture();
|
||||
malformed.artifact.path = "/other.har";
|
||||
const outputController = new AbortController();
|
||||
const invalid = await new WebNetworkCaptureService({
|
||||
inspect: () => {
|
||||
outputController.abort();
|
||||
return Promise.resolve(ok(malformed));
|
||||
},
|
||||
}).inspect(
|
||||
{ capture_path: "/capture.har", format: "har" },
|
||||
{ signal: outputController.signal },
|
||||
);
|
||||
expect(invalid.ok).toBe(false);
|
||||
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["producer"],
|
||||
["artifact"],
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
captureSensitiveValues,
|
||||
webNetworkCaptureSchema,
|
||||
} from "../domain/webNetworkCapture.js";
|
||||
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "inspect_web_network_capture";
|
||||
|
||||
@@ -75,6 +74,8 @@ export class WebNetworkCaptureService {
|
||||
);
|
||||
const loaded = await this.port.inspect(input, options);
|
||||
if (!loaded.ok) return failure(loaded.error);
|
||||
if (options?.signal?.aborted)
|
||||
return failure(new AnalysisCancelledError(OPERATION));
|
||||
const report = webNetworkCaptureSchema.safeParse(loaded.value);
|
||||
if (!report.success)
|
||||
return failure(
|
||||
@@ -158,55 +159,45 @@ export class WebNetworkCaptureService {
|
||||
record_ordinals: selected,
|
||||
sensitive_value_count: input.sensitive_values.length,
|
||||
});
|
||||
return completeProviderEvidence(
|
||||
ok(
|
||||
createEvidence(
|
||||
safeSha256 === null
|
||||
? undefined
|
||||
: {
|
||||
path: safePath,
|
||||
sha256: safeSha256,
|
||||
format: "file",
|
||||
},
|
||||
value.decoder,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters,
|
||||
result: {
|
||||
...safeValue,
|
||||
artifact: {
|
||||
...value.artifact,
|
||||
path: safePath,
|
||||
sha256: safeSha256,
|
||||
},
|
||||
records,
|
||||
limitations:
|
||||
safeSha256 === null
|
||||
? [...limitations, subjectUnavailableReason]
|
||||
: limitations,
|
||||
return ok(
|
||||
createEvidence(
|
||||
safeSha256 === null
|
||||
? undefined
|
||||
: {
|
||||
path: safePath,
|
||||
sha256: safeSha256,
|
||||
format: "file",
|
||||
},
|
||||
...(safeValue.format !== "qlog"
|
||||
? {}
|
||||
: {
|
||||
analysisProfile: createAnalysisProfile(value.decoder, {
|
||||
qlog_profile: safeValue.qlog.profile,
|
||||
clock: safeValue.qlog.clock,
|
||||
scope: safeValue.qlog.scope,
|
||||
}),
|
||||
}),
|
||||
confidence: "observed",
|
||||
authority: "historical-reference",
|
||||
limitations,
|
||||
...(safeSha256 === null ? { subjectUnavailableReason } : {}),
|
||||
locations:
|
||||
safePath === ""
|
||||
? []
|
||||
: [{ kind: "artifact-path", path: safePath }],
|
||||
value.decoder,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters,
|
||||
result: {
|
||||
...safeValue,
|
||||
artifact: { ...value.artifact, path: safePath, sha256: safeSha256 },
|
||||
records,
|
||||
limitations:
|
||||
safeSha256 === null
|
||||
? [...limitations, subjectUnavailableReason]
|
||||
: limitations,
|
||||
},
|
||||
),
|
||||
...(safeValue.format !== "qlog"
|
||||
? {}
|
||||
: {
|
||||
analysisProfile: createAnalysisProfile(value.decoder, {
|
||||
qlog_profile: safeValue.qlog.profile,
|
||||
clock: safeValue.qlog.clock,
|
||||
scope: safeValue.qlog.scope,
|
||||
}),
|
||||
}),
|
||||
confidence: "observed",
|
||||
authority: "historical-reference",
|
||||
limitations,
|
||||
...(safeSha256 === null ? { subjectUnavailableReason } : {}),
|
||||
locations:
|
||||
safePath === "" ? [] : [{ kind: "artifact-path", path: safePath }],
|
||||
},
|
||||
),
|
||||
OPERATION,
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import type {
|
||||
|
||||
/** Provider-neutral live source attribution operations with distinct instrumentation authority. */
|
||||
export interface WebRuntimePort {
|
||||
/** Release provider-owned transports during command or server shutdown. */
|
||||
close?(): Promise<void>;
|
||||
identity(): ProviderIdentity;
|
||||
observeExecution(
|
||||
input: ObserveWebExecutionInput,
|
||||
|
||||
@@ -2,14 +2,10 @@ import { expect, it } from "vitest";
|
||||
import { WebRuntimeService } from "./WebRuntimeService.js";
|
||||
import {
|
||||
recordingWebRuntimePort,
|
||||
webExecutionFixture,
|
||||
webListenersFixture,
|
||||
} from "./WebRuntimeService.fixture.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
it("rejects malformed input and prior cancellation without calling a provider", async () => {
|
||||
const provider = recordingWebRuntimePort();
|
||||
@@ -63,92 +59,3 @@ it("preserves actual provider failures and rejects changed target/selector ident
|
||||
if (changed.ok) throw new Error("Changed selection must be rejected");
|
||||
expect(changed.error._tag).toBe("AnalysisOutputError");
|
||||
});
|
||||
|
||||
it("converts raw execution retained by provider cleanup failure into partial Evidence", async () => {
|
||||
const args = {
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "selected-page",
|
||||
observation_ms: 5,
|
||||
};
|
||||
const execution = webExecutionFixture(args.target_id, args.observation_ms);
|
||||
const failure = new ProviderCleanupError(
|
||||
"rea-cdp-browser",
|
||||
["selected-page"],
|
||||
{ reason: "target detach failed" },
|
||||
{ operation: "observe_web_execution", partialObservation: execution },
|
||||
);
|
||||
const result = await new WebRuntimeService({
|
||||
identity: () => ({
|
||||
id: "web-runtime",
|
||||
name: "Runtime fixture",
|
||||
version: "1",
|
||||
}),
|
||||
observeExecution: async () => err(failure),
|
||||
inspectEventListeners: async () => err(failure),
|
||||
}).observe(args);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "observe_web_execution",
|
||||
normalized_result: execution,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("retains completed execution Evidence on late abort and keeps invalid output primary", async () => {
|
||||
const input = {
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "selected-page",
|
||||
observation_ms: 5,
|
||||
};
|
||||
const unused = new AnalysisTimeoutError(
|
||||
"inspect_web_event_listeners",
|
||||
20_000,
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const completed = await new WebRuntimeService({
|
||||
identity: () => ({
|
||||
id: "web-runtime",
|
||||
name: "Runtime fixture",
|
||||
version: "1",
|
||||
}),
|
||||
observeExecution: async () => {
|
||||
controller.abort();
|
||||
return ok(webExecutionFixture(input.target_id, input.observation_ms));
|
||||
},
|
||||
inspectEventListeners: async () => err(unused),
|
||||
}).observe(input, { signal: controller.signal });
|
||||
expect(completed.ok).toBe(false);
|
||||
if (!completed.ok)
|
||||
expect(parseEvidence(completed.error.partialObservation)).toMatchObject({
|
||||
operation: "observe_web_execution",
|
||||
normalized_result: webExecutionFixture(
|
||||
input.target_id,
|
||||
input.observation_ms,
|
||||
),
|
||||
});
|
||||
|
||||
const late = new AbortController();
|
||||
const invalid = await new WebRuntimeService({
|
||||
identity: () => ({
|
||||
id: "web-runtime",
|
||||
name: "Runtime fixture",
|
||||
version: "1",
|
||||
}),
|
||||
observeExecution: async () => {
|
||||
late.abort();
|
||||
return ok(webExecutionFixture(input.target_id, input.observation_ms));
|
||||
},
|
||||
inspectEventListeners: async () => err(unused),
|
||||
}).observe(
|
||||
{ ...input, allowed_origins: ["https://allowed.example"] },
|
||||
{ signal: late.signal },
|
||||
);
|
||||
expect(invalid).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
||||
import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
inspectWebEventListenersInputSchema,
|
||||
webEventListenersSchema,
|
||||
} from "../domain/webEventListeners.js";
|
||||
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
|
||||
|
||||
/** Shared public workflows keep instrumentation and listener inspection independently selectable. */
|
||||
export class WebRuntimeService {
|
||||
@@ -38,35 +37,7 @@ export class WebRuntimeService {
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
const result = await this.provider.observeExecution(parsed.value, options);
|
||||
if (!result.ok) {
|
||||
const partial = result.error.partialObservation;
|
||||
if (
|
||||
partial !== undefined &&
|
||||
!("evidence_id" in partial) &&
|
||||
"instrumentation" in partial &&
|
||||
"window" in partial
|
||||
) {
|
||||
const verifiedPartial = webExecutionSchema.safeParse(partial);
|
||||
if (verifiedPartial.success) {
|
||||
const evidence = this.evidence(
|
||||
operation,
|
||||
parsed.value,
|
||||
verifiedPartial.data,
|
||||
);
|
||||
if (evidence.ok) result.error.partialObservation = evidence.value;
|
||||
else if (result.error instanceof AnalysisCancelledError)
|
||||
return evidence;
|
||||
} else if (result.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider retained a malformed completed execution observation.",
|
||||
{ cause: verifiedPartial.error },
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (!result.ok) return result;
|
||||
const verified = webExecutionSchema.safeParse(result.value);
|
||||
if (!verified.success)
|
||||
return err(new AnalysisOutputError(operation, verified.error.message));
|
||||
@@ -100,35 +71,7 @@ export class WebRuntimeService {
|
||||
parsed.value,
|
||||
options,
|
||||
);
|
||||
if (!result.ok) {
|
||||
const partial = result.error.partialObservation;
|
||||
if (
|
||||
partial !== undefined &&
|
||||
!("evidence_id" in partial) &&
|
||||
"selected_node" in partial &&
|
||||
"listeners" in partial
|
||||
) {
|
||||
const verifiedPartial = webEventListenersSchema.safeParse(partial);
|
||||
if (verifiedPartial.success) {
|
||||
const evidence = this.evidence(
|
||||
operation,
|
||||
parsed.value,
|
||||
verifiedPartial.data,
|
||||
);
|
||||
if (evidence.ok) result.error.partialObservation = evidence.value;
|
||||
else if (result.error instanceof AnalysisCancelledError)
|
||||
return evidence;
|
||||
} else if (result.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider retained malformed completed event-listener data.",
|
||||
{ cause: verifiedPartial.error },
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (!result.ok) return result;
|
||||
const verified = webEventListenersSchema.safeParse(result.value);
|
||||
if (!verified.success)
|
||||
return err(new AnalysisOutputError(operation, verified.error.message));
|
||||
@@ -155,6 +98,8 @@ export class WebRuntimeService {
|
||||
| z.infer<typeof webEventListenersSchema>,
|
||||
options?: ExecutionOptions,
|
||||
): Result<Evidence, AnalysisError> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
if (
|
||||
input.allowed_origins.length > 0 &&
|
||||
!input.allowed_origins.includes(result.target.origin)
|
||||
@@ -165,20 +110,16 @@ export class WebRuntimeService {
|
||||
"Provider returned a document outside the selected origin scope.",
|
||||
),
|
||||
);
|
||||
return completeProviderEvidence(
|
||||
ok(
|
||||
createEvidence(undefined, this.provider.identity(), {
|
||||
predicateType: `rea.${operation.replaceAll("_", "-")}`,
|
||||
operation,
|
||||
parameters: jsonObjectSchema.parse(input),
|
||||
result,
|
||||
confidence: "observed",
|
||||
authority: "external-service",
|
||||
limitations: result.limitations,
|
||||
}),
|
||||
),
|
||||
operation,
|
||||
options,
|
||||
return ok(
|
||||
createEvidence(undefined, this.provider.identity(), {
|
||||
predicateType: `rea.${operation.replaceAll("_", "-")}`,
|
||||
operation,
|
||||
parameters: jsonObjectSchema.parse(input),
|
||||
result: jsonValueSchema.parse(result),
|
||||
confidence: "observed",
|
||||
authority: "external-service",
|
||||
limitations: result.limitations,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,6 @@ import {
|
||||
webSourceMapDecoderFixture as decoder,
|
||||
} from "../../tests/fixtures/webSourceLocation.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { webSourceLocationResultSchema } from "../domain/webSourceLocation.js";
|
||||
import { WebSourceLocationService } from "./WebSourceLocationService.js";
|
||||
@@ -164,42 +159,5 @@ it("honors cancellation both before acquisition and across decoder completion",
|
||||
},
|
||||
}).trace(args, { signal: controller.signal });
|
||||
if (late.ok) throw new Error("expected cancellation");
|
||||
expect(late.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(parseEvidence(late.error.partialObservation)).toMatchObject({
|
||||
operation: "trace_web_source_location",
|
||||
confidence: "derived",
|
||||
});
|
||||
|
||||
const malformedDecoder: WebSourceMapPort = {
|
||||
trace: async (input, options) => {
|
||||
const decoded = await decoder.trace(input, options);
|
||||
return decoded.ok
|
||||
? ok({ ...decoded.value, source_map_sha256: "b".repeat(64) })
|
||||
: decoded;
|
||||
},
|
||||
};
|
||||
const malformed = await new WebSourceLocationService(
|
||||
{ load: () => Promise.resolve(ok(webSourceLocationFixture())) },
|
||||
malformedDecoder,
|
||||
).trace(args);
|
||||
expect(malformed.ok).toBe(false);
|
||||
if (!malformed.ok)
|
||||
expect(malformed.error).toBeInstanceOf(AnalysisOutputError);
|
||||
|
||||
const invalidController = new AbortController();
|
||||
const malformedLate = await new WebSourceLocationService(
|
||||
{ load: () => Promise.resolve(ok(webSourceLocationFixture())) },
|
||||
{
|
||||
trace: async (input, options) => {
|
||||
const decoded = await decoder.trace(input, options);
|
||||
invalidController.abort();
|
||||
return decoded.ok
|
||||
? ok({ ...decoded.value, source_map_sha256: "b".repeat(64) })
|
||||
: decoded;
|
||||
},
|
||||
},
|
||||
).trace(args, { signal: invalidController.signal });
|
||||
expect(malformedLate.ok).toBe(false);
|
||||
if (!malformedLate.ok)
|
||||
expect(malformedLate.error).toBeInstanceOf(AnalysisOutputError);
|
||||
expect(late.error._tag).toBe("AnalysisCancelledError");
|
||||
});
|
||||
@@ -34,7 +34,6 @@ import type {
|
||||
WebSourceLocationArtifactPort,
|
||||
WebSourceMapPort,
|
||||
} from "./WebSourceLocationPorts.js";
|
||||
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "trace_web_source_location";
|
||||
const artifactsSchema = selectedWebScriptArtifactsSchema.extend({
|
||||
@@ -127,6 +126,8 @@ export class WebSourceLocationService {
|
||||
if (!traced.ok) return traced;
|
||||
const report = verifyReport(input, map.file.sha256, traced.value);
|
||||
if (!report.ok) return report;
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const limitations = [
|
||||
...LIMITATIONS,
|
||||
codecBudgetLimitation(report.value.runtime),
|
||||
@@ -146,41 +147,37 @@ export class WebSourceLocationService {
|
||||
limitations,
|
||||
});
|
||||
if (!normalized.success) return invalidOutput(normalized.error.message);
|
||||
return completeProviderEvidence(
|
||||
ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: data.sourceFile.path,
|
||||
sha256: data.sourceFile.sha256,
|
||||
format: "file",
|
||||
},
|
||||
{
|
||||
id: "rea-web-source-location",
|
||||
name: "REA captured source-map point trace",
|
||||
version: "1",
|
||||
},
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: jsonObjectSchema.parse(input),
|
||||
result: normalized.data,
|
||||
rawResult: { source_map_text: map.text },
|
||||
confidence: "derived",
|
||||
authority: "historical-reference",
|
||||
limitations,
|
||||
locations: [
|
||||
data.sourceFile.path,
|
||||
data.manifestFile.path,
|
||||
map.file.path,
|
||||
].map((path) => ({ kind: "artifact-path", path })),
|
||||
evidenceLinks:
|
||||
data.manifest.source_evidence_id === null
|
||||
? []
|
||||
: [data.manifest.source_evidence_id],
|
||||
},
|
||||
),
|
||||
return ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: data.sourceFile.path,
|
||||
sha256: data.sourceFile.sha256,
|
||||
format: "file",
|
||||
},
|
||||
{
|
||||
id: "rea-web-source-location",
|
||||
name: "REA captured source-map point trace",
|
||||
version: "1",
|
||||
},
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: jsonObjectSchema.parse(input),
|
||||
result: normalized.data,
|
||||
rawResult: { source_map_text: map.text },
|
||||
confidence: "derived",
|
||||
authority: "historical-reference",
|
||||
limitations,
|
||||
locations: [
|
||||
data.sourceFile.path,
|
||||
data.manifestFile.path,
|
||||
map.file.path,
|
||||
].map((path) => ({ kind: "artifact-path", path })),
|
||||
evidenceLinks:
|
||||
data.manifest.source_evidence_id === null
|
||||
? []
|
||||
: [data.manifest.source_evidence_id],
|
||||
},
|
||||
),
|
||||
OPERATION,
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,9 +224,6 @@ const createAnalysisViewEvidence = (
|
||||
: "derived",
|
||||
authority: parent.authority,
|
||||
limitations: result.limitations,
|
||||
...(result.kind === "native" && parent.relationship_coverage !== null
|
||||
? { relationshipCoverage: parent.relationship_coverage }
|
||||
: {}),
|
||||
locations: [
|
||||
...(result.artifact.path.length > 0
|
||||
? [{ kind: "artifact-path" as const, path: result.artifact.path }]
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"
|
||||
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import {
|
||||
ProviderSelectionError,
|
||||
type ProviderSelectionFailureReason,
|
||||
@@ -136,35 +135,6 @@ export class AnalysisProviderRegistry {
|
||||
);
|
||||
}
|
||||
|
||||
/** Report retained candidate resources without retrying cleanup. */
|
||||
hasPendingOwnedResources(): boolean {
|
||||
return this.#providers.some(
|
||||
(provider) => provider.hasPendingOwnedResources?.() === true,
|
||||
);
|
||||
}
|
||||
|
||||
/** Close candidate-owned resources even when no target client was opened. */
|
||||
async closeOwnedResources(): Promise<Result<null, AnalysisError>> {
|
||||
const failures: AnalysisError[] = [];
|
||||
for (const provider of this.#providers) {
|
||||
if (provider.closeOwnedResources === undefined) continue;
|
||||
const result = await provider.closeOwnedResources();
|
||||
if (!result.ok) failures.push(result.error);
|
||||
}
|
||||
if (failures.length === 0) return ok(null);
|
||||
if (failures.length === 1) return err(failures[0]!);
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
"rea",
|
||||
failures.flatMap((failure) => failure.cleanupResources),
|
||||
{
|
||||
reason: failures.map((failure) => failure.message).join("; "),
|
||||
},
|
||||
{ cause: new AggregateError(failures) },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** List the unique deep-operation declarations without probing candidates. */
|
||||
declaredOperations(): readonly string[] {
|
||||
return [...this.#declaredOperations];
|
||||
|
||||
@@ -55,15 +55,12 @@ const ENHANCED_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
ENHANCED_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
);
|
||||
|
||||
interface SessionRun {
|
||||
interface SessionBinding {
|
||||
readonly target: BinaryTarget;
|
||||
readonly client: AnalysisClient;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly route: SessionProviderRoute;
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
interface SessionBinding extends SessionRun {
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
nativeSourceAdmitted: boolean;
|
||||
}
|
||||
|
||||
@@ -82,7 +79,8 @@ export class BinarySession
|
||||
#externalMutationCleanupFailure: AnalysisError | undefined;
|
||||
#pendingCleanup:
|
||||
| {
|
||||
readonly run: SessionRun;
|
||||
readonly client: AnalysisClient;
|
||||
readonly providerId: string;
|
||||
readonly retainDocument: boolean;
|
||||
}
|
||||
| undefined;
|
||||
@@ -320,7 +318,10 @@ export class BinarySession
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
if (previous !== undefined) {
|
||||
const closed = await this.#retireClient(previous);
|
||||
const closed = await this.#retireClient(
|
||||
previous.client,
|
||||
previous.route.identity.id,
|
||||
);
|
||||
if (!closed.ok) {
|
||||
// A failed open retires the provider cache, not the investigation.
|
||||
// Preserve Evidence and Unknown history for export and recovery.
|
||||
@@ -338,15 +339,22 @@ export class BinarySession
|
||||
);
|
||||
const runId = randomUUID();
|
||||
const client = route.createClient(target, { runId });
|
||||
const run: SessionRun = { client, route, runId };
|
||||
const started = await client.execute("health", {}, options);
|
||||
if (!started.ok) {
|
||||
return err(await this.#failedOpen(run, previous, started.error));
|
||||
return err(
|
||||
await this.#failedOpen(
|
||||
client,
|
||||
route.identity.id,
|
||||
previous,
|
||||
started.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isAborted(options.signal)) {
|
||||
return err(
|
||||
await this.#failedOpen(
|
||||
run,
|
||||
client,
|
||||
route.identity.id,
|
||||
previous,
|
||||
new AnalysisCancelledError("open_binary"),
|
||||
),
|
||||
@@ -354,8 +362,10 @@ export class BinarySession
|
||||
}
|
||||
this.#active = {
|
||||
target,
|
||||
client,
|
||||
profile,
|
||||
...run,
|
||||
route,
|
||||
runId,
|
||||
nativeSourceAdmitted: false,
|
||||
};
|
||||
this.#clearRuntimeAvailability();
|
||||
@@ -368,7 +378,14 @@ export class BinarySession
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) {
|
||||
this.#active = undefined;
|
||||
return err(await this.#failedOpen(run, previous, imported.error));
|
||||
return err(
|
||||
await this.#failedOpen(
|
||||
client,
|
||||
route.identity.id,
|
||||
previous,
|
||||
imported.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return ok(target);
|
||||
@@ -488,47 +505,42 @@ export class BinarySession
|
||||
options.progress,
|
||||
options.retainProviderDocuments,
|
||||
)
|
||||
: await this.#retireClient(previous, {
|
||||
...(options.progress === undefined
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
...(options.retainProviderDocuments === true
|
||||
? { retainDocument: true }
|
||||
: {}),
|
||||
});
|
||||
: await this.#retireClient(
|
||||
previous.client,
|
||||
previous.route.identity.id,
|
||||
{
|
||||
...(options.progress === undefined
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
...(options.retainProviderDocuments === true
|
||||
? { retainDocument: true }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
if (clearRecords) this.clearSessionRecords();
|
||||
this.#clearRuntimeAvailability();
|
||||
const fileResources = await this.closeWriteResources();
|
||||
const candidates = await this.#providerRouter.closeOwnedResources();
|
||||
let lifecycle: Result<null, AnalysisError>;
|
||||
if (!closed.ok && !fileResources.ok)
|
||||
lifecycle = err(
|
||||
return err(
|
||||
analysisErrorWithCleanupFailure(
|
||||
closed.error,
|
||||
fileResources.error,
|
||||
"close_binary",
|
||||
),
|
||||
);
|
||||
else lifecycle = closed.ok ? fileResources : closed;
|
||||
if (!lifecycle.ok && !candidates.ok)
|
||||
return err(
|
||||
analysisErrorWithCleanupFailure(
|
||||
lifecycle.error,
|
||||
candidates.error,
|
||||
"close_binary",
|
||||
),
|
||||
);
|
||||
return lifecycle.ok ? candidates : lifecycle;
|
||||
return closed.ok ? fileResources : closed;
|
||||
}
|
||||
|
||||
async #retireClient(
|
||||
run: SessionRun,
|
||||
client: AnalysisClient,
|
||||
providerId: string,
|
||||
options: Pick<ExecutionOptions, "progress"> & {
|
||||
readonly retainDocument?: boolean;
|
||||
} = {},
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
this.#pendingCleanup = {
|
||||
run,
|
||||
client,
|
||||
providerId,
|
||||
retainDocument: options.retainDocument === true,
|
||||
};
|
||||
return this.#retryPendingCleanup(options.progress);
|
||||
@@ -545,8 +557,8 @@ export class BinarySession
|
||||
this.#pendingCleanup = pending;
|
||||
}
|
||||
const closed = await closeAnalysisClient(
|
||||
pending.run.client,
|
||||
pending.run.route.identity.id,
|
||||
pending.client,
|
||||
pending.providerId,
|
||||
{
|
||||
...(progress === undefined ? {} : { progress }),
|
||||
...(pending.retainDocument ? { retainDocument: true } : {}),
|
||||
@@ -557,11 +569,12 @@ export class BinarySession
|
||||
}
|
||||
|
||||
async #failedOpen(
|
||||
run: SessionRun,
|
||||
client: AnalysisClient,
|
||||
providerId: string,
|
||||
previous: SessionBinding | undefined,
|
||||
primary: AnalysisError,
|
||||
): Promise<AnalysisError> {
|
||||
const closed = await this.#retireClient(run);
|
||||
const closed = await this.#retireClient(client, providerId);
|
||||
if (!closed.ok)
|
||||
return analysisErrorWithCleanupFailure(
|
||||
primary,
|
||||
@@ -583,21 +596,17 @@ export class BinarySession
|
||||
|
||||
/** Describe the active binary session. */
|
||||
status(): JsonValue {
|
||||
const run = this.#active ?? this.#pendingCleanup?.run;
|
||||
const client = run?.client;
|
||||
const client = this.#active?.client ?? this.#pendingCleanup?.client;
|
||||
return binarySessionStatus({
|
||||
target: this.#active?.target,
|
||||
route: this.#currentRoute(),
|
||||
router: this.#providerRouter,
|
||||
runtimeUnavailability: this.#runtimeUnavailability,
|
||||
runId: run?.runId,
|
||||
runId: this.#active?.runId,
|
||||
runtimeLineageSnapshots: client?.runtimeLineageSnapshots?.() ?? [],
|
||||
requestActivitySnapshots: client?.requestActivitySnapshots?.() ?? [],
|
||||
providerOperationHealth: client?.operationHealthSnapshot?.() ?? null,
|
||||
cleanupPending:
|
||||
this.#pendingCleanup !== undefined ||
|
||||
this.hasPendingWriteResources ||
|
||||
this.#providerRouter.hasPendingOwnedResources(),
|
||||
cleanupPending: this.#pendingCleanup !== undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -933,19 +942,20 @@ export class BinarySession
|
||||
if (previous === undefined) return ok(null);
|
||||
const runId = randomUUID();
|
||||
const client = previous.route.createClient(previous.target, { runId });
|
||||
const run: SessionRun = { client, route: previous.route, runId };
|
||||
const started = await client.execute("health", {});
|
||||
if (started.ok) {
|
||||
this.#active = {
|
||||
target: previous.target,
|
||||
client,
|
||||
profile: previous.profile,
|
||||
...run,
|
||||
route: previous.route,
|
||||
runId,
|
||||
nativeSourceAdmitted: false,
|
||||
};
|
||||
this.#clearRuntimeAvailability();
|
||||
return ok(null);
|
||||
}
|
||||
const closed = await this.#retireClient(run);
|
||||
const closed = await this.#retireClient(client, previous.route.identity.id);
|
||||
return closed.ok
|
||||
? ok(null)
|
||||
: err(
|
||||
|
||||
@@ -63,11 +63,6 @@ export abstract class BinarySessionRecords {
|
||||
this.#records = records;
|
||||
}
|
||||
|
||||
/** Snapshot staging retained after a failed resource cleanup attempt. */
|
||||
protected get hasPendingWriteResources(): boolean {
|
||||
return this.#writeResources.hasPending;
|
||||
}
|
||||
|
||||
/** Observe changes to the mutable current analysis snapshot resource. */
|
||||
onAnalysisSnapshotChanged(listener: () => void | Promise<void>): () => void {
|
||||
this.#snapshotListeners.add(listener);
|
||||
|
||||
@@ -70,16 +70,6 @@ export class SessionProviderRouter {
|
||||
return this.route;
|
||||
}
|
||||
|
||||
/** Close provider owners not represented by the currently bound client. */
|
||||
closeOwnedResources() {
|
||||
return this.registry.closeOwnedResources();
|
||||
}
|
||||
|
||||
/** Report retained candidate cleanup without performing cleanup work. */
|
||||
hasPendingOwnedResources(): boolean {
|
||||
return this.registry.hasPendingOwnedResources();
|
||||
}
|
||||
|
||||
/** Resolve target routes before any provider client is created. */
|
||||
async resolve(
|
||||
target: BinaryTarget,
|
||||
|
||||
@@ -76,23 +76,18 @@ it("cancels before invocation and after an acquired observation", async () => {
|
||||
return Promise.resolve(ok(binaryLayoutFixture()));
|
||||
},
|
||||
});
|
||||
const late = await service.inspect(
|
||||
{ path: "/artifacts/source-owned.elf" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(late).toMatchObject({
|
||||
expect(
|
||||
await service.inspect(
|
||||
{ path: "/artifacts/source-owned.elf" },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
capturedOutput: binaryLayoutFixture().diagnostics,
|
||||
},
|
||||
});
|
||||
if (!late.ok) {
|
||||
expect(parseEvidence(late.error.partialObservation)).toMatchObject({
|
||||
operation: "inspect_binary_layout",
|
||||
normalized_result: binaryLayoutFixture(),
|
||||
});
|
||||
}
|
||||
const untouched = new BinaryLayoutService({
|
||||
inspectAndClose(input, options) {
|
||||
return this.inspect(input, options);
|
||||
@@ -147,23 +142,5 @@ it.each(["different-path", "invalid-digest", "outside-range"])(
|
||||
capturedOutput: fixture.diagnostics,
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const lateInvalid = await new BinaryLayoutService({
|
||||
inspectAndClose(input, options) {
|
||||
return this.inspect(input, options);
|
||||
},
|
||||
identity: BINARY_LAYOUT_TEST_PROVIDER,
|
||||
inspect: () => {
|
||||
controller.abort();
|
||||
return Promise.resolve(ok(fixture));
|
||||
},
|
||||
}).inspect(
|
||||
{ path: "/artifacts/source-owned.elf" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(lateInvalid).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "../../domain/native/binaryLayout.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "inspect_binary_layout";
|
||||
|
||||
@@ -80,6 +79,8 @@ export class BinaryLayoutService {
|
||||
const outputOptions = diagnostics.success
|
||||
? { capturedOutput: diagnostics.data }
|
||||
: undefined;
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION, outputOptions));
|
||||
const report = binaryLayoutSchema.safeParse(inspected.value);
|
||||
if (!report.success || report.data.artifact.path !== input.data.path)
|
||||
return err(
|
||||
@@ -90,31 +91,26 @@ export class BinaryLayoutService {
|
||||
),
|
||||
);
|
||||
const value = report.data;
|
||||
return completeProviderEvidence(
|
||||
ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
sha256: value.artifact.sha256,
|
||||
},
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
result: value,
|
||||
// The decoded report is the only representation; do not repeat it.
|
||||
rawResult: null,
|
||||
confidence: "observed",
|
||||
limitations: value.limitations,
|
||||
locations: [{ kind: "artifact-path", path: value.artifact.path }],
|
||||
},
|
||||
),
|
||||
return ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
sha256: value.artifact.sha256,
|
||||
},
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
result: value,
|
||||
// The decoded report is the only representation; do not repeat it.
|
||||
rawResult: null,
|
||||
confidence: "observed",
|
||||
limitations: value.limitations,
|
||||
locations: [{ kind: "artifact-path", path: value.artifact.path }],
|
||||
},
|
||||
),
|
||||
OPERATION,
|
||||
options,
|
||||
outputOptions,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,6 @@ import { inspectPeResourcesInputSchema } from "../../domain/native/peResources.j
|
||||
import { parsePeResources } from "../../native/pe/PeResourceParser.js";
|
||||
import { peResourceFixture } from "../../native/pe/PeResources.fixture.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
|
||||
const observedReport = () => {
|
||||
const { bytes } = peResourceFixture();
|
||||
@@ -50,7 +45,7 @@ it.each([
|
||||
},
|
||||
);
|
||||
|
||||
it("retains a completed observation on late cancellation and keeps output errors primary", async () => {
|
||||
it("discards an observation if the caller cancels while the provider completes", async () => {
|
||||
const report = await observedReport();
|
||||
const controller = new AbortController();
|
||||
const service = new PeResourcesService({
|
||||
@@ -60,34 +55,10 @@ it("retains a completed observation on late cancellation and keeps output errors
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
});
|
||||
const late = await service.inspect(
|
||||
{ path: report.artifact.path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(late).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
if (!late.ok) {
|
||||
expect(parseEvidence(late.error.partialObservation)).toMatchObject({
|
||||
operation: "inspect_pe_resources",
|
||||
normalized_result: report,
|
||||
});
|
||||
}
|
||||
|
||||
const malformed = await observedReport();
|
||||
malformed.artifact.path = "/other.exe";
|
||||
const outputController = new AbortController();
|
||||
const invalid = await new PeResourcesService({
|
||||
identity: PE_RESOURCES_PROVIDER,
|
||||
inspect: () => {
|
||||
outputController.abort();
|
||||
return Promise.resolve(ok(malformed));
|
||||
},
|
||||
}).inspect(
|
||||
{ path: "/fixtures/example.exe" },
|
||||
{ signal: outputController.signal },
|
||||
);
|
||||
expect(invalid.ok).toBe(false);
|
||||
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
|
||||
expect(
|
||||
await service.inspect(
|
||||
{ path: report.artifact.path },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
||||
});
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "../../domain/native/peResources.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */
|
||||
export class PeResourcesService {
|
||||
@@ -52,6 +51,8 @@ export class PeResourcesService {
|
||||
);
|
||||
const result = await this.provider.inspect(input.data, options);
|
||||
if (!result.ok) return result;
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const report = peResourcesSchema.safeParse(result.value);
|
||||
if (
|
||||
!report.success ||
|
||||
@@ -69,27 +70,23 @@ export class PeResourcesService {
|
||||
),
|
||||
);
|
||||
const value = report.data;
|
||||
return completeProviderEvidence(
|
||||
ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
format: "pe",
|
||||
sha256: value.artifact.sha256,
|
||||
},
|
||||
this.provider.identity,
|
||||
{
|
||||
operation,
|
||||
parameters: input.data,
|
||||
result: value,
|
||||
confidence: "observed",
|
||||
limitations: value.limitations,
|
||||
locations: [{ kind: "artifact-path", path: value.artifact.path }],
|
||||
},
|
||||
),
|
||||
return ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
format: "pe",
|
||||
sha256: value.artifact.sha256,
|
||||
},
|
||||
this.provider.identity,
|
||||
{
|
||||
operation,
|
||||
parameters: input.data,
|
||||
result: value,
|
||||
confidence: "observed",
|
||||
limitations: value.limitations,
|
||||
locations: [{ kind: "artifact-path", path: value.artifact.path }],
|
||||
},
|
||||
),
|
||||
operation,
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,23 +105,18 @@ it("cancels before invocation and after an acquired observation", async () => {
|
||||
return Promise.resolve(ok(recordedCrashFixture()));
|
||||
},
|
||||
});
|
||||
const late = await service.inspect(
|
||||
{ path: "/artifacts/source-owned.core" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(late).toMatchObject({
|
||||
expect(
|
||||
await service.inspect(
|
||||
{ path: "/artifacts/source-owned.core" },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
capturedOutput: recordedCrashFixture().diagnostics,
|
||||
},
|
||||
});
|
||||
if (!late.ok) {
|
||||
expect(parseEvidence(late.error.partialObservation)).toMatchObject({
|
||||
operation: "inspect_recorded_crash",
|
||||
normalized_result: recordedCrashFixture(),
|
||||
});
|
||||
}
|
||||
const untouched = new RecordedCrashService({
|
||||
inspectAndClose(input, options) {
|
||||
return this.inspect(input, options);
|
||||
@@ -175,23 +170,5 @@ it.each(["different-path", "invalid-digest", "outside-range"])(
|
||||
capturedOutput: fixture.diagnostics,
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const lateInvalid = await new RecordedCrashService({
|
||||
inspectAndClose(input, options) {
|
||||
return this.inspect(input, options);
|
||||
},
|
||||
identity: RECORDED_CRASH_TEST_PROVIDER,
|
||||
inspect: () => {
|
||||
controller.abort();
|
||||
return Promise.resolve(ok(fixture));
|
||||
},
|
||||
}).inspect(
|
||||
{ path: "/artifacts/source-owned.core" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(lateInvalid).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "../../domain/native/recordedCrash.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "inspect_recorded_crash";
|
||||
|
||||
@@ -81,6 +80,8 @@ export class RecordedCrashService {
|
||||
const outputOptions = diagnostics.success
|
||||
? { capturedOutput: diagnostics.data }
|
||||
: undefined;
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION, outputOptions));
|
||||
const report = recordedCrashSchema.safeParse(inspected.value);
|
||||
if (
|
||||
!report.success ||
|
||||
@@ -96,34 +97,29 @@ export class RecordedCrashService {
|
||||
),
|
||||
);
|
||||
const value = report.data;
|
||||
return completeProviderEvidence(
|
||||
ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
format: "elf",
|
||||
architecture:
|
||||
RECORDED_LINUX_CORE_PROFILES[value.architecture]
|
||||
.evidenceArchitecture,
|
||||
sha256: value.artifact.sha256,
|
||||
},
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
result: value,
|
||||
// The decoded report is the only representation; do not repeat it.
|
||||
rawResult: null,
|
||||
confidence:
|
||||
value.debugger.status === "available" ? "derived" : "observed",
|
||||
limitations: value.limitations,
|
||||
locations: [{ kind: "artifact-path", path: value.artifact.path }],
|
||||
},
|
||||
),
|
||||
return ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
format: "elf",
|
||||
architecture:
|
||||
RECORDED_LINUX_CORE_PROFILES[value.architecture]
|
||||
.evidenceArchitecture,
|
||||
sha256: value.artifact.sha256,
|
||||
},
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
result: value,
|
||||
// The decoded report is the only representation; do not repeat it.
|
||||
rawResult: null,
|
||||
confidence:
|
||||
value.debugger.status === "available" ? "derived" : "observed",
|
||||
limitations: value.limitations,
|
||||
locations: [{ kind: "artifact-path", path: value.artifact.path }],
|
||||
},
|
||||
),
|
||||
OPERATION,
|
||||
options,
|
||||
outputOptions,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
|
||||
/** Attempt every web workflow cleanup owner and report all failures. */
|
||||
export const closeOwnedWebResources = async (
|
||||
close: readonly ((() => Promise<void>) | undefined)[],
|
||||
@@ -13,55 +9,7 @@ export const closeOwnedWebResources = async (
|
||||
),
|
||||
);
|
||||
const failures = results.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason as unknown] : [],
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
);
|
||||
if (failures.length === 1) {
|
||||
const [failure] = failures;
|
||||
if (failure instanceof AnalysisError) throw failure;
|
||||
throw new ProviderCleanupError(
|
||||
"web-workflow",
|
||||
[],
|
||||
{ reason: failure instanceof Error ? failure.message : String(failure) },
|
||||
{ cause: failure, operation: "close_web_workflow" },
|
||||
);
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
const normalized = failures.map((cause) => ({
|
||||
cause,
|
||||
projection:
|
||||
cause instanceof AnalysisError
|
||||
? projectAnalysisError(cause)
|
||||
: undefined,
|
||||
resources: cause instanceof AnalysisError ? cause.cleanupResources : [],
|
||||
}));
|
||||
const recoveryActions = [
|
||||
...new Set(
|
||||
normalized.flatMap(({ projection }) =>
|
||||
projection === undefined ? [] : [projection.remediation.action],
|
||||
),
|
||||
),
|
||||
];
|
||||
throw new ProviderCleanupError(
|
||||
"web-workflow",
|
||||
[...new Set(normalized.flatMap(({ resources }) => resources))],
|
||||
{
|
||||
failures: normalized.map(
|
||||
({ cause, projection }) =>
|
||||
projection ?? {
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
cause: new AggregateError(
|
||||
normalized.map(({ cause }) => cause),
|
||||
message,
|
||||
),
|
||||
operation: "close_web_workflow",
|
||||
...(recoveryActions.length === 0
|
||||
? {}
|
||||
: { remediationAction: recoveryActions.join("; ") }),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (failures.length > 0) throw new AggregateError(failures, message);
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
import type { ElectronFuseReport } from "../../domain/electron/electronFuses.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
import { ElectronFuseService } from "./ElectronFuseService.js";
|
||||
|
||||
const identity = { id: "fuse-fixture", name: "Fixture", version: "1" };
|
||||
const report: ElectronFuseReport = {
|
||||
artifact: {
|
||||
selected_path: "/artifacts/app",
|
||||
resolved_path: "/artifacts/app",
|
||||
sha256: "a".repeat(64),
|
||||
bytes: 0,
|
||||
format: "elf",
|
||||
},
|
||||
slices: [
|
||||
{
|
||||
index: 0,
|
||||
file_offset: 0,
|
||||
size: 0,
|
||||
architecture: null,
|
||||
sentinel_count: 0,
|
||||
sentinels: [],
|
||||
},
|
||||
],
|
||||
runtime_implications: [],
|
||||
limitations: ["source-owned fixture"],
|
||||
};
|
||||
|
||||
it("retains completed fuse Evidence when the provider completes after abort", async () => {
|
||||
const controller = new AbortController();
|
||||
const result = await new ElectronFuseService({
|
||||
identity,
|
||||
inspect: async () => {
|
||||
controller.abort();
|
||||
return ok(report);
|
||||
},
|
||||
}).inspect(
|
||||
{ path: report.artifact.selected_path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "inspect_electron_fuses",
|
||||
normalized_result: report,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves malformed fuse output ahead of late cancellation", async () => {
|
||||
const malformed = {
|
||||
...report,
|
||||
artifact: { ...report.artifact, selected_path: "/artifacts/other" },
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const invalid = await new ElectronFuseService({
|
||||
identity,
|
||||
inspect: async () => {
|
||||
controller.abort();
|
||||
return ok(malformed);
|
||||
},
|
||||
}).inspect(
|
||||
{ path: report.artifact.selected_path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(invalid.ok).toBe(false);
|
||||
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "inspect_electron_fuses";
|
||||
|
||||
@@ -50,6 +49,8 @@ export class ElectronFuseService {
|
||||
);
|
||||
const inspected = await this.provider.inspect(input.data, options);
|
||||
if (!inspected.ok) return inspected;
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const report = electronFuseReportSchema.safeParse(inspected.value);
|
||||
if (
|
||||
!report.success ||
|
||||
@@ -63,28 +64,24 @@ export class ElectronFuseService {
|
||||
);
|
||||
const value = report.data;
|
||||
const architecture = subjectArchitecture(value);
|
||||
return completeProviderEvidence(
|
||||
ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.resolved_path,
|
||||
sha256: value.artifact.sha256,
|
||||
format: value.artifact.format,
|
||||
...(architecture === undefined ? {} : { architecture }),
|
||||
},
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
result: value,
|
||||
confidence: "observed",
|
||||
limitations: value.limitations,
|
||||
locations: locationsFor(value),
|
||||
},
|
||||
),
|
||||
return ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.resolved_path,
|
||||
sha256: value.artifact.sha256,
|
||||
format: value.artifact.format,
|
||||
...(architecture === undefined ? {} : { architecture }),
|
||||
},
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
result: value,
|
||||
confidence: "observed",
|
||||
limitations: value.limitations,
|
||||
locations: locationsFor(value),
|
||||
},
|
||||
),
|
||||
OPERATION,
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,7 @@ import {
|
||||
evmInterfaceFixture,
|
||||
} from "../../../tests/fixtures/evm/interface.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
||||
|
||||
it.each([
|
||||
{ path: "relative.hex", encoding: "hex" },
|
||||
@@ -88,24 +83,18 @@ it("preserves meaningful provider failure and cancellation before/after executio
|
||||
return Promise.resolve(ok(evmInterfaceExecution(observed)));
|
||||
},
|
||||
});
|
||||
const late = await cancelled.inspect(
|
||||
{ path: "/artifacts/source-owned.hex", encoding: "hex" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(late).toMatchObject({
|
||||
expect(
|
||||
await cancelled.inspect(
|
||||
{ path: "/artifacts/source-owned.hex", encoding: "hex" },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
capturedOutput: observed.diagnostics,
|
||||
},
|
||||
});
|
||||
if (!late.ok) {
|
||||
expect(late.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(parseEvidence(late.error.partialObservation)).toMatchObject({
|
||||
operation: "inspect_evm_interface",
|
||||
normalized_result: observed,
|
||||
});
|
||||
}
|
||||
const untouched = new EvmInterfaceService({
|
||||
inspect: () => {
|
||||
throw new Error("must not start");
|
||||
@@ -118,20 +107,3 @@ it("preserves meaningful provider failure and cancellation before/after executio
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
||||
});
|
||||
|
||||
it("keeps malformed interface output ahead of a late cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
const malformed = evmInterfaceFixture();
|
||||
malformed.artifact.path = "/artifacts/other.hex";
|
||||
const result = await new EvmInterfaceService({
|
||||
inspect: () => {
|
||||
controller.abort();
|
||||
return Promise.resolve(ok(evmInterfaceExecution(malformed)));
|
||||
},
|
||||
}).inspect(
|
||||
{ path: "/artifacts/source-owned.hex", encoding: "hex" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "../../domain/evm/evmInterface.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "inspect_evm_interface";
|
||||
const diagnosticsSchema = z.object({
|
||||
@@ -60,6 +59,8 @@ export class EvmInterfaceService {
|
||||
const outputOptions = diagnostics.success
|
||||
? { capturedOutput: diagnostics.data.diagnostics }
|
||||
: undefined;
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION, outputOptions));
|
||||
const execution = inspected.value;
|
||||
const report = evmInterfaceSchema.safeParse(execution.result);
|
||||
if (
|
||||
@@ -77,23 +78,18 @@ export class EvmInterfaceService {
|
||||
outputOptions,
|
||||
),
|
||||
);
|
||||
return completeProviderEvidence(
|
||||
ok(
|
||||
createEvidence(execution.subject, execution.provider, {
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
result: report.data,
|
||||
...(execution.rawResult === null
|
||||
? {}
|
||||
: { rawResult: execution.rawResult }),
|
||||
confidence: "inferred",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
}),
|
||||
),
|
||||
OPERATION,
|
||||
options,
|
||||
outputOptions,
|
||||
return ok(
|
||||
createEvidence(execution.subject, execution.provider, {
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
result: report.data,
|
||||
...(execution.rawResult === null
|
||||
? {}
|
||||
: { rawResult: execution.rawResult }),
|
||||
confidence: "inferred",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { GoBinary } from "../../domain/go/goBinary.js";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
|
||||
const identity = {
|
||||
@@ -167,13 +166,8 @@ it("honors cancellation before reading and after a completed observation", async
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(completed.ok).toBe(false);
|
||||
if (!completed.ok) {
|
||||
if (!completed.ok)
|
||||
expect(completed.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(parseEvidence(completed.error.partialObservation)).toMatchObject({
|
||||
operation: "inspect_go_binary",
|
||||
normalized_result: report,
|
||||
});
|
||||
}
|
||||
const before = await service.inspect(
|
||||
{ path: report.artifact.path },
|
||||
{ signal: controller.signal },
|
||||
@@ -182,20 +176,3 @@ it("honors cancellation before reading and after a completed observation", async
|
||||
if (!before.ok) expect(before.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps malformed provider output ahead of a late cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
const malformed = {
|
||||
...report,
|
||||
artifact: { ...report.artifact, path: "/other" },
|
||||
};
|
||||
const result = await new GoBinaryService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
controller.abort();
|
||||
return Promise.resolve(ok(malformed));
|
||||
},
|
||||
}).inspect({ path: report.artifact.path }, { signal: controller.signal });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "../../domain/go/goBinary.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "inspect_go_binary";
|
||||
const architectureForSubject = (architecture: string) => {
|
||||
@@ -65,6 +64,8 @@ export class GoBinaryService {
|
||||
);
|
||||
const inspected = await this.provider.inspect(input.data, options);
|
||||
if (!inspected.ok) return inspected;
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const report = goBinarySchema.safeParse(inspected.value);
|
||||
if (!report.success || report.data.artifact.path !== input.data.path)
|
||||
return err(
|
||||
@@ -75,39 +76,35 @@ export class GoBinaryService {
|
||||
);
|
||||
const value = report.data;
|
||||
const architecture = architectureForSubject(value.architecture);
|
||||
return completeProviderEvidence(
|
||||
ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
sha256: value.artifact.sha256,
|
||||
format: value.format === "macho" ? "mach-o" : value.format,
|
||||
...(architecture === undefined ? {} : { architecture }),
|
||||
},
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
result: value,
|
||||
confidence: "observed",
|
||||
limitations: value.limitations,
|
||||
locations: [
|
||||
{ kind: "artifact-path", path: value.artifact.path },
|
||||
...(value.build_info === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
kind: "file-offset-range" as const,
|
||||
start: value.build_info.header_offset,
|
||||
end: value.build_info.header_offset + 32,
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
),
|
||||
return ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
sha256: value.artifact.sha256,
|
||||
format: value.format === "macho" ? "mach-o" : value.format,
|
||||
...(architecture === undefined ? {} : { architecture }),
|
||||
},
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
result: value,
|
||||
confidence: "observed",
|
||||
limitations: value.limitations,
|
||||
locations: [
|
||||
{ kind: "artifact-path", path: value.artifact.path },
|
||||
...(value.build_info === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
kind: "file-offset-range" as const,
|
||||
start: value.build_info.header_offset,
|
||||
end: value.build_info.header_offset + 32,
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
),
|
||||
OPERATION,
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -276,11 +276,12 @@ export class EvidenceLedger {
|
||||
} catch (cause: unknown) {
|
||||
return err(new UnknownRegistryError("invalid-transition", { cause }));
|
||||
}
|
||||
const creationUnknown = this.#unknownRevisions.get(
|
||||
unknownRevisionKey(unknown),
|
||||
);
|
||||
if (creationUnknown !== undefined) {
|
||||
if (creationUnknown.revision_digest !== unknown.revision_digest)
|
||||
const existingUnknown = this.#unknownHeads.get(unknown.unknown_id);
|
||||
if (existingUnknown !== undefined) {
|
||||
if (
|
||||
existingUnknown.revision !== 1 ||
|
||||
existingUnknown.revision_digest !== unknown.revision_digest
|
||||
)
|
||||
return err(new UnknownRegistryError("already-exists"));
|
||||
const checked = this.#appendIncremental([evidence]);
|
||||
if (!checked.ok) return checked;
|
||||
|
||||
@@ -230,41 +230,3 @@ it("retains complete mutation intent and distinguishes independent update reques
|
||||
});
|
||||
expect(mutations[0]?.evidence_id).not.toBe(mutations[1]?.evidence_id);
|
||||
});
|
||||
|
||||
it("rejects changed creation intent after the original unknown was updated", () => {
|
||||
const records = new InvestigationRecords();
|
||||
const input = unknownInput("Which observation explains this result?");
|
||||
const created = records.recordEvidenceWithUnknown(evidence, input);
|
||||
if (!created.ok) throw created.error;
|
||||
if (created.value === null) throw new Error("Expected a new unknown");
|
||||
const { question: _question, domain: _domain, ...details } = input;
|
||||
const updated = records.updateUnknown({
|
||||
...details,
|
||||
unknown_id: created.value.unknown_id,
|
||||
expected_revision: 1,
|
||||
status: "investigating",
|
||||
severity: "high",
|
||||
resolution: null,
|
||||
});
|
||||
if (!updated.ok) throw updated.error;
|
||||
const retained = records.exportEvidenceBundle();
|
||||
const addition = createEvidence(undefined, provider, {
|
||||
operation: "another-derived-result",
|
||||
parameters: {},
|
||||
result: true,
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
});
|
||||
for (const result of [evidence, addition]) {
|
||||
expect(
|
||||
records.recordEvidenceWithUnknown(result, {
|
||||
...input,
|
||||
severity: "high",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "UnknownRegistryError", reason: "already-exists" },
|
||||
});
|
||||
expect(records.exportEvidenceBundle()).toEqual(retained);
|
||||
}
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type Evidence,
|
||||
type EvidenceObservation,
|
||||
} from "../../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import type {
|
||||
ElectronPageInspection,
|
||||
ElectronTargetList,
|
||||
@@ -27,7 +28,7 @@ export const createElectronEvidence = (
|
||||
: "rea.electron-page-inspection",
|
||||
operation,
|
||||
parameters: parameters(input),
|
||||
result,
|
||||
result: jsonValueSchema.parse(result),
|
||||
confidence: "observed",
|
||||
authority: "external-service",
|
||||
environment:
|
||||
|
||||
@@ -13,8 +13,6 @@ import type { Result } from "../../domain/result.js";
|
||||
|
||||
/** Provider-neutral boundary for root-confined Electron file-page observation. */
|
||||
export interface ElectronObservationPort {
|
||||
/** Release provider-owned transports during command or server shutdown. */
|
||||
close?(): Promise<void>;
|
||||
identity(): ProviderIdentity;
|
||||
listTargets(
|
||||
input: ListElectronTargetsInput,
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { classifyBrowserCompleteness } from "../../domain/browserCompleteness.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
electronPageInspectionSchema,
|
||||
electronTargetListSchema,
|
||||
inspectElectronPageInputSchema,
|
||||
} from "../../domain/javascript/electronObservation.js";
|
||||
import { err } from "../../domain/result.js";
|
||||
import type { ElectronObservationPort } from "./ElectronObservationPort.js";
|
||||
import {
|
||||
inspectElectronPage,
|
||||
listElectronTargets,
|
||||
} from "./ElectronObservationService.js";
|
||||
|
||||
const identity = { id: "electron-fixture", name: "Fixture", version: "1" };
|
||||
const input = inspectElectronPageInputSchema.parse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "electron-page",
|
||||
observation_ms: 0,
|
||||
});
|
||||
const inspection = electronPageInspectionSchema.parse({
|
||||
browser: {
|
||||
product: "Electron",
|
||||
protocol_version: "1.3",
|
||||
revision: "fixture",
|
||||
user_agent: "Electron fixture",
|
||||
js_version: "V8 fixture",
|
||||
},
|
||||
target: {
|
||||
target_id: "electron-page",
|
||||
type: "page",
|
||||
title: "Fixture",
|
||||
file_path: "/app/index.html",
|
||||
attached: false,
|
||||
},
|
||||
capture_window: {
|
||||
started_at: "2026-01-01T00:00:00.000Z",
|
||||
ended_at: "2026-01-01T00:00:00.000Z",
|
||||
observation_ms: 0,
|
||||
},
|
||||
completeness: classifyBrowserCompleteness({
|
||||
policyFilteredSections: new Set(),
|
||||
attachLimitedSections: new Set(),
|
||||
truncatedSections: new Set(),
|
||||
unavailableSections: new Set(),
|
||||
excluded: [],
|
||||
droppedEvents: {
|
||||
scripts: 0,
|
||||
network_requests: 0,
|
||||
console_events: 0,
|
||||
websocket_connections: 0,
|
||||
websocket_frames: 0,
|
||||
webmcp_tools: 0,
|
||||
timeline_events: 0,
|
||||
},
|
||||
}),
|
||||
frames: [],
|
||||
dom: { total_nodes: 0, nodes: [] },
|
||||
scripts: { total: 0, items: [] },
|
||||
resources: [],
|
||||
workers: [],
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
it("converts a retained raw Electron inspection into partial Evidence", async () => {
|
||||
const failure = new AnalysisCancelledError("inspect_electron_page", {
|
||||
partialObservation: inspection,
|
||||
});
|
||||
const provider: ElectronObservationPort = {
|
||||
identity: () => identity,
|
||||
listTargets: async () =>
|
||||
err(new AnalysisCancelledError("list_electron_targets")),
|
||||
inspectPage: async () => err(failure),
|
||||
};
|
||||
const result = await inspectElectronPage(provider, input);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "inspect_electron_page",
|
||||
normalized_result: inspection,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("converts a raw inspection retained by provider cleanup failure", async () => {
|
||||
const failure = new ProviderCleanupError(
|
||||
"rea-cdp-electron",
|
||||
["electron-page"],
|
||||
{ reason: "target detach failed" },
|
||||
{ operation: "inspect_electron_page", partialObservation: inspection },
|
||||
);
|
||||
const provider: ElectronObservationPort = {
|
||||
identity: () => identity,
|
||||
listTargets: async () =>
|
||||
err(new AnalysisCancelledError("list_electron_targets")),
|
||||
inspectPage: async () => err(failure),
|
||||
};
|
||||
const result = await inspectElectronPage(provider, input);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "inspect_electron_page",
|
||||
normalized_result: inspection,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a typed output error for malformed Electron target output after abort", async () => {
|
||||
const controller = new AbortController();
|
||||
const malformed = {
|
||||
browser: {
|
||||
product: "Electron",
|
||||
protocol_version: "1.3",
|
||||
revision: "fixture",
|
||||
user_agent: "Electron fixture",
|
||||
js_version: "V8 fixture",
|
||||
},
|
||||
targets: [],
|
||||
excluded: { unsupported_url: 0, non_page: 0 },
|
||||
limitations: [() => undefined],
|
||||
} as unknown as ReturnType<typeof electronTargetListSchema.parse>;
|
||||
const provider: ElectronObservationPort = {
|
||||
identity: () => identity,
|
||||
listTargets: async () => {
|
||||
controller.abort();
|
||||
return { ok: true, value: malformed };
|
||||
},
|
||||
inspectPage: async () =>
|
||||
err(new AnalysisCancelledError("inspect_electron_page")),
|
||||
};
|
||||
const result = await listElectronTargets(
|
||||
provider,
|
||||
{
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
@@ -5,19 +5,10 @@ import type {
|
||||
InspectElectronPageInput,
|
||||
ListElectronTargetsInput,
|
||||
} from "../../domain/javascript/electronObservation.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { createElectronEvidence } from "./ElectronEvidence.js";
|
||||
import {
|
||||
electronPageInspectionSchema,
|
||||
electronTargetListSchema,
|
||||
} from "../../domain/javascript/electronObservation.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
/** List local file pages exposed by the explicitly selected endpoint. */
|
||||
export const listElectronTargets = async (
|
||||
@@ -27,31 +18,17 @@ export const listElectronTargets = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireProvider(provider, "list_electron_targets");
|
||||
if (!ready.ok) return ready;
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("list_electron_targets"));
|
||||
const result = await ready.value.listTargets(input, options);
|
||||
if (!result.ok) return result;
|
||||
const report = electronTargetListSchema.safeParse(result.value);
|
||||
if (!report.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"list_electron_targets",
|
||||
"Electron provider returned a malformed target list.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
return completeProviderEvidence(
|
||||
ok(
|
||||
createElectronEvidence(
|
||||
"list_electron_targets",
|
||||
input,
|
||||
report.data,
|
||||
ready.value.identity(),
|
||||
),
|
||||
),
|
||||
"list_electron_targets",
|
||||
options,
|
||||
);
|
||||
return result.ok
|
||||
? ok(
|
||||
createElectronEvidence(
|
||||
"list_electron_targets",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: result;
|
||||
};
|
||||
|
||||
/** Inspect one local file page from the explicitly selected endpoint. */
|
||||
@@ -62,61 +39,17 @@ export const inspectElectronPage = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireProvider(provider, "inspect_electron_page");
|
||||
if (!ready.ok) return ready;
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("inspect_electron_page"));
|
||||
const result = await ready.value.inspectPage(input, options);
|
||||
if (!result.ok) {
|
||||
if (
|
||||
result.error.partialObservation !== undefined &&
|
||||
!("evidence_id" in result.error.partialObservation) &&
|
||||
"target" in result.error.partialObservation &&
|
||||
"dom" in result.error.partialObservation &&
|
||||
"capture_window" in result.error.partialObservation
|
||||
) {
|
||||
const report = electronPageInspectionSchema.safeParse(
|
||||
result.error.partialObservation,
|
||||
);
|
||||
if (!report.success) {
|
||||
if (result.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"inspect_electron_page",
|
||||
"Electron provider retained a malformed completed page inspection.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
result.error.partialObservation = createElectronEvidence(
|
||||
"inspect_electron_page",
|
||||
input,
|
||||
report.data,
|
||||
ready.value.identity(),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const report = electronPageInspectionSchema.safeParse(result.value);
|
||||
if (!report.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"inspect_electron_page",
|
||||
"Electron provider returned a malformed page inspection.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
return completeProviderEvidence(
|
||||
ok(
|
||||
createElectronEvidence(
|
||||
"inspect_electron_page",
|
||||
input,
|
||||
report.data,
|
||||
ready.value.identity(),
|
||||
),
|
||||
),
|
||||
"inspect_electron_page",
|
||||
options,
|
||||
);
|
||||
return result.ok
|
||||
? ok(
|
||||
createElectronEvidence(
|
||||
"inspect_electron_page",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: result;
|
||||
};
|
||||
|
||||
const requireProvider = (
|
||||
|
||||
@@ -22,8 +22,6 @@ import type {
|
||||
} from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import type {
|
||||
AnalyzedJavaScriptArtifactFile,
|
||||
JavaScriptArtifactAnalysisFailure,
|
||||
JavaScriptArtifactAnalysisScope,
|
||||
JavaScriptArtifactAnalysis,
|
||||
JavaScriptModuleArtifactAnalysis,
|
||||
JavaScriptModuleSemanticIr,
|
||||
@@ -34,7 +32,6 @@ import type {
|
||||
JavaScriptSourceMapObservation,
|
||||
JavaScriptSourceMapOriginal,
|
||||
} from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import { javascriptArtifactAnalysisScopes } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import { analyzeJavaScriptJsonModule } from "./JavaScriptJsonModules.js";
|
||||
import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js";
|
||||
import { htmlArtifactReferences } from "../../domain/javascript/htmlArtifactReferences.js";
|
||||
@@ -144,7 +141,6 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async (
|
||||
fileSet.files.length - (fileSet.source_selection?.unselected_files ?? 0);
|
||||
let selectedFileIndex = 0;
|
||||
for (const file of fileSet.files) {
|
||||
let analysisStarted = false;
|
||||
try {
|
||||
await setImmediate();
|
||||
if (classifyOutsideAnalysisScope(file, state, projection)) {
|
||||
@@ -159,7 +155,6 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async (
|
||||
if (signal?.aborted === true)
|
||||
throw new AnalysisCancelledError("analyze_javascript_application");
|
||||
await beforeFile(file, selectedFileIndex++, selectedFileCount);
|
||||
analysisStarted = true;
|
||||
if (file.kind !== "javascript" || !file.text.included) {
|
||||
const steps = analyzeArtifactFileSteps(file, {
|
||||
state,
|
||||
@@ -187,12 +182,10 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async (
|
||||
: error instanceof AnalysisTimeoutError
|
||||
? { worker_timeout_ms: error.timeoutMs }
|
||||
: null;
|
||||
const failure: JavaScriptArtifactAnalysisFailure | undefined =
|
||||
const failure =
|
||||
error === null
|
||||
? undefined
|
||||
: {
|
||||
scope: javascriptArtifactAnalysisScopes.javascript,
|
||||
status: "interrupted",
|
||||
reason: `${file.path}: ${error.message}`,
|
||||
error: projectAnalysisError(error),
|
||||
limits,
|
||||
@@ -252,22 +245,15 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async (
|
||||
? cause
|
||||
: null;
|
||||
if (interruption !== null) {
|
||||
const scope = javascriptArtifactAnalysisScopes[file.kind];
|
||||
state.files.push({
|
||||
file,
|
||||
javascript: null,
|
||||
semantic: null,
|
||||
...(scope === null
|
||||
? {}
|
||||
: {
|
||||
analysis_failure: {
|
||||
scope,
|
||||
status: analysisStarted ? "interrupted" : "not-started",
|
||||
reason: `${file.path}: ${interruption.message}`,
|
||||
error: projectAnalysisError(interruption),
|
||||
limits: null,
|
||||
},
|
||||
}),
|
||||
analysis_failure: {
|
||||
reason: `${file.path}: ${interruption.message}`,
|
||||
error: projectAnalysisError(interruption),
|
||||
limits: null,
|
||||
},
|
||||
});
|
||||
projection.recordUnavailableFile(
|
||||
file,
|
||||
@@ -291,36 +277,25 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async (
|
||||
? { worker_timeout_ms: interruption.timeoutMs }
|
||||
: null,
|
||||
};
|
||||
// This interruption has one cause. Share its immutable failure per phase
|
||||
// rather than copying it for every unvisited source in a large bundle.
|
||||
const failures = new Map<
|
||||
JavaScriptArtifactAnalysisScope,
|
||||
JavaScriptArtifactAnalysisFailure
|
||||
>();
|
||||
for (const file of fileSet.files) {
|
||||
if (analyzedPaths.has(file.path)) continue;
|
||||
if (classifyOutsideAnalysisScope(file, state, projection)) continue;
|
||||
const scope = javascriptArtifactAnalysisScopes[file.kind];
|
||||
let unavailable = scope === null ? undefined : failures.get(scope);
|
||||
if (scope !== null && unavailable === undefined) {
|
||||
unavailable = { ...failure, scope, status: "not-started" };
|
||||
failures.set(scope, unavailable);
|
||||
}
|
||||
state.files.push({
|
||||
file,
|
||||
javascript: null,
|
||||
semantic: null,
|
||||
...(unavailable === undefined ? {} : { analysis_failure: unavailable }),
|
||||
analysis_failure: failure,
|
||||
});
|
||||
projection.recordUnavailableFile(
|
||||
file,
|
||||
failure.reason,
|
||||
failure.limits,
|
||||
interruption instanceof AnalysisResourceConstraintError ||
|
||||
interruption instanceof AnalysisTimeoutError
|
||||
? "resource-limit"
|
||||
: "incomplete-module",
|
||||
);
|
||||
if (file.kind === "javascript")
|
||||
projection.recordUnavailableFile(
|
||||
file,
|
||||
failure.reason,
|
||||
failure.limits,
|
||||
interruption instanceof AnalysisResourceConstraintError ||
|
||||
interruption instanceof AnalysisTimeoutError
|
||||
? "resource-limit"
|
||||
: "incomplete-module",
|
||||
);
|
||||
}
|
||||
}
|
||||
return { analysis: finalizeArtifactAnalysis(state), interruption };
|
||||
@@ -448,12 +423,13 @@ const classifyOutsideAnalysisScope = <
|
||||
if (file.text.included || file.text.reason !== "outside-analysis-scope")
|
||||
return false;
|
||||
state.files.push({ file, javascript: null, semantic: null });
|
||||
projection?.recordUnavailableFile(
|
||||
file,
|
||||
"Source was not analyzed because it is outside the caller-selected source_paths.",
|
||||
null,
|
||||
"incomplete-module",
|
||||
);
|
||||
if (file.kind === "javascript")
|
||||
projection?.recordUnavailableFile(
|
||||
file,
|
||||
"Source was not analyzed because it is outside the caller-selected source_paths.",
|
||||
null,
|
||||
"incomplete-module",
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@ import type {
|
||||
} from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
|
||||
import type { JavaScriptModuleSemanticIr } from "../../domain/javascript/javascriptModuleSemanticIr.js";
|
||||
import type {
|
||||
JavaScriptArtifactFile,
|
||||
JavaScriptArtifactFileKind,
|
||||
} from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
|
||||
interface JavaScriptStructuredObservation {
|
||||
@@ -110,47 +107,6 @@ export type JavaScriptJsonModuleObservation = JavaScriptStructuredObservation &
|
||||
/** Module facts needed after file-local semantic projection has finished. */
|
||||
export type { JavaScriptModuleSemanticIr } from "../../domain/javascript/javascriptModuleSemanticIr.js";
|
||||
|
||||
/** Applicable processing of inventoried files; native bytes remain metadata-only. */
|
||||
export const javascriptArtifactAnalysisScopes = {
|
||||
javascript: {
|
||||
operation: "analyze-javascript-source",
|
||||
authority: "ast-static-analysis",
|
||||
},
|
||||
"package-json": {
|
||||
operation: "parse-package-json",
|
||||
authority: "artifact-bytes",
|
||||
},
|
||||
json: { operation: "parse-json-module", authority: "artifact-bytes" },
|
||||
html: {
|
||||
operation: "parse-html-script-references",
|
||||
authority: "artifact-bytes",
|
||||
},
|
||||
"source-map": {
|
||||
operation: "parse-local-source-map",
|
||||
authority: "artifact-bytes",
|
||||
},
|
||||
"native-addon": null,
|
||||
} as const satisfies Record<
|
||||
JavaScriptArtifactFileKind,
|
||||
{
|
||||
readonly operation: string;
|
||||
readonly authority: "artifact-bytes" | "ast-static-analysis";
|
||||
} | null
|
||||
>;
|
||||
|
||||
export type JavaScriptArtifactAnalysisScope = NonNullable<
|
||||
(typeof javascriptArtifactAnalysisScopes)[JavaScriptArtifactFileKind]
|
||||
>;
|
||||
|
||||
/** A processing gap preserves applicability and whether the phase was entered. */
|
||||
export interface JavaScriptArtifactAnalysisFailure {
|
||||
readonly scope: JavaScriptArtifactAnalysisScope;
|
||||
readonly status: "interrupted" | "not-started";
|
||||
readonly reason: string;
|
||||
readonly error: JsonValue;
|
||||
readonly limits: Readonly<Record<string, JsonValue>> | null;
|
||||
}
|
||||
|
||||
/** One relevant file plus optional AST-only JavaScript facts. */
|
||||
export interface AnalyzedJavaScriptArtifactFile<
|
||||
SemanticIr extends JavaScriptModuleSemanticIr = JavaScriptSemanticIr,
|
||||
@@ -161,7 +117,11 @@ export interface AnalyzedJavaScriptArtifactFile<
|
||||
readonly ir: SemanticIr;
|
||||
} | null;
|
||||
/** Operational incompleteness, distinct from a malformed source file. */
|
||||
readonly analysis_failure?: JavaScriptArtifactAnalysisFailure;
|
||||
readonly analysis_failure?: {
|
||||
readonly reason: string;
|
||||
readonly error: JsonValue;
|
||||
readonly limits: Readonly<Record<string, JsonValue>> | null;
|
||||
};
|
||||
/** Facts retained inline when their expanded graph exceeds available memory. */
|
||||
readonly application_projection_failure?: {
|
||||
readonly reason: string;
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptSt
|
||||
import type {
|
||||
JavaScriptHtmlInlineScriptsObservation,
|
||||
JavaScriptModuleArtifactAnalysis,
|
||||
JavaScriptArtifactAnalysisScope,
|
||||
} from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import type { JavaScriptUnanalyzedArtifactLookup } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import { HTML_INLINE_SCRIPT_LOCATION_LIMIT } from "../../domain/javascript/htmlArtifactReferences.js";
|
||||
@@ -26,7 +25,7 @@ import {
|
||||
artifactObservationEvidence,
|
||||
astObservationEvidence,
|
||||
staticInferenceEvidence,
|
||||
unavailableStaticEvidence,
|
||||
unavailableAstEvidence,
|
||||
} from "./JavaScriptArtifactGraphEvidence.js";
|
||||
import {
|
||||
artifactPathResolutionProperties,
|
||||
@@ -107,12 +106,11 @@ export interface RoleAssetLinkInput {
|
||||
readonly coverage?: JavaScriptArtifactGraphCoverage;
|
||||
}
|
||||
|
||||
/** An explicit processing/selection gap with the applicable phase's authority. */
|
||||
/** Explicit package, JavaScript, or source-map parse gap. */
|
||||
export interface UnavailableParseInput {
|
||||
readonly file: JavaScriptArtifactFile;
|
||||
readonly asset: ApplicationNode;
|
||||
readonly operation: string;
|
||||
readonly authority: JavaScriptArtifactAnalysisScope["authority"];
|
||||
readonly limitation: string;
|
||||
readonly failure?: JsonValue;
|
||||
readonly limits?: JavaScriptArtifactGraphCoverage["limits"];
|
||||
@@ -232,8 +230,7 @@ export const addUnavailableStaticParseScope = (
|
||||
? {}
|
||||
: { analysis_failure: input.failure }),
|
||||
},
|
||||
evidence: unavailableStaticEvidence({
|
||||
authority: input.authority,
|
||||
evidence: unavailableAstEvidence({
|
||||
sha256: input.file.sha256,
|
||||
path: input.file.path,
|
||||
operation: input.operation,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ApplicationGraphEvidence } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
|
||||
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactAnalysisScope } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
|
||||
const EXTRACTOR = {
|
||||
name: "rea-javascript-artifact-reconstruction",
|
||||
@@ -94,14 +93,11 @@ export const staticInferenceEvidence = (
|
||||
evidence_ids: [],
|
||||
});
|
||||
|
||||
/** Unavailable static fact with the authority of its applicable processing phase. */
|
||||
export const unavailableStaticEvidence = (
|
||||
input: EvidenceInput & {
|
||||
readonly authority: JavaScriptArtifactAnalysisScope["authority"];
|
||||
readonly limitation: string;
|
||||
},
|
||||
/** Unavailable AST fact that preserves a parse, approval, or limit gap. */
|
||||
export const unavailableAstEvidence = (
|
||||
input: EvidenceInput & { readonly limitation: string },
|
||||
): ApplicationGraphEvidence => ({
|
||||
authority: input.authority,
|
||||
authority: "ast-static-analysis",
|
||||
state: "unavailable",
|
||||
confidence: "unknown",
|
||||
artifact: artifactReference(input.sha256),
|
||||
|
||||
@@ -202,22 +202,15 @@ const addEndpoint = (
|
||||
) ?? input.asset;
|
||||
const key = compactStringIdentityKey(input.value.value);
|
||||
const candidate = input.value.interpretation === "candidate";
|
||||
const partial = input.value.resolution === "partial";
|
||||
const candidateLimitation =
|
||||
"The unbound method-like argument is only a candidate; its receiver does not establish a network call.";
|
||||
const partialLimitation =
|
||||
"The request target is not an exact literal; the endpoint value retains the unresolved source expression.";
|
||||
const label = `${input.value.kind} ${candidate ? "endpoint candidate" : "endpoint"}`;
|
||||
const node = context.accumulator.addNode({
|
||||
kind: "endpoint",
|
||||
identity: artifactLocalIdentity(
|
||||
input.file.sha256,
|
||||
input.value.kind,
|
||||
JSON.stringify([
|
||||
input.value.interpretation ?? "literal",
|
||||
...(partial ? ["partial", input.value.location] : []),
|
||||
key,
|
||||
]),
|
||||
JSON.stringify([input.value.interpretation ?? "literal", key]),
|
||||
),
|
||||
observations: [
|
||||
{
|
||||
@@ -228,7 +221,6 @@ const addEndpoint = (
|
||||
value: input.value.value,
|
||||
mechanism: input.value.mechanism,
|
||||
...(candidate ? { interpretation: input.value.interpretation } : {}),
|
||||
...(partial ? { resolution: input.value.resolution } : {}),
|
||||
},
|
||||
evidence: candidate
|
||||
? staticInferenceEvidence({
|
||||
@@ -246,7 +238,6 @@ const addEndpoint = (
|
||||
range: input.value.location,
|
||||
operation: "discover-static-endpoint",
|
||||
coverage: input.coverage,
|
||||
...(partial ? { limitations: [partialLimitation] } : {}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
@@ -261,16 +252,13 @@ const addEndpoint = (
|
||||
properties: {
|
||||
mechanism: input.value.mechanism,
|
||||
...(candidate ? { interpretation: input.value.interpretation } : {}),
|
||||
...(partial ? { resolution: input.value.resolution } : {}),
|
||||
},
|
||||
...(candidate
|
||||
? {
|
||||
confidence: "low" as const,
|
||||
limitations: [candidateLimitation],
|
||||
}
|
||||
: partial
|
||||
? { limitations: [partialLimitation] }
|
||||
: {}),
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
partialApplicationCoverage,
|
||||
} from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
|
||||
import type { JavaScriptModuleArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import { javascriptArtifactAnalysisScopes } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { freezeJsonSnapshot } from "../../domain/immutableJson.js";
|
||||
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||
@@ -212,17 +211,11 @@ export const addJavaScriptArtifactFiles = (
|
||||
}),
|
||||
});
|
||||
}
|
||||
const analysisScope = javascriptArtifactAnalysisScopes[file.kind];
|
||||
if (
|
||||
!file.text.included &&
|
||||
file.text.reason === "outside-analysis-scope" &&
|
||||
analysisScope !== null
|
||||
)
|
||||
if (!file.text.included && file.text.reason === "outside-analysis-scope")
|
||||
addUnavailableStaticParseScope(context, {
|
||||
file,
|
||||
asset: target,
|
||||
operation: "select-javascript-sources",
|
||||
authority: analysisScope.authority,
|
||||
limitation:
|
||||
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
|
||||
});
|
||||
@@ -230,13 +223,12 @@ export const addJavaScriptArtifactFiles = (
|
||||
addUnavailableStaticParseScope(context, {
|
||||
file,
|
||||
asset: target,
|
||||
...analyzed.analysis_failure.scope,
|
||||
operation: "analyze-javascript-source",
|
||||
limitation: analyzed.analysis_failure.reason,
|
||||
failure: analyzed.analysis_failure.error,
|
||||
limits: javaScriptAnalysisCoverageLimits(
|
||||
analyzed.analysis_failure.limits,
|
||||
),
|
||||
details: { analysis_status: analyzed.analysis_failure.status },
|
||||
});
|
||||
else if (
|
||||
file.kind === "javascript" &&
|
||||
@@ -247,7 +239,6 @@ export const addJavaScriptArtifactFiles = (
|
||||
file,
|
||||
asset: target,
|
||||
operation: "parse-javascript",
|
||||
authority: javascriptArtifactAnalysisScopes.javascript.authority,
|
||||
limitation: file.text.included
|
||||
? "JavaScript syntax could not be parsed."
|
||||
: `JavaScript text was unavailable: ${file.text.reason}.`,
|
||||
@@ -257,7 +248,6 @@ export const addJavaScriptArtifactFiles = (
|
||||
file,
|
||||
asset: target,
|
||||
operation: "project-javascript-application",
|
||||
authority: javascriptArtifactAnalysisScopes.javascript.authority,
|
||||
limitation: analyzed.application_projection_failure.reason,
|
||||
failure: analyzed.application_projection_failure.error,
|
||||
limits: javaScriptAnalysisCoverageLimits(
|
||||
@@ -269,14 +259,14 @@ export const addJavaScriptArtifactFiles = (
|
||||
addUnavailableStaticParseScope(context, {
|
||||
file,
|
||||
asset: target,
|
||||
...javascriptArtifactAnalysisScopes["package-json"],
|
||||
operation: "parse-package-json",
|
||||
limitation: packageValue.limitation,
|
||||
});
|
||||
if (jsonValue !== undefined && jsonValue.status !== "included")
|
||||
addUnavailableStaticParseScope(context, {
|
||||
file,
|
||||
asset: target,
|
||||
...javascriptArtifactAnalysisScopes.json,
|
||||
operation: "parse-json-module",
|
||||
limitation: jsonValue.limitation,
|
||||
});
|
||||
const sourceMap = sourceMapsByPath.get(file.path);
|
||||
@@ -284,7 +274,7 @@ export const addJavaScriptArtifactFiles = (
|
||||
addUnavailableStaticParseScope(context, {
|
||||
file,
|
||||
asset: target,
|
||||
...javascriptArtifactAnalysisScopes["source-map"],
|
||||
operation: "parse-local-source-map",
|
||||
limitation: sourceMap.limitation,
|
||||
});
|
||||
const inlineScripts = inlineScriptsByPath.get(file.path);
|
||||
@@ -293,7 +283,6 @@ export const addJavaScriptArtifactFiles = (
|
||||
file,
|
||||
asset: target,
|
||||
operation: "parse-html-inline-scripts",
|
||||
authority: javascriptArtifactAnalysisScopes.javascript.authority,
|
||||
limitation: htmlInlineScriptLimitation(inlineScripts),
|
||||
limits:
|
||||
inlineScripts.script_count > inlineScripts.scripts.length
|
||||
|
||||
@@ -20,7 +20,6 @@ import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySn
|
||||
import type { ArtifactInventoryPartialObservation } from "../../domain/artifactPartialObservation.js";
|
||||
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import { analyzeAndAdoptJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js";
|
||||
import { javascriptArtifactAnalysisScopes } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import { createJavaScriptSourceAnalysis } from "../../artifacts/javascript/JavaScriptSourceAnalysis.js";
|
||||
import { JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS } from "../../domain/javascript/javascriptAnalysisResourceControls.js";
|
||||
import { createOwnedJavaScriptApplicationEvidenceCooperatively } from "./JavaScriptApplicationEvidence.js";
|
||||
@@ -138,7 +137,7 @@ export const reconstructJavaScriptArtifact = async (
|
||||
);
|
||||
await reportPhase(
|
||||
"parse_javascript_sources",
|
||||
`Processing ${String(files.files.length - (files.source_selection?.unselected_files ?? 0))} selected application files`,
|
||||
`Parsing and projecting ${String(files.files.length - (files.source_selection?.unselected_files ?? 0))} selected application source files`,
|
||||
);
|
||||
const semanticProjection = createJavaScriptSemanticGraphProjection({
|
||||
...(input.max_semantic_nodes === undefined
|
||||
@@ -169,16 +168,11 @@ export const reconstructJavaScriptArtifact = async (
|
||||
semanticProjection,
|
||||
async (file, completed, total) => {
|
||||
abortIfNeeded(signal);
|
||||
const ast =
|
||||
javascriptArtifactAnalysisScopes[file.kind]?.authority ===
|
||||
"ast-static-analysis";
|
||||
await progress?.report({
|
||||
phase: ast
|
||||
? "parse_javascript_source"
|
||||
: "process_javascript_artifact_metadata",
|
||||
phase: "parse_javascript_source",
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message: `${ast ? "Parsing and projecting" : "Processing metadata for"} ${file.path} (${String(completed + 1)}/${String(total)})`,
|
||||
message: `Parsing and projecting ${file.path} (${String(completed + 1)}/${String(total)})`,
|
||||
});
|
||||
abortIfNeeded(signal);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type Evidence,
|
||||
type EvidenceObservation,
|
||||
} from "../../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import type {
|
||||
JavaScriptRuntimeObservation,
|
||||
JavaScriptRuntimeTargetList,
|
||||
@@ -29,7 +30,7 @@ export const createJavaScriptRuntimeObservationEvidence = (
|
||||
: "rea.javascript-runtime-observation",
|
||||
operation,
|
||||
parameters: parameters(input),
|
||||
result,
|
||||
result: jsonValueSchema.parse(result),
|
||||
rawResult: null,
|
||||
confidence: "observed",
|
||||
authority: "external-service",
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { BrowserObservationError } from "../../domain/browserObservationError.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import type { JavaScriptRuntimeTargetList } from "../../domain/javascript/javascriptRuntimeObservation.js";
|
||||
import { javascriptRuntimeObservationSchema } from "../../domain/javascript/javascriptRuntimeObservation.js";
|
||||
import type { JavaScriptRuntimeObservationPort } from "./JavaScriptRuntimeObservationPort.js";
|
||||
import {
|
||||
listJavaScriptRuntimeTargets,
|
||||
@@ -91,138 +85,4 @@ it("does not publish targets when cancellation arrives during provider cleanup",
|
||||
operation: "list_javascript_runtime_targets",
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "list_javascript_runtime_targets",
|
||||
normalized_result: { targets: [] },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let late cancellation hide an invalid completed Inspector report", async () => {
|
||||
const malformed = {
|
||||
runtime: {
|
||||
product: "Node.js",
|
||||
protocol_version: "1.3",
|
||||
v8_version: "12.0",
|
||||
},
|
||||
targets: [],
|
||||
excluded: { unsupported_location: -1, unconnectable: 0 },
|
||||
limitations: [],
|
||||
} as unknown as JavaScriptRuntimeTargetList;
|
||||
const output = await listJavaScriptRuntimeTargets(
|
||||
{
|
||||
identity: () => ({ id: "test", name: "Test", version: "1" }),
|
||||
listTargets: async () => ok(malformed),
|
||||
observe: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
},
|
||||
{ inspector_endpoint: "http://127.0.0.1:9229" },
|
||||
);
|
||||
expect(output).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
if (!output.ok) expect(output.error).toBeInstanceOf(AnalysisOutputError);
|
||||
|
||||
const controller = new AbortController();
|
||||
const late = await listJavaScriptRuntimeTargets(
|
||||
{
|
||||
identity: () => ({ id: "test", name: "Test", version: "1" }),
|
||||
listTargets: async () => {
|
||||
controller.abort();
|
||||
return ok(malformed);
|
||||
},
|
||||
observe: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
},
|
||||
{ inspector_endpoint: "http://127.0.0.1:9229" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(late).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
if (!late.ok) expect(late.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
|
||||
it("converts a raw runtime observation retained by cleanup failure", async () => {
|
||||
const observation = javascriptRuntimeObservationSchema.parse({
|
||||
runtime: {
|
||||
product: "node.js/v24",
|
||||
protocol_version: "1.3",
|
||||
v8_version: "13",
|
||||
},
|
||||
target: {
|
||||
target_id: "target",
|
||||
protocol_type: "node",
|
||||
attached: false,
|
||||
location: { kind: "file", file_path: "/fixture.js" },
|
||||
runtime_kind: "node",
|
||||
runtime_kind_authority: "caller-declared-unverified",
|
||||
},
|
||||
capture: {
|
||||
observation_ms: 0,
|
||||
events_observed: 0,
|
||||
events_retained: 0,
|
||||
events_dropped: 0,
|
||||
metadata_bytes_retained: 0,
|
||||
truncated: false,
|
||||
truncation_reasons: [],
|
||||
},
|
||||
scripts: {
|
||||
items: [],
|
||||
observed_total: 0,
|
||||
excluded: { unsupported_location: 0, invalid_protocol_value: 0 },
|
||||
},
|
||||
execution_contexts: [],
|
||||
directly_observed: ["Inspector target attached and queried."],
|
||||
unavailable_without_instrumentation: [],
|
||||
unknowns: [],
|
||||
limitations: [],
|
||||
});
|
||||
const failure = new BrowserObservationError(
|
||||
"observe_javascript_runtime",
|
||||
"cleanup_failed",
|
||||
{
|
||||
cleanup: {
|
||||
reason: "Inspector close failed",
|
||||
resources: ["browser_transport"],
|
||||
},
|
||||
partialObservation: observation,
|
||||
},
|
||||
);
|
||||
const result = await observeJavaScriptRuntime(
|
||||
{
|
||||
identity: () => ({ id: "test", name: "Test", version: "1" }),
|
||||
listTargets: async () =>
|
||||
ok({
|
||||
runtime: {
|
||||
product: "node.js/v24",
|
||||
protocol_version: "1.3",
|
||||
v8_version: "13",
|
||||
},
|
||||
targets: [],
|
||||
excluded: { unsupported_location: 0, unconnectable: 0 },
|
||||
limitations: [],
|
||||
}),
|
||||
observe: async () => err(failure),
|
||||
},
|
||||
{
|
||||
inspector_endpoint: "http://127.0.0.1:9229",
|
||||
target_id: "target",
|
||||
observation_ms: 0,
|
||||
},
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "observe_javascript_runtime",
|
||||
normalized_result: observation,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import type { Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type {
|
||||
@@ -13,11 +12,6 @@ import type {
|
||||
ObserveJavaScriptRuntimeInput,
|
||||
} from "../../domain/javascript/javascriptRuntimeObservation.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
import {
|
||||
javascriptRuntimeObservationSchema,
|
||||
javascriptRuntimeTargetListSchema,
|
||||
} from "../../domain/javascript/javascriptRuntimeObservation.js";
|
||||
|
||||
/** List targets exposed by the explicitly selected loopback Inspector. */
|
||||
export const listJavaScriptRuntimeTargets = async (
|
||||
@@ -27,26 +21,19 @@ export const listJavaScriptRuntimeTargets = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireProvider(provider, "list_javascript_runtime_targets");
|
||||
if (!ready.ok) return ready;
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("list_javascript_runtime_targets"));
|
||||
const result = await ready.value.listTargets(input, options);
|
||||
if (!result.ok) return result;
|
||||
const report = javascriptRuntimeTargetListSchema.safeParse(result.value);
|
||||
if (!report.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"list_javascript_runtime_targets",
|
||||
"Inspector returned a malformed target list.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
return runtimeEvidence(
|
||||
"list_javascript_runtime_targets",
|
||||
input,
|
||||
report.data,
|
||||
ready.value.identity(),
|
||||
options,
|
||||
);
|
||||
if (result.ok && options.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("list_javascript_runtime_targets"));
|
||||
return result.ok
|
||||
? ok(
|
||||
createJavaScriptRuntimeObservationEvidence(
|
||||
"list_javascript_runtime_targets",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: result;
|
||||
};
|
||||
|
||||
/** Observe one attach-only Inspector target selected by the request. */
|
||||
@@ -57,51 +44,17 @@ export const observeJavaScriptRuntime = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireProvider(provider, "observe_javascript_runtime");
|
||||
if (!ready.ok) return ready;
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("observe_javascript_runtime"));
|
||||
const result = await ready.value.observe(input, options);
|
||||
if (!result.ok) {
|
||||
const partial = result.error.partialObservation;
|
||||
if (
|
||||
partial !== undefined &&
|
||||
!("evidence_id" in partial) &&
|
||||
!("kind" in partial)
|
||||
) {
|
||||
const report = javascriptRuntimeObservationSchema.safeParse(partial);
|
||||
if (!report.success && result.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"observe_javascript_runtime",
|
||||
"Inspector retained a malformed completed runtime observation.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
if (report.success)
|
||||
result.error.partialObservation =
|
||||
createJavaScriptRuntimeObservationEvidence(
|
||||
"observe_javascript_runtime",
|
||||
input,
|
||||
report.data,
|
||||
ready.value.identity(),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const report = javascriptRuntimeObservationSchema.safeParse(result.value);
|
||||
if (!report.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"observe_javascript_runtime",
|
||||
"Inspector returned a malformed runtime observation.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
return runtimeEvidence(
|
||||
"observe_javascript_runtime",
|
||||
input,
|
||||
report.data,
|
||||
ready.value.identity(),
|
||||
options,
|
||||
if (!result.ok) return result;
|
||||
if (options.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("observe_javascript_runtime"));
|
||||
return ok(
|
||||
createJavaScriptRuntimeObservationEvidence(
|
||||
"observe_javascript_runtime",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -118,24 +71,3 @@ const requireProvider = (
|
||||
),
|
||||
)
|
||||
: ok(provider);
|
||||
|
||||
const runtimeEvidence = (
|
||||
operation: Parameters<typeof createJavaScriptRuntimeObservationEvidence>[0],
|
||||
input: Parameters<typeof createJavaScriptRuntimeObservationEvidence>[1],
|
||||
result: Parameters<typeof createJavaScriptRuntimeObservationEvidence>[2],
|
||||
provider: Parameters<typeof createJavaScriptRuntimeObservationEvidence>[3],
|
||||
options: ExecutionOptions,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
return completeProviderEvidence(
|
||||
ok(
|
||||
createJavaScriptRuntimeObservationEvidence(
|
||||
operation,
|
||||
input,
|
||||
result,
|
||||
provider,
|
||||
),
|
||||
),
|
||||
operation,
|
||||
options,
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from "../../domain/javascript/javascriptSemanticAnalysis.js";
|
||||
import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js";
|
||||
import { semanticCoverageResourceLimits } from "../../domain/javascript/javascriptSemanticCoverage.js";
|
||||
import { getterExpansionSource } from "../../../tests/fixtures/javascriptGetterExpansion.js";
|
||||
|
||||
const SHA256 = "a".repeat(64);
|
||||
|
||||
@@ -178,15 +177,13 @@ it("preserves primitive candidate budget unknowns in semantic graph coverage", (
|
||||
status: "partial",
|
||||
resourceLimits: ["primitive-candidates"],
|
||||
});
|
||||
expect(
|
||||
graph.unknowns.filter(({ reason }) => reason === "resource-limit"),
|
||||
).toEqual([
|
||||
expect(graph.unknowns).toContainEqual(
|
||||
expect.objectContaining({
|
||||
family: "data-flow",
|
||||
reason: "resource-limit",
|
||||
detail: expect.stringMatching(/primitive candidate budget exceeded/i),
|
||||
}),
|
||||
]);
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves derived string-byte limit reasons in semantic graph unknowns", () => {
|
||||
@@ -245,14 +242,12 @@ it("preserves deep expression resource reasons through graph projection", () =>
|
||||
expect(semanticCoverageResourceLimits(ir.coverage)).toEqual([
|
||||
"expression-depth",
|
||||
]);
|
||||
expect(
|
||||
graph.unknowns.filter(({ reason }) => reason === "resource-limit"),
|
||||
).toEqual([
|
||||
expect(graph.unknowns).toContainEqual(
|
||||
expect.objectContaining({
|
||||
reason: "resource-limit",
|
||||
detail: expect.stringMatching(/expression depth budget exceeded/i),
|
||||
}),
|
||||
]);
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps ambiguous interprocedural flow out of the default traversal", () => {
|
||||
@@ -478,26 +473,6 @@ const semanticNodeFor = (roleKey: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
it("preserves getter expansion limits in semantic graph coverage", () => {
|
||||
const source = getterExpansionSource();
|
||||
const graph = graphFor(source, analyzeJavaScriptSemantics(source));
|
||||
const module = graph.nodes.find(({ kind }) => kind === "module");
|
||||
if (module === undefined) throw new Error("Expected module node");
|
||||
expect(
|
||||
graph.unknowns.filter(({ reason }) => reason === "resource-limit"),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
node_id: module.node_id,
|
||||
family: "data-flow",
|
||||
reason: "resource-limit",
|
||||
detail: expect.stringMatching(
|
||||
/getter expansion was conservatively bounded/i,
|
||||
),
|
||||
}),
|
||||
]);
|
||||
expect(graph.coverage.status).toBe("unknown");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"const value = consume(Promise.resolve(1));",
|
||||
"async function run() { await consume(Promise.resolve(1)); }",
|
||||
|
||||
@@ -34,16 +34,8 @@ import {
|
||||
import { projectSemanticResources } from "./JavaScriptSemanticGraphResourceProjection.js";
|
||||
import { projectSemanticObjects } from "./JavaScriptSemanticGraphObjectProjection.js";
|
||||
import { projectSemanticValues } from "./JavaScriptSemanticGraphValueProjection.js";
|
||||
import {
|
||||
inferredSemanticEvidenceAt,
|
||||
observedSemanticEvidence,
|
||||
unknownSemanticEvidence,
|
||||
} from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import {
|
||||
semanticCoverageResourceLimits,
|
||||
semanticResourceLimitCoverage,
|
||||
} from "../../domain/javascript/javascriptSemanticCoverage.js";
|
||||
import { semanticResourceLimitReason } from "../../domain/javascript/javascriptSemanticResourceLimits.js";
|
||||
import { inferredSemanticEvidenceAt } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import { observedSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import {
|
||||
partialApplicationCoverage,
|
||||
type ApplicationCoverage,
|
||||
@@ -171,7 +163,7 @@ export interface JavaScriptSemanticGraphProjection {
|
||||
readonly adoptFileProjection: (
|
||||
projection: JavaScriptSemanticFileProjection,
|
||||
) => void;
|
||||
/** Preserve JavaScript-source unknowns; other file kinds have no semantic module. */
|
||||
/** Preserve source-bound unknowns for an execution or result-retention failure. */
|
||||
readonly recordUnavailableFile: (
|
||||
file: JavaScriptArtifactFile,
|
||||
reason: string,
|
||||
@@ -354,9 +346,6 @@ export const createJavaScriptSemanticGraphProjection = (
|
||||
if (projection.truncated) truncatedFiles += 1;
|
||||
},
|
||||
recordUnavailableFile: (file, reason, limits, unknownReason) => {
|
||||
// Both full and incremental construction enter here. Metadata/native
|
||||
// processing gaps belong to the application graph, never JavaScript IR.
|
||||
if (file.kind !== "javascript") return;
|
||||
const retainedLimits = javaScriptAnalysisCoverageLimits(limits);
|
||||
for (const limit of retainedLimits) {
|
||||
executionLimits.set(
|
||||
@@ -473,7 +462,6 @@ function* projectFileSteps(
|
||||
});
|
||||
if (moduleNode === null) return [];
|
||||
state.roots.add(moduleNode.node_id);
|
||||
projectFileResourceLimits({ file, ir, state, moduleNode });
|
||||
yield* projectElectronSemanticCallPaths(file, paths, state);
|
||||
// Node creation keeps source order, which the per-file node budget relies
|
||||
// on; yields between nodes do not change it.
|
||||
@@ -562,36 +550,6 @@ function* projectFileSteps(
|
||||
);
|
||||
}
|
||||
|
||||
const projectFileResourceLimits = (
|
||||
context: Pick<FileContext, "file" | "ir" | "state" | "moduleNode">,
|
||||
): void => {
|
||||
// Getter limits describe query effects without tagged values; other limits
|
||||
// already project from binding or return-site values and must not be duplicated.
|
||||
for (const limit of semanticCoverageResourceLimits(context.ir.coverage)) {
|
||||
if (limit !== "getter-expansion") continue;
|
||||
const unknown = createJavaScriptSemanticGraphUnknown(
|
||||
{
|
||||
node_id: context.moduleNode.node_id,
|
||||
family: "data-flow",
|
||||
relation_kinds: ["defines"],
|
||||
reason: "resource-limit",
|
||||
detail: semanticResourceLimitReason(limit),
|
||||
candidate_node_ids: [],
|
||||
evidence: {
|
||||
...unknownSemanticEvidence(context.file, null),
|
||||
coverage: partialApplicationCoverage(
|
||||
semanticResourceLimitCoverage([limit]),
|
||||
null,
|
||||
),
|
||||
limitations: [semanticResourceLimitReason(limit)],
|
||||
},
|
||||
},
|
||||
context.state.evidenceContexts,
|
||||
);
|
||||
context.state.unknowns.set(unknown.unknown_id, unknown);
|
||||
}
|
||||
};
|
||||
|
||||
function* createReturnSiteNodes(
|
||||
file: JavaScriptArtifactFile,
|
||||
ir: JavaScriptSemanticIr,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import type { RustBinary } from "../../domain/rust/rustBinary.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
import { RustBinaryService } from "./RustBinaryService.js";
|
||||
@@ -132,40 +130,3 @@ it.each([
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
|
||||
it("retains a completed report on late cancellation and keeps output errors primary", async () => {
|
||||
const controller = new AbortController();
|
||||
const completed = await new RustBinaryService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
controller.abort();
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
}).inspect({ path: report.artifact.path }, { signal: controller.signal });
|
||||
expect(completed.ok).toBe(false);
|
||||
if (!completed.ok) {
|
||||
expect(completed.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(parseEvidence(completed.error.partialObservation)).toMatchObject({
|
||||
operation: "inspect_rust_binary",
|
||||
normalized_result: report,
|
||||
});
|
||||
}
|
||||
|
||||
const malformed = {
|
||||
...report,
|
||||
artifact: { ...report.artifact, path: "/artifacts/other" },
|
||||
};
|
||||
const outputController = new AbortController();
|
||||
const invalid = await new RustBinaryService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
outputController.abort();
|
||||
return Promise.resolve(ok(malformed));
|
||||
},
|
||||
}).inspect(
|
||||
{ path: report.artifact.path },
|
||||
{ signal: outputController.signal },
|
||||
);
|
||||
expect(invalid.ok).toBe(false);
|
||||
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "../../domain/rust/rustBinary.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "inspect_rust_binary";
|
||||
|
||||
@@ -62,6 +61,8 @@ export class RustBinaryService {
|
||||
);
|
||||
const inspected = await this.provider.inspect(input.data, options);
|
||||
if (!inspected.ok) return inspected;
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const report = rustBinarySchema.safeParse(inspected.value);
|
||||
if (!report.success || report.data.artifact.path !== input.data.path)
|
||||
return err(
|
||||
@@ -70,11 +71,7 @@ export class RustBinaryService {
|
||||
"Rust dependency reader returned malformed metadata or changed the selected artifact identity.",
|
||||
),
|
||||
);
|
||||
return completeProviderEvidence(
|
||||
ok(evidenceFor(this.provider.identity, input.data, report.data)),
|
||||
OPERATION,
|
||||
options,
|
||||
);
|
||||
return ok(evidenceFor(this.provider.identity, input.data, report.data));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, realpath, rm, type FileHandle } from "node:fs/promises";
|
||||
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
@@ -11,12 +11,6 @@ import {
|
||||
type ArtifactTraversalOptions,
|
||||
} from "./ArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
|
||||
import {
|
||||
NonRegularFileReadError,
|
||||
RegularFileAdmissionFailure,
|
||||
openRegularFile,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import {
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
@@ -50,24 +44,16 @@ export interface NativeDmgHost {
|
||||
delay?(milliseconds: number): Promise<void>;
|
||||
}
|
||||
|
||||
interface NativeDmgFailureClassification {
|
||||
readonly reason: ArtifactReaderFailure["reason"];
|
||||
readonly verifyDiagnostic?: string;
|
||||
/**
|
||||
* hdiutil could not recognize the target, for a reason it reported only in
|
||||
* localized text; the cause stays unknown unless the target cannot be opened.
|
||||
*/
|
||||
readonly unrecognizedImage?: boolean;
|
||||
}
|
||||
|
||||
class NativeDmgCommandFailure extends ArtifactReaderFailure {
|
||||
readonly processNotStarted: boolean;
|
||||
readonly verifyDiagnostic: string | undefined;
|
||||
readonly unrecognizedImage: boolean;
|
||||
readonly exitCode: number | null;
|
||||
|
||||
constructor(
|
||||
failure: NativeDmgFailureClassification,
|
||||
failure: {
|
||||
readonly reason: ArtifactReaderFailure["reason"];
|
||||
readonly verifyDiagnostic?: string;
|
||||
},
|
||||
message: string,
|
||||
readonly stdout: string,
|
||||
options?: {
|
||||
@@ -83,7 +69,6 @@ class NativeDmgCommandFailure extends ArtifactReaderFailure {
|
||||
);
|
||||
this.processNotStarted = options?.processNotStarted ?? false;
|
||||
this.verifyDiagnostic = failure.verifyDiagnostic;
|
||||
this.unrecognizedImage = failure.unrecognizedImage ?? false;
|
||||
this.exitCode = options?.exitCode ?? null;
|
||||
}
|
||||
}
|
||||
@@ -151,7 +136,6 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
#closed = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
readonly #activeDirectoryOperations = new Set<Promise<unknown>>();
|
||||
#verificationProbeHandle: OwnedFileHandle | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
@@ -223,7 +207,6 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
async #closeOwnedResources(): Promise<void> {
|
||||
await this.#initialization?.catch(() => undefined);
|
||||
await Promise.allSettled(this.#activeDirectoryOperations);
|
||||
await this.#closeVerificationProbeHandle();
|
||||
await this.#closeMountedDirectory();
|
||||
let detachFailure = await this.#detachOwnedDevices();
|
||||
if (
|
||||
@@ -270,23 +253,6 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
);
|
||||
}
|
||||
|
||||
async #closeVerificationProbeHandle(): Promise<void> {
|
||||
const owner = this.#verificationProbeHandle;
|
||||
if (owner === undefined) return;
|
||||
try {
|
||||
await owner.close();
|
||||
this.#verificationProbeHandle = undefined;
|
||||
} catch (cause: unknown) {
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
cause,
|
||||
ArtifactReaderFailure.cleanupObservation(
|
||||
cause,
|
||||
`DMG verification target descriptor ${this.path}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #detachOwnedDevices(): Promise<unknown> {
|
||||
let detachFailure = await this.#reconcileAttachState();
|
||||
let remainingDevices: string[] = [];
|
||||
@@ -489,7 +455,13 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
const arguments_ = ["verify", this.path];
|
||||
const deadline = performance.now() + HDIUTIL_COMMAND_TIMEOUT_MS;
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
this.#throwIfVerificationCancelled(signal);
|
||||
if (signal?.aborted === true)
|
||||
throw new NativeDmgCommandFailure(
|
||||
{ reason: "cancelled" },
|
||||
"DMG operation cancelled",
|
||||
"",
|
||||
{ cause: signal.reason },
|
||||
);
|
||||
try {
|
||||
await runChecked(this.host, arguments_, signal, {
|
||||
timeoutMs: Math.max(1, Math.ceil(deadline - performance.now())),
|
||||
@@ -499,28 +471,10 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof NativeDmgCommandFailure)) throw cause;
|
||||
this.#provenance.push(command(arguments_, ["read"], cause.exitCode));
|
||||
if (cause.unrecognizedImage) {
|
||||
const unreadable = await this.#isUnreadableTarget(signal, cause);
|
||||
this.#throwIfVerificationCancelled(signal);
|
||||
if (unreadable)
|
||||
throw new NativeDmgCommandFailure(
|
||||
{ reason: "io" },
|
||||
cause.message,
|
||||
cause.stdout,
|
||||
{
|
||||
cause: cause.cause,
|
||||
...(cause.exitCode === null
|
||||
? {}
|
||||
: { exitCode: cause.exitCode }),
|
||||
},
|
||||
);
|
||||
}
|
||||
const delayMs = VERIFY_SETTLE_DELAYS_MS[attempt];
|
||||
// Retry only the producer's explicit transient diagnostic, or a localized
|
||||
// refusal of a readable target that may be the same, before any mount.
|
||||
// Retry only the producer's explicit transient diagnostic, before any mount.
|
||||
if (
|
||||
(cause.verifyDiagnostic !== "Resource temporarily unavailable" &&
|
||||
!cause.unrecognizedImage) ||
|
||||
cause.verifyDiagnostic !== "Resource temporarily unavailable" ||
|
||||
delayMs === undefined ||
|
||||
deadline - performance.now() <= delayMs
|
||||
)
|
||||
@@ -569,72 +523,6 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
this.#devices = [];
|
||||
return "none";
|
||||
}
|
||||
|
||||
#cancelledVerification(cause: unknown): NativeDmgCommandFailure {
|
||||
return new NativeDmgCommandFailure(
|
||||
{ reason: "cancelled" },
|
||||
"DMG operation cancelled",
|
||||
"",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
#throwIfVerificationCancelled(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted === true)
|
||||
throw this.#cancelledVerification(signal.reason);
|
||||
}
|
||||
|
||||
async #isUnreadableTarget(
|
||||
signal: AbortSignal | undefined,
|
||||
primaryFailure: NativeDmgCommandFailure,
|
||||
): Promise<boolean> {
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await openRegularFile(this.path, {
|
||||
symlinks: "follow",
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileAdmissionFailure) {
|
||||
this.#verificationProbeHandle = cause.owner;
|
||||
const failure =
|
||||
signal?.aborted === true
|
||||
? this.#cancelledVerification(signal.reason)
|
||||
: primaryFailure;
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
failure,
|
||||
ArtifactReaderFailure.cleanupObservation(
|
||||
cause.cleanupCause,
|
||||
`DMG verification target descriptor ${this.path}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (signal?.aborted === true)
|
||||
throw this.#cancelledVerification(signal.reason);
|
||||
if (cause instanceof NonRegularFileReadError) return false;
|
||||
return classifyCommandFailure(cause, undefined).reason === "io";
|
||||
}
|
||||
|
||||
const owner = new OwnedFileHandle(handle);
|
||||
this.#verificationProbeHandle = owner;
|
||||
try {
|
||||
await owner.close();
|
||||
this.#verificationProbeHandle = undefined;
|
||||
} catch (cause: unknown) {
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
signal?.aborted === true
|
||||
? this.#cancelledVerification(signal.reason)
|
||||
: primaryFailure,
|
||||
ArtifactReaderFailure.cleanupObservation(
|
||||
cause,
|
||||
`DMG verification target descriptor ${this.path}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (signal?.aborted === true)
|
||||
throw this.#cancelledVerification(signal.reason);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const failureMessage = (cause: unknown): string =>
|
||||
@@ -756,17 +644,24 @@ const processOutput = (cause: unknown, field: "stdout" | "stderr"): string => {
|
||||
const classifyCommandFailure = (
|
||||
cause: unknown,
|
||||
operation: string | undefined,
|
||||
): NativeDmgFailureClassification => {
|
||||
): {
|
||||
readonly reason: ArtifactReaderFailure["reason"];
|
||||
readonly verifyDiagnostic?: string;
|
||||
} => {
|
||||
if (typeof cause !== "object" || cause === null)
|
||||
return { reason: "unavailable" };
|
||||
const code = Reflect.get(cause, "code");
|
||||
const exitCode = Reflect.get(cause, "exitCode");
|
||||
if (typeof code === "number" || typeof exitCode === "number") {
|
||||
if (operation !== "verify") return { reason: "unavailable" };
|
||||
return classifyVerifyFailure(
|
||||
const diagnostic = verifyFailureDiagnostic(
|
||||
processOutput(cause, "stdout"),
|
||||
processOutput(cause, "stderr"),
|
||||
);
|
||||
return {
|
||||
reason: verifyFailureReason(diagnostic),
|
||||
...(diagnostic === undefined ? {} : { verifyDiagnostic: diagnostic }),
|
||||
};
|
||||
}
|
||||
if (code === "ENOENT") {
|
||||
const syscall = Reflect.get(cause, "syscall");
|
||||
@@ -792,26 +687,9 @@ const classifyCommandFailure = (
|
||||
return { reason: "unavailable" };
|
||||
};
|
||||
|
||||
const classifyVerifyFailure = (
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
): NativeDmgFailureClassification => {
|
||||
const diagnostic = verifyFailureDiagnostic(stdout, stderr);
|
||||
const reason = verifyFailureReason(diagnostic);
|
||||
const untranslated =
|
||||
reason === undefined
|
||||
? untranslatedVerifyFailure(stdout, stderr)
|
||||
: undefined;
|
||||
return {
|
||||
...untranslated,
|
||||
reason: reason ?? untranslated?.reason ?? "unavailable",
|
||||
...(diagnostic === undefined ? {} : { verifyDiagnostic: diagnostic }),
|
||||
};
|
||||
};
|
||||
|
||||
const verifyFailureReason = (
|
||||
diagnostic: string | undefined,
|
||||
): ArtifactReaderFailure["reason"] | undefined => {
|
||||
): ArtifactReaderFailure["reason"] => {
|
||||
switch (diagnostic) {
|
||||
// hdiutil follows AppleLanguages, so en-GB and en-AU spell it "recognised".
|
||||
case "image not recognized":
|
||||
@@ -824,10 +702,8 @@ const verifyFailureReason = (
|
||||
case "Permission denied":
|
||||
case "Input/output error":
|
||||
return "io";
|
||||
case "Resource temporarily unavailable":
|
||||
return "unavailable";
|
||||
default:
|
||||
return undefined;
|
||||
return "unavailable";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -836,33 +712,13 @@ const verifyFailureDiagnostic = (
|
||||
stderr: string,
|
||||
): string | undefined => {
|
||||
const prefix = "hdiutil: verify failed - ";
|
||||
return outputLines(stdout, stderr)
|
||||
return `${stdout}\n${stderr}`
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith(prefix))
|
||||
?.slice(prefix.length);
|
||||
};
|
||||
|
||||
// hdiutil localizes the trailing failure reason but not its own status lines.
|
||||
const INVALID_CHECKSUM_LINE = /^hdiutil: verify: checksum of ".*" is INVALID$/u;
|
||||
const UNRECOGNIZED_IMAGE_LINE =
|
||||
/^hdiutil: verify: unable to recognize ".*" as a disk image\./u;
|
||||
|
||||
const untranslatedVerifyFailure = (
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
): NativeDmgFailureClassification | undefined => {
|
||||
const lines = outputLines(stdout, stderr);
|
||||
if (lines.some((line) => INVALID_CHECKSUM_LINE.test(line)))
|
||||
return { reason: "integrity" };
|
||||
// Malformed, unreadable and transiently busy targets all produce this line;
|
||||
// only the localized reason distinguishes them, so the cause stays unknown.
|
||||
if (lines.some((line) => UNRECOGNIZED_IMAGE_LINE.test(line)))
|
||||
return { reason: "unavailable", unrecognizedImage: true };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const outputLines = (stdout: string, stderr: string): string[] =>
|
||||
`${stdout}\n${stderr}`.split(/\r?\n/u).map((line) => line.trim());
|
||||
|
||||
const command = (
|
||||
arguments_: readonly string[],
|
||||
effects: ArtifactCommand["effects"],
|
||||
|
||||
@@ -36,11 +36,6 @@ import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
|
||||
import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js";
|
||||
import {
|
||||
binaryPlistNumberLiterals,
|
||||
xmlPlistNumberLiterals,
|
||||
} from "./PlistNumberLiterals.js";
|
||||
import type { PlistNumberLiterals } from "../../domain/apple/plistNumbers.js";
|
||||
import {
|
||||
hasBinaryPlistSignature,
|
||||
hasNibArchiveSignature,
|
||||
@@ -134,7 +129,6 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
bytes,
|
||||
decodeBudget.remainingBytes,
|
||||
xmlText,
|
||||
{ numberProjection: true },
|
||||
);
|
||||
decodeBudget.reserve(
|
||||
decodedReservation.estimatedBytes,
|
||||
@@ -148,15 +142,9 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
value: raw,
|
||||
omittedPrototypeKeys,
|
||||
unknownDateCount,
|
||||
numberLiterals,
|
||||
} = nib === null
|
||||
? decodePlist(bytes, xmlText, binaryPrototypeKeyObserved)
|
||||
: {
|
||||
value: nib.raw,
|
||||
omittedPrototypeKeys: 0,
|
||||
unknownDateCount: 0,
|
||||
numberLiterals: undefined,
|
||||
};
|
||||
: { value: nib.raw, omittedPrototypeKeys: 0, unknownDateCount: 0 };
|
||||
if (nib !== null && nib.omitted > 0)
|
||||
incompleteHierarchies.set(entry.path, nib.omitted);
|
||||
if (nib !== null && nib.unresolvedConnections > 0)
|
||||
@@ -173,7 +161,6 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
? "storyboard_scene"
|
||||
: "nib",
|
||||
raw,
|
||||
...(numberLiterals === undefined ? {} : { numberLiterals }),
|
||||
...(nib === null || nib.omitted === 0
|
||||
? {}
|
||||
: { omittedSerializedHierarchyReferences: nib.omitted }),
|
||||
@@ -746,7 +733,6 @@ const decodePlist = (
|
||||
readonly value: JsonValue;
|
||||
readonly omittedPrototypeKeys: number | null;
|
||||
readonly unknownDateCount: number;
|
||||
readonly numberLiterals: PlistNumberLiterals;
|
||||
} => {
|
||||
const binary = hasBinaryPlistSignature(bytes);
|
||||
const { value, omittedPrototypeKeys } = binary
|
||||
@@ -760,8 +746,5 @@ const decodePlist = (
|
||||
value: projected.value,
|
||||
omittedPrototypeKeys,
|
||||
unknownDateCount: projected.unknownDateCount,
|
||||
numberLiterals: binary
|
||||
? binaryPlistNumberLiterals(bytes)
|
||||
: xmlPlistNumberLiterals(xmlText ?? decodeXmlPlistText(bytes)),
|
||||
};
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { buildBinary } from "plist";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js";
|
||||
@@ -11,34 +10,6 @@ import { parseXmlPropertyList } from "../../domain/propertyListKeys.js";
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
|
||||
const example = "<!DOCTYPE document [ harmless example ]>";
|
||||
|
||||
it.each([
|
||||
[
|
||||
"XML",
|
||||
Buffer.from(
|
||||
"<plist><array><integer>9007199254740993</integer></array></plist>",
|
||||
),
|
||||
],
|
||||
["binary", Buffer.from(buildBinary([9007199254740992]))],
|
||||
] as const)(
|
||||
"reserves %s number metadata and value projection before decoding",
|
||||
(_format, bytes) => {
|
||||
const ordinary = preflightPropertyListDecode(
|
||||
bytes,
|
||||
1024 * 1024,
|
||||
).estimatedBytes;
|
||||
expect(() =>
|
||||
preflightPropertyListDecode(bytes, ordinary, undefined, {
|
||||
numberProjection: true,
|
||||
}),
|
||||
).toThrow(/budget/u);
|
||||
expect(() =>
|
||||
preflightPropertyListDecode(bytes, 1024 * 1024, undefined, {
|
||||
numberProjection: true,
|
||||
}),
|
||||
).not.toThrow();
|
||||
},
|
||||
);
|
||||
const archive = (content: string, comment = "") =>
|
||||
`<?xml version="1.0"?><plist version="1.0">${comment}<dict><key>$archiver</key><string>NSKeyedArchiver</string><key>$objects</key><array><string>$null</string><dict><key>$class</key><dict><key>CF$UID</key><integer>2</integer></dict><key>title</key><string>${content}</string></dict><dict><key>$classname</key><string>NSButton</string><key>$classes</key><array><string>NSButton</string><string>NSObject</string></array></dict></array><key>$top</key><dict><key>root</key><dict><key>CF$UID</key><integer>1</integer></dict></dict></dict></plist>`;
|
||||
|
||||
|
||||
@@ -102,18 +102,11 @@ export const preflightPropertyListDecode = (
|
||||
bytes: Buffer,
|
||||
maxBytes: number,
|
||||
xmlText?: string,
|
||||
options: Pick<BinaryPlistExpansionOptions, "numberProjection"> = {},
|
||||
): PlistDecodePreflight => {
|
||||
const { numberProjection = false } = options;
|
||||
const expansion = hasBinaryPlistSignature(bytes)
|
||||
? estimateBinaryPlistExpansion(bytes, maxBytes, { numberProjection })
|
||||
? estimateBinaryPlistExpansion(bytes, maxBytes)
|
||||
: {
|
||||
estimatedBytes: estimateXmlPlistExpansion(
|
||||
bytes,
|
||||
maxBytes,
|
||||
xmlText,
|
||||
numberProjection,
|
||||
),
|
||||
estimatedBytes: estimateXmlPlistExpansion(bytes, maxBytes, xmlText),
|
||||
binaryPrototypeKeyObserved: false,
|
||||
};
|
||||
if (expansion.estimatedBytes > maxBytes)
|
||||
@@ -128,13 +121,10 @@ const estimateXmlPlistExpansion = (
|
||||
bytes: Buffer,
|
||||
maxBytes: number,
|
||||
xmlText?: string,
|
||||
numberProjection = false,
|
||||
): number => {
|
||||
const xml = xmlText ?? decodeXmlPlistText(bytes);
|
||||
const decodedSourceBytes = xml.length * 2;
|
||||
let elementCount = 0;
|
||||
// Reserve room for numeric source metadata and the projected attribute copy.
|
||||
const elementBytes = numberProjection ? 256 : 128;
|
||||
// Value nesting is bounded by the shared plist parser, which counts plist
|
||||
// value depth; raw element depth would also count the envelope and keys.
|
||||
for (const token of xmlPreflightTokens(xml)) {
|
||||
@@ -149,10 +139,10 @@ const estimateXmlPlistExpansion = (
|
||||
continue;
|
||||
}
|
||||
elementCount += 1;
|
||||
if (decodedSourceBytes * 2 + elementCount * elementBytes > maxBytes)
|
||||
if (decodedSourceBytes * 2 + elementCount * 128 > maxBytes)
|
||||
return maxBytes + 1;
|
||||
}
|
||||
return decodedSourceBytes * 2 + elementCount * elementBytes;
|
||||
return decodedSourceBytes * 2 + elementCount * 128;
|
||||
};
|
||||
|
||||
/** Consume each XML span once; malformed tokens cannot restart suffix scans. */
|
||||
@@ -207,8 +197,6 @@ const interfaceBuilderBudgetFailure = (
|
||||
|
||||
/** How a caller names and reports binary plist expansion failures. */
|
||||
export interface BinaryPlistExpansionOptions {
|
||||
/** Include original number metadata and exact-integer attribute projection. */
|
||||
readonly numberProjection?: boolean;
|
||||
/** Budget named in failure messages. */
|
||||
readonly budget?: string;
|
||||
/** Error for budget, depth, and reference-cycle failures. */
|
||||
@@ -241,7 +229,6 @@ export const estimateBinaryPlistExpansion = (
|
||||
budget = "the aggregate Interface Builder decode budget",
|
||||
fail = interfaceBuilderBudgetFailure,
|
||||
bounds = "strict",
|
||||
numberProjection = false,
|
||||
}: BinaryPlistExpansionOptions = {},
|
||||
): PlistDecodePreflight => {
|
||||
if (bytes.length < 40)
|
||||
@@ -274,10 +261,7 @@ export const estimateBinaryPlistExpansion = (
|
||||
offsetTable + objectCount * offsetSize > tableEnd
|
||||
)
|
||||
throw new TypeError("binary plist trailer or object table is invalid");
|
||||
// Metadata covers the full object table; projected values may repeat through references.
|
||||
const objectBytes = numberProjection ? 144 : 16;
|
||||
const expandedNodeBytes = numberProjection ? 224 : 96;
|
||||
let estimate = bytes.length * 2 + objectCount * objectBytes;
|
||||
let estimate = bytes.length * 2 + objectCount * 16;
|
||||
let binaryPrototypeKeyObserved = false;
|
||||
if (estimate > maxBytes)
|
||||
throw fail("expansion", `binary plist object table exceeds ${budget}`);
|
||||
@@ -302,7 +286,7 @@ export const estimateBinaryPlistExpansion = (
|
||||
throw fail("depth", `binary plist nesting exceeds ${budget}`);
|
||||
if (active.has(entry.object))
|
||||
throw fail("cycle", `binary plist reference cycle exceeds ${budget}`);
|
||||
estimate += expandedNodeBytes;
|
||||
estimate += 96;
|
||||
if (estimate > maxBytes)
|
||||
throw fail(
|
||||
"expansion",
|
||||
@@ -368,9 +352,7 @@ export const estimateBinaryPlistExpansion = (
|
||||
const refsEnd = cursor + childCount * referenceSize;
|
||||
if (!Number.isSafeInteger(refsEnd) || refsEnd > objectsEnd)
|
||||
throw new TypeError("binary plist reference table is truncated");
|
||||
const availableNodes = Math.floor(
|
||||
(maxBytes - estimate) / expandedNodeBytes,
|
||||
);
|
||||
const availableNodes = Math.floor((maxBytes - estimate) / 96);
|
||||
if (childCount > availableNodes - pendingObjectCount)
|
||||
throw fail(
|
||||
"expansion",
|
||||
|
||||
@@ -214,16 +214,9 @@ const materializeSelection = async ({
|
||||
readonly selected: readonly SelectedOccurrence[];
|
||||
readonly signal: AbortSignal | undefined;
|
||||
}): Promise<ArtifactExtractionResult> => {
|
||||
const byPath = new Map<string, SelectedOccurrence>();
|
||||
for (const item of selected) {
|
||||
const path = item.occurrence.logical_path;
|
||||
if (byPath.has(path))
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Artifact extraction path collision: ${JSON.stringify(path)}. Multiple selected occurrences share this normalized logical path; they cannot be materialized at the same destination path, even when the archive is valid. Inventory retains each occurrence.`,
|
||||
);
|
||||
byPath.set(path, item);
|
||||
}
|
||||
const byPath = new Map(
|
||||
selected.map((item) => [item.occurrence.logical_path, item]),
|
||||
);
|
||||
const reader = await createReader(sourcePath, input);
|
||||
const readerOwner: ArtifactResourceOwner = {
|
||||
kind: "reader" as const,
|
||||
|
||||
@@ -42,18 +42,13 @@ import {
|
||||
type WebScreenshot,
|
||||
type WebScreenshotDiff,
|
||||
} from "../domain/webScreenshot.js";
|
||||
import {
|
||||
AnalysisError,
|
||||
type AnalysisPartialObservation,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
@@ -78,7 +73,6 @@ import { comparePngScreenshots } from "./PngVisualDiff.js";
|
||||
|
||||
import { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { CdpTargetSessionOwner } from "./CdpTargetSessionOwner.js";
|
||||
const CLEANUP_DOMAINS = [
|
||||
"Network",
|
||||
"Debugger",
|
||||
@@ -89,12 +83,6 @@ const CLEANUP_DOMAINS = [
|
||||
|
||||
/** Passive browser observation through a selected user-owned CDP endpoint. */
|
||||
export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
readonly #owner = new CdpTargetSessionOwner();
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.#owner.close();
|
||||
}
|
||||
|
||||
identity(): ProviderIdentity {
|
||||
return CDP_BROWSER_PROVIDER_IDENTITY;
|
||||
}
|
||||
@@ -108,11 +96,8 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
input.cdp_endpoint,
|
||||
"list_browser_targets",
|
||||
options.signal,
|
||||
undefined,
|
||||
this.#owner,
|
||||
);
|
||||
if (endpoint.connection !== undefined)
|
||||
await this.#owner.finish(endpoint.connection);
|
||||
await endpoint.connection?.close();
|
||||
const { discovery } = endpoint;
|
||||
const selected = selectTargets(
|
||||
discovery,
|
||||
@@ -163,9 +148,12 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
input: AnalyzeWebBundleInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebBundleAnalysis, AnalysisError>> {
|
||||
let captured: CapturedPage | undefined;
|
||||
try {
|
||||
captured = await this.#capture(input, options, "analyze_web_bundle");
|
||||
const captured = await this.#capture(
|
||||
input,
|
||||
options,
|
||||
"analyze_web_bundle",
|
||||
);
|
||||
const sourceMaps = input.fetch_source_maps
|
||||
? await fetchWebSourceMaps(
|
||||
captured.sourceMapRequests,
|
||||
@@ -188,14 +176,7 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
// fetchWebSourceMaps rethrows the exact abort reason. Preserve cancellation
|
||||
// even when a caller supplied an AnalysisError as that reason.
|
||||
if (options.signal?.aborted === true && cause === options.signal.reason) {
|
||||
return err(
|
||||
new AnalysisCancelledError("analyze_web_bundle", {
|
||||
cause,
|
||||
...(captured === undefined
|
||||
? {}
|
||||
: { partialObservation: captured.inspection }),
|
||||
}),
|
||||
);
|
||||
return err(new AnalysisCancelledError("analyze_web_bundle", { cause }));
|
||||
}
|
||||
return err(providerError(cause, "analyze_web_bundle", options.signal));
|
||||
}
|
||||
@@ -213,7 +194,6 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
"observe_web_session",
|
||||
options,
|
||||
observeCdpSession,
|
||||
this.#owner,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -234,7 +214,6 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
"discover_webmcp_tools",
|
||||
options,
|
||||
discoverWebMcp,
|
||||
this.#owner,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -267,7 +246,6 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
"capture_web_screenshot",
|
||||
options,
|
||||
captureCdpScreenshot,
|
||||
this.#owner,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -304,8 +282,6 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
operation,
|
||||
options,
|
||||
async (session) => await capturePage({ ...session, operation }),
|
||||
this.#owner,
|
||||
(captured) => captured.inspection,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -333,19 +309,14 @@ const withTargetSession = async <
|
||||
operation: BrowserObservationOperation,
|
||||
options: ExecutionOptions,
|
||||
work: (session: CdpTargetOperationSession<Input>) => Promise<Output>,
|
||||
owner: CdpTargetSessionOwner,
|
||||
partialObservation?: (output: Output) => AnalysisPartialObservation,
|
||||
): Promise<Output> => {
|
||||
const endpoint = await openCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
operation,
|
||||
options.signal,
|
||||
undefined,
|
||||
owner,
|
||||
);
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
let outcome: Result<Output, unknown>;
|
||||
let cleanupFailure: AnalysisError | undefined;
|
||||
let output: Output;
|
||||
try {
|
||||
const target = authorizeTarget(endpoint.discovery, input);
|
||||
const scopedInput = scopeToTargetOrigin(input, target);
|
||||
@@ -356,97 +327,40 @@ const withTargetSession = async <
|
||||
target,
|
||||
operation,
|
||||
options.signal,
|
||||
undefined,
|
||||
owner,
|
||||
)
|
||||
: await attachCdpTargetSession(
|
||||
endpoint.connection,
|
||||
target,
|
||||
operation,
|
||||
options.signal,
|
||||
owner,
|
||||
);
|
||||
outcome = ok(
|
||||
await work({
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
discovery: endpoint.discovery,
|
||||
target,
|
||||
input: scopedInput,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
...(options.progress === undefined
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
outcome = err(cause);
|
||||
}
|
||||
try {
|
||||
output = await work({
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
discovery: endpoint.discovery,
|
||||
target,
|
||||
input: scopedInput,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
...(options.progress === undefined ? {} : { progress: options.progress }),
|
||||
});
|
||||
} finally {
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(
|
||||
targetSession,
|
||||
CLEANUP_DOMAINS,
|
||||
options.signal,
|
||||
owner,
|
||||
);
|
||||
if (
|
||||
endpoint.connection !== undefined &&
|
||||
endpoint.connection !== targetSession?.connection
|
||||
)
|
||||
await owner.finish(endpoint.connection);
|
||||
} catch (cause: unknown) {
|
||||
cleanupFailure =
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderCleanupError(
|
||||
"rea-cdp-browser",
|
||||
[input.target_id],
|
||||
{ reason: cause instanceof Error ? cause.message : String(cause) },
|
||||
{ operation, cause },
|
||||
);
|
||||
// An unadopted browser socket is closed here; closing is idempotent.
|
||||
await endpoint.connection?.close();
|
||||
}
|
||||
|
||||
if (outcome.ok && options.signal?.aborted === true)
|
||||
outcome = err(
|
||||
partialObservation === undefined
|
||||
? new AnalysisCancelledError(operation, {
|
||||
cause: options.signal.reason,
|
||||
})
|
||||
: new AnalysisCancelledError(operation, {
|
||||
cause: options.signal.reason,
|
||||
partialObservation: partialObservation(outcome.value),
|
||||
}),
|
||||
);
|
||||
|
||||
if (cleanupFailure !== undefined) {
|
||||
if (!outcome.ok && outcome.error instanceof AnalysisError)
|
||||
throw analysisErrorWithCleanupFailure(
|
||||
outcome.error,
|
||||
cleanupFailure,
|
||||
operation,
|
||||
);
|
||||
if (!outcome.ok)
|
||||
throw new AggregateError(
|
||||
[outcome.error, cleanupFailure],
|
||||
`CDP operation ${operation} failed and cleanup remains incomplete.`,
|
||||
{ cause: outcome.error },
|
||||
);
|
||||
throw new ProviderCleanupError(
|
||||
"rea-cdp-browser",
|
||||
cleanupFailure.cleanupResources,
|
||||
{ cleanup_error: cleanupFailure.message },
|
||||
{
|
||||
operation,
|
||||
cause: cleanupFailure,
|
||||
...(partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: partialObservation(outcome.value) }),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!outcome.ok) throw outcome.error;
|
||||
return outcome.value;
|
||||
// Cancellation during teardown must not publish the completed observation.
|
||||
// Errors from work or cleanup still propagate through the try/finally above.
|
||||
if (options.signal?.aborted === true)
|
||||
throw new AnalysisCancelledError(operation, {
|
||||
cause: options.signal.reason,
|
||||
});
|
||||
return output;
|
||||
};
|
||||
|
||||
const selectTargets = (
|
||||
|
||||
@@ -299,90 +299,6 @@ describe("CDP frame capture", () => {
|
||||
});
|
||||
|
||||
describe("CDP accessibility capture", () => {
|
||||
it("keeps an explicit empty accessibility inventory complete", () => {
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
const capture = captureAccessibility([{ nodes: [] }], {
|
||||
includeText: true,
|
||||
completeness,
|
||||
});
|
||||
|
||||
expect(capture).toMatchObject({
|
||||
total: 0,
|
||||
nodes: [],
|
||||
hasUnobservedChildren: false,
|
||||
});
|
||||
expect(completeness.snapshot()).toMatchObject({
|
||||
unavailable_sections: [],
|
||||
excluded: [],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([undefined, { length: 1 }])(
|
||||
"keeps an omitted or non-array node inventory unknown: %s",
|
||||
(nodes) => {
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
const capture = captureAccessibility([{ nodes }], {
|
||||
includeText: true,
|
||||
completeness,
|
||||
});
|
||||
|
||||
expect(capture).toMatchObject({
|
||||
total: 0,
|
||||
nodes: [],
|
||||
hasUnobservedChildren: false,
|
||||
});
|
||||
expect(completeness.snapshot()).toMatchObject({
|
||||
unavailable_sections: ["accessibility"],
|
||||
excluded: [
|
||||
{
|
||||
section: "accessibility",
|
||||
reason: "invalid_protocol_value",
|
||||
count: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("retains valid nodes and counts malformed inventory entries", () => {
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
const capture = captureAccessibility(
|
||||
[
|
||||
{
|
||||
nodes: [
|
||||
null,
|
||||
{},
|
||||
{ nodeId: "missing-ignored" },
|
||||
{ nodeId: "invalid-ignored", ignored: "false" },
|
||||
{ nodeId: "invalid-parent", ignored: false, parentId: 3 },
|
||||
{ nodeId: "invalid-children", ignored: false, childIds: "bad" },
|
||||
{ nodeId: "node-1", ignored: false, role: { value: "button" } },
|
||||
"malformed",
|
||||
],
|
||||
},
|
||||
],
|
||||
{ includeText: true, completeness },
|
||||
);
|
||||
|
||||
expect(capture.total).toBe(3);
|
||||
expect(capture.nodes.map((node) => node.node_id)).toEqual([
|
||||
"invalid-parent",
|
||||
"invalid-children",
|
||||
"node-1",
|
||||
]);
|
||||
expect(capture.nodes[2]).toMatchObject({ role: "button", ignored: false });
|
||||
expect(completeness.snapshot()).toMatchObject({
|
||||
unavailable_sections: ["accessibility"],
|
||||
excluded: [
|
||||
{
|
||||
section: "accessibility",
|
||||
reason: "invalid_protocol_value",
|
||||
count: 7,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("retains full approved accessibility text", () => {
|
||||
const capture = captureAccessibility(
|
||||
[
|
||||
@@ -390,7 +306,6 @@ describe("CDP accessibility capture", () => {
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "node-1",
|
||||
ignored: false,
|
||||
role: { value: "button" },
|
||||
name: { value: "😀a" },
|
||||
description: { value: "éé" },
|
||||
@@ -413,7 +328,7 @@ describe("CDP accessibility capture", () => {
|
||||
description: "éé",
|
||||
states: [{ name: "disabled", value: true }],
|
||||
});
|
||||
expect(capture.hasUnobservedChildren).toBe(true);
|
||||
expect(capture.treeIncomplete).toBe(true);
|
||||
expect(capture.textCapture).toEqual({
|
||||
status: "included",
|
||||
retained_bytes: 9,
|
||||
@@ -428,7 +343,6 @@ describe("CDP accessibility capture", () => {
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "node-1",
|
||||
ignored: false,
|
||||
name: { value: "private name" },
|
||||
description: { value: "private description" },
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { InspectWebPageInput } from "../domain/browserObservation.js";
|
||||
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
admitRecords,
|
||||
numberValue,
|
||||
recordValue,
|
||||
recordsValue,
|
||||
@@ -506,63 +505,16 @@ export const captureAccessibility = (
|
||||
options: {
|
||||
readonly includeText: boolean;
|
||||
readonly unavailable?: boolean;
|
||||
readonly completeness?: CdpCaptureCompleteness;
|
||||
},
|
||||
): {
|
||||
readonly total: number;
|
||||
readonly nodes: WebPageInspection["accessibility"]["nodes"];
|
||||
readonly textCapture: WebPageInspection["accessibility"]["text_capture"];
|
||||
readonly hasUnobservedChildren: boolean;
|
||||
readonly treeIncomplete: boolean;
|
||||
} => {
|
||||
const all = results.flatMap((result) => {
|
||||
const inventory = admitRecords(requiredRecord(result).nodes);
|
||||
if (inventory === undefined) {
|
||||
options.completeness?.exclude(
|
||||
"accessibility",
|
||||
"invalid_protocol_value",
|
||||
null,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const nodes: UnknownRecord[] = [];
|
||||
let malformedCount = 0;
|
||||
for (const entry of inventory) {
|
||||
const node = recordValue(entry);
|
||||
if (
|
||||
node === undefined ||
|
||||
cdpStringValue(node.nodeId) === undefined ||
|
||||
cdpStringValue(node.nodeId)?.length === 0 ||
|
||||
typeof node.ignored !== "boolean"
|
||||
) {
|
||||
malformedCount += 1;
|
||||
continue;
|
||||
}
|
||||
const parentId = cdpStringValue(node.parentId);
|
||||
if (
|
||||
node.parentId !== undefined &&
|
||||
(parentId === undefined || parentId.length === 0)
|
||||
)
|
||||
malformedCount += 1;
|
||||
if (
|
||||
node.childIds !== undefined &&
|
||||
(!Array.isArray(node.childIds) ||
|
||||
node.childIds.some(
|
||||
(childId) =>
|
||||
cdpStringValue(childId) === undefined ||
|
||||
cdpStringValue(childId)?.length === 0,
|
||||
))
|
||||
)
|
||||
malformedCount += 1;
|
||||
nodes.push(node);
|
||||
}
|
||||
if (malformedCount > 0)
|
||||
options.completeness?.exclude(
|
||||
"accessibility",
|
||||
"invalid_protocol_value",
|
||||
malformedCount,
|
||||
);
|
||||
return nodes;
|
||||
});
|
||||
const all = results.flatMap((result) =>
|
||||
recordsValue(requiredRecord(result).nodes),
|
||||
);
|
||||
const nodeIds = new Set(
|
||||
all.flatMap((node) => {
|
||||
const nodeId = cdpStringValue(node.nodeId);
|
||||
@@ -593,7 +545,7 @@ export const captureAccessibility = (
|
||||
return {
|
||||
total: all.length,
|
||||
nodes,
|
||||
hasUnobservedChildren: all.some(
|
||||
treeIncomplete: all.some(
|
||||
(node) =>
|
||||
Array.isArray(node.childIds) &&
|
||||
node.childIds.some((childId) => {
|
||||
|
||||
@@ -12,10 +12,6 @@ const isRecord = (value: unknown): value is UnknownRecord =>
|
||||
export const recordValue = (value: unknown): UnknownRecord | undefined =>
|
||||
isRecord(value) ? value : undefined;
|
||||
|
||||
/** Preserve an omitted producer inventory as unknown rather than empty. */
|
||||
export const admitRecords = (value: unknown): readonly unknown[] | undefined =>
|
||||
Array.isArray(value) ? value : undefined;
|
||||
|
||||
export const recordsValue = (value: unknown): readonly UnknownRecord[] =>
|
||||
Array.isArray(value)
|
||||
? value.flatMap((item) => {
|
||||
|
||||
Loaded 100 of 314 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user