/* RFC-0685: Workspace tree index — a single in-memory map of file paths to metadata (mtimeMs, size) built once per pipeline run. Replaces N independent directory walks with one walk + in-memory glob filtering. Do not implement cache storage — that lives in cache-layer.ts and sqlite-cache-layer.ts. Do not implement fingerprinting — that lives in @warpgogol/fingerprint. Do not implement cache read/write logic — that lives in command-result-cache.ts. The tree index is a single in-memory map — rebuilt on demand rather than persisted. mtimeMs is the staleness signal — content hashes are deliberately not tracked. RFC-0685: initial implementation — WorkspaceTreeEntry, WorkspaceTreeIndex, buildWorkspaceTreeIndex, filterTreeIndex. 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. RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt */ import { join, relative, sep } from "node:path"; import picomatch from "picomatch"; import { createDefaultIO, type DirEntry, type WorkspaceIO, } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; export interface WorkspaceTreeEntry { mtimeMs: number; size: number; } export type WorkspaceTreeIndex = Map; const DEFAULT_EXCLUDE_DIRS = [".git", "node_modules"]; function toPosix(p: string): string { return p.split(sep).join("/"); } /** * Build a workspace tree index: a Map from POSIX-relative file paths to * { mtimeMs, size } metadata. Walks the workspace root once, excluding * `.git/` and `node_modules/` by default. * * The `dist/` and `missions/` directories are intentionally NOT excluded * because some commands declare `reads` patterns matching files in those * directories (e.g. dist/client html files). */ export async function buildWorkspaceTreeIndex( workspaceRoot: string, excludeDirs?: string[], ): Promise { const exclude = new Set(excludeDirs ?? DEFAULT_EXCLUDE_DIRS); const index: WorkspaceTreeIndex = new Map(); // fs.walk.lint: allow — walks via WorkspaceIO abstraction (io.readdir) and builds a DirEntry tree index, not a path list async function walk(dir: string, io: WorkspaceIO = createDefaultIO().io): Promise { let entries: DirEntry[]; try { entries = await io.readdir(dir); } catch { return; } for (const entry of entries) { if (entry.isDirectory) { if (exclude.has(entry.name)) continue; await walk(join(dir, entry.name)); } else if (entry.isFile) { const abs = join(dir, entry.name); const rel = toPosix(relative(workspaceRoot, abs)); try { const s = await io.stat(abs); index.set(rel, { mtimeMs: s.mtimeMs, size: s.size }); } catch { // skip files that disappear between readdir and stat } } } } await walk(workspaceRoot); return index; } /** * Resolve the app token in a read pattern relative to the base directory, * then return the workspace-root-relative POSIX path. */ function resolvePattern(pattern: string, baseDir: string, workspaceRoot: string): string { return pattern.replace("", toPosix(relative(workspaceRoot, baseDir))); } /** * Filter the workspace tree index against picomatch glob patterns. * Returns sorted absolute file paths matching any pattern. */ export function filterTreeIndex( index: WorkspaceTreeIndex, patterns: string[], baseDir: string, workspaceRoot: string, ): string[] { const resolvedPatterns = patterns.map((p) => resolvePattern(p, baseDir, workspaceRoot)); const isMatch = picomatch(resolvedPatterns, { dot: true, nocase: false }); const matched: string[] = []; for (const rel of index.keys()) { if (isMatch(rel)) { matched.push(join(workspaceRoot, ...rel.split("/"))); } } return matched.sort(); }