|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * validate.mjs — Validate all MarketIR data against schema + invariants. |
| 4 | + * |
| 5 | + * Checks: |
| 6 | + * 1. Every file referenced in the index validates against its schema type |
| 7 | + * 2. All IDs are unique across the entire graph |
| 8 | + * 3. Proven claims have >= 1 evidenceRef |
| 9 | + * 4. All evidenceRefs exist in the evidence manifest |
| 10 | + * 5. All claimRefs in messages exist in the tool's claims |
| 11 | + * 6. All audienceRefs exist as audience files |
| 12 | + * 7. Campaign toolRef and audienceRefs resolve |
| 13 | + * 8. Campaign messageRefs resolve to tool messages |
| 14 | + */ |
| 15 | + |
| 16 | +import Ajv2020 from "ajv/dist/2020.js"; |
| 17 | +import addFormats from "ajv-formats"; |
| 18 | +import { readFile } from "node:fs/promises"; |
| 19 | +import { join, dirname } from "node:path"; |
| 20 | +import { fileURLToPath } from "node:url"; |
| 21 | + |
| 22 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 23 | +const root = join(__dirname, ".."); |
| 24 | + |
| 25 | +const errors = []; |
| 26 | +function fail(msg) { |
| 27 | + errors.push(msg); |
| 28 | + console.error(` FAIL: ${msg}`); |
| 29 | +} |
| 30 | + |
| 31 | +// Load schema |
| 32 | +const schemaText = await readFile(join(root, "schema/marketing.schema.json"), "utf8"); |
| 33 | +const schema = JSON.parse(schemaText); |
| 34 | + |
| 35 | +const ajv = new Ajv2020({ allErrors: true, strict: false }); |
| 36 | +addFormats(ajv); |
| 37 | +ajv.addSchema(schema); |
| 38 | + |
| 39 | +const schemaId = schema.$id; |
| 40 | +const validateTool = ajv.compile({ $ref: `${schemaId}#/$defs/tool` }); |
| 41 | +const validateAudience = ajv.compile({ $ref: `${schemaId}#/$defs/audience` }); |
| 42 | +const validateCampaign = ajv.compile({ $ref: `${schemaId}#/$defs/campaign` }); |
| 43 | +const validateIndex = ajv.compile({ $ref: `${schemaId}#/$defs/index` }); |
| 44 | +const validateEvidence = ajv.compile({ $ref: `${schemaId}#/$defs/evidence` }); |
| 45 | + |
| 46 | +// Load index |
| 47 | +const indexText = await readFile(join(root, "data/marketing.index.json"), "utf8"); |
| 48 | +const index = JSON.parse(indexText); |
| 49 | + |
| 50 | +console.log("Validating index..."); |
| 51 | +if (!validateIndex(index)) { |
| 52 | + for (const e of validateIndex.errors) fail(`index: ${e.instancePath} ${e.message}`); |
| 53 | +} |
| 54 | + |
| 55 | +// Load all referenced files |
| 56 | +async function loadRef(ref) { |
| 57 | + const text = await readFile(join(root, "data", ref), "utf8"); |
| 58 | + return JSON.parse(text); |
| 59 | +} |
| 60 | + |
| 61 | +// Collect all IDs for uniqueness check |
| 62 | +const allIds = new Set(); |
| 63 | +function checkUniqueId(id, source) { |
| 64 | + if (allIds.has(id)) { |
| 65 | + fail(`Duplicate ID: ${id} (in ${source})`); |
| 66 | + } |
| 67 | + allIds.add(id); |
| 68 | +} |
| 69 | + |
| 70 | +// Load audiences |
| 71 | +const audiences = new Map(); |
| 72 | +console.log("\nValidating audiences..."); |
| 73 | +for (const { ref } of index.audiences) { |
| 74 | + const aud = await loadRef(ref); |
| 75 | + if (!validateAudience(aud)) { |
| 76 | + for (const e of validateAudience.errors) fail(`${ref}: ${e.instancePath} ${e.message}`); |
| 77 | + } |
| 78 | + checkUniqueId(aud.id, ref); |
| 79 | + audiences.set(aud.id, aud); |
| 80 | + console.log(` OK: ${aud.id}`); |
| 81 | +} |
| 82 | + |
| 83 | +// Load evidence manifest |
| 84 | +console.log("\nValidating evidence manifest..."); |
| 85 | +const evidenceText = await readFile(join(root, "manifests/evidence.manifest.json"), "utf8"); |
| 86 | +const evidenceManifest = JSON.parse(evidenceText); |
| 87 | +const evidenceIds = new Set(); |
| 88 | +for (const entry of evidenceManifest.entries) { |
| 89 | + if (!validateEvidence(entry)) { |
| 90 | + for (const e of validateEvidence.errors) fail(`evidence ${entry.id}: ${e.instancePath} ${e.message}`); |
| 91 | + } |
| 92 | + checkUniqueId(entry.id, "evidence.manifest.json"); |
| 93 | + evidenceIds.add(entry.id); |
| 94 | + console.log(` OK: ${entry.id}`); |
| 95 | +} |
| 96 | + |
| 97 | +// Load tools |
| 98 | +const tools = new Map(); |
| 99 | +console.log("\nValidating tools..."); |
| 100 | +for (const { ref } of index.tools) { |
| 101 | + const tool = await loadRef(ref); |
| 102 | + if (!validateTool(tool)) { |
| 103 | + for (const e of validateTool.errors) fail(`${ref}: ${e.instancePath} ${e.message}`); |
| 104 | + } |
| 105 | + checkUniqueId(tool.id, ref); |
| 106 | + tools.set(tool.id, tool); |
| 107 | + |
| 108 | + // Collect claim and message IDs |
| 109 | + const claimIds = new Set(); |
| 110 | + for (const claim of tool.claims) { |
| 111 | + checkUniqueId(claim.id, ref); |
| 112 | + claimIds.add(claim.id); |
| 113 | + |
| 114 | + // Check evidenceRefs exist in manifest |
| 115 | + if (claim.evidenceRefs) { |
| 116 | + for (const evRef of claim.evidenceRefs) { |
| 117 | + if (!evidenceIds.has(evRef)) { |
| 118 | + fail(`${ref}: claim ${claim.id} references evidence ${evRef} which is not in the manifest`); |
| 119 | + } |
| 120 | + } |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + // Check message claimRefs resolve to tool's claims |
| 125 | + for (const msg of tool.messages) { |
| 126 | + checkUniqueId(msg.id, ref); |
| 127 | + for (const claimRef of msg.claimRefs) { |
| 128 | + if (!claimIds.has(claimRef)) { |
| 129 | + fail(`${ref}: message ${msg.id} references claim ${claimRef} which is not in this tool's claims`); |
| 130 | + } |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + // Check audienceRefs resolve |
| 135 | + for (const audRef of tool.audienceRefs) { |
| 136 | + if (!audiences.has(audRef)) { |
| 137 | + fail(`${ref}: audienceRef ${audRef} does not exist`); |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + console.log(` OK: ${tool.id} (${tool.claims.length} claims, ${tool.messages.length} messages)`); |
| 142 | +} |
| 143 | + |
| 144 | +// Load campaigns |
| 145 | +console.log("\nValidating campaigns..."); |
| 146 | +for (const { ref } of index.campaigns) { |
| 147 | + const campaign = await loadRef(ref); |
| 148 | + if (!validateCampaign(campaign)) { |
| 149 | + for (const e of validateCampaign.errors) fail(`${ref}: ${e.instancePath} ${e.message}`); |
| 150 | + } |
| 151 | + checkUniqueId(campaign.id, ref); |
| 152 | + |
| 153 | + // Check toolRef resolves |
| 154 | + if (!tools.has(campaign.toolRef)) { |
| 155 | + fail(`${ref}: toolRef ${campaign.toolRef} does not exist`); |
| 156 | + } |
| 157 | + |
| 158 | + // Check audienceRefs resolve |
| 159 | + for (const audRef of campaign.audienceRefs) { |
| 160 | + if (!audiences.has(audRef)) { |
| 161 | + fail(`${ref}: audienceRef ${audRef} does not exist`); |
| 162 | + } |
| 163 | + } |
| 164 | + |
| 165 | + // Check messageRefs resolve to tool's messages |
| 166 | + const tool = tools.get(campaign.toolRef); |
| 167 | + if (tool) { |
| 168 | + const toolMsgIds = new Set(tool.messages.map((m) => m.id)); |
| 169 | + for (const phase of campaign.phases) { |
| 170 | + if (phase.messageRefs) { |
| 171 | + for (const msgRef of phase.messageRefs) { |
| 172 | + if (!toolMsgIds.has(msgRef)) { |
| 173 | + fail(`${ref}: phase "${phase.name}" references message ${msgRef} which is not in tool ${campaign.toolRef}`); |
| 174 | + } |
| 175 | + } |
| 176 | + } |
| 177 | + } |
| 178 | + } |
| 179 | + |
| 180 | + console.log(` OK: ${campaign.id} (${campaign.phases.length} phases)`); |
| 181 | +} |
| 182 | + |
| 183 | +// Summary |
| 184 | +console.log(`\n${allIds.size} unique IDs checked.`); |
| 185 | +if (errors.length > 0) { |
| 186 | + console.error(`\n${errors.length} error(s) found.`); |
| 187 | + process.exit(1); |
| 188 | +} else { |
| 189 | + console.log("All validations passed."); |
| 190 | +} |
0 commit comments