/*
run-update — synchronizes the integrity manifests with file system changes across tracked files.
- Do not handle raw file parsing or content validation.
- Do not manage external configuration or transport orchestration.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
*/
/**
* Update integrity manifests after file system changes.
* Processes Git changes to update entities, manifests, and path bindings.
*/
import path from "node:path";
import { byteHashFile } from "@warpgogol/werkstatt-engine/fingerprint";
import { discoverManagedFiles } from "./discover.ts";
import { getChangedPaths, getFileHistory } from "./git.ts";
import { readJsonFile, writeJsonFile } from "./json.ts";
import {
createEmptyManifest,
getManifestFileName,
loadDirectoryManifest,
removeManifestRecord,
saveDirectoryManifest,
upsertManifestRecord,
} from "./manifests.ts";
import { deletedLogPath } from "./paths.ts";
import {
loadEntitiesById,
loadPathsCurrent,
saveEntitiesById,
savePathsCurrent,
} from "./registry.ts";
import { detectMoves } from "./move-detection.ts";
import { v7 as uuidv7 } from "uuid";
import type {
DeletedLogItem,
DirectoryManifest,
EntitiesById,
ManifestFileRecord,
MoveCandidate,
PathsCurrent,
RegistryEntity,
} from "./types.ts";
function uniqueSorted(values: Iterable): string[] {
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
}
async function findOutOfSyncActivePaths(args: {
cwd: string;
entities: EntitiesById;
paths: PathsCurrent;
currentManagedFiles: string[];
}): Promise {
const { cwd, entities, paths, currentManagedFiles } = args;
const outOfSyncPaths: string[] = [];
for (const repoPath of currentManagedFiles) {
const entityId = paths[repoPath];
if (!entityId) {
continue;
}
const entity = entities[entityId];
if (!entity || entity.status !== "active") {
continue;
}
const actualHash = await byteHashFile(path.join(cwd, repoPath)).catch(() => null);
if (!actualHash || actualHash === entity.contentHash) {
continue;
}
outOfSyncPaths.push(repoPath);
}
return outOfSyncPaths;
}
function entityToManifestRecord(entityId: string, entity: RegistryEntity): ManifestFileRecord {
return {
entityId,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
revision: entity.revision,
contentHash: entity.contentHash,
gitSha: entity.gitSha,
status: entity.status,
};
}
async function loadDeletedLog(cwd: string): Promise {
return readJsonFile(deletedLogPath(cwd)).catch(() => []);
}
function createManifestStore(cwd: string) {
const cache = new Map();
return {
async get(repoDir: string): Promise {
const existing = cache.get(repoDir);
if (existing) return existing;
const loaded = (await loadDirectoryManifest(cwd, repoDir)) ?? createEmptyManifest(repoDir);
cache.set(repoDir, loaded);
return loaded;
},
async saveAll(): Promise {
for (const manifest of cache.values()) {
await saveDirectoryManifest(cwd, manifest);
}
},
};
}
async function moveEntity(args: {
cwd: string;
entities: EntitiesById;
paths: PathsCurrent;
manifestStore: ReturnType;
move: MoveCandidate;
}): Promise {
const { cwd, entities, paths, manifestStore, move } = args;
const entity = entities[move.entityId];
if (!entity) return;
const oldPath = move.from;
const newPath = move.to;
const oldHash = entity.contentHash;
const absNewPath = path.join(cwd, newPath);
const nextHash = await byteHashFile(absNewPath).catch(() => oldHash);
const history = await getFileHistory(cwd, newPath);
const now = new Date().toISOString();
const isContentChange = nextHash !== oldHash;
entity.currentPath = newPath;
entity.status = "active";
entity.gitSha = history.lastCommitSha ?? entity.gitSha;
if (isContentChange) {
entity.contentHash = nextHash;
entity.updatedAt = history.updatedAt ?? now;
entity.revision += 1;
}
entity.moves = entity.moves ?? [];
entity.moves.push({
from: oldPath,
to: newPath,
detectedAt: now,
confidence: move.confidence,
method: move.method,
});
delete paths[oldPath];
paths[newPath] = move.entityId;
const oldDir = path.posix.dirname(oldPath);
const newDir = path.posix.dirname(newPath);
const oldFileName = getManifestFileName(oldPath);
const newFileName = getManifestFileName(newPath);
const oldManifest = await manifestStore.get(oldDir);
removeManifestRecord(oldManifest, oldFileName);
const newManifest = await manifestStore.get(newDir);
upsertManifestRecord(newManifest, newFileName, entityToManifestRecord(move.entityId, entity));
}
async function markDeleted(args: {
entities: EntitiesById;
paths: PathsCurrent;
deletedLog: DeletedLogItem[];
manifestStore: ReturnType;
repoPath: string;
}): Promise {
const { entities, paths, deletedLog, manifestStore, repoPath } = args;
const entityId = paths[repoPath];
if (!entityId) return;
const entity = entities[entityId];
if (!entity) return;
entity.status = "deleted";
delete paths[repoPath];
deletedLog.push({
entityId,
lastPath: repoPath,
deletedAt: new Date().toISOString(),
lastRevision: entity.revision,
lastHash: entity.contentHash,
});
const repoDir = path.posix.dirname(repoPath);
const fileName = getManifestFileName(repoPath);
const manifest = await manifestStore.get(repoDir);
removeManifestRecord(manifest, fileName);
}
async function addNewEntity(args: {
cwd: string;
entities: EntitiesById;
paths: PathsCurrent;
manifestStore: ReturnType;
repoPath: string;
}): Promise {
const { cwd, entities, paths, manifestStore, repoPath } = args;
const absPath = path.join(cwd, repoPath);
const hash = await byteHashFile(absPath);
const history = await getFileHistory(cwd, repoPath);
const now = new Date().toISOString();
const entityId = uuidv7();
entities[entityId] = {
currentPath: repoPath,
firstPath: repoPath,
createdAt: history.createdAt ?? now,
updatedAt: history.updatedAt ?? now,
revision: 1,
contentHash: hash,
gitSha: history.lastCommitSha ?? "unknown",
status: "active",
moves: [],
};
paths[repoPath] = entityId;
const repoDir = path.posix.dirname(repoPath);
const fileName = getManifestFileName(repoPath);
const manifest = await manifestStore.get(repoDir);
upsertManifestRecord(manifest, fileName, entityToManifestRecord(entityId, entities[entityId]));
}
async function updateModifiedEntity(args: {
cwd: string;
entities: EntitiesById;
paths: PathsCurrent;
manifestStore: ReturnType;
repoPath: string;
}): Promise {
const { cwd, entities, paths, manifestStore, repoPath } = args;
const entityId = paths[repoPath];
if (!entityId) return;
const entity = entities[entityId];
if (!entity || entity.status !== "active") return;
const absPath = path.join(cwd, repoPath);
const nextHash = await byteHashFile(absPath);
if (nextHash === entity.contentHash) return;
const history = await getFileHistory(cwd, repoPath);
entity.contentHash = nextHash;
entity.updatedAt = history.updatedAt ?? new Date().toISOString();
entity.gitSha = history.lastCommitSha ?? entity.gitSha;
entity.revision += 1;
const repoDir = path.posix.dirname(repoPath);
const fileName = getManifestFileName(repoPath);
const manifest = await manifestStore.get(repoDir);
upsertManifestRecord(manifest, fileName, entityToManifestRecord(entityId, entity));
}
export async function runUpdate(args: { cwd: string; baseRef?: string }): Promise {
const { cwd, baseRef } = args;
const entities = await loadEntitiesById(cwd);
const paths = await loadPathsCurrent(cwd);
const deletedLog = await loadDeletedLog(cwd);
const manifestStore = createManifestStore(cwd);
const currentManagedFiles = await discoverManagedFiles(cwd);
const currentManagedSet = new Set(currentManagedFiles);
const changes = await getChangedPaths(cwd, baseRef);
const outOfSyncActivePaths = await findOutOfSyncActivePaths({
cwd,
entities,
paths,
currentManagedFiles,
});
const activePaths = Object.entries(paths)
.filter(([, entityId]) => entities[entityId]?.status === "active")
.map(([repoPath]) => repoPath)
.sort((a, b) => a.localeCompare(b));
const deletedCandidates = uniqueSorted([
...changes.deleted,
...activePaths.filter((repoPath) => !currentManagedSet.has(repoPath)),
]).filter((repoPath) => paths[repoPath]);
const addedCandidates = uniqueSorted([
...changes.added,
...currentManagedFiles.filter((repoPath) => !paths[repoPath]),
]).filter((repoPath) => currentManagedSet.has(repoPath));
const modifiedCandidates = uniqueSorted([
...changes.modified,
...changes.renamed.map((item) => item.to),
...outOfSyncActivePaths,
]).filter((repoPath) => currentManagedSet.has(repoPath) && Boolean(paths[repoPath]));
const matchedDeleted = new Set();
const matchedAdded = new Set();
const explicitMoves: MoveCandidate[] = changes.renamed
.filter(({ from, to }) => Boolean(paths[from]) && currentManagedSet.has(to))
.map(({ from, to }) => ({
entityId: paths[from],
from,
to,
confidence: 1,
method: "heuristic",
}));
for (const move of explicitMoves) {
if (matchedDeleted.has(move.from) || matchedAdded.has(move.to)) continue;
await moveEntity({ cwd, entities, paths, manifestStore, move });
matchedDeleted.add(move.from);
matchedAdded.add(move.to);
}
const inferredMoves = await detectMoves({
cwd,
deletedPaths: deletedCandidates.filter((repoPath) => !matchedDeleted.has(repoPath)),
addedPaths: addedCandidates.filter((repoPath) => !matchedAdded.has(repoPath)),
});
for (const move of inferredMoves) {
if (matchedDeleted.has(move.from) || matchedAdded.has(move.to)) continue;
await moveEntity({ cwd, entities, paths, manifestStore, move });
matchedDeleted.add(move.from);
matchedAdded.add(move.to);
}
for (const repoPath of deletedCandidates) {
if (matchedDeleted.has(repoPath)) continue;
await markDeleted({ entities, paths, deletedLog, manifestStore, repoPath });
}
for (const repoPath of addedCandidates) {
if (matchedAdded.has(repoPath)) continue;
await addNewEntity({ cwd, entities, paths, manifestStore, repoPath });
}
for (const repoPath of modifiedCandidates) {
if (!paths[repoPath]) continue;
await updateModifiedEntity({ cwd, entities, paths, manifestStore, repoPath });
}
await manifestStore.saveAll();
await saveEntitiesById(cwd, entities);
await savePathsCurrent(cwd, paths);
await writeJsonFile(deletedLogPath(cwd), deletedLog);
console.log("");
console.log("Integrity update");
console.log(` base ref ${baseRef ?? "(working tree)"}`);
console.log(` added ${addedCandidates.length - matchedAdded.size}`);
console.log(` deleted ${deletedCandidates.length - matchedDeleted.size}`);
console.log(` moved ${matchedAdded.size}`);
console.log(` modified ${modifiedCandidates.length}`);
}