mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(web): preserve static WebMCP schema property coverage
Reuse the shared object-member owner for literal spreads, retain known property names under unresolved membership, and distinguish complete, partial, unknown, and absent projections. Keep historical coverage omission unchanged and preserve own keys across prototype setters. Refs #5075
This commit is contained in:
1 parent
a7d13893a6
commit
d23be5c089
8 files changed
+884
-88
No files matched your search
@@ -19,8 +19,24 @@ Inspect native SAZ or HAR 1.2 offline with REA, or connect Everywhere's separate
|
||||
- `analyze_web_bundle` / `rea analyze-web-bundle` parses selected script artifacts without execution and derives chunk edges, route and endpoint candidates, vendor fingerprints, static WebMCP declarations, and optionally source-map/original-source evidence.
|
||||
- `observe_web_session` / `rea observe-web-session` arms an observation window of the requested duration for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata.
|
||||
- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return every in-scope registration and its complete observed `input_schema` declaration and canonical `input_schema_sha256` digest inline. Declarations are untrusted JSON data, including declared types, required fields, enums, examples and reference URLs. REA does not resolve schema references. These fields replace `input_schema_shape`. Registrations with the same name and URL in distinct observed frames retain separate identities. `tool_key` includes the observed frame ID; it is not a stable identity across navigation or browser sessions. Same-frame registration updates replace that owner only, and removal leaves other owners intact. Declared `readOnly`, `untrustedContent`, `consequential`, and `autosubmit` annotation hints are reported per registration: `true`/`false` are declared values, `null` means the declaration did not provide the hint in that capture, and a missing `consequential` key means the hint was not recorded, either in a historical capture or because the observed annotation was invalid. Consult discovery completeness and exclusions to distinguish these cases. Malformed annotation values retain the registration with incomplete coverage and an `invalid_protocol_value` exclusion. Other malformed protocol payloads can fail discovery explicitly. REA never exposes `WebMCP.invokeTool`.
|
||||
|
||||
- `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence.
|
||||
|
||||
For `analyze_web_bundle`, each `webmcp_declarations` entry's
|
||||
`schema_property_names` is a source-derived projection of statically known own
|
||||
property keys, not schema validation. `schema_property_names_coverage` reports
|
||||
how complete that projection is: `complete` means a literal properties object
|
||||
was fully enumerated (including an empty literal); `partial` means a literal
|
||||
object was available but some computed or spread membership was unresolved,
|
||||
and the name array retains keys that were known; `unknown` means the schema or
|
||||
properties owner could not be enumerated as an object literal, including
|
||||
explicit non-object values; `not_present` means no effective schema or own
|
||||
`properties` member was present after the `inputSchema ?? input_schema` alias
|
||||
selection. `not_present` describes this projection only: it does not validate a
|
||||
schema or rule out properties expressed through other schema constructs. Older
|
||||
observations may omit the coverage field; treat omission as `unknown` rather
|
||||
than inferring completeness from the array.
|
||||
|
||||
Passive DOM comparison checks complete node inventories and complete frame
|
||||
parent/child structure independently. Frame IDs and sibling enumeration order
|
||||
are capture-local and do not establish a change; duplicate IDs, missing parents,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { expect, it } from "vitest";
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
staticObjectProperty,
|
||||
staticObjectPropertyNames,
|
||||
} from "./javascriptAstValues.js";
|
||||
import { SEMANTIC_EXPRESSION_DEPTH_LIMIT } from "./javascriptSemanticResourceLimits.js";
|
||||
|
||||
@@ -70,3 +71,188 @@ it("bounds nested inline object spreads without losing a shallow final property"
|
||||
.status,
|
||||
).toBe("dynamic");
|
||||
});
|
||||
|
||||
it("collects complete own-key sets through bounded literal spreads", () => {
|
||||
const names = staticObjectPropertyNames(
|
||||
t.objectExpression([
|
||||
t.spreadElement(
|
||||
t.objectExpression([
|
||||
t.objectProperty(t.identifier("query"), t.objectExpression([])),
|
||||
t.objectProperty(t.identifier("shared"), t.numericLiteral(1)),
|
||||
]),
|
||||
),
|
||||
t.objectProperty(t.identifier("direct"), t.objectExpression([])),
|
||||
t.objectProperty(t.identifier("shared"), t.numericLiteral(2)),
|
||||
]),
|
||||
);
|
||||
expect(names).toEqual({
|
||||
status: "complete",
|
||||
names: ["query", "shared", "direct"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps key enumeration unknown when object membership is unresolved", () => {
|
||||
const dynamicSpread = t.identifier("dynamicProperties");
|
||||
const computedKey = t.identifier("propertyName");
|
||||
const cases = [
|
||||
t.objectExpression([
|
||||
t.spreadElement(dynamicSpread),
|
||||
t.objectProperty(t.identifier("direct"), t.objectExpression([])),
|
||||
]),
|
||||
t.objectExpression([
|
||||
t.objectProperty(computedKey, t.objectExpression([]), true),
|
||||
t.objectProperty(t.identifier("direct"), t.objectExpression([])),
|
||||
]),
|
||||
t.objectExpression([
|
||||
t.spreadElement(
|
||||
t.objectExpression([
|
||||
t.objectMethod(
|
||||
"get",
|
||||
t.identifier("query"),
|
||||
[],
|
||||
t.blockStatement([]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
];
|
||||
for (const object of cases)
|
||||
expect(staticObjectPropertyNames(object).status).toBe("partial");
|
||||
});
|
||||
|
||||
it("retains known keys alongside unresolved object membership", () => {
|
||||
expect(
|
||||
staticObjectPropertyNames(
|
||||
t.objectExpression([
|
||||
t.spreadElement(t.identifier("dynamicProperties")),
|
||||
t.objectProperty(t.identifier("direct"), t.objectExpression([])),
|
||||
t.objectProperty(
|
||||
t.identifier("propertyName"),
|
||||
t.objectExpression([]),
|
||||
true,
|
||||
),
|
||||
]),
|
||||
),
|
||||
).toEqual({ status: "partial", names: ["direct"] });
|
||||
});
|
||||
|
||||
it("does not claim a complete key set after exceeding spread depth", () => {
|
||||
let object = t.objectExpression([
|
||||
t.objectProperty(t.identifier("query"), t.objectExpression([])),
|
||||
]);
|
||||
for (let depth = 0; depth < SEMANTIC_EXPRESSION_DEPTH_LIMIT; depth++)
|
||||
object = t.objectExpression([t.spreadElement(object)]);
|
||||
expect(staticObjectPropertyNames(object).status).toBe("complete");
|
||||
expect(
|
||||
staticObjectPropertyNames(t.objectExpression([t.spreadElement(object)]))
|
||||
.status,
|
||||
).toBe("partial");
|
||||
});
|
||||
|
||||
it("enumerates a wide inline spread without a variadic call overflow", () => {
|
||||
const properties = Array.from({ length: 130_000 }, (_, index) =>
|
||||
t.objectProperty(t.identifier(`key${index}`), t.numericLiteral(index)),
|
||||
);
|
||||
const result = staticObjectPropertyNames(
|
||||
t.objectExpression([t.spreadElement(t.objectExpression(properties))]),
|
||||
);
|
||||
expect(result.status).toBe("complete");
|
||||
if (result.status === "complete") {
|
||||
expect(result.names).toHaveLength(properties.length);
|
||||
expect(result.names[0]).toBe("key0");
|
||||
expect(result.names.at(-1)).toBe("key129999");
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes prototype setters from own keys but preserves other __proto__ forms", () => {
|
||||
const setter = t.objectProperty(
|
||||
t.identifier("__proto__"),
|
||||
t.objectExpression([
|
||||
t.objectProperty(t.identifier("inherited"), t.numericLiteral(1)),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
staticObjectPropertyNames(
|
||||
t.objectExpression([
|
||||
setter,
|
||||
t.objectProperty(t.identifier("own"), t.numericLiteral(2)),
|
||||
]),
|
||||
),
|
||||
).toEqual({ status: "complete", names: ["own"] });
|
||||
|
||||
const ownForms = [
|
||||
t.objectProperty(t.stringLiteral("__proto__"), t.numericLiteral(1), true),
|
||||
t.objectProperty(
|
||||
t.identifier("__proto__"),
|
||||
t.identifier("__proto__"),
|
||||
false,
|
||||
true,
|
||||
),
|
||||
t.objectMethod(
|
||||
"method",
|
||||
t.identifier("__proto__"),
|
||||
[],
|
||||
t.blockStatement([]),
|
||||
),
|
||||
];
|
||||
for (const member of ownForms)
|
||||
expect(staticObjectPropertyNames(t.objectExpression([member]))).toEqual({
|
||||
status: "complete",
|
||||
names: ["__proto__"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps inherited lookup uncertain after a top-level prototype setter", () => {
|
||||
const setter = t.objectProperty(
|
||||
t.identifier("__proto__"),
|
||||
t.objectExpression([
|
||||
t.objectProperty(t.identifier("inherited"), t.numericLiteral(1)),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
staticObjectProperty(t.objectExpression([setter]), "inherited").status,
|
||||
).toBe("dynamic");
|
||||
expect(
|
||||
staticObjectProperty(
|
||||
t.objectExpression([
|
||||
setter,
|
||||
t.objectProperty(t.identifier("inherited"), t.numericLiteral(2)),
|
||||
]),
|
||||
"inherited",
|
||||
).status,
|
||||
).toBe("explicit");
|
||||
expect(
|
||||
staticObjectProperty(
|
||||
t.objectExpression([
|
||||
t.objectProperty(t.identifier("inherited"), t.numericLiteral(2)),
|
||||
setter,
|
||||
]),
|
||||
"inherited",
|
||||
).status,
|
||||
).toBe("explicit");
|
||||
const spreadSource = t.objectExpression([
|
||||
setter,
|
||||
t.objectProperty(t.identifier("visible"), t.numericLiteral(3)),
|
||||
]);
|
||||
expect(
|
||||
staticObjectProperty(
|
||||
t.objectExpression([t.spreadElement(spreadSource)]),
|
||||
"inherited",
|
||||
).status,
|
||||
).toBe("missing");
|
||||
});
|
||||
|
||||
it("omits prototype setters copied by literal object spread", () => {
|
||||
const source = t.objectExpression([
|
||||
t.objectProperty(
|
||||
t.identifier("__proto__"),
|
||||
t.objectExpression([
|
||||
t.objectProperty(t.identifier("hidden"), t.numericLiteral(1)),
|
||||
]),
|
||||
),
|
||||
t.objectProperty(t.identifier("visible"), t.numericLiteral(2)),
|
||||
]);
|
||||
expect(
|
||||
staticObjectPropertyNames(t.objectExpression([t.spreadElement(source)])),
|
||||
).toEqual({ status: "complete", names: ["visible"] });
|
||||
});
|
||||
@@ -130,6 +130,79 @@ type StaticObjectProperty =
|
||||
| { readonly status: "missing" | "dynamic" }
|
||||
| { readonly status: "explicit"; readonly property: t.ObjectProperty };
|
||||
|
||||
type StaticObjectMember =
|
||||
| { readonly kind: "unknown" }
|
||||
| { readonly kind: "prototype-setter" }
|
||||
| {
|
||||
readonly kind: "property";
|
||||
readonly property: t.ObjectProperty | t.ObjectMethod;
|
||||
};
|
||||
|
||||
const UNKNOWN_STATIC_OBJECT_MEMBER: StaticObjectMember = { kind: "unknown" };
|
||||
|
||||
const isObjectPrototypeSetter = (property: t.ObjectProperty): boolean =>
|
||||
!property.computed &&
|
||||
!property.shorthand &&
|
||||
semanticStaticPropertyKey(property.key, false) === "__proto__";
|
||||
|
||||
/** Expand only bounded literal spreads, preserving unknown writes in order. */
|
||||
const appendStaticObjectMembers = (
|
||||
members: StaticObjectMember[],
|
||||
object: t.ObjectExpression,
|
||||
depth: number,
|
||||
isNullish?: (node: t.Node) => boolean,
|
||||
): void => {
|
||||
for (const property of object.properties) {
|
||||
if (!t.isSpreadElement(property)) {
|
||||
if (t.isObjectProperty(property) && isObjectPrototypeSetter(property)) {
|
||||
// A top-level setter changes lookup inheritance but is never an own
|
||||
// key; one copied through object spread has no effect on the target.
|
||||
if (depth === 0) members.push({ kind: "prototype-setter" });
|
||||
continue;
|
||||
}
|
||||
members.push({ kind: "property", property });
|
||||
continue;
|
||||
}
|
||||
const { node: spread, depth: wrappers } = unwrapJavaScriptExpression(
|
||||
property.argument,
|
||||
);
|
||||
if (t.isObjectExpression(spread)) {
|
||||
const nextDepth = depth + wrappers + 1;
|
||||
// Copying a getter can run code that changes another source key.
|
||||
if (
|
||||
nextDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT ||
|
||||
spread.properties.some((entry) =>
|
||||
t.isObjectMethod(entry, { kind: "get" }),
|
||||
)
|
||||
) {
|
||||
members.push(UNKNOWN_STATIC_OBJECT_MEMBER);
|
||||
continue;
|
||||
}
|
||||
appendStaticObjectMembers(members, spread, nextDepth, isNullish);
|
||||
continue;
|
||||
}
|
||||
// These primitive boxes have no own keys; strings can own index keys.
|
||||
if (
|
||||
t.isBooleanLiteral(spread) ||
|
||||
t.isNumericLiteral(spread) ||
|
||||
t.isBigIntLiteral(spread) ||
|
||||
isNullish?.(property.argument) === true
|
||||
)
|
||||
continue;
|
||||
members.push(UNKNOWN_STATIC_OBJECT_MEMBER);
|
||||
}
|
||||
};
|
||||
|
||||
const staticObjectMembers = (
|
||||
object: t.ObjectExpression,
|
||||
depth: number,
|
||||
isNullish?: (node: t.Node) => boolean,
|
||||
): readonly StaticObjectMember[] => {
|
||||
const members: StaticObjectMember[] = [];
|
||||
appendStaticObjectMembers(members, object, depth, isNullish);
|
||||
return members;
|
||||
};
|
||||
|
||||
/** Find the effective explicit property, preserving unresolved overrides. */
|
||||
export const staticObjectProperty = (
|
||||
object: t.Node | undefined,
|
||||
@@ -138,49 +211,57 @@ export const staticObjectProperty = (
|
||||
): StaticObjectProperty => {
|
||||
if (object === undefined) return { status: "missing" };
|
||||
if (!t.isObjectExpression(object)) return { status: "dynamic" };
|
||||
const lookup = (
|
||||
current: t.ObjectExpression,
|
||||
depth: number,
|
||||
): StaticObjectProperty => {
|
||||
for (const property of current.properties.toReversed()) {
|
||||
if (t.isSpreadElement(property)) {
|
||||
const { node: spread, depth: wrappers } = unwrapJavaScriptExpression(
|
||||
property.argument,
|
||||
);
|
||||
if (t.isObjectExpression(spread)) {
|
||||
const nextDepth = depth + wrappers + 1;
|
||||
// Copying a getter can run code that changes another source key.
|
||||
if (
|
||||
nextDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT ||
|
||||
spread.properties.some((entry) =>
|
||||
t.isObjectMethod(entry, { kind: "get" }),
|
||||
)
|
||||
)
|
||||
return { status: "dynamic" };
|
||||
const found = lookup(spread, nextDepth);
|
||||
if (found.status === "missing") continue;
|
||||
return found;
|
||||
}
|
||||
// These primitive boxes have no own keys; strings can own index keys.
|
||||
if (
|
||||
t.isBooleanLiteral(spread) ||
|
||||
t.isNumericLiteral(spread) ||
|
||||
t.isBigIntLiteral(spread) ||
|
||||
isNullish?.(property.argument) === true
|
||||
)
|
||||
continue;
|
||||
return { status: "dynamic" };
|
||||
}
|
||||
const key = semanticStaticPropertyKey(property.key, property.computed);
|
||||
if (key === null) return { status: "dynamic" };
|
||||
if (key !== name) continue;
|
||||
return t.isObjectProperty(property)
|
||||
? { status: "explicit", property }
|
||||
: { status: "dynamic" };
|
||||
let prototypeMaySupply = false;
|
||||
for (const member of staticObjectMembers(object, 0, isNullish).toReversed()) {
|
||||
if (member.kind === "prototype-setter") {
|
||||
prototypeMaySupply = true;
|
||||
continue;
|
||||
}
|
||||
return { status: "missing" };
|
||||
};
|
||||
return lookup(object, 0);
|
||||
if (member.kind === "unknown") return { status: "dynamic" };
|
||||
const { property } = member;
|
||||
const key = semanticStaticPropertyKey(property.key, property.computed);
|
||||
if (key === null) return { status: "dynamic" };
|
||||
if (key !== name) continue;
|
||||
return t.isObjectProperty(property)
|
||||
? { status: "explicit", property }
|
||||
: { status: "dynamic" };
|
||||
}
|
||||
return prototypeMaySupply ? { status: "dynamic" } : { status: "missing" };
|
||||
};
|
||||
|
||||
export type StaticObjectPropertyNames =
|
||||
| { readonly status: "missing" | "dynamic" }
|
||||
| { readonly status: "partial"; readonly names: readonly string[] }
|
||||
| { readonly status: "complete"; readonly names: readonly string[] };
|
||||
|
||||
/** Retain known own names and report unresolved object membership separately. */
|
||||
export const staticObjectPropertyNames = (
|
||||
object: t.Node | undefined,
|
||||
isNullish?: (node: t.Node) => boolean,
|
||||
): StaticObjectPropertyNames => {
|
||||
if (object === undefined) return { status: "missing" };
|
||||
if (!t.isObjectExpression(object)) return { status: "dynamic" };
|
||||
const names = new Set<string>();
|
||||
let complete = true;
|
||||
for (const member of staticObjectMembers(object, 0, isNullish)) {
|
||||
if (member.kind === "prototype-setter") continue;
|
||||
if (member.kind === "unknown") {
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
const key = semanticStaticPropertyKey(
|
||||
member.property.key,
|
||||
member.property.computed,
|
||||
);
|
||||
if (key === null) {
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
names.add(key);
|
||||
}
|
||||
return complete
|
||||
? { status: "complete", names: [...names] }
|
||||
: { status: "partial", names: [...names] };
|
||||
};
|
||||
|
||||
/** Later properties own a key; an intervening dynamic write leaves it unknown. */
|
||||
|
||||
@@ -220,7 +220,17 @@ export const webBundleAnalysisSchema = z.object({
|
||||
z.object({
|
||||
name: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
schema_property_names: z.array(z.string()),
|
||||
schema_property_names: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
"Statically known own property names from the declared schema projection; a partial list may omit unresolved members.",
|
||||
),
|
||||
schema_property_names_coverage: z
|
||||
.enum(["complete", "partial", "unknown", "not_present"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Coverage of the schema property-name projection. Missing means unknown for historical observations. Not a schema-validity result.",
|
||||
),
|
||||
trust: z.literal("page-declared-untrusted"),
|
||||
location: sourceLocationSchema,
|
||||
}),
|
||||
|
||||
@@ -158,6 +158,232 @@ it("discloses widened returned-reference effects only for the affected script",
|
||||
).toEqual(["/candidate"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "inline declaration and schema spreads",
|
||||
source: `document.modelContext.registerTool({
|
||||
...{
|
||||
name: "spread-tool",
|
||||
description: "Copied from a literal",
|
||||
inputSchema: {
|
||||
...{
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});`,
|
||||
name: "spread-tool",
|
||||
description: "Copied from a literal",
|
||||
properties: ["query"],
|
||||
coverage: "complete",
|
||||
},
|
||||
{
|
||||
label: "unrelated trailing literal spread",
|
||||
source: `document.modelContext.registerTool({
|
||||
name: "direct-tool",
|
||||
description: "Explicit metadata",
|
||||
inputSchema: { properties: { query: { type: "string" } } },
|
||||
...{ timeout: 5 }
|
||||
});`,
|
||||
name: "direct-tool",
|
||||
description: "Explicit metadata",
|
||||
properties: ["query"],
|
||||
coverage: "complete",
|
||||
},
|
||||
{
|
||||
label: "null spread after explicit metadata",
|
||||
source: `document.modelContext.registerTool({
|
||||
name: "null-safe-tool",
|
||||
description: "Null spread is inert",
|
||||
inputSchema: { properties: { query: { type: "string" } } },
|
||||
...null
|
||||
});`,
|
||||
name: "null-safe-tool",
|
||||
description: "Null spread is inert",
|
||||
properties: ["query"],
|
||||
coverage: "complete",
|
||||
},
|
||||
{
|
||||
label: "snake-case schema property from a spread",
|
||||
source: `document.modelContext.registerTool({
|
||||
...{
|
||||
name: "snake-tool",
|
||||
description: "Snake-case schema",
|
||||
input_schema: { properties: { item_id: { type: "string" } } }
|
||||
}
|
||||
});`,
|
||||
name: "snake-tool",
|
||||
description: "Snake-case schema",
|
||||
properties: ["item_id"],
|
||||
coverage: "complete",
|
||||
},
|
||||
])(
|
||||
"preserves WebMCP facts through $label",
|
||||
({ source, name, description, properties, coverage }) => {
|
||||
const result = analyzeCapturedWebBundle(inspection(source));
|
||||
|
||||
expect(result.observations.webmcp_declarations).toEqual([
|
||||
expect.objectContaining({
|
||||
name,
|
||||
description,
|
||||
schema_property_names: properties,
|
||||
schema_property_names_coverage: coverage,
|
||||
trust: "page-declared-untrusted",
|
||||
}),
|
||||
]);
|
||||
expect(result.completeness.status).toBe("complete");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "unknown spread after explicit facts",
|
||||
source: `document.modelContext.registerTool({
|
||||
name: "possibly-overridden",
|
||||
description: "Possibly overridden",
|
||||
inputSchema: { properties: { query: { type: "string" } } },
|
||||
...dynamicOptions
|
||||
});`,
|
||||
name: null,
|
||||
description: null,
|
||||
properties: [],
|
||||
coverage: "unknown",
|
||||
},
|
||||
{
|
||||
label: "explicit facts after an unknown spread",
|
||||
source: `document.modelContext.registerTool({
|
||||
...dynamicOptions,
|
||||
name: "known-after-spread",
|
||||
description: "Explicitly restored",
|
||||
inputSchema: { properties: { query: { type: "string" } } }
|
||||
});`,
|
||||
name: "known-after-spread",
|
||||
description: "Explicitly restored",
|
||||
properties: ["query"],
|
||||
coverage: "complete",
|
||||
},
|
||||
])(
|
||||
"preserves rightmost uncertainty for $label",
|
||||
({ source, name, description, properties, coverage }) => {
|
||||
const result = analyzeCapturedWebBundle(inspection(source));
|
||||
|
||||
expect(result.observations.webmcp_declarations).toEqual([
|
||||
expect.objectContaining({
|
||||
name,
|
||||
description,
|
||||
schema_property_names: properties,
|
||||
schema_property_names_coverage: coverage,
|
||||
trust: "page-declared-untrusted",
|
||||
}),
|
||||
]);
|
||||
expect(result.completeness.status).toBe("complete");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "an unknown camel-case schema override",
|
||||
source: `document.modelContext.registerTool({
|
||||
...dynamicOptions,
|
||||
inputSchema: { properties: { camel: {} } },
|
||||
...anotherDynamicOptions,
|
||||
input_schema: { properties: { snake: {} } },
|
||||
name: "tool",
|
||||
description: "Explicit metadata"
|
||||
});`,
|
||||
name: "tool",
|
||||
properties: [],
|
||||
coverage: "unknown",
|
||||
},
|
||||
{
|
||||
label: "an explicit null camel-case schema",
|
||||
source: `document.modelContext.registerTool({
|
||||
name: "null-alias",
|
||||
description: "Fallback applies",
|
||||
inputSchema: null,
|
||||
input_schema: { properties: { snake: {} } }
|
||||
});`,
|
||||
name: "null-alias",
|
||||
properties: ["snake"],
|
||||
coverage: "complete",
|
||||
},
|
||||
{
|
||||
label: "literal property spreads",
|
||||
source: `document.modelContext.registerTool({
|
||||
name: "spread-properties",
|
||||
inputSchema: { properties: { ...{ query: {} }, direct: {} } }
|
||||
});`,
|
||||
name: "spread-properties",
|
||||
properties: ["direct", "query"],
|
||||
coverage: "complete",
|
||||
},
|
||||
{
|
||||
label: "prototype setters are not schema properties",
|
||||
source: `document.modelContext.registerTool({
|
||||
name: "prototype-properties",
|
||||
inputSchema: { properties: { __proto__: { hidden: {} }, direct: {}, ["__proto__"]: {} } }
|
||||
});`,
|
||||
name: "prototype-properties",
|
||||
properties: ["__proto__", "direct"],
|
||||
coverage: "complete",
|
||||
},
|
||||
{
|
||||
label: "own schema properties before a prototype setter",
|
||||
source: `document.modelContext.registerTool({
|
||||
name: "own-before-prototype",
|
||||
inputSchema: { properties: { known: {} }, __proto__: { properties: { inherited: {} } } }
|
||||
});`,
|
||||
name: "own-before-prototype",
|
||||
properties: ["known"],
|
||||
coverage: "complete",
|
||||
},
|
||||
{
|
||||
label: "own schema properties after a prototype setter",
|
||||
source: `document.modelContext.registerTool({
|
||||
name: "own-after-prototype",
|
||||
inputSchema: { __proto__: { properties: { inherited: {} } }, properties: { known: {} } }
|
||||
});`,
|
||||
name: "own-after-prototype",
|
||||
properties: ["known"],
|
||||
coverage: "complete",
|
||||
},
|
||||
{
|
||||
label: "an unresolved property spread",
|
||||
source: `document.modelContext.registerTool({
|
||||
name: "unknown-properties",
|
||||
inputSchema: { properties: { ...dynamicProperties, direct: {} } }
|
||||
});`,
|
||||
name: "unknown-properties",
|
||||
properties: ["direct"],
|
||||
coverage: "partial",
|
||||
},
|
||||
{
|
||||
label: "an unresolved computed property key",
|
||||
source: `document.modelContext.registerTool({
|
||||
name: "computed-properties",
|
||||
inputSchema: { properties: { [propertyName]: {}, direct: {} } }
|
||||
});`,
|
||||
name: "computed-properties",
|
||||
properties: ["direct"],
|
||||
coverage: "partial",
|
||||
},
|
||||
])(
|
||||
"preserves WebMCP schema ownership for $label",
|
||||
({ source, name, properties, coverage }) => {
|
||||
const result = analyzeCapturedWebBundle(inspection(source));
|
||||
|
||||
expect(result.observations.webmcp_declarations).toEqual([
|
||||
expect.objectContaining({
|
||||
name,
|
||||
schema_property_names: properties,
|
||||
schema_property_names_coverage: coverage,
|
||||
trust: "page-declared-untrusted",
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
describe("web bundle analyzer", () => {
|
||||
it("extracts graph, route, endpoint, vendor, and WebMCP evidence", () => {
|
||||
const source = `
|
||||
@@ -206,6 +432,7 @@ describe("web bundle analyzer", () => {
|
||||
name: "lookup-user",
|
||||
trust: "page-declared-untrusted",
|
||||
schema_property_names: ["userId", "verbose"],
|
||||
schema_property_names_coverage: "complete",
|
||||
}),
|
||||
]);
|
||||
expect(result.inferences).toEqual(
|
||||
@@ -266,6 +493,7 @@ describe("web bundle analyzer", () => {
|
||||
name,
|
||||
description,
|
||||
schema_property_names: [propertyName],
|
||||
schema_property_names_coverage: "complete",
|
||||
trust: "page-declared-untrusted",
|
||||
});
|
||||
});
|
||||
@@ -1481,10 +1709,24 @@ describe("web metadata key semantics", () => {
|
||||
expect.objectContaining({
|
||||
name: "new",
|
||||
schema_property_names: ["literal"],
|
||||
schema_property_names_coverage: "partial",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: null,
|
||||
schema_property_names_coverage: "unknown",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: null,
|
||||
schema_property_names_coverage: "not_present",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "restored",
|
||||
schema_property_names_coverage: "unknown",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "valid",
|
||||
schema_property_names_coverage: "not_present",
|
||||
}),
|
||||
expect.objectContaining({ name: null }),
|
||||
expect.objectContaining({ name: "restored" }),
|
||||
expect.objectContaining({ name: "valid" }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1514,20 +1756,86 @@ describe("web metadata key semantics", () => {
|
||||
|
||||
expect(
|
||||
result.observations.webmcp_declarations.map(
|
||||
({ name, schema_property_names }) => ({ name, schema_property_names }),
|
||||
({ name, schema_property_names, schema_property_names_coverage }) => ({
|
||||
name,
|
||||
schema_property_names,
|
||||
schema_property_names_coverage,
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{ name: "literal", schema_property_names: ["", "properties", "type"] },
|
||||
{ name: "missing properties", schema_property_names: [] },
|
||||
{ name: "dynamic properties", schema_property_names: [] },
|
||||
{ name: "null properties", schema_property_names: [] },
|
||||
{ name: "missing schema", schema_property_names: [] },
|
||||
{ name: "dynamic schema", schema_property_names: [] },
|
||||
{ name: "null schema", schema_property_names: [] },
|
||||
{ name: "spread schema", schema_property_names: [] },
|
||||
{ name: "computed literal keys", schema_property_names: ["", "literal"] },
|
||||
{
|
||||
name: "literal",
|
||||
schema_property_names: ["", "properties", "type"],
|
||||
schema_property_names_coverage: "partial",
|
||||
},
|
||||
{
|
||||
name: "missing properties",
|
||||
schema_property_names: [],
|
||||
schema_property_names_coverage: "not_present",
|
||||
},
|
||||
{
|
||||
name: "dynamic properties",
|
||||
schema_property_names: [],
|
||||
schema_property_names_coverage: "unknown",
|
||||
},
|
||||
{
|
||||
name: "null properties",
|
||||
schema_property_names: [],
|
||||
schema_property_names_coverage: "unknown",
|
||||
},
|
||||
{
|
||||
name: "missing schema",
|
||||
schema_property_names: [],
|
||||
schema_property_names_coverage: "not_present",
|
||||
},
|
||||
{
|
||||
name: "dynamic schema",
|
||||
schema_property_names: [],
|
||||
schema_property_names_coverage: "unknown",
|
||||
},
|
||||
{
|
||||
name: "null schema",
|
||||
schema_property_names: [],
|
||||
schema_property_names_coverage: "not_present",
|
||||
},
|
||||
{
|
||||
name: "spread schema",
|
||||
schema_property_names: [],
|
||||
schema_property_names_coverage: "unknown",
|
||||
},
|
||||
{
|
||||
name: "computed literal keys",
|
||||
schema_property_names: ["", "literal"],
|
||||
schema_property_names_coverage: "partial",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps missing coverage absent when parsing historical observations", () => {
|
||||
const analyzed = analyzeCapturedWebBundle(
|
||||
inspection(
|
||||
`document.modelContext.registerTool({ name: "legacy", inputSchema: { properties: {} } });`,
|
||||
),
|
||||
);
|
||||
const declaration = analyzed.observations.webmcp_declarations[0];
|
||||
expect(declaration).toBeDefined();
|
||||
if (declaration === undefined) throw new Error("Expected declaration");
|
||||
const { schema_property_names_coverage: _coverage, ...legacyDeclaration } =
|
||||
declaration;
|
||||
const parsed = webBundleAnalysisSchema.parse({
|
||||
...analyzed,
|
||||
observations: {
|
||||
...analyzed.observations,
|
||||
webmcp_declarations: [legacyDeclaration],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
Object.hasOwn(
|
||||
parsed.observations.webmcp_declarations[0] ?? {},
|
||||
"schema_property_names_coverage",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("retains WebMCP declarations through optional member and call chaining", () => {
|
||||
@@ -1695,6 +2003,8 @@ it("retains distinct WebMCP descriptions and names while deduplicating equivalen
|
||||
document.modelContext.registerTool({ name: "lookup", description: "second", inputSchema: { properties: { alpha: {} } } });
|
||||
document.modelContext.registerTool({ name: "equivalent", inputSchema: { properties: { alpha: {}, beta: {} } } });
|
||||
document.modelContext.registerTool({ name: "equivalent", inputSchema: { properties: { beta: {}, alpha: {}, alpha: {} } } });
|
||||
document.modelContext.registerTool({ name: "empty-status", inputSchema: { properties: {} } });
|
||||
document.modelContext.registerTool({ name: "empty-status", inputSchema: { properties: dynamicProperties } });
|
||||
document.modelContext.registerTool({ description: "unnamed" });
|
||||
document.modelContext.registerTool({ name: "", description: "unnamed" });
|
||||
`),
|
||||
@@ -1702,10 +2012,16 @@ it("retains distinct WebMCP descriptions and names while deduplicating equivalen
|
||||
|
||||
expect(
|
||||
result.observations.webmcp_declarations.map(
|
||||
({ name, description, schema_property_names }) => ({
|
||||
({
|
||||
name,
|
||||
description,
|
||||
schema_property_names,
|
||||
schema_property_names_coverage,
|
||||
}) => ({
|
||||
name,
|
||||
description,
|
||||
schema_property_names,
|
||||
schema_property_names_coverage,
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
@@ -1713,19 +2029,44 @@ it("retains distinct WebMCP descriptions and names while deduplicating equivalen
|
||||
name: "lookup",
|
||||
description: "first",
|
||||
schema_property_names: ["alpha"],
|
||||
schema_property_names_coverage: "complete",
|
||||
},
|
||||
{
|
||||
name: "lookup",
|
||||
description: "second",
|
||||
schema_property_names: ["alpha"],
|
||||
schema_property_names_coverage: "complete",
|
||||
},
|
||||
{
|
||||
name: "equivalent",
|
||||
description: null,
|
||||
schema_property_names: ["alpha", "beta"],
|
||||
schema_property_names_coverage: "complete",
|
||||
},
|
||||
{
|
||||
name: "empty-status",
|
||||
description: null,
|
||||
schema_property_names: [],
|
||||
schema_property_names_coverage: "complete",
|
||||
},
|
||||
{
|
||||
name: "empty-status",
|
||||
description: null,
|
||||
schema_property_names: [],
|
||||
schema_property_names_coverage: "unknown",
|
||||
},
|
||||
{
|
||||
name: null,
|
||||
description: "unnamed",
|
||||
schema_property_names: [],
|
||||
schema_property_names_coverage: "not_present",
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
description: "unnamed",
|
||||
schema_property_names: [],
|
||||
schema_property_names_coverage: "not_present",
|
||||
},
|
||||
{ name: null, description: "unnamed", schema_property_names: [] },
|
||||
{ name: "", description: "unnamed", schema_property_names: [] },
|
||||
]);
|
||||
expect(result.observations.webmcp_declarations[2]?.location).toEqual({
|
||||
script_key: `scr_${"1".repeat(64)}`,
|
||||
|
||||
@@ -2,28 +2,18 @@ import * as t from "@babel/types";
|
||||
|
||||
import { sanitizeBrowserUrl } from "./browserObservation.js";
|
||||
import {
|
||||
isNullishJavaScriptSyntax,
|
||||
readExactJavaScriptLiteral,
|
||||
semanticStaticPropertyName,
|
||||
staticObjectProperty,
|
||||
} from "./javascript/javascriptAstValues.js";
|
||||
|
||||
/** Pick the endpoint argument for common network-call patterns. */
|
||||
|
||||
/** Read a statically known own property value from an object expression. */
|
||||
export const objectValue = (
|
||||
object: t.ObjectExpression,
|
||||
name: string,
|
||||
): t.ObjectProperty["value"] | undefined => {
|
||||
let value: t.ObjectProperty["value"] | undefined;
|
||||
for (const property of object.properties) {
|
||||
if (t.isSpreadElement(property)) {
|
||||
value = undefined;
|
||||
continue;
|
||||
}
|
||||
const key = semanticStaticPropertyName(property.key, property.computed);
|
||||
if (key === "" && !t.isStringLiteral(property.key)) value = undefined;
|
||||
else if (key === name)
|
||||
value = t.isObjectProperty(property) ? property.value : undefined;
|
||||
}
|
||||
return value;
|
||||
const result = staticObjectProperty(object, name, isNullishJavaScriptSyntax);
|
||||
return result.status === "explicit" ? result.property.value : undefined;
|
||||
};
|
||||
|
||||
export const objectString = (
|
||||
|
||||
@@ -12,6 +12,9 @@ import { isIntrinsicMapLookup } from "./javascript/javascriptSemanticMapOperatio
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
semanticStaticPropertyName,
|
||||
isNullishJavaScriptSyntax,
|
||||
staticObjectProperty,
|
||||
staticObjectPropertyNames,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascript/javascriptAstValues.js";
|
||||
import {
|
||||
@@ -23,7 +26,6 @@ import {
|
||||
location,
|
||||
locationFields,
|
||||
objectString,
|
||||
objectValue,
|
||||
resolveSpecifier,
|
||||
isUrlLikeModuleSpecifier,
|
||||
} from "./webBundleAnalyzerAst.js";
|
||||
@@ -365,23 +367,60 @@ const addWebMcpDeclaration = (
|
||||
if (!t.isObjectExpression(declaration)) return;
|
||||
const name = objectString(declaration, "name");
|
||||
const description = objectString(declaration, "description");
|
||||
const schema =
|
||||
objectValue(declaration, "inputSchema") ??
|
||||
objectValue(declaration, "input_schema");
|
||||
const properties = t.isObjectExpression(schema)
|
||||
? objectValue(schema, "properties")
|
||||
: undefined;
|
||||
const schemaPropertyNames = t.isObjectExpression(properties)
|
||||
? properties.properties.flatMap((property) => {
|
||||
if (!t.isObjectProperty(property)) return [];
|
||||
const key = semanticStaticPropertyKey(property.key, property.computed);
|
||||
return key === null ? [] : [key];
|
||||
})
|
||||
: [];
|
||||
const camelSchema = staticObjectProperty(
|
||||
declaration,
|
||||
"inputSchema",
|
||||
isNullishJavaScriptSyntax,
|
||||
);
|
||||
const schemaProperty =
|
||||
camelSchema.status === "missing" ||
|
||||
(camelSchema.status === "explicit" &&
|
||||
isNullishJavaScriptSyntax(camelSchema.property.value))
|
||||
? staticObjectProperty(
|
||||
declaration,
|
||||
"input_schema",
|
||||
isNullishJavaScriptSyntax,
|
||||
)
|
||||
: camelSchema;
|
||||
let schemaPropertyNames: readonly string[] = [];
|
||||
let schemaPropertyNamesCoverage: NonNullable<
|
||||
WebMcpDeclaration["schema_property_names_coverage"]
|
||||
> = "unknown";
|
||||
if (schemaProperty.status === "missing") {
|
||||
schemaPropertyNamesCoverage = "not_present";
|
||||
} else if (schemaProperty.status === "explicit") {
|
||||
const schema = schemaProperty.property.value;
|
||||
if (t.isObjectExpression(schema)) {
|
||||
const propertiesProperty = staticObjectProperty(
|
||||
schema,
|
||||
"properties",
|
||||
isNullishJavaScriptSyntax,
|
||||
);
|
||||
if (propertiesProperty.status === "missing") {
|
||||
schemaPropertyNamesCoverage = "not_present";
|
||||
} else if (propertiesProperty.status === "explicit") {
|
||||
const properties = propertiesProperty.property.value;
|
||||
if (t.isObjectExpression(properties)) {
|
||||
const projection = staticObjectPropertyNames(
|
||||
properties,
|
||||
isNullishJavaScriptSyntax,
|
||||
);
|
||||
if (
|
||||
projection.status === "complete" ||
|
||||
projection.status === "partial"
|
||||
) {
|
||||
schemaPropertyNames = projection.names;
|
||||
schemaPropertyNamesCoverage = projection.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const metadata = {
|
||||
name: name ?? null,
|
||||
description: description ?? null,
|
||||
schema_property_names: [...new Set(schemaPropertyNames)].sort(),
|
||||
schema_property_names_coverage: schemaPropertyNamesCoverage,
|
||||
};
|
||||
// Deduplicate the metadata we return, preserving omission and descriptions.
|
||||
// Property names are a variable-length source-derived list: NUL-joins
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const capturedSource = `
|
||||
const dynamic = globalThis.options;
|
||||
document.modelContext.registerTool({
|
||||
name: "spread",
|
||||
inputSchema: { properties: { ...{ query: {} }, direct: {} } }
|
||||
});
|
||||
document.modelContext.registerTool({
|
||||
name: "partial",
|
||||
inputSchema: { properties: { known: {}, ...dynamic } }
|
||||
});
|
||||
document.modelContext.registerTool({
|
||||
name: "empty-status", description: "same",
|
||||
inputSchema: { properties: dynamic }
|
||||
});
|
||||
document.modelContext.registerTool({
|
||||
name: "empty-status", description: "same",
|
||||
inputSchema: { properties: {} }
|
||||
});
|
||||
document.modelContext.registerTool({
|
||||
name: "own-after-prototype-setter",
|
||||
inputSchema: {
|
||||
properties: { own: {} },
|
||||
__proto__: { properties: { inherited: {} } }
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
const inspectDeclarations = (document: unknown) => {
|
||||
const parsed =
|
||||
toolContract("analyze_web_bundle").outputSchema.parse(document);
|
||||
const parsedEvidence = parseEvidence(parsed);
|
||||
return {
|
||||
evidence: parsedEvidence,
|
||||
declarations: parsed.normalized_result.observations.webmcp_declarations,
|
||||
};
|
||||
};
|
||||
|
||||
const expectCoverageCases = (
|
||||
declarations: ReturnType<typeof inspectDeclarations>["declarations"],
|
||||
) => {
|
||||
expect(declarations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "spread",
|
||||
schema_property_names: ["direct", "query"],
|
||||
schema_property_names_coverage: "complete",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "partial",
|
||||
schema_property_names: ["known"],
|
||||
schema_property_names_coverage: "partial",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "empty-status",
|
||||
description: "same",
|
||||
schema_property_names: [],
|
||||
schema_property_names_coverage: "unknown",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "empty-status",
|
||||
description: "same",
|
||||
schema_property_names: [],
|
||||
schema_property_names_coverage: "complete",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "own-after-prototype-setter",
|
||||
schema_property_names: ["own"],
|
||||
schema_property_names_coverage: "complete",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
declarations.filter(({ name }) => name === "empty-status"),
|
||||
).toHaveLength(2);
|
||||
};
|
||||
|
||||
cliTest(
|
||||
"preserves WebMCP schema-name coverage through compiled CLI and stdio MCP",
|
||||
async ({ cli }) => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
commandResult: ({ method }) =>
|
||||
method === "Debugger.getScriptSource"
|
||||
? { scriptSource: capturedSource }
|
||||
: undefined,
|
||||
});
|
||||
try {
|
||||
const contract = toolContract("analyze_web_bundle");
|
||||
const cliReply = await cli.run({
|
||||
arguments: [
|
||||
"analyze-web-bundle",
|
||||
browser.endpoint,
|
||||
"allowed-page",
|
||||
"--allowed-origins",
|
||||
browser.allowedOrigin,
|
||||
"--observation-ms",
|
||||
"0",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(cliReply.exitCode, cliReply.stdout + cliReply.stderr).toBe(0);
|
||||
const cliAnalysis = inspectDeclarations(cliReply.json);
|
||||
expectCoverageCases(cliAnalysis.declarations);
|
||||
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const input = {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 0,
|
||||
include_script_sources: true,
|
||||
fetch_source_maps: false,
|
||||
};
|
||||
const response = await call(contract.name, input);
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
const mcpAnalysis = inspectDeclarations(response.structuredContent);
|
||||
expectCoverageCases(mcpAnalysis.declarations);
|
||||
expect(mcpAnalysis.evidence.operation).toBe("analyze_web_bundle");
|
||||
expect(mcpAnalysis.evidence.authority).toBe("external-service");
|
||||
expect(mcpAnalysis.declarations).toEqual(cliAnalysis.declarations);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
Reference in new issue
Block a user