diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df942a32b0..52eb03a432 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -318,6 +318,33 @@ jobs: - name: Validate install manifest run: npm run validate:manifest + registry-determinism: + name: Entity Registry Determinism + needs: changes + if: ${{ needs.changes.outputs.code == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Validate registry determinism + # Re-runs populate-entity-registry.js and compares output against the + # committed entity-registry.yaml. Catches the category of drift that + # produced #754 (regen against partially-edited working tree). See #758. + run: npm run validate:registry-determinism + ide-sync-validation: name: IDE Command Sync Validation needs: changes @@ -409,6 +436,7 @@ jobs: test, story-validation, manifest-validation, + registry-determinism, ide-sync-validation, installer-smoke-test, compatibility-parity-gate, @@ -435,6 +463,7 @@ jobs: echo " Tests: ${{ needs.test.result }}" echo " Story Validation: ${{ needs.story-validation.result }}" echo " Manifest Validation: ${{ needs.manifest-validation.result }}" + echo " Registry Determinism: ${{ needs.registry-determinism.result }}" echo " IDE Sync Validation: ${{ needs.ide-sync-validation.result }}" echo " Installer Smoke Test: ${{ needs.installer-smoke-test.result }}" echo " Dependency Validation: ${{ needs.dependency-validation.result }}" @@ -480,6 +509,12 @@ jobs: FAILED=true fi + # Registry Determinism: fail if ran and didn't succeed + if [ "${{ needs.registry-determinism.result }}" == "failure" ]; then + echo "❌ Entity registry determinism check failed — regen drift detected" + FAILED=true + fi + # IDE Sync: fail if ran and didn't succeed if [ "${{ needs.ide-sync-validation.result }}" == "failure" ]; then echo "❌ IDE sync validation failed" diff --git a/package.json b/package.json index 296abf2e7c..cad37a69bb 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "release:test": "semantic-release --dry-run --no-ci || echo 'Config test complete - authentication errors are expected locally'", "generate:manifest": "node scripts/generate-install-manifest.js", "validate:manifest": "node scripts/validate-manifest.js", + "validate:registry-determinism": "node scripts/validate-registry-determinism.js", "validate:structure": "node .aiox-core/infrastructure/scripts/source-tree-guardian/index.js", "validate:agents": "node .aiox-core/infrastructure/scripts/validate-agents.js", "sync:ide": "node .aiox-core/infrastructure/scripts/ide-sync/index.js sync", diff --git a/scripts/validate-registry-determinism.js b/scripts/validate-registry-determinism.js new file mode 100644 index 0000000000..8bd64bb6a6 --- /dev/null +++ b/scripts/validate-registry-determinism.js @@ -0,0 +1,173 @@ +#!/usr/bin/env node +/** + * Validate Entity Registry Determinism + * + * Re-runs the IDS registry populator against the current source tree and + * compares the output against the committed `.aiox-core/data/entity-registry.yaml`. + * Mutations of `dependencies` and `usedBy` arrays — not just timestamp drift — + * fail the check. Catches the category of drift that produced #754 (false + * alarm): a partially-edited working tree silently producing a registry that + * doesn't match what the algorithm would produce on the same source. + * + * @script scripts/validate-registry-determinism.js + * @issue #758 + * + * Usage: + * node scripts/validate-registry-determinism.js + * npm run validate:registry-determinism + * + * Exit codes: + * 0 - Committed registry matches a clean regen + * 1 - Drift detected — run populate-entity-registry.js locally and commit + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const { execFileSync } = require('child_process'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const REGISTRY_PATH = path.join(REPO_ROOT, '.aiox-core', 'data', 'entity-registry.yaml'); +const POPULATOR_PATH = path.join(REPO_ROOT, '.aiox-core', 'development', 'scripts', 'populate-entity-registry.js'); + +// Fields that legitimately differ between runs (clock-driven or +// content-hash-driven and re-computed on every regen). We compare structure +// minus these. +const VOLATILE_FIELDS = new Set(['lastUpdated', 'lastVerified', 'checksum']); + +/** + * Recursively strip volatile fields from a parsed YAML structure. + */ +function stripVolatile(node) { + if (Array.isArray(node)) { + return node.map(stripVolatile); + } + if (node && typeof node === 'object') { + const out = {}; + for (const key of Object.keys(node)) { + if (VOLATILE_FIELDS.has(key)) continue; + out[key] = stripVolatile(node[key]); + } + return out; + } + return node; +} + +function fail(message, details) { + console.error('\n❌ Registry determinism check FAILED'); + console.error(' ' + message); + if (details) { + console.error(''); + console.error(details); + } + console.error(''); + console.error(' Remediate locally:'); + console.error(' node .aiox-core/development/scripts/populate-entity-registry.js'); + console.error(' git add .aiox-core/data/entity-registry.yaml'); + console.error(' git commit -m "chore(ids): regen entity-registry"'); + console.error(''); + process.exit(1); +} + +function main() { + if (!fs.existsSync(REGISTRY_PATH)) { + fail(`Registry not found at ${REGISTRY_PATH}`); + } + if (!fs.existsSync(POPULATOR_PATH)) { + fail(`Populator script not found at ${POPULATOR_PATH}`); + } + + // 1. Snapshot the committed registry so we can restore it regardless of outcome. + const committedYaml = fs.readFileSync(REGISTRY_PATH, 'utf8'); + const committedParsed = yaml.load(committedYaml); + + let regennedYaml; + let regennedParsed; + try { + // 2. Run the populator. It mutates `REGISTRY_PATH` in place. + execFileSync('node', [POPULATOR_PATH], { + cwd: REPO_ROOT, + stdio: 'pipe', + }); + regennedYaml = fs.readFileSync(REGISTRY_PATH, 'utf8'); + regennedParsed = yaml.load(regennedYaml); + } finally { + // 3. Restore the committed registry no matter what — the populator + // mutated the working tree and CI/local should not carry that drift + // forward in subsequent steps. Tests + lint may also rely on the + // committed state. + fs.writeFileSync(REGISTRY_PATH, committedYaml); + } + + // 4. Compare structures with volatile fields stripped. + const committedStripped = stripVolatile(committedParsed); + const regennedStripped = stripVolatile(regennedParsed); + + // Sort keys recursively before stringifying so map ordering doesn't trigger + // false positives. (JSON.stringify's second arg is a replacer, not a key + // sorter — passing an array there would FILTER keys instead.) + const sortedStringify = (value) => + JSON.stringify(value, (_key, v) => { + if (v && typeof v === 'object' && !Array.isArray(v)) { + const sorted = {}; + for (const k of Object.keys(v).sort()) { + sorted[k] = v[k]; + } + return sorted; + } + return v; + }); + + const committedJson = sortedStringify(committedStripped); + const regennedJson = sortedStringify(regennedStripped); + + if (committedJson === regennedJson) { + console.log('✅ Registry determinism check PASSED'); + console.log(` ${committedParsed.metadata.entityCount} entities, structure matches regen output.`); + return; + } + + // 5. Diff failed — emit an actionable summary of what drifted. + const drifts = []; + const committedEntities = committedStripped.entities || {}; + const regennedEntities = regennedStripped.entities || {}; + + const allCategories = new Set([ + ...Object.keys(committedEntities), + ...Object.keys(regennedEntities), + ]); + + for (const category of allCategories) { + const committedCat = committedEntities[category] || {}; + const regennedCat = regennedEntities[category] || {}; + const allNames = new Set([...Object.keys(committedCat), ...Object.keys(regennedCat)]); + for (const name of allNames) { + const c = committedCat[name]; + const r = regennedCat[name]; + if (!c || !r) { + drifts.push(` [${category}] ${name}: ${!c ? 'added by regen' : 'removed by regen'}`); + continue; + } + const cDeps = JSON.stringify((c.dependencies || []).slice().sort()); + const rDeps = JSON.stringify((r.dependencies || []).slice().sort()); + if (cDeps !== rDeps) { + drifts.push(` [${category}] ${name}: dependencies differ`); + } + const cUsed = JSON.stringify((c.usedBy || []).slice().sort()); + const rUsed = JSON.stringify((r.usedBy || []).slice().sort()); + if (cUsed !== rUsed) { + drifts.push(` [${category}] ${name}: usedBy differs`); + } + } + } + + const detail = drifts.length + ? drifts.slice(0, 30).join('\n') + (drifts.length > 30 ? `\n ... and ${drifts.length - 30} more` : '') + : `(structural delta beyond dependency/usedBy — diff committed vs regenned ${REGISTRY_PATH})`; + + fail(`${drifts.length} entries diverged between committed registry and clean regen output.`, detail); +} + +main();