Files
sencho/scripts/website-catalog/canonical-validate.mjs
Anso a53fc42b86 fix(catalog): use js-yaml namespace import for ESM v5 (#1942)
js-yaml 5 removed the ESM default export, so the website-catalog scripts
failed Catalog Drift Check with a SyntaxError on import.
2026-09-19 15:12:26 -04:00

108 lines
4.0 KiB
JavaScript

#!/usr/bin/env node
/**
* Sencho tier-reconciliation: canonical catalog validator
* Verifies docs/feature-catalog.yaml against the canonical schema and
* the required cross-field invariant (tier: internal iff availability: internal).
* Exits 0 on valid, exits 1 with diagnostic lines on invalid.
*/
import * as yaml from 'js-yaml';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_FILE = path.resolve(__dirname, '../../docs/feature-catalog.yaml');
const VALID_TIERS = new Set(['community', 'admiral', 'internal']);
const VALID_AVAILABILITY = new Set(['shipped', 'planned', 'internal']);
const VALID_CATEGORIES = new Set([
'compose-deploy', 'fleet-orchestration', 'security-foundation',
'automation-operations', 'recovery', 'identity-access',
'governance', 'assurance', 'internal',
]);
const errors = [];
function error(msg) { errors.push('ERROR: ' + msg); }
function readCatalog() {
const text = fs.readFileSync(CATALOG_FILE, 'utf8');
const doc = yaml.load(text, { schema: yaml.CORE_SCHEMA });
if (!doc || typeof doc !== 'object') {
throw new Error('catalog is not a YAML mapping');
}
return doc;
}
function main() {
const doc = readCatalog();
if (!Array.isArray(doc.entries)) {
console.error('FAIL: catalog has no entries array.');
process.exit(1);
}
const entries = doc.entries;
const ids = new Set();
for (const [i, entry] of entries.entries()) {
const prefix = `entry[${i}].id=${entry?.id ?? '(missing)'}`;
if (!entry || typeof entry !== 'object') {
error(`${prefix}: entry is not an object`);
continue;
}
if (!entry.id) error(`${prefix}: missing id`);
else if (ids.has(entry.id)) error(`${prefix}: duplicate id "${entry.id}"`);
else ids.add(entry.id);
if (!entry.name) error(`${prefix}: missing name`);
if (!VALID_TIERS.has(entry.tier))
error(`${prefix}: invalid tier "${entry.tier}"; must be one of community/admiral/internal`);
if (!VALID_AVAILABILITY.has(entry.availability))
error(`${prefix}: invalid availability "${entry.availability}"; must be one of shipped/planned/internal`);
// Cross-field invariant: tier: internal iff availability: internal
if (entry.tier === 'internal' && entry.availability !== 'internal')
error(`${prefix}: tier: internal requires availability: internal`);
if (entry.tier !== 'internal' && entry.availability === 'internal')
error(`${prefix}: non-internal tier requires non-internal availability`);
if (entry.availability === 'planned') {
if (!entry.publicRoadmapKey) error(`${prefix}: planned entry must have publicRoadmapKey`);
}
if (!VALID_CATEGORIES.has(entry.category))
error(`${prefix}: unknown category "${entry.category}"`);
// Internal-only fields must not leak into committed catalog.
// The canonical file IS public, so we enforce: no linear, no evidence with internal identifiers,
// no internalNote. Public-name fields only.
if (entry.linear) {
// Reject any internal Linear identifier in committed file.
if (/SEN-[0-9]/.test(String(entry.linear)))
error(`${prefix}: committed catalog contains internal Linear identifier in linear field ("${entry.linear}"); use publicRoadmapKey instead`);
}
// No evidence field allowed in canonical committed file (evidence stays internal).
if (entry.evidence)
error(`${prefix}: evidence field must not appear in committed canonical catalog (use Linear/non-public record for evidence); got: ${entry.evidence}`);
if (entry.internalNote)
error(`${prefix}: internalNote field must not appear in committed canonical catalog`);
}
if (errors.length === 0) {
console.log(`VALID: catalog has ${entries.length} entries; all invariants pass.`);
process.exit(0);
} else {
for (const msg of errors) console.error(msg);
console.error(`FAIL: ${errors.length} error(s) found.`);
process.exit(1);
}
}
try {
main();
} catch (e) {
console.error('FAIL: ' + (e.message || e));
process.exit(1);
}