Skip to content

Commit f31254d

Browse files
rafaelscostaclaude
andauthored
feat(ci): add entity-registry determinism check — fixes #758 (#765)
* feat(ci): add entity-registry determinism check [#758] Re-runs `populate-entity-registry.js` against the current source tree and diffs the output against the committed `.aiox-core/data/entity-registry.yaml`. Fails the build if any non-volatile fields (dependencies, usedBy, structural) differ between the committed registry and a clean regen. Catches the category of drift that produced #754 (false alarm): a regen run against a partially-edited working tree silently producing a registry that doesn't match what the algorithm would produce on the same source. ## Implementation - `scripts/validate-registry-determinism.js` — snapshots the committed registry, runs the populator (which mutates the file in place), reads back, always restores the snapshot in `finally` so the working tree is preserved regardless of outcome, strips volatile fields (lastUpdated, lastVerified, checksum) before deep-comparing, and emits an actionable error listing the specific entries that diverged on dependencies or usedBy. - `package.json` — `validate:registry-determinism` npm script wired to the new script. - `.github/workflows/ci.yml` — new `registry-determinism` job in the existing `changes.outputs.code` gate (.aiox-core/**, scripts/**, etc), runs in ~5 min, blocks merge on drift. ## Tested locally - Clean main: PASSES (820 entities, structure matches). - Induced drift (manually zeroed deps of one task): FAILS with "[tasks] analyze-brownfield: dependencies differ" and remediation hint. ## What this does NOT catch - Drift inside other generated files (install-manifest.yaml, IDE projections) — covered by `validate:manifest` and `validate:ide-sync` already. - Algorithmic regressions in the populator itself — those should land via the regular dev/test loop; this gate only verifies deterministic output given the committed source state. Closes #758 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): wire registry-determinism into validation-summary gate [#758] CodeRabbit pointed out that the new `registry-determinism` job was running but not aggregated by `validation-summary`, so a determinism failure wouldn't block the merge gate. Added to `needs` list and to the FAILED check logic with a clear error message. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6216cda commit f31254d

3 files changed

Lines changed: 209 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,33 @@ jobs:
318318
- name: Validate install manifest
319319
run: npm run validate:manifest
320320

321+
registry-determinism:
322+
name: Entity Registry Determinism
323+
needs: changes
324+
if: ${{ needs.changes.outputs.code == 'true' }}
325+
runs-on: ubuntu-latest
326+
timeout-minutes: 5
327+
328+
steps:
329+
- name: Checkout code
330+
uses: actions/checkout@v6
331+
332+
- name: Setup Node.js
333+
uses: actions/setup-node@v6
334+
with:
335+
node-version: '20'
336+
cache: 'npm'
337+
cache-dependency-path: package-lock.json
338+
339+
- name: Install dependencies
340+
run: npm ci
341+
342+
- name: Validate registry determinism
343+
# Re-runs populate-entity-registry.js and compares output against the
344+
# committed entity-registry.yaml. Catches the category of drift that
345+
# produced #754 (regen against partially-edited working tree). See #758.
346+
run: npm run validate:registry-determinism
347+
321348
ide-sync-validation:
322349
name: IDE Command Sync Validation
323350
needs: changes
@@ -409,6 +436,7 @@ jobs:
409436
test,
410437
story-validation,
411438
manifest-validation,
439+
registry-determinism,
412440
ide-sync-validation,
413441
installer-smoke-test,
414442
compatibility-parity-gate,
@@ -435,6 +463,7 @@ jobs:
435463
echo " Tests: ${{ needs.test.result }}"
436464
echo " Story Validation: ${{ needs.story-validation.result }}"
437465
echo " Manifest Validation: ${{ needs.manifest-validation.result }}"
466+
echo " Registry Determinism: ${{ needs.registry-determinism.result }}"
438467
echo " IDE Sync Validation: ${{ needs.ide-sync-validation.result }}"
439468
echo " Installer Smoke Test: ${{ needs.installer-smoke-test.result }}"
440469
echo " Dependency Validation: ${{ needs.dependency-validation.result }}"
@@ -480,6 +509,12 @@ jobs:
480509
FAILED=true
481510
fi
482511
512+
# Registry Determinism: fail if ran and didn't succeed
513+
if [ "${{ needs.registry-determinism.result }}" == "failure" ]; then
514+
echo "❌ Entity registry determinism check failed — regen drift detected"
515+
FAILED=true
516+
fi
517+
483518
# IDE Sync: fail if ran and didn't succeed
484519
if [ "${{ needs.ide-sync-validation.result }}" == "failure" ]; then
485520
echo "❌ IDE sync validation failed"

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"release:test": "semantic-release --dry-run --no-ci || echo 'Config test complete - authentication errors are expected locally'",
7272
"generate:manifest": "node scripts/generate-install-manifest.js",
7373
"validate:manifest": "node scripts/validate-manifest.js",
74+
"validate:registry-determinism": "node scripts/validate-registry-determinism.js",
7475
"validate:structure": "node .aiox-core/infrastructure/scripts/source-tree-guardian/index.js",
7576
"validate:agents": "node .aiox-core/infrastructure/scripts/validate-agents.js",
7677
"sync:ide": "node .aiox-core/infrastructure/scripts/ide-sync/index.js sync",
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Validate Entity Registry Determinism
4+
*
5+
* Re-runs the IDS registry populator against the current source tree and
6+
* compares the output against the committed `.aiox-core/data/entity-registry.yaml`.
7+
* Mutations of `dependencies` and `usedBy` arrays — not just timestamp drift —
8+
* fail the check. Catches the category of drift that produced #754 (false
9+
* alarm): a partially-edited working tree silently producing a registry that
10+
* doesn't match what the algorithm would produce on the same source.
11+
*
12+
* @script scripts/validate-registry-determinism.js
13+
* @issue #758
14+
*
15+
* Usage:
16+
* node scripts/validate-registry-determinism.js
17+
* npm run validate:registry-determinism
18+
*
19+
* Exit codes:
20+
* 0 - Committed registry matches a clean regen
21+
* 1 - Drift detected — run populate-entity-registry.js locally and commit
22+
*/
23+
24+
'use strict';
25+
26+
const fs = require('fs');
27+
const path = require('path');
28+
const yaml = require('js-yaml');
29+
const { execFileSync } = require('child_process');
30+
31+
const REPO_ROOT = path.resolve(__dirname, '..');
32+
const REGISTRY_PATH = path.join(REPO_ROOT, '.aiox-core', 'data', 'entity-registry.yaml');
33+
const POPULATOR_PATH = path.join(REPO_ROOT, '.aiox-core', 'development', 'scripts', 'populate-entity-registry.js');
34+
35+
// Fields that legitimately differ between runs (clock-driven or
36+
// content-hash-driven and re-computed on every regen). We compare structure
37+
// minus these.
38+
const VOLATILE_FIELDS = new Set(['lastUpdated', 'lastVerified', 'checksum']);
39+
40+
/**
41+
* Recursively strip volatile fields from a parsed YAML structure.
42+
*/
43+
function stripVolatile(node) {
44+
if (Array.isArray(node)) {
45+
return node.map(stripVolatile);
46+
}
47+
if (node && typeof node === 'object') {
48+
const out = {};
49+
for (const key of Object.keys(node)) {
50+
if (VOLATILE_FIELDS.has(key)) continue;
51+
out[key] = stripVolatile(node[key]);
52+
}
53+
return out;
54+
}
55+
return node;
56+
}
57+
58+
function fail(message, details) {
59+
console.error('\n❌ Registry determinism check FAILED');
60+
console.error(' ' + message);
61+
if (details) {
62+
console.error('');
63+
console.error(details);
64+
}
65+
console.error('');
66+
console.error(' Remediate locally:');
67+
console.error(' node .aiox-core/development/scripts/populate-entity-registry.js');
68+
console.error(' git add .aiox-core/data/entity-registry.yaml');
69+
console.error(' git commit -m "chore(ids): regen entity-registry"');
70+
console.error('');
71+
process.exit(1);
72+
}
73+
74+
function main() {
75+
if (!fs.existsSync(REGISTRY_PATH)) {
76+
fail(`Registry not found at ${REGISTRY_PATH}`);
77+
}
78+
if (!fs.existsSync(POPULATOR_PATH)) {
79+
fail(`Populator script not found at ${POPULATOR_PATH}`);
80+
}
81+
82+
// 1. Snapshot the committed registry so we can restore it regardless of outcome.
83+
const committedYaml = fs.readFileSync(REGISTRY_PATH, 'utf8');
84+
const committedParsed = yaml.load(committedYaml);
85+
86+
let regennedYaml;
87+
let regennedParsed;
88+
try {
89+
// 2. Run the populator. It mutates `REGISTRY_PATH` in place.
90+
execFileSync('node', [POPULATOR_PATH], {
91+
cwd: REPO_ROOT,
92+
stdio: 'pipe',
93+
});
94+
regennedYaml = fs.readFileSync(REGISTRY_PATH, 'utf8');
95+
regennedParsed = yaml.load(regennedYaml);
96+
} finally {
97+
// 3. Restore the committed registry no matter what — the populator
98+
// mutated the working tree and CI/local should not carry that drift
99+
// forward in subsequent steps. Tests + lint may also rely on the
100+
// committed state.
101+
fs.writeFileSync(REGISTRY_PATH, committedYaml);
102+
}
103+
104+
// 4. Compare structures with volatile fields stripped.
105+
const committedStripped = stripVolatile(committedParsed);
106+
const regennedStripped = stripVolatile(regennedParsed);
107+
108+
// Sort keys recursively before stringifying so map ordering doesn't trigger
109+
// false positives. (JSON.stringify's second arg is a replacer, not a key
110+
// sorter — passing an array there would FILTER keys instead.)
111+
const sortedStringify = (value) =>
112+
JSON.stringify(value, (_key, v) => {
113+
if (v && typeof v === 'object' && !Array.isArray(v)) {
114+
const sorted = {};
115+
for (const k of Object.keys(v).sort()) {
116+
sorted[k] = v[k];
117+
}
118+
return sorted;
119+
}
120+
return v;
121+
});
122+
123+
const committedJson = sortedStringify(committedStripped);
124+
const regennedJson = sortedStringify(regennedStripped);
125+
126+
if (committedJson === regennedJson) {
127+
console.log('✅ Registry determinism check PASSED');
128+
console.log(` ${committedParsed.metadata.entityCount} entities, structure matches regen output.`);
129+
return;
130+
}
131+
132+
// 5. Diff failed — emit an actionable summary of what drifted.
133+
const drifts = [];
134+
const committedEntities = committedStripped.entities || {};
135+
const regennedEntities = regennedStripped.entities || {};
136+
137+
const allCategories = new Set([
138+
...Object.keys(committedEntities),
139+
...Object.keys(regennedEntities),
140+
]);
141+
142+
for (const category of allCategories) {
143+
const committedCat = committedEntities[category] || {};
144+
const regennedCat = regennedEntities[category] || {};
145+
const allNames = new Set([...Object.keys(committedCat), ...Object.keys(regennedCat)]);
146+
for (const name of allNames) {
147+
const c = committedCat[name];
148+
const r = regennedCat[name];
149+
if (!c || !r) {
150+
drifts.push(` [${category}] ${name}: ${!c ? 'added by regen' : 'removed by regen'}`);
151+
continue;
152+
}
153+
const cDeps = JSON.stringify((c.dependencies || []).slice().sort());
154+
const rDeps = JSON.stringify((r.dependencies || []).slice().sort());
155+
if (cDeps !== rDeps) {
156+
drifts.push(` [${category}] ${name}: dependencies differ`);
157+
}
158+
const cUsed = JSON.stringify((c.usedBy || []).slice().sort());
159+
const rUsed = JSON.stringify((r.usedBy || []).slice().sort());
160+
if (cUsed !== rUsed) {
161+
drifts.push(` [${category}] ${name}: usedBy differs`);
162+
}
163+
}
164+
}
165+
166+
const detail = drifts.length
167+
? drifts.slice(0, 30).join('\n') + (drifts.length > 30 ? `\n ... and ${drifts.length - 30} more` : '')
168+
: `(structural delta beyond dependency/usedBy — diff committed vs regenned ${REGISTRY_PATH})`;
169+
170+
fail(`${drifts.length} entries diverged between committed registry and clean regen output.`, detail);
171+
}
172+
173+
main();

0 commit comments

Comments
 (0)