Files
clawdbot/vendor/a2ui/specification/0.9/eval/src/validator.ts
2025-12-17 11:35:06 +01:00

366 lines
11 KiB
TypeScript

/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Ajv from "ajv/dist/2020";
import * as fs from "fs";
import * as path from "path";
import * as yaml from "js-yaml";
import { GeneratedResult, ValidatedResult, IssueSeverity } from "./types";
import { logger } from "./logger";
export class Validator {
private ajv: Ajv;
private validateFn: any;
constructor(
private schemas: Record<string, any>,
private outputDir?: string
) {
this.ajv = new Ajv({ allErrors: true, strict: false }); // strict: false to be lenient with unknown keywords if any
for (const [name, schema] of Object.entries(schemas)) {
this.ajv.addSchema(schema, name);
}
this.validateFn = this.ajv.getSchema(
"https://a2ui.dev/specification/0.9/server_to_client.json"
);
}
async run(results: GeneratedResult[]): Promise<ValidatedResult[]> {
logger.info(
`Starting Phase 2: Schema Validation (${results.length} items)`
);
const validatedResults: ValidatedResult[] = [];
let passedCount = 0;
let failedCount = 0;
// Phase 2 is fast (CPU bound), so we can just iterate.
// If we wanted to be fancy we could chunk it, but for < 1000 items it's instant.
for (const result of results) {
if (result.error || !result.components) {
validatedResults.push({ ...result, validationErrors: [] }); // Already failed generation
continue;
}
const errors: string[] = [];
const components = result.components;
// AJV Validation
// AJV Validation
if (this.ajv) {
for (const message of components) {
// Smart validation: check which key is present and validate against that specific definition
// to avoid noisy "oneOf" errors.
let validated = false;
const schemaUri =
"https://a2ui.dev/specification/0.9/server_to_client.json";
if (message.createSurface) {
validated = this.ajv.validate(
`${schemaUri}#/$defs/CreateSurfaceMessage`,
message
);
} else if (message.updateComponents) {
validated = this.ajv.validate(
`${schemaUri}#/$defs/UpdateComponentsMessage`,
message
);
} else if (message.updateDataModel) {
validated = this.ajv.validate(
`${schemaUri}#/$defs/UpdateDataModelMessage`,
message
);
} else if (message.deleteSurface) {
validated = this.ajv.validate(
`${schemaUri}#/$defs/DeleteSurfaceMessage`,
message
);
} else {
// Fallback to top-level validation if no known key matches (or if it's empty/invalid structure)
validated = this.validateFn(message);
}
if (!validated) {
errors.push(
...(this.ajv.errors || []).map(
(err: any) => `${err.instancePath} ${err.message}`
)
);
}
}
}
// Custom Validation (Referential Integrity, etc.)
this.validateCustom(components, errors);
if (errors.length > 0) {
failedCount++;
if (this.outputDir) {
this.saveFailure(result, errors);
}
} else {
passedCount++;
}
validatedResults.push({
...result,
validationErrors: errors,
});
}
logger.info(
`Phase 2: Validation Complete. Passed: ${passedCount}, Failed: ${failedCount}`
);
return validatedResults;
}
private saveFailure(result: GeneratedResult, errors: string[]) {
if (!this.outputDir) return;
const modelDir = path.join(
this.outputDir,
`output-${result.modelName.replace(/[\/:]/g, "_")}`
);
const detailsDir = path.join(modelDir, "details");
const failureData = {
pass: false,
reason: "Schema validation failure",
issues: errors.map((e) => ({
issue: e,
severity: "criticalSchema" as IssueSeverity,
})),
overallSeverity: "criticalSchema" as IssueSeverity,
};
fs.writeFileSync(
path.join(
detailsDir,
`${result.prompt.name}.${result.runNumber}.failed.yaml`
),
yaml.dump(failureData)
);
}
private validateCustom(messages: any[], errors: string[]) {
let hasUpdateComponents = false;
let hasRootComponent = false;
const createdSurfaces = new Set<string>();
for (const message of messages) {
if (message.updateComponents) {
hasUpdateComponents = true;
const surfaceId = message.updateComponents.surfaceId;
if (surfaceId && !createdSurfaces.has(surfaceId)) {
errors.push(
`updateComponents message received for surface '${surfaceId}' before createSurface message.`
);
}
this.validateUpdateComponents(message.updateComponents, errors);
// Check for root component in this message
if (message.updateComponents.components) {
for (const comp of message.updateComponents.components) {
if (comp.id === "root") {
hasRootComponent = true;
}
}
}
} else if (message.createSurface) {
this.validateCreateSurface(message.createSurface, errors);
if (message.createSurface.surfaceId) {
createdSurfaces.add(message.createSurface.surfaceId);
}
} else if (message.updateDataModel) {
this.validateUpdateDataModel(message.updateDataModel, errors);
} else if (message.deleteSurface) {
this.validateDeleteSurface(message.deleteSurface, errors);
} else {
errors.push(
`Unknown message type in output: ${JSON.stringify(message)}`
);
}
}
// Algorithmic check for root component
if (hasUpdateComponents && !hasRootComponent) {
errors.push(
"Missing root component: At least one 'updateComponents' message must contain a component with id: 'root'."
);
}
}
// ... Copied helper functions ...
private validateCreateSurface(data: any, errors: string[]) {
if (data.surfaceId === undefined) {
errors.push("createSurface must have a 'surfaceId' property.");
}
if (data.catalogId === undefined) {
errors.push("createSurface must have a 'catalogId' property.");
}
const allowed = ["surfaceId", "catalogId"];
for (const key in data) {
if (!allowed.includes(key)) {
errors.push(`createSurface has unexpected property: ${key}`);
}
}
}
private validateDeleteSurface(data: any, errors: string[]) {
if (data.surfaceId === undefined) {
errors.push("DeleteSurface must have a 'surfaceId' property.");
}
const allowed = ["surfaceId"];
for (const key in data) {
if (!allowed.includes(key)) {
errors.push(`DeleteSurface has unexpected property: ${key}`);
}
}
}
private validateUpdateComponents(data: any, errors: string[]) {
if (data.surfaceId === undefined) {
errors.push("UpdateComponents must have a 'surfaceId' property.");
}
if (!data.components || !Array.isArray(data.components)) {
errors.push("UpdateComponents must have a 'components' array.");
return;
}
const componentIds = new Set<string>();
for (const c of data.components) {
const id = c.id;
if (id) {
if (componentIds.has(id)) {
errors.push(`Duplicate component ID found: ${id}`);
}
componentIds.add(id);
}
// Smart Component Validation
if (this.ajv && c.component) {
const componentType = c.component;
const schemaUri =
"https://a2ui.dev/specification/0.9/standard_catalog_definition.json";
const defRef = `${schemaUri}#/$defs/${componentType}`;
const valid = this.ajv.validate(defRef, c);
if (!valid) {
errors.push(
...(this.ajv.errors || []).map(
(err: any) =>
`${err.instancePath} ${err.message} (in component '${
c.id || "unknown"
}')`
)
);
}
}
}
for (const component of data.components) {
this.validateComponent(component, componentIds, errors);
}
}
private validateUpdateDataModel(data: any, errors: string[]) {
// Schema validation handles types, required fields (surfaceId, op), and extra properties.
// We only need to validate the conditional requirement of 'value' based on 'op'.
if (data.op === "remove") {
if (data.value !== undefined) {
errors.push(
"updateDataModel 'value' property must not be present when op is 'remove'."
);
}
} else {
// op is 'add' or 'replace' (schema validates enum values)
if (data.value === undefined) {
errors.push(
`updateDataModel 'value' property is required when op is '${data.op}'.`
);
}
}
}
private validateComponent(
component: any,
allIds: Set<string>,
errors: string[]
) {
const id = component.id;
if (!id) {
errors.push(`Component is missing an 'id'.`);
return;
}
const componentType = component.component;
if (!componentType || typeof componentType !== "string") {
errors.push(`Component '${id}' is missing 'component' property.`);
return;
}
// Basic required checks that might be missed by AJV if it's lenient or if we want specific messages
// Actually AJV covers most of this, but the custom logic for 'children' and 'refs' is key.
const checkRefs = (ids: (string | undefined)[]) => {
for (const id of ids) {
if (id && !allIds.has(id)) {
errors.push(
`Component ${JSON.stringify(id)} references non-existent component ID.`
);
}
}
};
switch (componentType) {
case "Row":
case "Column":
case "List":
if (component.children) {
if (Array.isArray(component.children)) {
checkRefs(component.children);
} else if (
typeof component.children === "object" &&
component.children !== null
) {
if (component.children.componentId) {
checkRefs([component.children.componentId]);
}
}
}
break;
case "Card":
checkRefs([component.child]);
break;
case "Tabs":
if (component.tabItems && Array.isArray(component.tabItems)) {
component.tabItems.forEach((tab: any) => {
checkRefs([tab.child]);
});
}
break;
case "Modal":
checkRefs([component.entryPointChild, component.contentChild]);
break;
case "Button":
checkRefs([component.child]);
break;
}
}
}