refactor(domain): encode comparison and lifecycle states exactly (#473)
* refactor(domain): encode comparison states exactly * refactor(domain): make evidence lifecycles explicit * refactor(browser): parse capture states once * test: prefer behavior over implementation seams * chore: refresh schema-derived metadata * test(process): preserve lifecycle cleanup coverage * test(hopper): bind rename cache coverage to dispatch
This commit is contained in:
1 parent
e91c8c39c2
commit
d65730c4c2
43 files changed
+1732
-780
No files matched your search
@@ -680,10 +680,10 @@
|
||||
"mcp_resource_templates": 11
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "1a4533fef9cbe075fdca76b19a138924bd69d93a6413cdb767b2a9240a46fd04",
|
||||
"tools_sha256": "b21e7a4794bf82b27931a373a7812978b54f136904a88681094f2c545e4413a6",
|
||||
"prompts_sha256": "3886a3bf55a448def79e3eb429e6a56eb2ba075423c5cb1e5747642b389ef8fe",
|
||||
"resources_sha256": "e0e872276ebf6f88e916ef24930a99eed27ce4c93e494e60820e9bd8cc8497c3",
|
||||
"combined_sha256": "8bd40516d78875cb75ba1c4a8786286311036742ad660914d7327c2941b2f73a",
|
||||
"combined_sha256": "6624247db5bcf7b52ce53173bbc0ac11bd51e9217e9e3be4298b755bcc60ea50",
|
||||
"providers_sha256": "7446b51788734ebf6f4f702e3a5a8c9f0b61967490c245f30bd37b2060827e6b"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"manifest_id": "ecm_5add7389cf00e86bfec005df9e0e0cf8d7aceb2f3ec3e5f6b74edb5f880bd9a7",
|
||||
"manifest_id": "ecm_1b79ae0f4063ccac065a4eff5dec420f968ea39bc4d93eb3bddcaaf318eb01a6",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -14,7 +14,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "2cca31c9550fe32c24adcb8134edb4b077aa772fb0f0f1057c99964cac6d9a8f"
|
||||
"sha256": "709a1ace114ea7ffa6f36a67c2f2df9ffa1029c2cad8508aebf40c5105862643"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, an
|
||||
metadata:
|
||||
version: "23"
|
||||
tool_count: 116
|
||||
catalog_digest: "8bd40516d78875cb75ba1c4a8786286311036742ad660914d7327c2941b2f73a"
|
||||
catalog_digest: "6624247db5bcf7b52ce53173bbc0ac11bd51e9217e9e3be4298b755bcc60ea50"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -3,8 +3,10 @@ import { AnyMap, eachMapping } from "@jridgewell/trace-mapping";
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import type {
|
||||
AnalyzeWebBundleInput,
|
||||
WebBundleAnalysis,
|
||||
WebSourceMapItem,
|
||||
WebSourceMaps,
|
||||
} from "../domain/webBundleAnalysis.js";
|
||||
import { webSourceMapsSchema } from "../domain/webBundleAnalysis.js";
|
||||
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
||||
|
||||
export interface WebSourceMapRequest {
|
||||
@@ -17,8 +19,9 @@ interface SourceMapFetchHost {
|
||||
readonly fetch: typeof fetch;
|
||||
}
|
||||
|
||||
type SourceMaps = WebBundleAnalysis["observations"]["source_maps"];
|
||||
type SourceMapItem = SourceMaps["items"][number];
|
||||
type SourceMaps = WebSourceMaps;
|
||||
type SourceMapItem = WebSourceMapItem;
|
||||
type ParsedSourceMapItem = Extract<SourceMapItem, { status: "included" }>;
|
||||
|
||||
/** Fetch and validate approved source maps without browser credentials. */
|
||||
export const fetchWebSourceMaps = async (
|
||||
@@ -51,7 +54,7 @@ export const fetchWebSourceMaps = async (
|
||||
const dropped = requests.length - items.length;
|
||||
const truncated =
|
||||
dropped > 0 || items.some(({ status }) => status === "truncated");
|
||||
return {
|
||||
return webSourceMapsSchema.parse({
|
||||
status: truncated
|
||||
? "truncated"
|
||||
: items.length === 0
|
||||
@@ -68,7 +71,7 @@ export const fetchWebSourceMaps = async (
|
||||
.slice(items.length)
|
||||
.map(({ scriptKey }) => scriptKey),
|
||||
items,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
interface SourceMapFetchResult {
|
||||
@@ -88,15 +91,13 @@ const fetchOne = async (
|
||||
context: SourceMapFetchContext,
|
||||
): Promise<SourceMapFetchResult> => {
|
||||
const { input, maximumBytes, signal, host } = context;
|
||||
const base = emptyItem(request);
|
||||
if (!approvedUrl(request.fetchUrl, input.allowed_origins))
|
||||
return {
|
||||
item: {
|
||||
...base,
|
||||
status: "policy_filtered",
|
||||
limitation:
|
||||
"Declared source-map URL is outside the approved exact origins.",
|
||||
},
|
||||
item: emptySourceMapItem(
|
||||
request,
|
||||
"policy_filtered",
|
||||
"Declared source-map URL is outside the approved exact origins.",
|
||||
),
|
||||
bytesRead: 0,
|
||||
};
|
||||
try {
|
||||
@@ -108,20 +109,20 @@ const fetchOne = async (
|
||||
);
|
||||
if (response === undefined)
|
||||
return {
|
||||
item: {
|
||||
...base,
|
||||
status: "policy_filtered",
|
||||
limitation: "A source-map redirect left the approved exact origins.",
|
||||
},
|
||||
item: emptySourceMapItem(
|
||||
request,
|
||||
"policy_filtered",
|
||||
"A source-map redirect left the approved exact origins.",
|
||||
),
|
||||
bytesRead: 0,
|
||||
};
|
||||
if (!response.ok)
|
||||
return {
|
||||
item: {
|
||||
...base,
|
||||
status: "fetch_failed",
|
||||
limitation: `Source-map server returned HTTP ${String(response.status)}.`,
|
||||
},
|
||||
item: emptySourceMapItem(
|
||||
request,
|
||||
"fetch_failed",
|
||||
`Source-map server returned HTTP ${String(response.status)}.`,
|
||||
),
|
||||
bytesRead: 0,
|
||||
};
|
||||
const body = await boundedResponseText(response, maximumBytes);
|
||||
@@ -132,13 +133,13 @@ const fetchOne = async (
|
||||
} catch (cause: unknown) {
|
||||
if (signal?.aborted === true) throw cause;
|
||||
return {
|
||||
item: {
|
||||
...base,
|
||||
status: isLimitError(cause) ? "truncated" : "fetch_failed",
|
||||
limitation: isLimitError(cause)
|
||||
item: emptySourceMapItem(
|
||||
request,
|
||||
isLimitError(cause) ? "truncated" : "fetch_failed",
|
||||
isLimitError(cause)
|
||||
? "Source-map response exceeded the remaining approved byte budget."
|
||||
: "Source-map fetch or validation failed.",
|
||||
},
|
||||
),
|
||||
bytesRead: cause instanceof SourceMapByteLimitError ? cause.bytesRead : 0,
|
||||
};
|
||||
}
|
||||
@@ -215,21 +216,20 @@ const normalizeSourceMap = (
|
||||
text: string,
|
||||
input: AnalyzeWebBundleInput,
|
||||
): SourceMapItem => {
|
||||
const base = emptyItem(request);
|
||||
if (!validSourceMapEnvelope(text))
|
||||
return {
|
||||
...base,
|
||||
status: "invalid",
|
||||
limitation: "Source-map JSON is not a bounded version 3 map.",
|
||||
};
|
||||
return emptySourceMapItem(
|
||||
request,
|
||||
"invalid",
|
||||
"Source-map JSON is not a bounded version 3 map.",
|
||||
);
|
||||
try {
|
||||
const map = new AnyMap(text, request.fetchUrl);
|
||||
if (map.sources.length > input.analysis_limits.max_original_sources)
|
||||
return {
|
||||
...base,
|
||||
status: "truncated",
|
||||
limitation: "Source map exceeds the original-source inventory limit.",
|
||||
};
|
||||
return emptySourceMapItem(
|
||||
request,
|
||||
"truncated",
|
||||
"Source map exceeds the original-source inventory limit.",
|
||||
);
|
||||
const resolvedBySource = new Map<string, string>();
|
||||
const originalSources = map.sources.map((source, index) => {
|
||||
const content = map.sourcesContent?.[index];
|
||||
@@ -244,7 +244,7 @@ const normalizeSourceMap = (
|
||||
: null,
|
||||
};
|
||||
});
|
||||
const mappings: SourceMapItem["mappings"] = [];
|
||||
const mappings: ParsedSourceMapItem["mappings"] = [];
|
||||
let totalMappings = 0;
|
||||
eachMapping(map, (mapping) => {
|
||||
if (
|
||||
@@ -284,32 +284,37 @@ const normalizeSourceMap = (
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return {
|
||||
...base,
|
||||
status: mappingTruncated || modules.truncated ? "truncated" : "included",
|
||||
const parsed = {
|
||||
...sourceMapContext(request),
|
||||
artifact: createWebTextArtifact(text, "application/source-map+json"),
|
||||
original_sources: originalSources,
|
||||
original_module_edges: modules.edges,
|
||||
mappings,
|
||||
limitation: limitations.length === 0 ? null : limitations.join(" "),
|
||||
};
|
||||
return limitations.length === 0
|
||||
? { ...parsed, status: "included", limitation: null }
|
||||
: {
|
||||
...parsed,
|
||||
status: "truncated",
|
||||
limitation: limitations.join(" "),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...base,
|
||||
status: "invalid",
|
||||
limitation: "Source-map mappings could not be decoded safely.",
|
||||
};
|
||||
return emptySourceMapItem(
|
||||
request,
|
||||
"invalid",
|
||||
"Source-map mappings could not be decoded safely.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const originalModuleEdges = (
|
||||
sources: SourceMapItem["original_sources"],
|
||||
sources: ParsedSourceMapItem["original_sources"],
|
||||
maximum: number,
|
||||
): {
|
||||
readonly edges: SourceMapItem["original_module_edges"];
|
||||
readonly edges: ParsedSourceMapItem["original_module_edges"];
|
||||
readonly truncated: boolean;
|
||||
} => {
|
||||
const edges: SourceMapItem["original_module_edges"] = [];
|
||||
const edges: ParsedSourceMapItem["original_module_edges"] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const source of sources) {
|
||||
if (source.artifact === null) continue;
|
||||
@@ -383,15 +388,23 @@ const approvedUrl = (
|
||||
}
|
||||
};
|
||||
|
||||
const emptyItem = (request: WebSourceMapRequest): SourceMapItem => ({
|
||||
const sourceMapContext = (request: WebSourceMapRequest) => ({
|
||||
script_key: request.scriptKey,
|
||||
declared_url: request.declaredUrl,
|
||||
status: "fetch_failed",
|
||||
});
|
||||
|
||||
const emptySourceMapItem = (
|
||||
request: WebSourceMapRequest,
|
||||
status: Extract<SourceMapItem, { readonly artifact: null }>["status"],
|
||||
limitation: string,
|
||||
): Extract<SourceMapItem, { readonly artifact: null }> => ({
|
||||
...sourceMapContext(request),
|
||||
status,
|
||||
artifact: null,
|
||||
original_sources: [],
|
||||
original_module_edges: [],
|
||||
mappings: [],
|
||||
limitation: null,
|
||||
limitation,
|
||||
});
|
||||
|
||||
const sanitizeSource = (value: string): string => {
|
||||
|
||||
@@ -93,6 +93,16 @@ describe("call path reconstruction", () => {
|
||||
expect(callPathResultSchema.parse(result)).toEqual(result);
|
||||
expect(result.status).toBe("found");
|
||||
expect(result.shortest_hops).toBe(2);
|
||||
expect(
|
||||
callPathResultSchema.safeParse({ ...result, shortest_hops: null })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
callPathResultSchema.safeParse({
|
||||
...result,
|
||||
paths: { ...result.paths, total: null },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
result.paths.items.map((path) =>
|
||||
path.nodes.map(({ address }) => address),
|
||||
@@ -118,9 +128,14 @@ describe("call path reconstruction", () => {
|
||||
});
|
||||
|
||||
it("only reports not_found for exhaustive closure", () => {
|
||||
expect(run([observe("0x1000", []), observe("0x4000", [])]).status).toBe(
|
||||
"not_found",
|
||||
);
|
||||
const notFound = run([observe("0x1000", []), observe("0x4000", [])]);
|
||||
expect(notFound.status).toBe("not_found");
|
||||
expect(
|
||||
callPathResultSchema.safeParse({
|
||||
...notFound,
|
||||
search_scope: { ...notFound.search_scope, exhaustive: false },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
const unknown = run([observe("0x1000", ["0x2000"])]);
|
||||
expect(unknown.status).toBe("unknown");
|
||||
expect(unknown.limitations).toContain(
|
||||
|
||||
+118
-152
@@ -1,102 +1,25 @@
|
||||
import { DirectedGraph } from "graphology";
|
||||
import { bidirectional } from "graphology-shortest-path/unweighted.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import { evidenceSchema } from "./evidence.js";
|
||||
import {
|
||||
callPathInputSchema,
|
||||
callPathResultSchema,
|
||||
parseCallPathAddress,
|
||||
type CallPathEvidenceGroup,
|
||||
type CallPathInput,
|
||||
type CallPathResult,
|
||||
type OutputCallPath,
|
||||
} from "./callPathSchemas.js";
|
||||
import {
|
||||
parseFunctionEvidence,
|
||||
type FunctionSnapshot,
|
||||
} from "./functionDossierEvidence.js";
|
||||
|
||||
const addressSchema = z.string().regex(/^0x(?:0|[1-9a-f][0-9a-f]*)$/u);
|
||||
const inputAddressSchema = z
|
||||
.string()
|
||||
.regex(/^0[xX][0-9a-fA-F]+$/u)
|
||||
.transform((address) => `0x${BigInt(address).toString(16)}`);
|
||||
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
|
||||
const evidenceGroupSchema = z.union([
|
||||
evidenceSchema,
|
||||
z.array(evidenceSchema).min(1).max(100),
|
||||
]);
|
||||
const MAX_EVIDENCE_PAGES = 2_000;
|
||||
const MAX_GRAPH_EDGES = 10_000;
|
||||
const MAX_PATH_EXPANSIONS = 100_000;
|
||||
|
||||
/** Strict, bounded input for explicit call-path reconstruction. */
|
||||
export const callPathInputSchema = z
|
||||
.object({
|
||||
functions: z.array(evidenceGroupSchema).min(1).max(500),
|
||||
start: z.object({ address: inputAddressSchema }).strict(),
|
||||
goal: z.object({ address: inputAddressSchema }).strict(),
|
||||
max_depth: z.number().int().min(0).max(32).default(8),
|
||||
max_paths: z.number().int().min(1).max(100).default(10),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1).max(100).default(100),
|
||||
unknown_registry_approved: z.literal(true).optional(),
|
||||
})
|
||||
.superRefine(({ functions }, context) => {
|
||||
const pages = functions.reduce(
|
||||
(total, group) => total + (Array.isArray(group) ? group.length : 1),
|
||||
0,
|
||||
);
|
||||
if (pages > MAX_EVIDENCE_PAGES)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `Call-path Evidence exceeds ${MAX_EVIDENCE_PAGES} pages`,
|
||||
path: ["functions"],
|
||||
});
|
||||
});
|
||||
|
||||
const citedNodeSchema = z.object({
|
||||
address: addressSchema,
|
||||
name: z.string().nullable(),
|
||||
evidence_links: z.array(evidenceIdSchema).min(1).max(100),
|
||||
});
|
||||
const citedEdgeSchema = z.object({
|
||||
source: addressSchema,
|
||||
target: addressSchema,
|
||||
evidence_links: z.array(evidenceIdSchema).min(1).max(100),
|
||||
});
|
||||
const pathSchema = z.object({
|
||||
hops: z.number().int().min(0),
|
||||
nodes: z.array(citedNodeSchema).min(1).max(33),
|
||||
edges: z.array(citedEdgeSchema).max(32),
|
||||
evidence_links: z.array(evidenceIdSchema).min(1).max(3_300),
|
||||
});
|
||||
|
||||
/** Evidence-cited, bounded directed call-path result. */
|
||||
export const callPathResultSchema = z.object({
|
||||
status: z.enum(["found", "not_found", "unknown", "truncated"]),
|
||||
start: addressSchema,
|
||||
goal: addressSchema,
|
||||
shortest_hops: z.number().int().min(0).nullable(),
|
||||
search_scope: z.object({
|
||||
max_depth: z.number().int().min(0).max(32),
|
||||
max_paths: z.number().int().min(1).max(100),
|
||||
exhaustive: z.boolean(),
|
||||
}),
|
||||
explored: z.object({
|
||||
nodes: z.number().int().min(0),
|
||||
edges: z.number().int().min(0),
|
||||
depth_reached: z.number().int().min(0),
|
||||
}),
|
||||
paths: z.object({
|
||||
items: z.array(pathSchema).max(100),
|
||||
offset: z.number().int().min(0),
|
||||
limit: z.number().int().min(1).max(100),
|
||||
total: z.number().int().min(0).nullable(),
|
||||
returned: z.number().int().min(0).max(100),
|
||||
truncated: z.boolean(),
|
||||
lower_bound: z.number().int().min(0),
|
||||
next_offset: z.number().int().min(0).nullable(),
|
||||
}),
|
||||
evidence_links: z.array(evidenceIdSchema).min(1).max(50_000),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type CallPathInput = z.infer<typeof callPathInputSchema>;
|
||||
export type CallPathResult = z.infer<typeof callPathResultSchema>;
|
||||
type OutputPath = z.infer<typeof pathSchema>;
|
||||
export { callPathInputSchema, callPathResultSchema };
|
||||
export type { CallPathInput, CallPathResult };
|
||||
|
||||
interface SearchState {
|
||||
readonly graph: DirectedGraph;
|
||||
@@ -109,13 +32,7 @@ interface SearchState {
|
||||
/** Reconstruct bounded direct-callee paths from explicit analyze_function Evidence. */
|
||||
export const buildCallPath = (input: CallPathInput): CallPathResult => {
|
||||
const parsed = callPathInputSchema.parse(input);
|
||||
const snapshots = parseSnapshots(parsed.functions);
|
||||
assertCompatible(snapshots);
|
||||
if (!snapshots.has(parsed.start.address))
|
||||
throw new TypeError(
|
||||
`No analyze_function Evidence was supplied for start ${parsed.start.address}`,
|
||||
);
|
||||
const graph = createGraph(snapshots);
|
||||
const { snapshots, graph } = prepareCallGraph(parsed);
|
||||
const search = inspectSearch({
|
||||
graph,
|
||||
snapshots,
|
||||
@@ -144,75 +61,125 @@ export const buildCallPath = (input: CallPathInput): CallPathResult => {
|
||||
const paths = retained.map((path) => citePath(path, snapshots));
|
||||
const total = paths.length;
|
||||
const items = paths.slice(parsed.offset, parsed.offset + parsed.limit);
|
||||
const found = paths.length > 0;
|
||||
const shortestHops = paths[0]?.hops;
|
||||
const found = shortestHops !== undefined;
|
||||
const exhaustive = search.exhaustive && !capped;
|
||||
const status = callPathStatus({ capped, found, exhaustive });
|
||||
const limitations = [...search.limitations];
|
||||
if (capped)
|
||||
limitations.push(
|
||||
enumeration.budgetExhausted
|
||||
? `Path enumeration stopped at ${MAX_PATH_EXPANSIONS} expansions`
|
||||
: `Path enumeration stopped at max_paths=${parsed.max_paths}`,
|
||||
);
|
||||
if (!found && shortest !== null && shortest.length - 1 > parsed.max_depth)
|
||||
limitations.push(
|
||||
`The shortest observed path exceeds max_depth=${parsed.max_depth}`,
|
||||
);
|
||||
return callPathResultSchema.parse({
|
||||
status,
|
||||
const limitations = deriveLimitations(
|
||||
search,
|
||||
{ budgetExhausted: enumeration.budgetExhausted, capped, found, shortest },
|
||||
parsed,
|
||||
);
|
||||
const resultContext = {
|
||||
start: parsed.start.address,
|
||||
goal: parsed.goal.address,
|
||||
shortest_hops: found ? (paths[0]?.hops ?? null) : null,
|
||||
search_scope: {
|
||||
max_depth: parsed.max_depth,
|
||||
max_paths: parsed.max_paths,
|
||||
exhaustive,
|
||||
},
|
||||
explored: {
|
||||
nodes: search.reached.size,
|
||||
edges: [...search.reached.keys()].reduce(
|
||||
(count, node) =>
|
||||
count + (graph.hasNode(node) ? graph.outDegree(node) : 0),
|
||||
0,
|
||||
),
|
||||
depth_reached: Math.max(0, ...search.reached.values()),
|
||||
},
|
||||
paths: {
|
||||
items,
|
||||
offset: parsed.offset,
|
||||
limit: parsed.limit,
|
||||
total: capped ? null : total,
|
||||
returned: items.length,
|
||||
truncated: capped,
|
||||
lower_bound: total + (hasKnownExtraPath ? 1 : 0),
|
||||
next_offset:
|
||||
parsed.offset + items.length < total
|
||||
? parsed.offset + items.length
|
||||
: null,
|
||||
},
|
||||
explored: summarizeSearch(graph, search.reached),
|
||||
evidence_links: uniqueEvidence(snapshots.values()),
|
||||
limitations: [...new Set(limitations)].sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
};
|
||||
const searchScope = {
|
||||
max_depth: parsed.max_depth,
|
||||
max_paths: parsed.max_paths,
|
||||
};
|
||||
const pathPage = {
|
||||
items,
|
||||
offset: parsed.offset,
|
||||
limit: parsed.limit,
|
||||
returned: items.length,
|
||||
lower_bound: total + (hasKnownExtraPath ? 1 : 0),
|
||||
next_offset:
|
||||
parsed.offset + items.length < total
|
||||
? parsed.offset + items.length
|
||||
: null,
|
||||
};
|
||||
if (capped)
|
||||
return callPathResultSchema.parse({
|
||||
...resultContext,
|
||||
status: "truncated",
|
||||
shortest_hops: shortestHops ?? null,
|
||||
search_scope: { ...searchScope, exhaustive: false },
|
||||
paths: { ...pathPage, total: null, truncated: true },
|
||||
});
|
||||
if (found)
|
||||
return callPathResultSchema.parse({
|
||||
...resultContext,
|
||||
status: "found",
|
||||
shortest_hops: shortestHops,
|
||||
search_scope: { ...searchScope, exhaustive },
|
||||
paths: { ...pathPage, total, truncated: false },
|
||||
});
|
||||
return callPathResultSchema.parse({
|
||||
...resultContext,
|
||||
status: exhaustive ? "not_found" : "unknown",
|
||||
shortest_hops: null,
|
||||
search_scope: { ...searchScope, exhaustive },
|
||||
paths: {
|
||||
...pathPage,
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
lower_bound: 0,
|
||||
next_offset: null,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const callPathStatus = ({
|
||||
capped,
|
||||
found,
|
||||
exhaustive,
|
||||
}: {
|
||||
interface PathOutcome {
|
||||
readonly budgetExhausted: boolean;
|
||||
readonly capped: boolean;
|
||||
readonly found: boolean;
|
||||
readonly exhaustive: boolean;
|
||||
}): CallPathResult["status"] => {
|
||||
if (capped) return "truncated";
|
||||
if (found) return "found";
|
||||
return exhaustive ? "not_found" : "unknown";
|
||||
readonly shortest: readonly string[] | null;
|
||||
}
|
||||
|
||||
const deriveLimitations = (
|
||||
search: SearchState,
|
||||
outcome: PathOutcome,
|
||||
input: CallPathInput,
|
||||
): string[] => {
|
||||
const limitations = [...search.limitations];
|
||||
if (outcome.capped)
|
||||
limitations.push(
|
||||
outcome.budgetExhausted
|
||||
? `Path enumeration stopped at ${MAX_PATH_EXPANSIONS} expansions`
|
||||
: `Path enumeration stopped at max_paths=${input.max_paths}`,
|
||||
);
|
||||
if (
|
||||
!outcome.found &&
|
||||
outcome.shortest !== null &&
|
||||
outcome.shortest.length - 1 > input.max_depth
|
||||
)
|
||||
limitations.push(
|
||||
`The shortest observed path exceeds max_depth=${input.max_depth}`,
|
||||
);
|
||||
return limitations;
|
||||
};
|
||||
|
||||
const summarizeSearch = (
|
||||
graph: DirectedGraph,
|
||||
reached: ReadonlyMap<string, number>,
|
||||
) => ({
|
||||
nodes: reached.size,
|
||||
edges: [...reached.keys()].reduce(
|
||||
(count, node) => count + (graph.hasNode(node) ? graph.outDegree(node) : 0),
|
||||
0,
|
||||
),
|
||||
depth_reached: Math.max(0, ...reached.values()),
|
||||
});
|
||||
|
||||
const prepareCallGraph = (input: CallPathInput) => {
|
||||
const snapshots = parseSnapshots(input.functions);
|
||||
assertCompatible(snapshots);
|
||||
if (!snapshots.has(input.start.address))
|
||||
throw new TypeError(
|
||||
`No analyze_function Evidence was supplied for start ${input.start.address}`,
|
||||
);
|
||||
return { snapshots, graph: createGraph(snapshots) };
|
||||
};
|
||||
|
||||
const parseSnapshots = (
|
||||
groups: readonly z.infer<typeof evidenceGroupSchema>[],
|
||||
groups: readonly CallPathEvidenceGroup[],
|
||||
): Map<string, FunctionSnapshot> => {
|
||||
const snapshots = new Map<string, FunctionSnapshot>();
|
||||
for (const group of groups) {
|
||||
@@ -416,7 +383,7 @@ const enumerateAtDepth = (state: EnumerationState, remaining: number): void => {
|
||||
const citePath = (
|
||||
addresses: readonly string[],
|
||||
snapshots: ReadonlyMap<string, FunctionSnapshot>,
|
||||
): OutputPath => {
|
||||
): OutputCallPath => {
|
||||
const edges = addresses.slice(0, -1).map((source, index) => {
|
||||
const target = addresses[index + 1];
|
||||
if (target === undefined)
|
||||
@@ -463,5 +430,4 @@ const uniqueEvidence = (snapshots: Iterable<FunctionSnapshot>): string[] =>
|
||||
...new Set([...snapshots].flatMap((snapshot) => snapshotLinks(snapshot))),
|
||||
].sort((left, right) => left.localeCompare(right));
|
||||
|
||||
const normalizeAddress = (input: string): string =>
|
||||
inputAddressSchema.parse(input);
|
||||
const normalizeAddress = (input: string): string => parseCallPathAddress(input);
|
||||
@@ -0,0 +1,194 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { evidenceSchema } from "./evidence.js";
|
||||
|
||||
const addressSchema = z.string().regex(/^0x(?:0|[1-9a-f][0-9a-f]*)$/u);
|
||||
const inputAddressSchema = z
|
||||
.string()
|
||||
.regex(/^0[xX][0-9a-fA-F]+$/u)
|
||||
.transform((address) => `0x${BigInt(address).toString(16)}`);
|
||||
|
||||
/** Parse a caller-supplied hexadecimal address into canonical form. */
|
||||
export const parseCallPathAddress = (input: unknown): string =>
|
||||
inputAddressSchema.parse(input);
|
||||
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
|
||||
const evidenceGroupSchema = z.union([
|
||||
evidenceSchema,
|
||||
z.array(evidenceSchema).min(1).max(100),
|
||||
]);
|
||||
|
||||
/** Strict, bounded input for explicit call-path reconstruction. */
|
||||
export const callPathInputSchema = z
|
||||
.object({
|
||||
functions: z.array(evidenceGroupSchema).min(1).max(500),
|
||||
start: z.object({ address: inputAddressSchema }).strict(),
|
||||
goal: z.object({ address: inputAddressSchema }).strict(),
|
||||
max_depth: z.number().int().min(0).max(32).default(8),
|
||||
max_paths: z.number().int().min(1).max(100).default(10),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1).max(100).default(100),
|
||||
unknown_registry_approved: z.literal(true).optional(),
|
||||
})
|
||||
.superRefine(({ functions }, context) => {
|
||||
const pages = functions.reduce(
|
||||
(total, group) => total + (Array.isArray(group) ? group.length : 1),
|
||||
0,
|
||||
);
|
||||
if (pages > 2_000)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Call-path Evidence exceeds 2000 pages",
|
||||
path: ["functions"],
|
||||
});
|
||||
});
|
||||
|
||||
const citedNodeSchema = z.object({
|
||||
address: addressSchema,
|
||||
name: z.string().nullable(),
|
||||
evidence_links: z.array(evidenceIdSchema).min(1).max(100),
|
||||
});
|
||||
const citedEdgeSchema = z.object({
|
||||
source: addressSchema,
|
||||
target: addressSchema,
|
||||
evidence_links: z.array(evidenceIdSchema).min(1).max(100),
|
||||
});
|
||||
const pathSchema = z
|
||||
.object({
|
||||
hops: z.number().int().min(0),
|
||||
nodes: z.array(citedNodeSchema).min(1).max(33),
|
||||
edges: z.array(citedEdgeSchema).max(32),
|
||||
evidence_links: z.array(evidenceIdSchema).min(1).max(3_300),
|
||||
})
|
||||
.superRefine((path, context) => {
|
||||
const edgesConnectNodes = path.edges.every(
|
||||
(edge, index) =>
|
||||
edge.source === path.nodes[index]?.address &&
|
||||
edge.target === path.nodes[index + 1]?.address,
|
||||
);
|
||||
if (
|
||||
path.hops !== path.edges.length ||
|
||||
path.nodes.length !== path.edges.length + 1 ||
|
||||
!edgesConnectNodes
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Path hops and edges must connect the ordered nodes",
|
||||
});
|
||||
});
|
||||
const searchScopeShape = {
|
||||
max_depth: z.number().int().min(0).max(32),
|
||||
max_paths: z.number().int().min(1).max(100),
|
||||
};
|
||||
const pathPageShape = {
|
||||
offset: z.number().int().min(0),
|
||||
limit: z.number().int().min(1).max(100),
|
||||
};
|
||||
const pageContextShape = {
|
||||
...pathPageShape,
|
||||
items: z.array(pathSchema).max(100),
|
||||
returned: z.number().int().min(0).max(100),
|
||||
next_offset: z.number().int().min(0).nullable(),
|
||||
};
|
||||
const completePathPageSchema = z
|
||||
.object({
|
||||
...pageContextShape,
|
||||
total: z.number().int().min(1),
|
||||
truncated: z.literal(false),
|
||||
lower_bound: z.number().int().min(1),
|
||||
})
|
||||
.superRefine((page, context) => {
|
||||
const expectedNext =
|
||||
page.offset + page.items.length < page.total
|
||||
? page.offset + page.items.length
|
||||
: null;
|
||||
if (
|
||||
page.returned !== page.items.length ||
|
||||
page.lower_bound !== page.total ||
|
||||
page.next_offset !== expectedNext
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Complete path page counters are inconsistent",
|
||||
});
|
||||
});
|
||||
const emptyPathPageSchema = z.object({
|
||||
...pathPageShape,
|
||||
items: z.tuple([]),
|
||||
total: z.literal(0),
|
||||
returned: z.literal(0),
|
||||
truncated: z.literal(false),
|
||||
lower_bound: z.literal(0),
|
||||
next_offset: z.null(),
|
||||
});
|
||||
const truncatedPathPageSchema = z
|
||||
.object({
|
||||
...pageContextShape,
|
||||
total: z.null(),
|
||||
truncated: z.literal(true),
|
||||
lower_bound: z.number().int().min(0),
|
||||
})
|
||||
.superRefine((page, context) => {
|
||||
if (page.returned !== page.items.length)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Returned path count must equal retained items",
|
||||
path: ["returned"],
|
||||
});
|
||||
});
|
||||
const resultContextShape = {
|
||||
start: addressSchema,
|
||||
goal: addressSchema,
|
||||
explored: z.object({
|
||||
nodes: z.number().int().min(0),
|
||||
edges: z.number().int().min(0),
|
||||
depth_reached: z.number().int().min(0),
|
||||
}),
|
||||
evidence_links: z.array(evidenceIdSchema).min(1).max(50_000),
|
||||
limitations: z.array(z.string()),
|
||||
};
|
||||
|
||||
/** Evidence-cited, bounded directed call-path result. */
|
||||
export const callPathResultSchema = z.union([
|
||||
z.object({
|
||||
...resultContextShape,
|
||||
status: z.literal("found"),
|
||||
shortest_hops: z.number().int().min(0),
|
||||
search_scope: z.object({ ...searchScopeShape, exhaustive: z.boolean() }),
|
||||
paths: completePathPageSchema,
|
||||
}),
|
||||
z.object({
|
||||
...resultContextShape,
|
||||
status: z.literal("not_found"),
|
||||
shortest_hops: z.null(),
|
||||
search_scope: z.object({
|
||||
...searchScopeShape,
|
||||
exhaustive: z.literal(true),
|
||||
}),
|
||||
paths: emptyPathPageSchema,
|
||||
}),
|
||||
z.object({
|
||||
...resultContextShape,
|
||||
status: z.literal("unknown"),
|
||||
shortest_hops: z.null(),
|
||||
search_scope: z.object({
|
||||
...searchScopeShape,
|
||||
exhaustive: z.literal(false),
|
||||
}),
|
||||
paths: emptyPathPageSchema,
|
||||
}),
|
||||
z.object({
|
||||
...resultContextShape,
|
||||
status: z.literal("truncated"),
|
||||
shortest_hops: z.number().int().min(0).nullable(),
|
||||
search_scope: z.object({
|
||||
...searchScopeShape,
|
||||
exhaustive: z.literal(false),
|
||||
}),
|
||||
paths: truncatedPathPageSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
export type CallPathEvidenceGroup = z.infer<typeof evidenceGroupSchema>;
|
||||
export type CallPathInput = z.infer<typeof callPathInputSchema>;
|
||||
export type CallPathResult = z.infer<typeof callPathResultSchema>;
|
||||
export type OutputCallPath = z.infer<typeof pathSchema>;
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
changedBehaviorResultSchema,
|
||||
findChangedBehavior,
|
||||
type ChangedBehaviorResult,
|
||||
} from "./changedBehavior.js";
|
||||
import { createEvidence, type Evidence } from "./evidence.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
@@ -123,6 +124,15 @@ const artifactResult = (
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
const expectInvalidSummary = (result: ChangedBehaviorResult): void => {
|
||||
expect(
|
||||
changedBehaviorResultSchema.safeParse({
|
||||
...result,
|
||||
summary: { ...result.summary, observed_changes: 0 },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
};
|
||||
|
||||
describe("changed behavior", () => {
|
||||
it("accepts declared-trace process comparison Evidence v4", () => {
|
||||
const result = findChangedBehavior(
|
||||
@@ -169,6 +179,7 @@ describe("changed behavior", () => {
|
||||
summary: { observed_changes: 1, static_candidates: 0 },
|
||||
findings: { total: 1, next_offset: null },
|
||||
});
|
||||
expectInvalidSummary(result);
|
||||
expect(result.findings.items[0]).toMatchObject({
|
||||
scope: "runtime",
|
||||
dimension: "terminal",
|
||||
|
||||
@@ -53,30 +53,74 @@ const findingSchema = z.object({
|
||||
});
|
||||
|
||||
/** Truthful aggregate: runtime observations remain separate from static candidates. */
|
||||
export const changedBehaviorResultSchema = z.object({
|
||||
behavior_status: z.enum([
|
||||
"observed_changed",
|
||||
"observed_unchanged",
|
||||
"unknown",
|
||||
"truncated",
|
||||
]),
|
||||
summary: z.object({
|
||||
observed_changes: z.number().int().min(0),
|
||||
static_candidates: z.number().int().min(0),
|
||||
contradictions: z.number().int().min(0),
|
||||
unresolved: z.number().int().min(0),
|
||||
}),
|
||||
findings: z.object({
|
||||
items: z.array(findingSchema).max(100),
|
||||
offset: z.number().int().min(0),
|
||||
limit: z.number().int().min(1).max(100),
|
||||
total: z.number().int().min(0),
|
||||
next_offset: z.number().int().min(0).nullable(),
|
||||
}),
|
||||
evidence_links: z.array(evidenceIdSchema).min(3).max(20_100),
|
||||
limitations: z.array(z.string()),
|
||||
investigation_run: investigationRunSummarySchema.optional(),
|
||||
});
|
||||
export const changedBehaviorResultSchema = z
|
||||
.object({
|
||||
behavior_status: z.enum([
|
||||
"observed_changed",
|
||||
"observed_unchanged",
|
||||
"unknown",
|
||||
"truncated",
|
||||
]),
|
||||
summary: z.object({
|
||||
observed_changes: z.number().int().min(0),
|
||||
static_candidates: z.number().int().min(0),
|
||||
contradictions: z.number().int().min(0),
|
||||
unresolved: z.number().int().min(0),
|
||||
}),
|
||||
findings: z.object({
|
||||
items: z.array(findingSchema).max(100),
|
||||
offset: z.number().int().min(0),
|
||||
limit: z.number().int().min(1).max(100),
|
||||
total: z.number().int().min(0),
|
||||
next_offset: z.number().int().min(0).nullable(),
|
||||
}),
|
||||
evidence_links: z.array(evidenceIdSchema).min(3).max(20_100),
|
||||
limitations: z.array(z.string()),
|
||||
investigation_run: investigationRunSummarySchema.optional(),
|
||||
})
|
||||
.superRefine((result, context) => {
|
||||
const summaryTotal = Object.values(result.summary).reduce(
|
||||
(total, count) => total + count,
|
||||
0,
|
||||
);
|
||||
const expectedNextOffset =
|
||||
result.findings.offset + result.findings.items.length <
|
||||
result.findings.total
|
||||
? result.findings.offset + result.findings.items.length
|
||||
: null;
|
||||
if (summaryTotal !== result.findings.total)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Finding classification counts must equal the finding total",
|
||||
path: ["summary"],
|
||||
});
|
||||
if (result.findings.next_offset !== expectedNextOffset)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Finding cursor must identify the next retained offset",
|
||||
path: ["findings", "next_offset"],
|
||||
});
|
||||
if (
|
||||
result.findings.offset < result.findings.total &&
|
||||
result.findings.items.length === 0
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A nonterminal finding page must retain at least one item",
|
||||
path: ["findings", "items"],
|
||||
});
|
||||
if (
|
||||
(result.behavior_status === "observed_changed" &&
|
||||
result.summary.observed_changes === 0) ||
|
||||
(result.behavior_status === "observed_unchanged" &&
|
||||
result.summary.observed_changes > 0)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Observed-change status must agree with runtime findings",
|
||||
path: ["behavior_status"],
|
||||
});
|
||||
});
|
||||
|
||||
export type ChangedBehaviorResult = z.infer<typeof changedBehaviorResultSchema>;
|
||||
type Finding = z.infer<typeof findingSchema>;
|
||||
|
||||
@@ -17,25 +17,59 @@ export const scenarioReplayResultSchema = z.strictObject({
|
||||
});
|
||||
|
||||
/** Result of replaying an entire conformance package. */
|
||||
export const packageReplayResultSchema = z.strictObject({
|
||||
package_id: z.string().min(1),
|
||||
total_scenarios: z.number().int().positive(),
|
||||
passed: z.number().int().nonnegative(),
|
||||
failed: z.number().int().nonnegative(),
|
||||
errored: z.number().int().nonnegative(),
|
||||
skipped: z.number().int().nonnegative(),
|
||||
scenario_results: z.array(scenarioReplayResultSchema),
|
||||
trust_gate_results: z.array(trustGateResultSchema),
|
||||
drift_detected: z.boolean(),
|
||||
first_drift: z
|
||||
.strictObject({
|
||||
scenario_id: z.string().min(1),
|
||||
dimension: z.string().min(1),
|
||||
evidence_id: z.string().min(1),
|
||||
message: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
export const packageReplayResultSchema = z
|
||||
.strictObject({
|
||||
package_id: z.string().min(1),
|
||||
total_scenarios: z.number().int().positive(),
|
||||
passed: z.number().int().nonnegative(),
|
||||
failed: z.number().int().nonnegative(),
|
||||
errored: z.number().int().nonnegative(),
|
||||
skipped: z.number().int().nonnegative(),
|
||||
scenario_results: z.array(scenarioReplayResultSchema),
|
||||
trust_gate_results: z.array(trustGateResultSchema),
|
||||
drift_detected: z.boolean(),
|
||||
first_drift: z
|
||||
.strictObject({
|
||||
scenario_id: z.string().min(1),
|
||||
dimension: z.string().min(1),
|
||||
evidence_id: z.string().min(1),
|
||||
message: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
.superRefine((result, context) => {
|
||||
const count = (status: ScenarioReplayResult["status"]): number =>
|
||||
result.scenario_results.filter((item) => item.status === status).length;
|
||||
const countMismatch =
|
||||
result.passed !== count("pass") ||
|
||||
result.failed !== count("fail") ||
|
||||
result.errored !== count("error") ||
|
||||
result.skipped !== count("skipped");
|
||||
const driftDetected = result.trust_gate_results.some(
|
||||
({ verdict }) => verdict === "fail",
|
||||
);
|
||||
if (
|
||||
result.total_scenarios !== result.scenario_results.length ||
|
||||
countMismatch
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Scenario totals must summarize scenario results",
|
||||
path: ["total_scenarios"],
|
||||
});
|
||||
if (result.drift_detected !== driftDetected)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Drift status must summarize trust-gate failures",
|
||||
path: ["drift_detected"],
|
||||
});
|
||||
if (!driftDetected && result.first_drift !== null)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A drift witness requires a failed trust gate",
|
||||
path: ["first_drift"],
|
||||
});
|
||||
});
|
||||
|
||||
export type ScenarioReplayResult = z.infer<typeof scenarioReplayResultSchema>;
|
||||
export type PackageReplayResult = z.infer<typeof packageReplayResultSchema>;
|
||||
@@ -120,7 +154,7 @@ export async function replayConformancePackage(
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
return packageReplayResultSchema.parse({
|
||||
package_id: pkg.package_id,
|
||||
total_scenarios: pkg.scenarios.length,
|
||||
passed,
|
||||
@@ -131,5 +165,5 @@ export async function replayConformancePackage(
|
||||
trust_gate_results: trustGateResults,
|
||||
drift_detected,
|
||||
first_drift,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -13,20 +13,30 @@ export const dimensionResultSchema = z.strictObject({
|
||||
evidence_ids: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
/** Result of evaluating a trust gate for one scenario. */
|
||||
export const trustGateResultSchema = z.strictObject({
|
||||
const trustGateResultShape = {
|
||||
scenario_id: z.string().min(1),
|
||||
verdict: z.enum(["pass", "fail", "unknown"]),
|
||||
dimension_results: z.array(dimensionResultSchema),
|
||||
first_divergence: z
|
||||
.strictObject({
|
||||
dimension: z.string().min(1),
|
||||
evidence_id: z.string().min(1),
|
||||
message: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
};
|
||||
const divergenceSchema = z.strictObject({
|
||||
dimension: z.string().min(1),
|
||||
evidence_id: z.string().min(1),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
/** Result of evaluating a trust gate for one scenario. */
|
||||
export const trustGateResultSchema = z.union([
|
||||
z.strictObject({
|
||||
...trustGateResultShape,
|
||||
verdict: z.enum(["pass", "unknown"]),
|
||||
first_divergence: z.null(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...trustGateResultShape,
|
||||
verdict: z.literal("fail"),
|
||||
first_divergence: divergenceSchema.nullable(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type DimensionResult = z.infer<typeof dimensionResultSchema>;
|
||||
export type TrustGateResult = z.infer<typeof trustGateResultSchema>;
|
||||
|
||||
@@ -366,12 +376,12 @@ export function evaluateTrustGate(
|
||||
? "unknown"
|
||||
: "pass";
|
||||
|
||||
return {
|
||||
return trustGateResultSchema.parse({
|
||||
scenario_id: contract.scenario_id,
|
||||
verdict,
|
||||
dimension_results: dimensionResults,
|
||||
first_divergence: firstDivergence,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -145,6 +145,7 @@ describe("function comparison", () => {
|
||||
FUNCTION_COMPARISON_EXAMPLE.left.evidence_id,
|
||||
FUNCTION_COMPARISON_EXAMPLE.right.evidence_id,
|
||||
]);
|
||||
expectFunctionDimensionAlgebra(result);
|
||||
});
|
||||
|
||||
it("normalizes CFG relocation but preserves changed edges and constants", () => {
|
||||
@@ -272,3 +273,28 @@ describe("function comparison", () => {
|
||||
expect(pseudocode?.left_digest).not.toBe(pseudocode?.right_digest);
|
||||
});
|
||||
});
|
||||
|
||||
const expectFunctionDimensionAlgebra = (
|
||||
result: ReturnType<typeof compareFunctions>,
|
||||
): void => {
|
||||
const observed = result.dimensions.find(
|
||||
({ status }) => status === "unchanged" || status === "changed",
|
||||
);
|
||||
expect(observed).toBeDefined();
|
||||
if (observed === undefined) return;
|
||||
expect(
|
||||
functionComparisonResultSchema.safeParse({
|
||||
...result,
|
||||
dimensions: [
|
||||
{ ...observed, left_digest: null, right_digest: null },
|
||||
...result.dimensions.slice(1),
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
functionComparisonResultSchema.safeParse({
|
||||
...result,
|
||||
function_match: { ...result.function_match, method: "explicit" },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
};
|
||||
@@ -23,21 +23,31 @@ export interface DimensionResultInput {
|
||||
|
||||
export const dimensionResult = (
|
||||
input: DimensionResultInput,
|
||||
): FunctionDimension => ({
|
||||
dimension: input.dimension,
|
||||
status: input.status,
|
||||
left_digest: digest(input.left),
|
||||
right_digest: digest(input.right),
|
||||
left_count: input.leftCount,
|
||||
right_count: input.rightCount,
|
||||
text_delta: input.textDelta,
|
||||
conclusion_kind:
|
||||
input.status === "changed" && input.providersDiffer
|
||||
): FunctionDimension => {
|
||||
const context = {
|
||||
dimension: input.dimension,
|
||||
left_digest: digest(input.left),
|
||||
right_digest: digest(input.right),
|
||||
left_count: input.leftCount,
|
||||
right_count: input.rightCount,
|
||||
text_delta: input.textDelta,
|
||||
evidence_links: [...input.links],
|
||||
limitations: [...input.limitations],
|
||||
};
|
||||
if (input.status === "unchanged")
|
||||
return {
|
||||
...context,
|
||||
status: "unchanged",
|
||||
conclusion_kind: "derived_relationship",
|
||||
};
|
||||
return {
|
||||
...context,
|
||||
status: "changed",
|
||||
conclusion_kind: input.providersDiffer
|
||||
? "contradiction"
|
||||
: "derived_relationship",
|
||||
evidence_links: [...input.links],
|
||||
limitations: [...input.limitations],
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const unresolvedDimension = (input: {
|
||||
readonly dimension: DimensionName;
|
||||
@@ -67,7 +77,6 @@ export const overallStatus = (
|
||||
return "truncated";
|
||||
if (
|
||||
match === "ambiguous" ||
|
||||
match === "unknown" ||
|
||||
dimensions.some(({ status }) => status === "unknown")
|
||||
)
|
||||
return "unknown";
|
||||
|
||||
@@ -2,8 +2,6 @@ import { z } from "zod";
|
||||
|
||||
const comparisonStatusSchema = z.enum([
|
||||
"unchanged",
|
||||
"added",
|
||||
"removed",
|
||||
"changed",
|
||||
"truncated",
|
||||
"unknown",
|
||||
@@ -36,32 +34,60 @@ const textDeltaSchema = z.object({
|
||||
hunks: z.number().int().min(0),
|
||||
});
|
||||
|
||||
const functionDimensionSchema = z.object({
|
||||
const functionDimensionContextShape = {
|
||||
dimension: dimensionNameSchema,
|
||||
status: comparisonStatusSchema,
|
||||
left_digest: digestSchema.nullable(),
|
||||
right_digest: digestSchema.nullable(),
|
||||
left_count: z.number().int().min(0).nullable(),
|
||||
right_count: z.number().int().min(0).nullable(),
|
||||
text_delta: textDeltaSchema.nullable(),
|
||||
conclusion_kind: z.enum([
|
||||
"derived_relationship",
|
||||
"contradiction",
|
||||
"unresolved_branch",
|
||||
]),
|
||||
evidence_links: z.array(evidenceIdSchema).min(2).max(200),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
};
|
||||
|
||||
const functionDimensionSchema = z.union([
|
||||
z.object({
|
||||
...functionDimensionContextShape,
|
||||
status: z.literal("unchanged"),
|
||||
left_digest: digestSchema,
|
||||
right_digest: digestSchema,
|
||||
text_delta: textDeltaSchema.nullable(),
|
||||
conclusion_kind: z.literal("derived_relationship"),
|
||||
}),
|
||||
z.object({
|
||||
...functionDimensionContextShape,
|
||||
status: z.literal("changed"),
|
||||
left_digest: digestSchema,
|
||||
right_digest: digestSchema,
|
||||
text_delta: textDeltaSchema.nullable(),
|
||||
conclusion_kind: z.enum(["derived_relationship", "contradiction"]),
|
||||
}),
|
||||
z.object({
|
||||
...functionDimensionContextShape,
|
||||
status: z.enum(["truncated", "unknown"]),
|
||||
left_digest: z.null(),
|
||||
right_digest: z.null(),
|
||||
text_delta: z.null(),
|
||||
conclusion_kind: z.literal("unresolved_branch"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const functionMatchSchema = z.union([
|
||||
z.object({
|
||||
status: z.literal("matched"),
|
||||
method: z.literal("symbol"),
|
||||
left_name: z.string(),
|
||||
right_name: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.enum(["mismatched", "ambiguous"]),
|
||||
method: z.literal("explicit"),
|
||||
left_name: z.string(),
|
||||
right_name: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Deterministic dimension-classified function comparison Evidence payload. */
|
||||
export const functionComparisonResultSchema = z.object({
|
||||
status: comparisonStatusSchema,
|
||||
function_match: z.object({
|
||||
status: z.enum(["matched", "mismatched", "ambiguous", "unknown"]),
|
||||
method: z.enum(["explicit", "symbol"]),
|
||||
left_name: z.string(),
|
||||
right_name: z.string(),
|
||||
}),
|
||||
function_match: functionMatchSchema,
|
||||
left_subject_sha256: digestSchema,
|
||||
right_subject_sha256: digestSchema,
|
||||
summary: z.object({
|
||||
|
||||
@@ -96,38 +96,80 @@ const targetSchema = z.object({
|
||||
|
||||
export type InvestigationRunTarget = z.infer<typeof targetSchema>;
|
||||
|
||||
const stageSchema = z.enum([
|
||||
"inventory_left",
|
||||
"inventory_right",
|
||||
"compare_artifacts",
|
||||
"find_changed_behavior",
|
||||
]);
|
||||
|
||||
const investigationRunBaseSchema = z.object({
|
||||
const investigationRunIdentityShape = {
|
||||
schema_version: z.literal(1),
|
||||
run_id: z.string().regex(/^run_[a-f0-9]{64}$/u),
|
||||
request_sha256: digestSchema,
|
||||
left: targetSchema,
|
||||
right: targetSchema,
|
||||
options: investigationRunOptionsSchema,
|
||||
left_inventory_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
|
||||
right_inventory_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
|
||||
limitations: z.array(z.string().max(4_096)).max(100),
|
||||
};
|
||||
const investigationRunContextShape = {
|
||||
...investigationRunIdentityShape,
|
||||
integrity_policy: z.enum(["fail", "record-and-continue"]),
|
||||
integrity_continue_approved: z.boolean(),
|
||||
max_integrity_mismatches: z.number().int().min(1).max(100),
|
||||
status: z.enum(["running", "complete"]),
|
||||
completed_stages: z.array(stageSchema).max(4),
|
||||
left_inventory_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
|
||||
right_inventory_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
|
||||
comparison_evidence_id: evidenceIdSchema.nullable(),
|
||||
result_evidence_id: evidenceIdSchema.nullable(),
|
||||
limitations: z.array(z.string().max(4_096)).max(100),
|
||||
});
|
||||
};
|
||||
const inventoryRunStateShape = {
|
||||
status: z.literal("running"),
|
||||
completed_stages: z.tuple([
|
||||
z.literal("inventory_left"),
|
||||
z.literal("inventory_right"),
|
||||
]),
|
||||
comparison_evidence_id: z.null(),
|
||||
result_evidence_id: z.null(),
|
||||
};
|
||||
const comparisonRunStateShape = {
|
||||
status: z.literal("running"),
|
||||
completed_stages: z.tuple([
|
||||
z.literal("inventory_left"),
|
||||
z.literal("inventory_right"),
|
||||
z.literal("compare_artifacts"),
|
||||
]),
|
||||
comparison_evidence_id: evidenceIdSchema,
|
||||
result_evidence_id: z.null(),
|
||||
};
|
||||
const completedRunStateShape = {
|
||||
status: z.literal("complete"),
|
||||
completed_stages: z.tuple([
|
||||
z.literal("inventory_left"),
|
||||
z.literal("inventory_right"),
|
||||
z.literal("compare_artifacts"),
|
||||
z.literal("find_changed_behavior"),
|
||||
]),
|
||||
comparison_evidence_id: evidenceIdSchema,
|
||||
result_evidence_id: evidenceIdSchema,
|
||||
};
|
||||
const investigationRunStateSchemas = [
|
||||
z.object({ ...investigationRunContextShape, ...inventoryRunStateShape }),
|
||||
z.object({ ...investigationRunContextShape, ...comparisonRunStateShape }),
|
||||
z.object({ ...investigationRunContextShape, ...completedRunStateShape }),
|
||||
] as const;
|
||||
const legacyIdentityRunStateSchemas = [
|
||||
z.object({
|
||||
...investigationRunContextShape,
|
||||
...inventoryRunStateShape,
|
||||
legacy_request_identity: z.literal(true),
|
||||
}),
|
||||
z.object({
|
||||
...investigationRunContextShape,
|
||||
...comparisonRunStateShape,
|
||||
legacy_request_identity: z.literal(true),
|
||||
}),
|
||||
z.object({
|
||||
...investigationRunContextShape,
|
||||
...completedRunStateShape,
|
||||
legacy_request_identity: z.literal(true),
|
||||
}),
|
||||
] as const;
|
||||
|
||||
export const investigationRunSchema = z
|
||||
.union([
|
||||
investigationRunBaseSchema.extend({
|
||||
legacy_request_identity: z.literal(true),
|
||||
}),
|
||||
investigationRunBaseSchema,
|
||||
z.union(legacyIdentityRunStateSchemas),
|
||||
z.union(investigationRunStateSchemas),
|
||||
])
|
||||
.superRefine((run, context) => {
|
||||
if (!("legacy_request_identity" in run)) return;
|
||||
@@ -175,13 +217,20 @@ export const investigationWorkspaceSchema = z.object({
|
||||
runs: z.array(investigationRunSchema).max(1_000),
|
||||
});
|
||||
|
||||
const legacyInvestigationRunSchema = investigationRunBaseSchema
|
||||
.omit({
|
||||
integrity_policy: true,
|
||||
integrity_continue_approved: true,
|
||||
max_integrity_mismatches: true,
|
||||
})
|
||||
.strict();
|
||||
const legacyInvestigationRunSchema = z.union([
|
||||
z.strictObject({
|
||||
...investigationRunIdentityShape,
|
||||
...inventoryRunStateShape,
|
||||
}),
|
||||
z.strictObject({
|
||||
...investigationRunIdentityShape,
|
||||
...comparisonRunStateShape,
|
||||
}),
|
||||
z.strictObject({
|
||||
...investigationRunIdentityShape,
|
||||
...completedRunStateShape,
|
||||
}),
|
||||
]);
|
||||
|
||||
const legacyInvestigationWorkspaceSchema = investigationWorkspaceSchema
|
||||
.extend({
|
||||
@@ -324,7 +373,6 @@ const validateRunReferences = (workspace: InvestigationWorkspace): void => {
|
||||
run.request_sha256 !== legacyIdentity.requestSha256)
|
||||
)
|
||||
throw new TypeError("Investigation run identity is invalid");
|
||||
validateRunStages(run);
|
||||
const references = [
|
||||
...run.left_inventory_evidence_ids,
|
||||
...run.right_inventory_evidence_ids,
|
||||
@@ -417,21 +465,6 @@ const migrateLegacyWorkspace = (input: unknown): InvestigationWorkspace => {
|
||||
return migrated;
|
||||
};
|
||||
|
||||
const validateRunStages = (run: InvestigationRun): void => {
|
||||
const expected = [
|
||||
"inventory_left",
|
||||
"inventory_right",
|
||||
...(run.comparison_evidence_id === null ? [] : ["compare_artifacts"]),
|
||||
...(run.result_evidence_id === null ? [] : ["find_changed_behavior"]),
|
||||
];
|
||||
if (JSON.stringify(run.completed_stages) !== JSON.stringify(expected))
|
||||
throw new TypeError("Investigation run stages are inconsistent");
|
||||
if (run.status === "complete" && run.result_evidence_id === null)
|
||||
throw new TypeError("Completed investigation run has no result Evidence");
|
||||
if (run.status === "running" && run.result_evidence_id !== null)
|
||||
throw new TypeError("Running investigation run has final result Evidence");
|
||||
};
|
||||
|
||||
const workspaceIdFor = (name: string): string =>
|
||||
`ws_${digestCanonical({ schema: "rea.workspace/v1", name })}`;
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export const compareJavaScriptApplicationVersions = (
|
||||
omitted_graph_edges: changeGraph.omittedEdges,
|
||||
omitted_graph_observations: changeGraph.omittedObservations,
|
||||
};
|
||||
const coverage = comparisonCoverage(input, omissions);
|
||||
const semantic = {
|
||||
schema_version: 1 as const,
|
||||
left: {
|
||||
@@ -85,12 +86,7 @@ export const compareJavaScriptApplicationVersions = (
|
||||
matching: matchingSummary(projection.items),
|
||||
items,
|
||||
graph: changeGraph.graph,
|
||||
coverage: {
|
||||
status: comparisonCoverageStatus(input, omissions),
|
||||
left_graph_status: input.left.graph.coverage.status,
|
||||
right_graph_status: input.right.graph.coverage.status,
|
||||
...omissions,
|
||||
},
|
||||
coverage,
|
||||
evidence_links: evidenceLinks,
|
||||
limitations: comparisonLimitations(input, omissions),
|
||||
};
|
||||
@@ -134,27 +130,52 @@ const countBasis = (
|
||||
): number => items.filter(({ match }) => match.basis === basis).length;
|
||||
|
||||
type ComparisonOmissions = ApplicationVersionComparisonResult["coverage"];
|
||||
type ComparisonOmissionCounts = Pick<
|
||||
ComparisonOmissions,
|
||||
| "omitted_comparison_items"
|
||||
| "omitted_candidate_references"
|
||||
| "omitted_graph_nodes"
|
||||
| "omitted_graph_edges"
|
||||
| "omitted_graph_observations"
|
||||
>;
|
||||
|
||||
const comparisonCoverageStatus = (
|
||||
const comparisonCoverage = (
|
||||
input: ApplicationVersionComparisonProjectionInput,
|
||||
omissions: Omit<
|
||||
ComparisonOmissions,
|
||||
"status" | "left_graph_status" | "right_graph_status"
|
||||
>,
|
||||
): ApplicationVersionComparisonResult["coverage"]["status"] =>
|
||||
Object.values(omissions).some((value) => value > 0)
|
||||
? "truncated"
|
||||
: input.left.graph.coverage.status === "complete" &&
|
||||
input.right.graph.coverage.status === "complete"
|
||||
? "complete-within-inputs"
|
||||
: "partial";
|
||||
omissions: ComparisonOmissionCounts,
|
||||
): ApplicationVersionComparisonResult["coverage"] => {
|
||||
const context = {
|
||||
left_graph_status: input.left.graph.coverage.status,
|
||||
right_graph_status: input.right.graph.coverage.status,
|
||||
};
|
||||
if (Object.values(omissions).some((value) => value > 0))
|
||||
return { ...context, ...omissions, status: "truncated" };
|
||||
if (
|
||||
input.left.graph.coverage.status === "complete" &&
|
||||
input.right.graph.coverage.status === "complete"
|
||||
)
|
||||
return {
|
||||
...context,
|
||||
status: "complete-within-inputs",
|
||||
omitted_comparison_items: 0,
|
||||
omitted_candidate_references: 0,
|
||||
omitted_graph_nodes: 0,
|
||||
omitted_graph_edges: 0,
|
||||
omitted_graph_observations: 0,
|
||||
};
|
||||
return {
|
||||
...context,
|
||||
status: "partial",
|
||||
omitted_comparison_items: 0,
|
||||
omitted_candidate_references: 0,
|
||||
omitted_graph_nodes: 0,
|
||||
omitted_graph_edges: 0,
|
||||
omitted_graph_observations: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const comparisonLimitations = (
|
||||
input: ApplicationVersionComparisonProjectionInput,
|
||||
omissions: Omit<
|
||||
ComparisonOmissions,
|
||||
"status" | "left_graph_status" | "right_graph_status"
|
||||
>,
|
||||
omissions: ComparisonOmissionCounts,
|
||||
): string[] =>
|
||||
uniqueSorted([
|
||||
"Application comparison never uses bundler module ordinals as persistent identity and performs no fuzzy pairing.",
|
||||
|
||||
@@ -51,29 +51,66 @@ export const compareApplicationVersionsInputSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
const applicationVersionMatchBasisSchema = z.enum([
|
||||
"exact-node-identity",
|
||||
"exact-content-digest",
|
||||
"exact-module-source-digest",
|
||||
"source-map-identity",
|
||||
"structural-fingerprint",
|
||||
"semantic-key",
|
||||
"none",
|
||||
const emptyCandidateNodesSchema = z.tuple([]);
|
||||
const candidateNodesSchema = z
|
||||
.tuple([nodeIdSchema])
|
||||
.rest(nodeIdSchema)
|
||||
.check(z.maxLength(1_000));
|
||||
const exactMatchSchema = z.strictObject({
|
||||
status: z.literal("matched"),
|
||||
basis: z.enum([
|
||||
"exact-node-identity",
|
||||
"exact-content-digest",
|
||||
"exact-module-source-digest",
|
||||
]),
|
||||
confidence: z.literal("exact"),
|
||||
candidate_left_node_ids: emptyCandidateNodesSchema,
|
||||
candidate_right_node_ids: emptyCandidateNodesSchema,
|
||||
});
|
||||
const sourceMapMatchSchema = z.strictObject({
|
||||
status: z.literal("matched"),
|
||||
basis: z.literal("source-map-identity"),
|
||||
confidence: z.literal("high"),
|
||||
candidate_left_node_ids: emptyCandidateNodesSchema,
|
||||
candidate_right_node_ids: emptyCandidateNodesSchema,
|
||||
});
|
||||
const inferredMatchSchema = z.strictObject({
|
||||
status: z.literal("matched"),
|
||||
basis: z.enum(["structural-fingerprint", "semantic-key"]),
|
||||
confidence: z.literal("medium"),
|
||||
candidate_left_node_ids: emptyCandidateNodesSchema,
|
||||
candidate_right_node_ids: emptyCandidateNodesSchema,
|
||||
});
|
||||
const matchedItemMatchSchema = z.union([
|
||||
exactMatchSchema,
|
||||
sourceMapMatchSchema,
|
||||
inferredMatchSchema,
|
||||
]);
|
||||
const unmatchedItemMatchSchema = z.strictObject({
|
||||
status: z.literal("unmatched"),
|
||||
basis: z.literal("none"),
|
||||
confidence: z.literal("unknown"),
|
||||
candidate_left_node_ids: emptyCandidateNodesSchema,
|
||||
candidate_right_node_ids: emptyCandidateNodesSchema,
|
||||
});
|
||||
const leftAmbiguousMatchSchema = z.strictObject({
|
||||
status: z.literal("ambiguous"),
|
||||
basis: z.literal("none"),
|
||||
confidence: z.literal("unknown"),
|
||||
candidate_left_node_ids: emptyCandidateNodesSchema,
|
||||
candidate_right_node_ids: candidateNodesSchema,
|
||||
});
|
||||
const rightAmbiguousMatchSchema = z.strictObject({
|
||||
status: z.literal("ambiguous"),
|
||||
basis: z.literal("none"),
|
||||
confidence: z.literal("unknown"),
|
||||
candidate_left_node_ids: candidateNodesSchema,
|
||||
candidate_right_node_ids: emptyCandidateNodesSchema,
|
||||
});
|
||||
|
||||
const comparisonItemSchema = z.strictObject({
|
||||
const comparisonItemContextShape = {
|
||||
item_id: z.string().regex(/^javc_item_[a-f0-9]{64}$/u),
|
||||
status: z.enum(["unchanged", "added", "removed", "changed", "unknown"]),
|
||||
node_kind: z.enum(JAVASCRIPT_APPLICATION_NODE_KINDS),
|
||||
left_node_id: nodeIdSchema.nullable(),
|
||||
right_node_id: nodeIdSchema.nullable(),
|
||||
match: z.strictObject({
|
||||
status: z.enum(["matched", "unmatched", "ambiguous"]),
|
||||
basis: applicationVersionMatchBasisSchema,
|
||||
confidence: z.enum(["exact", "high", "medium", "unknown"]),
|
||||
candidate_left_node_ids: z.array(nodeIdSchema).max(1_000),
|
||||
candidate_right_node_ids: z.array(nodeIdSchema).max(1_000),
|
||||
}),
|
||||
dimensions: z
|
||||
.array(
|
||||
z.enum([
|
||||
@@ -88,64 +125,147 @@ const comparisonItemSchema = z.strictObject({
|
||||
.max(6),
|
||||
evidence_links: z.array(evidenceIdSchema).min(2).max(130),
|
||||
limitations: z.array(boundedTextSchema).max(100),
|
||||
});
|
||||
};
|
||||
|
||||
const comparisonItemSchema = z.union([
|
||||
z.strictObject({
|
||||
...comparisonItemContextShape,
|
||||
status: z.enum(["unchanged", "changed", "unknown"]),
|
||||
left_node_id: nodeIdSchema,
|
||||
right_node_id: nodeIdSchema,
|
||||
match: matchedItemMatchSchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
...comparisonItemContextShape,
|
||||
status: z.enum(["removed", "unknown"]),
|
||||
left_node_id: nodeIdSchema,
|
||||
right_node_id: z.null(),
|
||||
match: unmatchedItemMatchSchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
...comparisonItemContextShape,
|
||||
status: z.enum(["added", "unknown"]),
|
||||
left_node_id: z.null(),
|
||||
right_node_id: nodeIdSchema,
|
||||
match: unmatchedItemMatchSchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
...comparisonItemContextShape,
|
||||
status: z.literal("unknown"),
|
||||
left_node_id: nodeIdSchema,
|
||||
right_node_id: z.null(),
|
||||
match: leftAmbiguousMatchSchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
...comparisonItemContextShape,
|
||||
status: z.literal("unknown"),
|
||||
left_node_id: z.null(),
|
||||
right_node_id: nodeIdSchema,
|
||||
match: rightAmbiguousMatchSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
const comparisonCoverageContextShape = {
|
||||
left_graph_status: z.enum(["complete", "partial", "unknown", "unavailable"]),
|
||||
right_graph_status: z.enum(["complete", "partial", "unknown", "unavailable"]),
|
||||
};
|
||||
const zeroComparisonOmissionsShape = {
|
||||
omitted_comparison_items: z.literal(0),
|
||||
omitted_candidate_references: z.literal(0),
|
||||
omitted_graph_nodes: z.literal(0),
|
||||
omitted_graph_edges: z.literal(0),
|
||||
omitted_graph_observations: z.literal(0),
|
||||
};
|
||||
const comparisonOmissionsShape = {
|
||||
omitted_comparison_items: z.number().int().min(0),
|
||||
omitted_candidate_references: z.number().int().min(0),
|
||||
omitted_graph_nodes: z.number().int().min(0),
|
||||
omitted_graph_edges: z.number().int().min(0),
|
||||
omitted_graph_observations: z.number().int().min(0),
|
||||
};
|
||||
const comparisonCoverageSchema = z.union([
|
||||
z.strictObject({
|
||||
...comparisonCoverageContextShape,
|
||||
...zeroComparisonOmissionsShape,
|
||||
status: z.literal("complete-within-inputs"),
|
||||
}),
|
||||
z.strictObject({
|
||||
...comparisonCoverageContextShape,
|
||||
...zeroComparisonOmissionsShape,
|
||||
status: z.literal("partial"),
|
||||
}),
|
||||
z
|
||||
.strictObject({
|
||||
...comparisonCoverageContextShape,
|
||||
...comparisonOmissionsShape,
|
||||
status: z.literal("truncated"),
|
||||
})
|
||||
.refine(
|
||||
(coverage) =>
|
||||
[
|
||||
coverage.omitted_comparison_items,
|
||||
coverage.omitted_candidate_references,
|
||||
coverage.omitted_graph_nodes,
|
||||
coverage.omitted_graph_edges,
|
||||
coverage.omitted_graph_observations,
|
||||
].some((value) => value > 0),
|
||||
{ message: "Truncated comparison coverage must name an omission" },
|
||||
),
|
||||
]);
|
||||
|
||||
/** Tiered module/entity matching plus a bounded cross-version change graph. */
|
||||
export const applicationVersionComparisonResultSchema = z.strictObject({
|
||||
schema_version: z.literal(1),
|
||||
comparison_id: z.string().regex(/^javc_[a-f0-9]{64}$/u),
|
||||
left: z.strictObject({
|
||||
evidence_id: evidenceIdSchema,
|
||||
graph_id: z.string().regex(/^jag_[a-f0-9]{64}$/u),
|
||||
root_artifact_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
}),
|
||||
right: z.strictObject({
|
||||
evidence_id: evidenceIdSchema,
|
||||
graph_id: z.string().regex(/^jag_[a-f0-9]{64}$/u),
|
||||
root_artifact_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
}),
|
||||
summary: z.strictObject({
|
||||
unchanged: z.number().int().min(0),
|
||||
added: z.number().int().min(0),
|
||||
removed: z.number().int().min(0),
|
||||
changed: z.number().int().min(0),
|
||||
unknown: z.number().int().min(0),
|
||||
}),
|
||||
matching: z.strictObject({
|
||||
exact_node_identity: z.number().int().min(0),
|
||||
exact_content_digest: z.number().int().min(0),
|
||||
exact_module_source_digest: z.number().int().min(0),
|
||||
source_map_identity: z.number().int().min(0),
|
||||
structural_fingerprint: z.number().int().min(0),
|
||||
semantic_key: z.number().int().min(0),
|
||||
ambiguous: z.number().int().min(0),
|
||||
unmatched: z.number().int().min(0),
|
||||
}),
|
||||
items: z.array(comparisonItemSchema).max(50_000),
|
||||
graph: javascriptApplicationGraphSchema,
|
||||
coverage: z.strictObject({
|
||||
status: z.enum(["complete-within-inputs", "partial", "truncated"]),
|
||||
left_graph_status: z.enum([
|
||||
"complete",
|
||||
"partial",
|
||||
"unknown",
|
||||
"unavailable",
|
||||
]),
|
||||
right_graph_status: z.enum([
|
||||
"complete",
|
||||
"partial",
|
||||
"unknown",
|
||||
"unavailable",
|
||||
]),
|
||||
omitted_comparison_items: z.number().int().min(0),
|
||||
omitted_candidate_references: z.number().int().min(0),
|
||||
omitted_graph_nodes: z.number().int().min(0),
|
||||
omitted_graph_edges: z.number().int().min(0),
|
||||
omitted_graph_observations: z.number().int().min(0),
|
||||
}),
|
||||
evidence_links: z.array(evidenceIdSchema).min(2).max(130),
|
||||
limitations: z.array(boundedTextSchema).max(1_000),
|
||||
});
|
||||
export const applicationVersionComparisonResultSchema = z
|
||||
.strictObject({
|
||||
schema_version: z.literal(1),
|
||||
comparison_id: z.string().regex(/^javc_[a-f0-9]{64}$/u),
|
||||
left: z.strictObject({
|
||||
evidence_id: evidenceIdSchema,
|
||||
graph_id: z.string().regex(/^jag_[a-f0-9]{64}$/u),
|
||||
root_artifact_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
}),
|
||||
right: z.strictObject({
|
||||
evidence_id: evidenceIdSchema,
|
||||
graph_id: z.string().regex(/^jag_[a-f0-9]{64}$/u),
|
||||
root_artifact_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
}),
|
||||
summary: z.strictObject({
|
||||
unchanged: z.number().int().min(0),
|
||||
added: z.number().int().min(0),
|
||||
removed: z.number().int().min(0),
|
||||
changed: z.number().int().min(0),
|
||||
unknown: z.number().int().min(0),
|
||||
}),
|
||||
matching: z.strictObject({
|
||||
exact_node_identity: z.number().int().min(0),
|
||||
exact_content_digest: z.number().int().min(0),
|
||||
exact_module_source_digest: z.number().int().min(0),
|
||||
source_map_identity: z.number().int().min(0),
|
||||
structural_fingerprint: z.number().int().min(0),
|
||||
semantic_key: z.number().int().min(0),
|
||||
ambiguous: z.number().int().min(0),
|
||||
unmatched: z.number().int().min(0),
|
||||
}),
|
||||
items: z.array(comparisonItemSchema).max(50_000),
|
||||
graph: javascriptApplicationGraphSchema,
|
||||
coverage: comparisonCoverageSchema,
|
||||
evidence_links: z.array(evidenceIdSchema).min(2).max(130),
|
||||
limitations: z.array(boundedTextSchema).max(1_000),
|
||||
})
|
||||
.superRefine((result, context) => {
|
||||
const sourceGraphsComplete =
|
||||
result.coverage.left_graph_status === "complete" &&
|
||||
result.coverage.right_graph_status === "complete";
|
||||
if (
|
||||
result.coverage.status !== "truncated" &&
|
||||
(result.coverage.status === "complete-within-inputs") !==
|
||||
sourceGraphsComplete
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["coverage", "status"],
|
||||
message: "Comparison coverage must match source graph completeness",
|
||||
});
|
||||
});
|
||||
|
||||
export type CompareApplicationVersionsInput = z.infer<
|
||||
typeof compareApplicationVersionsInputSchema
|
||||
@@ -156,6 +276,3 @@ export type ApplicationVersionComparisonItem = z.infer<
|
||||
export type ApplicationVersionComparisonResult = z.infer<
|
||||
typeof applicationVersionComparisonResultSchema
|
||||
>;
|
||||
export type ApplicationVersionMatchBasis = z.infer<
|
||||
typeof applicationVersionMatchBasisSchema
|
||||
>;
|
||||
@@ -36,6 +36,27 @@ interface ItemContext {
|
||||
readonly maxCandidates: number;
|
||||
}
|
||||
|
||||
type OneSidedNode =
|
||||
| {
|
||||
readonly left: ApplicationNode;
|
||||
readonly right?: never;
|
||||
readonly candidateRightNodeIds: readonly string[];
|
||||
readonly candidateLeftNodeIds?: never;
|
||||
}
|
||||
| {
|
||||
readonly left?: never;
|
||||
readonly right: ApplicationNode;
|
||||
readonly candidateRightNodeIds?: never;
|
||||
readonly candidateLeftNodeIds: readonly string[];
|
||||
};
|
||||
|
||||
type ComparisonItemSemantic<
|
||||
Item extends
|
||||
ApplicationVersionComparisonItem = ApplicationVersionComparisonItem,
|
||||
> = Item extends ApplicationVersionComparisonItem
|
||||
? Omit<Item, "item_id">
|
||||
: never;
|
||||
|
||||
/** Classify matched, unmatched, and ambiguous nodes without inventing absence. */
|
||||
export const classifyJavaScriptApplicationVersions = (
|
||||
matching: ApplicationVersionMatchingProjection,
|
||||
@@ -64,9 +85,10 @@ export const classifyJavaScriptApplicationVersions = (
|
||||
candidates.length - context.maxCandidates,
|
||||
);
|
||||
return unmatchedItem(
|
||||
node,
|
||||
"left",
|
||||
candidates.slice(0, context.maxCandidates),
|
||||
{
|
||||
left: node,
|
||||
candidateRightNodeIds: candidates.slice(0, context.maxCandidates),
|
||||
},
|
||||
fullContext,
|
||||
);
|
||||
});
|
||||
@@ -77,9 +99,10 @@ export const classifyJavaScriptApplicationVersions = (
|
||||
candidates.length - context.maxCandidates,
|
||||
);
|
||||
return unmatchedItem(
|
||||
node,
|
||||
"right",
|
||||
candidates.slice(0, context.maxCandidates),
|
||||
{
|
||||
right: node,
|
||||
candidateLeftNodeIds: candidates.slice(0, context.maxCandidates),
|
||||
},
|
||||
fullContext,
|
||||
);
|
||||
});
|
||||
@@ -112,18 +135,12 @@ const matchedItem = (
|
||||
status === "unknown" && !dimensions.includes("coverage")
|
||||
? [...dimensions, "coverage" as const]
|
||||
: dimensions;
|
||||
const semantic = {
|
||||
const semantic: ComparisonItemSemantic = {
|
||||
status,
|
||||
node_kind: pair.left.kind,
|
||||
left_node_id: pair.left.node_id,
|
||||
right_node_id: pair.right.node_id,
|
||||
match: {
|
||||
status: "matched" as const,
|
||||
basis: pair.basis,
|
||||
confidence: pair.confidence,
|
||||
candidate_left_node_ids: [],
|
||||
candidate_right_node_ids: [],
|
||||
},
|
||||
match: matchedMatch(pair),
|
||||
dimensions: finalDimensions,
|
||||
evidence_links: itemEvidenceLinks(pair.left, pair.right, context),
|
||||
limitations: pairLimitations(pair, status),
|
||||
@@ -132,59 +149,150 @@ const matchedItem = (
|
||||
};
|
||||
|
||||
const unmatchedItem = (
|
||||
member: OneSidedNode,
|
||||
context: ItemContext,
|
||||
): ApplicationVersionComparisonItem =>
|
||||
member.left === undefined
|
||||
? rightOnlyItem(member.right, member.candidateLeftNodeIds, context)
|
||||
: leftOnlyItem(member.left, member.candidateRightNodeIds, context);
|
||||
|
||||
const leftOnlyItem = (
|
||||
node: ApplicationNode,
|
||||
side: "left" | "right",
|
||||
candidates: readonly string[],
|
||||
candidateNodeIds: readonly string[],
|
||||
context: ItemContext,
|
||||
): ApplicationVersionComparisonItem => {
|
||||
const ambiguous = candidates.length > 0;
|
||||
const oppositeComplete =
|
||||
(side === "left" ? context.rightGraph : context.leftGraph).coverage
|
||||
.status === "complete";
|
||||
const status: ApplicationVersionComparisonItem["status"] = ambiguous
|
||||
? "unknown"
|
||||
: oppositeComplete
|
||||
? side === "left"
|
||||
? "removed"
|
||||
: "added"
|
||||
: "unknown";
|
||||
const semantic = {
|
||||
status,
|
||||
const [firstCandidate, ...remainingCandidates] = candidateNodeIds;
|
||||
const common = {
|
||||
node_kind: node.kind,
|
||||
left_node_id: side === "left" ? node.node_id : null,
|
||||
right_node_id: side === "right" ? node.node_id : null,
|
||||
match: {
|
||||
status: ambiguous ? ("ambiguous" as const) : ("unmatched" as const),
|
||||
basis: "none" as const,
|
||||
confidence: "unknown" as const,
|
||||
candidate_left_node_ids: side === "right" ? [...candidates] : [],
|
||||
candidate_right_node_ids: side === "left" ? [...candidates] : [],
|
||||
},
|
||||
dimensions: [
|
||||
"availability" as const,
|
||||
...(status === "unknown" && !ambiguous ? (["coverage"] as const) : []),
|
||||
],
|
||||
evidence_links: itemEvidenceLinks(
|
||||
side === "left" ? node : undefined,
|
||||
side === "right" ? node : undefined,
|
||||
context,
|
||||
),
|
||||
limitations: [
|
||||
...(ambiguous
|
||||
? [
|
||||
"Multiple nodes share the same permitted match key; no cross-version pair was selected.",
|
||||
]
|
||||
: []),
|
||||
...(status === "unknown" && !ambiguous
|
||||
? [
|
||||
"The opposite graph is incomplete, so an unmatched node cannot be classified as added or removed.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
evidence_links: itemEvidenceLinks(node, undefined, context),
|
||||
};
|
||||
return itemWithId(semantic);
|
||||
if (firstCandidate !== undefined)
|
||||
return itemWithId({
|
||||
...common,
|
||||
status: "unknown",
|
||||
left_node_id: node.node_id,
|
||||
right_node_id: null,
|
||||
match: {
|
||||
status: "ambiguous",
|
||||
basis: "none",
|
||||
confidence: "unknown",
|
||||
candidate_left_node_ids: [],
|
||||
candidate_right_node_ids: [firstCandidate, ...remainingCandidates],
|
||||
},
|
||||
dimensions: ["availability"],
|
||||
limitations: [AMBIGUOUS_MATCH_LIMITATION],
|
||||
});
|
||||
const absenceObserved = context.rightGraph.coverage.status === "complete";
|
||||
return itemWithId({
|
||||
...common,
|
||||
status: absenceObserved ? "removed" : "unknown",
|
||||
left_node_id: node.node_id,
|
||||
right_node_id: null,
|
||||
match: unmatchedMatch(),
|
||||
dimensions: absenceObserved
|
||||
? ["availability"]
|
||||
: ["availability", "coverage"],
|
||||
limitations: absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION],
|
||||
});
|
||||
};
|
||||
|
||||
const rightOnlyItem = (
|
||||
node: ApplicationNode,
|
||||
candidateNodeIds: readonly string[],
|
||||
context: ItemContext,
|
||||
): ApplicationVersionComparisonItem => {
|
||||
const [firstCandidate, ...remainingCandidates] = candidateNodeIds;
|
||||
const common = {
|
||||
node_kind: node.kind,
|
||||
evidence_links: itemEvidenceLinks(undefined, node, context),
|
||||
};
|
||||
if (firstCandidate !== undefined)
|
||||
return itemWithId({
|
||||
...common,
|
||||
status: "unknown",
|
||||
left_node_id: null,
|
||||
right_node_id: node.node_id,
|
||||
match: {
|
||||
status: "ambiguous",
|
||||
basis: "none",
|
||||
confidence: "unknown",
|
||||
candidate_left_node_ids: [firstCandidate, ...remainingCandidates],
|
||||
candidate_right_node_ids: [],
|
||||
},
|
||||
dimensions: ["availability"],
|
||||
limitations: [AMBIGUOUS_MATCH_LIMITATION],
|
||||
});
|
||||
const absenceObserved = context.leftGraph.coverage.status === "complete";
|
||||
return itemWithId({
|
||||
...common,
|
||||
status: absenceObserved ? "added" : "unknown",
|
||||
left_node_id: null,
|
||||
right_node_id: node.node_id,
|
||||
match: unmatchedMatch(),
|
||||
dimensions: absenceObserved
|
||||
? ["availability"]
|
||||
: ["availability", "coverage"],
|
||||
limitations: absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION],
|
||||
});
|
||||
};
|
||||
|
||||
const matchedMatch = (
|
||||
pair: ApplicationVersionNodePair,
|
||||
): Extract<
|
||||
ApplicationVersionComparisonItem["match"],
|
||||
{ readonly status: "matched" }
|
||||
> => {
|
||||
const candidates: {
|
||||
readonly candidate_left_node_ids: [];
|
||||
readonly candidate_right_node_ids: [];
|
||||
} = {
|
||||
candidate_left_node_ids: [],
|
||||
candidate_right_node_ids: [],
|
||||
};
|
||||
switch (pair.basis) {
|
||||
case "exact-node-identity":
|
||||
case "exact-content-digest":
|
||||
case "exact-module-source-digest":
|
||||
return {
|
||||
status: "matched",
|
||||
basis: pair.basis,
|
||||
confidence: "exact",
|
||||
...candidates,
|
||||
};
|
||||
case "source-map-identity":
|
||||
return {
|
||||
status: "matched",
|
||||
basis: pair.basis,
|
||||
confidence: "high",
|
||||
...candidates,
|
||||
};
|
||||
case "structural-fingerprint":
|
||||
case "semantic-key":
|
||||
return {
|
||||
status: "matched",
|
||||
basis: pair.basis,
|
||||
confidence: "medium",
|
||||
...candidates,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const unmatchedMatch = (): Extract<
|
||||
ApplicationVersionComparisonItem["match"],
|
||||
{ readonly status: "unmatched" }
|
||||
> => ({
|
||||
status: "unmatched",
|
||||
basis: "none",
|
||||
confidence: "unknown",
|
||||
candidate_left_node_ids: [],
|
||||
candidate_right_node_ids: [],
|
||||
});
|
||||
|
||||
const AMBIGUOUS_MATCH_LIMITATION =
|
||||
"Multiple nodes share the same permitted match key; no cross-version pair was selected.";
|
||||
const INCOMPLETE_GRAPH_LIMITATION =
|
||||
"The opposite graph is incomplete, so an unmatched node cannot be classified as added or removed.";
|
||||
|
||||
const changedDimensions = (
|
||||
pair: ApplicationVersionNodePair,
|
||||
context: ItemContext,
|
||||
@@ -340,9 +448,9 @@ const pairLimitations = (
|
||||
: []),
|
||||
];
|
||||
|
||||
const itemWithId = (
|
||||
semantic: Omit<ApplicationVersionComparisonItem, "item_id">,
|
||||
): ApplicationVersionComparisonItem => ({
|
||||
const itemWithId = <Semantic extends ComparisonItemSemantic>(
|
||||
semantic: Semantic,
|
||||
): Semantic & { readonly item_id: string } => ({
|
||||
...semantic,
|
||||
item_id: `javc_item_${digestCanonical(semantic)}`,
|
||||
});
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import type { ApplicationNode } from "./javascriptApplicationGraph.js";
|
||||
import { compareCodePoints } from "./javascriptApplicationGraph.js";
|
||||
import type { ApplicationVersionMatchBasis } from "./javascriptApplicationVersionComparisonSchemas.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
|
||||
/** One deterministic one-to-one match between two graph versions. */
|
||||
export interface ApplicationVersionNodePair {
|
||||
type ApplicationVersionPairMatch =
|
||||
| {
|
||||
readonly basis:
|
||||
| "exact-node-identity"
|
||||
| "exact-content-digest"
|
||||
| "exact-module-source-digest";
|
||||
readonly confidence: "exact";
|
||||
}
|
||||
| {
|
||||
readonly basis: "source-map-identity";
|
||||
readonly confidence: "high";
|
||||
}
|
||||
| {
|
||||
readonly basis: "structural-fingerprint" | "semantic-key";
|
||||
readonly confidence: "medium";
|
||||
};
|
||||
|
||||
export type ApplicationVersionNodePair = {
|
||||
readonly left: ApplicationNode;
|
||||
readonly right: ApplicationNode;
|
||||
readonly basis: Exclude<ApplicationVersionMatchBasis, "none">;
|
||||
readonly confidence: "exact" | "high" | "medium";
|
||||
}
|
||||
} & ApplicationVersionPairMatch;
|
||||
|
||||
/** Matched nodes plus ambiguity candidates that were never forced into pairs. */
|
||||
export interface ApplicationVersionMatchingProjection {
|
||||
@@ -21,26 +35,35 @@ export interface ApplicationVersionMatchingProjection {
|
||||
}
|
||||
|
||||
interface MatchTier {
|
||||
readonly basis: ApplicationVersionNodePair["basis"];
|
||||
readonly confidence: ApplicationVersionNodePair["confidence"];
|
||||
readonly match: ApplicationVersionPairMatch;
|
||||
readonly key: (node: ApplicationNode) => string | null;
|
||||
}
|
||||
|
||||
const matchTiers = (): readonly MatchTier[] => [
|
||||
{ basis: "exact-content-digest", confidence: "exact", key: contentDigestKey },
|
||||
{
|
||||
basis: "exact-module-source-digest",
|
||||
confidence: "exact",
|
||||
match: { basis: "exact-content-digest", confidence: "exact" },
|
||||
key: contentDigestKey,
|
||||
},
|
||||
{
|
||||
match: { basis: "exact-module-source-digest", confidence: "exact" },
|
||||
key: moduleSourceDigestKey,
|
||||
},
|
||||
{ basis: "exact-node-identity", confidence: "exact", key: nodeIdentityKey },
|
||||
{ basis: "source-map-identity", confidence: "high", key: sourceMapKey },
|
||||
{
|
||||
basis: "structural-fingerprint",
|
||||
confidence: "medium",
|
||||
match: { basis: "exact-node-identity", confidence: "exact" },
|
||||
key: nodeIdentityKey,
|
||||
},
|
||||
{
|
||||
match: { basis: "source-map-identity", confidence: "high" },
|
||||
key: sourceMapKey,
|
||||
},
|
||||
{
|
||||
match: { basis: "structural-fingerprint", confidence: "medium" },
|
||||
key: structuralFingerprintKey,
|
||||
},
|
||||
{ basis: "semantic-key", confidence: "medium", key: semanticKey },
|
||||
{
|
||||
match: { basis: "semantic-key", confidence: "medium" },
|
||||
key: semanticKey,
|
||||
},
|
||||
];
|
||||
|
||||
/** Apply ordered, unique-only matching tiers without fuzzy pairing. */
|
||||
@@ -99,7 +122,11 @@ const applyTier = (context: TierContext): void => {
|
||||
const leftNode = leftGroup[0];
|
||||
const rightNode = rightGroup[0];
|
||||
if (leftNode === undefined || rightNode === undefined) continue;
|
||||
context.pairs.push({ left: leftNode, right: rightNode, ...context.tier });
|
||||
context.pairs.push({
|
||||
left: leftNode,
|
||||
right: rightNode,
|
||||
...context.tier.match,
|
||||
});
|
||||
context.left.delete(leftNode.node_id);
|
||||
context.right.delete(rightNode.node_id);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../contracts/processCaptureExampl
|
||||
import { parseProcessCapture, type ProcessCapture } from "./processCapture.js";
|
||||
import {
|
||||
compareProcessTraces,
|
||||
processTraceComparisonResultSchema,
|
||||
type ProcessTraceSpecification,
|
||||
} from "./processTraceComparison.js";
|
||||
const emptyCapture = parseProcessCapture(EMPTY_PROCESS_CAPTURE_EXAMPLE);
|
||||
@@ -195,6 +196,12 @@ describe("process trace comparison", () => {
|
||||
locations: [{ capture_order: 2 }, { capture_order: 1 }],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
processTraceComparisonResultSchema.safeParse({
|
||||
...comparison,
|
||||
right: { ...comparison.right, matched_variant: "invented" },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
it("enforces explicit negative not-before constraints", () => {
|
||||
const specification: ProcessTraceSpecification = {
|
||||
|
||||
@@ -26,12 +26,23 @@ const matchedEventSchema = z.strictObject({
|
||||
location: locationSchema,
|
||||
});
|
||||
|
||||
const sideResultSchema = z.strictObject({
|
||||
status: z.enum(["pass", "fail", "unknown"]),
|
||||
matched_variant: identifierSchema.nullable(),
|
||||
const sideResultShape = {
|
||||
satisfied_constraints: z.array(z.string()),
|
||||
raw_trace: z.array(matchedEventSchema),
|
||||
});
|
||||
};
|
||||
|
||||
const sideResultSchema = z.union([
|
||||
z.strictObject({
|
||||
...sideResultShape,
|
||||
status: z.literal("pass"),
|
||||
matched_variant: identifierSchema.nullable(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...sideResultShape,
|
||||
status: z.enum(["fail", "unknown"]),
|
||||
matched_variant: z.null(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const diagnosticSchema = z.strictObject({
|
||||
kind: z.enum([
|
||||
|
||||
@@ -232,6 +232,18 @@ describe("reconstruction verification", () => {
|
||||
summary: { total: 1, passed: 1, failed: 0, unknown: 0 },
|
||||
claims: { total: 1, next_offset: null },
|
||||
});
|
||||
expect(
|
||||
reconstructionVerificationResultSchema.safeParse({
|
||||
...result,
|
||||
summary: { ...result.summary, passed: 0 },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
reconstructionVerificationResultSchema.safeParse({
|
||||
...result,
|
||||
status: "unknown",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(result.claims.items[0]?.evidence_links).toEqual(
|
||||
expect.arrayContaining([
|
||||
comparison.evidence_id,
|
||||
|
||||
@@ -114,37 +114,68 @@ export const reconstructionClaimResultSchema = z.object({
|
||||
});
|
||||
|
||||
/** Evidence-cited result over every declared claim, independently paged. */
|
||||
export const reconstructionVerificationResultSchema = z.object({
|
||||
status: verificationStatusSchema,
|
||||
specification_sha256: digestSchema,
|
||||
summary: z.object({
|
||||
total: z.number().int().min(1).max(100),
|
||||
passed: z.number().int().min(0),
|
||||
failed: z.number().int().min(0),
|
||||
unknown: z.number().int().min(0),
|
||||
behavioral: z.number().int().min(0),
|
||||
structural: z.number().int().min(0),
|
||||
}),
|
||||
claims: z.object({
|
||||
items: z.array(reconstructionClaimResultSchema).max(100),
|
||||
offset: z.number().int().min(0),
|
||||
limit: z.number().int().min(1).max(100),
|
||||
total: z.number().int().min(1).max(100),
|
||||
next_offset: z.number().int().min(0).nullable(),
|
||||
}),
|
||||
recommended_probes: z
|
||||
.array(
|
||||
z.object({
|
||||
operation: z.string().min(1).max(200),
|
||||
rationale: z.string().min(1).max(1_000),
|
||||
claim_ids: z.array(claimIdSchema).min(1).max(100),
|
||||
unknown_ids: z.array(unknownIdSchema).max(100),
|
||||
}),
|
||||
)
|
||||
.max(2_000),
|
||||
evidence_links: z.array(evidenceIdSchema).min(3).max(20_100),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export const reconstructionVerificationResultSchema = z
|
||||
.object({
|
||||
status: verificationStatusSchema,
|
||||
specification_sha256: digestSchema,
|
||||
summary: z.object({
|
||||
total: z.number().int().min(1).max(100),
|
||||
passed: z.number().int().min(0),
|
||||
failed: z.number().int().min(0),
|
||||
unknown: z.number().int().min(0),
|
||||
behavioral: z.number().int().min(0),
|
||||
structural: z.number().int().min(0),
|
||||
}),
|
||||
claims: z.object({
|
||||
items: z.array(reconstructionClaimResultSchema).max(100),
|
||||
offset: z.number().int().min(0),
|
||||
limit: z.number().int().min(1).max(100),
|
||||
total: z.number().int().min(1).max(100),
|
||||
next_offset: z.number().int().min(0).nullable(),
|
||||
}),
|
||||
recommended_probes: z
|
||||
.array(
|
||||
z.object({
|
||||
operation: z.string().min(1).max(200),
|
||||
rationale: z.string().min(1).max(1_000),
|
||||
claim_ids: z.array(claimIdSchema).min(1).max(100),
|
||||
unknown_ids: z.array(unknownIdSchema).max(100),
|
||||
}),
|
||||
)
|
||||
.max(2_000),
|
||||
evidence_links: z.array(evidenceIdSchema).min(3).max(20_100),
|
||||
limitations: z.array(z.string()),
|
||||
})
|
||||
.superRefine((result, context) => {
|
||||
const { summary } = result;
|
||||
const expectedStatus =
|
||||
summary.failed > 0 ? "fail" : summary.unknown > 0 ? "unknown" : "pass";
|
||||
const issues = [
|
||||
[
|
||||
summary.total !== summary.passed + summary.failed + summary.unknown,
|
||||
"Claim status counts must equal the summary total",
|
||||
["summary", "total"],
|
||||
],
|
||||
[
|
||||
summary.total !== summary.behavioral + summary.structural,
|
||||
"Claim kind counts must equal the summary total",
|
||||
["summary", "total"],
|
||||
],
|
||||
[
|
||||
result.claims.total !== summary.total,
|
||||
"Paged claim total must equal the summary total",
|
||||
["claims", "total"],
|
||||
],
|
||||
[
|
||||
result.status !== expectedStatus,
|
||||
"Verification status must summarize claim statuses",
|
||||
["status"],
|
||||
],
|
||||
] as const;
|
||||
for (const [invalid, message, path] of issues)
|
||||
if (invalid)
|
||||
context.addIssue({ code: "custom", message, path: [...path] });
|
||||
});
|
||||
|
||||
export type ReconstructionClaim = z.infer<typeof reconstructionClaimSchema>;
|
||||
export type ReconstructionClaimResult = z.infer<
|
||||
|
||||
@@ -16,6 +16,8 @@ const HASH_B = "b".repeat(64);
|
||||
type Entry = HistoricalSourceGraphInput["entries"][number];
|
||||
type DirectoryEntry = Extract<Entry, { kind: "directory" }>;
|
||||
type FileEntry = Extract<Entry, { kind: "file" }>;
|
||||
type HashedFileEntry = Extract<FileEntry, { content_state: "hashed" }>;
|
||||
type UnavailableFileEntry = Exclude<FileEntry, HashedFileEntry>;
|
||||
type SymlinkEntry = Extract<Entry, { kind: "symlink" }>;
|
||||
|
||||
const directoryEntry = (
|
||||
@@ -28,7 +30,9 @@ const directoryEntry = (
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
const fileEntry = (overrides: Partial<FileEntry> = {}): FileEntry => ({
|
||||
const fileEntry = (
|
||||
overrides: Partial<HashedFileEntry> = {},
|
||||
): HashedFileEntry => ({
|
||||
path: "src/main.ts",
|
||||
kind: "file",
|
||||
sha256: HASH_B,
|
||||
@@ -40,6 +44,20 @@ const fileEntry = (overrides: Partial<FileEntry> = {}): FileEntry => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const unavailableFileEntry = (
|
||||
overrides: Partial<UnavailableFileEntry> = {},
|
||||
): UnavailableFileEntry => ({
|
||||
path: "src/main.ts",
|
||||
kind: "file",
|
||||
sha256: null,
|
||||
size: null,
|
||||
language: "TypeScript",
|
||||
classifications: ["source"],
|
||||
content_state: "unreadable",
|
||||
limitations: ["Permission denied"],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const symlinkEntry = (overrides: Partial<SymlinkEntry> = {}): SymlinkEntry => ({
|
||||
path: "src/package.json",
|
||||
kind: "symlink",
|
||||
@@ -91,6 +109,16 @@ describe("historical source graph", () => {
|
||||
expect(parseHistoricalSourceGraph(graph)).toEqual(graph);
|
||||
expect(parseHistoricalSourceManifest(first)).toEqual(first);
|
||||
expect(first.graph_sha256).toBe(computeHistoricalSourceGraphSha256(graph));
|
||||
expect(() =>
|
||||
createHistoricalSourceGraph({
|
||||
...graphInput(),
|
||||
entries: [
|
||||
directoryEntry(),
|
||||
{ ...fileEntry(), content_state: "unreadable" },
|
||||
symlinkEntry(),
|
||||
],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects stale roots and commits file, directory, and symlink semantics", () => {
|
||||
@@ -161,12 +189,7 @@ describe("partial historical source graphs", () => {
|
||||
it("preserves partial observations but rejects every partial complete graph", () => {
|
||||
const partial = graphInput();
|
||||
partial.inventory_state = "partial";
|
||||
partial.entries[1] = fileEntry({
|
||||
sha256: null,
|
||||
size: null,
|
||||
content_state: "unreadable",
|
||||
limitations: ["Permission denied"],
|
||||
});
|
||||
partial.entries[1] = unavailableFileEntry();
|
||||
partial.parse_failures = [
|
||||
{ path: "src/main.ts", parser: "typescript", reason: "Malformed input" },
|
||||
];
|
||||
|
||||
@@ -38,37 +38,32 @@ const entryBaseShape = {
|
||||
limitations: z.array(boundedTextSchema).max(100),
|
||||
};
|
||||
|
||||
const sourceFileSchema = z
|
||||
.strictObject({
|
||||
...entryBaseShape,
|
||||
kind: z.literal("file"),
|
||||
sha256: digestSchema.nullable(),
|
||||
const sourceFileBaseShape = {
|
||||
...entryBaseShape,
|
||||
kind: z.literal("file"),
|
||||
language: z.string().min(1).max(100).nullable(),
|
||||
};
|
||||
|
||||
const sourceFileSchema = z.union([
|
||||
z.strictObject({
|
||||
...sourceFileBaseShape,
|
||||
sha256: digestSchema,
|
||||
size: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
||||
content_state: z.literal("hashed"),
|
||||
}),
|
||||
z.strictObject({
|
||||
...sourceFileBaseShape,
|
||||
sha256: z.null(),
|
||||
size: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable(),
|
||||
language: z.string().min(1).max(100).nullable(),
|
||||
content_state: z.enum([
|
||||
"hashed",
|
||||
"redacted-secret",
|
||||
"excluded",
|
||||
"unreadable",
|
||||
"too-large",
|
||||
"unknown",
|
||||
]),
|
||||
})
|
||||
.superRefine((file, context) => {
|
||||
checkSortedUnique(file.classifications, "classifications", context);
|
||||
if ((file.content_state === "hashed") !== (file.sha256 !== null))
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Only hashed files have a content digest",
|
||||
path: ["sha256"],
|
||||
});
|
||||
if (file.content_state === "hashed" && file.size === null)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Hashed files require a byte size",
|
||||
path: ["size"],
|
||||
});
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
const sourceDirectorySchema = z.strictObject({
|
||||
...entryBaseShape,
|
||||
@@ -108,7 +103,7 @@ const sourceSymlinkSchema = z.strictObject({
|
||||
]),
|
||||
});
|
||||
|
||||
const sourceEntrySchema = z.discriminatedUnion("kind", [
|
||||
const sourceEntrySchema = z.union([
|
||||
sourceFileSchema,
|
||||
sourceDirectorySchema,
|
||||
sourceSymlinkSchema,
|
||||
|
||||
@@ -81,43 +81,58 @@ const findingSchema = z.object({
|
||||
location: sourceLocationSchema,
|
||||
});
|
||||
|
||||
const sourceMapSchema = z.object({
|
||||
const originalSourceSchema = z.object({
|
||||
source: z.string(),
|
||||
artifact: webTextArtifactSchema.nullable(),
|
||||
});
|
||||
const originalModuleEdgeSchema = z.object({
|
||||
from_source: z.string(),
|
||||
kind: z.enum(["static_import", "dynamic_import", "require"]),
|
||||
specifier: z.string(),
|
||||
resolved_source: z.string().nullable(),
|
||||
});
|
||||
const sourceMapMappingSchema = z.object({
|
||||
generated_line: z.number().int().min(1),
|
||||
generated_column: z.number().int().min(0),
|
||||
source: z.string(),
|
||||
original_line: z.number().int().min(1),
|
||||
original_column: z.number().int().min(0),
|
||||
name: z.string().nullable(),
|
||||
});
|
||||
const sourceMapContextShape = {
|
||||
script_key: z.string(),
|
||||
declared_url: z.string(),
|
||||
status: z.enum([
|
||||
"included",
|
||||
"fetch_failed",
|
||||
"invalid",
|
||||
"policy_filtered",
|
||||
"truncated",
|
||||
]),
|
||||
artifact: webTextArtifactSchema.nullable(),
|
||||
original_sources: z.array(
|
||||
z.object({
|
||||
source: z.string(),
|
||||
artifact: webTextArtifactSchema.nullable(),
|
||||
}),
|
||||
),
|
||||
original_module_edges: z.array(
|
||||
z.object({
|
||||
from_source: z.string(),
|
||||
kind: z.enum(["static_import", "dynamic_import", "require"]),
|
||||
specifier: z.string(),
|
||||
resolved_source: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
mappings: z.array(
|
||||
z.object({
|
||||
generated_line: z.number().int().min(1),
|
||||
generated_column: z.number().int().min(0),
|
||||
source: z.string(),
|
||||
original_line: z.number().int().min(1),
|
||||
original_column: z.number().int().min(0),
|
||||
name: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
limitation: z.string().nullable(),
|
||||
});
|
||||
};
|
||||
const parsedSourceMapShape = {
|
||||
artifact: webTextArtifactSchema,
|
||||
original_sources: z.array(originalSourceSchema),
|
||||
original_module_edges: z.array(originalModuleEdgeSchema),
|
||||
mappings: z.array(sourceMapMappingSchema),
|
||||
};
|
||||
|
||||
const sourceMapSchema = z.union([
|
||||
z.object({
|
||||
...sourceMapContextShape,
|
||||
...parsedSourceMapShape,
|
||||
status: z.literal("included"),
|
||||
limitation: z.null(),
|
||||
}),
|
||||
z.object({
|
||||
...sourceMapContextShape,
|
||||
...parsedSourceMapShape,
|
||||
status: z.literal("truncated"),
|
||||
limitation: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
...sourceMapContextShape,
|
||||
status: z.enum(["fetch_failed", "invalid", "policy_filtered", "truncated"]),
|
||||
artifact: z.null(),
|
||||
original_sources: z.tuple([]),
|
||||
original_module_edges: z.tuple([]),
|
||||
mappings: z.tuple([]),
|
||||
limitation: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const webTextArtifactSummarySchema = z.object({
|
||||
uri: z.string().regex(/^rea:\/\/web-content\/sha256\/[a-f0-9]{64}$/u),
|
||||
@@ -128,7 +143,7 @@ const webTextArtifactSummarySchema = z.object({
|
||||
text_available: z.literal(true),
|
||||
});
|
||||
|
||||
const sourceMapsSchema = z
|
||||
export const webSourceMapsSchema = z
|
||||
.object({
|
||||
status: z.enum([
|
||||
"not_requested",
|
||||
@@ -153,8 +168,30 @@ const sourceMapsSchema = z
|
||||
code: "custom",
|
||||
message: "Source-map coverage counts are inconsistent",
|
||||
});
|
||||
const statuses = sourceMaps.items.map(({ status }) => status);
|
||||
const included = statuses.filter((status) => status === "included").length;
|
||||
const hasTruncated = statuses.includes("truncated");
|
||||
const allowedStatuses =
|
||||
sourceMaps.dropped > 0 || hasTruncated
|
||||
? ["truncated"]
|
||||
: sourceMaps.items.length === 0
|
||||
? ["not_requested", "unavailable"]
|
||||
: included === sourceMaps.items.length
|
||||
? ["included"]
|
||||
: included > 0
|
||||
? ["partial"]
|
||||
: ["unavailable"];
|
||||
if (!allowedStatuses.includes(sourceMaps.status))
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Source-map status contradicts retained coverage",
|
||||
path: ["status"],
|
||||
});
|
||||
});
|
||||
|
||||
export type WebSourceMapItem = z.infer<typeof sourceMapSchema>;
|
||||
export type WebSourceMaps = z.infer<typeof webSourceMapsSchema>;
|
||||
|
||||
/** Provider-neutral result of bounded JavaScript bundle reverse engineering. */
|
||||
export const webBundleAnalysisSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
@@ -200,7 +237,7 @@ export const webBundleAnalysisSchema = z.object({
|
||||
location: sourceLocationSchema,
|
||||
}),
|
||||
),
|
||||
source_maps: sourceMapsSchema,
|
||||
source_maps: webSourceMapsSchema,
|
||||
}),
|
||||
inferences: z.array(
|
||||
z.object({
|
||||
|
||||
@@ -1,74 +1,26 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import canonicalize from "canonicalize";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { WebPageInspection } from "./browserObservation.js";
|
||||
import {
|
||||
webPageInspectionSchema,
|
||||
type WebPageInspection,
|
||||
} from "./browserObservation.js";
|
||||
import {
|
||||
webMcpDiscoverySchema,
|
||||
type WebMcpDiscovery,
|
||||
} from "./webMcpDiscovery.js";
|
||||
webCaptureDiffSchema,
|
||||
type CompareWebCapturesInput,
|
||||
type WebCaptureChange,
|
||||
type WebCaptureDiff,
|
||||
type WebCaptureDimension,
|
||||
} from "./webCaptureDiffSchemas.js";
|
||||
import type { WebMcpDiscovery } from "./webMcpDiscovery.js";
|
||||
|
||||
/** One normalized passive page snapshot accepted by capture comparison. */
|
||||
export const captureSnapshotSchema = z.object({
|
||||
inspection: webPageInspectionSchema,
|
||||
webmcp: webMcpDiscoverySchema.nullable().default(null),
|
||||
});
|
||||
export {
|
||||
captureSnapshotSchema,
|
||||
compareWebCapturesInputSchema,
|
||||
webCaptureDiffSchema,
|
||||
} from "./webCaptureDiffSchemas.js";
|
||||
export type { CompareWebCapturesInput, WebCaptureDiff };
|
||||
|
||||
/** Input for deterministic comparison of two normalized web captures. */
|
||||
export const compareWebCapturesInputSchema = z.object({
|
||||
before: captureSnapshotSchema,
|
||||
after: captureSnapshotSchema,
|
||||
max_changes: z.number().int().min(1).max(20_000).default(2_000),
|
||||
});
|
||||
export type CompareWebCapturesInput = z.infer<
|
||||
typeof compareWebCapturesInputSchema
|
||||
>;
|
||||
|
||||
const changeSchema = z.object({
|
||||
identity: z.string(),
|
||||
change: z.enum(["added", "removed", "modified"]),
|
||||
});
|
||||
const dimensionSchema = z.object({
|
||||
status: z.enum(["changed", "unchanged", "unknown"]),
|
||||
total_changes: z.number().int().min(0),
|
||||
changes: z.array(changeSchema),
|
||||
omitted_changes: z.number().int().min(0),
|
||||
reason: z.string().nullable(),
|
||||
});
|
||||
const legacyUnknownDimension = {
|
||||
status: "unknown" as const,
|
||||
total_changes: 0,
|
||||
changes: [],
|
||||
omitted_changes: 0,
|
||||
reason: "Dimension was not recorded by this version 1 capture diff.",
|
||||
};
|
||||
|
||||
/** Completeness-aware changes across stable browser evidence dimensions. */
|
||||
export const webCaptureDiffSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
overall_status: z.enum(["changed", "unchanged", "unknown"]),
|
||||
before_target: z.object({ target_id: z.string(), url: z.string() }),
|
||||
after_target: z.object({ target_id: z.string(), url: z.string() }),
|
||||
dimensions: z.object({
|
||||
dom_structure: dimensionSchema,
|
||||
scripts: dimensionSchema,
|
||||
resources: dimensionSchema,
|
||||
network: dimensionSchema,
|
||||
metadata: dimensionSchema,
|
||||
webmcp: dimensionSchema,
|
||||
accessibility: dimensionSchema.default(legacyUnknownDimension),
|
||||
storage: dimensionSchema.default(legacyUnknownDimension),
|
||||
}),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export type WebCaptureDiff = z.infer<typeof webCaptureDiffSchema>;
|
||||
type Dimension =
|
||||
WebCaptureDiff["dimensions"][keyof WebCaptureDiff["dimensions"]];
|
||||
type Change = Dimension["changes"][number];
|
||||
type Dimension = WebCaptureDimension;
|
||||
type Change = WebCaptureChange;
|
||||
|
||||
/** Compare normalized observations without treating incomplete absence as proof. */
|
||||
export const compareWebCaptures = (
|
||||
@@ -135,13 +87,29 @@ const compareDimension =
|
||||
const all = compareIdentities(before, after);
|
||||
const retained = all.slice(0, remaining.value);
|
||||
remaining.value -= retained.length;
|
||||
return {
|
||||
status: all.length > 0 ? "changed" : complete ? "unchanged" : "unknown",
|
||||
total_changes: all.length,
|
||||
changes: retained,
|
||||
omitted_changes: all.length - retained.length,
|
||||
reason: all.length === 0 && !complete ? reason : null,
|
||||
};
|
||||
if (all.length > 0)
|
||||
return {
|
||||
status: "changed",
|
||||
total_changes: all.length,
|
||||
changes: retained,
|
||||
omitted_changes: all.length - retained.length,
|
||||
reason: null,
|
||||
};
|
||||
return complete
|
||||
? {
|
||||
status: "unchanged",
|
||||
total_changes: 0,
|
||||
changes: [],
|
||||
omitted_changes: 0,
|
||||
reason: null,
|
||||
}
|
||||
: {
|
||||
status: "unknown",
|
||||
total_changes: 0,
|
||||
changes: [],
|
||||
omitted_changes: 0,
|
||||
reason,
|
||||
};
|
||||
};
|
||||
|
||||
const accessibilityDimension = (
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { webPageInspectionSchema } from "./browserObservation.js";
|
||||
import { webMcpDiscoverySchema } from "./webMcpDiscovery.js";
|
||||
|
||||
/** One normalized passive page snapshot accepted by capture comparison. */
|
||||
export const captureSnapshotSchema = z.object({
|
||||
inspection: webPageInspectionSchema,
|
||||
webmcp: webMcpDiscoverySchema.nullable().default(null),
|
||||
});
|
||||
|
||||
/** Input for deterministic comparison of two normalized web captures. */
|
||||
export const compareWebCapturesInputSchema = z.object({
|
||||
before: captureSnapshotSchema,
|
||||
after: captureSnapshotSchema,
|
||||
max_changes: z.number().int().min(1).max(20_000).default(2_000),
|
||||
});
|
||||
|
||||
const changeSchema = z.object({
|
||||
identity: z.string(),
|
||||
change: z.enum(["added", "removed", "modified"]),
|
||||
});
|
||||
const emptyDimensionShape = {
|
||||
total_changes: z.literal(0),
|
||||
changes: z.tuple([]),
|
||||
omitted_changes: z.literal(0),
|
||||
};
|
||||
const dimensionSchema = z.union([
|
||||
z
|
||||
.object({
|
||||
status: z.literal("changed"),
|
||||
total_changes: z.number().int().min(1),
|
||||
changes: z.array(changeSchema),
|
||||
omitted_changes: z.number().int().min(0),
|
||||
reason: z.null(),
|
||||
})
|
||||
.superRefine((dimension, context) => {
|
||||
if (
|
||||
dimension.changes.length + dimension.omitted_changes !==
|
||||
dimension.total_changes
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Retained and omitted changes must equal total changes",
|
||||
path: ["total_changes"],
|
||||
});
|
||||
}),
|
||||
z.object({
|
||||
...emptyDimensionShape,
|
||||
status: z.literal("unchanged"),
|
||||
reason: z.null(),
|
||||
}),
|
||||
z.object({
|
||||
...emptyDimensionShape,
|
||||
status: z.literal("unknown"),
|
||||
reason: z.string(),
|
||||
}),
|
||||
]);
|
||||
const legacyUnknownDimension: z.input<typeof dimensionSchema> = {
|
||||
status: "unknown",
|
||||
total_changes: 0,
|
||||
changes: [],
|
||||
omitted_changes: 0,
|
||||
reason: "Dimension was not recorded by this version 1 capture diff.",
|
||||
};
|
||||
|
||||
/** Completeness-aware changes across stable browser evidence dimensions. */
|
||||
export const webCaptureDiffSchema = z
|
||||
.object({
|
||||
schema_version: z.literal(1),
|
||||
overall_status: z.enum(["changed", "unchanged", "unknown"]),
|
||||
before_target: z.object({ target_id: z.string(), url: z.string() }),
|
||||
after_target: z.object({ target_id: z.string(), url: z.string() }),
|
||||
dimensions: z.object({
|
||||
dom_structure: dimensionSchema,
|
||||
scripts: dimensionSchema,
|
||||
resources: dimensionSchema,
|
||||
network: dimensionSchema,
|
||||
metadata: dimensionSchema,
|
||||
webmcp: dimensionSchema,
|
||||
accessibility: dimensionSchema.default(legacyUnknownDimension),
|
||||
storage: dimensionSchema.default(legacyUnknownDimension),
|
||||
}),
|
||||
limitations: z.array(z.string()),
|
||||
})
|
||||
.superRefine((comparison, context) => {
|
||||
const statuses = Object.values(comparison.dimensions).map(
|
||||
({ status }) => status,
|
||||
);
|
||||
const expected = statuses.includes("changed")
|
||||
? "changed"
|
||||
: statuses.includes("unknown")
|
||||
? "unknown"
|
||||
: "unchanged";
|
||||
if (comparison.overall_status !== expected)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Overall status must summarize dimension statuses",
|
||||
path: ["overall_status"],
|
||||
});
|
||||
});
|
||||
|
||||
export type CompareWebCapturesInput = z.infer<
|
||||
typeof compareWebCapturesInputSchema
|
||||
>;
|
||||
export type WebCaptureDiff = z.infer<typeof webCaptureDiffSchema>;
|
||||
export type WebCaptureDimension =
|
||||
WebCaptureDiff["dimensions"][keyof WebCaptureDiff["dimensions"]];
|
||||
export type WebCaptureChange = WebCaptureDimension["changes"][number];
|
||||
@@ -30,7 +30,7 @@ interface GeneratedPayload {
|
||||
}
|
||||
|
||||
const GENERATED_PAYLOAD_JSON =
|
||||
'{"auxiliaryProviders":[{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":false,"inputContractVersion":1,"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"limits":{"maxPayloadBytes":4194304,"maxResults":500,"timeoutMs":120000},"operation":"inventory_artifact","outputContractVersion":1,"pagination":"offset","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":false,"inputContractVersion":1,"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"limits":{"maxPayloadBytes":4194304,"maxResults":500,"timeoutMs":120000},"operation":"inspect_artifact","outputContractVersion":1,"pagination":"none","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":false,"inputContractVersion":1,"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"limits":{"maxPayloadBytes":4194304,"maxResults":500,"timeoutMs":120000},"operation":"extract_artifact","outputContractVersion":1,"pagination":"offset","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null}],"identity":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"}},{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":false,"inputContractVersion":1,"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"limits":{"maxPayloadBytes":4194304,"maxResults":null,"timeoutMs":30000},"operation":"inspect_macho","outputContractVersion":1,"pagination":"none","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":true,"inputContractVersion":1,"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"limits":{"maxPayloadBytes":4194304,"maxResults":null,"timeoutMs":30000},"operation":"inspect_signature","outputContractVersion":1,"pagination":"none","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":true,"inputContractVersion":1,"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"limits":{"maxPayloadBytes":4194304,"maxResults":null,"timeoutMs":30000},"operation":"inspect_plist","outputContractVersion":1,"pagination":"none","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":true,"inputContractVersion":1,"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"limits":{"maxPayloadBytes":4194304,"maxResults":null,"timeoutMs":30000},"operation":"list_architectures","outputContractVersion":1,"pagination":"none","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"muLine truncated
|
||||
'{"auxiliaryProviders":[{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":false,"inputContractVersion":1,"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"limits":{"maxPayloadBytes":4194304,"maxResults":500,"timeoutMs":120000},"operation":"inventory_artifact","outputContractVersion":1,"pagination":"offset","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":false,"inputContractVersion":1,"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"limits":{"maxPayloadBytes":4194304,"maxResults":500,"timeoutMs":120000},"operation":"inspect_artifact","outputContractVersion":1,"pagination":"none","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":false,"inputContractVersion":1,"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"limits":{"maxPayloadBytes":4194304,"maxResults":500,"timeoutMs":120000},"operation":"extract_artifact","outputContractVersion":1,"pagination":"offset","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null}],"identity":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"}},{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":false,"inputContractVersion":1,"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"limits":{"maxPayloadBytes":4194304,"maxResults":null,"timeoutMs":30000},"operation":"inspect_macho","outputContractVersion":1,"pagination":"none","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":true,"inputContractVersion":1,"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"limits":{"maxPayloadBytes":4194304,"maxResults":null,"timeoutMs":30000},"operation":"inspect_signature","outputContractVersion":1,"pagination":"none","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":true,"inputContractVersion":1,"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"limits":{"maxPayloadBytes":4194304,"maxResults":null,"timeoutMs":30000},"operation":"inspect_plist","outputContractVersion":1,"pagination":"none","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":true,"inputContractVersion":1,"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"limits":{"maxPayloadBytes":4194304,"maxResults":null,"timeoutMs":30000},"operation":"list_architectures","outputContractVersion":1,"pagination":"none","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"muLine truncated
|
||||
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
|
||||
if (!isGeneratedPayload(generatedPayload))
|
||||
throw new TypeError("Generated MCP catalog payload is invalid");
|
||||
|
||||
@@ -7,7 +7,7 @@ import { GHIDRA_INVENTORY_OPERATIONS } from "./GhidraInventoryValues.js";
|
||||
import { GhidraProvider } from "./GhidraProvider.js";
|
||||
|
||||
describe("Ghidra provider capabilities", () => {
|
||||
it("publishes only admitted immutable read-only operations", () => {
|
||||
it("publishes only admitted read-only operations and resists caller mutation", () => {
|
||||
const config = parseConfig({});
|
||||
expect(config.ok).toBe(true);
|
||||
if (!config.ok) return;
|
||||
@@ -17,10 +17,10 @@ describe("Ghidra provider capabilities", () => {
|
||||
probeJava: () => undefined,
|
||||
});
|
||||
const capabilities = provider.capabilities();
|
||||
const published = structuredClone(capabilities);
|
||||
expect(capabilities.map(({ operation }) => operation).sort()).toEqual(
|
||||
[...GHIDRA_INVENTORY_OPERATIONS, ...GHIDRA_FUNCTION_OPERATIONS].sort(),
|
||||
);
|
||||
expect(Object.isFrozen(capabilities)).toBe(true);
|
||||
for (const descriptor of capabilities) {
|
||||
expect(descriptor).toMatchObject({
|
||||
available: true,
|
||||
@@ -33,10 +33,15 @@ describe("Ghidra provider capabilities", () => {
|
||||
requiresRoot: false,
|
||||
},
|
||||
});
|
||||
expect(Object.isFrozen(descriptor)).toBe(true);
|
||||
expect(Object.isFrozen(descriptor.effects)).toBe(true);
|
||||
expect(Object.isFrozen(descriptor.limits)).toBe(true);
|
||||
expect(Object.isFrozen(descriptor.limitations)).toBe(true);
|
||||
}
|
||||
const first = capabilities[0];
|
||||
if (first === undefined) throw new Error("Ghidra capabilities are empty");
|
||||
Reflect.set(capabilities, 0, { ...first, available: false });
|
||||
Reflect.set(first, "available", false);
|
||||
Reflect.set(first.effects, "mutatesArtifact", true);
|
||||
Reflect.set(first.limits, "maxResults", 0);
|
||||
Reflect.set(first.limitations, 0, "forged limitation");
|
||||
|
||||
expect(provider.capabilities()).toEqual(published);
|
||||
});
|
||||
});
|
||||
@@ -5,17 +5,17 @@ import { silentLogger } from "../logger.js";
|
||||
import { HopperProvider } from "./HopperProvider.js";
|
||||
|
||||
describe("Hopper provider capabilities", () => {
|
||||
it("publishes deterministic immutable capability descriptors", () => {
|
||||
it("publishes deterministic descriptors and resists caller mutation", () => {
|
||||
const config = parseConfig({});
|
||||
expect(config.ok).toBe(true);
|
||||
if (!config.ok) return;
|
||||
const provider = new HopperProvider(config.value, silentLogger);
|
||||
const capabilities = provider.capabilities();
|
||||
const published = structuredClone(capabilities);
|
||||
expect(capabilities).toHaveLength(37);
|
||||
expect(new Set(capabilities.map(({ operation }) => operation)).size).toBe(
|
||||
capabilities.length,
|
||||
);
|
||||
expect(Object.isFrozen(capabilities)).toBe(true);
|
||||
for (const descriptor of capabilities) {
|
||||
expect(descriptor.provider).toEqual(provider.identity());
|
||||
expect(descriptor).toMatchObject({
|
||||
@@ -24,10 +24,6 @@ describe("Hopper provider capabilities", () => {
|
||||
available: true,
|
||||
reason: null,
|
||||
});
|
||||
expect(Object.isFrozen(descriptor)).toBe(true);
|
||||
expect(Object.isFrozen(descriptor.effects)).toBe(true);
|
||||
expect(Object.isFrozen(descriptor.limits)).toBe(true);
|
||||
expect(Object.isFrozen(descriptor.limitations)).toBe(true);
|
||||
}
|
||||
expect(
|
||||
capabilities.find(({ operation }) => operation === "list_procedures"),
|
||||
@@ -37,5 +33,14 @@ describe("Hopper provider capabilities", () => {
|
||||
).toMatchObject({
|
||||
effects: { mutatesArtifact: true, mayWriteFilesystem: true },
|
||||
});
|
||||
const first = capabilities[0];
|
||||
if (first === undefined) throw new Error("Hopper capabilities are empty");
|
||||
Reflect.set(capabilities, 0, { ...first, available: false });
|
||||
Reflect.set(first, "available", false);
|
||||
Reflect.set(first.effects, "mutatesArtifact", true);
|
||||
Reflect.set(first.limits, "maxResults", 0);
|
||||
Reflect.set(first.limitations, 0, "forged limitation");
|
||||
|
||||
expect(provider.capabilities()).toEqual(published);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,10 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js";
|
||||
import { compareApplicationVersionsEvidence } from "../../../src/application/JavaScriptApplicationWorkflowService.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { applicationVersionComparisonResultSchema } from "../../../src/domain/javascriptApplicationVersionComparisonSchemas.js";
|
||||
import {
|
||||
applicationVersionComparisonResultSchema,
|
||||
type ApplicationVersionComparisonResult,
|
||||
} from "../../../src/domain/javascriptApplicationVersionComparisonSchemas.js";
|
||||
import { compareJavaScriptApplicationVersions } from "../../../src/domain/javascriptApplicationVersionComparison.js";
|
||||
import { createJavaScriptApplicationGraph } from "../../../src/domain/javascriptApplicationGraph.js";
|
||||
import { traceApplicationFeature } from "../../../src/domain/javascriptFeatureTrace.js";
|
||||
@@ -188,6 +191,7 @@ describe("JavaScript application comparison workflows", () => {
|
||||
expect(result.limitations.join(" ")).toMatch(
|
||||
/module ordinals|fuzzy pairing/u,
|
||||
);
|
||||
expectComparisonItemAlgebra(result);
|
||||
});
|
||||
|
||||
it("pairs source-map originals without promoting the match to exact", async () => {
|
||||
@@ -291,3 +295,42 @@ const analyzeFixture = async (root: string, sourceMaps = false) => {
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
};
|
||||
|
||||
const expectComparisonItemAlgebra = (
|
||||
result: ApplicationVersionComparisonResult,
|
||||
): void => {
|
||||
const matched = result.items.find(({ match }) => match.status === "matched");
|
||||
expect(matched).toBeDefined();
|
||||
if (matched === undefined) return;
|
||||
expect(
|
||||
applicationVersionComparisonResultSchema.safeParse({
|
||||
...result,
|
||||
items: [{ ...matched, right_node_id: null }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
applicationVersionComparisonResultSchema.safeParse({
|
||||
...result,
|
||||
items: [
|
||||
{
|
||||
...matched,
|
||||
match: {
|
||||
...matched.match,
|
||||
basis: "none",
|
||||
confidence: "unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
applicationVersionComparisonResultSchema.safeParse({
|
||||
...result,
|
||||
coverage: {
|
||||
...result.coverage,
|
||||
status: "complete-within-inputs",
|
||||
omitted_comparison_items: 1,
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
};
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
} from "../../fixtures/fakeCdpBrowser.js";
|
||||
|
||||
const browsers: FakeCdpBrowser[] = [];
|
||||
const expectInvalidDiff = (input: unknown): void => {
|
||||
expect(webCaptureDiffSchema.safeParse(input).success).toBe(false);
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(browsers.splice(0).map(async (browser) => browser.close()));
|
||||
@@ -58,6 +61,21 @@ describe("web capture diff", () => {
|
||||
status: "unknown",
|
||||
total_changes: 0,
|
||||
});
|
||||
expectInvalidDiff({ ...result, overall_status: "unchanged" });
|
||||
expectInvalidDiff({
|
||||
...result,
|
||||
dimensions: {
|
||||
...result.dimensions,
|
||||
webmcp: { ...result.dimensions.webmcp, reason: null },
|
||||
},
|
||||
});
|
||||
expectInvalidDiff({
|
||||
...result,
|
||||
dimensions: {
|
||||
...result.dimensions,
|
||||
scripts: { ...result.dimensions.scripts, total_changes: 2 },
|
||||
},
|
||||
});
|
||||
const {
|
||||
accessibility: _accessibility,
|
||||
storage: _storage,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { fetchWebSourceMaps } from "../../../src/browser/WebSourceMapFetcher.js";
|
||||
import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js";
|
||||
import {
|
||||
analyzeWebBundleInputSchema,
|
||||
webSourceMapsSchema,
|
||||
type WebSourceMaps,
|
||||
} from "../../../src/domain/webBundleAnalysis.js";
|
||||
|
||||
const origin = "https://app.example.test";
|
||||
const request = {
|
||||
@@ -10,6 +14,18 @@ const request = {
|
||||
fetchUrl: `${origin}/assets/app.js.map?token=secret`,
|
||||
};
|
||||
|
||||
const expectInvalidSourceMaps = (value: unknown): void => {
|
||||
expect(webSourceMapsSchema.safeParse(value).success).toBe(false);
|
||||
};
|
||||
|
||||
const expectInvalidIncludedSourceMaps = (result: WebSourceMaps): void => {
|
||||
expectInvalidSourceMaps({
|
||||
...result,
|
||||
items: [{ ...result.items[0], artifact: null }],
|
||||
});
|
||||
expectInvalidSourceMaps({ ...result, status: "unavailable" });
|
||||
};
|
||||
|
||||
describe("web source-map fetching and validation", () => {
|
||||
it("fetches without credentials and derives mappings and original modules", async () => {
|
||||
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
|
||||
@@ -77,6 +93,7 @@ describe("web source-map fetching and validation", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expectInvalidIncludedSourceMaps(result);
|
||||
});
|
||||
|
||||
it("validates sectioned maps and reports bounded mapping truncation", async () => {
|
||||
@@ -136,6 +153,10 @@ describe("web source-map fetching and validation", () => {
|
||||
status: "unavailable",
|
||||
items: [{ status: "policy_filtered" }],
|
||||
});
|
||||
expectInvalidSourceMaps({
|
||||
...result,
|
||||
items: [{ ...result.items[0], limitation: null }],
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes invalid JSON from declared or streamed byte truncation", async () => {
|
||||
|
||||
@@ -82,6 +82,9 @@ describe("persistent cross-version investigation workspace", () => {
|
||||
const run = completed.value.workspace.runs[0];
|
||||
if (run === undefined || run.comparison_evidence_id === null)
|
||||
throw new Error("missing completed run");
|
||||
expect(
|
||||
investigationRunSchema.safeParse({ ...run, status: "running" }).success,
|
||||
).toBe(false);
|
||||
const partialRun = investigationRunSchema.parse({
|
||||
...run,
|
||||
status: "running",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js";
|
||||
@@ -26,8 +26,16 @@ describe("evidence bundle release", () => {
|
||||
it("notifies snapshot observers and invalidates the retained URI", async () => {
|
||||
const session = createSession();
|
||||
const server = createServer(session, session);
|
||||
const resourceUpdated = vi.spyOn(server.server, "sendResourceUpdated");
|
||||
const client = new Client({ name: "release-test", version: "1" });
|
||||
const bundleUpdated = new Promise<string>((resolve) => {
|
||||
client.setNotificationHandler(
|
||||
"notifications/resources/updated",
|
||||
(notice) => {
|
||||
if (notice.params.uri.startsWith("rea://evidence-bundle/"))
|
||||
resolve(notice.params.uri);
|
||||
},
|
||||
);
|
||||
});
|
||||
let updates = 0;
|
||||
session.onAnalysisSnapshotChanged(() => {
|
||||
updates += 1;
|
||||
@@ -64,9 +72,7 @@ describe("evidence bundle release", () => {
|
||||
released: true,
|
||||
});
|
||||
expect(updates).toBe(1);
|
||||
expect(resourceUpdated).toHaveBeenCalledWith({
|
||||
uri: snapshot.bundle_uri,
|
||||
});
|
||||
await expect(bundleUpdated).resolves.toBe(snapshot.bundle_uri);
|
||||
await expect(
|
||||
client.readResource({ uri: snapshot.bundle_uri }),
|
||||
).rejects.toThrow(/not found/iu);
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("provider process runtime and wait primitives", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the startup timer and external cancellation listener", async () => {
|
||||
it("classifies external cancellation during startup", async () => {
|
||||
vi.useFakeTimers();
|
||||
const controller = new AbortController();
|
||||
const deadline = new ProviderStartupDeadline(1_000, controller.signal);
|
||||
@@ -120,7 +120,7 @@ describe("provider process runtime and wait primitives", () => {
|
||||
deadline.dispose();
|
||||
});
|
||||
|
||||
it("releases pending request timers and abort listeners on every outcome", async () => {
|
||||
it("settles pending requests once on cancellation, timeout, and failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
const operations = new PendingOperations<number, string>();
|
||||
const controller = new AbortController();
|
||||
@@ -134,6 +134,7 @@ describe("provider process runtime and wait primitives", () => {
|
||||
controller.abort();
|
||||
await expect(cancelled).resolves.toBe("cancelled");
|
||||
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0);
|
||||
expect(operations.settle(1, "late reply")).toBe(false);
|
||||
|
||||
const timedOut = operations.wait(2, {
|
||||
timeoutMs: 50,
|
||||
@@ -142,6 +143,7 @@ describe("provider process runtime and wait primitives", () => {
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
await expect(timedOut).resolves.toBe("timeout");
|
||||
expect(operations.settle(2, "late reply")).toBe(false);
|
||||
|
||||
const failed = operations.wait(3, {
|
||||
timeoutMs: 1_000,
|
||||
@@ -150,6 +152,7 @@ describe("provider process runtime and wait primitives", () => {
|
||||
});
|
||||
operations.failAll((key) => `failed:${String(key)}`);
|
||||
await expect(failed).resolves.toBe("failed:3");
|
||||
expect(operations.settle(3, "late reply")).toBe(false);
|
||||
expect(operations.size).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
@@ -4,29 +4,17 @@ import { fileURLToPath } from "node:url";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
let bridgeSource = "";
|
||||
let queueSource = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
[bridgeSource, queueSource] = await Promise.all([
|
||||
readFile(
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../../../bridge/ghidra/ReaGhidraBridge.java",
|
||||
import.meta.url,
|
||||
),
|
||||
bridgeSource = await readFile(
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../../../bridge/ghidra/ReaGhidraBridge.java",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../../../src/ghidra/GhidraRequestQueue.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
]);
|
||||
"utf8",
|
||||
);
|
||||
});
|
||||
|
||||
describe("Ghidra bridge truthfulness", () => {
|
||||
@@ -76,15 +64,6 @@ describe("Ghidra bridge truthfulness", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes a bounded request queue before crossing the socket", () => {
|
||||
expect(queueSource).toContain("class GhidraRequestQueue");
|
||||
expect(queueSource).toContain("if (this.#size >= this.maximum)");
|
||||
expect(queueSource).toContain("if (this.#active) return");
|
||||
expect(queueSource).toContain(
|
||||
"remaining = entry.deadline - performance.now()",
|
||||
);
|
||||
});
|
||||
|
||||
it("binds the Windows fallback to authenticated IPv4 loopback", () => {
|
||||
expect(bridgeSource).toContain(
|
||||
'descriptor.transport.equals("unix-socket")',
|
||||
|
||||
@@ -379,7 +379,7 @@ describe("GhidraClient established requests", () => {
|
||||
});
|
||||
|
||||
describe("GhidraClient cleanup and diagnostics", () => {
|
||||
it("makes double close idempotent and detaches process listeners", async () => {
|
||||
it("makes double close idempotent and releases its process runtime", async () => {
|
||||
const launcher = new FixtureLauncher();
|
||||
const client = clientFor(launcher);
|
||||
await expect(client.start()).resolves.toMatchObject({ ok: true });
|
||||
|
||||
@@ -79,34 +79,6 @@ describe("Hopper bridge truthfulness", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds search and makes regex an explicitly constrained opt-in", () => {
|
||||
expect(bridgeSource).toContain('params.get("mode", "literal")');
|
||||
expect(bridgeSource).toContain("pattern.casefold()");
|
||||
expect(bridgeSource).toContain("_validate_regex_node(parsed)");
|
||||
expect(bridgeSource).toContain(
|
||||
"Nested regex repetitions are not supported",
|
||||
);
|
||||
expect(bridgeSource).toContain("MAX_SEARCH_PATTERN_LENGTH = 256");
|
||||
expect(bridgeSource).toContain("MAX_SEARCH_VALUE_LENGTH = 4096");
|
||||
expect(bridgeSource).toContain("MAX_REGEX_BACKTRACKING_PATHS = 10000");
|
||||
expect(bridgeSource).toContain("MAX_REGEX_CANDIDATE_LENGTH = 4096");
|
||||
expect(bridgeSource).toContain("MAX_REGEX_SEARCH_WORK_UNITS = 1000000");
|
||||
expect(bridgeSource).toContain("_checked_regex_paths(");
|
||||
expect(bridgeSource).toContain("_bounded_regex_matcher(");
|
||||
expect(bridgeSource).toContain('"value_truncated"');
|
||||
expect(bridgeSource).toContain("page_end = offset + limit");
|
||||
expect(bridgeSource).toContain("if offset <= total < page_end:");
|
||||
expect(bridgeSource).not.toContain(
|
||||
"matching = [item for item in _search_inventory(document, kind)",
|
||||
);
|
||||
});
|
||||
|
||||
it("caches sorted search inventories and invalidates them after renames", () => {
|
||||
expect(bridgeSource).toContain("_search_inventory_cache");
|
||||
expect(bridgeSource).toContain("tuple(sorted(values.items()");
|
||||
expect(bridgeSource).toContain("_invalidate_search_inventory(document)");
|
||||
});
|
||||
|
||||
it("stops background analysis and closes the session-owned document", () => {
|
||||
expect(bridgeSource).toContain("def _session_document():");
|
||||
expect(bridgeSource).toContain("os.path.realpath(REA_TARGET_PATH)");
|
||||
@@ -128,6 +100,29 @@ describe("Hopper bridge truthfulness", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("invalidates cached search inventories in both rename paths", () => {
|
||||
const singleRenameStart = bridgeSource.indexOf(
|
||||
'if method == "set_address_name":',
|
||||
);
|
||||
const bulkRenameStart = bridgeSource.indexOf(
|
||||
'if method == "set_addresses_names":',
|
||||
singleRenameStart,
|
||||
);
|
||||
const nextMethodStart = bridgeSource.indexOf(
|
||||
'if method in ("set_comment", "set_inline_comment"):',
|
||||
bulkRenameStart,
|
||||
);
|
||||
expect(singleRenameStart).toBeGreaterThan(-1);
|
||||
expect(bulkRenameStart).toBeGreaterThan(singleRenameStart);
|
||||
expect(nextMethodStart).toBeGreaterThan(bulkRenameStart);
|
||||
expect(bridgeSource.slice(singleRenameStart, bulkRenameStart)).toContain(
|
||||
"_invalidate_search_inventory(document)",
|
||||
);
|
||||
expect(bridgeSource.slice(bulkRenameStart, nextMethodStart)).toContain(
|
||||
"_invalidate_search_inventory(document)",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns deterministic addresses for direct procedure relationships", () => {
|
||||
expect(bridgeSource).toContain(
|
||||
"_hex(item.getEntryPoint()) for item in procedure.getAllCallerProcedures()",
|
||||
|
||||
@@ -270,7 +270,7 @@ describe("HopperClient cleanup", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("makes sequential double-close leave no runtime or process listeners", async () => {
|
||||
it("makes sequential double-close release its process runtime", async () => {
|
||||
const launcher = new FixtureLauncher();
|
||||
const client = new HopperClient({ launcher, startupTimeoutMs: 1_000 });
|
||||
clients.push(client);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "../../../src/domain/conformancePackage.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import {
|
||||
packageReplayResultSchema,
|
||||
replayConformancePackage,
|
||||
type ScenarioReplayResult,
|
||||
} from "../../../src/domain/conformanceReplay.js";
|
||||
@@ -97,6 +98,9 @@ describe("conformance CI replay", () => {
|
||||
expect(result.passed).toBe(1);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(result.errored).toBe(0);
|
||||
expect(
|
||||
packageReplayResultSchema.safeParse({ ...result, passed: 0 }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("replays a package with failing scenarios", async () => {
|
||||
@@ -115,6 +119,12 @@ describe("conformance CI replay", () => {
|
||||
{},
|
||||
passingRunner,
|
||||
);
|
||||
expect(
|
||||
packageReplayResultSchema.safeParse({
|
||||
...result,
|
||||
drift_detected: true,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(result.drift_detected).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
evaluateTrustGate,
|
||||
isSemanticDimension,
|
||||
isVolatileDimension,
|
||||
trustGateResultSchema,
|
||||
} from "../../../src/domain/conformanceTrustGate.js";
|
||||
|
||||
const contract: VerifierContract = {
|
||||
@@ -18,6 +19,21 @@ const contract: VerifierContract = {
|
||||
timing_tolerance_ms: 100,
|
||||
};
|
||||
|
||||
const expectDivergenceRejected = (
|
||||
result: ReturnType<typeof evaluateTrustGate>,
|
||||
): void => {
|
||||
expect(
|
||||
trustGateResultSchema.safeParse({
|
||||
...result,
|
||||
first_divergence: {
|
||||
dimension: "exit_code",
|
||||
evidence_id: "invented",
|
||||
message: "invented",
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
};
|
||||
|
||||
describe("conformance trust gates", () => {
|
||||
it("classifies volatile dimensions correctly", () => {
|
||||
expect(isVolatileDimension("timing")).toBe(true);
|
||||
@@ -143,6 +159,7 @@ describe("conformance trust gates", () => {
|
||||
{ exit_code: 0, stdout: "hello" },
|
||||
);
|
||||
expect(result.verdict).toBe("pass");
|
||||
expectDivergenceRejected(result);
|
||||
});
|
||||
|
||||
it("fails when a required dimension mismatches", () => {
|
||||
|
||||
Reference in new issue
Block a user