aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
318 lines (273 loc) • 10.4 kB
JavaScript
/**
* lint-schemas.mjs
*
* Validates:
* 1. schemas/executor-v1.json is a valid draft-2020-12 JSON Schema (meta-schema check)
* 2. Each fixture in test/conformance/executor-v1/fixtures/ validates against
* schemas/executor-v1.json — specifically the per-message-type refs declared in
* the fixture's `_schema_refs` or `_validates_as` fields.
*
* Uses ajv (already a transitive dep via @modelcontextprotocol/sdk) via dynamic require.
* Does NOT add ajv as a top-level package.json dependency.
*
* Exit 0 = all checks pass.
* Exit 1 = one or more validation errors.
*
* Usage:
* node tools/scripts/lint-schemas.mjs
* npm run lint:schemas
*
* @see docs/contracts/executor.v1.md
* @see schemas/executor-v1.json
* @see test/conformance/executor-v1/
* @issue #1178
*/
import { readFileSync, readdirSync, existsSync } from 'fs';
import { resolve, join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, '..', '..');
// ── Ajv bootstrap ──────────────────────────────────────────────────────────
let Ajv2020, addFormats;
function loadAjv() {
const require = createRequire(import.meta.url);
const ajvPaths = [
join(projectRoot, 'node_modules', 'ajv', 'dist', '2020.js'),
join(projectRoot, 'node_modules', 'ajv', 'dist', 'ajv.js'),
];
const formatsPath = join(projectRoot, 'node_modules', 'ajv-formats', 'dist', 'index.js');
let Ajv = null;
for (const p of ajvPaths) {
if (existsSync(p)) {
try {
Ajv = require(p);
break;
} catch {
// try next
}
}
}
if (!Ajv) {
console.error(
'\n[lint-schemas] ERROR: Could not load ajv from node_modules.\n' +
'ajv is a transitive dependency but was not found at the expected path.\n' +
'Install it as a devDependency: npm install --save-dev ajv ajv-formats\n'
);
process.exit(1);
}
let formats = null;
if (existsSync(formatsPath)) {
try {
formats = require(formatsPath);
} catch {
// formats are optional — warn but continue
}
}
return { Ajv, formats };
}
const { Ajv, formats: formatsModule } = loadAjv();
// Ajv may be exported as default, as Ajv2020, or as the constructor directly.
// The 2020.js dist exports { Ajv2020, default } — prefer the named export.
const AjvConstructor = Ajv.Ajv2020 ?? Ajv.default ?? Ajv;
const ajv = new AjvConstructor({
strict: false,
allErrors: true,
verbose: true,
// Do not try to fetch/validate the $schema meta-schema URI — it is already
// implied by using the Ajv2020 constructor. Without this flag Ajv throws
// "no schema with key or ref https://json-schema.org/draft/2020-12".
validateSchema: false,
});
if (formatsModule) {
const addFormatsFn = formatsModule.default ?? formatsModule;
if (typeof addFormatsFn === 'function') {
addFormatsFn(ajv);
}
}
// ── Paths ──────────────────────────────────────────────────────────────────
const SCHEMA_PATH = join(projectRoot, 'schemas', 'executor-v1.json');
const FIXTURES_DIR = join(projectRoot, 'test', 'conformance', 'executor-v1', 'fixtures');
// ── Helpers ────────────────────────────────────────────────────────────────
/** Load and parse a JSON file, throwing a clear error on parse failure. */
function loadJson(filePath) {
const raw = readFileSync(filePath, 'utf8');
try {
return JSON.parse(raw);
} catch (err) {
throw new Error(`JSON parse error in ${filePath}: ${err.message}`);
}
}
/** Collect every object in a deep structure that has a _validates_as key. */
function collectValidatables(obj, accumulator = []) {
if (obj === null || typeof obj !== 'object') return accumulator;
if (Array.isArray(obj)) {
for (const item of obj) collectValidatables(item, accumulator);
return accumulator;
}
if (typeof obj._validates_as === 'string') {
accumulator.push({ ref: obj._validates_as, value: obj });
}
for (const val of Object.values(obj)) {
collectValidatables(val, accumulator);
}
return accumulator;
}
/** Strip private fields (prefixed _) before validating. */
function stripPrivate(obj) {
if (obj === null || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(stripPrivate);
const out = {};
for (const [k, v] of Object.entries(obj)) {
if (!k.startsWith('_')) {
out[k] = stripPrivate(v);
}
}
return out;
}
/** Resolve a JSON-pointer ref like "#/$defs/foo" into a sub-schema. */
function resolveRef(schema, ref) {
if (!ref.startsWith('#/')) {
return schema; // external ref — treat as root schema
}
const parts = ref.slice(2).split('/');
let current = schema;
for (const part of parts) {
if (current === undefined || current === null) return null;
current = current[part];
}
return current ?? null;
}
// ── Step 1: validate the schema file itself ───────────────────────────────
let errors = 0;
let warnings = 0;
console.log('\n[lint-schemas] Checking executor-v1.json …');
if (!existsSync(SCHEMA_PATH)) {
console.error(` ERROR: Schema file not found at ${SCHEMA_PATH}`);
process.exit(1);
}
const executorSchema = loadJson(SCHEMA_PATH);
// Check the $schema declaration
const declared = executorSchema['$schema'] ?? '';
if (!declared.includes('2020-12')) {
console.warn(` WARN: $schema declares "${declared}" — expected draft 2020-12.`);
warnings++;
} else {
console.log(' ✓ $schema declares draft-2020-12');
}
// Compile the schema — Ajv will throw on structural errors
let rootValidate;
try {
rootValidate = ajv.compile(executorSchema);
console.log(' ✓ Schema compiled successfully');
} catch (err) {
console.error(` ERROR: Schema failed to compile: ${err.message}`);
process.exit(1);
}
// Verify every $def is resolvable and compilable
const defs = executorSchema['$defs'] ?? {};
const defNames = Object.keys(defs);
console.log(` ✓ $defs present: ${defNames.length} definitions`);
for (const defName of defNames) {
const subSchema = defs[defName];
try {
// Use addSchema so we can validate sub-schemas independently
const fakeId = `executor.aiwg.io/v1/defs/${defName}`;
// Inline the $defs so refs resolve; wrap in a root with $defs
const wrappedSchema = {
$schema: 'https://json-schema.org/draft/2020-12',
$defs: defs,
...subSchema,
};
ajv.compile(wrappedSchema);
// console.log(` ✓ $def/${defName} compiles`);
} catch (err) {
console.error(` ERROR: $def/${defName} failed to compile: ${err.message}`);
errors++;
}
}
if (errors === 0) {
console.log(` ✓ All ${defNames.length} $defs compile without errors`);
}
// ── Step 2: validate fixtures ─────────────────────────────────────────────
if (!existsSync(FIXTURES_DIR)) {
console.warn(`\n[lint-schemas] WARN: fixtures dir not found at ${FIXTURES_DIR}. Skipping fixture validation.`);
warnings++;
} else {
const fixtureFiles = readdirSync(FIXTURES_DIR).filter(f => f.endsWith('.json'));
console.log(`\n[lint-schemas] Checking ${fixtureFiles.length} fixture(s) in ${FIXTURES_DIR} …`);
for (const fname of fixtureFiles) {
const fixturePath = join(FIXTURES_DIR, fname);
console.log(`\n [${fname}]`);
let fixture;
try {
fixture = loadJson(fixturePath);
} catch (err) {
console.error(` ERROR: ${err.message}`);
errors++;
continue;
}
// Collect all validatable objects in the fixture
const validatables = collectValidatables(fixture);
if (validatables.length === 0) {
console.warn(` WARN: No _validates_as markers found. Add "_validates_as": "#/$defs/<name>" to validate specific message shapes.`);
warnings++;
continue;
}
let fixtureErrors = 0;
let fixtureChecks = 0;
for (const { ref, value } of validatables) {
fixtureChecks++;
const subSchema = resolveRef(executorSchema, ref);
if (!subSchema) {
console.warn(` WARN: Cannot resolve ref "${ref}" in schema — skipping.`);
warnings++;
continue;
}
// Build a wrapped schema so $refs inside the sub-schema resolve correctly
const wrappedSchema = {
$schema: 'https://json-schema.org/draft/2020-12',
$defs: defs,
...subSchema,
};
let validate;
try {
validate = ajv.compile(wrappedSchema);
} catch (err) {
console.error(` ERROR: Failed to compile sub-schema for "${ref}": ${err.message}`);
fixtureErrors++;
continue;
}
const cleaned = stripPrivate(value);
const valid = validate(cleaned);
if (!valid) {
console.error(` ERROR: Validation failed for "${ref}":`);
for (const vErr of (validate.errors ?? [])) {
const path = vErr.instancePath || '/';
console.error(` - ${path}: ${vErr.message} (${JSON.stringify(vErr.params)})`);
}
fixtureErrors++;
}
}
if (fixtureErrors > 0) {
console.error(` ✗ ${fixtureErrors}/${fixtureChecks} check(s) failed`);
errors += fixtureErrors;
} else {
console.log(` ✓ ${fixtureChecks} check(s) passed`);
}
}
}
// ── Summary ────────────────────────────────────────────────────────────────
console.log('\n' + '─'.repeat(60));
if (errors > 0) {
console.error(`[lint-schemas] FAILED — ${errors} error(s), ${warnings} warning(s)`);
process.exit(1);
} else {
if (warnings > 0) {
console.warn(`[lint-schemas] PASSED — 0 errors, ${warnings} warning(s)`);
} else {
console.log('[lint-schemas] PASSED — all checks green');
}
process.exit(0);
}