/*
RFC-0382: RFC-specific cache helpers. Reads and fills cached RFC frontmatter
entries using the CacheLayer interface. Content hashing uses @warpgogol/fingerprint
(DNA-53). Cache invalidation is per-file via mtime + content hash.
- Do not implement cache storage — that lives in cache-layer.ts and sqlite-cache-layer.ts.
- Do not validate RFC frontmatter — that lives in handlers/validate-rules.ts.
- RFC lookups are cached because RFC content is immutable once accepted.
- RFC-0382: initial implementation — RfcCacheEntry, getCachedRfcEntries, rfcCacheEntryToParsedRfc.
- RFC-0382 post-review: check schemaVersion during cache invalidation; use top-level readFile import.
- 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.
*/
import { join } from "node:path";
import { byteHashFile } from "@warpgogol/werkstatt-engine/fingerprint";
import type { CacheLayer } from "./cache-layer.ts";
import { listRfcFiles, parseRfcFile, type ParsedRfc } from "@warpgogol/forge/os/rfc";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
export const RFC_CACHE_NAMESPACE = "rfc_entries";
export const RFC_CACHE_SCHEMA_VERSION = 1;
export interface RfcCacheEntry {
id: string;
fileName: string;
status: string;
kind: string;
scope: string;
title: string;
owners: string[];
createdAt: string;
updatedAt: string;
implementedAt: string | null;
closedAt: string | null;
supersedes: string[];
supersededBy: string | null;
amends: string[];
amendedBy: string[];
related: string[];
satisfies: string[];
commandsProposed: string[];
commandsAdded: string[];
commandsChanged: string[];
commandsRemoved: string[];
frontmatterRaw: string;
body: string;
bodyLength: number;
schemaVersion: number;
mtime: number;
contentHash: string;
}
function arr(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
function str(value: unknown): string {
return typeof value === "string" ? value : "";
}
function nullableStr(value: unknown): string | null {
if (typeof value === "string" && value.length > 0) return value;
return null;
}
function buildRfcCacheEntry(
fileName: string,
parsed: ParsedRfc,
mtime: number,
contentHash: string,
): RfcCacheEntry {
const fm = parsed.frontmatter;
const commands = (fm["commands"] ?? {}) as Record;
return {
id: str(fm["id"]),
fileName,
status: str(fm["status"]),
kind: str(fm["kind"]),
scope: str(fm["scope"]),
title: str(fm["title"]),
owners: arr(fm["owners"]),
createdAt: str(fm["createdAt"]),
updatedAt: str(fm["updatedAt"]),
implementedAt: nullableStr(fm["implementedAt"]),
closedAt: nullableStr(fm["closedAt"]),
supersedes: arr(fm["supersedes"]),
supersededBy: nullableStr(fm["supersededBy"]),
amends: arr(fm["amends"]),
amendedBy: arr(fm["amendedBy"]),
related: arr(fm["related"]),
satisfies: arr(fm["satisfies"]),
commandsProposed: arr(commands["proposed"] ?? fm["commandsProposed"]),
commandsAdded: arr(commands["added"] ?? fm["commandsAdded"]),
commandsChanged: arr(commands["changed"] ?? fm["commandsChanged"]),
commandsRemoved: arr(commands["removed"] ?? fm["commandsRemoved"]),
frontmatterRaw: JSON.stringify(fm),
body: parsed.body,
bodyLength: parsed.body.length,
schemaVersion: RFC_CACHE_SCHEMA_VERSION,
mtime,
contentHash,
};
}
export function rfcCacheEntryToParsedRfc(entry: RfcCacheEntry): {
fileName: string;
parsed: ParsedRfc;
} {
return {
fileName: entry.fileName,
parsed: {
frontmatter: JSON.parse(entry.frontmatterRaw) as Record,
body: entry.body,
},
};
}
async function getCachedEntryForFile(
cache: CacheLayer,
rfcDirPath: string,
fileName: string,
forceRefresh: boolean,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
if (!forceRefresh && cache.available) {
const cached = await cache.get(RFC_CACHE_NAMESPACE, fileName);
if (cached) {
const cachedEntry = cached.data as RfcCacheEntry;
// Check schema version + mtime + content hash for invalidation
if (cachedEntry.schemaVersion !== RFC_CACHE_SCHEMA_VERSION) {
// Schema changed — force reparse by falling through to cache miss
} else {
const filePath = join(rfcDirPath, fileName);
try {
const fileStat = await io.stat(filePath);
if (
fileStat.mtimeMs === cachedEntry.mtime &&
cachedEntry.contentHash === cached.contentHash
) {
return cachedEntry;
}
} catch {
// File may have been deleted — return undefined to skip
return undefined;
}
}
}
}
// Cache miss or force refresh — read and parse the file
const filePath = join(rfcDirPath, fileName);
let fileContent: string;
let mtime: number;
try {
fileContent = await io.readFile(filePath);
const fileStat = await io.stat(filePath);
mtime = fileStat.mtimeMs;
} catch {
return undefined;
}
const parsed = parseRfcFile(fileContent);
const contentHash = await byteHashFile(filePath);
const entry = buildRfcCacheEntry(fileName, parsed, mtime, contentHash);
if (cache.available) {
await cache.set(RFC_CACHE_NAMESPACE, fileName, entry, mtime, contentHash);
}
return entry;
}
export async function getCachedRfcEntries(
cache: CacheLayer,
rfcDirPath: string,
forceRefresh = false,
): Promise