Files
sencho/scripts/website-catalog/canonical-validate.mjs
T
Anso 9940efb94f feat: add canonical tier catalog with cross-repo drift detection (#1873)
* feat(tier-reconcile): seed tier-catalog with validated inventory

Verified current-state catalog (29 entries) with cross-field invariant
(tier: internal iff availability: internal). No internal Linear IDs
in committed file; publicRoadmapKey slugs used instead.
Canonical validator (scripts/website-catalog/canonical-validate.mjs) passes.

Refs: SEN-549

* feat(tier-reconcile): add canonical catalog, sync scripts, and CI drift check

Add canonical feature catalog (29 entries, no SEN-NNN identifiers) with
cross-field invariant (tier:internal iff availability:internal).
Sencho-owned scripts:
- canonical-validate.mjs: schema + invariant validation
- sync-feature-catalog.mjs: builds sanitized public projection
- check-website-drift.mjs: checksum-based drift detection
- test-drift-detection.mjs: unit tests for drift logic
- test-catalog-no-leak.mjs: no prohibited identifiers

GitHub Actions catalog-drift.yml: pull_request required check + push safeguard.

Refs: SEN-549

* fix(tier-reconcile): correct relative paths in scripts for standalone runs

Use fileURLToPath to resolve paths relative to script directory rather
than cwd. Fixes PA-01/PA-02 script execution from any directory.
Also removes SEN-NNN references from docs/feature-catalog.yaml entries
and updates limitation text per audit.

* ci(catalog-drift): authenticate the cross-repo website checkout

The drift check reads the website repository, which is private, so the
ambient workflow token cannot see it and the checkout failed with a
not-found error before any validation ran. Mint a GitHub App
installation token scoped to that one repository with read-only contents
access, matching the pattern the docs sync workflow already uses.

Also declare contents: read at the workflow level so the job stops
inheriting the repository default token permissions.

* fix(catalog-drift): make the drift check able to fail

The job reported success no matter what the website repository contained,
for two compounding reasons.

The root checkout ran after the website checkout. actions/checkout cleans
its destination, so it deleted website-checkout before any script ran.
Reorder so the root checkout comes first.

The verify step then regenerated the snapshot into that directory before
comparing against it, so the comparison only ever read back what it had
just written, recreating the deleted tree along the way. Drop the sync
call and compare against what the website has actually committed.

The comparison also trusted the checksum recorded in the snapshot
metadata without checking that it described the snapshot file sitting
next to it, so a hand-edited or stale snapshot passed beside fresh
metadata. Require both to agree.

Round out the surrounding tooling: a catalog with no entries array now
fails validation instead of reporting zero entries, the unused clone
branch no longer calls require from an ES module, and the failure output
names the regeneration command, which is now reachable as an npm script.

* ci(catalog-drift): check for website-side drift on a daily schedule

The path filters only fire on changes inside this repository, so an
edited or reverted snapshot in the website repository left the check
green while the two were genuinely out of sync. A daily run closes that
window without waiting for someone to touch the canonical catalog.

* fix(catalog-scripts): check every prohibited key and drop an inert test

The leak check listed five prohibited keys but only tested three by
hand, so an entry carrying route or service would have reached the
public catalog unnoticed. Drive the loop from the list instead.

Remove test-drift-detection.mjs. Nothing invoked it, and it asserted
against a reimplemented normalizer rather than the drift script it named,
so it reported coverage it did not provide.
2026-08-30 20:18:08 -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 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);
}