Reference implementation for the Phoenix Architecture. Work in progress.
aicoding.leaflet.pub/
ai
coding
crazy
1/**
2 * Drift Detection — compares working tree vs generated manifest.
3 *
4 * Detects when generated files have been manually edited without
5 * a waiver, which breaks the provenance chain.
6 */
7
8import { readFileSync, existsSync } from 'node:fs';
9import type { GeneratedManifest, DriftEntry, DriftReport, DriftWaiver } from './models/manifest.js';
10import { DriftStatus } from './models/manifest.js';
11import { sha256 } from './semhash.js';
12
13/**
14 * Check all files in the manifest against the working tree.
15 */
16export function detectDrift(
17 manifest: GeneratedManifest,
18 projectRoot: string,
19 waivers?: Map<string, DriftWaiver>,
20): DriftReport {
21 const entries: DriftEntry[] = [];
22
23 for (const iuManifest of Object.values(manifest.iu_manifests)) {
24 for (const [filePath, entry] of Object.entries(iuManifest.files)) {
25 const fullPath = projectRoot + '/' + filePath;
26 const waiver = waivers?.get(filePath);
27
28 if (!existsSync(fullPath)) {
29 entries.push({
30 status: DriftStatus.MISSING,
31 file_path: filePath,
32 iu_id: iuManifest.iu_id,
33 expected_hash: entry.content_hash,
34 });
35 continue;
36 }
37
38 const actualContent = readFileSync(fullPath, 'utf8');
39 const actualHash = sha256(actualContent);
40
41 if (actualHash === entry.content_hash) {
42 entries.push({
43 status: DriftStatus.CLEAN,
44 file_path: filePath,
45 iu_id: iuManifest.iu_id,
46 expected_hash: entry.content_hash,
47 actual_hash: actualHash,
48 });
49 } else if (waiver) {
50 entries.push({
51 status: DriftStatus.WAIVED,
52 file_path: filePath,
53 iu_id: iuManifest.iu_id,
54 expected_hash: entry.content_hash,
55 actual_hash: actualHash,
56 waiver,
57 });
58 } else {
59 entries.push({
60 status: DriftStatus.DRIFTED,
61 file_path: filePath,
62 iu_id: iuManifest.iu_id,
63 expected_hash: entry.content_hash,
64 actual_hash: actualHash,
65 });
66 }
67 }
68 }
69
70 const clean = entries.filter(e => e.status === DriftStatus.CLEAN).length;
71 const drifted = entries.filter(e => e.status === DriftStatus.DRIFTED).length;
72 const missing = entries.filter(e => e.status === DriftStatus.MISSING).length;
73 const waived = entries.filter(e => e.status === DriftStatus.WAIVED).length;
74
75 let summary: string;
76 if (drifted === 0 && missing === 0) {
77 summary = `All ${clean} generated files are clean.${waived > 0 ? ` ${waived} waived.` : ''}`;
78 } else {
79 const parts: string[] = [];
80 if (drifted > 0) parts.push(`${drifted} drifted`);
81 if (missing > 0) parts.push(`${missing} missing`);
82 summary = `DRIFT DETECTED: ${parts.join(', ')}. ${clean} clean.`;
83 }
84
85 return { entries, clean_count: clean, drifted_count: drifted, missing_count: missing, summary };
86}