mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(cli): summarize managed members with durable Evidence paging (#5167)
Fixes #5148. Add opt-in durable managed member summaries and authenticated paging while preserving complete default output, original Evidence, cleanup ownership and queued cancellation. Verified with bounded real Newtonsoft/Redux/Rust tasks, independent oracles, 556 focused tests and the expanded domain gate; paired token estimates and latency tradeoffs are recorded in experiments/analysis-efficiency/managed-cli.md.
This commit is contained in:
1 parent
d8948efb81
commit
57fd3ea5d8
48 files changed
+4671
-196
No files matched your search
+16
-1
@@ -63,6 +63,16 @@ or repeat it to select several types.
|
||||
Selection limits decoding to the selected types; member references remain an
|
||||
assembly-wide inventory, so the output can still be large.
|
||||
|
||||
Select `--detail summary --evidence-bundle /absolute/path/members.json` to
|
||||
receive identity, selection, coverage, limitations and collection counts while
|
||||
saving the complete original Evidence in a new bundle. Summary requires that
|
||||
explicit destination; existing files are preserved. Later
|
||||
`inspect-analysis-view` calls can page the exact parent from the saved bundle
|
||||
without reopening the assembly. See
|
||||
[CLI summary and saved member rows](managed-code-analysis.md#cli-summary-and-saved-member-rows)
|
||||
for the complete command sequence. Omitted or `--detail complete` retains the
|
||||
full inline response.
|
||||
|
||||
Choose the workflow from the artifact's contents rather than its `.exe` or
|
||||
`.dll` extension. The managed parser requires PE/CLI metadata; it does not
|
||||
unpack .NET single-file hosts or decode IL2CPP. For the image, type and method
|
||||
@@ -279,7 +289,12 @@ Exports preserve an existing destination unless `--overwrite` is explicit.
|
||||
For application traces and version comparisons, see
|
||||
[JavaScript application workflows](javascript-application-workflows.md).
|
||||
MCP [retained Evidence references](mcp-contracts.md#retained-application-evidence-inputs)
|
||||
belong to one connection. Separate CLI calls consume full saved records.
|
||||
belong to one connection. Separate CLI calls can consume full saved records.
|
||||
`inspect-analysis-view REQUEST.json --evidence-bundle BUNDLE.json` resolves
|
||||
the request's `retained-evidence` source ID from that authenticated bundle,
|
||||
including bundles exported from an MCP session. The same view JSON works for
|
||||
every supported parent kind; no target is reopened and no record is selected
|
||||
by file order. Omit `--evidence-bundle` when the request supplies inline Evidence.
|
||||
|
||||
## Import historical source
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ rejects selected summary before analysis; complete delivery remains available.
|
||||
|
||||
Complete `inspect_managed_members` delivery always includes the assembly-wide
|
||||
`member_refs` and `exported_types`, which `type_names` does not narrow, so even
|
||||
one selected or unmatched type can return megabytes. Select
|
||||
one selected or unmatched type can return megabytes. In MCP, select
|
||||
`{"path":"/absolute/path/Assembly.dll","type_names":["Namespace.Type"],"detail":"summary"}`
|
||||
to keep the complete source in the session and receive identity, metadata
|
||||
status, the selection with its unmatched names, coverage/issues, the separate
|
||||
@@ -115,6 +115,49 @@ literal pool's own coverage. Older Evidence that never examined exported types
|
||||
or user strings reports a null count and refuses those pages rather than
|
||||
returning an empty one.
|
||||
|
||||
#### CLI summary and saved member rows
|
||||
|
||||
The CLI accepts the same `complete` and `summary` detail choices. Omitted or
|
||||
`--detail complete` returns complete Evidence. To receive a summary, choose a
|
||||
new Evidence bundle destination so its original complete parent remains
|
||||
available after the command exits:
|
||||
|
||||
```bash
|
||||
rea inspect-managed-members /absolute/path/Assembly.dll --type Namespace.Type \
|
||||
--detail summary --evidence-bundle /absolute/path/evidence/members.json --json
|
||||
```
|
||||
|
||||
`--evidence-bundle` writes the complete original Evidence in REA's canonical
|
||||
bundle format. Its parent directory must exist, and it never replaces an
|
||||
existing path. Relative paths resolve against the command's working directory.
|
||||
The option also works with complete delivery when a saved bundle is useful.
|
||||
Summary without a destination is rejected before analysis. A publication failure
|
||||
returns a typed error with the completed Evidence under
|
||||
`details.partial_observation`; it does not report successful summary delivery.
|
||||
Cancellation before publication prevents the new output. Once atomic
|
||||
publication has started, it can complete despite cancellation; a cancelled
|
||||
delivery then retains the committed path and byte count under
|
||||
`details.diagnostics.evidence_bundle`.
|
||||
|
||||
Copy `normalized_result.parent_evidence_id` from the summary into the
|
||||
`source.evidence_id` field of the `managed-members` request above, then save that
|
||||
small request as `members-page.json` and run:
|
||||
|
||||
```bash
|
||||
rea inspect-analysis-view /absolute/path/evidence/members-page.json \
|
||||
--evidence-bundle /absolute/path/evidence/members.json --json
|
||||
```
|
||||
|
||||
The view command authenticates the selected bundle and resolves that exact
|
||||
parent ID. It does not reopen the assembly, so the original target may have
|
||||
been moved or removed. Subsequent calls can select another collection or use
|
||||
the returned `coverage.next_offset` to continue the same collection. An inline
|
||||
source already includes its parent and cannot also select `--evidence-bundle`.
|
||||
|
||||
Summary changes delivery, while type selection still controls decoding and the
|
||||
same inspection resource limits apply. Each separate CLI page reads and
|
||||
authenticates the saved bundle; the full source is retained on disk.
|
||||
|
||||
## Shipped scope
|
||||
|
||||
The canonical parser admits PE/CLI bytes and their metadata/CIL without loading
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Summarize completed CLI trials; token counts are estimates, not model usage."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
import tiktoken
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('root', type=Path)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
root = args.root
|
||||
inputs = json.loads((root / 'measurement-inputs.json').read_text())
|
||||
expected = inputs['expected_runtime_bindings']
|
||||
assert expected['complete']['git_revision'] == inputs['baseline_revision']
|
||||
assert expected['selected']['git_revision'] == inputs['feature_parent_revision']
|
||||
assert expected['complete']['root'] != expected['selected']['root']
|
||||
assert expected['complete']['compiled_cli_sha256'] != expected['selected']['compiled_cli_sha256']
|
||||
assert expected['complete']['source_diff_sha256'] == hashlib.sha256(b'').hexdigest()
|
||||
assert expected['complete']['untracked_source_sha256'] == hashlib.sha256(b'[]').hexdigest()
|
||||
enc = tiktoken.get_encoding('cl100k_base')
|
||||
count = lambda text: len(enc.encode(text, disallowed_special=()))
|
||||
compact = lambda obj: json.dumps(obj, ensure_ascii=False, separators=(',', ':'))
|
||||
runs = []
|
||||
for case in sorted(root.glob('pair-*')):
|
||||
if not case.is_dir():
|
||||
continue
|
||||
report = json.loads((case / 'report.json').read_text())
|
||||
assert report['status'] == 'passed' and report['cleanup_verified']
|
||||
assert report['runtime_binding_verified']
|
||||
assert report['runtime_binding'] == expected[report['mode']], case.name
|
||||
assert str(Path(report['runtime']).resolve()) == report['runtime_binding']['root']
|
||||
receipt = next(json.loads(line) for line in reversed(case.with_name(case.name + '.log').read_text().splitlines()) if line.startswith('{"resource_scope":'))
|
||||
obs = receipt['payload_resource_observation']
|
||||
assert receipt['abort'] is None and receipt['payload_resource_observation_verified']
|
||||
assert obs['payload_exit_code'] == 0 and obs['payload_cleanup_verified']
|
||||
assert obs['resource_events']['memory.events']['oom_kill'] == 0
|
||||
tokens = {'question_and_recipe': count(report['question'] + '\n' + report['recipe']),
|
||||
'arguments': sum(count(compact(call['arguments'])) for call in report['calls']),
|
||||
'help_and_replies': sum(count((case / call['stdout_file']).read_text()) for call in report['calls'])}
|
||||
tokens['whole_task'] = sum(tokens.values())
|
||||
runs.append({'case': case.name, 'mode': report['mode'], 'cache': report['cache'], 'runtime': report['runtime'], 'runtime_binding': report['runtime_binding'], 'runtime_binding_audit_ms': report['runtime_binding_audit_ms'],
|
||||
'wall_seconds': receipt['elapsed_seconds'], 'cpu_seconds': obs['cpu_stat']['usage_usec'] / 1e6,
|
||||
'peak_process_family_bytes_including_page_cache': obs['memory_peak_bytes'],
|
||||
'tokens_estimated': tokens, 'calls': report['calls'],
|
||||
'parent_evidence_id': report['parent_evidence_id'], 'parent_semantic_sha256': report['parent_semantic_sha256'],
|
||||
'answer_sha256': report['answer_sha256'], 'oracle_verified': report['oracle_verified'],
|
||||
'cleanup_verified': True, 'resource_events': obs['resource_events']})
|
||||
assert len(runs) == 12
|
||||
for key in ['answer_sha256', 'parent_semantic_sha256', 'parent_evidence_id']:
|
||||
assert len({row[key] for row in runs}) == 1, key
|
||||
summary = {}
|
||||
for cache in ['cold', 'warm']:
|
||||
summary[cache] = {}
|
||||
for mode in ['complete', 'selected']:
|
||||
rows = [r for r in runs if r['mode'] == mode and r['cache'] == cache]
|
||||
assert len(rows) == 3
|
||||
summary[cache][mode] = {key: {'median': statistics.median(r[key] for r in rows), 'min': min(r[key] for r in rows), 'max': max(r[key] for r in rows)} for key in ['wall_seconds', 'cpu_seconds', 'peak_process_family_bytes_including_page_cache']}
|
||||
summary[cache][mode]['whole_task_tokens_estimated'] = [r['tokens_estimated']['whole_task'] for r in rows]
|
||||
result = {'status': 'passed', 'tokenizer': 'tiktoken==0.12.0/cl100k_base; estimates, not model usage',
|
||||
'context_scope': 'Question, supplied command recipe, actual selected-command help, all logical CLI argv and all stdout. No model invoked; reasoning, framing, irrelevant tool discovery and actual native-agent usage unmeasured.',
|
||||
'cache_scope': 'cold disables Node compile cache; warm primes the identical workflow once per runtime/mode before timing. Fresh processes and isolated HOME each trial; OS page cache uncontrolled.',
|
||||
'resource_scope': 'Whole task driver plus all CLI children; includes pre/post source-and-compiled-runtime binding verification, bundle publication/authentication, help, extra calls and local answer/oracle checking. Kernel cgroup peak includes file cache; not sampled RSS. Download/build/oracle preparation and priming excluded.',
|
||||
'acquisition': json.loads((root / 'acquisition.json').read_text()), 'measurement_inputs': inputs,
|
||||
'summary': summary, 'runs': runs}
|
||||
native = root / 'native-control'
|
||||
if native.exists():
|
||||
binding = json.loads((native / 'runtime-binding.json').read_text())
|
||||
assert binding['runtime_binding_verified'] and binding['runtime_binding'] == expected['complete']
|
||||
assert str(Path(binding['runtime']).resolve()) == binding['runtime_binding']['root']
|
||||
receipt = next(json.loads(line) for line in reversed((root / 'native-control.log').read_text().splitlines()) if line.startswith('{"resource_scope":'))
|
||||
obs = receipt['payload_resource_observation']
|
||||
assert receipt['abort'] is None and receipt['payload_resource_observation_verified'] and obs['payload_exit_code'] == 0 and obs['payload_cleanup_verified']
|
||||
baseline_case = root / next(run['case'] for run in runs if run['mode'] == 'complete')
|
||||
assert json.loads((native / 'answer.json').read_text()) == json.loads((baseline_case / 'answer.json').read_text())
|
||||
control_source = Path(__file__).with_name('managed-cli-local-control.py').read_text()
|
||||
result['existing_native_control'] = {'runtime': binding['runtime'], 'runtime_binding': binding['runtime_binding'], 'runtime_binding_audit_ms': binding['runtime_binding_audit_ms'], 'repetitions': 1,
|
||||
'tokens_estimated_script_plus_help_plus_answer': count(control_source) + count((native / 'help.txt').read_text()) + count((native / 'answer.json').read_text()),
|
||||
'token_scope': 'Projection source, original CLI help and returned answer; excludes shared question and launcher argv. No actual model usage measured.',
|
||||
'wall_seconds': receipt['elapsed_seconds'], 'cpu_seconds': obs['cpu_stat']['usage_usec'] / 1e6,
|
||||
'peak_process_family_bytes_including_page_cache': obs['memory_peak_bytes'], 'answer_matches': True, 'cleanup_verified': True}
|
||||
args.output.write_text(json.dumps(result, indent=2) + '\n')
|
||||
print(json.dumps(summary, indent=2))
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Existing native code-composition control: save complete stdout, return selected facts."""
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('runtime', type=Path)
|
||||
parser.add_argument('target', type=Path)
|
||||
parser.add_argument('output', type=Path)
|
||||
parser.add_argument('--measurement-inputs', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
args.output.mkdir()
|
||||
node = 'node'
|
||||
inputs = json.loads(args.measurement_inputs.read_text())
|
||||
expected = inputs['expected_runtime_bindings']['complete']
|
||||
assert expected['git_revision'] == inputs['baseline_revision']
|
||||
helper = Path(__file__).with_name('managed-cli-runtime.mjs')
|
||||
def capture_binding():
|
||||
return json.loads(subprocess.check_output([node, str(helper), str(args.runtime)], text=True))
|
||||
binding_started = time.monotonic()
|
||||
binding = capture_binding()
|
||||
binding_audit_ms = {'before': (time.monotonic() - binding_started) * 1000}
|
||||
assert binding == expected
|
||||
|
||||
entry = str(args.runtime / 'scripts/rea.mjs')
|
||||
help_result = subprocess.run([node, entry, 'inspect-managed-members', '--help'], check=True, capture_output=True, text=True)
|
||||
(args.output / 'help.txt').write_text(help_result.stdout)
|
||||
with (args.output / 'parent.json').open('w') as file:
|
||||
subprocess.run([node, entry, 'inspect-managed-members', str(args.target), '--type', 'Newtonsoft.Json.JsonConvert', '--json'], check=True, stdout=file)
|
||||
parent = json.loads((args.output / 'parent.json').read_text())
|
||||
data = parent['normalized_result']
|
||||
answer = {'parent_evidence_id': parent['evidence_id'], 'artifact_sha256': data['artifact']['sha256'],
|
||||
'selection': data['selection'], 'source_coverage': data['coverage'],
|
||||
'total_methods': len(data['methods']), 'total_member_refs': len(data['member_refs']),
|
||||
'methods': data['methods'][:8], 'member_refs': data['member_refs'][:16],
|
||||
'limitation': 'MemberRef rows are assembly-wide; methods are selected-type rows. Page exhaustion is not an absence claim.'}
|
||||
(args.output / 'answer.json').write_text(json.dumps(answer))
|
||||
binding_finished = time.monotonic()
|
||||
assert capture_binding() == binding
|
||||
binding_audit_ms['after'] = (time.monotonic() - binding_finished) * 1000
|
||||
(args.output / 'runtime-binding.json').write_text(json.dumps({'runtime': str(args.runtime), 'runtime_binding': binding, 'runtime_binding_verified': True, 'runtime_binding_audit_ms': binding_audit_ms}))
|
||||
print(json.dumps(answer))
|
||||
@@ -0,0 +1,18 @@
|
||||
import json,hashlib,argparse
|
||||
from pathlib import Path
|
||||
import dnfile
|
||||
parser=argparse.ArgumentParser();parser.add_argument('root',type=Path);root=parser.parse_args().root;path=root/'Newtonsoft.Json.dll'
|
||||
pe=dnfile.dnPE(str(path));assert pe.net is not None
|
||||
counts={name:len(getattr(pe.net.mdtables,name).rows) if getattr(pe.net.mdtables,name,None) else 0 for name in ['TypeDef','MethodDef','MemberRef','Field','Property','Event','Param','StandAloneSig','TypeSpec','ExportedType']}
|
||||
selected=[]
|
||||
for index,row in enumerate(pe.net.mdtables.TypeDef.rows,1):
|
||||
name=(str(row.TypeNamespace)+'.' if str(row.TypeNamespace) else '')+str(row.TypeName)
|
||||
if name!='Newtonsoft.Json.JsonConvert':continue
|
||||
selected.append({'token':f'0x{0x02000000|index:08x}','name':name,'methods':[{'token':f'0x{0x06000000|m.row_index:08x}','name':str(m.row.Name),'rva':m.row.Rva,'signature_hex':m.row.Signature.value.hex()} for m in row.MethodList]})
|
||||
assert len(selected)==1
|
||||
refs=[{'token':f'0x{0x0a000000|i:08x}','name':str(row.Name),'signature_hex':row.Signature.value.hex()} for i,row in enumerate(pe.net.mdtables.MemberRef.rows[:16],1)]
|
||||
assembly=pe.net.mdtables.Assembly.rows[0]
|
||||
oracle={'decoder':'dnfile==0.18.0 (independent Python PE/CLI parser)','assembly_sha256':hashlib.sha256(path.read_bytes()).hexdigest(),'assembly_name':str(assembly.Name),'assembly_version':'.'.join(str(getattr(assembly,k)) for k in ['MajorVersion','MinorVersion','BuildNumber','RevisionNumber']),'row_counts':counts,'selected_types':selected,'member_refs_first_16':refs,'target_execution':False}
|
||||
(root/'oracle.json').write_text(json.dumps(oracle,indent=2)+'\n')
|
||||
print(json.dumps({'assembly':oracle['assembly_name'],'version':oracle['assembly_version'],'row_counts':counts,'selected_type':selected[0]['name'],'selected_methods':len(selected[0]['methods']),'target_execution':False}))
|
||||
pe.close()
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, readdir, realpath } from "node:fs/promises";
|
||||
import { join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const sha = (value) => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
/** Bind a trial to its source checkout and the compiled CLI actually launched. */
|
||||
export const captureRuntimeBinding = async (runtime) => {
|
||||
const root = await realpath(runtime);
|
||||
const git = (args) =>
|
||||
execFileSync("git", args, { cwd: root, maxBuffer: 16 * 1024 * 1024 });
|
||||
const gitRevision = git(["rev-parse", "HEAD"]).toString().trim();
|
||||
const sourcePaths = ["src", "scripts", "package.json", "package-lock.json"];
|
||||
const sourceDiff = git([
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--binary",
|
||||
"HEAD",
|
||||
"--",
|
||||
...sourcePaths,
|
||||
]);
|
||||
const untracked = git([
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
"-z",
|
||||
"--",
|
||||
...sourcePaths,
|
||||
])
|
||||
.toString()
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
const untrackedEntries = [];
|
||||
for (const name of untracked)
|
||||
untrackedEntries.push([name, sha(await readFile(join(root, name)))]);
|
||||
const files = ["scripts/rea.mjs", "package.json", "package-lock.json"];
|
||||
const visit = async (directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
for (const entry of entries.sort((a, b) =>
|
||||
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
||||
)) {
|
||||
const path = join(directory, entry.name);
|
||||
assert.ok(!entry.isSymbolicLink(), `Unbound compiled CLI link: ${path}`);
|
||||
if (entry.isDirectory()) await visit(path);
|
||||
else if (entry.isFile() && /\.(?:[cm]?js|json)$/.test(entry.name))
|
||||
files.push(relative(root, path));
|
||||
}
|
||||
};
|
||||
await visit(join(root, "dist/cli-runtime"));
|
||||
assert.ok(
|
||||
files.includes(join("dist", "cli-runtime", "cli.js")),
|
||||
"Missing compiled CLI entry",
|
||||
);
|
||||
const hash = createHash("sha256");
|
||||
let bytes = 0;
|
||||
for (const name of files.sort()) {
|
||||
const value = await readFile(join(root, name));
|
||||
bytes += value.length;
|
||||
hash.update(JSON.stringify([name, value.length, sha(value)]) + "\n");
|
||||
}
|
||||
return {
|
||||
binding_version: 1,
|
||||
root,
|
||||
node: process.version,
|
||||
git_revision: gitRevision,
|
||||
source_diff_sha256: sha(sourceDiff),
|
||||
untracked_source_sha256: sha(JSON.stringify(untrackedEntries)),
|
||||
compiled_cli_sha256: hash.digest("hex"),
|
||||
compiled_cli_files: files.length,
|
||||
compiled_cli_bytes: bytes,
|
||||
};
|
||||
};
|
||||
|
||||
export const assertRuntimeBinding = (actual, expected, mode) => {
|
||||
assert.ok(expected, `Missing expected runtime binding for ${mode}`);
|
||||
assert.deepEqual(actual, expected, `Runtime binding differs for ${mode}`);
|
||||
};
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
console.log(
|
||||
JSON.stringify(await captureRuntimeBinding(process.argv[2]), null, 2),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"scope": "Single completed SDK regression after shared JSON reader/admission changes; not a new paired source-to-bundle performance comparison. Existing semantic-node cap one answers static correspondence only; no equal semantic-graph coverage claim. Corpus/reference preparation performed earlier and excluded.",
|
||||
"artifact": {
|
||||
"name": "Redux Toolkit 2.2.7",
|
||||
"tarball_sha256": "f2517c466b50424b661a27e48039f15db7783e5822f46b5c7d39f20104ac67a9"
|
||||
},
|
||||
"runtime_parent_revision": "4fab2dfda16236dc407aa179920780d6a970dda3",
|
||||
"production_manifest": "managed-cli.json#measurement_inputs.production_sources_sha256",
|
||||
"input_schema_profile": "default compact",
|
||||
"output_schema_profile": "full",
|
||||
"tokens_estimated": {
|
||||
"input_catalog_once": 49054,
|
||||
"arguments": 307,
|
||||
"text_replies": 7017,
|
||||
"whole_task": 56378
|
||||
},
|
||||
"token_scope": "tiktoken==0.12.0/cl100k_base; one name/description/input-schema catalog plus actual arguments and MCP text replies; excludes output schemas, framing, model prompts/reasoning and actual model usage. Not actual native-agent token usage.",
|
||||
"actual_model_usage": null,
|
||||
"wall_seconds": 21.38,
|
||||
"cpu_seconds": 21.022683,
|
||||
"peak_process_family_bytes_including_page_cache": 974041088,
|
||||
"calls": [
|
||||
{
|
||||
"name": "analyze_javascript_application",
|
||||
"elapsed_ms": 9837.265845000002,
|
||||
"is_error": false,
|
||||
"reply_bytes": 25941
|
||||
},
|
||||
{
|
||||
"name": "compare_source_to_bundle",
|
||||
"elapsed_ms": 256.3669919999993,
|
||||
"is_error": false,
|
||||
"reply_bytes": 13199
|
||||
},
|
||||
{
|
||||
"name": "inspect_analysis_view",
|
||||
"elapsed_ms": 3012.057904000001,
|
||||
"is_error": false,
|
||||
"reply_bytes": 19393
|
||||
},
|
||||
{
|
||||
"name": "export_evidence_bundle",
|
||||
"elapsed_ms": 148.9011160000009,
|
||||
"is_error": false,
|
||||
"reply_bytes": 364
|
||||
}
|
||||
],
|
||||
"independent_proof": {
|
||||
"status": "passed",
|
||||
"method": "Independent Python JSON/SHA-256/POSIX path and basename joins; no REA parser or matcher.",
|
||||
"source_files": 180,
|
||||
"embedded_nodes": 99,
|
||||
"candidate_relations": 210,
|
||||
"strong_location_rows": 33,
|
||||
"limitation": "Static multiple-location projections; no semantic-equivalence or removal claim."
|
||||
},
|
||||
"resource_events": {
|
||||
"memory.events": {
|
||||
"low": 0,
|
||||
"high": 0,
|
||||
"max": 0,
|
||||
"oom": 0,
|
||||
"oom_kill": 0,
|
||||
"oom_group_kill": 0
|
||||
},
|
||||
"pids.events": {
|
||||
"max": 0
|
||||
}
|
||||
},
|
||||
"cleanup_verified": true
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
captureRuntimeBinding,
|
||||
assertRuntimeBinding,
|
||||
} from "./managed-cli-runtime.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
const execute = promisify(execFile);
|
||||
const [runtime, root, output, mode, cache = "cold"] = process.argv.slice(2);
|
||||
for (const value of [runtime, root, output])
|
||||
assert.equal(resolve(value), value);
|
||||
assert.ok(["complete", "selected"].includes(mode));
|
||||
assert.ok(["cold", "warm"].includes(cache));
|
||||
await mkdir(output);
|
||||
await mkdir(join(output, "tmp"));
|
||||
const oracle = JSON.parse(await readFile(join(root, "oracle.json"), "utf8"));
|
||||
const target = join(root, "Newtonsoft.Json.dll");
|
||||
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
||||
assert.equal(hash(await readFile(target)), oracle.assembly_sha256);
|
||||
const stable = (value) =>
|
||||
Array.isArray(value)
|
||||
? value.map(stable)
|
||||
: value !== null && typeof value === "object"
|
||||
? Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, stable(value[key])]),
|
||||
)
|
||||
: value;
|
||||
const report = {
|
||||
status: "incomplete",
|
||||
runtime,
|
||||
mode,
|
||||
cache,
|
||||
node: process.version,
|
||||
question:
|
||||
"Report identity/selection/source coverage for JsonConvert, its total method count and the first eight complete method records, plus the first sixteen assembly-wide MemberRef records and continuation coverage.",
|
||||
recipe:
|
||||
mode === "selected"
|
||||
? "Use --detail summary --evidence-bundle FILE; pass the exact parent_evidence_id to inspect-analysis-view --evidence-bundle FILE with source kind retained-evidence and view {kind:managed-members,collection:methods|member_refs,offset:0,limit:8|16}."
|
||||
: "Use inspect-managed-members --type Newtonsoft.Json.JsonConvert; the completed full Evidence supplies the requested rows and remains accessible.",
|
||||
calls: [],
|
||||
cleanup_verified: false,
|
||||
};
|
||||
const began = performance.now();
|
||||
const environment = {
|
||||
PATH: process.env.PATH,
|
||||
HOME: output,
|
||||
TMPDIR: join(output, "tmp"),
|
||||
LANG: "C.UTF-8",
|
||||
REA_LOG_LEVEL: "silent",
|
||||
UV_THREADPOOL_SIZE: "1",
|
||||
NODE_OPTIONS: "--max-old-space-size=2048",
|
||||
...(cache === "cold"
|
||||
? { NODE_DISABLE_COMPILE_CACHE: "1" }
|
||||
: { NODE_COMPILE_CACHE: join(root, `compile-cache-${mode}`) }),
|
||||
};
|
||||
const call = async (label, args, json = true) => {
|
||||
const started = performance.now();
|
||||
const result = await execute(
|
||||
process.execPath,
|
||||
[join(runtime, "scripts/rea.mjs"), ...args],
|
||||
{
|
||||
cwd: output,
|
||||
env: environment,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
timeout: 120000,
|
||||
},
|
||||
);
|
||||
await writeFile(join(output, label + ".stdout"), result.stdout);
|
||||
await writeFile(join(output, label + ".stderr"), result.stderr);
|
||||
report.calls.push({
|
||||
label,
|
||||
arguments: ["rea", ...args],
|
||||
elapsed_ms: performance.now() - started,
|
||||
stdout_bytes: Buffer.byteLength(result.stdout),
|
||||
stdout_file: label + ".stdout",
|
||||
});
|
||||
return json ? JSON.parse(result.stdout) : result.stdout;
|
||||
};
|
||||
let parent, summary, methods, refs;
|
||||
try {
|
||||
const inputs = JSON.parse(
|
||||
await readFile(join(root, "measurement-inputs.json"), "utf8"),
|
||||
);
|
||||
const expected = inputs.expected_runtime_bindings?.[mode];
|
||||
assert.ok(expected, `Missing expected runtime for ${mode}`);
|
||||
assert.equal(
|
||||
expected.git_revision,
|
||||
mode === "complete"
|
||||
? inputs.baseline_revision
|
||||
: inputs.feature_parent_revision,
|
||||
);
|
||||
assert.notEqual(
|
||||
inputs.expected_runtime_bindings.complete.root,
|
||||
inputs.expected_runtime_bindings.selected.root,
|
||||
);
|
||||
assert.notEqual(
|
||||
inputs.expected_runtime_bindings.complete.compiled_cli_sha256,
|
||||
inputs.expected_runtime_bindings.selected.compiled_cli_sha256,
|
||||
);
|
||||
const bindingStarted = performance.now();
|
||||
report.runtime_binding = await captureRuntimeBinding(runtime);
|
||||
report.runtime_binding_audit_ms = {
|
||||
before: performance.now() - bindingStarted,
|
||||
};
|
||||
assertRuntimeBinding(report.runtime_binding, expected, mode);
|
||||
await call("help-members", ["inspect-managed-members", "--help"], false);
|
||||
if (mode === "complete") {
|
||||
parent = await call("complete", [
|
||||
"inspect-managed-members",
|
||||
target,
|
||||
"--type",
|
||||
"Newtonsoft.Json.JsonConvert",
|
||||
"--json",
|
||||
]);
|
||||
methods = parent.normalized_result.methods.slice(0, 8);
|
||||
refs = parent.normalized_result.member_refs.slice(0, 16);
|
||||
} else {
|
||||
await call("help-view", ["inspect-analysis-view", "--help"], false);
|
||||
const bundlePath = join(output, "parent.json");
|
||||
summary = await call("summary", [
|
||||
"inspect-managed-members",
|
||||
target,
|
||||
"--type",
|
||||
"Newtonsoft.Json.JsonConvert",
|
||||
"--detail",
|
||||
"summary",
|
||||
"--evidence-bundle",
|
||||
bundlePath,
|
||||
"--json",
|
||||
]);
|
||||
const id = summary.normalized_result.parent_evidence_id;
|
||||
assert.ok(id);
|
||||
const bundle = JSON.parse(await readFile(bundlePath, "utf8"));
|
||||
assert.equal(bundle.records.length, 1);
|
||||
parent = bundle.records[0];
|
||||
assert.equal(parent.evidence_id, id);
|
||||
for (const [collection, limit] of [
|
||||
["methods", 8],
|
||||
["member_refs", 16],
|
||||
]) {
|
||||
const value = await call(collection, [
|
||||
"inspect-analysis-view",
|
||||
JSON.stringify({
|
||||
source: { kind: "retained-evidence", evidence_id: id },
|
||||
view: { kind: "managed-members", collection, offset: 0, limit },
|
||||
}),
|
||||
"--evidence-bundle",
|
||||
bundlePath,
|
||||
"--json",
|
||||
]);
|
||||
const page = value.normalized_result;
|
||||
assert.equal(page.parent_evidence_id, id);
|
||||
assert.deepEqual(page.source_coverage, parent.normalized_result.coverage);
|
||||
assert.deepEqual(
|
||||
page.page.rows,
|
||||
parent.normalized_result[collection].slice(0, limit),
|
||||
);
|
||||
assert.equal(
|
||||
page.coverage.total,
|
||||
parent.normalized_result[collection].length,
|
||||
);
|
||||
assert.equal(page.coverage.next_offset, limit);
|
||||
assert.equal(page.coverage.exhausted, false);
|
||||
if (collection === "methods") methods = page.page.rows;
|
||||
else refs = page.page.rows;
|
||||
}
|
||||
}
|
||||
const data = parent.normalized_result;
|
||||
assert.equal(data.artifact.sha256, oracle.assembly_sha256);
|
||||
assert.equal(data.methods.length, 72);
|
||||
assert.equal(data.member_refs.length, 2149);
|
||||
assert.equal(data.types.length, 1);
|
||||
assert.equal(data.types[0].token, oracle.selected_types[0].token);
|
||||
for (let i = 0; i < data.methods.length; i++) {
|
||||
const row = data.methods[i],
|
||||
wanted = oracle.selected_types[0].methods[i];
|
||||
assert.equal(row.token, wanted.token);
|
||||
assert.equal(row.name, wanted.name);
|
||||
assert.equal(row.rva, wanted.rva);
|
||||
assert.equal(
|
||||
row.signature.raw_sha256,
|
||||
hash(Buffer.from(wanted.signature_hex, "hex")),
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < refs.length; i++) {
|
||||
const row = refs[i],
|
||||
wanted = oracle.member_refs_first_16[i];
|
||||
assert.equal(row.token, wanted.token);
|
||||
assert.equal(row.name, wanted.name);
|
||||
assert.equal(
|
||||
row.signature.raw_sha256,
|
||||
hash(Buffer.from(wanted.signature_hex, "hex")),
|
||||
);
|
||||
}
|
||||
assert.equal(methods.length, 8);
|
||||
assert.equal(refs.length, 16);
|
||||
if (summary) {
|
||||
assert.equal(summary.normalized_result.summary.reported_counts.methods, 72);
|
||||
assert.equal(
|
||||
summary.normalized_result.summary.reported_counts.member_refs,
|
||||
2149,
|
||||
);
|
||||
assert.deepEqual(summary.normalized_result.summary.coverage, data.coverage);
|
||||
assert.deepEqual(
|
||||
summary.normalized_result.summary.selection,
|
||||
data.selection,
|
||||
);
|
||||
}
|
||||
const answer = {
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
artifact_sha256: oracle.assembly_sha256,
|
||||
selection: data.selection,
|
||||
source_coverage: data.coverage,
|
||||
total_methods: data.methods.length,
|
||||
total_member_refs: data.member_refs.length,
|
||||
methods,
|
||||
member_refs: refs,
|
||||
limitation:
|
||||
"MemberRef rows are assembly-wide; methods are selected-type rows. Page exhaustion is not an absence claim.",
|
||||
};
|
||||
report.parent_evidence_id = parent.evidence_id;
|
||||
report.parent_semantic_sha256 = hash(JSON.stringify(stable(parent)));
|
||||
report.answer_sha256 = hash(JSON.stringify(stable(answer)));
|
||||
report.oracle_verified = {
|
||||
all_selected_method_metadata: 72,
|
||||
returned_methods: 8,
|
||||
returned_member_refs: 16,
|
||||
};
|
||||
const bindingFinished = performance.now();
|
||||
assertRuntimeBinding(
|
||||
await captureRuntimeBinding(runtime),
|
||||
report.runtime_binding,
|
||||
mode,
|
||||
);
|
||||
report.runtime_binding_audit_ms.after = performance.now() - bindingFinished;
|
||||
report.runtime_binding_verified = true;
|
||||
report.status = "passed";
|
||||
await writeFile(join(output, "parent-evidence.json"), JSON.stringify(parent));
|
||||
await writeFile(join(output, "answer.json"), JSON.stringify(answer));
|
||||
} finally {
|
||||
report.elapsed_ms = performance.now() - began;
|
||||
report.cleanup_verified = true;
|
||||
await writeFile(
|
||||
join(output, "report.json"),
|
||||
JSON.stringify(report, null, 2) + "\n",
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
status: report.status,
|
||||
mode,
|
||||
cache,
|
||||
parent: report.parent_evidence_id,
|
||||
answer: report.answer_sha256,
|
||||
calls: report.calls.map((c) => ({
|
||||
label: c.label,
|
||||
elapsed_ms: c.elapsed_ms,
|
||||
bytes: c.stdout_bytes,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,219 @@
|
||||
# Portable managed CLI member views
|
||||
|
||||
Issue #5148 / PR #5167. Baseline `main@3dbe314ada9f6dcc994ca4655dc0faf082503847`;
|
||||
feature integrates the original `7b3ed4d025edd24d062cde0327a53bd311e174fc`.
|
||||
The measured production-file SHA-256 manifest is in [managed-cli.json](managed-cli.json).
|
||||
These are working-tree measurements. Each trial verifies both source and the
|
||||
complete compiled CLI graph before and after execution against its explicitly
|
||||
selected build. The two roles have distinct checkout roots and compiled digests.
|
||||
Identity-audit time is included in whole-task costs and recorded separately.
|
||||
The audit reads compiled files before timing individual CLI calls, warming those
|
||||
filesystem pages; cold means disabled Node compile cache, not disk-cold startup.
|
||||
|
||||
## Question and independent checks
|
||||
|
||||
Report identity, selected type, source coverage, the total method count, the first
|
||||
eight complete method records, and the first sixteen assembly-wide MemberRef
|
||||
records, including continuation coverage. Keep complete original Evidence available.
|
||||
The question deliberately does not ask for every method or reference.
|
||||
|
||||
Public artifact: [Newtonsoft.Json 13.0.3](https://www.nuget.org/packages/Newtonsoft.Json/13.0.3),
|
||||
from the NuGet flat-container archive. The 2,441,966-byte archive SHA-256 is
|
||||
`872fc189e638ab1056555b03aaa38f68bcb54286e221aa646eb1129babf63c77`.
|
||||
Selected member `lib/net6.0/Newtonsoft.Json.dll`, 712,464 bytes, SHA-256
|
||||
`22c649f75fce5be7c7ccda8880473b634ef69ecf33f5d1ab8ad892caf47d5a07`.
|
||||
Package version 13.0.3 is distinct from metadata assembly version 13.0.0.0.
|
||||
No library code is executed; REA parses PE/CLI statically.
|
||||
|
||||
An independent Python oracle uses `dnfile==0.18.0` and `pefile==2024.8.26`.
|
||||
It finds 494 TypeDefs, 4,208 MethodDefs and 2,149 MemberRefs. The selected
|
||||
`Newtonsoft.Json.JsonConvert` has 72 methods. Every trial checks all 72 method
|
||||
tokens, names, RVAs and original signature hashes, plus the sixteen returned
|
||||
MemberRef tokens, names and signature hashes, against this oracle. This does
|
||||
not independently establish complete CIL semantics or opcode correctness.
|
||||
The full parent Evidence and requested answers are identical across all twelve
|
||||
trials; returned rows equal the exact parent slices. Selection and source
|
||||
coverage remain distinct from page exhaustion. MemberRefs remain assembly-wide.
|
||||
|
||||
## Completed-task cost
|
||||
|
||||
Linux x64, Node 24.18.0/npm 11.16.0. Three alternating pairs per cache condition,
|
||||
serial, one CPU; 4 GiB hard / 3 GiB early-stop family guard, no swap, 192-task
|
||||
ceiling. Every task includes actual selected-command help, all CLI calls,
|
||||
provider parsing, bundle publication/authentication, local answer extraction
|
||||
and oracle comparisons, plus both runtime-identity audits. Build, download, oracle preparation and warm priming
|
||||
are excluded. OS page cache is uncontrolled. No build/test overlapped timing.
|
||||
|
||||
Complete mode starts two CLI processes (help plus analysis). Selected mode starts
|
||||
five (two help commands, summary/publication, methods page, MemberRef page).
|
||||
Cold disables the Node compile cache; warm primes the exact workflow once per
|
||||
runtime/mode, then still starts fresh processes with isolated HOME each trial.
|
||||
All trials complete with verified process cleanup and no OOM/resource-stop event.
|
||||
|
||||
| Whole task, median | Complete | Summary + two pages |
|
||||
| ----------------------------------------------- | -------: | ------------------: |
|
||||
| Estimated context tokens, both cache conditions | 451,373 | 13,078 |
|
||||
| Cold wall seconds | 9.969 | 14.801 |
|
||||
| Cold family CPU seconds | 9.743 | 14.555 |
|
||||
| Cold kernel family peak MiB | 393.281 | 400.098 |
|
||||
| Warm wall seconds | 10.339 | 24.141 |
|
||||
| Warm family CPU seconds | 10.097 | 23.842 |
|
||||
| Warm kernel family peak MiB | 401.809 | 407.164 |
|
||||
|
||||
Context estimates include the question, supplied command recipe, all logical
|
||||
argv, actual help and stdout. Tokenizer `tiktoken==0.12.0`, `cl100k_base`.
|
||||
The reduction is 97.1% for this fixed task. There is no model invocation;
|
||||
actual billed/native-agent usage, reasoning and transport framing are unmeasured.
|
||||
The memory counter is the kernel cgroup process-family peak including file
|
||||
cache, not sampled RSS. Ranges and individual receipts are in the JSON.
|
||||
|
||||
This is an opt-in context/portability improvement with extra latency and CPU,
|
||||
not a speed or memory optimization. Complete provider parsing and full authentication of file-supplied bundles
|
||||
remain. Fresh managed Evidence is sealed at its producing factory and reused
|
||||
without another payload clone, including low-headroom error projection. Complete output remains the default and is preferable
|
||||
for small outputs or questions requiring every row. No cache or parser cap
|
||||
was introduced.
|
||||
|
||||
## Existing native code composition
|
||||
|
||||
The unchanged baseline can already redirect full stdout to a file and select
|
||||
rows in code. [The measured control](managed-cli-local-control.py) returns the
|
||||
identical answer and retains the complete original Evidence. One cold run:
|
||||
11.249 s wall, 11.000 s family CPU,
|
||||
375.3 MiB kernel peak. Projection source,
|
||||
original help and returned answer together estimate 10,555 tokens; the shared
|
||||
question and launcher argv are excluded. This single-run control does not
|
||||
establish stable performance or actual native-agent usage. It does demonstrate
|
||||
that raw CLI reply savings are not universal savings over code composition.
|
||||
The additive interface supplies authenticated reusable projections through the
|
||||
same application/domain owner as MCP, without caller-maintained JSON slicing.
|
||||
|
||||
## Correctness and resource boundaries
|
||||
|
||||
The public CLI regression pages all eight managed collections after process
|
||||
exit, target deletion and a working-directory change. Complete parent identity,
|
||||
original row order, signatures, source/page coverage and unknowns survive.
|
||||
Existing outputs are not replaced; failed publication retains completed facts.
|
||||
|
||||
The Evidence factory parses mutable caller data into independent trees, computes
|
||||
identity, then seals its fresh owned snapshot with active ancestry/iterators.
|
||||
It does not retain a set/array of every completed object. Existing sealed roots
|
||||
can be shared; mutable input aliases are cloned independently. General graph
|
||||
sealing keeps its separate cycle, sharing, hidden-field and atomic-failure rules.
|
||||
Managed and derived-view factories seal their own completed Evidence, so CLI
|
||||
error projection can borrow it without reauthentication or another payload copy.
|
||||
The view owner retains strict JSON normalization for optional-field contracts;
|
||||
the CLI no longer copies its already validated completed projection again.
|
||||
|
||||
Shared heap admission traverses by depth rather than queueing every report row
|
||||
or allocating an entries array. It reserves parsed working copies and actual
|
||||
escaping expansion before cloning/hashing, reporting UTF-16 storage explicitly.
|
||||
Canonical bundle equality streams existing ordinary JSON encoder parts, preserving
|
||||
native key order, omission, negative zero and Unicode semantics without two
|
||||
whole-document strings. All production bundle readers explicitly pass their
|
||||
operation: CLI import/export/compare/inspection, retained views, MCP import/
|
||||
comparison and both native-matching read passes. Errors retain the original
|
||||
cause, cleanup primary and lease through retry.
|
||||
|
||||
One process primitive dispatches queued callbacks and then their OS signal
|
||||
notifications. The managed producer checks it before accepting complete Evidence.
|
||||
Publication/read stages use it before their final checks. Inline, file and bundle
|
||||
CLI views share the existing command finalizer and actual resource scope; the
|
||||
finalizer checks interruption after close while preserving any primary/cleanup
|
||||
failure. Existing inline error labels remain intact. Synchronous analysis is
|
||||
cooperatively cancellable between stages, not preemptible.
|
||||
|
||||
Four compiled publication cases (SIGINT/SIGTERM × event/actual POSIX signal)
|
||||
fail without their checkpoint and pass restored. Eight additional compiled
|
||||
completion cases cover default complete delivery and inline projection; all fail
|
||||
with only their new completion checkpoints removed and pass restored. Fixture
|
||||
hooks match the selected target path or exact parent ID. Two real Newtonsoft
|
||||
publication runs and four real default/inline completion runs retain the identical
|
||||
parent or independently checked selected summary, exit 130/143 and preserve
|
||||
committed receipts where applicable. No target library code is executed.
|
||||
|
||||
All 556 focused tests across 36 files pass, including direct
|
||||
finalizer/cleanup ownership, WASM/Android cancellation and native matching.
|
||||
All six `npm run check` tasks plus architecture guards, and all six
|
||||
`npm run docs:check` tasks plus five README tests pass. The expanded domain
|
||||
project passes 5,617 tests in 287 files with
|
||||
3 skipped cases; this overlaps the focused gate. Its first
|
||||
batched shard hit two existing 30-second timeouts in unchanged large synthetic
|
||||
tests. Both passed isolated and in the fresh shard rerun under the same timeout;
|
||||
the cause of the batch variation is unverified. No production/timeout change
|
||||
was made for those cases. An earlier unsplit expansion was manually stopped
|
||||
before its heap ceiling; that incomplete attempt is not counted as a pass.
|
||||
|
||||
Eight producing/operation negative controls also catch graph-sized sealing/
|
||||
admission scratch and wrong operation attribution, including native reload.
|
||||
Native built-in instrumentation is confined to the isolated process-global lane.
|
||||
Initial test-only spy/probe/schema-import issues were corrected before these
|
||||
passing gates. No actual heap exhaustion is induced. Simulated providers do not
|
||||
qualify foreign engines; Windows OS signals remain unverified locally.
|
||||
|
||||
## Shared-reader real-artifact regression
|
||||
|
||||
The existing Redux Toolkit 2.2.7 source/bundle task also completes through the
|
||||
updated reader with default full output schemas and compact input schemas.
|
||||
An independent Python SHA-256/path oracle verifies 180 historical files,
|
||||
99 embedded source nodes and 210 candidate relations (33 strong-location rows,
|
||||
147 unknown). Original signals and partial source coverage remain visible.
|
||||
This uses the existing explicit semantic-node cap of one for static correspondence;
|
||||
it does not establish equivalent semantic-graph coverage or behavioral equivalence.
|
||||
|
||||
One completed task: 21.380 s wall,
|
||||
21.023 s family CPU,
|
||||
928.9 MiB kernel family peak,
|
||||
56,378 estimated context tokens (one input catalog,
|
||||
all arguments and MCP text replies; excludes output schemas, framing and model
|
||||
reasoning). Corpus/reference preparation was performed earlier and excluded.
|
||||
This is a regression verification with resource observation, not a new paired
|
||||
performance comparison. Actual model usage remains unmeasured. Details:
|
||||
[managed-cli-shared-read.json](managed-cli-shared-read.json).
|
||||
|
||||
## Reproduction
|
||||
|
||||
Extract the selected DLL to a private lab as `Newtonsoft.Json.dll`. Create an
|
||||
`acquisition.json` containing the URL, hashes, sizes and selected ZIP member
|
||||
shown above; never commit the DLL. Prepare the independent oracle:
|
||||
|
||||
```bash
|
||||
uv run --no-project --with dnfile==0.18.0 --with pefile==2024.8.26 python \
|
||||
experiments/analysis-efficiency/managed-cli-oracle.py /absolute/lab
|
||||
```
|
||||
|
||||
Build baseline and feature separately with the pinned toolchain. Run each under
|
||||
the shared resource guard, alternating complete/selected, selected/complete,
|
||||
complete/selected for cold and warm. Use a new output directory for every run:
|
||||
|
||||
```bash
|
||||
python3 experiments/analysis-efficiency/resource-run.py --observe-payload-exit -- \
|
||||
node experiments/analysis-efficiency/managed-cli-task.mjs \
|
||||
/absolute/runtime /absolute/lab /absolute/lab/pair-cold-1-selected selected cold
|
||||
```
|
||||
|
||||
Save each guard output to the adjacent `pair-*.log`. Warm mode uses the lab's
|
||||
mode-specific compile-cache directory; prime each identical workflow once first.
|
||||
Supply a `measurement-inputs.json` with exact revisions and production hashes,
|
||||
plus `expected_runtime_bindings.complete` and `.selected`. Capture each with
|
||||
`node experiments/analysis-efficiency/managed-cli-runtime.mjs /absolute/runtime`
|
||||
after building. Bind complete to the clean baseline revision, selected to the
|
||||
feature parent revision plus tracked/untracked source digests, with different
|
||||
roots and compiled-graph digests. The collector rejects a wrong build before any
|
||||
CLI call, rechecks it afterward, and aggregation independently enforces identity.
|
||||
A real wrong-runtime attempt is rejected before analysis. A copied trial with a
|
||||
tampered revision fails aggregation; removing only that consumer guard accepts it.
|
||||
The twelve-trial aggregation reproduces without private pilots. The native
|
||||
control also requires `--measurement-inputs /absolute/lab/measurement-inputs.json`
|
||||
and performs the same before/after identity checks.
|
||||
Then aggregate with:
|
||||
|
||||
```bash
|
||||
uv run --no-project --with tiktoken==0.12.0 python \
|
||||
experiments/analysis-efficiency/managed-cli-costs.py /absolute/lab \
|
||||
--output /absolute/results.json
|
||||
```
|
||||
|
||||
The original issue reporter's executable, foreign operating systems and actual
|
||||
model sessions remain unverified. Setup pilots and earlier unbound cohorts, including one failed setup run
|
||||
while compiled artifacts were absent after a typecheck failure, are excluded.
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { EvidenceCreationOptions } from "../domain/evidence.js";
|
||||
import type { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type { OfficialToolName } from "../contracts/officialToolContracts.js";
|
||||
@@ -146,6 +147,7 @@ export const createAnalysisExecutionEvidence = (
|
||||
input: unknown,
|
||||
execution: AnalysisExecution,
|
||||
confidence: "observed" | "derived",
|
||||
options?: EvidenceCreationOptions,
|
||||
): Evidence =>
|
||||
createEvidence(
|
||||
execution.subject === undefined
|
||||
@@ -167,6 +169,7 @@ export const createAnalysisExecutionEvidence = (
|
||||
locations: execution.locations,
|
||||
confidence,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
/** Provider-neutral application capability for one analysis operation. */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExecutableFormatHint } from "../domain/dosCom.js";
|
||||
import { dispatchQueuedInterrupts } from "../process/queuedInterrupts.js";
|
||||
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js";
|
||||
@@ -59,7 +60,11 @@ import {
|
||||
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
|
||||
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
|
||||
import { resolveXrefsAddress } from "./XrefsAddressResolution.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { deliverAnalysisEvidence } from "./EvidenceBundleCommands.js";
|
||||
import type { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import {
|
||||
directAnalysisCleanupFailure,
|
||||
@@ -146,24 +151,81 @@ export const runDirectAnalysis = async (
|
||||
/** Execute one provider-native semantic operation with atomic provenance. */
|
||||
export const runProviderAnalysis = async (
|
||||
dependencies: DirectAnalysisDependencies,
|
||||
...[path, tool, arguments_, logger = silentLogger, signal]: readonly [
|
||||
path: string,
|
||||
tool: NativeToolName | ArtifactAnalysisOperation | ManagedToolName,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
logger?: Logger,
|
||||
signal?: AbortSignal,
|
||||
]
|
||||
...[path, tool, arguments_, logger = silentLogger, signal, delivery]:
|
||||
| readonly [
|
||||
path: string,
|
||||
tool: NativeToolName | ArtifactAnalysisOperation | ManagedToolName,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
logger?: Logger | undefined,
|
||||
signal?: AbortSignal | undefined,
|
||||
]
|
||||
| readonly [
|
||||
path: string,
|
||||
tool: "inspect_managed_members",
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
logger: Logger | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
delivery: {
|
||||
readonly detail?: "complete" | "summary";
|
||||
readonly evidenceBundlePath?: string;
|
||||
},
|
||||
]
|
||||
): Promise<JsonValue> => {
|
||||
if (isManagedToolName(tool))
|
||||
return runManagedProviderAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
tool,
|
||||
arguments_,
|
||||
signal,
|
||||
if (delivery !== undefined && tool !== "inspect_managed_members")
|
||||
return cliError(
|
||||
new AnalysisInputError(tool, undefined, [
|
||||
{
|
||||
path: ["delivery"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Evidence delivery options are only supported by inspect_managed_members",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return withProcessCancellation(signal, (operationSignal) =>
|
||||
runAnalysis(dependencies, path, tool, arguments_, {
|
||||
if (
|
||||
delivery?.detail === "summary" &&
|
||||
delivery.evidenceBundlePath === undefined
|
||||
)
|
||||
return cliError(
|
||||
new AnalysisInputError(tool, undefined, [
|
||||
{
|
||||
path: ["evidence_bundle"],
|
||||
reason: "missing_argument",
|
||||
message:
|
||||
"summary detail requires --evidence-bundle so complete Evidence remains available to later CLI commands",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return withProcessCancellation(signal, async (operationSignal) => {
|
||||
if (isManagedToolName(tool)) {
|
||||
const original = await runManagedProviderAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
tool,
|
||||
arguments_,
|
||||
operationSignal,
|
||||
);
|
||||
await dispatchQueuedInterrupts();
|
||||
if (!original.ok) return cliError(original.error);
|
||||
if (operationSignal.aborted)
|
||||
return cliError(
|
||||
new AnalysisCancelledError(tool, {
|
||||
cause: operationSignal.reason,
|
||||
partialObservation: original.value,
|
||||
}),
|
||||
);
|
||||
const result =
|
||||
delivery?.evidenceBundlePath === undefined
|
||||
? original
|
||||
: await deliverAnalysisEvidence(
|
||||
original.value,
|
||||
delivery.evidenceBundlePath,
|
||||
delivery.detail,
|
||||
operationSignal,
|
||||
);
|
||||
return result.ok ? result.value : cliError(result.error);
|
||||
}
|
||||
return runAnalysis(dependencies, path, tool, arguments_, {
|
||||
logger,
|
||||
snapshotPath: undefined,
|
||||
signal: operationSignal,
|
||||
@@ -172,8 +234,8 @@ export const runProviderAnalysis = async (
|
||||
: NATIVE_INSPECTION_OPERATIONS.has(tool)
|
||||
? { createSession: dependencies.createNativeBinarySession }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/** Execute managed metadata inspection in an isolated managed-only session. */
|
||||
@@ -199,13 +261,14 @@ export const runManagedProviderExecution = async (
|
||||
);
|
||||
});
|
||||
|
||||
/** Preserve typed managed Evidence before selecting its CLI delivery. */
|
||||
const runManagedProviderAnalysis = async (
|
||||
dependencies: DirectAnalysisDependencies,
|
||||
dependencies: Pick<DirectAnalysisDependencies, "createManagedBinarySession">,
|
||||
path: string,
|
||||
tool: ManagedToolName,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<JsonValue> => {
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const execution = await runManagedProviderExecution(
|
||||
dependencies,
|
||||
path,
|
||||
@@ -213,9 +276,13 @@ const runManagedProviderAnalysis = async (
|
||||
signal,
|
||||
arguments_,
|
||||
);
|
||||
if (!execution.ok) return cliError(execution.error);
|
||||
if (!execution.ok) return execution;
|
||||
const value = execution.value;
|
||||
return createAnalysisExecutionEvidence(tool, arguments_, value, "observed");
|
||||
return ok(
|
||||
createAnalysisExecutionEvidence(tool, arguments_, value, "observed", {
|
||||
immutable: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const runAnalysis = async (
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { dispatchQueuedInterrupts } from "../process/queuedInterrupts.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import { requireAuthenticationHeadroom } from "./JsonAuthenticationHeadroom.js";
|
||||
|
||||
import {
|
||||
EvidenceFileError,
|
||||
EvidenceIntegrityError,
|
||||
type PublishedEvidenceFile,
|
||||
} from "../domain/evidenceErrors.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import {
|
||||
createImmutableEvidenceBundle,
|
||||
type EvidenceBundle,
|
||||
} from "../domain/evidenceBundle.js";
|
||||
import type { InspectAnalysisViewInput } from "../domain/analysisView/analysisView.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
@@ -14,12 +28,18 @@ import {
|
||||
readImmutableEvidenceBundle,
|
||||
writeEvidenceBundle,
|
||||
} from "./EvidenceBundleFiles.js";
|
||||
import { inspectAnalysisViewValidated } from "./analysisView/AnalysisViewService.js";
|
||||
|
||||
/** Validate a standalone bundle and report its complete record/revision counts. */
|
||||
export const importEvidenceBundleCommand = async (
|
||||
path: string,
|
||||
): Promise<Result<JsonValue, AnalysisError>> => {
|
||||
const loaded = await readEvidenceBundle(path);
|
||||
const loaded = await readEvidenceBundle(
|
||||
path,
|
||||
undefined,
|
||||
undefined,
|
||||
"import_evidence_bundle",
|
||||
);
|
||||
if (!loaded.ok) return loaded;
|
||||
return ok({
|
||||
imported: loaded.value.records.length,
|
||||
@@ -35,16 +55,136 @@ export const exportEvidenceBundleCommand = async (
|
||||
overwrite: boolean,
|
||||
): Promise<Result<JsonValue, AnalysisError>> => {
|
||||
// The sealed snapshot lets the writer reuse this authentication.
|
||||
const loaded = await readImmutableEvidenceBundle(sourcePath);
|
||||
const loaded = await readImmutableEvidenceBundle(
|
||||
sourcePath,
|
||||
undefined,
|
||||
undefined,
|
||||
"export_evidence_bundle",
|
||||
);
|
||||
if (!loaded.ok) return loaded;
|
||||
return projectWrite(
|
||||
loaded.value,
|
||||
await persistEvidenceBundleCommand(loaded.value, outputPath, overwrite),
|
||||
);
|
||||
};
|
||||
|
||||
/** Publish complete parent Evidence before delivering it or its linked summary. */
|
||||
export const deliverAnalysisEvidence = async (
|
||||
evidence: Evidence,
|
||||
outputPath: string,
|
||||
detail: "complete" | "summary" = "complete",
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const headroom = requireAuthenticationHeadroom(evidence, {
|
||||
operation: evidence.operation,
|
||||
path: absoluteCallerPath(outputPath),
|
||||
description:
|
||||
"Insufficient heap headroom to authenticate the complete parent Evidence",
|
||||
});
|
||||
if (!headroom.ok)
|
||||
return err(headroom.error.retainPartialObservation(evidence));
|
||||
const bundle = createImmutableEvidenceBundle([evidence]);
|
||||
const delivered =
|
||||
detail === "summary"
|
||||
? inspectAnalysisViewValidated(
|
||||
{
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: evidence.evidence_id,
|
||||
},
|
||||
view: { kind: "summary" },
|
||||
},
|
||||
(id) => bundle.records.find((record) => record.evidence_id === id),
|
||||
)
|
||||
: ok(evidence);
|
||||
if (!delivered.ok) return delivered;
|
||||
const written = await persistEvidenceBundleCommand(
|
||||
bundle,
|
||||
outputPath,
|
||||
false,
|
||||
signal,
|
||||
evidence.operation,
|
||||
);
|
||||
// Deliver interrupts queued during final publication cleanup before the
|
||||
// command's signal owner accepts completion and removes its listeners.
|
||||
await dispatchQueuedInterrupts();
|
||||
if (!written.ok) return err(written.error.retainPartialObservation(evidence));
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(evidence.operation, {
|
||||
cause: signal.reason,
|
||||
partialObservation: delivered.value,
|
||||
diagnostics: { evidence_bundle: { ...written.value } },
|
||||
}),
|
||||
);
|
||||
return delivered;
|
||||
};
|
||||
|
||||
/** Resolve the caller's exact retained parent from an authenticated portable bundle. */
|
||||
export const inspectAnalysisViewFromBundle = async (
|
||||
input: InspectAnalysisViewInput,
|
||||
bundlePath: string,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
if (input.source.kind !== "retained-evidence")
|
||||
return err(
|
||||
new AnalysisInputError("inspect_analysis_view", undefined, [
|
||||
{
|
||||
path: ["source", "kind"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"--evidence-bundle requires a retained-evidence source with the exact parent evidence_id; inline Evidence already supplies its source",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const loaded = await readImmutableEvidenceBundle(
|
||||
bundlePath,
|
||||
signal,
|
||||
resources,
|
||||
"inspect_analysis_view",
|
||||
);
|
||||
// Authentication and projection are synchronous; dispatch queued interrupts before accepting either result.
|
||||
await dispatchQueuedInterrupts();
|
||||
if (!loaded.ok) return loaded;
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError("inspect_analysis_view", {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
const projected = inspectAnalysisViewValidated(input, (id) =>
|
||||
loaded.value.records.find((record) => record.evidence_id === id),
|
||||
);
|
||||
await dispatchQueuedInterrupts();
|
||||
if (projected.ok && signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError("inspect_analysis_view", {
|
||||
cause: signal.reason,
|
||||
partialObservation: projected.value,
|
||||
}),
|
||||
);
|
||||
return projected;
|
||||
};
|
||||
|
||||
/** Publish a completed bundle and retry its exact staged-output cleanup owner. */
|
||||
const persistEvidenceBundleCommand = async (
|
||||
bundle: EvidenceBundle,
|
||||
outputPath: string,
|
||||
overwrite: boolean,
|
||||
signal?: AbortSignal,
|
||||
operation?: string,
|
||||
): Promise<Result<PublishedEvidenceFile, AnalysisError>> => {
|
||||
const resources = new ArtifactResourceScope();
|
||||
const written = await writeEvidenceBundle(
|
||||
loaded.value,
|
||||
bundle,
|
||||
outputPath,
|
||||
overwrite,
|
||||
resources,
|
||||
signal,
|
||||
operation,
|
||||
);
|
||||
if (written.ok) return projectWrite(loaded.value, written);
|
||||
if (written.ok) return written;
|
||||
|
||||
// Confirmed release resolves the bound error to its primary outcome; a
|
||||
// failed retry leaves that error incomplete with its exact retained owner.
|
||||
@@ -54,10 +194,7 @@ export const exportEvidenceBundleCommand = async (
|
||||
written.error instanceof EvidenceFileError &&
|
||||
written.error.published
|
||||
) {
|
||||
return projectWrite(loaded.value, {
|
||||
ok: true,
|
||||
value: written.error.published,
|
||||
});
|
||||
return ok(written.error.published);
|
||||
}
|
||||
return written;
|
||||
};
|
||||
@@ -70,8 +207,18 @@ export const compareEvidenceBundlesCommand = async (input: {
|
||||
// Sealed snapshots let comparison and its bundle digests reuse this
|
||||
// authentication instead of re-validating every record twice more.
|
||||
const [left, right] = await Promise.all([
|
||||
readImmutableEvidenceBundle(input.leftPath),
|
||||
readImmutableEvidenceBundle(input.rightPath),
|
||||
readImmutableEvidenceBundle(
|
||||
input.leftPath,
|
||||
undefined,
|
||||
undefined,
|
||||
"compare_bundles",
|
||||
),
|
||||
readImmutableEvidenceBundle(
|
||||
input.rightPath,
|
||||
undefined,
|
||||
undefined,
|
||||
"compare_bundles",
|
||||
),
|
||||
]);
|
||||
if (!left.ok) return left;
|
||||
if (!right.ok) return right;
|
||||
@@ -88,7 +235,7 @@ export const compareEvidenceBundlesCommand = async (input: {
|
||||
|
||||
const projectWrite = (
|
||||
bundle: EvidenceBundle,
|
||||
written: Awaited<ReturnType<typeof writeEvidenceBundle>>,
|
||||
written: Result<PublishedEvidenceFile, AnalysisError>,
|
||||
): Result<JsonValue, AnalysisError> =>
|
||||
written.ok
|
||||
? ok({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requireAuthenticationHeadroom } from "./JsonAuthenticationHeadroom.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
|
||||
import {
|
||||
@@ -39,45 +40,47 @@ export const readEvidenceBundle = (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
resources?: ArtifactResourceScope,
|
||||
operation = "import_evidence_bundle",
|
||||
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
|
||||
readBundle(path, parseEvidenceBundle, signal, resources);
|
||||
readBundle(path, parseEvidenceBundle, signal, resources, operation);
|
||||
|
||||
/** Read an authenticated immutable snapshot for a subsequent session import. */
|
||||
export const readImmutableEvidenceBundle = (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
resources?: ArtifactResourceScope,
|
||||
operation = "import_evidence_bundle",
|
||||
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
|
||||
readBundle(path, parseImmutableEvidenceBundle, signal, resources);
|
||||
readBundle(path, parseImmutableEvidenceBundle, signal, resources, operation);
|
||||
|
||||
const readBundle = async (
|
||||
path: string,
|
||||
parseBundle: (input: unknown) => EvidenceBundle,
|
||||
signal?: AbortSignal,
|
||||
resources?: ArtifactResourceScope,
|
||||
operation = "import_evidence_bundle",
|
||||
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> => {
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError("import_evidence_bundle", {
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
const loaded = await readJsonFile(path, signal, resources);
|
||||
if (!loaded.ok) {
|
||||
if (loaded.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisCancelledError("import_evidence_bundle", {
|
||||
cause: loaded.error.cause,
|
||||
}),
|
||||
);
|
||||
return loaded;
|
||||
}
|
||||
const loaded = await readJsonFile(path, signal, resources, operation);
|
||||
if (!loaded.ok) return loaded;
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError("import_evidence_bundle", {
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
const headroom = requireAuthenticationHeadroom(loaded.value, {
|
||||
operation,
|
||||
path: absoluteCallerPath(path),
|
||||
description:
|
||||
"Insufficient heap headroom to authenticate the Evidence bundle",
|
||||
});
|
||||
if (!headroom.ok) return headroom;
|
||||
let bundle: EvidenceBundle;
|
||||
try {
|
||||
bundle = parseBundle(loaded.value);
|
||||
@@ -121,6 +124,7 @@ export const writeEvidenceBundle = async (
|
||||
overwrite: boolean,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
operation = "export_evidence_bundle",
|
||||
): Promise<
|
||||
Result<
|
||||
{ readonly path: string; readonly bytes: number },
|
||||
@@ -128,7 +132,11 @@ export const writeEvidenceBundle = async (
|
||||
>
|
||||
> => {
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("export_evidence_bundle"));
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
let checked: EvidenceBundle;
|
||||
try {
|
||||
checked = parseEvidenceBundle(bundle);
|
||||
@@ -144,8 +152,6 @@ export const writeEvidenceBundle = async (
|
||||
path,
|
||||
overwrite,
|
||||
resources,
|
||||
signal === undefined
|
||||
? undefined
|
||||
: { signal, operation: "export_evidence_bundle" },
|
||||
signal === undefined ? undefined : { signal, operation },
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,9 @@ describe("exact Evidence input resolution", () => {
|
||||
actual: null,
|
||||
},
|
||||
});
|
||||
expect(projected.message).toContain("not retained in this session");
|
||||
expect(projected.message).toContain(
|
||||
"not available from the selected source",
|
||||
);
|
||||
expect(projected.remediation.action).toContain(
|
||||
"complete inline Evidence",
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ export const resolveEvidenceInput = (
|
||||
new EvidenceReferenceError(
|
||||
input.evidence_id,
|
||||
"missing",
|
||||
"Evidence retained in this session",
|
||||
"Evidence available to the selected source",
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,50 @@ import {
|
||||
requireAuthenticationHeadroom,
|
||||
} from "./JsonAuthenticationHeadroom.js";
|
||||
describe("JSON authentication headroom", () => {
|
||||
it("does not queue every row while deciding whether a wide report can fit", () => {
|
||||
let pending = 0;
|
||||
let peak = 0;
|
||||
const rows: { admission_probe: boolean }[] = [];
|
||||
for (let index = 0; index < 512; index++) {
|
||||
const row = {
|
||||
get admission_probe() {
|
||||
pending--;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
// Observe reading a row separately from examining its scalar. A width
|
||||
// queue reads every row first; an iterator finishes each row before advancing.
|
||||
Object.defineProperty(rows, index, {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
peak = Math.max(peak, ++pending);
|
||||
if (pending > 32)
|
||||
throw new Error(
|
||||
"Admission queued report rows before refusing their allocation",
|
||||
);
|
||||
return row;
|
||||
},
|
||||
});
|
||||
}
|
||||
const value = { rows };
|
||||
const result = requireAuthenticationHeadroom(value, {
|
||||
operation: "inspect_managed_members",
|
||||
path: "/tmp/parent.json",
|
||||
description: "Cannot authenticate",
|
||||
availableBytes: 0,
|
||||
});
|
||||
expect(pending).toBe(0);
|
||||
expect(peak).toBe(1);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
reportedLimits: {
|
||||
serialized_json_utf16_bytes: JSON.stringify(value).length * 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
it("estimates parsed JSON at no less than its UTF-16 text", () => {
|
||||
const text = "x".repeat(1000);
|
||||
expect(estimateJsonValueBytes(text)).toBeGreaterThanOrEqual(2000);
|
||||
@@ -42,3 +86,28 @@ describe("JSON authentication headroom", () => {
|
||||
).toEqual({ ok: true, value: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["control characters", { text: "\u0000\u0001\b\t\n\f\r".repeat(2000) }],
|
||||
[
|
||||
"escaped keys",
|
||||
{ ['\u0000\\"'.repeat(2000)]: [true, false, null, -0, 1e30] },
|
||||
],
|
||||
["surrogate boundaries", { text: "😀\ud800x\udfff".repeat(2000) }],
|
||||
])("reserves actual escaped JSON storage for %s", (_label, value) => {
|
||||
const result = requireAuthenticationHeadroom(value, {
|
||||
operation: "inspect_analysis_view",
|
||||
path: "/tmp/bundle.json",
|
||||
description: "Cannot authenticate bundle",
|
||||
availableBytes: estimateJsonValueBytes(value) * 2 + 1,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
reportedLimits: {
|
||||
serialized_json_utf16_bytes: JSON.stringify(value).length * 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,66 @@
|
||||
import { jsonStringCodeUnits } from "../domain/jsonSerialization.js";
|
||||
import { getHeapStatistics } from "node:v8";
|
||||
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/** Authentication clones parsed JSON and hashes its canonical commitment. */
|
||||
const AUTHENTICATION_COPIES = 2;
|
||||
|
||||
/** Conservative bytes a parsed JSON value occupies: UTF-16 text plus container and slot overhead. */
|
||||
export const estimateJsonValueBytes = (value: unknown): number => {
|
||||
let bytes = 0;
|
||||
const stack: unknown[] = [value];
|
||||
/** Parsed working copies and conservative UTF-16 storage for escaped JSON. */
|
||||
const estimateJsonStorage = (
|
||||
value: unknown,
|
||||
): { valueBytes: number; serializedUtf16Bytes: number } => {
|
||||
let valueBytes = 0;
|
||||
let serializedCharacters = 0;
|
||||
const stack: Iterator<unknown>[] = [[value][Symbol.iterator]()];
|
||||
while (stack.length > 0) {
|
||||
const item = stack.pop();
|
||||
if (typeof item === "string") bytes += 64 + item.length * 2;
|
||||
else if (Array.isArray(item)) {
|
||||
bytes += 128 + item.length * 16;
|
||||
for (const entry of item) stack.push(entry);
|
||||
const next = stack[stack.length - 1]!.next();
|
||||
if (next.done) {
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
const item = next.value;
|
||||
if (typeof item === "string") {
|
||||
valueBytes += 64 + item.length * 2;
|
||||
serializedCharacters += jsonStringCodeUnits(item);
|
||||
} else if (Array.isArray(item)) {
|
||||
valueBytes += 128 + item.length * 16;
|
||||
serializedCharacters += 2 + Math.max(0, item.length - 1);
|
||||
stack.push(item.values());
|
||||
} else if (typeof item === "object" && item !== null) {
|
||||
bytes += 128;
|
||||
for (const [key, entry] of Object.entries(item)) {
|
||||
bytes += 64 + key.length * 2;
|
||||
stack.push(entry);
|
||||
}
|
||||
} else bytes += 16;
|
||||
valueBytes += 128;
|
||||
serializedCharacters += 2;
|
||||
stack.push(
|
||||
(function* () {
|
||||
let count = 0;
|
||||
for (const key in item) {
|
||||
if (!Object.hasOwn(item, key)) continue;
|
||||
valueBytes += 64 + key.length * 2;
|
||||
serializedCharacters +=
|
||||
jsonStringCodeUnits(key) + 1 + (count++ === 0 ? 0 : 1);
|
||||
yield (item as Record<string, unknown>)[key];
|
||||
}
|
||||
})(),
|
||||
);
|
||||
} else {
|
||||
valueBytes += 16;
|
||||
serializedCharacters +=
|
||||
typeof item === "number"
|
||||
? String(item).length
|
||||
: item === true
|
||||
? 4
|
||||
: item === false
|
||||
? 5
|
||||
: 4;
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
return { valueBytes, serializedUtf16Bytes: serializedCharacters * 2 };
|
||||
};
|
||||
|
||||
/** Conservative parsed JSON bytes, independent of its escaped representation. */
|
||||
export const estimateJsonValueBytes = (value: unknown): number =>
|
||||
estimateJsonStorage(value).valueBytes;
|
||||
|
||||
/** Refuse before authentication when live heap headroom cannot hold its validation and commitment working copies. */
|
||||
export const requireAuthenticationHeadroom = (
|
||||
value: unknown,
|
||||
@@ -36,11 +70,14 @@ export const requireAuthenticationHeadroom = (
|
||||
readonly description: string;
|
||||
readonly availableBytes?: number;
|
||||
},
|
||||
): Result<void, AnalysisError> => {
|
||||
): Result<void, AnalysisResourceConstraintError> => {
|
||||
const availableBytes =
|
||||
options.availableBytes ?? getHeapStatistics().total_available_size;
|
||||
const valueBytes = estimateJsonValueBytes(value);
|
||||
const requiredBytes = valueBytes * AUTHENTICATION_COPIES;
|
||||
const { valueBytes, serializedUtf16Bytes } = estimateJsonStorage(value);
|
||||
// Canonical hashing and manifest normalization can still encode scalars or
|
||||
// metadata; account for their escaped representations before allocating them.
|
||||
const requiredBytes =
|
||||
(valueBytes + serializedUtf16Bytes) * AUTHENTICATION_COPIES;
|
||||
if (requiredBytes <= availableBytes) return ok(undefined);
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
@@ -50,6 +87,7 @@ export const requireAuthenticationHeadroom = (
|
||||
{
|
||||
input_path: options.path,
|
||||
projected_value_bytes: valueBytes,
|
||||
serialized_json_utf16_bytes: serializedUtf16Bytes,
|
||||
authentication_bytes: requiredBytes,
|
||||
available_heap_bytes: availableBytes,
|
||||
},
|
||||
|
||||
@@ -52,13 +52,14 @@ export const readJsonFile = async (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
resources?: ArtifactResourceScope,
|
||||
operation = "read_evidence_file",
|
||||
): Promise<
|
||||
Result<
|
||||
unknown,
|
||||
EvidenceFileError | AnalysisResourceConstraintError | AnalysisCancelledError
|
||||
>
|
||||
> => {
|
||||
const read = await readJsonFileWithSource(path, signal, resources);
|
||||
const read = await readJsonFileWithSource(path, signal, resources, operation);
|
||||
return read.ok ? ok(read.value.data) : read;
|
||||
};
|
||||
|
||||
@@ -67,6 +68,7 @@ export const readJsonFileWithSource = (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
resources?: ArtifactResourceScope,
|
||||
operation = "read_evidence_file",
|
||||
): Promise<
|
||||
Result<
|
||||
{ readonly data: unknown; readonly path: string },
|
||||
@@ -78,13 +80,14 @@ export const readJsonFileWithSource = (
|
||||
async (selected) => {
|
||||
const read = await readJsonInputFile(
|
||||
selected,
|
||||
"read_evidence_file",
|
||||
operation,
|
||||
signal,
|
||||
resources,
|
||||
);
|
||||
return read.ok ? ok({ data: read.value, path: selected }) : read;
|
||||
},
|
||||
signal,
|
||||
operation,
|
||||
);
|
||||
|
||||
/** Read selected JSON members through the same regular-file error boundary. */
|
||||
@@ -124,6 +127,7 @@ const readJsonFileInput = async <Value>(
|
||||
>
|
||||
>,
|
||||
signal?: AbortSignal,
|
||||
operation = "read_evidence_file",
|
||||
): Promise<
|
||||
Result<
|
||||
Value,
|
||||
@@ -153,7 +157,7 @@ const readJsonFileInput = async <Value>(
|
||||
if (cause instanceof AnalysisResourceConstraintError) return err(cause);
|
||||
if (cause instanceof JsonInputCancellationFailure)
|
||||
return err(
|
||||
new AnalysisCancelledError("read_evidence_file", {
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: cause.reason,
|
||||
}),
|
||||
);
|
||||
@@ -166,7 +170,7 @@ const readJsonFileInput = async <Value>(
|
||||
);
|
||||
if (isJsonReadCancellation(cause, signal))
|
||||
return err(
|
||||
new AnalysisCancelledError("read_evidence_file", {
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: signal?.reason,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { INSPECT_ANALYSIS_VIEW_OPERATION } from "../../domain/analysisView/analysisViewErrors.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ANALYSIS_VIEW_PROVIDER } from "../InvestigationProviders.js";
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
type AnalysisViewRequest,
|
||||
type InspectAnalysisViewInput,
|
||||
} from "../../domain/analysisView/analysisView.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
immutableJsonSnapshotMember,
|
||||
isImmutableJsonSnapshot,
|
||||
@@ -332,39 +332,44 @@ const createAnalysisViewEvidence = (
|
||||
? {}
|
||||
: { architecture: parent.subject.architecture }),
|
||||
};
|
||||
return createEvidence(target, ANALYSIS_VIEW_PROVIDER, {
|
||||
predicateType: "rea.analysis-view",
|
||||
operation: INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
parameters: {
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
view: jsonValueSchema.parse(input.view),
|
||||
return createEvidence(
|
||||
target,
|
||||
ANALYSIS_VIEW_PROVIDER,
|
||||
{
|
||||
predicateType: "rea.analysis-view",
|
||||
operation: INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
parameters: {
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
view: jsonValueSchema.parse(input.view),
|
||||
},
|
||||
result: jsonValueSchema.parse(result),
|
||||
rawResult: null,
|
||||
confidence:
|
||||
// Grouping is whole-capture, so any templated group or parameter makes the result inferred;
|
||||
// entity types and identity in an extracted state always are.
|
||||
result.kind === "environment-state" ||
|
||||
((result.kind === "api-surface" ||
|
||||
result.kind === "environment-manifest") &&
|
||||
result.templated_groups > 0)
|
||||
? "inferred"
|
||||
: "derived",
|
||||
authority: parent.authority,
|
||||
limitations: result.limitations,
|
||||
...(result.kind === "native" && parent.relationship_coverage !== null
|
||||
? { relationshipCoverage: parent.relationship_coverage }
|
||||
: {}),
|
||||
locations: [
|
||||
...(result.artifact != null && result.artifact.path.length > 0
|
||||
? [{ kind: "artifact-path" as const, path: result.artifact.path }]
|
||||
: []),
|
||||
...(result.kind === "native" &&
|
||||
result.procedure_address !== null &&
|
||||
result.procedure_address.length > 0
|
||||
? [{ kind: "address" as const, address: result.procedure_address }]
|
||||
: []),
|
||||
],
|
||||
evidenceLinks: [parent.evidence_id],
|
||||
},
|
||||
result: jsonValueSchema.parse(result),
|
||||
rawResult: null,
|
||||
confidence:
|
||||
// Grouping is whole-capture, so any templated group or parameter makes the result inferred;
|
||||
// entity types and identity in an extracted state always are.
|
||||
result.kind === "environment-state" ||
|
||||
((result.kind === "api-surface" ||
|
||||
result.kind === "environment-manifest") &&
|
||||
result.templated_groups > 0)
|
||||
? "inferred"
|
||||
: "derived",
|
||||
authority: parent.authority,
|
||||
limitations: result.limitations,
|
||||
...(result.kind === "native" && parent.relationship_coverage !== null
|
||||
? { relationshipCoverage: parent.relationship_coverage }
|
||||
: {}),
|
||||
locations: [
|
||||
...(result.artifact != null && result.artifact.path.length > 0
|
||||
? [{ kind: "artifact-path" as const, path: result.artifact.path }]
|
||||
: []),
|
||||
...(result.kind === "native" &&
|
||||
result.procedure_address !== null &&
|
||||
result.procedure_address.length > 0
|
||||
? [{ kind: "address" as const, address: result.procedure_address }]
|
||||
: []),
|
||||
],
|
||||
evidenceLinks: [parent.evidence_id],
|
||||
});
|
||||
{ immutable: true },
|
||||
);
|
||||
};
|
||||
@@ -32,16 +32,13 @@ export const readEvidenceFileSource = async (
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
const loaded = await readJsonFile(path, signal, resources);
|
||||
if (!loaded.ok) {
|
||||
if (loaded.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
|
||||
cause: loaded.error.cause,
|
||||
}),
|
||||
);
|
||||
return loaded;
|
||||
}
|
||||
const loaded = await readJsonFile(
|
||||
path,
|
||||
signal,
|
||||
resources,
|
||||
INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
);
|
||||
if (!loaded.ok) return loaded;
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
|
||||
|
||||
@@ -37,16 +37,13 @@ export const resolveSourceToBundleReference = async (
|
||||
}),
|
||||
);
|
||||
if (!("kind" in reference)) return ok({ reference });
|
||||
const read = await readJsonFileWithSource(reference.path, signal, resources);
|
||||
if (!read.ok) {
|
||||
return read.error instanceof AnalysisCancelledError
|
||||
? err(
|
||||
new AnalysisCancelledError("compare_source_to_bundle", {
|
||||
cause: read.error.cause,
|
||||
}),
|
||||
)
|
||||
: read;
|
||||
}
|
||||
const read = await readJsonFileWithSource(
|
||||
reference.path,
|
||||
signal,
|
||||
resources,
|
||||
"compare_source_to_bundle",
|
||||
);
|
||||
if (!read.ok) return read;
|
||||
await setImmediate();
|
||||
if (cancelled())
|
||||
return err(
|
||||
|
||||
@@ -83,6 +83,7 @@ export const loadNativeMatchingBundleSources = async ({
|
||||
paths[file]!,
|
||||
admission.signal,
|
||||
resources,
|
||||
admission.operation,
|
||||
);
|
||||
if (!loaded.ok) throw loaded.error;
|
||||
snapshots.push(await recordSnapshot(loaded.value, admission));
|
||||
@@ -141,6 +142,7 @@ export const loadNativeMatchingBundleSources = async ({
|
||||
paths[file]!,
|
||||
admission.signal,
|
||||
resources,
|
||||
admission.operation,
|
||||
);
|
||||
if (!loaded.ok) throw loaded.error;
|
||||
if ((await recordSnapshot(loaded.value, admission)) !== snapshots[file])
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { z } from "incur";
|
||||
|
||||
import { inspectAnalysisViewValidatedWithFiles } from "../application/analysisView/AnalysisViewService.js";
|
||||
import { inspectAnalysisViewFromBundle } from "../application/EvidenceBundleCommands.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { ANALYSIS_VIEW_PROVIDER } from "../application/InvestigationProviders.js";
|
||||
import { readEvidenceBundle } from "../application/EvidenceBundleFiles.js";
|
||||
import { inspectEvidenceBundle } from "../application/investigation/InspectEvidenceBundle.js";
|
||||
import {
|
||||
@@ -12,9 +15,11 @@ import { parseCliJsonInput, resolveCliJsonPaths } from "../cliJsonInput.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
|
||||
|
||||
import { inspectAnalysisViewInputSchema } from "../domain/analysisView/analysisView.js";
|
||||
/** Project selected views of completed analysis Evidence through the CLI. */
|
||||
@@ -54,7 +59,12 @@ export const registerAnalysisViewCommands = (
|
||||
),
|
||||
),
|
||||
};
|
||||
const loaded = await readEvidenceBundle(parsed.data.path);
|
||||
const loaded = await readEvidenceBundle(
|
||||
parsed.data.path,
|
||||
undefined,
|
||||
undefined,
|
||||
"get_evidence_bundle",
|
||||
);
|
||||
if (!loaded.ok)
|
||||
return {
|
||||
error: "Evidence bundle inspection failed",
|
||||
@@ -75,34 +85,63 @@ export const registerAnalysisViewCommands = (
|
||||
args: z.object({
|
||||
inputJson: z.string().describe("Inline workflow JSON or JSON file path"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectAnalysisView, async () => {
|
||||
const input = await parseCliJsonInput(
|
||||
args.inputJson,
|
||||
CLI_COMMANDS.inspectAnalysisView,
|
||||
);
|
||||
if (!input.ok) return input.error;
|
||||
// An evidence-file path is resolved against the operator's working directory.
|
||||
const value = resolveCliJsonPaths(input.value, [["source", "path"]]);
|
||||
const parsed = inspectAnalysisViewInputSchema.safeParse(value);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
error: "Application workflow failed",
|
||||
...projectAnalysisError(
|
||||
analysisInputErrorFromIssues(
|
||||
CLI_COMMANDS.inspectAnalysisView,
|
||||
parsed.error.issues,
|
||||
value,
|
||||
),
|
||||
),
|
||||
};
|
||||
const result = await inspectAnalysisViewValidatedWithFiles(parsed.data);
|
||||
return result.ok
|
||||
? jsonValueSchema.parse(result.value)
|
||||
: {
|
||||
options: z.object({
|
||||
evidenceBundle: localPathStringSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Read the exact retained-evidence source ID from this local Evidence bundle without reopening the target; omit for inline Evidence",
|
||||
),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectAnalysisView, async () => {
|
||||
const input = await parseCliJsonInput(
|
||||
args.inputJson,
|
||||
CLI_COMMANDS.inspectAnalysisView,
|
||||
signal,
|
||||
);
|
||||
if (!input.ok) return input.error;
|
||||
// An evidence-file path is resolved against the operator's working directory.
|
||||
const value = resolveCliJsonPaths(input.value, [["source", "path"]]);
|
||||
const parsed = inspectAnalysisViewInputSchema.safeParse(value);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
error: "Application workflow failed",
|
||||
...projectAnalysisError(result.error),
|
||||
...projectAnalysisError(
|
||||
analysisInputErrorFromIssues(
|
||||
CLI_COMMANDS.inspectAnalysisView,
|
||||
parsed.error.issues,
|
||||
value,
|
||||
),
|
||||
),
|
||||
};
|
||||
}),
|
||||
const resources = new ArtifactResourceScope();
|
||||
const bundlePath = options.evidenceBundle;
|
||||
const result = await finishAnalysisCommand(
|
||||
() =>
|
||||
bundlePath === undefined
|
||||
? inspectAnalysisViewValidatedWithFiles(
|
||||
parsed.data,
|
||||
undefined,
|
||||
{ signal, resources },
|
||||
)
|
||||
: inspectAnalysisViewFromBundle(
|
||||
parsed.data,
|
||||
bundlePath,
|
||||
resources,
|
||||
signal,
|
||||
),
|
||||
resources,
|
||||
ANALYSIS_VIEW_PROVIDER.id,
|
||||
"inspect_analysis_view",
|
||||
signal,
|
||||
);
|
||||
// Preserve the inline command's existing error label without copying
|
||||
// the authenticated completed Evidence or its partial observation.
|
||||
return bundlePath === undefined && "code" in result
|
||||
? { error: "Application workflow failed", ...result }
|
||||
: result;
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { dispatchQueuedInterrupts } from "../process/queuedInterrupts.js";
|
||||
|
||||
/** Finish a one-shot CLI operation while retaining primary results through close. */
|
||||
export const finishAnalysisCommand = async <
|
||||
@@ -59,16 +60,18 @@ export const finishAnalysisCommand = async <
|
||||
);
|
||||
}
|
||||
if (result.ok) cleanup.retainPartialObservation(result.value);
|
||||
return projectAnalysisError(
|
||||
result.ok
|
||||
result = {
|
||||
ok: false,
|
||||
error: result.ok
|
||||
? cleanup
|
||||
: analysisErrorWithCleanupFailure(
|
||||
result.error,
|
||||
cleanup,
|
||||
canonicalOperation,
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
await dispatchQueuedInterrupts();
|
||||
if (result.ok && signal.aborted)
|
||||
return projectAnalysisError(
|
||||
new AnalysisCancelledError(canonicalOperation, {
|
||||
|
||||
@@ -17,6 +17,8 @@ import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { registerProjectManagedApplicationGraph } from "./managedProjectGraphCommand.js";
|
||||
import { managedMembersInputSchema } from "../contracts/managed/managedToolContracts.js";
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
|
||||
export const registerManagedCommands = (
|
||||
cli: CliInstance,
|
||||
@@ -64,14 +66,33 @@ const registerInspectManagedMembers = (
|
||||
type: managedMembersInputSchema.shape.type_names.describe(
|
||||
"Inspect only members declared by this exact type full name, and the string literals they load, such as System.Threading.Monitor; a nested type is named by its bare name, which selects every nested type of that name (repeatable)",
|
||||
),
|
||||
detail: managedMembersInputSchema.shape.detail.describe(
|
||||
"complete returns full Evidence (default); summary returns identity, selection, coverage, limitations and collection counts and requires --evidence-bundle",
|
||||
),
|
||||
evidenceBundle: localPathStringSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Write complete parent Evidence to a new local bundle for later inspect-analysis-view calls; never replaces an existing path",
|
||||
),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "inspect-managed-members", () =>
|
||||
runProviderAnalysis(
|
||||
args.path,
|
||||
"inspect_managed_members",
|
||||
options.type === undefined ? {} : { type_names: options.type },
|
||||
logger,
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, "inspect-managed-members", () =>
|
||||
runProviderAnalysis(
|
||||
args.path,
|
||||
"inspect_managed_members",
|
||||
options.type === undefined ? {} : { type_names: options.type },
|
||||
logger,
|
||||
signal,
|
||||
{
|
||||
...(options.detail === undefined
|
||||
? {}
|
||||
: { detail: options.detail }),
|
||||
...(options.evidenceBundle === undefined
|
||||
? {}
|
||||
: { evidenceBundlePath: options.evidenceBundle }),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -7,10 +7,13 @@ type DirectAnalysisStatusRuntime =
|
||||
typeof import("../application/DirectAnalysisStatus.js");
|
||||
|
||||
type BoundOperation<Operation> = Operation extends (
|
||||
dependencies: infer _Dependencies,
|
||||
...args: infer Arguments
|
||||
) => infer Result
|
||||
? (...args: Arguments) => Result
|
||||
? (
|
||||
...args: Arguments extends [unknown, ...infer BoundArguments]
|
||||
? BoundArguments
|
||||
: never
|
||||
) => Result
|
||||
: never;
|
||||
|
||||
/** Bind one-shot analysis and status commands to one caller-selected environment. */
|
||||
|
||||
@@ -118,7 +118,7 @@ export const analysisErrorRemediationAction = (
|
||||
? "Correct the reported address, document, or arguments and retry."
|
||||
: "Review the Hopper diagnostic details and correct the request or target; run `rea doctor` if the provider itself is failing. Repeating the unchanged request is not expected to succeed.";
|
||||
if (error instanceof EvidenceReferenceError)
|
||||
return "Use an exact Evidence reference retained by this session, supply the complete inline Evidence, re-run its producer, or import its Evidence bundle. close_binary clears retained records.";
|
||||
return "Use an exact Evidence reference available from the selected source, supply the complete inline Evidence, re-run its producer, or select/import its Evidence bundle. close_binary clears a server session's retained records.";
|
||||
if (
|
||||
error instanceof BinaryTargetError &&
|
||||
error.constraint === "directory_requires_file"
|
||||
@@ -286,7 +286,7 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => {
|
||||
if (error instanceof ArtifactOperationError) return artifactMessage(error);
|
||||
if (error instanceof EvidenceReferenceError)
|
||||
return error.reason === "missing"
|
||||
? `Evidence ${error.evidenceId} is not retained in this session. Supply complete inline Evidence, re-run its producer, or import its bundle before using this reference.`
|
||||
? `Evidence ${error.evidenceId} is not available from the selected source. Supply complete inline Evidence, re-run its producer, or select/import its bundle before using this reference.`
|
||||
: `Evidence ${error.evidenceId} does not match the requested reference (${error.reason}). Check the expected and actual identity in the diagnostic details.`;
|
||||
if (error instanceof EvidenceIntegrityError)
|
||||
return "Evidence is invalid or has changed. Recreate or re-import it, then try again.";
|
||||
|
||||
+16
-1
@@ -24,6 +24,7 @@ import {
|
||||
} from "./jsonValue.js";
|
||||
import {
|
||||
freezeJsonSnapshot,
|
||||
freezeOwnedJsonTreeSnapshot,
|
||||
canBorrowParsedJsonSnapshot,
|
||||
isImmutableJsonSnapshot,
|
||||
} from "./immutableJson.js";
|
||||
@@ -346,11 +347,17 @@ const rememberImmutableEvidence = (evidence: Evidence): Evidence => {
|
||||
return evidence;
|
||||
};
|
||||
|
||||
/** Ownership policy for the freshly parsed Evidence snapshot. */
|
||||
export interface EvidenceCreationOptions {
|
||||
readonly immutable?: boolean;
|
||||
}
|
||||
|
||||
/** Build deterministic Evidence from an immutable artifact subject. */
|
||||
export const createEvidence = (
|
||||
target: EvidenceSubjectTarget | BinaryTarget | undefined,
|
||||
provider: EvidenceProvider,
|
||||
observation: EvidenceObservation,
|
||||
options?: EvidenceCreationOptions,
|
||||
): Evidence => {
|
||||
const { normalized, sharedResult } = normalizeEvidenceObservation(
|
||||
target,
|
||||
@@ -361,7 +368,15 @@ export const createEvidence = (
|
||||
...normalized,
|
||||
evidence_id: computeEvidenceId(normalized),
|
||||
};
|
||||
return sharedResult ? rememberImmutableEvidence(evidence) : evidence;
|
||||
// Seal this already parsed, exclusively owned snapshot without authenticating
|
||||
// or copying its payload again, including later partial-error delivery.
|
||||
if (sharedResult || options?.immutable === true) {
|
||||
// The normalizer's JSON parser clones mutable payloads into trees and only
|
||||
// borrows previously sealed roots. No graph-sized sealing scratch is needed.
|
||||
freezeOwnedJsonTreeSnapshot(evidence);
|
||||
immutableEvidenceSnapshots.set(evidence, evidence);
|
||||
}
|
||||
return evidence;
|
||||
};
|
||||
|
||||
/** Create Evidence from owned immutable JSON while exposing cooperative computation steps. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { equalJsonText } from "./jsonSerialization.js";
|
||||
import { z } from "zod";
|
||||
import canonicalize from "canonicalize";
|
||||
|
||||
@@ -322,7 +323,7 @@ const authenticateBundle = (
|
||||
}),
|
||||
parsed.unknowns,
|
||||
);
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(canonical))
|
||||
if (!equalJsonText(parsed, canonical))
|
||||
throw new NonCanonicalEvidenceBundleError();
|
||||
if (seal) {
|
||||
freezeJsonSnapshot(canonical);
|
||||
|
||||
@@ -139,6 +139,74 @@ export const freezeJsonSnapshot = <T extends JsonValue>(value: T): T => {
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Seal a freshly parsed, exclusively owned JSON tree. Its parser has removed
|
||||
* aliases, accessors and hidden properties; existing sealed subtrees may be shared.
|
||||
* Keep only the active ancestry rather than retaining every completed object.
|
||||
* Unlike general graph sealing, failure can leave this provisional tree frozen.
|
||||
*/
|
||||
export const freezeOwnedJsonTreeSnapshot = <T extends JsonValue>(
|
||||
value: T,
|
||||
): T => {
|
||||
const frames: { value: object; children: Iterator<unknown> }[] = [];
|
||||
const ancestors = new Set<object>();
|
||||
const enter = (item: unknown): void => {
|
||||
if (isImmutableJsonSnapshot(item)) return;
|
||||
if (typeof item !== "object" || item === null)
|
||||
throw new TypeError("Immutable JSON snapshots require JSON values");
|
||||
if (isProxy(item))
|
||||
throw new TypeError("Immutable JSON snapshots cannot contain proxies");
|
||||
if (ancestors.has(item))
|
||||
throw new TypeError(
|
||||
"Immutable JSON snapshots cannot contain circular references",
|
||||
);
|
||||
const prototype: unknown = Object.getPrototypeOf(item);
|
||||
if (
|
||||
!Array.isArray(item) &&
|
||||
prototype !== Object.prototype &&
|
||||
prototype !== null
|
||||
)
|
||||
throw new TypeError("Immutable JSON snapshots require ordinary objects");
|
||||
ancestors.add(item);
|
||||
frames.push({ value: item, children: ownedTreeChildren(item) });
|
||||
};
|
||||
enter(value);
|
||||
while (frames.length > 0) {
|
||||
const frame = frames[frames.length - 1]!;
|
||||
const next = frame.children.next();
|
||||
if (!next.done) enter(next.value);
|
||||
else {
|
||||
Object.freeze(frame.value);
|
||||
ancestors.delete(frame.value);
|
||||
frames.pop();
|
||||
}
|
||||
}
|
||||
if (typeof value === "object" && value !== null)
|
||||
immutableSnapshots.add(value);
|
||||
return value;
|
||||
};
|
||||
|
||||
function* ownedTreeChildren(value: object): Generator<unknown> {
|
||||
if (Array.isArray(value)) {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, index);
|
||||
if (descriptor === undefined || !Object.hasOwn(descriptor, "value"))
|
||||
throw new TypeError("Owned JSON trees require dense data arrays");
|
||||
yield descriptor.value;
|
||||
}
|
||||
} else {
|
||||
for (const key in value) {
|
||||
if (!Object.hasOwn(value, key)) continue;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (descriptor === undefined || !Object.hasOwn(descriptor, "value"))
|
||||
throw new TypeError(
|
||||
"Immutable JSON snapshots cannot contain accessors",
|
||||
);
|
||||
yield descriptor.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal exclusively owned JSON in bounded steps, authenticating only completion.
|
||||
* Abandonment or failure can leave the provisional input partly frozen.
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
bufferedJsonParts,
|
||||
canonicalJsonParts,
|
||||
jsonParts,
|
||||
equalJsonText,
|
||||
} from "./jsonSerialization.js";
|
||||
|
||||
/** Compact and pretty streamed JSON must equal native JSON. */
|
||||
@@ -104,3 +105,28 @@ describe("iterative JSON encoding", () => {
|
||||
expect(smallParts.length).toBeLessThan(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JSON text equality without whole-document strings", () => {
|
||||
it.each([
|
||||
[null, null],
|
||||
[-0, 0],
|
||||
[
|
||||
{ first: 1, omitted: undefined, second: 2 },
|
||||
{ first: 1, second: 2 },
|
||||
],
|
||||
[
|
||||
{ first: 1, second: 2 },
|
||||
{ second: 2, first: 1 },
|
||||
],
|
||||
[
|
||||
{ ["\u0000".repeat(9000)]: "\u0001😀\ud800".repeat(20000) },
|
||||
{ ["\u0000".repeat(9000)]: "\u0001😀\ud800".repeat(20000) },
|
||||
],
|
||||
["prefix".repeat(20000) + "a", "prefix".repeat(20000) + "b"],
|
||||
["prefix".repeat(20000), "prefix".repeat(20000) + "a"],
|
||||
])("preserves native JSON comparison semantics", (left, right) => {
|
||||
expect(equalJsonText(left, right)).toBe(
|
||||
JSON.stringify(left) === JSON.stringify(right),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,23 @@
|
||||
/** Exact UTF-16 code units of a JSON string, including escaping and quotes. */
|
||||
export const jsonStringCodeUnits = (value: string): number => {
|
||||
let length = value.length + 2;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code === 34 || code === 92) length += 1;
|
||||
else if (code < 32)
|
||||
length +=
|
||||
code === 8 || code === 9 || code === 10 || code === 12 || code === 13
|
||||
? 1
|
||||
: 5;
|
||||
else if (code >= 0xd800 && code <= 0xdfff) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) index += 1;
|
||||
else length += 5;
|
||||
}
|
||||
}
|
||||
return length;
|
||||
};
|
||||
|
||||
/** Encode ordinary JSON as bounded string parts without a document-sized allocation. */
|
||||
export function* jsonParts(value: unknown, pretty = false): Generator<string> {
|
||||
yield* encodeValue(value, pretty, false);
|
||||
@@ -8,6 +28,37 @@ export function* canonicalJsonParts(value: unknown): Generator<string> {
|
||||
yield* encodeValue(value, false, true);
|
||||
}
|
||||
|
||||
/** Compare ordinary JSON text in bounded parts, preserving native key order. */
|
||||
export const equalJsonText = (left: unknown, right: unknown): boolean => {
|
||||
const a = jsonParts(left);
|
||||
const b = jsonParts(right);
|
||||
let x = a.next();
|
||||
let y = b.next();
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
try {
|
||||
while (!x.done && !y.done) {
|
||||
const length = Math.min(x.value.length - i, y.value.length - j);
|
||||
if (x.value.slice(i, i + length) !== y.value.slice(j, j + length))
|
||||
return false;
|
||||
i += length;
|
||||
j += length;
|
||||
if (i === x.value.length) {
|
||||
x = a.next();
|
||||
i = 0;
|
||||
}
|
||||
if (j === y.value.length) {
|
||||
y = b.next();
|
||||
j = 0;
|
||||
}
|
||||
}
|
||||
return x.done === true && y.done === true;
|
||||
} finally {
|
||||
a.return(undefined);
|
||||
b.return(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
/** Buffer JSON parts while preserving complete UTF-16 surrogate pairs. */
|
||||
export function* bufferedJsonParts(parts: Iterable<string>): Generator<string> {
|
||||
let buffer = "";
|
||||
|
||||
@@ -29,6 +29,7 @@ interface ManagedPeFixtureOptions {
|
||||
readonly moduleRowCount?: number;
|
||||
readonly assemblyRowCount?: number;
|
||||
readonly memberRefParentRaw?: number;
|
||||
readonly memberRefRowCount?: number;
|
||||
readonly memberRefName?: string;
|
||||
readonly memberRefSignature?: Buffer;
|
||||
/** Raw MethodDefOrRef coded values, one MethodSpec row each. */
|
||||
@@ -771,7 +772,7 @@ const fixtureIndexWidths = ({
|
||||
[4, 1],
|
||||
[6, options.methodRowCount ?? 1],
|
||||
[8, 0],
|
||||
[10, 1],
|
||||
[10, options.memberRefRowCount ?? 1],
|
||||
[
|
||||
12,
|
||||
options.targetFramework === null
|
||||
@@ -885,7 +886,7 @@ const fixtureMetadataRows = ({
|
||||
],
|
||||
[
|
||||
10,
|
||||
[
|
||||
repeatedRows(options.memberRefRowCount ?? 1, () =>
|
||||
memberRefRow({
|
||||
name: indexes.constructorName,
|
||||
signature: indexes.constructorSignature,
|
||||
@@ -896,7 +897,7 @@ const fixtureMetadataRows = ({
|
||||
? {}
|
||||
: { parentRaw: options.memberRefParentRaw }),
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
[12, customAttributeRows({ options, indexes, heapSizes }, widths)],
|
||||
...(indexes.pinvokeModuleName === null
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
|
||||
/** Dispatch queued callbacks, then the OS signal notifications they may enqueue. */
|
||||
export const dispatchQueuedInterrupts = async (): Promise<void> => {
|
||||
await setImmediate();
|
||||
await setImmediate();
|
||||
};
|
||||
@@ -37,11 +37,13 @@ export const registerBundleComparisonTool = (
|
||||
input.left_bundle_path,
|
||||
context.mcpReq.signal,
|
||||
resources,
|
||||
contract.name,
|
||||
),
|
||||
readImmutableEvidenceBundle(
|
||||
input.right_bundle_path,
|
||||
context.mcpReq.signal,
|
||||
resources,
|
||||
contract.name,
|
||||
),
|
||||
]);
|
||||
if (!left.ok) return server.delivery.toCallToolResult(left, contract);
|
||||
|
||||
@@ -111,6 +111,7 @@ const registerImportEvidenceTool = ({
|
||||
path,
|
||||
signal,
|
||||
resources,
|
||||
importContract.name,
|
||||
);
|
||||
if (!loaded.ok)
|
||||
return server.delivery.toCallToolResult(loaded, importContract);
|
||||
|
||||
@@ -4,8 +4,13 @@ import {
|
||||
} from "../../fixtures/historicalHar.js";
|
||||
import { inspectAnalysisView } from "../../../src/application/analysisView/AnalysisViewService.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
analysisCliErrorEnvelopeSchema,
|
||||
analysisErrorProjectionSchema,
|
||||
} from "../../../src/contracts/errorSchemas.js";
|
||||
|
||||
import { expect } from "vitest";
|
||||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
@@ -29,6 +34,101 @@ import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.
|
||||
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { inspectEvidenceBundle } from "../../../src/application/investigation/InspectEvidenceBundle.js";
|
||||
|
||||
cliTest(
|
||||
"authenticates an exact portable parent for non-managed views and rejects ambiguous or altered sources",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-analysis-bundle-cli-");
|
||||
const path = join(root, "bundle.json");
|
||||
const parent = analysisViewLayoutEvidence();
|
||||
const view = {
|
||||
kind: "item",
|
||||
collection: "sections",
|
||||
selector: { name: ".data" },
|
||||
};
|
||||
const request = {
|
||||
source: { kind: "retained-evidence", evidence_id: parent.evidence_id },
|
||||
view,
|
||||
};
|
||||
const bundle = createEvidenceBundle([
|
||||
analysisViewJavaScriptEvidence(),
|
||||
parent,
|
||||
]);
|
||||
const original = JSON.stringify(bundle);
|
||||
await writeFile(path, original);
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
JSON.stringify(request),
|
||||
"--evidence-bundle",
|
||||
path,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const expected = inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: parent },
|
||||
view,
|
||||
});
|
||||
if (!expected.ok) throw expected.error;
|
||||
const evidence = parseEvidence(result.json);
|
||||
expect(evidence.evidence_id).toBe(expected.value.evidence_id);
|
||||
expect(evidence.normalized_result).toEqual(
|
||||
expected.value.normalized_result,
|
||||
);
|
||||
expect(evidence.evidence_links).toEqual([parent.evidence_id]);
|
||||
expect(await readFile(path, "utf8")).toBe(original);
|
||||
|
||||
for (const [source, expectedCode] of [
|
||||
[{ kind: "inline", evidence: parent }, "invalid_request"],
|
||||
[
|
||||
{ kind: "retained-evidence", evidence_id: `ev_${"f".repeat(64)}` },
|
||||
"evidence_integrity_mismatch",
|
||||
],
|
||||
] as const) {
|
||||
const rejected = await cli.run({
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
JSON.stringify({ source, view }),
|
||||
"--evidence-bundle",
|
||||
path,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(rejected.exitCode).toBe(1);
|
||||
expect(rejected.json).toMatchObject({ code: expectedCode });
|
||||
expect(rejected.json).not.toHaveProperty("evidence_id");
|
||||
}
|
||||
for (const [contents, expectedCode] of [
|
||||
[JSON.stringify(parent), "invalid_request"],
|
||||
["{invalid JSON", "evidence_integrity_mismatch"],
|
||||
[
|
||||
JSON.stringify({
|
||||
...bundle,
|
||||
records: bundle.records.map((record) => ({
|
||||
...record,
|
||||
normalized_result: "altered",
|
||||
})),
|
||||
}),
|
||||
"evidence_integrity_mismatch",
|
||||
],
|
||||
] as const) {
|
||||
await writeFile(path, contents);
|
||||
const rejected = await cli.run({
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
JSON.stringify(request),
|
||||
"--evidence-bundle",
|
||||
path,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(rejected.exitCode).toBe(1);
|
||||
expect(rejected.json).toMatchObject({ code: expectedCode });
|
||||
expect(rejected.json).not.toHaveProperty("evidence_id");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"preserves native relationship coverage independently of exhausted CLI pages",
|
||||
async ({ cli }) => {
|
||||
@@ -759,3 +859,66 @@ cliTest(
|
||||
expect(JSON.stringify(result.json)).not.toContain("probe folder");
|
||||
},
|
||||
);
|
||||
|
||||
cliTest.for(
|
||||
(process.platform === "win32"
|
||||
? (["event"] as const)
|
||||
: (["event", "os"] as const)
|
||||
).flatMap((delivery) => [
|
||||
{ signal: "SIGINT" as const, status: 130, delivery },
|
||||
{ signal: "SIGTERM" as const, status: 143, delivery },
|
||||
]),
|
||||
)(
|
||||
"retains inline projection when $signal ($delivery) is queued during final sealing",
|
||||
async ({ signal, status, delivery }, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-inline-view-signal-");
|
||||
const parent = analysisViewLayoutEvidence(),
|
||||
marker = join(root, "signal.json");
|
||||
const preload = resolve(
|
||||
"tests/fixtures/evidenceCompletionSignalPreload.mjs",
|
||||
);
|
||||
const reply = await cli.run({
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
JSON.stringify({
|
||||
source: { kind: "inline", evidence: parent },
|
||||
view: { kind: "summary" },
|
||||
}),
|
||||
"--json",
|
||||
],
|
||||
environment: {
|
||||
HOME: root,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_TEST_EVIDENCE_COMPLETION: JSON.stringify({
|
||||
operation: "inspect_analysis_view",
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
marker,
|
||||
signal,
|
||||
delivery,
|
||||
}),
|
||||
NODE_OPTIONS: `--max-old-space-size=2048 --import=${pathToFileURL(preload).href}`,
|
||||
},
|
||||
});
|
||||
expect(reply.exitCode).toBe(status);
|
||||
const { error: label, ...projection } =
|
||||
analysisCliErrorEnvelopeSchema.parse(reply.json);
|
||||
expect(label).toBe("Application workflow failed");
|
||||
const error = analysisErrorProjectionSchema.parse(projection);
|
||||
expect(error.code).toBe("cancelled");
|
||||
expect(error.details?.operation).toBe("inspect_analysis_view");
|
||||
const partial = parseEvidence(error.details?.partial_observation);
|
||||
expect(partial.operation).toBe("inspect_analysis_view");
|
||||
expect(partial.evidence_links).toEqual([parent.evidence_id]);
|
||||
expect(partial.normalized_result).toMatchObject({
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
kind: "summary",
|
||||
});
|
||||
const proof = JSON.parse(await readFile(marker, "utf8"));
|
||||
expect(proof).toMatchObject({
|
||||
evidence_id: partial.evidence_id,
|
||||
signal,
|
||||
delivery,
|
||||
});
|
||||
expect(proof.listeners).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { readdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect } from "vitest";
|
||||
@@ -52,6 +52,38 @@ const revisionBundle = () => {
|
||||
return createEvidenceBundle(records, [initial, revised]);
|
||||
};
|
||||
|
||||
cliTest.for([
|
||||
"export_evidence_bundle",
|
||||
"compare_bundles",
|
||||
"get_evidence_bundle",
|
||||
])("attributes an early heap refusal to $0", async (operation, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-bundle-operation-");
|
||||
const path = join(root, "parent.json");
|
||||
const output = join(root, "output.json");
|
||||
await writeFile(path, JSON.stringify(revisionBundle()));
|
||||
const preload =
|
||||
"import v8 from 'node:v8';import {syncBuiltinESMExports} from 'node:module';const read=v8.getHeapStatistics;v8.getHeapStatistics=()=>({...read(),total_available_size:0});syncBuiltinESMExports();";
|
||||
const arguments_ =
|
||||
operation === "export_evidence_bundle"
|
||||
? ["evidence-export", path, output]
|
||||
: operation === "compare_bundles"
|
||||
? ["compare", path, path]
|
||||
: ["inspect-evidence-bundle", JSON.stringify({ path })];
|
||||
const result = await cli.run({
|
||||
arguments: [...arguments_, "--json"],
|
||||
environment: {
|
||||
REA_LOG_LEVEL: "silent",
|
||||
NODE_OPTIONS: `--max-old-space-size=2048 --import=data:text/javascript;base64,${Buffer.from(preload).toString("base64")}`,
|
||||
},
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: { operation },
|
||||
});
|
||||
expect(await readdir(root)).toEqual(["parent.json"]);
|
||||
});
|
||||
|
||||
cliTest(
|
||||
"reports standalone import counts for every unknown revision",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -533,7 +533,7 @@ describe("CLI result output surface", () => {
|
||||
},
|
||||
});
|
||||
const cli = Cli.create("rea-output-test", { sync: false });
|
||||
registerManagedCommands(cli, silentLogger, async (path, operation) => {
|
||||
registerManagedCommands(cli, silentLogger, async (...[path, operation]) => {
|
||||
expect(path).toBe("fixture.dll");
|
||||
expect(operation).toBe("inspect_managed_members");
|
||||
return value;
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
realpath,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { inspectAnalysisView } from "../../../src/application/analysisView/AnalysisViewService.js";
|
||||
import {
|
||||
analysisCliErrorEnvelopeSchema,
|
||||
analysisErrorProjectionSchema,
|
||||
} from "../../../src/contracts/errorSchemas.js";
|
||||
import {
|
||||
analysisViewResultSchema,
|
||||
managedMemberSummaryViewResultSchema,
|
||||
} from "../../../src/domain/analysisView/analysisView.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { managedMemberInspectionSchema } from "../../../src/domain/managed/managedArtifact.js";
|
||||
import { buildManagedPeFixture } from "../../../src/dotnet/ManagedPe.fixture.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const memberFixture = (): Buffer =>
|
||||
buildManagedPeFixture({
|
||||
methodRowCount: 2,
|
||||
memberRefRowCount: 3,
|
||||
// ldarg.0; ldfld field; call member ref; ldstr "ok"; pop; ret
|
||||
ilBody: Buffer.from([
|
||||
(18 << 2) | 2,
|
||||
0x02,
|
||||
0x7b,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0x04,
|
||||
0x28,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0x0a,
|
||||
0x72,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0x70,
|
||||
0x26,
|
||||
0x2a,
|
||||
]),
|
||||
userStringHeap: Buffer.from([0, 5, 0x6f, 0, 0x6b, 0, 0]),
|
||||
exportedTypes: [
|
||||
{
|
||||
namespace: "System",
|
||||
name: "Object",
|
||||
flags: 0x0020_0001,
|
||||
implementationRaw: (1 << 2) | 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
cliTest(
|
||||
"advertises durable summary and portable paging options in CLI help",
|
||||
async ({ cli }) => {
|
||||
for (const command of [
|
||||
"inspect-managed-members",
|
||||
"inspect-analysis-view",
|
||||
]) {
|
||||
const result = await cli.run({ arguments: [command, "--help"] });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("--evidence-bundle");
|
||||
if (command === "inspect-managed-members") {
|
||||
expect(result.stdout).toContain("--detail");
|
||||
expect(result.stdout).toContain("summary");
|
||||
expect(result.stdout).toContain("complete");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"retains complete member Evidence for exact CLI pages after process exit and target removal",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-member-summary-cli-");
|
||||
const path = join(root, "fixture.dll");
|
||||
const bundlePath = join(root, "members.json");
|
||||
await writeFile(path, memberFixture());
|
||||
const typeNames = ["Fixture.Program", "Missing.Type"];
|
||||
const arguments_ = [
|
||||
"inspect-managed-members",
|
||||
path,
|
||||
...typeNames.flatMap((type) => ["--type", type]),
|
||||
"--json",
|
||||
];
|
||||
const omitted = await cli.run({ arguments: arguments_ });
|
||||
expect(omitted.exitCode).toBe(0);
|
||||
const complete = parseEvidence(omitted.json);
|
||||
const completeBundlePath = join(root, "complete.json");
|
||||
const explicit = await cli.run({
|
||||
arguments: [
|
||||
...arguments_,
|
||||
"--detail",
|
||||
"complete",
|
||||
"--evidence-bundle",
|
||||
completeBundlePath,
|
||||
],
|
||||
});
|
||||
expect(explicit.exitCode).toBe(0);
|
||||
expect(parseEvidence(explicit.json).evidence_id).toBe(complete.evidence_id);
|
||||
expect(
|
||||
parseEvidenceBundle(
|
||||
JSON.parse(await readFile(completeBundlePath, "utf8")),
|
||||
).records[0]?.evidence_id,
|
||||
).toBe(complete.evidence_id);
|
||||
|
||||
const reply = await cli.run({
|
||||
cwd: root,
|
||||
arguments: [
|
||||
...arguments_,
|
||||
"--detail",
|
||||
"summary",
|
||||
"--evidence-bundle",
|
||||
"members.json",
|
||||
],
|
||||
});
|
||||
expect(reply.exitCode).toBe(0);
|
||||
const receipt = parseEvidence(reply.json);
|
||||
const summary = managedMemberSummaryViewResultSchema.parse(
|
||||
receipt.normalized_result,
|
||||
);
|
||||
const originalBytes = await readFile(bundlePath, "utf8");
|
||||
const bundle = parseEvidenceBundle(JSON.parse(originalBytes));
|
||||
expect(bundle.records).toHaveLength(1);
|
||||
const parent = bundle.records[0]!;
|
||||
expect(parent.evidence_id).toBe(complete.evidence_id);
|
||||
expect(parent.normalized_result).toEqual(complete.normalized_result);
|
||||
expect(parent.raw_result).toEqual(complete.raw_result);
|
||||
expect(parent.subject).toEqual(complete.subject);
|
||||
expect(parent.locations).toEqual(complete.locations);
|
||||
expect(parent.parameters).toEqual({ type_names: typeNames });
|
||||
expect(receipt.evidence_links).toEqual([parent.evidence_id]);
|
||||
const portable = inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: parent },
|
||||
view: { kind: "summary" },
|
||||
});
|
||||
if (!portable.ok) throw portable.error;
|
||||
expect(receipt.evidence_id).toBe(portable.value.evidence_id);
|
||||
expect(receipt.normalized_result).toEqual(portable.value.normalized_result);
|
||||
const report = managedMemberInspectionSchema.parse(
|
||||
parent.normalized_result,
|
||||
);
|
||||
expect(summary.summary.selection).toEqual({
|
||||
type_names: typeNames,
|
||||
unmatched_type_names: ["Missing.Type"],
|
||||
});
|
||||
expect(summary.summary.coverage).toEqual(report.coverage);
|
||||
expect(summary.summary.identity_scope).toEqual(report.identity_scope);
|
||||
expect(summary.summary).not.toHaveProperty("member_refs");
|
||||
expect(summary.summary).not.toHaveProperty("methods");
|
||||
|
||||
await unlink(path);
|
||||
const otherDirectory = join(root, "later-process");
|
||||
await mkdir(otherDirectory);
|
||||
for (const collection of [
|
||||
"types",
|
||||
"exported_types",
|
||||
"fields",
|
||||
"methods",
|
||||
"member_refs",
|
||||
"call_edges",
|
||||
"field_accesses",
|
||||
"user_strings",
|
||||
] as const) {
|
||||
const rows =
|
||||
collection === "user_strings"
|
||||
? report.user_strings?.literals
|
||||
: report[collection];
|
||||
if (rows == null) throw new Error(`${collection} was not examined`);
|
||||
expect(rows.length, collection).toBeGreaterThan(0);
|
||||
expect(summary.summary.reported_counts[collection]).toBe(rows.length);
|
||||
const paged: unknown[] = [];
|
||||
for (let offset: number | null = 0; offset !== null;) {
|
||||
const response = await cli.run({
|
||||
cwd: otherDirectory,
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
JSON.stringify({
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: parent.evidence_id,
|
||||
},
|
||||
view: { kind: "managed-members", collection, offset, limit: 1 },
|
||||
}),
|
||||
"--evidence-bundle",
|
||||
bundlePath,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(response.exitCode).toBe(0);
|
||||
const evidence = parseEvidence(response.json);
|
||||
expect(evidence.evidence_links).toEqual([parent.evidence_id]);
|
||||
expect(evidence.subject).toEqual(parent.subject);
|
||||
const page = analysisViewResultSchema.parse(evidence.normalized_result);
|
||||
if (page.kind !== "managed-members") throw new Error("Wrong page kind");
|
||||
expect(page.page.collection).toBe(collection);
|
||||
expect(page.selection).toEqual(report.selection);
|
||||
expect(page.source_coverage).toEqual(report.coverage);
|
||||
expect(page.source_metadata_status).toBe(report.metadata.status);
|
||||
expect(page.coverage.total).toBe(rows.length);
|
||||
paged.push(...page.page.rows);
|
||||
offset = page.coverage.next_offset;
|
||||
}
|
||||
expect(paged).toEqual(rows);
|
||||
}
|
||||
expect(await readFile(bundlePath, "utf8")).toBe(originalBytes);
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"refuses summary without a durable destination before opening the target",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-member-summary-missing-");
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"inspect-managed-members",
|
||||
join(root, "absent.dll"),
|
||||
"--detail",
|
||||
"summary",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: ["evidence_bundle"],
|
||||
message: expect.stringContaining("summary"),
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.json).not.toHaveProperty("evidence_id");
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"does not replace caller files or report successful summary when bundle publication fails",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-member-summary-write-");
|
||||
const path = join(root, "fixture.dll");
|
||||
const bytes = buildManagedPeFixture();
|
||||
await writeFile(path, bytes);
|
||||
const complete = await cli.run({
|
||||
arguments: ["inspect-managed-members", path, "--json"],
|
||||
});
|
||||
expect(complete.exitCode).toBe(0);
|
||||
const parent = parseEvidence(complete.json);
|
||||
for (const destination of [path, join(root, "absent", "bundle.json")]) {
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"inspect-managed-members",
|
||||
path,
|
||||
"--detail",
|
||||
"summary",
|
||||
"--evidence-bundle",
|
||||
destination,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
path: destination,
|
||||
reason: destination === path ? "exists" : "missing",
|
||||
},
|
||||
});
|
||||
const {
|
||||
error: _label,
|
||||
input_path: _inputPath,
|
||||
input_reason: _inputReason,
|
||||
...projection
|
||||
} = analysisCliErrorEnvelopeSchema.parse(result.json);
|
||||
const failure = analysisErrorProjectionSchema.parse(projection);
|
||||
if (failure.code !== "invalid_request")
|
||||
throw new Error(`Unexpected publication failure: ${failure.code}`);
|
||||
const retained = parseEvidence(failure.details?.partial_observation);
|
||||
expect(retained.evidence_id).toBe(parent.evidence_id);
|
||||
expect(retained.normalized_result).toEqual(parent.normalized_result);
|
||||
expect(result.json).not.toHaveProperty("evidence_id");
|
||||
expect(result.json).not.toHaveProperty("normalized_result");
|
||||
}
|
||||
expect(await readFile(path)).toEqual(bytes);
|
||||
expect(await readdir(root)).toEqual(["fixture.dll"]);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"preserves provider refusal without publishing an Evidence bundle",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-member-summary-provider-");
|
||||
const path = join(root, "absent.dll");
|
||||
const destination = join(root, "bundle.json");
|
||||
const complete = await cli.run({
|
||||
arguments: ["inspect-managed-members", path, "--json"],
|
||||
});
|
||||
const summary = await cli.run({
|
||||
arguments: [
|
||||
"inspect-managed-members",
|
||||
path,
|
||||
"--detail",
|
||||
"summary",
|
||||
"--evidence-bundle",
|
||||
destination,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(complete.exitCode).toBe(1);
|
||||
expect(summary.exitCode).toBe(1);
|
||||
expect(summary.json).toEqual(complete.json);
|
||||
await expect(readFile(destination)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"keeps unavailable managed member coverage explicit in saved summary Evidence",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-member-summary-unknown-");
|
||||
const path = join(root, "malformed.dll");
|
||||
const destination = join(root, "bundle.json");
|
||||
await writeFile(path, buildManagedPeFixture({ virtualSectionSize: 0 }));
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"inspect-managed-members",
|
||||
path,
|
||||
"--detail",
|
||||
"summary",
|
||||
"--evidence-bundle",
|
||||
destination,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const summary = managedMemberSummaryViewResultSchema.parse(
|
||||
parseEvidence(result.json).normalized_result,
|
||||
);
|
||||
expect(summary.summary).toMatchObject({
|
||||
metadata: { status: "malformed" },
|
||||
coverage: { state: "unavailable" },
|
||||
reported_counts: { methods: 0 },
|
||||
});
|
||||
expect(summary.unknowns.length).toBeGreaterThan(0);
|
||||
const bundle = parseEvidenceBundle(
|
||||
JSON.parse(await readFile(destination, "utf8")),
|
||||
);
|
||||
const report = managedMemberInspectionSchema.parse(
|
||||
bundle.records[0]!.normalized_result,
|
||||
);
|
||||
expect(summary.summary.coverage).toEqual(report.coverage);
|
||||
expect(summary.limitations).toEqual(report.limitations);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest.for(
|
||||
(process.platform === "win32"
|
||||
? (["event"] as const)
|
||||
: (["event", "os"] as const)
|
||||
).flatMap((delivery) => [
|
||||
{ signal: "SIGINT" as const, status: 130, delivery },
|
||||
{ signal: "SIGTERM" as const, status: 143, delivery },
|
||||
]),
|
||||
)(
|
||||
"keeps a committed receipt when $signal ($delivery) is queued during final cleanup",
|
||||
async ({ signal, status, delivery }, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-managed-queued-signal-");
|
||||
const target = join(root, "fixture.dll");
|
||||
const destination = join(root, "parent.json");
|
||||
const marker = join(root, "signal.json");
|
||||
const preload = resolve(
|
||||
"tests/fixtures/managedPublicationSignalPreload.mjs",
|
||||
);
|
||||
const prefix = join(await realpath(root), ".rea-write-");
|
||||
await writeFile(target, memberFixture());
|
||||
const reply = await cli.run({
|
||||
cwd: root,
|
||||
arguments: [
|
||||
"inspect-managed-members",
|
||||
target,
|
||||
"--type",
|
||||
"Fixture.Program",
|
||||
"--detail",
|
||||
"summary",
|
||||
"--evidence-bundle",
|
||||
destination,
|
||||
"--json",
|
||||
],
|
||||
environment: {
|
||||
HOME: root,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_TEST_MANAGED_PUBLICATION: JSON.stringify({
|
||||
prefix,
|
||||
marker,
|
||||
signal,
|
||||
delivery,
|
||||
}),
|
||||
NODE_OPTIONS: `--max-old-space-size=2048 --import=${pathToFileURL(preload).href}`,
|
||||
},
|
||||
});
|
||||
expect(reply.exitCode).toBe(status);
|
||||
const { error: _label, ...projection } =
|
||||
analysisCliErrorEnvelopeSchema.parse(reply.json);
|
||||
const error = analysisErrorProjectionSchema.parse(projection);
|
||||
expect(error.code).toBe("cancelled");
|
||||
expect(error.details?.operation).toBe("inspect_managed_members");
|
||||
const bytes = await readFile(destination);
|
||||
const parent = parseEvidenceBundle(JSON.parse(bytes.toString()))
|
||||
.records[0]!;
|
||||
expect(error.details).toMatchObject({
|
||||
diagnostics: {
|
||||
evidence_bundle: { path: destination, bytes: bytes.length },
|
||||
},
|
||||
});
|
||||
const partial = parseEvidence(error.details?.partial_observation);
|
||||
expect(partial.evidence_links).toEqual([parent.evidence_id]);
|
||||
expect(partial.normalized_result).toMatchObject({
|
||||
kind: "summary",
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
});
|
||||
expect(JSON.parse(await readFile(marker, "utf8"))).toMatchObject({
|
||||
signal,
|
||||
listeners: expect.any(Number),
|
||||
});
|
||||
expect(
|
||||
JSON.parse(await readFile(marker, "utf8")).listeners,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
(await readdir(root)).some((name) => name.startsWith(".rea-write-")),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest.for(
|
||||
(process.platform === "win32"
|
||||
? (["event"] as const)
|
||||
: (["event", "os"] as const)
|
||||
).flatMap((delivery) => [
|
||||
{ signal: "SIGINT" as const, status: 130, delivery },
|
||||
{ signal: "SIGTERM" as const, status: 143, delivery },
|
||||
]),
|
||||
)(
|
||||
"retains complete default Evidence when $signal ($delivery) is queued during sealing",
|
||||
async ({ signal, status, delivery }, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-managed-complete-signal-");
|
||||
const target = join(root, "fixture.dll"),
|
||||
marker = join(root, "signal.json");
|
||||
await writeFile(target, memberFixture());
|
||||
const preload = resolve(
|
||||
"tests/fixtures/evidenceCompletionSignalPreload.mjs",
|
||||
);
|
||||
const reply = await cli.run({
|
||||
arguments: [
|
||||
"inspect-managed-members",
|
||||
target,
|
||||
"--type",
|
||||
"Fixture.Program",
|
||||
"--json",
|
||||
],
|
||||
environment: {
|
||||
HOME: root,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_TEST_EVIDENCE_COMPLETION: JSON.stringify({
|
||||
operation: "inspect_managed_members",
|
||||
target_path: target,
|
||||
marker,
|
||||
signal,
|
||||
delivery,
|
||||
}),
|
||||
NODE_OPTIONS: `--max-old-space-size=2048 --import=${pathToFileURL(preload).href}`,
|
||||
},
|
||||
});
|
||||
expect(reply.exitCode).toBe(status);
|
||||
const { error: _label, ...projection } =
|
||||
analysisCliErrorEnvelopeSchema.parse(reply.json);
|
||||
const error = analysisErrorProjectionSchema.parse(projection);
|
||||
expect(error.code).toBe("cancelled");
|
||||
expect(error.details?.operation).toBe("inspect_managed_members");
|
||||
const partial = parseEvidence(error.details?.partial_observation);
|
||||
expect(partial.operation).toBe("inspect_managed_members");
|
||||
expect(partial.subject?.local_path).toBe(target);
|
||||
expect(
|
||||
managedMemberInspectionSchema.parse(partial.normalized_result).methods,
|
||||
).toHaveLength(2);
|
||||
const proof = JSON.parse(await readFile(marker, "utf8"));
|
||||
expect(proof).toMatchObject({
|
||||
evidence_id: partial.evidence_id,
|
||||
signal,
|
||||
delivery,
|
||||
});
|
||||
expect(proof.listeners).toBeGreaterThan(0);
|
||||
expect((await readdir(root)).sort()).toEqual([
|
||||
"fixture.dll",
|
||||
"signal.json",
|
||||
]);
|
||||
},
|
||||
);
|
||||
@@ -18,20 +18,26 @@ import {
|
||||
readImmutableEvidenceBundle,
|
||||
writeEvidenceBundle,
|
||||
} from "../../../src/application/EvidenceBundleFiles.js";
|
||||
import { compareEvidenceBundlesCommand } from "../../../src/application/EvidenceBundleCommands.js";
|
||||
import {
|
||||
compareEvidenceBundlesCommand,
|
||||
deliverAnalysisEvidence,
|
||||
} from "../../../src/application/EvidenceBundleCommands.js";
|
||||
import { EvidenceLedger } from "../../../src/application/investigation/EvidenceLedger.js";
|
||||
import { RegularFileCleanupFailure } from "../../../src/application/RegularFileRead.js";
|
||||
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
|
||||
import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { writeTextParts } from "../../../src/application/JsonFiles.js";
|
||||
import { failFileReadAfterAbort } from "../../support/failFileReadAfterAbort.js";
|
||||
import { holdFirstFileRead } from "../../support/holdFirstFileRead.js";
|
||||
import { JsonInputPhysicalReadFailure } from "../../../src/application/JsonInputFile.js";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
parseEvidenceBundle,
|
||||
serializeEvidenceBundle,
|
||||
} from "../../../src/domain/evidenceBundle.js";
|
||||
import { analysisViewLayoutEvidence } from "../../fixtures/analysisView.js";
|
||||
|
||||
const bundle = (result = true) =>
|
||||
createEvidenceBundle([
|
||||
@@ -58,6 +64,7 @@ it.each([
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "import_evidence_bundle",
|
||||
cause: controller.signal.reason,
|
||||
},
|
||||
});
|
||||
if (result.ok) throw new Error("Expected cancelled bundle read");
|
||||
@@ -194,6 +201,32 @@ it("preserves the failed-close owner when import is also cancelled", async () =>
|
||||
});
|
||||
|
||||
describe("evidence bundle export cancellation", () => {
|
||||
it("retains the completed parent and exact reason when delivery is cancelled before publication", async () => {
|
||||
const root = await createTestTempDirectory("rea-delivery-cancel-");
|
||||
const parent = analysisViewLayoutEvidence();
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("cancel before bundle publication");
|
||||
controller.abort(reason);
|
||||
const result = await deliverAnalysisEvidence(
|
||||
parent,
|
||||
join(root, "bundle.json"),
|
||||
"summary",
|
||||
controller.signal,
|
||||
);
|
||||
if (result.ok) throw new Error("Cancelled delivery reported success");
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: parent.operation,
|
||||
});
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
details: { operation: parent.operation },
|
||||
});
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.partialObservation).toBe(parent);
|
||||
expect(await readdir(root)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not inspect the path or advance a source cancelled before writing", async () => {
|
||||
const root = await createTestTempDirectory("rea-export-pre-cancel-");
|
||||
const controller = new AbortController();
|
||||
@@ -213,6 +246,7 @@ describe("evidence bundle export cancellation", () => {
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "export_evidence_bundle",
|
||||
cause: controller.signal.reason,
|
||||
},
|
||||
});
|
||||
expect(advanced).toBe(false);
|
||||
@@ -224,7 +258,13 @@ describe("evidence bundle export cancellation", () => {
|
||||
false,
|
||||
controller.signal,
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
cause: controller.signal.reason,
|
||||
},
|
||||
});
|
||||
expect(await readdir(root)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -296,6 +336,68 @@ describe("evidence bundle export cancellation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([1, 2])(
|
||||
"reports only unresolved cancellation cleanup after %s failed close attempts",
|
||||
async (failedAttempts) => {
|
||||
const root = await createTestTempDirectory("rea-delivery-cleanup-retry-");
|
||||
const parent = analysisViewLayoutEvidence();
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("cancel before bundle publication");
|
||||
const cleanupCause = new Error("fixture staged-output close failure");
|
||||
const ready = OwnedStagedOutput.prototype.assertReadyForPublication;
|
||||
const close = OwnedStagedOutput.prototype.close;
|
||||
let owner: OwnedStagedOutput | undefined;
|
||||
let attempts = 0;
|
||||
const publication = vi
|
||||
.spyOn(OwnedStagedOutput.prototype, "assertReadyForPublication")
|
||||
.mockImplementationOnce(async function (this: OwnedStagedOutput, staged) {
|
||||
await ready.call(this, staged);
|
||||
controller.abort(reason);
|
||||
});
|
||||
const cleanup = vi
|
||||
.spyOn(OwnedStagedOutput.prototype, "close")
|
||||
.mockImplementation(async function (this: OwnedStagedOutput) {
|
||||
owner = this;
|
||||
if (++attempts <= failedAttempts) throw cleanupCause;
|
||||
await close.call(this);
|
||||
});
|
||||
try {
|
||||
const result = await deliverAnalysisEvidence(
|
||||
parent,
|
||||
join(root, "bundle.json"),
|
||||
"summary",
|
||||
controller.signal,
|
||||
);
|
||||
if (result.ok) throw new Error("Cancelled delivery reported success");
|
||||
expect(attempts).toBe(2);
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.partialObservation).toBe(parent);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.cleanupPrimary).toMatchObject({
|
||||
operation: parent.operation,
|
||||
});
|
||||
expect(result.error.cleanupPrimary?.cause).toBe(reason);
|
||||
expect(result.error.cleanup?.reason).toBe(cleanupCause.message);
|
||||
const incomplete = failedAttempts === 2;
|
||||
expect(result.error.cleanupIncomplete).toBe(incomplete);
|
||||
expect(result.error.cleanupResources).toEqual(
|
||||
incomplete ? [owner?.directory] : [],
|
||||
);
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: incomplete ? "cleanup_incomplete" : "cancelled",
|
||||
details: { operation: parent.operation },
|
||||
});
|
||||
expect((await readdir(root)).map((entry) => join(root, entry))).toEqual(
|
||||
incomplete ? [owner?.directory] : [],
|
||||
);
|
||||
} finally {
|
||||
publication.mockRestore();
|
||||
cleanup.mockRestore();
|
||||
await owner?.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const writeBundle = (
|
||||
value: ReturnType<typeof bundle>,
|
||||
path: string,
|
||||
@@ -320,6 +422,50 @@ const writeParts = (
|
||||
);
|
||||
|
||||
describe("evidence bundle publication", () => {
|
||||
it("retains the committed bundle receipt when cancellation arrives during final cleanup", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-delivery-published-cancel-",
|
||||
);
|
||||
const path = join(root, "bundle.json");
|
||||
const parent = analysisViewLayoutEvidence();
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("cancel after actual staged-output cleanup");
|
||||
const close = OwnedStagedOutput.prototype.close;
|
||||
const seam = vi
|
||||
.spyOn(OwnedStagedOutput.prototype, "close")
|
||||
.mockImplementationOnce(async function (this: OwnedStagedOutput) {
|
||||
await close.call(this);
|
||||
controller.abort(reason);
|
||||
});
|
||||
let result: Awaited<ReturnType<typeof deliverAnalysisEvidence>>;
|
||||
try {
|
||||
result = await deliverAnalysisEvidence(
|
||||
parent,
|
||||
path,
|
||||
"summary",
|
||||
controller.signal,
|
||||
);
|
||||
} finally {
|
||||
seam.mockRestore();
|
||||
}
|
||||
if (result.ok) throw new Error("Cancelled delivery reported success");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
const bytes = await readFile(path);
|
||||
expect(result.error.diagnostics?.evidence_bundle).toEqual({
|
||||
path,
|
||||
bytes: bytes.length,
|
||||
});
|
||||
expect(
|
||||
parseEvidence(result.error.partialObservation).evidence_links,
|
||||
).toEqual([parent.evidence_id]);
|
||||
expect(
|
||||
parseEvidenceBundle(JSON.parse(bytes.toString("utf8"))).records[0]
|
||||
?.evidence_id,
|
||||
).toBe(parent.evidence_id);
|
||||
expect(await readdir(root)).toEqual(["bundle.json"]);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"never publishes a partial streamed file (overwrite: %s)",
|
||||
async (overwrite) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
runDirectAnalysis,
|
||||
@@ -8,7 +10,11 @@ import type { DirectAnalysisDependencies } from "../../src/application/DirectAna
|
||||
import { parseConfig } from "../../src/config/parseConfig.js";
|
||||
import { analysisCliErrorEnvelopeSchema } from "../../src/contracts/errorSchemas.js";
|
||||
import type { AnalysisError } from "../../src/domain/analysisErrorBase.js";
|
||||
import { AnalysisTimeoutError } from "../../src/domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../../src/domain/analysisErrorCore.js";
|
||||
import {
|
||||
projectAnalysisError,
|
||||
projectAnalysisErrorWithoutCapturedOutput,
|
||||
@@ -44,6 +50,7 @@ const factories = (
|
||||
readonly thrownExecution?: Error;
|
||||
readonly cleanupError?: AnalysisError;
|
||||
readonly thrownCleanup?: Error;
|
||||
readonly onClose?: () => void;
|
||||
} = {},
|
||||
) => {
|
||||
const session = createTestBinarySession(() => ({
|
||||
@@ -56,6 +63,7 @@ const factories = (
|
||||
},
|
||||
close: async () => {
|
||||
if (options.thrownCleanup !== undefined) throw options.thrownCleanup;
|
||||
options.onClose?.();
|
||||
return options.cleanupError === undefined
|
||||
? ok(null)
|
||||
: err(options.cleanupError);
|
||||
@@ -222,6 +230,85 @@ describe("one-shot analysis cleanup outcomes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["complete", "summary"] as const)(
|
||||
"retains completed Evidence when cancellation arrives during managed close before %s delivery",
|
||||
async (detail) => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const files = await readdir(dirname(path));
|
||||
const original = parseEvidence(
|
||||
await runProviderAnalysis(
|
||||
factories().dependencies,
|
||||
path,
|
||||
"inspect_managed_members",
|
||||
{},
|
||||
),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("cancel during managed close");
|
||||
const { dependencies, session } = factories({
|
||||
onClose: () => controller.abort(reason),
|
||||
});
|
||||
const result =
|
||||
detail === "summary"
|
||||
? await runProviderAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
"inspect_managed_members",
|
||||
{},
|
||||
undefined,
|
||||
controller.signal,
|
||||
{
|
||||
detail,
|
||||
evidenceBundlePath: join(dirname(path), "bundle.json"),
|
||||
},
|
||||
)
|
||||
: await runProviderAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
"inspect_managed_members",
|
||||
{},
|
||||
undefined,
|
||||
controller.signal,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
error: "Analysis failed",
|
||||
...projectAnalysisError(
|
||||
new AnalysisCancelledError("inspect_managed_members", {
|
||||
cause: reason,
|
||||
partialObservation: original,
|
||||
}),
|
||||
),
|
||||
});
|
||||
expect(session.activeTarget()).toBeUndefined();
|
||||
expect(await readdir(dirname(path))).toEqual(files);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves a managed provider failure when cancellation arrives during close", async () => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const controller = new AbortController();
|
||||
const failure = new AnalysisInputError("inspect_managed_members", undefined, [
|
||||
{ path: ["path"], reason: "invalid_format" },
|
||||
]);
|
||||
const { dependencies, session } = factories({
|
||||
executionError: failure,
|
||||
onClose: () => controller.abort(new Error("later cancellation")),
|
||||
});
|
||||
const result = await runProviderAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
"inspect_managed_members",
|
||||
{},
|
||||
undefined,
|
||||
controller.signal,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
error: "Analysis failed",
|
||||
...projectAnalysisError(failure),
|
||||
});
|
||||
expect(session.activeTarget()).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("managed one-shot cleanup outcomes", () => {
|
||||
it("returns a managed cleanup error retaining the complete successful execution", async () => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
|
||||
@@ -56,6 +56,57 @@ const recordingFactories = (
|
||||
return { dependencies, sessions, clientsClosed: () => clientsClosed };
|
||||
};
|
||||
|
||||
it("requires a durable summary destination before acquiring a managed session", async () => {
|
||||
const factories = recordingFactories();
|
||||
const result = await runProviderAnalysis(
|
||||
factories.dependencies,
|
||||
"/fixture/missing.dll",
|
||||
"inspect_managed_members",
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
{ detail: "summary" },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "inspect_managed_members",
|
||||
issues: [{ path: ["evidence_bundle"], reason: "missing_argument" }],
|
||||
},
|
||||
});
|
||||
expect(factories.sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"inspect_macho",
|
||||
"inventory_artifact",
|
||||
"inspect_managed_artifact",
|
||||
] as const)(
|
||||
"rejects delivery options for %s before selecting a provider",
|
||||
async (tool) => {
|
||||
const factories = recordingFactories();
|
||||
const invocation = [
|
||||
factories.dependencies,
|
||||
"/fixture/missing",
|
||||
tool,
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
{ detail: "summary", evidenceBundlePath: "/fixture/bundle.json" },
|
||||
] as const;
|
||||
// @ts-expect-error JavaScript callers can bypass managed-members-only delivery.
|
||||
const result = await runProviderAnalysis(...invocation);
|
||||
expect(result).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: tool,
|
||||
issues: [{ path: ["delivery"], reason: "invalid_value" }],
|
||||
},
|
||||
});
|
||||
expect(factories.sessions).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
describe("one-shot analysis factory boundary", () => {
|
||||
it("preserves thrown failures while releasing clients and cancellation listeners", async () => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
const input = JSON.parse(process.env.REA_TEST_EVIDENCE_COMPLETION ?? "null");
|
||||
assert.ok(input && typeof input.marker === "string");
|
||||
assert.ok(
|
||||
["inspect_managed_members", "inspect_analysis_view"].includes(
|
||||
input.operation,
|
||||
),
|
||||
);
|
||||
assert.ok(["SIGINT", "SIGTERM"].includes(input.signal));
|
||||
assert.ok(["event", "os"].includes(input.delivery));
|
||||
assert.ok(
|
||||
input.operation === "inspect_managed_members"
|
||||
? typeof input.target_path === "string"
|
||||
: typeof input.parent_evidence_id === "string",
|
||||
);
|
||||
const freeze = Object.freeze;
|
||||
let queued = false;
|
||||
Object.freeze = (value) => {
|
||||
const frozen = freeze(value);
|
||||
if (
|
||||
!queued &&
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
value.operation === input.operation &&
|
||||
typeof value.evidence_id === "string" &&
|
||||
/^ev_[a-f0-9]{64}$/.test(value.evidence_id) &&
|
||||
(input.operation === "inspect_managed_members"
|
||||
? value.subject?.local_path === input.target_path
|
||||
: Array.isArray(value.evidence_links) &&
|
||||
value.evidence_links.includes(input.parent_evidence_id))
|
||||
) {
|
||||
queued = true;
|
||||
setImmediate(() => {
|
||||
writeFileSync(
|
||||
input.marker,
|
||||
JSON.stringify({
|
||||
operation: input.operation,
|
||||
evidence_id: value.evidence_id,
|
||||
signal: input.signal,
|
||||
delivery: input.delivery,
|
||||
listeners: process.listenerCount(input.signal),
|
||||
}),
|
||||
);
|
||||
if (input.delivery === "os") process.kill(process.pid, input.signal);
|
||||
else process.emit(input.signal);
|
||||
});
|
||||
}
|
||||
return frozen;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire, syncBuiltinESMExports } from "node:module";
|
||||
import { writeFileSync } from "node:fs";
|
||||
const input = JSON.parse(process.env.REA_TEST_MANAGED_PUBLICATION ?? "null");
|
||||
assert.ok(
|
||||
input && typeof input.prefix === "string" && typeof input.marker === "string",
|
||||
);
|
||||
assert.ok(["SIGINT", "SIGTERM"].includes(input.signal));
|
||||
assert.ok(["event", "os"].includes(input.delivery));
|
||||
const require = createRequire(import.meta.url);
|
||||
const fs = require("node:fs/promises");
|
||||
const remove = fs.rmdir;
|
||||
let queued = false;
|
||||
fs.rmdir = async (...arguments_) => {
|
||||
const result = await remove(...arguments_);
|
||||
if (!queued && String(arguments_[0]).startsWith(input.prefix)) {
|
||||
queued = true;
|
||||
setImmediate(() => {
|
||||
writeFileSync(
|
||||
input.marker,
|
||||
JSON.stringify({
|
||||
signal: input.signal,
|
||||
delivery: input.delivery,
|
||||
listeners: process.listenerCount(input.signal),
|
||||
}),
|
||||
);
|
||||
if (input.delivery === "os") process.kill(process.pid, input.signal);
|
||||
else process.emit(input.signal);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
syncBuiltinESMExports();
|
||||
@@ -1,13 +1,22 @@
|
||||
import { readdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Cli } from "incur";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
import * as analysisViewService from "../../src/application/analysisView/AnalysisViewService.js";
|
||||
import { WasmArtifactService } from "../../src/application/wasm/WasmArtifactService.js";
|
||||
import { registerAnalysisViewCommands } from "../../src/cli/analysisViewCommands.js";
|
||||
import { registerWasmCommands } from "../../src/cli/wasmCommands.js";
|
||||
import { wasmArtifactModule } from "../../src/composition/wasm.js";
|
||||
import { AnalysisOutputError } from "../../src/domain/analysisErrorCore.js";
|
||||
import { createEvidenceBundle } from "../../src/domain/evidenceBundle.js";
|
||||
import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js";
|
||||
import { err, ok } from "../../src/domain/result.js";
|
||||
import { OwnedFileHandle } from "../../src/filesystem/OwnedFileHandle.js";
|
||||
import { silentLogger } from "../../src/logger.js";
|
||||
import { analysisViewLayoutEvidence } from "../fixtures/analysisView.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
wasmArtifactExecution,
|
||||
wasmArtifactFixture,
|
||||
@@ -15,7 +24,12 @@ import {
|
||||
|
||||
const host = vi.hoisted(() => ({
|
||||
listeners: new Map<string, () => void>(),
|
||||
exitCode: undefined as number | undefined,
|
||||
get exitCode() {
|
||||
return process.exitCode;
|
||||
},
|
||||
set exitCode(value: typeof process.exitCode) {
|
||||
process.exitCode = value;
|
||||
},
|
||||
on(signal: string, listener: () => void) {
|
||||
this.listeners.set(signal, listener);
|
||||
},
|
||||
@@ -40,7 +54,6 @@ vi.mock("../../src/cli/commandCancellation.js", async (importOriginal) => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
host.listeners.clear();
|
||||
host.exitCode = undefined;
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -86,7 +99,9 @@ it.each([
|
||||
registerWasmCommands(cli, silentLogger, {});
|
||||
let output = "";
|
||||
const previousExitCode = process.exitCode;
|
||||
let exitCode;
|
||||
try {
|
||||
process.exitCode = undefined;
|
||||
await cli.serve(
|
||||
["inspect-wasm-artifact", value.artifact.path, "--json"],
|
||||
{
|
||||
@@ -96,6 +111,7 @@ it.each([
|
||||
exit: () => {},
|
||||
},
|
||||
);
|
||||
exitCode = process.exitCode;
|
||||
} finally {
|
||||
process.exitCode = previousExitCode;
|
||||
}
|
||||
@@ -104,13 +120,13 @@ it.each([
|
||||
expect(host.listeners.size).toBe(0);
|
||||
if (scenario === "control") {
|
||||
expect(result.normalized_result).toEqual(value);
|
||||
expect(host.exitCode).toBeUndefined();
|
||||
expect(exitCode).toBeUndefined();
|
||||
} else if (scenario === "primary-failure") {
|
||||
expect(result).toMatchObject({
|
||||
code: "unreadable_output",
|
||||
details: { reason: "primary reply failure" },
|
||||
});
|
||||
expect(host.exitCode).toBe(130);
|
||||
expect(exitCode).toBe(130);
|
||||
} else {
|
||||
expect(result).toMatchObject({
|
||||
code: scenario === "interrupt" ? "cancelled" : "cleanup_incomplete",
|
||||
@@ -124,7 +140,143 @@ it.each([
|
||||
: { resources: ["exact-owned-root"] }),
|
||||
},
|
||||
});
|
||||
if (scenario !== "cleanup-failure") expect(host.exitCode).toBe(130);
|
||||
if (scenario !== "cleanup-failure") expect(exitCode).toBe(130);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["bundle", "control", "close"],
|
||||
["bundle", "SIGINT", "close"],
|
||||
["bundle", "SIGTERM", "close"],
|
||||
["evidence-file", "control", "close"],
|
||||
["evidence-file", "SIGINT", "close"],
|
||||
["evidence-file", "SIGTERM", "close"],
|
||||
["request-file", "control", "close"],
|
||||
["request-file", "SIGINT", "close"],
|
||||
["request-file", "SIGTERM", "close"],
|
||||
["bundle", "SIGINT", "authentication"],
|
||||
["bundle", "SIGTERM", "authentication"],
|
||||
["bundle", "SIGINT", "projection"],
|
||||
["bundle", "SIGTERM", "projection"],
|
||||
["bundle", "SIGINT", "authentication-failure"],
|
||||
["bundle", "SIGTERM", "projection-failure"],
|
||||
] as const)(
|
||||
"preserves the analysis-view result and exit status for %s: %s during %s",
|
||||
async (source, signal, phase) => {
|
||||
const root = await createTestTempDirectory("rea-analysis-view-signal-");
|
||||
const parent = analysisViewLayoutEvidence();
|
||||
const filename = `${source}.json`;
|
||||
const path = join(root, filename);
|
||||
const request = {
|
||||
source:
|
||||
source === "bundle"
|
||||
? {
|
||||
kind: "retained-evidence",
|
||||
evidence_id:
|
||||
phase === "projection-failure"
|
||||
? `ev_${"f".repeat(64)}`
|
||||
: parent.evidence_id,
|
||||
}
|
||||
: source === "evidence-file"
|
||||
? { kind: "evidence-file", path }
|
||||
: { kind: "inline", evidence: parent },
|
||||
view: { kind: "summary" },
|
||||
};
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify(
|
||||
source === "bundle"
|
||||
? {
|
||||
...createEvidenceBundle([parent]),
|
||||
...(phase === "authentication-failure"
|
||||
? { records: [{ ...parent, normalized_result: "altered" }] }
|
||||
: {}),
|
||||
}
|
||||
: source === "evidence-file"
|
||||
? parent
|
||||
: request,
|
||||
),
|
||||
);
|
||||
let interruptHandled = false;
|
||||
let pendingInterrupt: Promise<void> | undefined;
|
||||
const interrupt = () => {
|
||||
const listener = host.listeners.get(signal);
|
||||
interruptHandled = listener !== undefined;
|
||||
listener?.();
|
||||
};
|
||||
const queueInterrupt = () => {
|
||||
pendingInterrupt = new Promise((resolve) => {
|
||||
setImmediate(() => {
|
||||
interrupt();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
const originalProject = analysisViewService.inspectAnalysisViewValidated;
|
||||
let projected: ReturnType<typeof originalProject> | undefined;
|
||||
const project = vi
|
||||
.spyOn(analysisViewService, "inspectAnalysisViewValidated")
|
||||
.mockImplementation((...args) => {
|
||||
projected = originalProject(...args);
|
||||
if (phase.startsWith("projection")) queueInterrupt();
|
||||
return projected;
|
||||
});
|
||||
const originalClose = OwnedFileHandle.prototype.close;
|
||||
const close = vi
|
||||
.spyOn(OwnedFileHandle.prototype, "close")
|
||||
.mockImplementation(async function (this: OwnedFileHandle) {
|
||||
await originalClose.call(this);
|
||||
expect(this.handle.fd).toBe(-1);
|
||||
if (signal !== "control" && phase === "close") interrupt();
|
||||
if (phase.startsWith("authentication")) queueInterrupt();
|
||||
});
|
||||
const cli = Cli.create("analysis-view-finalization-test", { sync: false });
|
||||
registerAnalysisViewCommands(cli, silentLogger);
|
||||
let output = "";
|
||||
const previousExitCode = process.exitCode;
|
||||
try {
|
||||
process.exitCode = undefined;
|
||||
await cli.serve(
|
||||
[
|
||||
"inspect-analysis-view",
|
||||
source === "request-file" ? path : JSON.stringify(request),
|
||||
...(source === "bundle" ? ["--evidence-bundle", path] : []),
|
||||
"--json",
|
||||
],
|
||||
{
|
||||
stdout: (part) => {
|
||||
output += part;
|
||||
},
|
||||
exit: () => {},
|
||||
},
|
||||
);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(host.listeners.size).toBe(0);
|
||||
expect(await readdir(root)).toEqual([filename]);
|
||||
const result = JSON.parse(output);
|
||||
expect(result).toMatchObject(
|
||||
signal === "control"
|
||||
? { operation: "inspect_analysis_view" }
|
||||
: {
|
||||
code: phase.endsWith("failure")
|
||||
? "evidence_integrity_mismatch"
|
||||
: "cancelled",
|
||||
},
|
||||
);
|
||||
expect(process.exitCode ?? 0).toBe(
|
||||
signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 0,
|
||||
);
|
||||
if (signal !== "control") expect(interruptHandled).toBe(true);
|
||||
if (phase.startsWith("authentication"))
|
||||
expect(project).not.toHaveBeenCalled();
|
||||
if (phase === "projection") {
|
||||
if (!projected?.ok) throw new Error("Expected a completed page");
|
||||
expect(result.details.partial_observation).toEqual(projected.value);
|
||||
}
|
||||
} finally {
|
||||
await pendingInterrupt;
|
||||
process.exitCode = previousExitCode;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,490 @@
|
||||
import { getHeapStatistics } from "node:v8";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { createServer } from "../../src/server/createServer.js";
|
||||
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
|
||||
import { loadNativeMatchingBundleSources } from "../../src/application/native/NativeMatchingBundleSources.js";
|
||||
import { NativeMatchingAdmission } from "../../src/application/native/NativeMatchingAdmission.js";
|
||||
import { matchNativeFunctionsInputSchema } from "../../src/contracts/native/functionMatchingContracts.js";
|
||||
import { createEvidence } from "../../src/domain/evidence.js";
|
||||
import { runProviderAnalysis } from "../../src/application/DirectAnalysis.js";
|
||||
import type { DirectAnalysisDependencies } from "../../src/application/DirectAnalysisDependencies.js";
|
||||
import { parseConfig } from "../../src/config/parseConfig.js";
|
||||
import {
|
||||
isImmutableEvidence,
|
||||
parseEvidence,
|
||||
type Evidence,
|
||||
} from "../../src/domain/evidence.js";
|
||||
import { ok } from "../../src/domain/result.js";
|
||||
import { observed } from "../fixtures/analysisExecution.js";
|
||||
import {
|
||||
createBinarySessionTargets,
|
||||
createTestBinarySession,
|
||||
} from "../fixtures/binarySession.js";
|
||||
import { readdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createImmutableEvidenceBundle,
|
||||
parseImmutableEvidenceBundle,
|
||||
} from "../../src/domain/evidenceBundle.js";
|
||||
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
deliverAnalysisEvidence,
|
||||
inspectAnalysisViewFromBundle,
|
||||
} from "../../src/application/EvidenceBundleCommands.js";
|
||||
import { readImmutableEvidenceBundle } from "../../src/application/EvidenceBundleFiles.js";
|
||||
import { requireAuthenticationHeadroom } from "../../src/application/JsonAuthenticationHeadroom.js";
|
||||
import { SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE } from "../../src/contracts/javascript/javascriptApplicationWorkflowExamples.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { holdFirstFileRead } from "../support/holdFirstFileRead.js";
|
||||
|
||||
vi.mock("../../src/application/JsonAuthenticationHeadroom.js", { spy: true });
|
||||
vi.mock("node:v8", { spy: true });
|
||||
vi.mock("../../src/domain/evidenceBundle.js", { spy: true });
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it.each(["compare_bundles", "match_native_functions"])(
|
||||
"attributes bundle authentication refusal to the actual %s boundary",
|
||||
async (operation) => {
|
||||
const { path, parent } = await savedParent();
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../src/application/JsonAuthenticationHeadroom.js")
|
||||
>("../../src/application/JsonAuthenticationHeadroom.js");
|
||||
vi.mocked(requireAuthenticationHeadroom).mockImplementation(
|
||||
(value, options) =>
|
||||
actual.requireAuthenticationHeadroom(value, {
|
||||
...options,
|
||||
availableBytes: 0,
|
||||
}),
|
||||
);
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
if (operation === "match_native_functions") {
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: parent.evidence_id,
|
||||
};
|
||||
await expect(
|
||||
loadNativeMatchingBundleSources({
|
||||
input: matchNativeFunctionsInputSchema.parse({
|
||||
left: source,
|
||||
right: source,
|
||||
}),
|
||||
paths: [path],
|
||||
resources,
|
||||
admission: new NativeMatchingAdmission(operation),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
operation,
|
||||
});
|
||||
} else {
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Comparison must not open a target");
|
||||
});
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({
|
||||
name: "bundle-operation-refusal",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const result = await client.callTool({
|
||||
name: operation,
|
||||
arguments: { left_bundle_path: path, right_bundle_path: path },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(parseMcpToolError(result)).toMatchObject({
|
||||
error: { code: "resource_constraint", details: { operation } },
|
||||
});
|
||||
expect(session.exportEvidenceBundle().records).toEqual([]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
vi.mocked(requireAuthenticationHeadroom).mockImplementation(
|
||||
actual.requireAuthenticationHeadroom,
|
||||
);
|
||||
await resources.close();
|
||||
}
|
||||
expect(resources.hasPending).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
const savedParent = async () => {
|
||||
const root = await createTestTempDirectory("rea-managed-bundle-guard-");
|
||||
const path = join(root, "parent.json");
|
||||
const parent = SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE.application;
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify(createImmutableEvidenceBundle([parent])),
|
||||
);
|
||||
return { path, parent };
|
||||
};
|
||||
|
||||
it("keeps the owning operation when native matching reloads a needed earlier bundle", async () => {
|
||||
const root = await createTestTempDirectory("rea-native-bundle-reload-");
|
||||
const path = join(root, "parent.json"),
|
||||
empty = join(root, "empty.json");
|
||||
const parent = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{ operation: "list_procedures", parameters: {}, result: {} },
|
||||
);
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify(createImmutableEvidenceBundle([parent])),
|
||||
);
|
||||
await writeFile(empty, JSON.stringify(createImmutableEvidenceBundle([])));
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../src/application/JsonAuthenticationHeadroom.js")
|
||||
>("../../src/application/JsonAuthenticationHeadroom.js");
|
||||
let reads = 0;
|
||||
vi.mocked(requireAuthenticationHeadroom).mockImplementation(
|
||||
(value, options) =>
|
||||
actual.requireAuthenticationHeadroom(
|
||||
value,
|
||||
++reads === 3 ? { ...options, availableBytes: 0 } : options,
|
||||
),
|
||||
);
|
||||
const resources = new ArtifactResourceScope();
|
||||
const source = { kind: "retained-evidence", evidence_id: parent.evidence_id };
|
||||
try {
|
||||
await expect(
|
||||
loadNativeMatchingBundleSources({
|
||||
input: matchNativeFunctionsInputSchema.parse({
|
||||
left: source,
|
||||
right: source,
|
||||
}),
|
||||
paths: [path, empty],
|
||||
resources,
|
||||
admission: new NativeMatchingAdmission("match_native_functions"),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
operation: "match_native_functions",
|
||||
});
|
||||
expect(reads).toBe(3);
|
||||
} finally {
|
||||
vi.mocked(requireAuthenticationHeadroom).mockImplementation(
|
||||
actual.requireAuthenticationHeadroom,
|
||||
);
|
||||
await resources.close();
|
||||
}
|
||||
expect(resources.hasPending).toBe(false);
|
||||
});
|
||||
|
||||
it("admits bundle authentication before cloning and hashing retained records", async () => {
|
||||
const { path } = await savedParent();
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../src/application/JsonAuthenticationHeadroom.js")
|
||||
>("../../src/application/JsonAuthenticationHeadroom.js");
|
||||
vi.mocked(requireAuthenticationHeadroom).mockImplementationOnce(
|
||||
(value, options) =>
|
||||
actual.requireAuthenticationHeadroom(value, {
|
||||
...options,
|
||||
availableBytes: 0,
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseImmutableEvidenceBundle).mockClear();
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
expect(
|
||||
await readImmutableEvidenceBundle(path, undefined, resources),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisResourceConstraintError", resource: "memory" },
|
||||
});
|
||||
expect(parseImmutableEvidenceBundle).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
expect(resources.hasPending).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the view operation and caller reason when bundle I/O is cancelled", async () => {
|
||||
const { path, parent } = await savedParent();
|
||||
const held = await holdFirstFileRead(path);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("cancel managed bundle page");
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const pending = inspectAnalysisViewFromBundle(
|
||||
{
|
||||
source: { kind: "retained-evidence", evidence_id: parent.evidence_id },
|
||||
view: { kind: "summary" },
|
||||
},
|
||||
path,
|
||||
resources,
|
||||
controller.signal,
|
||||
);
|
||||
await held.firstRead;
|
||||
controller.abort(reason);
|
||||
held.release();
|
||||
const result = await pending;
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "inspect_analysis_view",
|
||||
},
|
||||
});
|
||||
if (result.ok) throw new Error("Expected cancelled bundle read");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
} finally {
|
||||
held.release();
|
||||
await resources.close();
|
||||
}
|
||||
expect(resources.hasPending).toBe(false);
|
||||
});
|
||||
|
||||
it("retains completed parent facts when bundle publication cannot admit authentication", async () => {
|
||||
const root = await createTestTempDirectory("rea-managed-delivery-guard-");
|
||||
const evidence = SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE.application;
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../src/application/JsonAuthenticationHeadroom.js")
|
||||
>("../../src/application/JsonAuthenticationHeadroom.js");
|
||||
vi.mocked(requireAuthenticationHeadroom).mockImplementationOnce(
|
||||
(value, options) =>
|
||||
actual.requireAuthenticationHeadroom(value, {
|
||||
...options,
|
||||
availableBytes: 0,
|
||||
}),
|
||||
);
|
||||
vi.mocked(createImmutableEvidenceBundle).mockClear();
|
||||
const result = await deliverAnalysisEvidence(
|
||||
evidence,
|
||||
join(root, "parent.json"),
|
||||
"summary",
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisResourceConstraintError", resource: "memory" },
|
||||
});
|
||||
if (result.ok) throw new Error("Expected refused bundle authentication");
|
||||
expect(result.error.partialObservation).toBe(evidence);
|
||||
expect(createImmutableEvidenceBundle).not.toHaveBeenCalled();
|
||||
expect(await readdir(root)).toEqual([]);
|
||||
});
|
||||
|
||||
it("projects a rejected managed parent by reference without cloning under low headroom", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-managed-low-heap-projection-",
|
||||
);
|
||||
const [target] = await createBinarySessionTargets();
|
||||
const source = {
|
||||
rows: Array.from({ length: 512 }, (_, token) => ({
|
||||
token,
|
||||
name: "member",
|
||||
})),
|
||||
};
|
||||
let closes = 0;
|
||||
const session = () =>
|
||||
createTestBinarySession(() => ({
|
||||
execute: async () => observed(source),
|
||||
close: async () => {
|
||||
closes += 1;
|
||||
return ok(null);
|
||||
},
|
||||
}));
|
||||
const dependencies: DirectAnalysisDependencies = {
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: session,
|
||||
createNativeDispatchBinarySession: session,
|
||||
createArtifactBinarySession: session,
|
||||
createNativeBinarySession: session,
|
||||
createManagedBinarySession: session,
|
||||
};
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../src/application/JsonAuthenticationHeadroom.js")
|
||||
>("../../src/application/JsonAuthenticationHeadroom.js");
|
||||
let parent: Evidence | undefined;
|
||||
vi.mocked(requireAuthenticationHeadroom).mockImplementationOnce(
|
||||
(value, options) => {
|
||||
parent = parseEvidence(value);
|
||||
expect(isImmutableEvidence(parent)).toBe(true);
|
||||
expect(parent).toBe(value);
|
||||
return actual.requireAuthenticationHeadroom(value, {
|
||||
...options,
|
||||
availableBytes: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
const result = await runProviderAnalysis(
|
||||
dependencies,
|
||||
target,
|
||||
"inspect_managed_members",
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
detail: "summary",
|
||||
evidenceBundlePath: join(root, "parent.json"),
|
||||
},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: { operation: "inspect_managed_members" },
|
||||
});
|
||||
if (typeof result !== "object" || result === null || Array.isArray(result))
|
||||
throw new Error("Expected CLI error");
|
||||
const details = result.details;
|
||||
if (typeof details !== "object" || details === null || Array.isArray(details))
|
||||
throw new Error("Expected CLI error details");
|
||||
expect(details.partial_observation).toBe(parent);
|
||||
expect(parent?.normalized_result).toEqual(source);
|
||||
expect(Object.isFrozen(source.rows[0])).toBe(false);
|
||||
expect(closes).toBe(1);
|
||||
expect(await readdir(root)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["JSON assembly", "bundle authentication"])(
|
||||
"attributes low-headroom %s to the invoking view",
|
||||
async (stage) => {
|
||||
const { path, parent } = await savedParent();
|
||||
const actualV8 = await vi.importActual<typeof import("node:v8")>("node:v8");
|
||||
if (stage === "JSON assembly") {
|
||||
vi.mocked(getHeapStatistics).mockReturnValue({
|
||||
...getHeapStatistics(),
|
||||
total_available_size: 0,
|
||||
});
|
||||
} else {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../src/application/JsonAuthenticationHeadroom.js")
|
||||
>("../../src/application/JsonAuthenticationHeadroom.js");
|
||||
vi.mocked(requireAuthenticationHeadroom).mockImplementationOnce(
|
||||
(value, options) =>
|
||||
actual.requireAuthenticationHeadroom(value, {
|
||||
...options,
|
||||
availableBytes: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
expect(
|
||||
await inspectAnalysisViewFromBundle(
|
||||
{
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: parent.evidence_id,
|
||||
},
|
||||
view: { kind: "summary" },
|
||||
},
|
||||
path,
|
||||
resources,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
operation: "inspect_analysis_view",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
vi.mocked(getHeapStatistics).mockImplementation(
|
||||
actualV8.getHeapStatistics,
|
||||
);
|
||||
await resources.close();
|
||||
}
|
||||
expect(resources.hasPending).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("authenticates escaped payloads without serializing either complete bundle", async () => {
|
||||
const root = await createTestTempDirectory("rea-escaped-bundle-");
|
||||
const path = join(root, "parent.json");
|
||||
const escaped = '\u0000\u0001\\"😀\ud800'.repeat(20000);
|
||||
const example = SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE.application;
|
||||
const parent = createEvidence(undefined, example.provider, {
|
||||
predicateType: example.predicate_type,
|
||||
operation: example.operation,
|
||||
parameters: example.parameters,
|
||||
result: example.normalized_result,
|
||||
rawResult: { escaped },
|
||||
});
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify(createImmutableEvidenceBundle([parent])),
|
||||
);
|
||||
const stringify = JSON.stringify;
|
||||
const fullDocuments: unknown[] = [];
|
||||
vi.spyOn(JSON, "stringify").mockImplementation((value: unknown) => {
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
Object.hasOwn(value, "records")
|
||||
) {
|
||||
fullDocuments.push(value);
|
||||
throw new Error(
|
||||
"Document-sized JSON allocation forbidden during authentication",
|
||||
);
|
||||
}
|
||||
return stringify(value);
|
||||
});
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const loaded = await readImmutableEvidenceBundle(
|
||||
path,
|
||||
undefined,
|
||||
resources,
|
||||
);
|
||||
if (!loaded.ok) throw loaded.error;
|
||||
expect(loaded.value.records[0]?.evidence_id).toBe(parent.evidence_id);
|
||||
expect(loaded.value.records[0]?.raw_result).toEqual({ escaped });
|
||||
expect(fullDocuments).toEqual([]);
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
expect(resources.hasPending).toBe(false);
|
||||
});
|
||||
|
||||
it("seals a fresh object-heavy Evidence tree without retaining a set of completed rows", () => {
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const row = { seal_probe: true, fields: [1, 2] };
|
||||
const result = { rows: Array.from({ length: 512 }, () => row) };
|
||||
const observation = {
|
||||
operation: "inspect_managed_members",
|
||||
parameters: {},
|
||||
result,
|
||||
};
|
||||
const complete = createEvidence(undefined, provider, observation);
|
||||
const add = Set.prototype.add;
|
||||
const guarded = vi.spyOn(Set.prototype, "add").mockImplementation(function (
|
||||
this: Set<unknown>,
|
||||
value: unknown,
|
||||
) {
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
Object.hasOwn(value, "seal_probe") &&
|
||||
this.size > 32
|
||||
)
|
||||
throw new Error(
|
||||
"Sealing retained completed rows beyond its ancestry budget",
|
||||
);
|
||||
return add.call(this, value);
|
||||
});
|
||||
let sealed;
|
||||
try {
|
||||
sealed = createEvidence(undefined, provider, observation, {
|
||||
immutable: true,
|
||||
});
|
||||
} finally {
|
||||
guarded.mockRestore();
|
||||
}
|
||||
expect(sealed).toEqual(complete);
|
||||
expect(isImmutableEvidence(sealed)).toBe(true);
|
||||
const data = sealed.normalized_result as typeof result;
|
||||
expect(data.rows[0]).not.toBe(row);
|
||||
expect(data.rows[0]).not.toBe(data.rows[1]);
|
||||
expect(Object.isFrozen(data.rows[0]!.fields)).toBe(true);
|
||||
expect(Object.isFrozen(row)).toBe(false);
|
||||
expect(parseEvidence(sealed)).toBe(sealed);
|
||||
});
|
||||
Reference in new issue
Block a user