mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 22:36:08 +00:00
* 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>
216 lines
7.7 KiB
TypeScript
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));
|
|
};
|