/*
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();
}