From 3c4c8eba1735e515ae826e67ea9ea928bad641be Mon Sep 17 00:00:00 2001 From: Clouds Beyond <34269366+cloudsbeyond@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:42:29 +0800 Subject: [PATCH] protocol: add conformance verification gate Change-Id: I35d107ab76f0e1c3f637ccffd1641a8e05ee2ef7 --- .github/workflows/verify.yml | 22 ++ AGENTS.md | 8 + README.md | 9 + README.zh-CN.md | 9 + protocol/README.md | 20 ++ protocol/fixtures/action-plan.invalid.json | 6 + protocol/fixtures/action-plan.valid.json | 14 + protocol/fixtures/action-result.invalid.json | 7 + protocol/fixtures/action-result.valid.json | 11 + protocol/fixtures/error-envelope.invalid.json | 6 + protocol/fixtures/error-envelope.valid.json | 8 + .../fixtures/scenario-manifest.invalid.json | 11 + .../fixtures/scenario-manifest.valid.json | 18 ++ protocol/schemas/action-plan.schema.json | 79 +++++ protocol/schemas/action-result.schema.json | 32 ++ protocol/schemas/error-envelope.schema.json | 22 ++ .../schemas/scenario-manifest.schema.json | 65 ++++ runtimes/desktop-gui/README.md | 8 + runtimes/desktop-gui/README.zh-CN.md | 8 + scripts/verify-local.sh | 31 ++ scripts/verify-protocol-conformance.mjs | 284 ++++++++++++++++++ 21 files changed, 678 insertions(+) create mode 100644 .github/workflows/verify.yml create mode 100644 protocol/fixtures/action-plan.invalid.json create mode 100644 protocol/fixtures/action-plan.valid.json create mode 100644 protocol/fixtures/action-result.invalid.json create mode 100644 protocol/fixtures/action-result.valid.json create mode 100644 protocol/fixtures/error-envelope.invalid.json create mode 100644 protocol/fixtures/error-envelope.valid.json create mode 100644 protocol/fixtures/scenario-manifest.invalid.json create mode 100644 protocol/fixtures/scenario-manifest.valid.json create mode 100644 protocol/schemas/action-plan.schema.json create mode 100644 protocol/schemas/action-result.schema.json create mode 100644 protocol/schemas/error-envelope.schema.json create mode 100644 protocol/schemas/scenario-manifest.schema.json create mode 100755 scripts/verify-local.sh create mode 100644 scripts/verify-protocol-conformance.mjs diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..966992e --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,22 @@ +name: Verify + +on: + pull_request: + push: + branches: + - main + +jobs: + verify: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Show tool versions + run: | + node --version + swift --version + + - name: Run local verification gate + run: bash scripts/verify-local.sh diff --git a/AGENTS.md b/AGENTS.md index 9b02151..100fdd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,13 @@ Project-local coordination notes are ignored by Git and are not project source-o Run from the repository root when touching runtime or protocol behavior: ```bash +bash scripts/verify-local.sh +``` + +The expanded core commands are: + +```bash +node scripts/verify-protocol-conformance.mjs swiftc -typecheck runtimes/desktop-gui/src/action_executor.swift swiftc -typecheck runtimes/desktop-gui/src/scene_runner.swift swiftc -typecheck runtimes/desktop-gui/src/task_flow.swift @@ -127,6 +134,7 @@ bash runtimes/desktop-gui/scripts/smoke.sh ``` Expected smoke success: `SMOKE_OK`. +Pull requests run the same local gate through `.github/workflows/verify.yml`. ## Spec Checkpoint diff --git a/README.md b/README.md index cb5dfb6..76f4fc0 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,14 @@ as adapters or forked projects. Run from the repository root: ```bash +bash scripts/verify-local.sh +``` + +The script runs the protocol conformance checks, adapter syntax checks, desktop GUI runtime +typechecks, shell syntax checks, and smoke test. The expanded core commands are: + +```bash +node scripts/verify-protocol-conformance.mjs swiftc -typecheck runtimes/desktop-gui/src/action_executor.swift swiftc -typecheck runtimes/desktop-gui/src/scene_runner.swift swiftc -typecheck runtimes/desktop-gui/src/task_flow.swift @@ -124,3 +132,4 @@ bash runtimes/desktop-gui/scripts/smoke.sh ``` Expected smoke result: `SMOKE_OK`. +Pull requests run the same local gate through `.github/workflows/verify.yml`. diff --git a/README.zh-CN.md b/README.zh-CN.md index 924397a..65e5d05 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -99,6 +99,14 @@ P0 定义为: 在仓库根目录运行: ```bash +bash scripts/verify-local.sh +``` + +这个脚本会执行协议 conformance、适配器语法检查、desktop GUI runtime typecheck、shell +语法检查和 smoke。展开后的核心命令是: + +```bash +node scripts/verify-protocol-conformance.mjs swiftc -typecheck runtimes/desktop-gui/src/action_executor.swift swiftc -typecheck runtimes/desktop-gui/src/scene_runner.swift swiftc -typecheck runtimes/desktop-gui/src/task_flow.swift @@ -106,3 +114,4 @@ bash runtimes/desktop-gui/scripts/smoke.sh ``` 期望 smoke 输出:`SMOKE_OK`。 +Pull Request 会通过 `.github/workflows/verify.yml` 运行同一个本地 gate。 diff --git a/protocol/README.md b/protocol/README.md index 498eba8..6466acb 100644 --- a/protocol/README.md +++ b/protocol/README.md @@ -29,6 +29,10 @@ documents: L1: protocol/L1-runtime-capability-manual.md L2: protocol/L2-surface-manual.md L3: protocol/L3-scenario-requirements.md + machine_verifiable_contracts: + schemas: protocol/schemas/ + fixtures: protocol/fixtures/ + local_check: node scripts/verify-protocol-conformance.mjs integration: integration/host-embedding.md runtime: desktop_gui_reference: @@ -232,6 +236,19 @@ host_integration: Capabilities: { command: "action_executor capabilities", defined_in: "INT section 7" } ``` +Machine-verifiable schemas currently cover the first stable wire slice: + +- `protocol/schemas/action-plan.schema.json` +- `protocol/schemas/action-result.schema.json` +- `protocol/schemas/error-envelope.schema.json` +- `protocol/schemas/scenario-manifest.schema.json` + +Fixtures under `protocol/fixtures/` provide one valid and one intentionally invalid example per +object. Run `node scripts/verify-protocol-conformance.mjs` to check the fixtures, the current +desktop GUI reference manifest, and runtime-backed negative cases for stable error-envelope +reasons. These schemas verify current wire shapes; the prose protocol files remain authoritative +for semantics, layer boundaries, and future extension rules. + ## 5. Cross-Cutting Invariants These invariants bind the root contract, current reference specs, and runtime files. @@ -313,6 +330,9 @@ playbooks: - "no model calls or task-success judgement entered runtime/adapters" - "binding-block citations still point to valid sections" run_checks: + local_gate: "bash scripts/verify-local.sh" + ci_gate: ".github/workflows/verify.yml" + conformance: "node scripts/verify-protocol-conformance.mjs" typecheck: - "swiftc -typecheck runtimes/desktop-gui/src/action_executor.swift" - "swiftc -typecheck runtimes/desktop-gui/src/scene_runner.swift" diff --git a/protocol/fixtures/action-plan.invalid.json b/protocol/fixtures/action-plan.invalid.json new file mode 100644 index 0000000..6d5c0da --- /dev/null +++ b/protocol/fixtures/action-plan.invalid.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "kind": "action_plan", + "dryRun": true, + "actions": [] +} diff --git a/protocol/fixtures/action-plan.valid.json b/protocol/fixtures/action-plan.valid.json new file mode 100644 index 0000000..0ad03dd --- /dev/null +++ b/protocol/fixtures/action-plan.valid.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "kind": "action_plan", + "wireFormat": "openai_computer_call_compatible", + "call_id": "fixture_action_plan_valid", + "pending_safety_checks": [], + "confirmationStatus": "not_required", + "dryRun": true, + "allowNegativeWindowOrigin": false, + "actions": [ + { "type": "keypress", "keys": ["ESC"], "mode": "hid" }, + { "type": "wait", "durationMs": 1 } + ] +} diff --git a/protocol/fixtures/action-result.invalid.json b/protocol/fixtures/action-result.invalid.json new file mode 100644 index 0000000..585d1fc --- /dev/null +++ b/protocol/fixtures/action-result.invalid.json @@ -0,0 +1,7 @@ +{ + "ok": false, + "kind": "action_result", + "results": [ + { "ok": true, "type": "wait", "dryRun": true } + ] +} diff --git a/protocol/fixtures/action-result.valid.json b/protocol/fixtures/action-result.valid.json new file mode 100644 index 0000000..462c2ee --- /dev/null +++ b/protocol/fixtures/action-result.valid.json @@ -0,0 +1,11 @@ +{ + "ok": true, + "kind": "action_result", + "wireFormat": "openai_computer_call_result_compatible", + "call_id": "fixture_action_plan_valid", + "dryRun": true, + "results": [ + { "index": 0, "ok": true, "type": "keypress", "dryRun": true, "appLabel": "target app" }, + { "index": 1, "ok": true, "type": "wait", "dryRun": true, "appLabel": "target app" } + ] +} diff --git a/protocol/fixtures/error-envelope.invalid.json b/protocol/fixtures/error-envelope.invalid.json new file mode 100644 index 0000000..ba0aeec --- /dev/null +++ b/protocol/fixtures/error-envelope.invalid.json @@ -0,0 +1,6 @@ +{ + "ok": false, + "kind": "error", + "reason": "PendingSafetyApproval", + "message": "" +} diff --git a/protocol/fixtures/error-envelope.valid.json b/protocol/fixtures/error-envelope.valid.json new file mode 100644 index 0000000..0a2cdfe --- /dev/null +++ b/protocol/fixtures/error-envelope.valid.json @@ -0,0 +1,8 @@ +{ + "ok": false, + "kind": "error", + "reason": "pending_safety_approval", + "message": "pending_safety_checks require confirmationStatus=approved", + "call_id": "fixture_safety_gate", + "pendingSafetyCheckCount": 1 +} diff --git a/protocol/fixtures/scenario-manifest.invalid.json b/protocol/fixtures/scenario-manifest.invalid.json new file mode 100644 index 0000000..591e42b --- /dev/null +++ b/protocol/fixtures/scenario-manifest.invalid.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "scene": "fixture-surface", + "profile": {}, + "intents": { + "closeOverlay": { + "required": [], + "steps": [] + } + } +} diff --git a/protocol/fixtures/scenario-manifest.valid.json b/protocol/fixtures/scenario-manifest.valid.json new file mode 100644 index 0000000..3f2be9d --- /dev/null +++ b/protocol/fixtures/scenario-manifest.valid.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "scene": "fixture-surface", + "profile": { + "bundleID": "com.example.placeholder", + "ownerNames": ["PlaceholderApp"], + "appLabel": "PlaceholderApp" + }, + "intents": { + "closeOverlay": { + "required": [], + "defaults": { "mode": "hid", "dryRun": true }, + "steps": [ + { "type": "keypress", "fields": { "keys": ["ESC"], "mode": "${mode}" } } + ] + } + } +} diff --git a/protocol/schemas/action-plan.schema.json b/protocol/schemas/action-plan.schema.json new file mode 100644 index 0000000..3a81b9c --- /dev/null +++ b/protocol/schemas/action-plan.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cloudsbeyond.github.io/visual-agent/protocol/schemas/action-plan.schema.json", + "title": "ActionPlan", + "description": "L1 ActionPlan consumed by the runtime capability executor.", + "type": "object", + "required": ["schemaVersion", "kind", "actions"], + "properties": { + "schemaVersion": { "const": 1 }, + "kind": { "const": "action_plan" }, + "wireFormat": { "type": "string" }, + "call_id": { "type": "string", "minLength": 1 }, + "status": { "type": "string" }, + "pending_safety_checks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "code": { "type": "string" }, + "message": { "type": "string" } + } + } + }, + "confirmationStatus": { + "type": "string", + "enum": ["not_required", "pending", "approved", "denied"] + }, + "dryRun": { "type": "boolean" }, + "bundleID": { "type": "string" }, + "ownerNames": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "appLabel": { "type": "string" }, + "allowNegativeWindowOrigin": { "type": "boolean" }, + "actions": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/action" } + } + }, + "$defs": { + "action": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["click", "scroll", "keypress", "wait", "screenshot", "type", "drag", "move", "double_click"] + }, + "x": { "type": "number" }, + "y": { "type": "number" }, + "width": { "type": "number", "minimum": 0 }, + "height": { "type": "number", "minimum": 0 }, + "scrollX": { "type": "number" }, + "scrollY": { "type": "number" }, + "times": { "type": "integer", "minimum": 1 }, + "button": { "type": "string" }, + "keys": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "durationMs": { "type": "integer", "minimum": 0 }, + "output": { "type": "string", "minLength": 1 }, + "maxEdge": { "type": "integer", "minimum": 1 }, + "mode": { "type": "string", "enum": ["cliclick", "hid", "pid"] }, + "coordinateSpace": { + "type": "string", + "enum": ["windowTopLeft", "windowBottomLeft", "screenTopLeft", "screenBottomLeft"] + }, + "restoreMouse": { "type": "boolean" }, + "dryRun": { "type": "boolean" }, + "allowNegativeWindowOrigin": { "type": "boolean" } + } + } + } +} diff --git a/protocol/schemas/action-result.schema.json b/protocol/schemas/action-result.schema.json new file mode 100644 index 0000000..61fcd18 --- /dev/null +++ b/protocol/schemas/action-result.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cloudsbeyond.github.io/visual-agent/protocol/schemas/action-result.schema.json", + "title": "ActionResult", + "description": "L1 runtime result. ok:true means runtime dispatch/completion only, never task success.", + "type": "object", + "required": ["ok", "kind", "results"], + "properties": { + "ok": { "const": true }, + "kind": { "const": "action_result" }, + "wireFormat": { "type": "string" }, + "call_id": { "type": "string" }, + "dryRun": { "type": "boolean" }, + "results": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["index", "ok", "type", "dryRun"], + "properties": { + "index": { "type": "integer", "minimum": 0 }, + "ok": { "type": "boolean" }, + "type": { "type": "string", "minLength": 1 }, + "dryRun": { "type": "boolean" }, + "appLabel": { "type": "string" }, + "resolved": { "type": "object" }, + "message": { "type": "string" } + } + } + } + } +} diff --git a/protocol/schemas/error-envelope.schema.json b/protocol/schemas/error-envelope.schema.json new file mode 100644 index 0000000..89340ff --- /dev/null +++ b/protocol/schemas/error-envelope.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cloudsbeyond.github.io/visual-agent/protocol/schemas/error-envelope.schema.json", + "title": "ErrorEnvelope", + "description": "Machine-readable failure envelope emitted by runtime entry points and adapters.", + "type": "object", + "required": ["ok", "kind", "reason", "message"], + "properties": { + "ok": { "const": false }, + "kind": { "const": "error" }, + "reason": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" + }, + "message": { + "type": "string", + "minLength": 1 + }, + "call_id": { "type": "string" }, + "pendingSafetyCheckCount": { "type": "integer", "minimum": 0 } + } +} diff --git a/protocol/schemas/scenario-manifest.schema.json b/protocol/schemas/scenario-manifest.schema.json new file mode 100644 index 0000000..5d5d399 --- /dev/null +++ b/protocol/schemas/scenario-manifest.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cloudsbeyond.github.io/visual-agent/protocol/schemas/scenario-manifest.schema.json", + "title": "ScenarioManifest", + "description": "L3 Scenario Manifest, currently named Scene Manifest by the desktop GUI reference runtime.", + "type": "object", + "required": ["schemaVersion", "scene", "profile", "intents"], + "properties": { + "schemaVersion": { "const": 1 }, + "scene": { "type": "string", "minLength": 1 }, + "profile": { + "type": "object", + "properties": { + "bundleID": { "type": "string" }, + "ownerNames": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "appLabel": { "type": "string" } + } + }, + "intents": { + "type": "object", + "minProperties": 1, + "additionalProperties": { "$ref": "#/$defs/intent" } + } + }, + "$defs": { + "intent": { + "type": "object", + "required": ["required", "steps"], + "properties": { + "description": { "type": "string" }, + "required": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "defaults": { "type": "object" }, + "paramTypes": { + "type": "object", + "additionalProperties": { + "type": "string", + "enum": ["string", "number", "integer", "boolean", "array", "object"] + } + }, + "steps": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/step" } + } + } + }, + "step": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["click", "scroll", "keypress", "wait", "screenshot", "type", "drag", "move", "double_click"] + }, + "fields": { "type": "object" } + } + } + } +} diff --git a/runtimes/desktop-gui/README.md b/runtimes/desktop-gui/README.md index 53cf0d0..d08b818 100644 --- a/runtimes/desktop-gui/README.md +++ b/runtimes/desktop-gui/README.md @@ -161,6 +161,13 @@ bash scripts/smoke.sh Run from the repository root: ```bash +bash scripts/verify-local.sh +``` + +Expanded runtime checks: + +```bash +node scripts/verify-protocol-conformance.mjs swiftc -typecheck runtimes/desktop-gui/src/action_executor.swift swiftc -typecheck runtimes/desktop-gui/src/scene_runner.swift swiftc -typecheck runtimes/desktop-gui/src/task_flow.swift @@ -168,6 +175,7 @@ bash runtimes/desktop-gui/scripts/smoke.sh ``` Expected smoke result: `SMOKE_OK`. +Pull requests run the same local gate through `../../.github/workflows/verify.yml`. ## Integration diff --git a/runtimes/desktop-gui/README.zh-CN.md b/runtimes/desktop-gui/README.zh-CN.md index 34adfe6..d05195b 100644 --- a/runtimes/desktop-gui/README.zh-CN.md +++ b/runtimes/desktop-gui/README.zh-CN.md @@ -152,6 +152,13 @@ bash scripts/smoke.sh 从仓库根目录运行: ```bash +bash scripts/verify-local.sh +``` + +展开后的 runtime 检查: + +```bash +node scripts/verify-protocol-conformance.mjs swiftc -typecheck runtimes/desktop-gui/src/action_executor.swift swiftc -typecheck runtimes/desktop-gui/src/scene_runner.swift swiftc -typecheck runtimes/desktop-gui/src/task_flow.swift @@ -159,6 +166,7 @@ bash runtimes/desktop-gui/scripts/smoke.sh ``` 期望 smoke 输出:`SMOKE_OK`。 +Pull Request 会通过 `../../.github/workflows/verify.yml` 运行同一个本地 gate。 ## 集成 diff --git a/scripts/verify-local.sh b/scripts/verify-local.sh new file mode 100755 index 0000000..558eda6 --- /dev/null +++ b/scripts/verify-local.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +echo "== diff hygiene ==" +git diff --check + +echo "== protocol conformance ==" +node --check scripts/verify-protocol-conformance.mjs +node scripts/verify-protocol-conformance.mjs + +echo "== adapter syntax ==" +node --check adapters/tool-calling/invoke.mjs +node --check adapters/workflow/node.mjs + +echo "== desktop GUI runtime typecheck ==" +swiftc -typecheck runtimes/desktop-gui/src/action_executor.swift +swiftc -typecheck runtimes/desktop-gui/src/scene_runner.swift +swiftc -typecheck runtimes/desktop-gui/src/task_flow.swift + +echo "== shell syntax ==" +bash -n runtimes/desktop-gui/scripts/build.sh +bash -n runtimes/desktop-gui/scripts/smoke.sh + +echo "== runtime smoke ==" +bash runtimes/desktop-gui/scripts/smoke.sh + +echo "VERIFY_LOCAL_OK" diff --git a/scripts/verify-protocol-conformance.mjs b/scripts/verify-protocol-conformance.mjs new file mode 100644 index 0000000..8c53973 --- /dev/null +++ b/scripts/verify-protocol-conformance.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const l1Runtime = "runtimes/desktop-gui/src/action_executor.swift"; +const l3Runtime = "runtimes/desktop-gui/src/task_flow.swift"; +const referenceManifest = "runtimes/desktop-gui/examples/reference-surface/manifest.json"; + +const checks = [ + { + name: "ActionPlan", + schema: "protocol/schemas/action-plan.schema.json", + valid: ["protocol/fixtures/action-plan.valid.json"], + invalid: ["protocol/fixtures/action-plan.invalid.json"], + }, + { + name: "ActionResult", + schema: "protocol/schemas/action-result.schema.json", + valid: ["protocol/fixtures/action-result.valid.json"], + invalid: ["protocol/fixtures/action-result.invalid.json"], + }, + { + name: "ErrorEnvelope", + schema: "protocol/schemas/error-envelope.schema.json", + valid: ["protocol/fixtures/error-envelope.valid.json"], + invalid: ["protocol/fixtures/error-envelope.invalid.json"], + }, + { + name: "ScenarioManifest", + schema: "protocol/schemas/scenario-manifest.schema.json", + valid: [ + "protocol/fixtures/scenario-manifest.valid.json", + referenceManifest, + ], + invalid: ["protocol/fixtures/scenario-manifest.invalid.json"], + }, +]; + +const runtimeErrorCases = [ + { + name: "reserved_action", + argv: [ + "swift", + l1Runtime, + "run-plan", + JSON.stringify({ + schemaVersion: 1, + kind: "action_plan", + call_id: "conformance_reserved_action", + dryRun: true, + actions: [{ type: "type" }], + }), + ], + reason: "reserved_action", + }, + { + name: "pending_safety_approval", + argv: [ + "swift", + l1Runtime, + "run-plan", + JSON.stringify({ + schemaVersion: 1, + kind: "action_plan", + call_id: "conformance_pending_safety", + dryRun: false, + pending_safety_checks: [{ id: "c1", code: "confirm", message: "needs approval" }], + actions: [{ type: "keypress", keys: ["ESC"] }], + }), + ], + reason: "pending_safety_approval", + }, + { + name: "missing_required_field", + argv: [ + "swift", + l3Runtime, + "--manifest", + referenceManifest, + "preview-json", + JSON.stringify({ intent: "openTarget", dryRun: true }), + ], + reason: "missing_required_field", + }, + { + name: "unsupported_intent", + argv: [ + "swift", + l3Runtime, + "--manifest", + referenceManifest, + "preview-json", + JSON.stringify({ intent: "noSuchIntent", dryRun: true }), + ], + reason: "unsupported_intent", + }, +]; + +function readJSON(relativePath) { + return JSON.parse(readFileSync(path.join(repoRoot, relativePath), "utf8")); +} + +function typeOf(value) { + if (Array.isArray(value)) return "array"; + if (value === null) return "null"; + if (Number.isInteger(value)) return "integer"; + return typeof value; +} + +function typeMatches(expected, value) { + const actual = typeOf(value); + if (expected === "number") return actual === "number" || actual === "integer"; + return actual === expected; +} + +function sameJSON(a, b) { + return JSON.stringify(a) === JSON.stringify(b); +} + +function resolveRef(root, ref) { + if (!ref.startsWith("#/")) throw new Error(`only local refs are supported: ${ref}`); + return ref + .slice(2) + .split("/") + .reduce((node, part) => { + if (!node || typeof node !== "object" || !(part in node)) { + throw new Error(`unresolvable schema ref: ${ref}`); + } + return node[part]; + }, root); +} + +function validate(schema, value, root = schema, at = "$") { + const errors = []; + + if (schema.$ref) { + return validate(resolveRef(root, schema.$ref), value, root, at); + } + + if ("const" in schema && !sameJSON(value, schema.const)) { + errors.push(`${at}: expected const ${JSON.stringify(schema.const)}`); + } + + if (schema.enum && !schema.enum.some((item) => sameJSON(value, item))) { + errors.push(`${at}: expected one of ${schema.enum.map((item) => JSON.stringify(item)).join(", ")}`); + } + + if (schema.type && !typeMatches(schema.type, value)) { + errors.push(`${at}: expected type ${schema.type}, got ${typeOf(value)}`); + return errors; + } + + if (schema.type === "string" || typeof value === "string") { + if (schema.minLength !== undefined && value.length < schema.minLength) { + errors.push(`${at}: expected string length >= ${schema.minLength}`); + } + if (schema.pattern && !new RegExp(schema.pattern).test(value)) { + errors.push(`${at}: expected string to match /${schema.pattern}/`); + } + } + + if ((schema.type === "number" || schema.type === "integer") && typeof value === "number") { + if (schema.minimum !== undefined && value < schema.minimum) { + errors.push(`${at}: expected number >= ${schema.minimum}`); + } + } + + if (schema.type === "array" || Array.isArray(value)) { + if (schema.minItems !== undefined && value.length < schema.minItems) { + errors.push(`${at}: expected array length >= ${schema.minItems}`); + } + if (schema.items) { + value.forEach((item, index) => { + errors.push(...validate(schema.items, item, root, `${at}[${index}]`)); + }); + } + } + + if ((schema.type === "object" || (value && typeof value === "object" && !Array.isArray(value))) && value !== null && !Array.isArray(value)) { + for (const field of schema.required ?? []) { + if (!(field in value)) errors.push(`${at}: missing required field ${field}`); + } + + const properties = schema.properties ?? {}; + for (const [field, childSchema] of Object.entries(properties)) { + if (field in value) { + errors.push(...validate(childSchema, value[field], root, `${at}.${field}`)); + } + } + + const keys = Object.keys(value); + if (schema.minProperties !== undefined && keys.length < schema.minProperties) { + errors.push(`${at}: expected at least ${schema.minProperties} properties`); + } + + if (schema.additionalProperties !== undefined) { + const extras = keys.filter((key) => !(key in properties)); + if (schema.additionalProperties === false && extras.length > 0) { + errors.push(`${at}: unexpected properties ${extras.join(", ")}`); + } else if (typeof schema.additionalProperties === "object") { + for (const key of extras) { + errors.push(...validate(schema.additionalProperties, value[key], root, `${at}.${key}`)); + } + } + } + } + + return errors; +} + +let failures = 0; + +for (const check of checks) { + const schema = readJSON(check.schema); + + for (const fixture of check.valid) { + const errors = validate(schema, readJSON(fixture)); + if (errors.length > 0) { + failures += 1; + console.error(`FAIL valid ${check.name}: ${fixture}`); + for (const error of errors) console.error(` ${error}`); + } else { + console.log(`OK valid ${check.name}: ${fixture}`); + } + } + + for (const fixture of check.invalid) { + const errors = validate(schema, readJSON(fixture)); + if (errors.length === 0) { + failures += 1; + console.error(`FAIL invalid ${check.name}: ${fixture} unexpectedly passed`); + } else { + console.log(`OK invalid ${check.name}: ${fixture}`); + } + } +} + +const errorEnvelopeSchema = readJSON("protocol/schemas/error-envelope.schema.json"); + +for (const runtimeCase of runtimeErrorCases) { + const result = spawnSync("/usr/bin/env", runtimeCase.argv, { + cwd: repoRoot, + encoding: "utf8", + }); + + if (result.error) { + failures += 1; + console.error(`FAIL runtime ${runtimeCase.name}: ${result.error.message}`); + continue; + } + + let parsed; + try { + parsed = JSON.parse((result.stdout || "").trim()); + } catch (error) { + failures += 1; + console.error(`FAIL runtime ${runtimeCase.name}: stdout was not JSON`); + if (result.stdout) console.error(` stdout: ${result.stdout.trim()}`); + if (result.stderr) console.error(` stderr: ${result.stderr.trim()}`); + continue; + } + + const errors = validate(errorEnvelopeSchema, parsed); + if (errors.length > 0) { + failures += 1; + console.error(`FAIL runtime ${runtimeCase.name}: output did not match ErrorEnvelope`); + for (const error of errors) console.error(` ${error}`); + continue; + } + + if (parsed.reason !== runtimeCase.reason) { + failures += 1; + console.error(`FAIL runtime ${runtimeCase.name}: expected reason ${runtimeCase.reason}, got ${parsed.reason}`); + } else { + console.log(`OK runtime ${runtimeCase.name}: reason=${parsed.reason}`); + } +} + +if (failures > 0) process.exit(1); +console.log("PROTOCOL_CONFORMANCE_OK");