PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / contracts / validate-contracts.js

validate-contracts.js in 404 Solution trunk, at contracts/validate-contracts.js

644 lines 20.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 #!/usr/bin/env node
2
3 // Pre-commit gate for outbound contracts.
4 // Validates contracts.json wiring: schemas exist, fixtures pass/fail correctly,
5 // test files exist and reference their contract IDs.
6 //
7 // Usage: node validate-contracts.js [--contracts-dir <path>] [--vendor-dir <path>]
8 // [--server-contracts-dir <path>]
9 // Defaults: --contracts-dir ./contracts --vendor-dir ./vendor-contracts
10 // --server-contracts-dir ../404-solution-server/contracts
11 // (env: ABJ404_SERVER_CONTRACTS_DIR)
12 //
13 // Exit 0: all checks pass (or no contracts directory found)
14 // Exit 1: validation failure
15
16 const path = require("path");
17 const {
18 fileExists,
19 pathExists,
20 filesEqual,
21 loadJson,
22 findJsonSchemaFiles,
23 fileContainsAnnotation,
24 fileContainsParityAnnotation,
25 } = require("./contractFileIO");
26
27 const args = process.argv.slice(2);
28
29 /**
30 * The single place this script resolves an input, in precedence order:
31 * CLI flag, then environment variable, then built-in default. Keeping all
32 * three in one function is what stops the same setting being read twice with
33 * two different defaults.
34 *
35 * @param {{name: string, fallback: string, envName?: string}} options
36 * @returns {string}
37 */
38 function getArg({ name, fallback, envName }) {
39 const idx = args.indexOf(`--${name}`);
40 if (idx !== -1) {
41 const value = args[idx + 1];
42 if (!value || value.startsWith("--")) {
43 console.error(`ERROR [MISSING_FLAG_VALUE]: --${name} requires a path.`);
44 process.exit(1);
45 }
46 return value;
47 }
48 const fromEnv = envName ? process.env[envName] : undefined; // allow-direct-env: this IS the config adapter, the only env read in this script
49 return fromEnv || fallback;
50 }
51
52 const contractsDir = path.resolve(getArg({ name: "contracts-dir", fallback: "./contracts" }));
53 const vendorDir = path.resolve(getArg({ name: "vendor-dir", fallback: "./vendor-contracts" }));
54 const serverContractsDir = path.resolve(
55 getArg({
56 name: "server-contracts-dir",
57 fallback: path.join(__dirname, "..", "..", "404-solution-server", "contracts"),
58 envName: "ABJ404_SERVER_CONTRACTS_DIR",
59 })
60 );
61
62 const errors = [];
63 function fail(msg) {
64 errors.push(msg);
65 }
66
67 function validateSchemaFile(schemaPath, label) {
68 if (!fileExists(schemaPath)) {
69 fail(`${label}: schema file not found: ${schemaPath}`);
70 return null;
71 }
72 let schema;
73 try {
74 schema = loadJson(schemaPath);
75 } catch (e) {
76 fail(`${label}: schema is not valid JSON: ${schemaPath} (${e.message})`);
77 return null;
78 }
79 if (typeof schema !== "object" || schema === null) {
80 fail(`${label}: schema must be a JSON object: ${schemaPath}`);
81 return null;
82 }
83 if (!schema.type && !schema.$ref && !schema.oneOf && !schema.anyOf && !schema.allOf) {
84 fail(`${label}: schema has no type, $ref, or composition keyword: ${schemaPath}`);
85 return null;
86 }
87 return schema;
88 }
89
90 function validateFixtures(schema, schemaPath, fixtures, baseDir, label) {
91 let Ajv;
92 const resolvePaths = [process.cwd(), __dirname];
93 try {
94 Ajv = require(require.resolve("ajv/dist/2020", { paths: resolvePaths }));
95 } catch {
96 try {
97 Ajv = require(require.resolve("ajv", { paths: resolvePaths }));
98 } catch {
99 // allow-silent-catch: AJV not installed; skip fixture validation with warning
100 console.log(
101 ` WARN: ajv not installed, skipping fixture validation for ${label}. Install with: npm install --save-dev ajv`
102 );
103 for (const f of [...(fixtures.valid || []), ...(fixtures.invalid || [])]) {
104 const fp = path.resolve(baseDir, f);
105 if (!fileExists(fp)) {
106 fail(`${label}: fixture file not found: ${f}`);
107 }
108 }
109 return;
110 }
111 }
112
113 const ajv = new Ajv({ allErrors: true, strict: false });
114 let validate;
115 try {
116 validate = compileJsonSchema(ajv, schema);
117 } catch (e) {
118 fail(`${label}: schema compilation failed: ${e.message}`);
119 return;
120 }
121
122 for (const f of fixtures.valid || []) {
123 const fp = path.resolve(baseDir, f);
124 if (!fileExists(fp)) {
125 fail(`${label}: valid fixture not found: ${f}`);
126 continue;
127 }
128 let data;
129 try {
130 data = loadJson(fp);
131 } catch (e) {
132 fail(`${label}: valid fixture is not valid JSON: ${f} (${e.message})`);
133 continue;
134 }
135 if (!validate(data)) {
136 const fieldErrors = validate.errors
137 .map((e) => ` ${e.instancePath || "/"}: ${e.message}`)
138 .join("\n");
139 fail(`${label}: valid fixture FAILED schema validation: ${f}\n${fieldErrors}`);
140 }
141 }
142
143 for (const f of fixtures.invalid || []) {
144 const fp = path.resolve(baseDir, f);
145 if (!fileExists(fp)) {
146 fail(`${label}: invalid fixture not found: ${f}`);
147 continue;
148 }
149 let data;
150 try {
151 data = loadJson(fp);
152 } catch (e) {
153 fail(`${label}: invalid fixture is not valid JSON: ${f} (${e.message})`);
154 continue;
155 }
156 if (validate(data)) {
157 fail(`${label}: invalid fixture PASSED schema validation (should have failed): ${f}`);
158 }
159 }
160 }
161
162 function compileJsonSchema(ajv, schema) {
163 try {
164 return ajv.compile(schema);
165 } catch (e) {
166 const message = e && e.message ? e.message : "";
167 if (
168 schema &&
169 schema.$schema === "https://json-schema.org/draft/2020-12/schema" &&
170 /no schema with key or ref/.test(message)
171 ) {
172 const fallbackSchema = JSON.parse(JSON.stringify(schema));
173 delete fallbackSchema.$schema;
174 return ajv.compile(fallbackSchema);
175 }
176 throw e;
177 }
178 }
179
180 function validateTestFiles(testSpec, contractId, side, label, annotationName = "contract") {
181 const tests = Array.isArray(testSpec) ? testSpec : [testSpec];
182 for (const t of tests) {
183 const tp = path.resolve(t);
184 if (!fileExists(tp)) {
185 const relTp = path.resolve(process.cwd(), t);
186 if (!fileExists(relTp)) {
187 fail(`${label}: ${side} test file not found: ${t}`);
188 continue;
189 }
190 if (!fileContainsAnnotation(relTp, contractId, annotationName)) {
191 fail(`${label}: ${side} test file missing @${annotationName} ${contractId} annotation: ${t}`);
192 }
193 continue;
194 }
195 if (!fileContainsAnnotation(tp, contractId, annotationName)) {
196 fail(`${label}: ${side} test file missing @${annotationName} ${contractId} annotation: ${t}`);
197 }
198 }
199 }
200
201 function validateParityTestFiles(testSpec, contractId, label) {
202 const tests = Array.isArray(testSpec) ? testSpec : [testSpec];
203 for (const t of tests) {
204 const tp = path.resolve(t);
205 const resolved = fileExists(tp) ? tp : path.resolve(process.cwd(), t);
206 if (!fileExists(resolved)) {
207 fail(`${label}: parityTest file not found: ${t}`);
208 continue;
209 }
210 if (!fileContainsParityAnnotation(resolved, contractId)) {
211 fail(`${label}: parityTest file missing @parityTest ${contractId} annotation: ${t}`);
212 }
213 }
214 }
215
216 function validateBilateralContracts(dir) {
217 const manifestPath = path.join(dir, "contracts.json");
218 if (!fileExists(manifestPath)) return;
219
220 console.log(`Validating bilateral contracts: ${manifestPath}`);
221
222 let manifest;
223 try {
224 manifest = loadJson(manifestPath);
225 } catch (e) {
226 fail(`contracts.json is not valid JSON: ${e.message}`);
227 return;
228 }
229
230 if (!manifest.contracts || !Array.isArray(manifest.contracts)) {
231 fail("contracts.json must have a 'contracts' array");
232 return;
233 }
234
235 const referencedSchemas = new Set();
236 const seenIds = new Set();
237
238 for (const contract of manifest.contracts) {
239 const label = `contract '${contract.id}'`;
240
241 if (!contract.id) {
242 fail("contract missing 'id' field");
243 continue;
244 }
245 if (seenIds.has(contract.id)) {
246 fail(`${label}: duplicate contract id`);
247 }
248 seenIds.add(contract.id);
249
250 if (!contract.schema) {
251 fail(`${label}: missing 'schema' field`);
252 continue;
253 }
254
255 if (!contract.direction) {
256 fail(`${label}: missing 'direction' field`);
257 }
258
259 const schemaPath = path.resolve(dir, contract.schema);
260 referencedSchemas.add(schemaPath);
261 const schema = validateSchemaFile(schemaPath, label);
262
263 if (contract.producer) {
264 if (contract.producer.test) {
265 validateTestFiles(contract.producer.test, contract.id, "producer", label);
266 } else {
267 fail(`${label}: producer missing 'test' field`);
268 }
269 } else if (contract.direction !== "server-to-client") {
270 fail(`${label}: bilateral contract missing 'producer'`);
271 }
272
273 if (contract.consumer) {
274 if (contract.consumer.test) {
275 validateTestFiles(contract.consumer.test, contract.id, "consumer", label);
276 } else {
277 fail(`${label}: consumer missing 'test' field`);
278 }
279 } else if (contract.direction !== "client-to-server") {
280 fail(`${label}: bilateral contract missing 'consumer'`);
281 }
282
283 if (contract.fixtures && schema) {
284 validateFixtures(schema, schemaPath, contract.fixtures, dir, label);
285 } else if (!contract.fixtures) {
286 fail(`${label}: missing 'fixtures' (need at least one valid and one invalid)`);
287 }
288
289 if (contract.parityTest) {
290 validateParityTestFiles(contract.parityTest, contract.id, label);
291 } else {
292 fail(
293 `${label}: missing 'parityTest' field. Every over-the-wire contract needs an integration test that exercises the real client -> real server -> real DB round-trip. See ~/.claude/docs/outbound-contracts.md for the pattern.`
294 );
295 }
296 }
297
298 const allSchemas = findJsonSchemaFiles(path.join(dir, "schemas"));
299 for (const s of allSchemas) {
300 if (!referencedSchemas.has(s)) {
301 const rel = path.relative(dir, s);
302 fail(`orphan schema not referenced by any contract: ${rel}`);
303 }
304 }
305 }
306
307 function validateVendorContracts(dir) {
308 const manifestPath = path.join(dir, "vendor-contracts.json");
309 if (!fileExists(manifestPath)) return;
310
311 console.log(`Validating vendor contracts: ${manifestPath}`);
312
313 let manifest;
314 try {
315 manifest = loadJson(manifestPath);
316 } catch (e) {
317 fail(`vendor-contracts.json is not valid JSON: ${e.message}`);
318 return;
319 }
320
321 if (!manifest.contracts || !Array.isArray(manifest.contracts)) {
322 fail("vendor-contracts.json must have a 'contracts' array");
323 return;
324 }
325
326 const seenIds = new Set();
327
328 for (const contract of manifest.contracts) {
329 const label = `vendor contract '${contract.id}'`;
330
331 if (!contract.id) {
332 fail("vendor contract missing 'id' field");
333 continue;
334 }
335 if (seenIds.has(contract.id)) {
336 fail(`${label}: duplicate contract id`);
337 }
338 seenIds.add(contract.id);
339
340 if (!contract.schema) {
341 fail(`${label}: missing 'schema' field`);
342 continue;
343 }
344
345 const schemaPath = path.resolve(dir, contract.schema);
346 const schema = validateSchemaFile(schemaPath, label);
347
348 if (contract.owner) {
349 if (contract.owner.test) {
350 validateTestFiles(contract.owner.test, contract.id, "owner", label);
351 } else {
352 fail(`${label}: owner missing 'test' field`);
353 }
354 } else {
355 fail(`${label}: missing 'owner'`);
356 }
357
358 if (contract.fixtures && schema) {
359 validateFixtures(schema, schemaPath, contract.fixtures, dir, label);
360 }
361 }
362 }
363
364 function validateLegacyFixtures(fixtures, baseDir, label) {
365 for (const f of fixtures.legacy || []) {
366 const fp = path.resolve(baseDir, f);
367 if (!fileExists(fp)) {
368 fail(`${label}: legacy fixture not found: ${f}`);
369 continue;
370 }
371 try {
372 loadJson(fp);
373 } catch (e) {
374 fail(`${label}: legacy fixture is not valid JSON: ${f} (${e.message})`);
375 }
376 }
377 }
378
379 function validateStorageContracts(dir) {
380 const manifestPath = path.join(dir, "storage-contracts.json");
381 if (!fileExists(manifestPath)) return;
382
383 console.log(`Validating storage contracts: ${manifestPath}`);
384
385 let manifest;
386 try {
387 manifest = loadJson(manifestPath);
388 } catch (e) {
389 fail(`storage-contracts.json is not valid JSON: ${e.message}`);
390 return;
391 }
392
393 if (!manifest.contracts || !Array.isArray(manifest.contracts)) {
394 fail("storage-contracts.json must have a 'contracts' array");
395 return;
396 }
397
398 const referencedSchemas = new Set();
399 const seenIds = new Set();
400
401 for (const contract of manifest.contracts) {
402 const label = `storage contract '${contract.id}'`;
403
404 if (!contract.id) {
405 fail("storage contract missing 'id' field");
406 continue;
407 }
408 if (seenIds.has(contract.id)) {
409 fail(`${label}: duplicate contract id`);
410 }
411 seenIds.add(contract.id);
412
413 if (!contract.schema) {
414 fail(`${label}: missing 'schema' field`);
415 continue;
416 }
417 if (!contract.storageKey) {
418 fail(`${label}: missing 'storageKey' field`);
419 }
420 if (!Number.isInteger(contract.currentVersion) || contract.currentVersion < 1) {
421 fail(`${label}: currentVersion must be a positive integer`);
422 }
423
424 const schemaPath = path.resolve(dir, contract.schema);
425 referencedSchemas.add(schemaPath);
426 const schema = validateSchemaFile(schemaPath, label);
427 if (schema) {
428 const required = Array.isArray(schema.required) ? schema.required : [];
429 if (!required.includes("_schemaVersion")) {
430 fail(`${label}: schema must require _schemaVersion`);
431 }
432 const versionSchema = schema.properties && schema.properties._schemaVersion;
433 if (!versionSchema || versionSchema.type !== "integer") {
434 fail(`${label}: schema property _schemaVersion must have type integer`);
435 } else if (
436 Number.isInteger(contract.currentVersion) &&
437 Object.prototype.hasOwnProperty.call(versionSchema, "const") &&
438 versionSchema.const !== contract.currentVersion
439 ) {
440 fail(`${label}: _schemaVersion const must match currentVersion`);
441 }
442 }
443
444 if (contract.writer && contract.writer.test) {
445 validateTestFiles(contract.writer.test, contract.id, "writer", label, "storage-contract");
446 } else {
447 fail(`${label}: writer missing 'test' field`);
448 }
449
450 if (contract.reader && contract.reader.test) {
451 validateTestFiles(contract.reader.test, contract.id, "reader", label, "storage-contract");
452 } else {
453 fail(`${label}: reader missing 'test' field`);
454 }
455
456 if (contract.migrations && typeof contract.migrations === "object") {
457 for (const [transition, migrationFile] of Object.entries(contract.migrations)) {
458 if (!/^[1-9][0-9]*-to-[1-9][0-9]*$/.test(transition)) {
459 fail(`${label}: migration key must look like 1-to-2: ${transition}`);
460 }
461 const migrationPath = path.resolve(dir, String(migrationFile));
462 if (!fileExists(migrationPath)) {
463 fail(`${label}: migration file not found for ${transition}: ${migrationFile}`);
464 }
465 }
466 }
467
468 if (contract.fixtures && schema) {
469 validateFixtures(schema, schemaPath, contract.fixtures, dir, label);
470 validateLegacyFixtures(contract.fixtures, dir, label);
471 } else if (!contract.fixtures) {
472 fail(`${label}: missing 'fixtures' (need valid, invalid, and legacy fixtures)`);
473 }
474 }
475
476 const allSchemas = findJsonSchemaFiles(path.join(dir, "storage-schemas"));
477 for (const s of allSchemas) {
478 if (!referencedSchemas.has(s)) {
479 const rel = path.relative(dir, s);
480 fail(`orphan storage schema not referenced by any storage contract: ${rel}`);
481 }
482 }
483 }
484
485 const VENDORED_SCHEMA_OWNER_MARKER = "OWNER: 404-solution-server";
486
487 /**
488 * True when the schema at this path records a FOREIGN repo as its owner.
489 *
490 * Keying off the file's own ownership record rather than a manifest field means
491 * a schema declares its status in one place, and any future vendored schema is
492 * picked up automatically. `direction` is deliberately NOT the discriminator:
493 * this repo's internal ajax-* contracts are 'client-to-server' too (browser to
494 * admin-ajax) and are owned right here.
495 *
496 * @param {string} schemaPath Absolute path to a JSON Schema file.
497 * @returns {boolean}
498 */
499 function isVendoredSchema(schemaPath) {
500 if (!fileExists(schemaPath)) return false;
501 let schema;
502 try {
503 schema = loadJson(schemaPath);
504 } catch {
505 // validateSchemaFile already reported the parse error for this path.
506 return false;
507 }
508 return (
509 typeof schema.$comment === "string" &&
510 schema.$comment.includes(VENDORED_SCHEMA_OWNER_MARKER)
511 );
512 }
513
514 /**
515 * Fails when any file this repo VENDORS has drifted from the copy that owns it.
516 *
517 * The unit is the whole contract, not just its schema: a contract whose schema
518 * is foreign-owned has foreign-owned GOLDEN FIXTURES too, because they are the
519 * agreed examples of that same wire payload and both repos validate against
520 * them. Checking only the schema is what let contracts/fixtures/
521 * error-report.valid.json drift for the same reason and in the same commit as
522 * the schema itself did.
523 *
524 * For report.schema.json the owner is 404-solution-server, which compiles its
525 * copy as the Fastify body schema; that is the only copy whose constraints can
526 * reject a request, while this one is a send-time pre-flight.
527 *
528 * A missing server checkout is a hard failure rather than a skip: this gate
529 * exists precisely because the previous detection
530 * (formerly delegated to an unpublished remote workflow) never ran once, the GitHub
531 * mirror being an allowlist publish that carries no workflows, and a check that
532 * stands down when it cannot see the other side reports "no drift" forever.
533 *
534 * @param {string} dir Absolute path to this repo's contracts directory.
535 * @returns {void}
536 */
537 function validateVendoredContractFiles(dir) {
538 const manifestPath = path.join(dir, "contracts.json");
539 if (!fileExists(manifestPath)) return;
540
541 let manifest;
542 try {
543 manifest = loadJson(manifestPath);
544 } catch (e) {
545 // validateBilateralContracts already reported this same parse failure with
546 // its own message; re-reporting would double-count one defect. Logged so
547 // an early return here is never silent.
548 console.log(` (skipping vendored-file check: contracts.json unreadable: ${e.message})`);
549 return;
550 }
551 if (!manifest.contracts || !Array.isArray(manifest.contracts)) return;
552
553 const shared = new Set();
554 for (const contract of manifest.contracts) {
555 if (!contract.schema) continue;
556 if (!isVendoredSchema(path.resolve(dir, contract.schema))) continue;
557
558 shared.add(contract.schema);
559 const fixtures = contract.fixtures || {};
560 for (const f of [...(fixtures.valid || []), ...(fixtures.invalid || [])]) {
561 shared.add(f);
562 }
563 }
564
565 const vendored = [...shared].sort();
566 if (vendored.length === 0) return;
567
568 console.log(
569 `Validating ${vendored.length} vendored contract file(s) against owner: ${serverContractsDir}`
570 );
571
572 if (!pathExists(serverContractsDir)) {
573 fail(
574 `vendored contract owner not found: ${serverContractsDir}. The files here ` +
575 `(${vendored.join(", ")}) are verbatim copies owned by 404-solution-server; ` +
576 `clone it beside this repo, or pass --server-contracts-dir / set ` +
577 `ABJ404_SERVER_CONTRACTS_DIR, so drift can actually be detected.`
578 );
579 return;
580 }
581
582 for (const rel of vendored) {
583 const localPath = path.resolve(dir, rel);
584 const ownerPath = path.resolve(serverContractsDir, rel);
585
586 if (!fileExists(ownerPath)) {
587 fail(
588 `vendored contract file '${rel}' has no owning copy at ${ownerPath}. Either ` +
589 `the server removed it (this copy must go too) or the path moved.`
590 );
591 continue;
592 }
593 if (!fileExists(localPath)) {
594 fail(`vendored contract file '${rel}' not found: ${localPath}`);
595 continue;
596 }
597
598 if (!filesEqual(localPath, ownerPath)) {
599 fail(
600 `vendored contract file '${rel}' has drifted from its owner. This copy is ` +
601 `never edited directly: make the change in 404-solution-server, then run\n` +
602 ` cp ${ownerPath} ${localPath}\n` +
603 ` and, for report.schema.json, update EXPECTED_SHA256 in ` +
604 `tests-js/report-schema-drift.test.js here and in ` +
605 `tests/report-schema-drift.test.js there.`
606 );
607 }
608 }
609 }
610
611 // --- Main ---
612
613 if (!pathExists(contractsDir) && !pathExists(vendorDir)) {
614 process.exit(0);
615 }
616
617 validateBilateralContracts(contractsDir);
618 validateVendorContracts(vendorDir);
619 validateStorageContracts(contractsDir);
620 validateVendoredContractFiles(contractsDir);
621
622 if (errors.length > 0) {
623 console.error(`\n${errors.length} contract validation error(s):\n`);
624 for (const e of errors) {
625 console.error(` FAIL: ${e}`);
626 }
627 console.error("");
628 process.exit(1);
629 } else {
630 const contractCount =
631 (fileExists(path.join(contractsDir, "contracts.json")) ? loadJson(path.join(contractsDir, "contracts.json")).contracts.length : 0) +
632 (fileExists(path.join(vendorDir, "vendor-contracts.json")) ? loadJson(path.join(vendorDir, "vendor-contracts.json")).contracts.length : 0);
633 const storageContractCount =
634 fileExists(path.join(contractsDir, "storage-contracts.json"))
635 ? loadJson(path.join(contractsDir, "storage-contracts.json")).contracts.length
636 : 0;
637 if (storageContractCount > 0) {
638 console.log(` OK: ${contractCount} contract(s), ${storageContractCount} storage contract(s) validated`);
639 } else {
640 console.log(` OK: ${contractCount} contract(s) validated`);
641 }
642 process.exit(0);
643 }
644