745 lines
29 KiB
JavaScript
745 lines
29 KiB
JavaScript
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { readdir, readFile } from "node:fs/promises";
|
|
import { relative, resolve, sep } from "node:path";
|
|
|
|
import Ajv2020 from "ajv/dist/2020.js";
|
|
|
|
export const JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
|
|
export const PRP_SCHEMA_ID_PREFIX = "https://paperclip.dev/schemas/prp/";
|
|
export const PRP_V1_SCHEMA_ID_PREFIX = `${PRP_SCHEMA_ID_PREFIX}v1/`;
|
|
export const SUPPORTED_FIXTURE_VERSION = 1;
|
|
export const MIN_SUPPORTED_PROTOCOL_VERSION = 1;
|
|
export const SUPPORTED_PROTOCOL_VERSION = 2;
|
|
export const SUPPORTED_EVENT_SCHEMA_VERSION = 1;
|
|
|
|
function contractError(code, detail) {
|
|
return new Error(`${code}: ${detail}`);
|
|
}
|
|
|
|
export async function readJson(path) {
|
|
const source = await readFile(path, "utf8");
|
|
try {
|
|
return { source, value: JSON.parse(source) };
|
|
} catch (error) {
|
|
throw contractError("invalid_json", `${path}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
export async function listJsonFiles(root) {
|
|
const entries = await readdir(root, { recursive: true, withFileTypes: true });
|
|
return entries
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
.map((entry) => resolve(entry.parentPath, entry.name))
|
|
.sort();
|
|
}
|
|
|
|
export function portableRelative(root, path) {
|
|
return relative(root, path).split(sep).join("/");
|
|
}
|
|
|
|
export function sha256(source) {
|
|
return createHash("sha256").update(source).digest("hex");
|
|
}
|
|
|
|
function collectReferences(value, output = []) {
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) collectReferences(item, output);
|
|
return output;
|
|
}
|
|
if (value === null || typeof value !== "object") return output;
|
|
for (const [key, child] of Object.entries(value)) {
|
|
if (key === "$ref" && typeof child === "string") output.push(child);
|
|
else collectReferences(child, output);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function resolveJsonPointer(value, fragment) {
|
|
if (fragment === "") return value;
|
|
if (!fragment.startsWith("/")) return undefined;
|
|
return fragment
|
|
.slice(1)
|
|
.split("/")
|
|
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
|
|
.reduce((current, part) => {
|
|
if (current === null || typeof current !== "object") return undefined;
|
|
return Object.hasOwn(current, part) ? current[part] : undefined;
|
|
}, value);
|
|
}
|
|
|
|
export async function loadSchemaCatalog(schemaDirectory) {
|
|
const files = await listJsonFiles(schemaDirectory);
|
|
const records = await Promise.all(files.map(async (path) => ({ path, ...(await readJson(path)) })));
|
|
const ids = new Map();
|
|
|
|
for (const record of records) {
|
|
const schema = record.value;
|
|
if (schema.$schema !== JSON_SCHEMA_DIALECT) {
|
|
throw contractError("unsupported_schema_dialect", portableRelative(schemaDirectory, record.path));
|
|
}
|
|
if (typeof schema.$id !== "string" || !schema.$id.startsWith(PRP_SCHEMA_ID_PREFIX)) {
|
|
throw contractError("invalid_schema_id", portableRelative(schemaDirectory, record.path));
|
|
}
|
|
if (ids.has(schema.$id)) throw contractError("duplicate_schema_id", schema.$id);
|
|
if (typeof schema.title !== "string" || schema.title.length === 0) {
|
|
throw contractError("missing_schema_title", schema.$id);
|
|
}
|
|
ids.set(schema.$id, record);
|
|
}
|
|
|
|
for (const record of records) {
|
|
for (const reference of collectReferences(record.value)) {
|
|
const [targetId, fragment = ""] = reference.split("#", 2);
|
|
const target = targetId === "" ? record : ids.get(targetId);
|
|
if (target === undefined) {
|
|
throw contractError("unresolved_schema_reference", `${record.value.$id} -> ${reference}`);
|
|
}
|
|
if (resolveJsonPointer(target.value, fragment) === undefined) {
|
|
throw contractError("unresolved_schema_fragment", `${record.value.$id} -> ${reference}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return records;
|
|
}
|
|
|
|
export function compileProtocolValidators(schemaRecords) {
|
|
const ajv = new Ajv2020({
|
|
allErrors: true,
|
|
strict: true,
|
|
strictRequired: false,
|
|
formats: {
|
|
"date-time": /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/,
|
|
},
|
|
});
|
|
for (const record of schemaRecords) ajv.addSchema(record.value);
|
|
|
|
const get = (name) => {
|
|
const id = `${PRP_V1_SCHEMA_ID_PREFIX}${name}.schema.json`;
|
|
const validator = ajv.getSchema(id);
|
|
if (validator === undefined) throw contractError("missing_schema_validator", id);
|
|
return validator;
|
|
};
|
|
const getVersioned = (version, name) => {
|
|
const id = `${PRP_SCHEMA_ID_PREFIX}v${version}/${name}.schema.json`;
|
|
const validator = ajv.getSchema(id);
|
|
if (validator === undefined) throw contractError("missing_schema_validator", id);
|
|
return validator;
|
|
};
|
|
return {
|
|
conformanceFixture: get("conformance-fixture"),
|
|
conformanceOutput: get("conformance-output"),
|
|
fixture: get("fixture"),
|
|
providerDescriptor: get("provider-descriptor"),
|
|
questionAdapterFixture: get("question-adapter-fixture"),
|
|
capabilitiesV2: getVersioned(2, "capabilities"),
|
|
commandV2: getVersioned(2, "command"),
|
|
eventV2: getVersioned(2, "event"),
|
|
};
|
|
}
|
|
|
|
export function assertSchemaInstance(validator, value, location, expectedValid = true) {
|
|
const valid = validator(value);
|
|
if (valid !== expectedValid) {
|
|
const detail = (validator.errors ?? [])
|
|
.slice(0, 8)
|
|
.map((error) => `${error.instancePath || "/"} ${error.message}`)
|
|
.join("; ");
|
|
const expectation = expectedValid ? "accepted" : "rejected";
|
|
throw contractError("schema_validation_failed", `${location} must be ${expectation}: ${detail || "no AJV error"}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requireSchema(value, expected, location) {
|
|
if (value?.schema !== expected) {
|
|
throw contractError("unsupported_required_schema", `${location} requires ${String(value?.schema)}`);
|
|
}
|
|
}
|
|
|
|
function requireVersion(value, expected, name) {
|
|
if (value !== expected) {
|
|
throw contractError("unsupported_required_version", `${name}=${String(value)}; supported=${expected}`);
|
|
}
|
|
}
|
|
|
|
function requireSupportedProtocolVersion(value) {
|
|
if (!Number.isInteger(value) || value < MIN_SUPPORTED_PROTOCOL_VERSION || value > SUPPORTED_PROTOCOL_VERSION) {
|
|
throw contractError(
|
|
"unsupported_required_version",
|
|
`protocolVersion=${String(value)}; supported=${MIN_SUPPORTED_PROTOCOL_VERSION}-${SUPPORTED_PROTOCOL_VERSION}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function assertReplayFixtureCompatibility(fixture) {
|
|
requireSchema(fixture, "paperclip.prp.fixture.v1", "fixture");
|
|
requireVersion(fixture.fixtureVersion, SUPPORTED_FIXTURE_VERSION, "fixtureVersion");
|
|
requireSupportedProtocolVersion(fixture.protocolVersion);
|
|
requireSchema(fixture.identity, "paperclip.prp.identity.v1", "identity");
|
|
requireSchema(fixture.capabilities, "paperclip.prp.capabilities.v1", "capabilities");
|
|
|
|
if (!Array.isArray(fixture.commands)) throw contractError("invalid_fixture", "commands must be an array");
|
|
for (const [index, command] of fixture.commands.entries()) {
|
|
if (command?.schema !== "paperclip.prp.command.v1" && command?.schema !== "paperclip.prp.command.v2") {
|
|
throw contractError("unsupported_required_schema", `commands[${index}] requires ${String(command?.schema)}`);
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(fixture.events) || fixture.events.length === 0) {
|
|
throw contractError("invalid_fixture", "events must be a non-empty array");
|
|
}
|
|
for (const [index, event] of fixture.events.entries()) {
|
|
requireSchema(event, "paperclip.prp.event.v1", `events[${index}]`);
|
|
requireVersion(event.schemaVersion, SUPPORTED_EVENT_SCHEMA_VERSION, `events[${index}].schemaVersion`);
|
|
const semanticToolVersion = event.payload?.semantic_tool?.schemaVersion;
|
|
if (semanticToolVersion !== undefined) {
|
|
requireVersion(semanticToolVersion, 1, `events[${index}].payload.semantic_tool.schemaVersion`);
|
|
}
|
|
}
|
|
|
|
requireSchema(fixture.result, "paperclip.run_result.v1", "result");
|
|
return fixture;
|
|
}
|
|
|
|
export function assertQuestionAdapterFixture(fixture) {
|
|
requireSchema(fixture, "paperclip.question_adapter_fixture.v1", "question fixture");
|
|
requireSchema(fixture.canonicalQuestionSet, "paperclip.question_set.v1", "canonicalQuestionSet");
|
|
requireSchema(fixture.canonicalResponse, "paperclip.question_response.v1", "canonicalResponse");
|
|
|
|
const questions = fixture.canonicalQuestionSet.questions;
|
|
if (!Array.isArray(questions) || questions.length === 0) {
|
|
throw contractError("invalid_question_adapter_fixture", "questions must be non-empty");
|
|
}
|
|
const questionsById = new Map();
|
|
const optionIdsByQuestion = new Map();
|
|
for (const question of questions) {
|
|
if (typeof question.id !== "string" || question.id.length === 0 || questionsById.has(question.id)) {
|
|
throw contractError("invalid_question_adapter_fixture", "question IDs must be unique");
|
|
}
|
|
questionsById.set(question.id, question);
|
|
const optionIds = new Set();
|
|
for (const option of question.options ?? []) {
|
|
if (typeof option.id !== "string" || option.id.length === 0 || optionIds.has(option.id)) {
|
|
throw contractError("invalid_question_adapter_fixture", `option IDs for ${question.id} must be unique`);
|
|
}
|
|
optionIds.add(option.id);
|
|
}
|
|
optionIdsByQuestion.set(question.id, optionIds);
|
|
const validation = question.textValidation;
|
|
if (
|
|
validation?.minLength !== undefined
|
|
&& validation.maxLength !== undefined
|
|
&& validation.minLength > validation.maxLength
|
|
) {
|
|
throw contractError("invalid_question_adapter_fixture", `text validation for ${question.id} has minLength greater than maxLength`);
|
|
}
|
|
if (
|
|
validation?.minimum !== undefined
|
|
&& validation.maximum !== undefined
|
|
&& validation.minimum > validation.maximum
|
|
) {
|
|
throw contractError("invalid_question_adapter_fixture", `text validation for ${question.id} has minimum greater than maximum`);
|
|
}
|
|
if (validation?.pattern !== undefined) {
|
|
try {
|
|
new RegExp(validation.pattern);
|
|
} catch {
|
|
throw contractError("invalid_question_adapter_fixture", `text validation pattern for ${question.id} is invalid`);
|
|
}
|
|
}
|
|
}
|
|
const answers = fixture.canonicalResponse.answers ?? {};
|
|
for (const [answerId, answer] of Object.entries(answers)) {
|
|
if (!questionsById.has(answerId)) {
|
|
throw contractError("invalid_question_adapter_fixture", `answer has unknown question ID ${answerId}`);
|
|
}
|
|
if (answer === null || typeof answer !== "object" || Array.isArray(answer)) {
|
|
throw contractError("invalid_question_adapter_fixture", `answer for ${answerId} must be an object`);
|
|
}
|
|
const selectedOptionIds = answer.selectedOptionIds ?? [];
|
|
if (!Array.isArray(selectedOptionIds) || selectedOptionIds.some((id) => typeof id !== "string")) {
|
|
throw contractError("invalid_question_adapter_fixture", `answer for ${answerId} has invalid option IDs`);
|
|
}
|
|
if (new Set(selectedOptionIds).size !== selectedOptionIds.length) {
|
|
throw contractError("invalid_question_adapter_fixture", `answer for ${answerId} repeats an option ID`);
|
|
}
|
|
for (const optionId of selectedOptionIds) {
|
|
if (!optionIdsByQuestion.get(answerId)?.has(optionId)) {
|
|
throw contractError("invalid_question_adapter_fixture", `answer has unknown option ID ${optionId}`);
|
|
}
|
|
}
|
|
}
|
|
for (const question of questions) {
|
|
const answer = answers[question.id];
|
|
if (answer === undefined) {
|
|
if (question.required) {
|
|
throw contractError("invalid_question_adapter_fixture", `answer for required question ${question.id} is missing`);
|
|
}
|
|
continue;
|
|
}
|
|
const selectedOptionIds = answer.selectedOptionIds ?? [];
|
|
const text = answer.text;
|
|
const customText = answer.customText;
|
|
if (question.answerMode === "text") {
|
|
if (selectedOptionIds.length > 0 || customText !== undefined) {
|
|
throw contractError("invalid_question_adapter_fixture", `text answer for ${question.id} carries select-only fields`);
|
|
}
|
|
} else {
|
|
if (text !== undefined) {
|
|
throw contractError("invalid_question_adapter_fixture", `select answer for ${question.id} carries text`);
|
|
}
|
|
if (question.answerMode === "single_select" && selectedOptionIds.length > 1) {
|
|
throw contractError("invalid_question_adapter_fixture", `single-select answer for ${question.id} chooses more than one option`);
|
|
}
|
|
if (customText !== undefined && question.customAnswer?.enabled !== true) {
|
|
throw contractError("invalid_question_adapter_fixture", `custom answer for ${question.id} is not enabled`);
|
|
}
|
|
if (
|
|
question.answerMode === "single_select"
|
|
&& typeof customText === "string"
|
|
&& customText.trim().length > 0
|
|
&& selectedOptionIds.length > 0
|
|
) {
|
|
throw contractError("invalid_question_adapter_fixture", `single-select answer for ${question.id} mixes option and custom values`);
|
|
}
|
|
}
|
|
const hasValue = fixtureAnswerHasValue(answer);
|
|
if (question.required && !hasValue) {
|
|
throw contractError("invalid_question_adapter_fixture", `answer for required question ${question.id} is empty`);
|
|
}
|
|
const boundedText = question.answerMode === "text" ? text : customText;
|
|
if (boundedText !== undefined) {
|
|
const validation = question.textValidation;
|
|
if (validation?.minLength !== undefined && boundedText.length < validation.minLength) {
|
|
throw contractError("invalid_question_adapter_fixture", `answer for ${question.id} must contain at least ${validation.minLength} characters`);
|
|
}
|
|
if (validation?.maxLength !== undefined && boundedText.length > validation.maxLength) {
|
|
throw contractError("invalid_question_adapter_fixture", `answer for ${question.id} must contain at most ${validation.maxLength} characters`);
|
|
}
|
|
if (validation?.pattern !== undefined) {
|
|
let pattern;
|
|
try {
|
|
pattern = new RegExp(validation.pattern);
|
|
} catch {
|
|
throw contractError("invalid_question_adapter_fixture", `text validation pattern for ${question.id} is invalid`);
|
|
}
|
|
if (!testFixturePatternWithDeadline(pattern.source, boundedText, question.id)) {
|
|
throw contractError("invalid_question_adapter_fixture", `answer for ${question.id} does not match the required format`);
|
|
}
|
|
}
|
|
if (validation?.inputType === "number" || validation?.inputType === "integer") {
|
|
const numeric = Number(boundedText);
|
|
if (!Number.isFinite(numeric) || (validation.inputType === "integer" && !Number.isInteger(numeric))) {
|
|
throw contractError("invalid_question_adapter_fixture", `answer for ${question.id} must be a valid ${validation.inputType}`);
|
|
}
|
|
if (validation.minimum !== undefined && numeric < validation.minimum) {
|
|
throw contractError("invalid_question_adapter_fixture", `answer for ${question.id} must be at least ${validation.minimum}`);
|
|
}
|
|
if (validation.maximum !== undefined && numeric > validation.maximum) {
|
|
throw contractError("invalid_question_adapter_fixture", `answer for ${question.id} must be at most ${validation.maximum}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return fixture;
|
|
}
|
|
|
|
export function assertCodexQuestionFixture(fixture) {
|
|
assertQuestionAdapterFixture(fixture);
|
|
if (fixture.adapter !== "codex") throw contractError("unsupported_provider", String(fixture.adapter));
|
|
if (fixture.nativeRequest?.method !== "item/tool/requestUserInput") {
|
|
throw contractError("invalid_codex_question_fixture", "native request method");
|
|
}
|
|
return fixture;
|
|
}
|
|
|
|
export function assertAcpxQuestionFixture(fixture) {
|
|
assertQuestionAdapterFixture(fixture);
|
|
if (fixture.adapter !== "acpx") {
|
|
throw contractError("unsupported_provider", String(fixture.adapter));
|
|
}
|
|
const request = fixture.nativeRequest;
|
|
if (!isPlainRecord(request) || request.method !== "elicitation/create") {
|
|
throw contractError("invalid_acpx_question_fixture", "native request method");
|
|
}
|
|
const params = request.params;
|
|
const requestedSchema = isPlainRecord(params) ? params.requestedSchema : null;
|
|
if (
|
|
!isPlainRecord(params)
|
|
|| params.mode !== "form"
|
|
|| !isPlainRecord(requestedSchema)
|
|
|| !isPlainRecord(requestedSchema.properties)
|
|
) {
|
|
throw contractError("invalid_acpx_question_fixture", "native form request");
|
|
}
|
|
const properties = Object.entries(requestedSchema.properties);
|
|
if (properties.length === 0 || properties.length > 64) {
|
|
throw contractError("invalid_acpx_question_fixture", "native form property count");
|
|
}
|
|
const required = normalizedAcpxRequired(requestedSchema);
|
|
for (const [name, property] of properties) {
|
|
assertAcpxProperty(name, property);
|
|
if (
|
|
isPlainRecord(property)
|
|
&& property.type === "string"
|
|
&& acpxEnumValues(property, "oneOf").length === 0
|
|
&& optionalFixtureText(property.pattern) !== undefined
|
|
) {
|
|
throw contractError(
|
|
"invalid_acpx_question_fixture",
|
|
`native property ${name} uses an unsupported pattern`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const response = fixture.nativeResponse;
|
|
if (
|
|
!isPlainRecord(response)
|
|
|| response.action !== "accept"
|
|
|| !isPlainRecord(response.content)
|
|
) {
|
|
throw contractError("invalid_acpx_question_fixture", "native accept response");
|
|
}
|
|
for (const [name] of properties) {
|
|
if (required.has(name) && !Object.hasOwn(response.content, name)) {
|
|
throw contractError("invalid_acpx_question_fixture", `native response omits required property ${name}`);
|
|
}
|
|
}
|
|
for (const [name, value] of Object.entries(response.content)) {
|
|
const property = requestedSchema.properties[name];
|
|
if (!isPlainRecord(property)) {
|
|
throw contractError("invalid_acpx_question_fixture", `native response has unknown property ${name}`);
|
|
}
|
|
assertAcpxPropertyValue(name, property, value);
|
|
}
|
|
const projected = projectAcpxFixture(params, fixture.canonicalResponse);
|
|
if (canonicalFixtureJson(projected.questionSet) !== canonicalFixtureJson(fixture.canonicalQuestionSet)) {
|
|
throw contractError("invalid_acpx_question_fixture", "native and canonical question sets differ");
|
|
}
|
|
if (canonicalFixtureJson(projected.nativeResponse) !== canonicalFixtureJson(fixture.nativeResponse)) {
|
|
throw contractError("invalid_acpx_question_fixture", "canonical and native responses differ");
|
|
}
|
|
return fixture;
|
|
}
|
|
|
|
function projectAcpxFixture(params, canonicalResponse) {
|
|
const schema = params.requestedSchema;
|
|
const required = normalizedAcpxRequired(schema);
|
|
const bindings = Object.entries(schema.properties).map(([name, property], index) =>
|
|
projectAcpxProperty(name, property, index, required.has(name))
|
|
);
|
|
const title = optionalFixtureText(schema.title) ?? "Additional information needed";
|
|
const descriptions = [
|
|
optionalFixtureText(params.message),
|
|
optionalFixtureText(schema.description),
|
|
].filter((value, index, all) => value !== undefined && value !== title && all.indexOf(value) === index);
|
|
const questionSet = {
|
|
schema: "paperclip.question_set.v1",
|
|
title,
|
|
...(descriptions.length > 0 ? { description: descriptions.join("\n\n") } : {}),
|
|
submitLabel: "Submit answers",
|
|
questions: bindings.map((binding) => binding.question),
|
|
};
|
|
const content = {};
|
|
for (const binding of bindings) {
|
|
const answer = canonicalResponse.answers?.[binding.question.id];
|
|
// The production parser accepts explicit empty optional answers and omits
|
|
// them from its normalized response. Mirror that before projecting ACP
|
|
// content so the fixture gate certifies the same wire behavior.
|
|
if (!answer || !fixtureAnswerHasValue(answer)) continue;
|
|
if (binding.type === "string" && binding.question.answerMode === "text") {
|
|
if (answer.text !== undefined) defineAcpxResponseProperty(content, binding.name, answer.text);
|
|
} else if (binding.type === "number" || binding.type === "integer") {
|
|
if (answer.text !== undefined) {
|
|
defineAcpxResponseProperty(content, binding.name, Number(answer.text));
|
|
}
|
|
} else if (binding.type === "boolean") {
|
|
const selected = answer.selectedOptionIds?.[0];
|
|
if (selected !== undefined) {
|
|
defineAcpxResponseProperty(
|
|
content,
|
|
binding.name,
|
|
binding.optionValues.get(selected) === "true",
|
|
);
|
|
}
|
|
} else {
|
|
const selected = (answer.selectedOptionIds ?? []).map((id) => binding.optionValues.get(id));
|
|
if (binding.type === "array") {
|
|
defineAcpxResponseProperty(content, binding.name, selected);
|
|
} else if (selected[0] !== undefined) {
|
|
defineAcpxResponseProperty(content, binding.name, selected[0]);
|
|
}
|
|
}
|
|
}
|
|
return { questionSet, nativeResponse: { action: "accept", content } };
|
|
}
|
|
|
|
function fixtureAnswerHasValue(answer) {
|
|
return (
|
|
(typeof answer.text === "string" && answer.text.trim().length > 0)
|
|
|| (typeof answer.customText === "string" && answer.customText.trim().length > 0)
|
|
|| (Array.isArray(answer.selectedOptionIds) && answer.selectedOptionIds.length > 0)
|
|
);
|
|
}
|
|
|
|
function defineAcpxResponseProperty(content, name, value) {
|
|
Object.defineProperty(content, name, {
|
|
value,
|
|
enumerable: true,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
}
|
|
|
|
function normalizedAcpxRequired(schema) {
|
|
const propertyNames = new Set(
|
|
isPlainRecord(schema.properties) ? Object.keys(schema.properties) : [],
|
|
);
|
|
return new Set(
|
|
Array.isArray(schema.required)
|
|
? schema.required
|
|
.filter(
|
|
(value) => typeof value === "string" && propertyNames.has(value),
|
|
)
|
|
: []
|
|
);
|
|
}
|
|
|
|
function projectAcpxProperty(name, property, index, required) {
|
|
const id = stableAcpxFieldId(name, index);
|
|
const header = optionalFixtureText(property.title) ?? name;
|
|
const base = {
|
|
id,
|
|
header,
|
|
prompt: optionalFixtureText(property.description) ?? header,
|
|
required,
|
|
};
|
|
if (property.type === "string") {
|
|
const options = acpxNativeOptions(property, "oneOf");
|
|
if (options.length > 0) return projectedAcpxOptions(name, property.type, base, options, "single_select");
|
|
const pattern = optionalFixtureText(property.pattern);
|
|
return {
|
|
name,
|
|
type: property.type,
|
|
optionValues: new Map(),
|
|
question: {
|
|
...base,
|
|
answerMode: "text",
|
|
textValidation: {
|
|
inputType: "text",
|
|
...(finiteNonNegativeFixtureInteger(property.minLength) !== undefined
|
|
? { minLength: property.minLength }
|
|
: {}),
|
|
...(finiteNonNegativeFixtureInteger(property.maxLength) !== undefined
|
|
? { maxLength: property.maxLength }
|
|
: {}),
|
|
...(pattern !== undefined ? { pattern } : {}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (property.type === "number" || property.type === "integer") {
|
|
return {
|
|
name,
|
|
type: property.type,
|
|
optionValues: new Map(),
|
|
question: {
|
|
...base,
|
|
answerMode: "text",
|
|
textValidation: {
|
|
inputType: property.type,
|
|
...(Number.isFinite(property.minimum) ? { minimum: property.minimum } : {}),
|
|
...(Number.isFinite(property.maximum) ? { maximum: property.maximum } : {}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (property.type === "boolean") {
|
|
return projectedAcpxOptions(name, property.type, base, [
|
|
{ value: "true", label: "Yes" },
|
|
{ value: "false", label: "No" },
|
|
], "single_select");
|
|
}
|
|
return projectedAcpxOptions(
|
|
name,
|
|
property.type,
|
|
base,
|
|
acpxNativeOptions(property.items, "anyOf"),
|
|
"multi_select",
|
|
);
|
|
}
|
|
|
|
function testFixturePatternWithDeadline(pattern, value, questionId) {
|
|
const outcome = spawnSync(
|
|
process.execPath,
|
|
[
|
|
"--input-type=module",
|
|
"--eval",
|
|
"const chunks=[];for await(const chunk of process.stdin)chunks.push(chunk);const {pattern,value}=JSON.parse(Buffer.concat(chunks).toString());process.stdout.write(new RegExp(pattern).test(value)?'1':'0');",
|
|
],
|
|
{
|
|
input: JSON.stringify({ pattern, value }),
|
|
encoding: "utf8",
|
|
timeout: 1_000,
|
|
maxBuffer: 1_024,
|
|
windowsHide: true,
|
|
},
|
|
);
|
|
if (outcome.error || outcome.signal || outcome.status !== 0) {
|
|
throw contractError(
|
|
"invalid_question_adapter_fixture",
|
|
`text validation pattern for ${questionId} could not be evaluated safely`,
|
|
);
|
|
}
|
|
return outcome.stdout === "1";
|
|
}
|
|
|
|
function projectedAcpxOptions(name, type, base, nativeOptions, answerMode) {
|
|
const optionValues = new Map();
|
|
const options = nativeOptions.map((option, index) => {
|
|
const id = `option-${index + 1}`;
|
|
optionValues.set(id, option.value);
|
|
return {
|
|
id,
|
|
label: option.label,
|
|
...(option.description !== undefined ? { description: option.description } : {}),
|
|
};
|
|
});
|
|
return { name, type, optionValues, question: { ...base, answerMode, options } };
|
|
}
|
|
|
|
function acpxNativeOptions(property, preferredTitledKey) {
|
|
const alternateTitledKey = preferredTitledKey === "anyOf" ? "oneOf" : "anyOf";
|
|
const titled = property[preferredTitledKey]
|
|
?? property[alternateTitledKey];
|
|
if (Array.isArray(titled)) {
|
|
return titled.map((entry) => ({
|
|
value: entry.const,
|
|
label: optionalFixtureText(entry.title) ?? entry.const,
|
|
...(optionalFixtureText(entry.description) !== undefined
|
|
? { description: entry.description }
|
|
: {}),
|
|
}));
|
|
}
|
|
return (Array.isArray(property.enum) ? property.enum : [])
|
|
.map((value) => ({ value, label: value }));
|
|
}
|
|
|
|
function stableAcpxFieldId(value, index) {
|
|
const readable = value
|
|
.trim()
|
|
.replace(/[^A-Za-z0-9._:-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 96);
|
|
return `field-${index + 1}-${readable || "value"}-${sha256(value).slice(0, 12)}`;
|
|
}
|
|
|
|
function optionalFixtureText(value) {
|
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
}
|
|
|
|
function finiteNonNegativeFixtureInteger(value) {
|
|
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
}
|
|
|
|
function canonicalFixtureJson(value) {
|
|
if (Array.isArray(value)) return `[${value.map(canonicalFixtureJson).join(",")}]`;
|
|
if (isPlainRecord(value)) {
|
|
return `{${Object.keys(value).sort().map((key) =>
|
|
`${JSON.stringify(key)}:${canonicalFixtureJson(value[key])}`
|
|
).join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function assertAcpxProperty(name, value) {
|
|
if (!isPlainRecord(value)) {
|
|
throw contractError("invalid_acpx_question_fixture", `native property ${name}`);
|
|
}
|
|
if (!["string", "number", "integer", "boolean", "array"].includes(value.type)) {
|
|
throw contractError("invalid_acpx_question_fixture", `unsupported native property ${name}`);
|
|
}
|
|
if (value.type === "array") {
|
|
if (!isPlainRecord(value.items) || acpxEnumValues(value.items, "anyOf").length === 0) {
|
|
throw contractError("invalid_acpx_question_fixture", `native array property ${name} requires options`);
|
|
}
|
|
} else if (value.type === "string") {
|
|
acpxEnumValues(value, "oneOf");
|
|
}
|
|
}
|
|
|
|
function assertAcpxPropertyValue(name, property, value) {
|
|
const typeMatches = property.type === "string"
|
|
? typeof value === "string"
|
|
: property.type === "number"
|
|
? typeof value === "number" && Number.isFinite(value)
|
|
: property.type === "integer"
|
|
? Number.isInteger(value)
|
|
: property.type === "boolean"
|
|
? typeof value === "boolean"
|
|
: Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
if (!typeMatches) {
|
|
throw contractError("invalid_acpx_question_fixture", `native response type for ${name}`);
|
|
}
|
|
const enumValues = property.type === "array"
|
|
? acpxEnumValues(property.items, "anyOf")
|
|
: property.type === "string"
|
|
? acpxEnumValues(property, "oneOf")
|
|
: [];
|
|
const responseValues = Array.isArray(value) ? value : [value];
|
|
if (enumValues.length > 0 && responseValues.some((item) => !enumValues.includes(item))) {
|
|
throw contractError("invalid_acpx_question_fixture", `native response option for ${name}`);
|
|
}
|
|
}
|
|
|
|
function acpxEnumValues(property, preferredTitledKey) {
|
|
if (!isPlainRecord(property)) return [];
|
|
const alternateTitledKey = preferredTitledKey === "anyOf" ? "oneOf" : "anyOf";
|
|
const titled = property[preferredTitledKey]
|
|
?? property[alternateTitledKey];
|
|
const values = Array.isArray(titled)
|
|
? titled.map((entry) => isPlainRecord(entry) ? entry.const : undefined)
|
|
: Array.isArray(property.enum)
|
|
? property.enum
|
|
: [];
|
|
if (
|
|
!Array.isArray(values)
|
|
|| values.length > 128
|
|
|| values.some((value) => typeof value !== "string" || value.length === 0)
|
|
) {
|
|
throw contractError("invalid_acpx_question_fixture", "native options must be non-empty bounded strings");
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function isPlainRecord(value) {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
export function assertConformanceFixturePair(fixture, output) {
|
|
if (fixture?.schemaVersion !== "paperclip.runner.conformance.fixture.v1") {
|
|
throw contractError("unsupported_required_schema", `conformance fixture requires ${String(fixture?.schemaVersion)}`);
|
|
}
|
|
if (output?.schemaVersion !== "paperclip.runner.conformance.output.v1") {
|
|
throw contractError("unsupported_required_schema", `conformance output requires ${String(output?.schemaVersion)}`);
|
|
}
|
|
if (fixture.run?.runId !== output.runIdentity?.runId || fixture.run?.sessionId !== output.runIdentity?.sessionId) {
|
|
throw contractError("conformance_identity_mismatch", "run or session identity differs");
|
|
}
|
|
for (const [index, event] of (fixture.events ?? []).entries()) {
|
|
if (event.runId !== fixture.run.runId || event.sequence !== index + 1) {
|
|
throw contractError("invalid_conformance_event", `events[${index}] does not match the run sequence`);
|
|
}
|
|
}
|
|
if (
|
|
fixture.result?.status !== output.result?.status
|
|
|| fixture.result?.summary !== output.result?.summary
|
|
|| fixture.result?.runId !== fixture.run.runId
|
|
) {
|
|
throw contractError("conformance_result_mismatch", "expected output does not match the fixture result");
|
|
}
|
|
return { fixture, output };
|
|
}
|