feat(analysis-view): project rich captures from an Evidence file and template path pairs
inspect_analysis_view accepts a source {kind: "evidence-file", path},
read through the shared JSON file reader, authenticated as complete
Evidence and projected like retained Evidence. A capture is then bounded
by the file reader instead of being charged as inline input at 14 bytes
per string character, which refused a 10.7 MB capture whose response
bodies the environment manifest never reads.
The environment-manifest view gains the opt-in rule sibling-pair-variance:
groups that differ only in two adjacent literals after a plural literal,
such as /repos/{owner}/{repo}/issues, merge when at least three distinct
pairs vary in both positions. Their parameters are named <singular>_namespace
and <singular>_name. A rich Gitea capture otherwise produced one tool per
repository, beyond the 128 tools model providers accept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2dd5effe2a
commit
a64367a912
17 files changed
+553
-30
No files matched your search
@@ -30,6 +30,13 @@ jq -c '{source: {kind: "inline", evidence: .}, view: {kind: "summary"}}' app-evi
|
||||
rea inspect-analysis-view app-view.json
|
||||
```
|
||||
|
||||
The request can also name the saved file instead of embedding it:
|
||||
`{"source": {"kind": "evidence-file", "path": "app-evidence.json"}, "view": {"kind": "summary"}}`.
|
||||
The CLI resolves a relative path against the working directory; MCP clients
|
||||
pass an absolute path. REA reads and authenticates the file, then projects it
|
||||
like retained Evidence, so a large file is bounded by the file reader rather
|
||||
than charged as inline input.
|
||||
|
||||
For example, a 334 MB Obsidian application Evidence file yields a summary view
|
||||
of about 10 KB. Replace the view with
|
||||
`{"kind": "page", "collection": "modules", "offset": 0, "limit": 32}` for a
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Plan
|
||||
|
||||
1. Add the `evidence-file` source to `analysisViewSourceSchema`
|
||||
(`src/domain/analysisView/analysisView.ts`).
|
||||
2. Add `inspectAnalysisViewWithFiles` / `inspectAnalysisViewValidatedWithFiles`
|
||||
to `AnalysisViewService`: read with `readJsonFile`, authenticate with
|
||||
`parseEvidence`, then project through the retained path with a lookup that
|
||||
returns the file's Evidence. The synchronous path refuses an unread file
|
||||
source.
|
||||
3. Route the CLI command (with `source.path` resolved against the working
|
||||
directory) and the MCP tool through the file-aware entry point.
|
||||
4. Add `sibling-pair-variance` to `TEMPLATE_RULES`, with its merge pass after
|
||||
`sibling-variance`, its parameter names and its segment-limit limitation.
|
||||
5. Document both in the tool contract, `docs/cli.md` and
|
||||
`docs/web-network-captures.md`.
|
||||
6. Tests: service (file equals inline; a capture refused inline is accepted
|
||||
from a file; refusals), CLI and MCP boundaries, and the pair rule's merges
|
||||
and non-merges.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Rich captures for the environment manifest
|
||||
|
||||
Issue: https://github.com/morluto/rea/issues/4933
|
||||
|
||||
## Outcome
|
||||
|
||||
A capture large enough to hold a rich application state can be projected into
|
||||
an environment manifest whose tool list stays within what model providers
|
||||
accept. Two changes:
|
||||
|
||||
- `inspect_analysis_view` accepts a third source,
|
||||
`{"kind": "evidence-file", "path": "<absolute path>"}`. REA reads one
|
||||
completed Evidence JSON file, authenticates it, and projects it like retained
|
||||
Evidence, so its strings are bounded by the file reader instead of being
|
||||
charged as inline input.
|
||||
- The environment-manifest view gains an opt-in template rule,
|
||||
`sibling-pair-variance`, that templates two adjacent literal segments after a
|
||||
plural literal, such as `/repos/{owner}/{repo}/…`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- An `evidence-file` path must be absolute and free of NUL bytes; the CLI
|
||||
resolves a relative path against the working directory before validation,
|
||||
and MCP clients pass an absolute path.
|
||||
- The file is read through the shared JSON file reader (regular file, heap and
|
||||
string-length guards) and parsed as complete Evidence. A missing or unreadable
|
||||
file is an `EvidenceFileError`; JSON that is not complete Evidence is invalid
|
||||
input reported under `source.path`.
|
||||
- A file source projects exactly what the same Evidence projects inline or
|
||||
retained, with the same parent Evidence ID, view parameters and result.
|
||||
- The synchronous projection API refuses an unread `evidence-file` source with
|
||||
a typed capability error; the CLI and MCP adapters read files first.
|
||||
- `sibling-pair-variance` applies only when listed, after `sibling-variance`. It
|
||||
compares groups equal in method, origin and segment count except at two
|
||||
adjacent literal positions at index 2 or later that follow a plural literal,
|
||||
and merges a bucket only when it holds at least three distinct pairs and both
|
||||
positions vary. Stop words and `v1`-style versions never qualify, and routes
|
||||
of more than 32 segments are not compared, with a limitation saying so.
|
||||
- Its parameters are named from the plural literal, singular plus `_namespace`
|
||||
and then `_name`, and labelled `sibling-pair-variance`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No change to the 96 MiB projection budget or to inline admission.
|
||||
- No change to `sibling-variance`; pairs are a separate rule so every inferred
|
||||
parameter keeps naming the rule that produced it.
|
||||
- No inference of semantic names (`owner`, `repo`) beyond the plural literal.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Record the outcome, requirements and non-goals.
|
||||
- [x] Add the `evidence-file` source and the file-aware service entry point.
|
||||
- [x] Route the CLI command and the MCP tool through it.
|
||||
- [x] Add the `sibling-pair-variance` rule, its names and its limitation.
|
||||
- [x] Document both; extend the tool contract text.
|
||||
- [x] Add service, CLI, MCP and rule tests.
|
||||
- [ ] Run static, formatting, duplication, documentation and test gates.
|
||||
- [ ] Rebuild a rich capture with both changes and record the results.
|
||||
@@ -300,7 +300,10 @@ exclusions and deduplicated limitations. It scans path spans without splitting
|
||||
literal paths into arrays. Before schema parsing, it reserves the capture clone
|
||||
and validation scratch by walking the parent without cloning it. Inline requests
|
||||
borrow the Evidence value through CLI/MCP envelope validation; the application
|
||||
admits the entire portable envelope before authenticated parsing or hashing.
|
||||
admits the entire portable envelope before authenticated parsing or hashing. An
|
||||
`evidence-file` source is read and authenticated first and then admitted like
|
||||
retained Evidence, so its strings are not charged as inline envelope text: a
|
||||
capture too large to project inline can be projected from its file.
|
||||
The envelope clone, hash scratch and capture clone stay charged during grouping.
|
||||
Temporary URL/path/MIME/query work and retained state must
|
||||
fit together within a 96 MiB projection representation budget. A whole group or
|
||||
@@ -367,11 +370,12 @@ template and `params` carry the four format rules with names `id`, `uuid`,
|
||||
only when listed, each at most once (a repeated rule is an `invalid_request`),
|
||||
in this order:
|
||||
|
||||
| Rule | Applies to | Parameter |
|
||||
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `after-uid-literal` | The literal segment right after a literal `uid`, unless it is a stop word | `uid` |
|
||||
| `opaque-token` | A lowercase alphanumeric segment of twelve or more characters mixing letters and digits that is not a `v1`-style version | `uid` |
|
||||
| `sibling-variance` | Groups equal in method, origin and segment count that differ in one literal at index 2 or later; the siblings whose literal is identifier-like (a digit or `:`, or three or more hyphenated names under a plural literal) merge, never a stop word, and the other literals stay their own tools | singular previous literal plus `_id`, else `id` |
|
||||
| Rule | Applies to | Parameter |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| `after-uid-literal` | The literal segment right after a literal `uid`, unless it is a stop word | `uid` |
|
||||
| `opaque-token` | A lowercase alphanumeric segment of twelve or more characters mixing letters and digits that is not a `v1`-style version | `uid` |
|
||||
| `sibling-variance` | Groups equal in method, origin and segment count that differ in one literal at index 2 or later; the siblings whose literal is identifier-like (a digit or `:`, or three or more hyphenated names under a plural literal) merge, never a stop word, and the other literals stay their own tools | singular previous literal plus `_id`, else `id` |
|
||||
| `sibling-pair-variance` | Groups equal in method, origin and segment count that differ in two adjacent literals right after a plural literal at index 2 or later, with at least three distinct pairs and both positions varying (`/repos/{owner}/{repo}/issues`); stop words and `v1`-style versions never qualify | singular plural literal plus `_namespace`, then `_name` |
|
||||
|
||||
With `sibling-variance` selected, literal siblings within its comparison scope
|
||||
fold into existing opt-in parameters as observed values unless they are stop words.
|
||||
@@ -388,7 +392,7 @@ cut to their first label, `_by_<params>` appended when parameters exist,
|
||||
sanitized to `[a-z0-9_]`, at most 60 characters and made unique with numeric
|
||||
suffixes: `get_dashboards_by_uid`, `get_plugins_settings_by_plugin_id`.
|
||||
|
||||
`sibling-variance` compares routes of at most 32 segments; longer routes keep
|
||||
`sibling-variance` and `sibling-pair-variance` compare routes of at most 32 segments; longer routes keep
|
||||
their grouped templates and the manifest says so in a limitation. `body_shape`
|
||||
is derived for `POST`, `PUT` and `PATCH` from the tool's earliest record when it
|
||||
carries an `application/json` or `application/*+json` request body: top-level
|
||||
@@ -403,11 +407,14 @@ typed unsupported errors as the api-surface view, including the canonical
|
||||
token `sensitive_values_unretained`.
|
||||
|
||||
The manifest accumulator shares the api-surface admission model: the capture
|
||||
clone, the inline envelope, record pointers, groups, every route part, observed
|
||||
clone, the inline envelope (for an inline source), record pointers, groups, every route part, observed
|
||||
values, query names and body-shape keys are admitted before allocation, and each
|
||||
recorded body is charged as temporary bytes before it is parsed. Retained state and
|
||||
temporary work must fit together within the 96 MiB projection representation
|
||||
budget; exceeding it returns `resource_constraint` without a partial manifest.
|
||||
Name a large capture with `{"kind": "evidence-file", "path": "/abs/capture.json"}`
|
||||
rather than inline: response bodies the manifest never reads are then charged
|
||||
once per value in the capture clone, not per character.
|
||||
|
||||
## Resource and lifecycle boundaries
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
analysisViewCaptureFixture,
|
||||
analysisViewCaptureEvidence,
|
||||
@@ -19,6 +22,7 @@ import {
|
||||
import {
|
||||
inspectAnalysisView,
|
||||
inspectAnalysisViewValidated,
|
||||
inspectAnalysisViewWithFiles,
|
||||
summarizeRetainedAnalysis,
|
||||
} from "./AnalysisViewService.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
@@ -27,6 +31,7 @@ import { err, ok } from "../../domain/result.js";
|
||||
import { EvidenceLedger } from "../investigation/EvidenceLedger.js";
|
||||
import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js";
|
||||
import { isImmutableJsonSnapshot } from "../../domain/immutableJson.js";
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
|
||||
it.each(["layout", "javascript"])(
|
||||
"carries the retained %s source snapshot into repeatable shared views",
|
||||
@@ -860,3 +865,104 @@ it("admits raw_result and parameters before authenticating a portable capture",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("inspectAnalysisViewWithFiles", () => {
|
||||
const view = { kind: "environment-manifest" } as const;
|
||||
const writeEvidenceFile = async (value: unknown): Promise<string> => {
|
||||
const root = await createTestTempDirectory("rea-analysis-view-file-");
|
||||
const path = join(root, "capture.json");
|
||||
await writeFile(path, JSON.stringify(value));
|
||||
return path;
|
||||
};
|
||||
|
||||
it("projects an Evidence file exactly as the same Evidence inline", async () => {
|
||||
const parent = analysisViewCaptureEvidence(
|
||||
environmentManifestCaptureFixture(),
|
||||
);
|
||||
const fromFile = await inspectAnalysisViewWithFiles({
|
||||
source: { kind: "evidence-file", path: await writeEvidenceFile(parent) },
|
||||
view,
|
||||
});
|
||||
const inline = inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: parent },
|
||||
view,
|
||||
});
|
||||
if (!fromFile.ok) throw fromFile.error;
|
||||
if (!inline.ok) throw inline.error;
|
||||
expect(fromFile.value.normalized_result).toEqual(
|
||||
inline.value.normalized_result,
|
||||
);
|
||||
expect(fromFile.value.parameters).toEqual(inline.value.parameters);
|
||||
});
|
||||
|
||||
it("accepts from a file a capture whose response text exceeds the inline budget", async () => {
|
||||
const capture = environmentManifestCaptureFixture();
|
||||
const reported = capture.records[0]!.reported as {
|
||||
response: { content: { text: string } };
|
||||
};
|
||||
// Inline admission charges about 14 bytes per character; this body alone overruns it.
|
||||
reported.response.content.text = "a".repeat(
|
||||
Math.ceil(WEB_NETWORK_CAPTURE_LIMITS.outputBytes / 14) + 1024,
|
||||
);
|
||||
const parent = analysisViewCaptureEvidence(capture);
|
||||
expect(
|
||||
inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: parent },
|
||||
view,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisResourceConstraintError", resource: "memory" },
|
||||
});
|
||||
const fromFile = await inspectAnalysisViewWithFiles({
|
||||
source: { kind: "evidence-file", path: await writeEvidenceFile(parent) },
|
||||
view,
|
||||
});
|
||||
if (!fromFile.ok) throw fromFile.error;
|
||||
expect(fromFile.value.parameters).toMatchObject({
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a relative path, a missing file and JSON that is not Evidence", async () => {
|
||||
expect(
|
||||
await inspectAnalysisViewWithFiles({
|
||||
source: { kind: "evidence-file", path: "capture.json" },
|
||||
view,
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
const missing = join(
|
||||
await createTestTempDirectory("rea-analysis-view-missing-"),
|
||||
"absent.json",
|
||||
);
|
||||
expect(
|
||||
await inspectAnalysisViewWithFiles({
|
||||
source: { kind: "evidence-file", path: missing },
|
||||
view,
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "EvidenceFileError" } });
|
||||
const notEvidence = await inspectAnalysisViewWithFiles({
|
||||
source: {
|
||||
kind: "evidence-file",
|
||||
path: await writeEvidenceFile({ hello: 1 }),
|
||||
},
|
||||
view,
|
||||
});
|
||||
expect(notEvidence).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisInputError" },
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves file sources to the file-aware entry point", () => {
|
||||
expect(
|
||||
inspectAnalysisView({
|
||||
source: { kind: "evidence-file", path: "/tmp/capture.json" },
|
||||
view,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { WebNetworkApiSurfaceAccumulator } from "../../domain/analysisView/WebNetworkApiSurfaceAccumulator.js";
|
||||
import { EnvironmentManifestAccumulator } from "../../domain/analysisView/EnvironmentManifestAccumulator.js";
|
||||
import { admitAnalysisViewData } from "../../domain/analysisView/analysisViewDataAdmission.js";
|
||||
import { readJsonFile } from "../JsonFiles.js";
|
||||
|
||||
/** Authenticate a selected-view request and project already completed Evidence. */
|
||||
export const inspectAnalysisView = (
|
||||
@@ -48,6 +49,48 @@ export const inspectAnalysisView = (
|
||||
return inspectAnalysisViewValidated(parsed.data, lookup);
|
||||
};
|
||||
|
||||
/** Validate a request whose source may be an Evidence file, then project it. */
|
||||
export const inspectAnalysisViewWithFiles = async (
|
||||
rawInput: unknown,
|
||||
lookup?: EvidenceLookup,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const parsed = inspectAnalysisViewInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
parsed.error.issues,
|
||||
rawInput,
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
return inspectAnalysisViewValidatedWithFiles(parsed.data, lookup);
|
||||
};
|
||||
|
||||
/**
|
||||
* Read and authenticate an Evidence-file source, then project it as retained
|
||||
* Evidence: its strings are bounded by the file reader rather than charged as
|
||||
* inline input. Other sources project unchanged.
|
||||
*/
|
||||
export const inspectAnalysisViewValidatedWithFiles = async (
|
||||
input: InspectAnalysisViewInput,
|
||||
lookup?: EvidenceLookup,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
if (input.source.kind !== "evidence-file")
|
||||
return inspectAnalysisViewValidated(input, lookup);
|
||||
const loaded = await readJsonFile(input.source.path);
|
||||
if (!loaded.ok) return loaded;
|
||||
const parent = authenticateEvidence(loaded.value, ["source", "path"], {
|
||||
source: input.source,
|
||||
});
|
||||
if (!parent.ok) return parent;
|
||||
const id = parent.value.evidence_id;
|
||||
return inspectAnalysisViewValidated(
|
||||
{ ...input, source: { kind: "retained-evidence", evidence_id: id } },
|
||||
(evidenceId) => (evidenceId === id ? parent.value : lookup?.(evidenceId)),
|
||||
);
|
||||
};
|
||||
|
||||
/** Authenticate borrowed inline Evidence after adapter envelope validation. */
|
||||
export const inspectAnalysisViewValidated = (
|
||||
input: InspectAnalysisViewInput,
|
||||
@@ -182,8 +225,28 @@ const resolveAnalysisViewSource = (
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
if (source.kind === "retained-evidence")
|
||||
return resolveEvidenceInput(source, lookup);
|
||||
// Files are read by inspectAnalysisViewValidatedWithFiles, which the CLI and MCP adapters call.
|
||||
if (source.kind === "evidence-file")
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
ANALYSIS_VIEW_PROVIDER.id,
|
||||
INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
"evidence-file sources are read before projection",
|
||||
),
|
||||
);
|
||||
return authenticateEvidence(source.evidence, ["source", "evidence"], {
|
||||
source,
|
||||
});
|
||||
};
|
||||
|
||||
/** Parse complete Evidence, reporting schema issues under the given request path. */
|
||||
const authenticateEvidence = (
|
||||
value: unknown,
|
||||
path: readonly (string | number)[],
|
||||
input: unknown,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
try {
|
||||
return ok(parseEvidence(source.evidence));
|
||||
return ok(parseEvidence(value));
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof z.ZodError)
|
||||
return err(
|
||||
@@ -191,9 +254,9 @@ const resolveAnalysisViewSource = (
|
||||
INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
cause.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "evidence", ...issue.path],
|
||||
path: [...path, ...issue.path],
|
||||
})),
|
||||
{ source },
|
||||
input,
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "incur";
|
||||
|
||||
import { inspectAnalysisViewValidated } from "../application/analysisView/AnalysisViewService.js";
|
||||
import { inspectAnalysisViewValidatedWithFiles } from "../application/analysisView/AnalysisViewService.js";
|
||||
import { readEvidenceBundle } from "../application/EvidenceBundleFiles.js";
|
||||
import { inspectEvidenceBundle } from "../application/investigation/InspectEvidenceBundle.js";
|
||||
import {
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
importEvidenceBundleInputSchema,
|
||||
} from "../contracts/sessionToolSchemas.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { parseCliJsonInput } from "../cliJsonInput.js";
|
||||
import { parseCliJsonInput, resolveCliJsonPaths } from "../cliJsonInput.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
@@ -82,7 +82,9 @@ export const registerAnalysisViewCommands = (
|
||||
CLI_COMMANDS.inspectAnalysisView,
|
||||
);
|
||||
if (!input.ok) return input.error;
|
||||
const parsed = inspectAnalysisViewInputSchema.safeParse(input.value);
|
||||
// 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",
|
||||
@@ -90,11 +92,11 @@ export const registerAnalysisViewCommands = (
|
||||
analysisInputErrorFromIssues(
|
||||
CLI_COMMANDS.inspectAnalysisView,
|
||||
parsed.error.issues,
|
||||
input.value,
|
||||
value,
|
||||
),
|
||||
),
|
||||
};
|
||||
const result = inspectAnalysisViewValidated(parsed.data);
|
||||
const result = await inspectAnalysisViewValidatedWithFiles(parsed.data);
|
||||
return result.ok
|
||||
? jsonValueSchema.parse(result.value)
|
||||
: {
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_analysis_view"),
|
||||
kind: "application",
|
||||
description:
|
||||
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profiLine truncated
|
||||
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: "<absolute path>"}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [Line truncated
|
||||
inputSchema: inspectAnalysisViewInputSchema,
|
||||
outputSchema: evidenceResultOf(analysisViewResultSchema),
|
||||
examples: [
|
||||
|
||||
@@ -438,6 +438,22 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
|
||||
});
|
||||
}
|
||||
|
||||
/** One limitation per selected sibling rule when a route is too long for it to compare. */
|
||||
#segmentLimitLimitations(toolGroups: readonly ToolGroup[]): string[] {
|
||||
if (
|
||||
!toolGroups.some(
|
||||
(group) => group.parts.length > SIBLING_VARIANCE_SEGMENT_LIMIT,
|
||||
)
|
||||
)
|
||||
return [];
|
||||
return (["sibling-variance", "sibling-pair-variance"] as const)
|
||||
.filter((rule) => this.#rules.has(rule))
|
||||
.map(
|
||||
(rule) =>
|
||||
`Routes with more than ${String(SIBLING_VARIANCE_SEGMENT_LIMIT)} segments are not compared by ${rule}; they keep their grouped templates.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Project the complete manifest; tools, records and exclusions are sorted deterministically. */
|
||||
finish(): Result<ManifestFields, AnalysisError> {
|
||||
const primary = this.#primaryOrigin();
|
||||
@@ -493,14 +509,7 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
|
||||
"A top-level __proto__ key in a recorded JSON body is omitted from body_shape; it cannot be an own property of the published object.",
|
||||
]
|
||||
: []),
|
||||
...(this.#rules.has("sibling-variance") &&
|
||||
toolGroups.some(
|
||||
(group) => group.parts.length > SIBLING_VARIANCE_SEGMENT_LIMIT,
|
||||
)
|
||||
? [
|
||||
`Routes with more than ${String(SIBLING_VARIANCE_SEGMENT_LIMIT)} segments are not compared by sibling-variance; they keep their grouped templates.`,
|
||||
]
|
||||
: []),
|
||||
...this.#segmentLimitLimitations(toolGroups),
|
||||
...(this.#unrepresented > 0
|
||||
? [
|
||||
`${String(this.#unrepresented)} retained records without a usable request line are not represented in the manifest.`,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
javascriptModuleReferencesRequestSchema,
|
||||
@@ -254,7 +257,7 @@ const inlineEvidenceInputSchema = z.unknown().nonoptional();
|
||||
inlineEvidenceInputSchema._zod.processJSONSchema =
|
||||
evidenceSchema._zod.processJSONSchema;
|
||||
|
||||
/** Unauthenticated inline input or an exact same-session retained reference. */
|
||||
/** Unauthenticated inline input, an exact same-session retained reference, or one local Evidence file. */
|
||||
export const analysisViewSourceSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
kind: z.literal("retained-evidence"),
|
||||
@@ -264,6 +267,17 @@ export const analysisViewSourceSchema = z.discriminatedUnion("kind", [
|
||||
kind: z.literal("inline"),
|
||||
evidence: inlineEvidenceInputSchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("evidence-file"),
|
||||
path: localPathStringSchema
|
||||
.refine(isAbsolute, {
|
||||
message:
|
||||
"path must be an absolute local filesystem path (for example /tmp/capture.json or C:\\rea\\capture.json)",
|
||||
})
|
||||
.describe(
|
||||
"Absolute local path of one completed Evidence JSON file. REA reads and authenticates it, then projects it like retained Evidence, so large captures are not charged as inline input.",
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Public inspect_analysis_view input. */
|
||||
|
||||
@@ -9,6 +9,7 @@ export const TEMPLATE_RULES = [
|
||||
"after-uid-literal",
|
||||
"opaque-token",
|
||||
"sibling-variance",
|
||||
"sibling-pair-variance",
|
||||
] as const;
|
||||
export type TemplateRule = (typeof TEMPLATE_RULES)[number];
|
||||
export type ManifestRule = RouteRule | TemplateRule;
|
||||
@@ -287,6 +288,98 @@ const mergeSiblingVariance = (groups: readonly ToolGroup[]): ToolGroup[] => {
|
||||
return [...current, ...beyondLimit];
|
||||
};
|
||||
|
||||
/** Fewest distinct literal pairs that make two adjacent positions one templated pair. */
|
||||
export const SIBLING_PAIR_MINIMUM = 3;
|
||||
|
||||
/** Both positions are literals that are neither stop words nor versions, right after a plural literal. */
|
||||
const pairCandidate = (group: ToolGroup, position: number): boolean => {
|
||||
const previous = group.parts[position - 1];
|
||||
const first = group.parts[position];
|
||||
const second = group.parts[position + 1];
|
||||
return (
|
||||
previous?.kind === "literal" &&
|
||||
previous.value.endsWith("s") &&
|
||||
!STOP_WORDS.has(previous.value) &&
|
||||
first?.kind === "literal" &&
|
||||
second?.kind === "literal" &&
|
||||
[first.value, second.value].every(
|
||||
(value) => !STOP_WORDS.has(value) && !VERSION_TOKEN.test(value),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/** Merge one bucket when its pairs are distinct enough and both positions vary; otherwise keep its tools. */
|
||||
const mergePairBucket = (
|
||||
bucket: readonly ToolGroup[],
|
||||
position: number,
|
||||
): ToolGroup[] => {
|
||||
const pairs = new Set(
|
||||
bucket.map((group) =>
|
||||
compositeKey([
|
||||
literalAt(group, position),
|
||||
literalAt(group, position + 1),
|
||||
]),
|
||||
),
|
||||
);
|
||||
const firsts = new Set(bucket.map((group) => literalAt(group, position)));
|
||||
const seconds = new Set(
|
||||
bucket.map((group) => literalAt(group, position + 1)),
|
||||
);
|
||||
if (pairs.size < SIBLING_PAIR_MINIMUM || firsts.size < 2 || seconds.size < 2)
|
||||
return [...bucket];
|
||||
const first = earliest(bucket);
|
||||
const shape = withParts(
|
||||
first,
|
||||
first.parts.map((part, index) =>
|
||||
index === position || index === position + 1
|
||||
? param("sibling-pair-variance", [literalAt(first, index)])
|
||||
: part,
|
||||
),
|
||||
);
|
||||
return [
|
||||
mergeGroups(
|
||||
bucket.map((group) => (group === first ? shape : group)),
|
||||
shape,
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
/** Merge groups that differ in two adjacent literals after a plural literal, such as `/repos/{owner}/{repo}/…`. */
|
||||
const mergeSiblingPairs = (groups: readonly ToolGroup[]): ToolGroup[] => {
|
||||
const beyondLimit = groups.filter(
|
||||
(group) => group.parts.length > SIBLING_VARIANCE_SEGMENT_LIMIT,
|
||||
);
|
||||
let current = groups.filter(
|
||||
(group) => group.parts.length <= SIBLING_VARIANCE_SEGMENT_LIMIT,
|
||||
);
|
||||
const longest = Math.max(0, ...current.map((group) => group.parts.length));
|
||||
for (let position = 2; position + 1 < longest; position += 1) {
|
||||
const buckets = new Map<string, ToolGroup[]>();
|
||||
const output: ToolGroup[] = [];
|
||||
for (const group of current) {
|
||||
if (!pairCandidate(group, position)) {
|
||||
output.push(group);
|
||||
continue;
|
||||
}
|
||||
const key = compositeKey([
|
||||
group.method,
|
||||
group.origin,
|
||||
group.parts.length,
|
||||
...group.parts.map((part, index) =>
|
||||
index === position || index === position + 1 ? null : partKey(part),
|
||||
),
|
||||
]);
|
||||
const bucket = buckets.get(key);
|
||||
if (bucket === undefined) buckets.set(key, [group]);
|
||||
else bucket.push(group);
|
||||
}
|
||||
for (const bucket of buckets.values())
|
||||
output.push(...mergePairBucket(bucket, position));
|
||||
current = output;
|
||||
}
|
||||
return [...current, ...beyondLimit];
|
||||
};
|
||||
|
||||
/** Apply the selected opt-in rules in fixed order; every merge is an inference. */
|
||||
export const applyTemplateRules = (
|
||||
groups: readonly ToolGroup[],
|
||||
@@ -303,6 +396,7 @@ export const applyTemplateRules = (
|
||||
);
|
||||
current = rebucket(current);
|
||||
if (rules.has("sibling-variance")) current = mergeSiblingVariance(current);
|
||||
if (rules.has("sibling-pair-variance")) current = mergeSiblingPairs(current);
|
||||
return current;
|
||||
};
|
||||
|
||||
@@ -343,6 +437,15 @@ const paramBaseName = (parts: readonly RoutePart[], index: number): string => {
|
||||
: "";
|
||||
return stem.length > 0 ? `${stem}_id` : "id";
|
||||
}
|
||||
case "sibling-pair-variance": {
|
||||
// The pair's first position follows the plural literal; the second follows the first.
|
||||
const second = parts[index - 1]?.kind === "param";
|
||||
const plural = parts[second ? index - 2 : index - 1];
|
||||
const stem =
|
||||
plural?.kind === "literal" ? sanitizeToken(singular(plural.value)) : "";
|
||||
const suffix = second ? "name" : "namespace";
|
||||
return stem.length > 0 ? `${stem}_${suffix}` : suffix;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -540,6 +540,84 @@ it("merges identifier-like siblings at index two or later and never folds stop w
|
||||
).toEqual(["blue-two", "green-three", "red-one"]);
|
||||
});
|
||||
|
||||
it("templates two adjacent literals after a plural literal as a pair when three pairs vary in both positions", () => {
|
||||
const projected = manifest(
|
||||
["sibling-pair-variance"],
|
||||
syntheticCapture([
|
||||
[
|
||||
"GET",
|
||||
"https://g.test/api/v1/repos/lighthouse-rota/lamp-room-log/issues",
|
||||
],
|
||||
["GET", "https://g.test/api/v1/repos/granite-ledger/ledger-core/issues"],
|
||||
[
|
||||
"GET",
|
||||
"https://g.test/api/v1/repos/blue-heron-kitchen/recipe-book/issues",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"https://g.test/api/v1/repos/lighthouse-rota/tide-tables/issues/4",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"https://g.test/api/v1/repos/granite-ledger/audit-trail/issues/9",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"https://g.test/api/v1/repos/saltmarsh-depot/crate-tracker/issues/2",
|
||||
],
|
||||
["GET", "https://g.test/api/v1/teams/one/alpha/members"],
|
||||
["GET", "https://g.test/api/v1/teams/two/beta/members"],
|
||||
["GET", "https://g.test/api/v1/orgs/acme/x1/hooks"],
|
||||
["GET", "https://g.test/api/v1/orgs/acme/x2/hooks"],
|
||||
["GET", "https://g.test/api/v1/orgs/acme/x3/hooks"],
|
||||
]),
|
||||
);
|
||||
expect(projected.tools.map((tool) => tool.path_template)).toEqual([
|
||||
"/api/v1/orgs/acme/x1/hooks",
|
||||
"/api/v1/orgs/acme/x2/hooks",
|
||||
"/api/v1/orgs/acme/x3/hooks",
|
||||
"/api/v1/repos/{repo_namespace}/{repo_name}/issues",
|
||||
"/api/v1/repos/{repo_namespace}/{repo_name}/issues/{numeric}",
|
||||
"/api/v1/teams/one/alpha/members",
|
||||
"/api/v1/teams/two/beta/members",
|
||||
]);
|
||||
expect(
|
||||
projected.tools.find(
|
||||
(tool) =>
|
||||
tool.path_template ===
|
||||
"/api/v1/repos/{repo_namespace}/{repo_name}/issues",
|
||||
),
|
||||
).toMatchObject({
|
||||
name: "get_repos_issues_by_repo_namespace_repo_name",
|
||||
params: [
|
||||
{
|
||||
name: "repo_namespace",
|
||||
rule: "sibling-pair-variance",
|
||||
observed_values: [
|
||||
"blue-heron-kitchen",
|
||||
"granite-ledger",
|
||||
"lighthouse-rota",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "repo_name",
|
||||
rule: "sibling-pair-variance",
|
||||
observed_values: ["lamp-room-log", "ledger-core", "recipe-book"],
|
||||
},
|
||||
],
|
||||
record_ordinals: [0, 1, 2],
|
||||
});
|
||||
expect(
|
||||
projected.tools
|
||||
.find((tool) => tool.path_template.endsWith("/issues/{numeric}"))
|
||||
?.params.map((param) => [param.name, param.rule]),
|
||||
).toEqual([
|
||||
["repo_namespace", "sibling-pair-variance"],
|
||||
["repo_name", "sibling-pair-variance"],
|
||||
["id", "numeric"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("never folds a literal sibling into a format-rule parameter", () => {
|
||||
const projected = manifest(
|
||||
ALL_RULES,
|
||||
|
||||
@@ -23,7 +23,7 @@ export const environmentManifestViewRequestSchema = z.strictObject({
|
||||
.meta({ uniqueItems: true })
|
||||
.default([])
|
||||
.describe(
|
||||
"Opt-in template rules applied after the four format rules, each listed at most once: after-uid-literal templates the segment following a literal uid, opaque-token templates long mixed alphanumeric segments, sibling-variance merges groups that differ in one identifier-like segment. Literal sibling folding into existing opt-in parameters also requires sibling-variance. Merged parameters are inferences.",
|
||||
"Opt-in template rules applied after the four format rules, each listed at most once: after-uid-literal templates the segment following a literal uid, opaque-token templates long mixed alphanumeric segments, sibling-variance merges groups that differ in one identifier-like segment, sibling-pair-variance merges groups that differ in two adjacent literals after a plural literal when at least three distinct pairs vary in both. Literal sibling folding into existing opt-in parameters also requires sibling-variance. Merged parameters are inferences.",
|
||||
),
|
||||
include_query_parameter_names: z
|
||||
.boolean()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
|
||||
import { inspectAnalysisView } from "../application/analysisView/AnalysisViewService.js";
|
||||
import { inspectAnalysisViewWithFiles } from "../application/analysisView/AnalysisViewService.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
@@ -24,7 +24,7 @@ export const registerAnalysisViewTool = (
|
||||
const registration = toolRegistrationOptions(contract);
|
||||
const handler = createEvidenceToolHandler(
|
||||
server,
|
||||
(input) => Promise.resolve(inspectAnalysisView(input, evidenceById)),
|
||||
(input) => inspectAnalysisViewWithFiles(input, evidenceById),
|
||||
logger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
|
||||
@@ -547,6 +547,49 @@ cliTest(
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"projects an environment manifest from an Evidence file through the compiled CLI",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-evidence-file-cli-");
|
||||
const capturePath = join(root, "capture.json");
|
||||
const parent = analysisViewCaptureEvidence(
|
||||
environmentManifestCaptureFixture(),
|
||||
);
|
||||
await writeFile(capturePath, JSON.stringify(parent));
|
||||
const requestPath = join(root, "view.json");
|
||||
const view = {
|
||||
kind: "environment-manifest",
|
||||
template_rules: ["opaque-token", "sibling-pair-variance"],
|
||||
} as const;
|
||||
await writeFile(
|
||||
requestPath,
|
||||
JSON.stringify({
|
||||
source: { kind: "evidence-file", path: capturePath },
|
||||
view,
|
||||
}),
|
||||
);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-analysis-view", requestPath, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stdout).toBe(0);
|
||||
const evidence = parseEvidence(result.json);
|
||||
expect(evidence.parameters).toMatchObject({
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
});
|
||||
// The file source projects exactly what the same Evidence projects inline.
|
||||
const inline = inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: parent },
|
||||
view,
|
||||
});
|
||||
if (!inline.ok) throw inline.error;
|
||||
expect(evidence.normalized_result).toEqual(inline.value.normalized_result);
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
kind: "environment-manifest",
|
||||
origin: "http://grafana.example",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"projects an environment manifest through the compiled CLI without recorded values",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -1167,7 +1167,7 @@ it.each(PUBLISHED_EXAMPLES)(
|
||||
},
|
||||
);
|
||||
|
||||
it("projects an environment manifest identically from retained and portable MCP Evidence", async () => {
|
||||
it("projects an environment manifest identically from retained, portable and file MCP Evidence", async () => {
|
||||
const { client, session } = await connect();
|
||||
const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas(
|
||||
client,
|
||||
@@ -1176,6 +1176,11 @@ it("projects an environment manifest identically from retained and portable MCP
|
||||
const capture = environmentManifestCaptureFixture();
|
||||
const parent = analysisViewCaptureEvidence(capture);
|
||||
expect(session.recordEvidence(parent).ok).toBe(true);
|
||||
const evidencePath = join(
|
||||
await createTestTempDirectory("rea-evidence-file-mcp-"),
|
||||
"capture.json",
|
||||
);
|
||||
await writeFile(evidencePath, JSON.stringify(parent));
|
||||
const view = {
|
||||
kind: "environment-manifest",
|
||||
template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"],
|
||||
@@ -1184,6 +1189,7 @@ it("projects an environment manifest identically from retained and portable MCP
|
||||
for (const source of [
|
||||
{ kind: "inline", evidence: parent },
|
||||
{ kind: "retained-evidence", evidence_id: parent.evidence_id },
|
||||
{ kind: "evidence-file", path: evidencePath },
|
||||
]) {
|
||||
expect(ajv.validate(inputSchema, { source, view })).toBe(true);
|
||||
expect(
|
||||
@@ -1224,6 +1230,16 @@ it("projects an environment manifest identically from retained and portable MCP
|
||||
outputs.push(evidence.normalized_result);
|
||||
}
|
||||
expect(outputs[0]).toEqual(outputs[1]);
|
||||
expect(outputs[2]).toEqual(outputs[0]);
|
||||
// JSON Schema cannot express "absolute"; the tool refuses a relative path when called.
|
||||
const relative = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
source: { kind: "evidence-file", path: "capture.json" },
|
||||
view,
|
||||
},
|
||||
});
|
||||
expect(parseMcpToolError(relative).error.code).toBe("invalid_request");
|
||||
const hidden = createEvidence(
|
||||
{
|
||||
path: capture.artifact.path,
|
||||
|
||||
Reference in new issue
Block a user