From 74fbf43e83fbc18f7b3c1b66943c8eb802d5d315 Mon Sep 17 00:00:00 2001 From: Eve Date: Fri, 8 May 2026 12:30:04 +0800 Subject: [PATCH] feat(reporting-governance): schema-validate profile generation --- .../reporting-governance/package-lock.json | 84 ++++++++ plugins/reporting-governance/package.json | 4 + .../src/storage/profile-generator.mjs | 181 ++++-------------- .../test/profile-generator.test.mjs | 127 +++++++++++- ..._reporting_governance_profile_artifact.mjs | 19 +- 5 files changed, 259 insertions(+), 156 deletions(-) create mode 100644 plugins/reporting-governance/package-lock.json diff --git a/plugins/reporting-governance/package-lock.json b/plugins/reporting-governance/package-lock.json new file mode 100644 index 0000000..a4557bf --- /dev/null +++ b/plugins/reporting-governance/package-lock.json @@ -0,0 +1,84 @@ +{ + "name": "@openclaw/plugin-reporting-governance", + "version": "0.1.0-mainline", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@openclaw/plugin-reporting-governance", + "version": "0.1.0-mainline", + "dependencies": { + "ajv": "^8.20.0", + "yaml": "^2.8.4" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/plugins/reporting-governance/package.json b/plugins/reporting-governance/package.json index f4f6efa..4feaefa 100644 --- a/plugins/reporting-governance/package.json +++ b/plugins/reporting-governance/package.json @@ -15,5 +15,9 @@ }, "scripts": { "test": "node --test test/package-structure.test.mjs test/policy-evaluator.test.mjs test/compatibility-preflight.test.mjs test/profile-artifact.test.mjs test/profile-generator.test.mjs test/decision-runner.test.mjs test/governance-contract.integration.test.mjs test/watchdog-chain.integration.test.mjs test/runtime-integrated.integration.test.mjs test/exports-boundary.integration.test.mjs" + }, + "dependencies": { + "ajv": "^8.20.0", + "yaml": "^2.8.4" } } diff --git a/plugins/reporting-governance/src/storage/profile-generator.mjs b/plugins/reporting-governance/src/storage/profile-generator.mjs index 3e6ba02..aacda24 100644 --- a/plugins/reporting-governance/src/storage/profile-generator.mjs +++ b/plugins/reporting-governance/src/storage/profile-generator.mjs @@ -1,170 +1,65 @@ import fs from 'node:fs'; import path from 'node:path'; +import Ajv2020 from 'ajv/dist/2020.js'; +import YAML from 'yaml'; + const packageRoot = path.resolve(import.meta.dirname, '..', '..'); const repoRoot = path.resolve(packageRoot, '..', '..'); -const schemaPath = '../../../schemas/reporting-governance/deployment-profile.schema.json'; +const schemaPath = path.resolve(repoRoot, 'schemas', 'reporting-governance', 'deployment-profile.schema.json'); +const artifactSchemaRefPath = '../../../schemas/reporting-governance/deployment-profile.schema.json'; function readText(filePath) { return fs.readFileSync(filePath, 'utf8'); } -function countIndent(line) { - let count = 0; - while (count < line.length && line[count] === ' ') count += 1; - return count; +function loadDeploymentProfileSchema() { + return JSON.parse(readText(schemaPath)); } -function parseScalar(raw) { - const value = raw.trim(); - if (value === 'true') return true; - if (value === 'false') return false; - if (/^-?\d+$/.test(value)) return Number.parseInt(value, 10); - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { - return value.slice(1, -1); - } - return value; -} +const ajv = new Ajv2020({ allErrors: true, strict: false }); +const validateDeploymentProfile = ajv.compile(loadDeploymentProfileSchema()); -function parseBlock(lines, startIndex = 0, baseIndent = 0) { - const firstMeaningful = lines.slice(startIndex).find((line) => line.trim() !== ''); - const container = firstMeaningful?.trim().startsWith('- ') ? [] : {}; - let index = startIndex; - - while (index < lines.length) { - const line = lines[index]; - const trimmed = line.trim(); - if (trimmed === '' || trimmed.startsWith('#')) { - index += 1; - continue; - } - - const indent = countIndent(line); - if (indent < baseIndent) break; - if (indent > baseIndent) { - throw new Error(`unexpected indentation at line ${index + 1}`); - } - - if (Array.isArray(container)) { - if (!trimmed.startsWith('- ')) { - throw new Error(`expected array item at line ${index + 1}`); - } - const itemBody = trimmed.slice(2); - if (itemBody === '') { - const nested = parseBlock(lines, index + 1, baseIndent + 2); - container.push(nested.value); - index = nested.nextIndex; - continue; - } - if (itemBody.includes(':')) { - const separator = itemBody.indexOf(':'); - const key = itemBody.slice(0, separator).trim(); - const remainder = itemBody.slice(separator + 1).trim(); - const objectItem = {}; - if (remainder === '') { - const nested = parseBlock(lines, index + 1, baseIndent + 4); - objectItem[key] = nested.value; - container.push(objectItem); - index = nested.nextIndex; - continue; - } - objectItem[key] = parseScalar(remainder); - container.push(objectItem); - index += 1; - continue; - } - container.push(parseScalar(itemBody)); - index += 1; - continue; - } - - const separator = trimmed.indexOf(':'); - if (separator === -1) { - throw new Error(`expected key/value pair at line ${index + 1}`); - } - - const key = trimmed.slice(0, separator).trim(); - let remainder = trimmed.slice(separator + 1).trim(); - - if (remainder === '>-') { - const blockLines = []; - index += 1; - while (index < lines.length) { - const nestedLine = lines[index]; - const nestedTrimmed = nestedLine.trim(); - if (nestedTrimmed === '') { - blockLines.push(''); - index += 1; - continue; - } - const nestedIndent = countIndent(nestedLine); - if (nestedIndent <= baseIndent) break; - blockLines.push(nestedLine.slice(baseIndent + 2).trim()); - index += 1; - } - container[key] = blockLines.join(' ').replace(/\s+/g, ' ').trim(); - continue; - } - - if (remainder === '') { - const nested = parseBlock(lines, index + 1, baseIndent + 2); - container[key] = nested.value; - index = nested.nextIndex; - continue; - } - - container[key] = parseScalar(remainder); - index += 1; +function formatAjvErrors(errors = []) { + if (!Array.isArray(errors) || errors.length === 0) { + return 'schema validation failed'; } - return { value: container, nextIndex: index }; + return errors.map((error) => { + const keyword = error.keyword; + const instancePath = error.instancePath || '/'; + + if (keyword === 'required') { + const missing = error.params?.missingProperty ?? 'unknown'; + return `${instancePath} missing required property ${missing}`; + } + + if (keyword === 'additionalProperties') { + const extra = error.params?.additionalProperty ?? 'unknown'; + return `${instancePath} must not include additional property ${extra}`; + } + + if (keyword === 'const') { + return `${instancePath} must equal ${JSON.stringify(error.params?.allowedValue)}`; + } + + return `${instancePath} ${error.message}`.trim(); + }).join('; '); } export function parseDeploymentProfileYaml(yamlText) { - const lines = yamlText.replace(/\r\n/g, '\n').split('\n'); - return parseBlock(lines, 0, 0).value; + return YAML.parse(yamlText); } export function validateDeploymentProfileSchema(profile) { if (!profile || typeof profile !== 'object' || Array.isArray(profile)) { throw new Error('deployment profile must be an object'); } - if (profile.apiVersion !== 'reporting-governance/v1alpha1') { - throw new Error('deployment profile apiVersion must be reporting-governance/v1alpha1'); - } - if (profile.kind !== 'DeploymentProfile') { - throw new Error('deployment profile kind must be DeploymentProfile'); - } - if (typeof profile?.metadata?.id !== 'string' || profile.metadata.id.trim() === '') { - throw new Error('deployment profile metadata.id must be a non-empty string'); - } - if (typeof profile?.metadata?.version !== 'string' || profile.metadata.version.trim() === '') { - throw new Error('deployment profile metadata.version must be a non-empty string'); - } - if (typeof profile?.metadata?.runtime !== 'string' || profile.metadata.runtime.trim() === '') { - throw new Error('deployment profile metadata.runtime must be a non-empty string'); - } - if (typeof profile?.spec?.package?.pluginVersion !== 'string' || profile.spec.package.pluginVersion.trim() === '') { - throw new Error('deployment profile spec.package.pluginVersion must be a non-empty string'); - } - if (!profile?.spec?.adapters || typeof profile.spec.adapters !== 'object' || Array.isArray(profile.spec.adapters)) { - throw new Error('deployment profile spec.adapters must be an object'); - } - if (!profile?.spec?.notifications || typeof profile.spec.notifications !== 'object' || Array.isArray(profile.spec.notifications)) { - throw new Error('deployment profile spec.notifications must be an object'); - } - if (!profile?.spec?.audit || typeof profile.spec.audit !== 'object' || Array.isArray(profile.spec.audit)) { - throw new Error('deployment profile spec.audit must be an object'); - } - if (!Array.isArray(profile?.spec?.audit?.requiredArtifacts) || profile.spec.audit.requiredArtifacts.length === 0) { - throw new Error('deployment profile spec.audit.requiredArtifacts must be a non-empty array'); - } - if (!profile?.spec?.capability_expectations || typeof profile.spec.capability_expectations !== 'object' || Array.isArray(profile.spec.capability_expectations)) { - throw new Error('deployment profile spec.capability_expectations must be an object'); - } - if (!Array.isArray(profile.spec.capability_expectations.required)) { - throw new Error('deployment profile spec.capability_expectations.required must be an array'); + + if (!validateDeploymentProfile(profile)) { + throw new Error(`deployment profile schema validation failed: ${formatAjvErrors(validateDeploymentProfile.errors)}`); } + return profile; } @@ -174,7 +69,7 @@ export function generateDeploymentProfileArtifact(profile, { sourceProfile } = { const sourceProfilePath = sourceProfile ?? path.join('profiles', `${id}.yaml`); return { - $schema: schemaPath, + $schema: artifactSchemaRefPath, apiVersion: validated.apiVersion, kind: 'DeploymentProfileArtifact', metadata: { diff --git a/plugins/reporting-governance/test/profile-generator.test.mjs b/plugins/reporting-governance/test/profile-generator.test.mjs index 30072ab..fce7e2c 100644 --- a/plugins/reporting-governance/test/profile-generator.test.mjs +++ b/plugins/reporting-governance/test/profile-generator.test.mjs @@ -1,7 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { parseDeploymentProfileYaml, @@ -14,6 +16,66 @@ import { validateDeploymentProfileArtifact } from '../src/storage/profile-artifa const packageRoot = path.resolve(import.meta.dirname, '..'); const repoRoot = path.resolve(packageRoot, '..', '..'); +const complexProfileYaml = `apiVersion: reporting-governance/v1alpha1 +kind: DeploymentProfile +metadata: + id: demo + version: 1.0.0 + runtime: openclaw + title: Demo Profile + summary: >- + Complex profile for YAML parser coverage with nested objects, + arrays, and folded scalars. +spec: + package: + pluginVersion: 0.1.0-mainline + compatibility: + adapterInterface: reporting-governance-adapter-interface + deploymentModel: reporting-governance-deployment-model + policies: + enabledPacks: + - no-silence + - verified-completion-only + overrides: + completion: + minQuality: moderate + checkpoints: + overdueAction: force_checkpoint + escalationAfterMisses: 2 + adapters: + watchdog: + enabled: true + scheduleMode: cron + queue: + enabled: true + dispatcher: + enabled: true + bridge: + enabled: true + truthfulDeliveryStates: + - dispatched + - acked + sender: + mode: openclaw-cli + orchestrator: + enabled: true + notifications: + operatorVisibleRecoveryRequired: true + allowedTerminalStates: + - acked + - blocked + audit: + portableArtifactsRequired: true + requiredArtifacts: + - queue_items + - bridge_receipts + capability_expectations: + required: + - create_queue_items + preferred: + - direct_sender_binding +`; + test('deployment profile yaml parser reads strict-manager-mode profile shape', () => { const artifact = generateDeploymentProfileArtifactFromFile(path.join(repoRoot, 'profiles', 'strict-manager-mode.yaml')); @@ -26,7 +88,7 @@ test('deployment profile yaml parser reads strict-manager-mode profile shape', ( test('deployment profile schema validator rejects malformed profile', () => { assert.throws( () => validateDeploymentProfileSchema({ kind: 'DeploymentProfile' }), - /deployment profile apiVersion must be reporting-governance\/v1alpha1/ + /deployment profile schema validation failed: .*missing required property apiVersion/ ); assert.throws( @@ -35,19 +97,32 @@ test('deployment profile schema validator rejects malformed profile', () => { kind: 'DeploymentProfile', metadata: { id: 'x', version: '1.0.0', runtime: 'openclaw' }, spec: { - package: { pluginVersion: '0.1.0-mainline' }, + package: { pluginVersion: 7 }, adapters: {}, - notifications: {}, - audit: { requiredArtifacts: [] }, + notifications: { + operatorVisibleRecoveryRequired: true, + allowedTerminalStates: ['acked'], + }, + audit: { portableArtifactsRequired: true, requiredArtifacts: ['queue_items'] }, capability_expectations: { required: [] }, }, }), - /deployment profile spec\.audit\.requiredArtifacts must be a non-empty array/ + /deployment profile schema validation failed: .*\/spec\/package\/pluginVersion must be string/ ); }); +test('deployment profile yaml parser covers composite nested yaml structure', () => { + const profile = parseDeploymentProfileYaml(complexProfileYaml); + + assert.equal(profile.metadata.summary, 'Complex profile for YAML parser coverage with nested objects, arrays, and folded scalars.'); + assert.deepEqual(profile.spec.policies.enabledPacks, ['no-silence', 'verified-completion-only']); + assert.equal(profile.spec.policies.overrides.checkpoints.overdueAction, 'force_checkpoint'); + assert.deepEqual(profile.spec.adapters.bridge.truthfulDeliveryStates, ['dispatched', 'acked']); + assert.deepEqual(profile.spec.notifications.allowedTerminalStates, ['acked', 'blocked']); +}); + test('yaml to artifact generation produces validator-compatible package artifact', () => { - const profile = parseDeploymentProfileYaml(`apiVersion: reporting-governance/v1alpha1\nkind: DeploymentProfile\nmetadata:\n id: demo\n version: 1.0.0\n runtime: openclaw\nspec:\n package:\n pluginVersion: 0.1.0-mainline\n adapters:\n watchdog:\n enabled: true\n queue:\n enabled: true\n dispatcher:\n enabled: true\n bridge:\n enabled: true\n sender:\n mode: openclaw-cli\n orchestrator:\n enabled: true\n notifications:\n operatorVisibleRecoveryRequired: true\n allowedTerminalStates:\n - acked\n audit:\n portableArtifactsRequired: true\n requiredArtifacts:\n - queue_items\n capability_expectations:\n required:\n - create_queue_items\n`); + const profile = parseDeploymentProfileYaml(complexProfileYaml); const artifact = generateDeploymentProfileArtifact(profile, { sourceProfile: 'profiles/demo.yaml' }); assert.equal(artifact.kind, 'DeploymentProfileArtifact'); @@ -69,3 +144,43 @@ test('strict-manager YAML generation matches checked-in package artifact snapsho assert.deepEqual(generated, checkedIn); }); + +test('profile artifact generator CLI exits 1 with actionable error on malformed profile', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'reporting-governance-profile-cli-')); + const malformedProfilePath = path.join(tempDir, 'malformed.yaml'); + const outputArtifactPath = path.join(tempDir, 'artifact.json'); + + fs.writeFileSync(malformedProfilePath, `apiVersion: reporting-governance/v1alpha1 +kind: DeploymentProfile +metadata: + id: malformed + version: 1.0.0 + runtime: openclaw +spec: + package: + pluginVersion: 123 + adapters: {} + notifications: + operatorVisibleRecoveryRequired: true + allowedTerminalStates: + - acked + audit: + portableArtifactsRequired: true + requiredArtifacts: + - queue_items + capability_expectations: + required: [] +`, 'utf8'); + + const result = spawnSync( + process.execPath, + [path.join(repoRoot, 'scripts', 'generate_reporting_governance_profile_artifact.mjs'), malformedProfilePath, outputArtifactPath], + { encoding: 'utf8' } + ); + + assert.equal(result.status, 1); + assert.match(result.stderr, /profile artifact generation failed:/); + assert.match(result.stderr, /deployment profile schema validation failed:/); + assert.match(result.stderr, /\/spec\/package\/pluginVersion must be string/); + assert.equal(fs.existsSync(outputArtifactPath), false); +}); diff --git a/scripts/generate_reporting_governance_profile_artifact.mjs b/scripts/generate_reporting_governance_profile_artifact.mjs index fa7c4cf..5677981 100644 --- a/scripts/generate_reporting_governance_profile_artifact.mjs +++ b/scripts/generate_reporting_governance_profile_artifact.mjs @@ -12,14 +12,19 @@ function main() { process.exit(1); } - const sourcePath = path.resolve(sourcePathArg); - const outputPath = path.resolve(outputPathArg); - const artifact = generateDeploymentProfileArtifactFromFile(sourcePath); - validateDeploymentProfileArtifact(artifact); + try { + const sourcePath = path.resolve(sourcePathArg); + const outputPath = path.resolve(outputPathArg); + const artifact = generateDeploymentProfileArtifactFromFile(sourcePath); + validateDeploymentProfileArtifact(artifact); - fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - fs.writeFileSync(outputPath, JSON.stringify(artifact, null, 2) + '\n', 'utf8'); - console.log(JSON.stringify({ ok: true, sourcePath, outputPath, profileId: artifact.metadata.id }, null, 2)); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, JSON.stringify(artifact, null, 2) + '\n', 'utf8'); + console.log(JSON.stringify({ ok: true, sourcePath, outputPath, profileId: artifact.metadata.id }, null, 2)); + } catch (error) { + console.error(`profile artifact generation failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } } main();