mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-01 21:27:58 +00:00
9940efb94f
* 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.
134 lines
4.6 KiB
JavaScript
134 lines
4.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Sencho tier-reconciliation: website drift detector
|
|
* Compares the current canonical catalog to the website's committed
|
|
* catalog-snapshot. Drift identity is the normalized content checksum.
|
|
* Commit SHA is NOT part of drift identity.
|
|
*
|
|
* Usage:
|
|
* node scripts/website-catalog/check-website-drift.mjs --website-dir <path>
|
|
* node scripts/website-catalog/check-website-drift.mjs --website-ref <git-ref>
|
|
*/
|
|
import yaml from 'js-yaml';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { execFileSync } from 'child_process';
|
|
import os from 'os';
|
|
import { createHash } from 'crypto';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const CANONICAL_FILE = path.resolve(__dirname, '../../docs/feature-catalog.yaml');
|
|
const PUBLIC_FIELDS = new Set([
|
|
'id', 'publicName', 'summary', 'description', 'category',
|
|
'tier', 'availability', 'featured', 'homepageOrder', 'publicRoadmapKey',
|
|
]);
|
|
|
|
function normalizeYaml(obj) {
|
|
return yaml.dump(obj, { sortKeys: true, lineWidth: -1 });
|
|
}
|
|
|
|
function computeChecksum(content) {
|
|
return createHash('sha256').update(content).digest('hex');
|
|
}
|
|
|
|
function loadSnapshot(websiteDir) {
|
|
const snapshotPath = path.join(websiteDir, 'src/data/catalog-snapshot.yaml');
|
|
if (!fs.existsSync(snapshotPath)) {
|
|
return null;
|
|
}
|
|
const text = fs.readFileSync(snapshotPath, 'utf8');
|
|
const doc = yaml.load(text, { schema: yaml.CORE_SCHEMA });
|
|
if (!doc || !Array.isArray(doc.entries)) return null;
|
|
return { doc, text, snapshotPath };
|
|
}
|
|
|
|
function buildProjection(canonical) {
|
|
return canonical.entries
|
|
.filter((e) => e.tier !== 'internal' && e.availability !== 'internal')
|
|
.map((e) => {
|
|
const pub = {};
|
|
for (const key of PUBLIC_FIELDS) {
|
|
if (key in e) pub[key] = e[key];
|
|
}
|
|
return pub;
|
|
});
|
|
}
|
|
|
|
function main() {
|
|
const args = process.argv.slice(2);
|
|
let websiteDir = null;
|
|
let websiteRef = null;
|
|
let cleanup = null;
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--website-dir' && args[i + 1]) {
|
|
websiteDir = args[i + 1]; i++;
|
|
} else if (args[i] === '--website-ref' && args[i + 1]) {
|
|
websiteRef = args[i + 1]; i++;
|
|
}
|
|
}
|
|
|
|
if (websiteRef && !websiteDir) {
|
|
// Clone to temp dir
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-website-'));
|
|
execFileSync('git', ['clone', '--depth', '1', '--branch', websiteRef,
|
|
'https://github.com/Studio-Saelix/sencho-website.git', tmp],
|
|
{ stdio: 'pipe' });
|
|
websiteDir = tmp;
|
|
cleanup = tmp;
|
|
}
|
|
|
|
if (!websiteDir) {
|
|
console.error('ERROR: --website-dir or --website-ref is required');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Read canonical catalog.
|
|
const canonicalText = fs.readFileSync(CANONICAL_FILE, 'utf8');
|
|
const canonical = yaml.load(canonicalText, { schema: yaml.CORE_SCHEMA });
|
|
const projection = buildProjection(canonical);
|
|
const projectionText = normalizeYaml({ entries: projection });
|
|
const currentChecksum = computeChecksum(projectionText);
|
|
|
|
// Read committed snapshot.
|
|
const snap = loadSnapshot(websiteDir);
|
|
if (!snap) {
|
|
console.error(`FAIL: no catalog-snapshot.yaml at ${path.join(websiteDir, 'src/data/')}`);
|
|
if (cleanup) fs.rmSync(cleanup, { recursive: true, force: true });
|
|
process.exit(1);
|
|
}
|
|
|
|
const metaPath = path.join(websiteDir, 'src/data/catalog-snapshot.meta.json');
|
|
let committedChecksum = null;
|
|
if (fs.existsSync(metaPath)) {
|
|
try {
|
|
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
committedChecksum = meta.checksum;
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
// Compute checksum of committed snapshot text (the actual file content).
|
|
const snapChecksum = computeChecksum(snap.text);
|
|
|
|
if (cleanup) fs.rmSync(cleanup, { recursive: true, force: true });
|
|
|
|
// The snapshot file must match the canonical projection, and the metadata
|
|
// must describe that same file. Trusting the metadata alone would let a
|
|
// hand-edited or stale snapshot pass beside a freshly written meta.json.
|
|
if (currentChecksum === committedChecksum && snapChecksum === committedChecksum) {
|
|
console.log(`OK: no drift. checksum=${currentChecksum.slice(0, 12)}...`);
|
|
process.exit(0);
|
|
} else {
|
|
console.error('DRIFT DETECTED:');
|
|
console.error(` current canonical checksum: ${currentChecksum}`);
|
|
console.error(` committed snapshot checksum: ${committedChecksum ?? '(none)'}`);
|
|
console.error(` committed file checksum: ${snapChecksum}`);
|
|
console.error('');
|
|
console.error('Regenerate the website snapshot and commit src/data/ in the website repo:');
|
|
console.error(' npm run catalog:sync -- --website-dir <path-to-sencho-website>');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|