Files
reverse-engineering-anything/src/domain/analysisView/webNetworkRouteTemplates.ts
T
291059adb4 feat(analysis-view): project an environment manifest from web network captures (#4954)
* feat(analysis-view): project an environment manifest from web network captures

Add the environment-manifest view kind to inspect_analysis_view for retained
HAR Evidence: the primary origin's recorded request lines become a
deterministic tool surface (names, path templates, parameters with their
rule and basis, observed query names, value-free body shapes, record
pointers), with excluded groups, three fixed unknowns and the shared
projection budget. The four format rules always apply; after-uid-literal,
opaque-token and sibling-variance are opt-in and labelled, and literal
fold-in never touches a format-rule parameter. The api-surface accumulator
moves onto shared route, media-type and budget helpers without behaviour
change.

Refs morluto/rea#4933

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(web-network): check the manifest refusal text like the api-surface refusal

The real-capture verifier stringified the whole MCP result for the
environment-manifest refusal, so a declared "[" literal matched the result
envelope itself; compare the parsed refusal as the api-surface branch does.

Refs morluto/rea#4933

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(specs): record the hosted web-network lane run for the manifest view

Refs morluto/rea#4933

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(specs): reconcile the manifest view verification scope note with the hosted run

Refs morluto/rea#4933

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(analysis-view): bound body, route and name work in the environment manifest

Addresses the automated review findings on the PR: read request bodies at the
top level only instead of walking nested values; admit every literal route
part before materializing it; compare at most 32 segments in sibling-variance
and say so; keep stop words literal after a uid literal; keep the next
collision suffix per tool-name base; preserve a templated exclusion when two
groups print the same row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(analysis-view): keep manifest folding, naming and media-type reads linear

Addresses the second automated review round: fold literal siblings through
set membership, key collision suffixes by the truncated name they share, and
charge a request media type before it is scanned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(analysis-view): charge the manifest group key before building it

Addresses the third automated review round: the composite key copies the
method, origin, template and rules, so that copy is reserved before the key
is stringified.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(analysis-view): accept a +json suffix only on application media types

Addresses the fourth automated review round: `image/example+json` or
`text/example+json` no longer counts as JSON for origin selection, exclusion
or body shapes, matching the documented `application/json` and
`application/*+json` contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(analysis-view): require the capture parent's exact subject for a manifest

Addresses the fifth automated review round: the manifest cites the report
path and digest, so a parent whose subject is absent or names another path or
SHA-256 is refused at admission, as the capture view already does; the
api-surface view keeps its existing admission.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(analysis-view): share truncated suffix allocation work

Track suffix progress by the actual truncated prefix and suffix width. Distinct full names can collide only after suffix truncation; sharing that progress prevents quadratic rescanning while preserving shorter free names and existing allocation order.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

* fix(analysis-view): merge only identifier-like siblings under sibling-variance

Addresses the sixth automated review finding: a bucket merged whenever one
literal carried a digit, folding plain words such as `mine` into the
parameter. The rule now partitions the bucket, merges the identifier-like
siblings and leaves the other literals as their own tools.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(analysis-view): combine qualifying sibling cohorts

Keep coded identifiers and a qualifying plural hyphenated cohort in one eligibility Set before checking the minimum merge size. Read each literal once and reuse the same membership at the consumer, preserving non-identifier routes and the three-hyphen minimum.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

* docs(analysis-view): clarify opt-in sibling folding

State the additional sibling-variance selection in the guide, acceptance specification and MCP parameter description. Preserve the existing single-rule behavior and caller-selected inference rules.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-19 07:07:37 +08:00

216 lines
7.7 KiB
TypeScript

import { z } from "zod";
import {
parseRecordedTransportUrl,
parseUrlQueryParameterNames,
} from "../recordedTransportUrl.js";
/** Exact segment formats shared by the api-surface and environment-manifest views. */
export const ROUTE_RULES = ["numeric", "uuid", "hex", "iso-date"] as const;
export type RouteRule = (typeof ROUTE_RULES)[number];
export interface RouteSegment {
readonly segment_index: number;
readonly rule: RouteRule;
readonly value: string;
}
export interface RouteTemplate {
readonly template: string;
readonly rules: string;
readonly segments: readonly RouteSegment[];
}
export type RequestLineExclusion =
| "url-redacted"
| "url-unparseable"
| "request-unavailable"
| "method-unavailable";
/** One retained request line, or the reason it cannot join a grouping view. */
export type RecordedRequestLine =
| { readonly kind: "excluded"; readonly reason: RequestLineExclusion }
| {
readonly kind: "line";
readonly method: string;
readonly origin: string;
readonly path: string;
readonly query: string | undefined;
readonly status: number | null;
readonly rawMime: string;
};
/** Temporary-byte reservation; false means the projection budget is exhausted. */
export type Reserve = (bytes: number) => boolean;
const requestSchema = z.object({
request: z.object({ url: z.unknown(), method: z.unknown() }),
});
const responseSchema = z.object({
response: z.object({
status: z.unknown().optional(),
content: z.unknown().optional(),
}),
});
const contentSchema = z.object({ mimeType: z.unknown().optional() });
const MEDIA_TYPE_ESSENCE =
/^[A-Za-z0-9!#$%&'*+.^_`|~-]+\/[A-Za-z0-9!#$%&'*+.^_`|~-]+$/u;
// Conservative representation allowances, not an enforced V8 heap limit.
// Fixed charges include the accumulator, bucket/set entries, array growth and
// page/schema projection. Text includes UTF-16 storage and two worst-case JSON
// encodings (transport text and UTF-8 bytes); no serialization is needed to count.
export const GROUP_BYTES = 2048;
export const SEGMENT_BYTES = 1024;
export const ENTRY_BYTES = 128;
export const RECORD_SCRATCH_BYTES = 512;
export const CAPTURE_CONTAINER_BYTES = 512;
const SEGMENT_SCRATCH_BYTES = 512;
export const textBytes = (length: number): number => 32 + length * 14;
/** Lowercase type/subtype without parameters; malformed essences are unknown. */
export const mediaTypeEssence = (reported: string): string | null => {
const separator = reported.indexOf(";");
let start = 0;
let end = separator < 0 ? reported.length : separator;
// HTTP OWS is SP/HTAB. Scan from both ends so malformed internal runs of
// whitespace cannot cause repeated trailing-regex searches.
while (start < end && (reported[start] === " " || reported[start] === "\t"))
start += 1;
while (
end > start &&
(reported[end - 1] === " " || reported[end - 1] === "\t")
)
end -= 1;
const essence = reported.slice(start, end);
// Validate ASCII tokens before case folding can turn Unicode into ASCII.
return MEDIA_TYPE_ESSENCE.test(essence) ? essence.toLowerCase() : null;
};
/** Route parts of a template: one more than its separators. */
export const routePartCount = (template: string): number => {
let count = 1;
for (const character of template) if (character === "/") count += 1;
return count;
};
/** The first exact format rule a recorded path segment satisfies, if any. */
export const segmentRule = (value: string): RouteRule | null => {
if (/^[0-9]+$/u.test(value)) return "numeric";
if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(
value,
)
)
return "uuid";
if (/^[0-9a-f]{32,}$/iu.test(value)) return "hex";
if (/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/u.test(value)) {
const date = new Date(`${value}T00:00:00.000Z`);
if (
Number.isFinite(date.getTime()) &&
date.toISOString().slice(0, 10) === value
)
return "iso-date";
}
return null;
};
/** Scan literal spans; only matched segments need descriptors or template parts. */
export const scanRouteTemplate = (
path: string,
temporary: Reserve,
): RouteTemplate | null => {
const segments: RouteSegment[] = [];
const parts: string[] = [];
const rules: string[] = [];
let templateLength = path.length;
let rulesLength = 0;
let copied = 0;
let index = 0;
let start = 1;
for (;;) {
const slash = path.indexOf("/", start);
const end = slash < 0 ? path.length : slash;
const value = path.slice(start, end);
const rule = segmentRule(value);
if (rule !== null) {
if (!temporary(SEGMENT_SCRATCH_BYTES)) return null;
const placeholder = `{${rule}}`;
const identity = `${index}:${rule};`;
segments.push({ segment_index: index, rule, value });
parts.push(path.slice(copied, start), placeholder);
rules.push(identity);
templateLength += placeholder.length - value.length;
rulesLength += identity.length;
copied = end;
}
if (slash < 0) break;
start = slash + 1;
index += 1;
}
if (segments.length === 0) return { template: path, rules: "", segments };
if (!temporary(textBytes(templateLength) + textBytes(rulesLength)))
return null;
parts.push(path.slice(copied));
return { template: parts.join(""), rules: rules.join(""), segments };
};
/** Read one record's request line, charging URL and MIME text before parsing them. */
export const readRecordedRequestLine = (
reported: unknown,
includeQuery: boolean,
temporary: Reserve,
): RecordedRequestLine | null => {
const excluded = (reason: RequestLineExclusion): RecordedRequestLine => ({
kind: "excluded",
reason,
});
const request = requestSchema.safeParse(reported);
if (!request.success) return excluded("request-unavailable");
const { url, method } = request.data.request;
if (url === null) return excluded("url-redacted");
if (typeof url !== "string") return excluded("url-unparseable");
// Admission precedes URL parsing, text normalization and path materialization.
if (!temporary(RECORD_SCRATCH_BYTES + textBytes(url.length))) return null;
const parsed = parseRecordedTransportUrl(url, { includeQuery });
if (parsed.origin === null) return excluded("url-unparseable");
if (typeof method !== "string" || method.length === 0)
return excluded("method-unavailable");
if (parsed.path === null) return excluded("url-unparseable");
const response = responseSchema.safeParse(reported);
const status = response.success ? response.data.response.status : undefined;
const content = contentSchema.safeParse(
response.success ? response.data.response.content : undefined,
);
const mime = content.success ? content.data.mimeType : undefined;
const rawMime = typeof mime === "string" ? mime : "";
if (!temporary(textBytes(rawMime.length))) return null;
return {
kind: "line",
method,
origin: parsed.origin,
path: parsed.path,
query: parsed.query,
status: typeof status === "number" && status !== 0 ? status : null,
rawMime,
};
};
/** Reserve form-parser scratch before decoding distinct query parameter names. */
export const readQueryParameterNames = (
query: string | undefined,
temporary: Reserve,
): ReadonlySet<string> | undefined | null => {
if (query === undefined) return undefined;
// Reserve form-parser tuples, decoded strings, the iterator and the name
// set before URLSearchParams materializes them. Delimiter count is an upper
// bound: empty query pairs are ignored by the WHATWG parser.
let pairs = query.length > 1 ? 1 : 0;
for (const character of query) if (character === "&") pairs += 1;
if (
!temporary(
RECORD_SCRATCH_BYTES + textBytes(query.length) + pairs * ENTRY_BYTES * 4,
)
)
return null;
return new Set(parseUrlQueryParameterNames(query));
};