diff --git a/docs/flutter-analysis.md b/docs/flutter-analysis.md index 75c23dd66..ebf0ccd84 100644 --- a/docs/flutter-analysis.md +++ b/docs/flutter-analysis.md @@ -25,6 +25,26 @@ The equivalent MCP methods are `identify_flutter_build` (with `path`) and arm64-v8a, then x86_64, then x86, then armeabi-v7a, then the remaining ABIs alphabetically). +## Optional retained AOT delivery + +MCP `inspect_dart_aot` keeps complete output when `detail` is omitted or +`"complete"`. Explicit `detail: "summary"` returns the selected target/ABI/library +identity, all snapshot headers, structural coverage, string-pool coverage and +original lower-bound counts without the string lists. It retains complete source +Evidence and returns a distinct linked `inspect_analysis_view` summary. Analysis +and the caller's ABI choice are unchanged; one-shot CLI output stays complete. + +Use `normalized_result.parent_evidence_id` as the retained source for +`inspect_analysis_view`, with `view: {kind: "dart-aot", collection: "package_uris", +offset: 0, limit: 64}`. Collections are `package_uris`, `dart_uris` and +`class_like_tokens`. Pages preserve the original strings and order, selected ABI, +library identity and source coverage. `returned_counts` describe producer-returned +rows; counts ending in `_at_least` remain lower bounds. Exhausting a page sequence +does not make a partial pool complete or turn byte-pattern tokens into typed +classes/functions. Views do not reopen the APK. Original Evidence remains +exportable under its existing session lifetime. Summary requires a retaining +server; older servers keep the complete request after schema discovery. + ## What the AOT inspection returns `inspect_dart_aot` reads one ABI's `libapp.so` in full and reports: diff --git a/experiments/analysis-efficiency/README.md b/experiments/analysis-efficiency/README.md index 8afef3a66..b028953ce 100644 --- a/experiments/analysis-efficiency/README.md +++ b/experiments/analysis-efficiency/README.md @@ -7,6 +7,14 @@ JSON measurements belong outside the checkout. This document keeps the measured findings, verification scope and revision references; the scripts and `corpus.json` keep the reproduction inputs. Generated `results/` is ignored. +Follow-up: [latest-main capability audit](latest-main-audit.md) records the +Flutter, SQLite, TShark and sigrok priorities and the first shipped Dart AOT +baseline, preserving existing controls before introducing another selector. + +Follow-up: [Dart AOT summary and retained string pools](flutter-aot-summary.md) +records real LocalSend delivery pairs and old Evidence continuation, preserving +ABI selection, snapshot identity, original lower bounds and partial coverage. + Follow-up: [native PE/DOS entry views and real model verification](native-entry-views.md) records the post-merge native continuation, including a small-function case where four selected replies cost more bytes than the complete dossier. diff --git a/experiments/analysis-efficiency/corpus.json b/experiments/analysis-efficiency/corpus.json index 540e88cdd..3b0f5c574 100644 --- a/experiments/analysis-efficiency/corpus.json +++ b/experiments/analysis-efficiency/corpus.json @@ -235,6 +235,21 @@ }, "immutable_scope": "Published Hardhat artifact does not record an immutable-reference map; absence is unknown, not proof of no immutables. Runtime template bytes remain exact." } + }, + { + "id": "localsend-arm64.apk", + "file": "LocalSend-1.18.2-android-arm64v8.apk", + "bytes": 46558706, + "sha256": "82ec3568fba2aa5295b9aae8b76f701d7a4703d86b9f8bad749472038fbaeab3", + "provenance": { + "project": "LocalSend", + "version": "1.18.2", + "upstreamFile": "LocalSend-1.18.2-android-arm64v8.apk", + "url": "https://github.com/localsend/localsend/releases/download/v1.18.2/LocalSend-1.18.2-android-arm64v8.apk", + "transport_sha256": "82ec3568fba2aa5295b9aae8b76f701d7a4703d86b9f8bad749472038fbaeab3", + "transport_bytes": 46558706, + "derivation": "Official arm64 release APK, compared with GitHub release asset digest; byte-only analysis without execution" + } } ] } diff --git a/experiments/analysis-efficiency/flutter-aot-summary.md b/experiments/analysis-efficiency/flutter-aot-summary.md new file mode 100644 index 000000000..8893cd917 --- /dev/null +++ b/experiments/analysis-efficiency/flutter-aot-summary.md @@ -0,0 +1,160 @@ +# Dart AOT summary and retained string pools + +Continues [#2016](https://github.com/morluto/rea/issues/2016) and +[#2455](https://github.com/morluto/rea/pull/2455). The selected analyst question +asks for shipped LocalSend target/ABI/library identity, every nullable snapshot +observation, string-pool lower bounds and returned counts, and original coverage +and limitations. It does not require individual observed strings. + +## Compatibility and ownership + +MCP `inspect_dart_aot` accepts explicit `detail: "summary"`. Omitted and complete +requests preserve the complete result and original provider inputs. The CLI +remains complete. ABI omission and explicit ABI selection retain their original +meaning; the provider never receives the delivery field. Missing retention, +invalid detail and pre-cancellation fail before provider work. + +The existing application workflow retains the complete original Evidence, then +returns a distinct derived summary linked to its parent ID. It preserves target +and libapp digests, every snapshot section and nullable fact, original pool and +overall coverage, lower-bound counts and limitations. `returned_counts` reports +the lengths of the producer-returned arrays; it does not upgrade lower bounds +to a complete snapshot inventory. The analysis and original source retention +are unchanged. No new engine, persistent cache or plugin SDK is introduced. + +Read exact retained strings using `inspect_analysis_view`, the parent ID, and +`view: {kind: "dart-aot", collection: "package_uris", offset: 0, limit: 64}`. +Other collections are `dart_uris` and `class_like_tokens`. Follow the returned +next offset. Pages preserve source order, ABI/library identity and partial pool +coverage without opening the APK again. Page exhaustion describes retained +strings, not the entire snapshot. Strings remain byte-pattern observations, +rather than recovered typed class/function semantics. Missing historical +subjects remain explicitly unknown; conflicting recorded target digests fail. + +## Frozen runtime and real SDK pairs + +The official LocalSend 1.18.2 arm64 APK acquisition, size and SHA-256 are pinned +in `corpus.json`. No APK installation, target execution or version-dataset +lookup occurred. Source runtime: `811034a7283c9751a3ecbff33e8b2f28bec55793`; +compiled dist/bridge file-table SHA-256: +`dabbb63536c5862be839ecdc0f3eaad05b29c2a3816f57041528d30f754311be`. +The previous runtime is `28c84e78cf2fbfde4aceed345b773c59eb889f68`, digest +`5332dda13981f043e146b3601ebec1f380d9f698280ca53d75786b638a912e11`. +Its production sources were verified byte-equal to main f254 at acquisition. +Subsequent test-only changes and main's SAZ addition do not relabel these pins. + +Node is 24.18.0 and the SDK client/server are 2.3.1. Each workflow starts a fresh +server and validates the selected live advertised schema. Artifact bytes, +provider environment/search-path fingerprints, driver and locked dependencies +stay equal. Host page caches are not flushed. Heavy tasks run serially under +the unchanged CPU, memory, task and host guards. + +| Trial | AOT reply bytes | SDK workflow seconds | Payload CPU seconds | Kernel peak bytes | +| -------------- | --------------: | -------------------: | ------------------: | ----------------: | +| sdk-complete-1 | 151,341 | 5.139 | 6.819 | 691,441,664 | +| sdk-summary-1 | 12,037 | 5.219 | 6.608 | 639,430,656 | +| sdk-summary-2 | 12,037 | 5.064 | 6.405 | 640,868,352 | +| sdk-complete-2 | 151,341 | 5.128 | 6.468 | 633,614,336 | + +Both pairs use one successful public tool call and pass settings/fact comparison, +including all snapshot fields, coverage, original limitations and all counts. +Reply bytes fall 92.05%. Whole SDK time stays approximately 5.1 seconds; these +trials do not establish engine speed, CPU or memory savings. Payload CPU includes +driver/catalog work beyond the SDK workflow timer. Catalog JSON grows from +2,506,213 bytes on the previous runtime to 2,514,896 (about 0.35%). + +A fresh old-runtime complete request passes comparison against candidate +complete, including identical 151,341-byte replies. This is scoped compatibility +evidence, not a guarantee for every historical release or third-party client. + +## Real old Evidence continuation + +Fifteen successful public MCP calls import genuine old-runtime AOT Evidence, +project retained summary, reconstruct all 500 package URIs, 216 Dart URIs and +2,000 class-like tokens, compare inline portable summary, and export the original +record. The APK is absent during all these operations. Original string order, +lower bounds, partial coverage, nullable snapshot facts, subject and parent ID +remain intact; the original exported record is deeply equal to its source. + +The first private verification attempt used a nonexistent receipt filename and +failed before import or artifact relocation. That driver error is retained; +the corrected attempt selects the fresh old-runtime record. It is not a REA +defect or a successful migration trial. + +## Actual Codex task outcomes + +Four serial trials use the same frozen candidate in complete/summary/summary/ +complete order. Requested configuration is Codex CLI 0.161.0, `gpt-5.6-terra` +high, one exact enabled tool (`inspect_dart_aot`), and identical optional owned +context capture/discovery guidance. This requested alias does not certify the +server-resolved model. Temporary configuration points to the modified frozen +REA MCP; all copied credential profiles are removed and target bytes remain +unchanged. The source question is fixed; equal treatment instructions choose +detail and explain the summary carrier and parent reference. + +| Trial | Input | Cached | Uncached | Output | Seconds | MCP attempts | Failed attempts | Facts | +| ---------------- | -----: | -----: | -------: | -----: | ------: | -----------: | --------------: | ----- | +| model-complete-1 | 74,641 | 50,176 | 24,465 | 2,765 | 116.898 | 3 | 1 | pass | +| model-summary-1 | 38,253 | 22,016 | 16,237 | 1,449 | 88.904 | 1 | 0 | pass | +| model-summary-2 | 38,363 | 22,016 | 16,347 | 1,515 | 55.190 | 1 | 0 | pass | +| model-complete-2 | 66,585 | 38,144 | 28,441 | 2,217 | 79.180 | 2 | 0 | pass | + +Both pairs pass checks for every selected producer fact, exact nullable +observations, target digest, observed response Evidence IDs, original limitations, +artifact preservation and profile cleanup. The grader compares the reported +facts with producer observations; it is not an independent Dart deserializer. +Total input falls 48.75% / 42.38%, uncached input 33.63% / 42.52%, reported output +47.59% / 31.66%, and whole-task time 23.95% / 30.30%. Usage is the final aggregate +reported by the actual CLI, one usage record per trial; cached tokens are not +subtracted from total input. Reported output includes reasoning output (1,262, +321, 383 and 709 tokens respectively). + +The first complete trial initially supplied `artifact_path` instead of the +advertised `path`, corrected it, then repeated a successful complete call. +The second complete trial made two successful calls. Both summary trials made +one successful call. All attempts, discovery and correction costs remain +included. The paired whole-task improvement therefore includes actual model +decisions and the convenience of explicit returned counts; it does not isolate +serialization as the sole cause or establish engine acceleration. Two pairs on +one app/question/model configuration support this scope, not a universal gain. + +Payload CPU seconds / kernel peak bytes are respectively 12.556 / 876,937,216; +9.213 / 785,272,832; 9.108 / 793,939,968; and 13.164 / 803,209,216. Model payload +observations verify owned cleanup and zero phase limit events, with no guard +aborts. Whole-scope final counters remain unknown, so every strict all-selected +resource grade remains false even though the separate factual grade passes. + +## Resource and verification scope + +SDK and migration payload-exit observations verify owned cleanup and zero +observed memory/pids limit events, with peaks below 692 MB. Missing whole-scope +terminal counters remain unknown; the strict whole-resource grader stays false. +One-CPU quotas are experiment controls rather than CPU-efficiency claims. + +Production-provider fixture tests cover explicit ABI, old complete defaults, +actual SDK-converted schemas, exact pages after APK removal, portable records, +nullable/partial facts, malformed inputs, target digest conflicts and early +refusals. CLI/MCP acceptance covers original report equality, duplicate-member +refusals and unsupported snapshot kinds. Fixture successes are distinguished +from the real LocalSend SDK/migration workflows. Other Flutter apps, architectures, +SDK layouts, typed class recovery and runtime behavior remain unverified here. +Raw replies, binaries, model traces and temporary credentials stay outside Git. + +## Integration with the newer main + +The rebased verification runtime is `63984ce0898bd7997e6d9b138e6d96d0a24f2b09` +on main `a2d8b94dd50dab8d966239dd63ab84718aa8b4b4`, with compiled file-table +SHA-256 `19c8ccdd3e99bb46a5c54f4edd8c6b2d2eb4f3a4870b3c129061d38145382b68`. +Targeted Flutter/SAZ/shared-view integration passes 35 tests; one opposite +runtime-capacity branch is skipped because this host enforces decoder memory. +Documentation checks and two README conformance tests pass. + +Fresh real complete/summary SDK checks on this runtime match the earlier selected +facts and settings, with unchanged 151,341 / 12,037-byte AOT replies. Fifteen +old-Evidence migration calls again preserve every original string and record +with the source APK absent. The real one-shot CLI complete result also matches +the previous-runtime normalized report, subject, parameters and original ID. +An initial CLI-only controller check lacked an owned resource receipt; it is +retained and excluded from resource/performance claims, then repeated in the +bounded owned scope. These checks establish integration, rather than relabeling +the earlier paired model/SDK measurements as the rebased runtime. diff --git a/experiments/analysis-efficiency/latest-main-audit.md b/experiments/analysis-efficiency/latest-main-audit.md new file mode 100644 index 000000000..8f767fb64 --- /dev/null +++ b/experiments/analysis-efficiency/latest-main-audit.md @@ -0,0 +1,75 @@ +# Latest-main efficiency continuation + +Continues [#2016](https://github.com/morluto/rea/issues/2016). The 2026-10-13 +fetch resolved main to `f254f5ac2b804b353a711264bb178f43fdf07867`, the same +revision as the preceding Android conclusion. Newer capabilities below were +added during that earlier cycle; they are additional measurement scopes, not +new commits after that conclusion. + +| Capability | Existing controls | Next evidence needed | +| ----------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Flutter / Dart AOT (#1582) | Explicit ABI, bounded string observations, partial coverage | Real shipped snapshot/count question; opt-in delivery only if unrelated strings dominate | +| SQLite (#2271) | Compact inventory, selected table/columns, row limit | Real application database and model workflow before adding another selector | +| TShark (#2318) | Fields, filter, decode-as, pass, default 50 matching rows, paging, explicit null for all | Real public capture and repeated task cost; paging repeats dissection | +| sigrok (#2339) | Decoder/channels/annotation selection, default 50 display lines, paging | Real public capture; display-line count is not annotation count and paging repeats decoding | +| Retained unknown head results | Existing retained paging | Reuse the existing view rather than duplicate its implementation | + +Attached IDA, Frida and other live runtime workflows involve separate authority +and lifecycle questions and are outside this static parsing continuation. +Plugin SDKs and cross-session engine caches remain deferred until an observed +need and benefit justify them. + +## First selected scope: Dart AOT + +The official LocalSend 1.18.2 arm64 APK is pinned in `corpus.json`. The initial +real public SDK baseline inspected actual bytes without executing/installing +the APK. Its complete default AOT reply was 151,341 compact client-result JSON +bytes (70,487 normalized bytes), with 500 package URIs, 216 Dart URIs and 2,000 +class-like tokens. String-pool and overall coverage were partial. A second +explicit arm64 request returned the same normalized facts. + +These are exploratory first/warm calls, not an optimization pair. The frozen +runtime source is `28c84e78cf2fbfde4aceed345b773c59eb889f68`, with production +source bytes verified equal to main f254 at baseline acquisition. Its compiled +file-table digest is +`5332dda13981f043e146b3601ebec1f380d9f698280ca53d75786b638a912e11`; +the receipt is not relabeled as a newer build. + +The candidate adds explicit MCP `detail: "summary"` and retained pool pages. +Omitted/complete requests and the CLI preserve the old complete path. Analysis, +ABI selection and original Evidence stay intact. Summary retains target/libapp +identity, exact nullable snapshot observations, lower bounds, returned counts, +coverage and limitations. Pages preserve original string order and source +coverage without rescanning. No engine cache, provider protocol or third-party +plugin API is introduced. + +Follow-up measurements must compare identical selected facts, portable old +Evidence and actual model tasks. Reply bytes do not establish token or latency +improvements. Whole-scope final counters remain unknown if their cgroup has +disappeared, even when the narrower payload-exit observation is verified. +Raw records, binaries and model traces stay in the private lab outside Git. +Heavy work remains serial under the unchanged one-CPU, 3 GiB early-stop / 4 GiB +hard memory, no-swap and host-availability guards. + +## Main advanced during the experiment + +Main subsequently advanced to `a2d8b94dd50dab8d966239dd63ab84718aa8b4b4`, +adding native Fiddler SAZ parsing. Its web-network capture contract already +offers opt-in summary and retained capture pages, archive-member coordinates +and exact exposed encoded bytes. It decodes all records before ordinal +selection and rejects malformed/oversized inputs without partial success. +Measure real public SAZ artifacts using these controls before adding another +API or changing decoding or credential-redaction semantics. SAZ performance +remains unverified in this Flutter continuation. + +The Flutter cohorts keep their original frozen runtime; subsequent integration +onto this newer main does not relabel earlier measurement receipts. + +Main advanced again to `bb154d106` during CI, adding retained aioquic 1.5.0 +qlog 0.3 JSON parsing and qlog views. The existing capture summary/page path +already applies. Qlog's trace-scoped STREAM interval unions and endpoint loss +declarations have separate semantics: they do not recover bodies, prove network +loss or establish PCAP correlation. Measure genuine supported recordings and +existing retained views before adding delivery or caching. Other qlog layouts +and qlog performance remain unverified in the Flutter experiment. Shared-view +integration is checked after rebasing; earlier runtime receipts remain pinned. diff --git a/experiments/analysis-efficiency/measure.mjs b/experiments/analysis-efficiency/measure.mjs index bfac3adb6..8774ed30b 100644 --- a/experiments/analysis-efficiency/measure.mjs +++ b/experiments/analysis-efficiency/measure.mjs @@ -726,6 +726,54 @@ const workflows = { }, "sharex.dll": inspectManagedTarget, "sharex.helpers.dll": inspectManagedTarget, + "localsend-arm64.apk": async () => { + const input = { + path: values.path, + ...(values.delivery === "summary" ? { detail: "summary" } : {}), + }; + const definition = catalogDefinitions.get("inspect_dart_aot"); + if ( + definition === undefined || + !new Ajv2020({ strict: false, validateFormats: false }).validate( + definition.inputSchema, + input, + ) + ) + throw new Error( + "Selected Dart AOT delivery is not advertised by this runtime", + ); + const inspected = await call("inspect_dart_aot", input); + const result = normalized(inspected); + if ( + values.delivery === "summary" && + (result.kind !== "summary" || + result.parent_operation !== "inspect_dart_aot") + ) + throw new Error("Runtime returned no retained Dart AOT summary"); + const report = values.delivery === "summary" ? result.summary : result; + const { package_uris, dart_uris, class_like_tokens, ...pool } = + report.string_pool; + return { + artifact_sha256: targetSha(inspected), + target: report.target, + abi: report.abi, + libapp: report.libapp, + string_pool: + values.delivery === "summary" + ? pool + : { + ...pool, + returned_counts: { + package_uris: package_uris.length, + dart_uris: dart_uris.length, + class_like_tokens: class_like_tokens.length, + }, + }, + coverage: report.coverage, + limitations: inspected.limitations, + evidence_ids: [inspected.evidence_id], + }; + }, "openwrt.bin": async () => { const info = await call("inspect_firmware_regions", { path: values.path }); return { diff --git a/experiments/analysis-efficiency/questions/flutter-aot.txt b/experiments/analysis-efficiency/questions/flutter-aot.txt new file mode 100644 index 000000000..c4c3c3897 --- /dev/null +++ b/experiments/analysis-efficiency/questions/flutter-aot.txt @@ -0,0 +1 @@ +Statically inspect the shipped Flutter APK at {{artifact_path}} using only the registered REA inspect_dart_aot tool. Use its default ABI selection. Report target identity, chosen ABI, every libapp ELF/snapshot field exactly as reported including null, original string-pool coverage, all three counts ending in _at_least, printable_run_count, and returned_counts for package_uris, dart_uris and class_like_tokens. Compute returned_counts from the returned arrays if needed. Preserve original overall coverage and producer limitations exactly, including order. Counts ending in _at_least are lower bounds and class-like tokens are byte-pattern observations, not recovered typed classes. Do not return individual strings or infer Dart SDK versions, class semantics, runtime behavior or complete inventory. Final answer must be JSON with artifact_sha256, target, abi, libapp, string_pool, coverage, limitations, evidence_ids. Use actual response Evidence IDs. Do not execute the APK, use shell or host file tools, access the network, or call other agents. Stop when these facts are supported. diff --git a/experiments/analysis-efficiency/review-model.py b/experiments/analysis-efficiency/review-model.py index a61bb0984..b8fe16bb3 100644 --- a/experiments/analysis-efficiency/review-model.py +++ b/experiments/analysis-efficiency/review-model.py @@ -69,7 +69,7 @@ def scope_with_verified_counts(value, expected_scope, returned_counts): def main(): parser = argparse.ArgumentParser() - parser.add_argument('--domain', choices=['wasm', 'evm', 'android', 'managed', 'managed-nonempty', 'firmware', 'inventory', 'javascript', 'native', 'browser'], required=True) + parser.add_argument('--domain', choices=['wasm', 'evm', 'android', 'flutter-aot', 'managed', 'managed-nonempty', 'firmware', 'inventory', 'javascript', 'native', 'browser'], required=True) parser.add_argument('--trial', type=Path, required=True) parser.add_argument('--reference', type=Path) parser.add_argument('--resource-log', type=Path, required=True) @@ -133,6 +133,9 @@ def main(): for key, expected in {'artifact_bytes': n['artifact']['bytes'], 'bytecode_sha256': n['bytecode']['sha256'], 'bytecode_bytes': n['bytecode']['bytes'], 'functions': n['functions'], 'evidence_kind': n['evidence_kind'], 'discovery_completeness': n['discovery_completeness'], 'runtime_execution': n['runtime_execution'], 'bytecode_kind': n['bytecode']['kind'], 'hardfork': n['bytecode']['hardfork'], 'deployment_authenticity': n['bytecode']['deployment_authenticity']}.items(): check(key, answer.get(key), expected) check('evm_original_limitations', answer.get('limitations'), n['limitations']) + elif domain == 'flutter-aot': + for key in ['target', 'abi', 'libapp', 'string_pool', 'coverage', 'limitations']: + check(key, answer.get(key), reference[key]) elif domain == 'android': for key in ['package_name', 'version_name', 'version_code', 'matches']: check(key, answer.get(key), reference[key]) diff --git a/src/application/flutter/DartAotInspection.ts b/src/application/flutter/DartAotInspection.ts new file mode 100644 index 000000000..c11bbe1f4 --- /dev/null +++ b/src/application/flutter/DartAotInspection.ts @@ -0,0 +1,40 @@ +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { FlutterBuildAnalysisService } from "./FlutterBuildAnalysisService.js"; +import { + requireAnalysisViewRetention, + summarizeRetainedAnalysis, + type AnalysisViewRetention, +} from "../analysisView/AnalysisViewService.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import { dartAotInspectionInputSchema } from "../../domain/flutter/flutterBuildAnalysis.js"; +import type { Evidence } from "../../domain/evidence.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { err, type Result } from "../../domain/result.js"; + +/** Keep the caller's ABI choice and complete analysis; select only its delivery. */ +export const inspectDartAot = async ( + service: FlutterBuildAnalysisService, + rawInput: unknown, + retention: AnalysisViewRetention, + options?: ExecutionOptions, +): Promise> => { + const operation = "inspect_dart_aot"; + if (options?.signal?.aborted === true) + return err(new AnalysisCancelledError(operation)); + const parsed = dartAotInspectionInputSchema.safeParse(rawInput); + if (!parsed.success) + return err( + analysisInputErrorFromIssues(operation, parsed.error.issues, rawInput, { + cause: parsed.error, + }), + ); + if (parsed.data.detail === "summary") { + const ready = requireAnalysisViewRetention(operation, retention); + if (!ready.ok) return ready; + } + const { detail, ...providerInput } = parsed.data; + const executed = await service.execute(operation, providerInput, options); + if (!executed.ok || detail !== "summary") return executed; + return summarizeRetainedAnalysis(executed.value, retention); +}; diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index 06506c83e..6011d544b 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -16,7 +16,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_evm_interface, inspect_managed_artifact, inspect_android_package or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, native, inventory, WASM, EVM, managed artifact and Android package sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_evm_interface, inspect_managed_artifact, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, native, inventory, WASM, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/flutter/flutterToolContracts.ts b/src/contracts/flutter/flutterToolContracts.ts index 9a663d492..9ade1057b 100644 --- a/src/contracts/flutter/flutterToolContracts.ts +++ b/src/contracts/flutter/flutterToolContracts.ts @@ -1,7 +1,10 @@ +import { z } from "zod"; import { flutterInputSchemas, + dartAotInspectionInputSchema, flutterResultSchemas, } from "../../domain/flutter/flutterBuildAnalysis.js"; +import { dartAotSummaryViewResultSchema } from "../../domain/analysisView/analysisView.js"; import type { ToolContract } from "../toolContractTypes.js"; import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; import { toolContractMetadata } from "../toolEffects.js"; @@ -27,10 +30,15 @@ export const FLUTTER_TOOL_CONTRACTS = [ name: "inspect_dart_aot", ...toolContractMetadata("inspect_dart_aot"), description: - "Inspect one ABI's Dart AOT snapshot inside an APK: the four snapshot symbols located through the ELF dynamic symbol table with their offsets, sizes, and section headers (magic, kind, length), the snapshot hash, and a bounded string-pool projection from the isolate data section (package: source URIs, dart: SDK URIs, identifier-like tokens) exposing the app's dependency structure. String-pool entries are byte-pattern observations, not deserialized cluster semantics; typed class and function recovery needs an SDK-specific parser. No engine, no execution.", + "Inspect one ABI's Dart AOT snapshot inside an APK: the four snapshot symbols located through the ELF dynamic symbol table with their offsets, sizes, and section headers (magic, kind, length), the snapshot hash, and a bounded string-pool projection from the isolate data section (package: source URIs, dart: SDK URIs, identifier-like tokens) exposing the app's dependency structure. String-pool entries are byte-pattern observations, not deserialized cluster semantics; typed class and function recovery needs an SDK-specific parser. Omitted or complete detail returns the full observation. Select detail: \"summary\" for target/ABI/library identity, all snapshot headers, coverage and original lower-bound counts without string lists. The complete source stays retained; read returned strings via inspect_analysis_view with parent_evidence_id and kind dart-aot, collection package_uris/dart_uris/class_like_tokens, offset and limit. Exhausting pages does not imply complete pool coverage. No engine, no execution.", kind: "flutter-provider", - inputSchema: flutterInputSchemas.inspect_dart_aot, - outputSchema: evidenceResultOf(flutterResultSchemas.inspect_dart_aot), + inputSchema: dartAotInspectionInputSchema, + outputSchema: evidenceResultOf( + z.union([ + flutterResultSchemas.inspect_dart_aot, + dartAotSummaryViewResultSchema, + ]), + ), examples: [ { title: "Inspect the preferred ABI's AOT snapshot", @@ -40,6 +48,10 @@ export const FLUTTER_TOOL_CONTRACTS = [ title: "Inspect one ABI explicitly", input: { path: "/targets/Example.apk", abi: "arm64-v8a" }, }, + { + title: "Inspect snapshot identity with retained string lists", + input: { path: "/targets/Example.apk", detail: "summary" }, + }, ], }, ] as const satisfies readonly ToolContract[]; diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 05f879024..48eee73f0 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -73,6 +73,12 @@ import { androidViewRequestSchema, androidManifestViewItemSchema, } from "./androidPackageView.js"; +import { + projectDartAotView, + dartAotSummarySchema, + dartAotViewRequestSchema, + dartAotViewContextSchema, +} from "./dartAotView.js"; /** Public name of the selected-view workflow. */ export const INSPECT_ANALYSIS_VIEW_OPERATION = "inspect_analysis_view" as const; @@ -190,6 +196,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ apiSurfaceViewSchema, captureViewRequestSchema, qlogViewRequestSchema, + dartAotViewRequestSchema, ]); // The application owns admission and authenticated Evidence parsing. SDK and @@ -305,6 +312,7 @@ const viewResultBase = { "inspect_managed_artifact", "inspect_web_network_capture", "inspect_android_package", + "inspect_dart_aot", ]), parent_digest: digestSchema, view_digest: digestSchema, @@ -376,6 +384,13 @@ export const androidPackageSummaryViewResultSchema = z.strictObject({ summary: androidPackageSummarySchema, }); +/** Exact snapshot and pool-coverage carrier for selected AOT delivery. */ +export const dartAotSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + parent_operation: z.literal("inspect_dart_aot"), + summary: dartAotSummarySchema, +}); + /** Projected facts for one selected view of retained analysis Evidence. */ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ z.strictObject({ @@ -400,6 +415,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ managedSummarySchema, captureSummarySchema, androidPackageSummarySchema, + dartAotSummarySchema, ]), }), z.strictObject({ @@ -470,6 +486,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ ...viewResultBase, parent_operation: z.literal("inspect_android_package"), }), + z.strictObject({ + kind: z.literal("dart-aot"), + view: dartAotViewRequestSchema, + items: z.array(z.string()), + ...viewResultBase, + ...dartAotViewContextSchema.shape, + parent_operation: z.literal("inspect_dart_aot"), + }), z.strictObject({ kind: z.literal("inventory"), view: inventoryViewSchema, @@ -549,6 +573,10 @@ export type UnsignedAnalysisView = | Omit< Extract, "view_digest" + > + | Omit< + Extract, + "view_digest" >; /** Attach the content digest that must change when projected bytes change. */ @@ -594,7 +622,7 @@ const unsupportedParent = ( new AnalysisUnsupportedTargetError( INSPECT_ANALYSIS_VIEW_OPERATION, parentArtifactPath(parent), - `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, EVM, managed artifact, Android package, or web network capture Evidence.`, + `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`, ); const parentArtifactPath = (parent: AnalysisViewParent): string => { @@ -624,6 +652,7 @@ const retainedViewProjectors = new Map([ ["inspect_evm_interface", projectEvmInterfaceView], ["inspect_managed_artifact", projectManagedArtifactView], ["inspect_android_package", projectAndroidPackageView], + ["inspect_dart_aot", projectDartAotView], ["inspect_artifact", projectArtifactInventoryView], ["inventory_artifact", projectArtifactInventoryView], ]); diff --git a/src/domain/analysisView/dartAotView.ts b/src/domain/analysisView/dartAotView.ts new file mode 100644 index 000000000..4d66aa190 --- /dev/null +++ b/src/domain/analysisView/dartAotView.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { flutterResultSchemas } from "../flutter/flutterBuildAnalysis.js"; +import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; +import { err, ok, type Result } from "../result.js"; +import { + analysisViewInputError, + type AnalysisViewParent, + type AnalysisViewRequest, + type UnsignedAnalysisView, +} from "./analysisView.js"; +import { + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; + +const aotSchema = flutterResultSchemas.inspect_dart_aot; + +/** Snapshot identity and original coverage, without unrelated string inventories. */ +export const dartAotSummarySchema = aotSchema.extend({ + string_pool: aotSchema.shape.string_pool + .omit({ package_uris: true, dart_uris: true, class_like_tokens: true }) + .extend({ + returned_counts: z.strictObject({ + package_uris: z.number().int().nonnegative(), + dart_uris: z.number().int().nonnegative(), + class_like_tokens: z.number().int().nonnegative(), + }), + }), +}); + +/** Pages of the producer-returned strings, preserving original order and scope. */ +export const dartAotViewRequestSchema = z.strictObject({ + kind: z.literal("dart-aot"), + collection: z.enum(["package_uris", "dart_uris", "class_like_tokens"]), + offset: z.number().int().nonnegative().default(0), + limit: z.number().int().positive().default(64), +}); + +export const dartAotViewContextSchema = z.strictObject({ + abi: aotSchema.shape.abi, + libapp: aotSchema.shape.libapp, + source_coverage: aotSchema.shape.coverage, + string_pool: dartAotSummarySchema.shape.string_pool, +}); + +/** Reuse authenticated AOT Evidence without opening the APK or rescanning its ELF. */ +export const projectDartAotView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + if (view.kind !== "summary" && view.kind !== "dart-aot") + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: "Dart AOT Evidence supports summary and string-pool pages.", + }, + ]), + ); + const parsed = aotSchema.safeParse(parent.normalizedResult); + if (!parsed.success) + return err( + analysisInputErrorFromIssues( + "inspect_analysis_view", + parsed.error.issues.map((issue) => ({ + ...issue, + path: ["source", "normalized_result", ...issue.path], + })), + { source: { normalized_result: parent.normalizedResult } }, + { cause: parsed.error }, + ), + ); + const report = parsed.data; + const { package_uris, dart_uris, class_like_tokens, ...pool } = + report.string_pool; + const string_pool = { + ...pool, + returned_counts: { + package_uris: package_uris.length, + dart_uris: dart_uris.length, + class_like_tokens: class_like_tokens.length, + }, + }; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "inspect_dart_aot" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: { path: report.target.path, sha256: report.target.sha256 }, + limitations: [...parent.limitations], + unknowns: [ + "String-pool entries are byte-pattern observations, not recovered typed classes or functions.", + "Counts ending in _at_least remain lower bounds; exhausting returned strings does not establish a complete snapshot inventory.", + ...(parent.subject == null + ? [ + "The parent has no recorded subject; its normalized target identity cannot be cross-checked against a subject digest.", + ] + : []), + ], + }; + if (view.kind === "summary") + return ok({ + kind: "summary", + view, + ...shared, + summary: { ...report, string_pool }, + coverage: completeWithinViewCoverage(1, 1), + }); + const collection = report.string_pool[view.collection]; + const items = collection.slice(view.offset, view.offset + view.limit); + return ok({ + kind: "dart-aot", + view, + ...shared, + items, + abi: report.abi, + libapp: report.libapp, + source_coverage: report.coverage, + string_pool, + coverage: pageViewCoverage(view.offset, items.length, collection.length), + }); +}; diff --git a/src/domain/flutter/flutterBuildAnalysis.ts b/src/domain/flutter/flutterBuildAnalysis.ts index 068149f68..10c5b7b00 100644 --- a/src/domain/flutter/flutterBuildAnalysis.ts +++ b/src/domain/flutter/flutterBuildAnalysis.ts @@ -30,6 +30,12 @@ export const flutterInputSchemas = { }), } as const; +/** Retained delivery is selected independently of the provider's ABI request. */ +export const dartAotInspectionInputSchema = + flutterInputSchemas.inspect_dart_aot.extend({ + detail: z.enum(["complete", "summary"]).optional(), + }); + /** Supported Flutter-backed operations. */ export type FlutterOperation = keyof typeof flutterInputSchemas; /** Validated Flutter requests carry their selected operation. */ diff --git a/src/server/createServer.ts b/src/server/createServer.ts index c213efb0b..4929142c4 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -444,6 +444,7 @@ export const createServer = ( toolLogger, recordEvidence, analysisAdmission, + evidenceById, ); registerApktoolTools( server, diff --git a/src/server/registerFlutterTools.ts b/src/server/registerFlutterTools.ts index 5b601c5f7..cd57e12a9 100644 --- a/src/server/registerFlutterTools.ts +++ b/src/server/registerFlutterTools.ts @@ -1,10 +1,15 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js"; +import { inspectDartAot } from "../application/flutter/DartAotInspection.js"; +import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; import { toolContract } from "../contracts/toolContracts.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; import type { Logger } from "pino"; -import { createContractToolHandler } from "./contractToolHandler.js"; +import { + createContractToolHandler, + type ContractToolService, +} from "./contractToolHandler.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; /** Bind Flutter handlers to their exact named contracts through admission. */ @@ -14,6 +19,7 @@ export const registerFlutterTools = ( logger: Logger, recordEvidence?: EvidenceWriter["recordEvidence"], withAdmittedAnalysis?: WithAdmittedAnalysis, + evidenceById?: EvidenceLookup, ): void => { const handler = createContractToolHandler( server, @@ -22,14 +28,23 @@ export const registerFlutterTools = ( recordEvidence, withAdmittedAnalysis, ); - for (const contract of [ - toolContract("identify_flutter_build"), - toolContract("inspect_dart_aot"), - ] as const) { - server.registerTool( - contract.name, - toolRegistrationOptions(contract), - handler(contract), - ); - } + const identify = toolContract("identify_flutter_build"); + server.registerTool( + identify.name, + toolRegistrationOptions(identify), + handler(identify), + ); + const aotService: ContractToolService<"inspect_dart_aot"> = { + execute: (_operation, input, options) => + inspectDartAot(service, input, { recordEvidence, evidenceById }, options), + }; + const aotHandler = createContractToolHandler( + server, + aotService, + logger, + recordEvidence, + withAdmittedAnalysis, + ); + const aot = toolContract("inspect_dart_aot"); + server.registerTool(aot.name, toolRegistrationOptions(aot), aotHandler(aot)); }; diff --git a/tests/acceptance/flutterAnalysis.test.ts b/tests/acceptance/flutterAnalysis.test.ts index 457dce96b..11671785e 100644 --- a/tests/acceptance/flutterAnalysis.test.ts +++ b/tests/acceptance/flutterAnalysis.test.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { expect } from "vitest"; import { toolContract } from "../../src/contracts/toolContracts.js"; import { parseEvidence } from "../../src/domain/evidence.js"; +import { flutterResultSchemas } from "../../src/domain/flutter/flutterBuildAnalysis.js"; import { aotFixtureImage, engineFixtureImage, @@ -59,6 +60,9 @@ cliTest( }); expect(result.exitCode, result.stderr).toBe(0); const evidence = toolContract(name).outputSchema.parse(result.json); + const complete = flutterResultSchemas[name].parse( + evidence.normalized_result, + ); const response = await call(name, { path, ...(abi === undefined ? {} : { abi }), @@ -70,7 +74,7 @@ cliTest( expect(mcp.subject).toMatchObject({ local_path: path, format: "apk", - digest: { sha256: evidence.normalized_result.target.sha256 }, + digest: { sha256: complete.target.sha256 }, }); expect(mcp.evidence_id).toBe(evidence.evidence_id); if (name === "inspect_dart_aot") { @@ -134,6 +138,9 @@ cliTest( const evidence = toolContract("inspect_dart_aot").outputSchema.parse( result.json, ); + const complete = flutterResultSchemas.inspect_dart_aot.parse( + evidence.normalized_result, + ); const { call } = await connectLocalToolsMcp(); const response = await call("inspect_dart_aot", { path }); expect(response.isError, JSON.stringify(response)).not.toBe(true); @@ -146,7 +153,7 @@ cliTest( string_pool: { coverage: "complete" }, }); expect( - evidence.normalized_result.libapp.sections + complete.libapp.sections .filter((section) => section.name.endsWith("Data")) .map((section) => section.kind), ).toEqual([4, 4]); diff --git a/tests/boundary/mcp/dartAotSummaryMcp.test.ts b/tests/boundary/mcp/dartAotSummaryMcp.test.ts new file mode 100644 index 000000000..be6a970d3 --- /dev/null +++ b/tests/boundary/mcp/dartAotSummaryMcp.test.ts @@ -0,0 +1,285 @@ +import { unlink } from "node:fs/promises"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it, onTestFinished, vi } from "vitest"; +import { FlutterBuildAnalysisService } from "../../../src/application/flutter/FlutterBuildAnalysisService.js"; +import { inspectDartAot } from "../../../src/application/flutter/DartAotInspection.js"; +import { inspectAnalysisView } from "../../../src/application/analysisView/AnalysisViewService.js"; +import { createFlutterBuildAnalysisProvider } from "../../../src/composition/flutter.js"; +import { + analysisViewResultSchema, + dartAotSummaryViewResultSchema, +} from "../../../src/domain/analysisView/analysisView.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { flutterResultSchemas } from "../../../src/domain/flutter/flutterBuildAnalysis.js"; +import type { JsonValue } from "../../../src/domain/jsonValue.js"; +import { aotFixtureImage } from "../../../src/flutter/FlutterBuild.fixture.js"; +import { createFlutterApk } from "../../fixtures/flutterApk.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; + +it("honors explicit ABI and old full requests, validates SDK schemas and reuses complete Evidence after APK removal", async () => { + const path = await createFlutterApk("views.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotFixtureImage() }, + { path: "lib/x86_64/libapp.so", bytes: aotFixtureImage("b".repeat(32)) }, + ]); + const provider = createFlutterBuildAnalysisProvider(); + const execute = vi.spyOn(provider, "execute"); + const harness = await createApplicationMcpHarness({ + serverOptions: { flutterAnalysis: provider }, + }); + onTestFinished(harness.close); + const { client, session } = harness; + const catalog = (await client.listTools()).tools; + const definition = catalog.find((tool) => tool.name === "inspect_dart_aot"); + if (definition?.outputSchema === undefined) + throw new Error("Missing AOT output schema"); + const outputSchema: Record = definition.outputSchema; + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect(ajv.validateSchema(definition.inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + expect( + ajv.validate( + catalog.find((tool) => tool.name === "identify_flutter_build")! + .inputSchema, + { path, detail: "summary" }, + ), + ).toBe(false); + const reply = await client.callTool({ + name: "inspect_dart_aot", + arguments: { path, abi: "x86_64", detail: "summary" }, + }); + expect(reply.isError).not.toBe(true); + expect( + ajv.validate(outputSchema, reply.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const receipt = parseEvidence(reply.structuredContent); + const view = dartAotSummaryViewResultSchema.parse(receipt.normalized_result); + const parent = session.evidenceById(view.parent_evidence_id); + if (parent === undefined) + throw new Error("Complete AOT source was not retained"); + const report = flutterResultSchemas.inspect_dart_aot.parse( + parent.normalized_result, + ); + expect(report.abi).toBe("x86_64"); + expect(report.libapp.snapshot_hash).toBe("b".repeat(32)); + expect(parent.parameters).toEqual({ path, abi: "x86_64" }); + expect(execute).toHaveBeenLastCalledWith( + { operation: "inspect_dart_aot", input: { path, abi: "x86_64" } }, + expect.anything(), + ); + expect(view.summary.libapp).toEqual(report.libapp); + expect(view.summary.target).toEqual(report.target); + expect(view.summary.coverage).toBe(report.coverage); + expect(receipt.evidence_id).not.toBe(parent.evidence_id); + expect(receipt.evidence_links).toEqual([parent.evidence_id]); + for (const detail of [undefined, "complete"]) { + const complete = parseEvidence( + ( + await client.callTool({ + name: "inspect_dart_aot", + arguments: { + path, + abi: "x86_64", + ...(detail === undefined ? {} : { detail }), + }, + }) + ).structuredContent, + ); + expect(complete.operation).toBe("inspect_dart_aot"); + expect(complete.normalized_result).toEqual(parent.normalized_result); + expect(complete.parameters).toEqual(parent.parameters); + } + const preferred = parseEvidence( + (await client.callTool({ name: "inspect_dart_aot", arguments: { path } })) + .structuredContent, + ); + expect( + flutterResultSchemas.inspect_dart_aot.parse(preferred.normalized_result) + .abi, + ).toBe("arm64-v8a"); + const original = JSON.stringify(parent); + await unlink(path); + const calls = execute.mock.calls.length; + for (const collection of [ + "package_uris", + "dart_uris", + "class_like_tokens", + ] as const) { + const strings: string[] = []; + let offset = 0; + do { + const reply = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }, + view: { kind: "dart-aot", collection, offset, limit: 1 }, + }, + }); + expect(reply.isError).not.toBe(true); + const page = analysisViewResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + if (page.kind !== "dart-aot") throw new Error("Wrong pool page kind"); + strings.push(...page.items); + expect(page.libapp).toEqual(report.libapp); + expect(page.source_coverage).toBe(report.coverage); + expect(page.string_pool.coverage).toBe(report.string_pool.coverage); + offset = + page.coverage.next_offset ?? report.string_pool[collection].length; + } while (offset < report.string_pool[collection].length); + expect(strings).toEqual(report.string_pool[collection]); + } + expect(execute).toHaveBeenCalledTimes(calls); + expect(JSON.stringify(session.evidenceById(parent.evidence_id))).toBe( + original, + ); + expect( + session + .exportEvidenceBundle() + .records.find((record) => record.evidence_id === parent.evidence_id), + ).toEqual(parent); + const portable = inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view: { kind: "summary" }, + }); + if (!portable.ok) throw portable.error; + expect(portable.value.normalized_result).toEqual(view); +}, 30000); + +it("preserves null snapshot facts and pool lower bounds after exhaustion, rejecting malformed and conflicting identities", () => { + const report = { + target: { path: "/source.apk", bytes: 10, sha256: "a".repeat(64) }, + abi: "arm64-v8a", + libapp: { + coverage: "partial", + bytes: 1, + sha256: "b".repeat(64), + snapshot_hash: null, + snapshot_hash_candidates: [], + snapshot_hash_sources: 0, + sections: [], + }, + string_pool: { + coverage: "partial", + package_uris: ["package:opaque/a.dart"], + package_uri_count_at_least: 1000, + dart_uris: [], + dart_uri_count_at_least: 0, + class_like_tokens: ["incidental"], + class_like_token_count_at_least: 4000, + printable_run_count: 12, + }, + coverage: "partial", + }; + const evidence = ( + result: JsonValue = report, + sha: string | null = "a".repeat(64), + ) => + createEvidence( + sha === null + ? undefined + : { path: "/source.apk", sha256: sha, format: "apk" }, + { id: "flutter", name: "Flutter", version: null }, + { + operation: "inspect_dart_aot", + parameters: {}, + result, + limitations: ["Original uncertainty"], + }, + ); + for (const parent of [evidence(), evidence(report, null)]) { + const summary = inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view: { kind: "summary" }, + }); + if (!summary.ok) throw summary.error; + const result = dartAotSummaryViewResultSchema.parse( + summary.value.normalized_result, + ); + expect(result.summary.libapp.snapshot_hash).toBeNull(); + expect(result.summary.string_pool.package_uri_count_at_least).toBe(1000); + expect(result.summary.string_pool.returned_counts.package_uris).toBe(1); + expect(result.limitations).toEqual(parent.limitations); + const page = inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view: { + kind: "dart-aot", + collection: "package_uris", + offset: 0, + limit: 10, + }, + }); + if (!page.ok) throw page.error; + expect(page.value.normalized_result).toMatchObject({ + kind: "dart-aot", + items: report.string_pool.package_uris, + source_coverage: "partial", + string_pool: { coverage: "partial", package_uri_count_at_least: 1000 }, + coverage: { total: 1, exhausted: true }, + }); + } + expect( + inspectAnalysisView({ + source: { kind: "inline", evidence: evidence(report, "c".repeat(64)) }, + view: { kind: "summary" }, + }), + ).toMatchObject({ ok: false, error: { _tag: "EvidenceIntegrityError" } }); + expect( + inspectAnalysisView({ + source: { + kind: "inline", + evidence: evidence({ + ...report, + string_pool: { + ...report.string_pool, + package_uri_count_at_least: -1, + }, + }), + }, + view: { kind: "summary" }, + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); + expect( + inspectAnalysisView({ + source: { kind: "inline", evidence: evidence() }, + view: { kind: "android", facet: "manifest", offset: 0, limit: 1 }, + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); +}); + +it("rejects unavailable retention, invalid delivery and cancellation before producer work", async () => { + const provider = createFlutterBuildAnalysisProvider(); + onTestFinished(() => provider.close()); + const execute = vi.spyOn(provider, "execute"); + const service = new FlutterBuildAnalysisService(provider); + const retention = { recordEvidence: undefined, evidenceById: undefined }; + expect( + await inspectDartAot( + service, + { path: "/missing.apk", abi: "x86_64", detail: "summary" }, + retention, + ), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + expect( + await inspectDartAot( + service, + { path: "/missing.apk", detail: "other" }, + retention, + ), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); + expect( + await inspectDartAot( + service, + { path: "/missing.apk", detail: "summary" }, + retention, + { signal: AbortSignal.abort() }, + ), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); + expect(execute).not.toHaveBeenCalled(); +});