/** * Topology — map a repo-relative file path to its product area. * * This is the DRY keystone of the topology layer: the ONLY area-matching * implementation in the monorepo. Consumers (CLI scan, Cloud managed-editor * preview, cross-repo reconciliation) import it; none reimplements glob/area * logic. Pure and browser-safe — no Node, no parser deps. * * See `apps/cloud/docs/topology/01-architecture.md §1`. */ export type AreaCriticality = "low" | "medium" | "high" | "revenue" | "regulated"; export interface Area { id: string; name: string; criticality: AreaCriticality; owners: string[]; /** Globs matched against repo-relative file paths in v0. */ files: string[]; /** Stored for later route-topology adapters; NOT matched in v0. */ routes?: string[]; /** Tie-break: higher wins. Defaults to declaration order. */ priority?: number; } export interface Topology { /** Bump signals "re-tag" to consumers that cache. */ version: number; /** Whether area file globs are matched relative to app.path or repo root. Defaults to "app". */ base?: "app" | "repo"; /** Array (not a keyed record) so precedence is deterministic. */ areas: Area[]; } export interface AreaMatch { areaId: string; areaName: string; criticality: AreaCriticality; owners: string[]; /** The winning pattern — surfaced in reports for transparency. */ matchedGlob: string; } /** * Compile a minimal glob to an anchored RegExp. We intentionally do NOT depend * on picomatch: core stays lean, the surface we need is small, and we want * exact control over Next.js route-group parens (`(marketing)` is a literal * path segment here, not an extglob group). * * Semantics: * - `/**` at a segment boundary (end-of-glob or before `/`) → "this directory * and everything under it": the leading slash is optional, so * `src/app/checkout/**` matches `src/app/checkout` AND any descendant. * - a bare/leading `**` → matches across path separators (`.*`). Note a * leading globstar-then-slash does NOT match the root (use a lone `**` * for "everything"). * - `*` → matches within a single segment (`[^/]*`) * - `?` → a single non-separator char * - `{a,b}` → alternation `(?:a|b)` * - every other regex-significant char (`.`, `(`, `)`, `+`, …) is literal * * Throws on a malformed pattern (e.g. an unbalanced `{`). Callers compile via * `compile()`, which catches the throw so a config typo degrades one glob to a * non-match instead of aborting the whole scan. */ function globToRegExp(glob: string): RegExp { let out = ""; let braceDepth = 0; let i = 0; while (i < glob.length) { const ch = glob[i]; // `/**` at a segment boundary → optional slash + anything-below. Matches // the directory itself (`a/**` ⇒ `a`) and every descendant. if ( ch === "/" && glob[i + 1] === "*" && glob[i + 2] === "*" && (glob[i + 3] === undefined || glob[i + 3] === "/") ) { out += "(?:/.*)?"; i += 3; // consume `/**`; a trailing `/` (in `a/**/b`) is handled next pass continue; } // Bare/leading globstar — crosses path separators. if (ch === "*" && glob[i + 1] === "*") { out += ".*"; i += 2; continue; } switch (ch) { case "*": out += "[^/]*"; // single segment break; case "?": out += "[^/]"; break; case "{": braceDepth += 1; out += "(?:"; break; case "}": if (braceDepth > 0) { braceDepth -= 1; out += ")"; } else { out += "\\}"; } break; case ",": out += braceDepth > 0 ? "|" : ","; break; // Regex-significant chars kept literal (parens cover route groups). case ".": case "(": case ")": case "+": case "^": case "$": case "|": case "[": case "]": case "\\": out += `\\${ch}`; break; default: out += ch; } i += 1; } return new RegExp(`^${out}$`); } interface CompiledArea { area: Area; matchers: { glob: string; re: RegExp }[]; } /** * Cache compiled matchers per topology object. Consumers pass the same * `topology` for every finding in a run, so this turns N×globs recompiles into * one. A reloaded config is a new object → fresh entry, so `version` bumps need * no manual invalidation. */ const compiledCache = new WeakMap(); function compile(topology: Topology): CompiledArea[] { const cached = compiledCache.get(topology); if (cached) return cached; // Order by (priority desc, declaration order). Decorate-sort-undecorate keeps // the sort stable across engines. const ordered = topology.areas .map((area, index) => ({ area, index })) .sort((a, b) => { const pa = a.area.priority ?? 0; const pb = b.area.priority ?? 0; if (pa !== pb) return pb - pa; return a.index - b.index; }) .map(({ area }) => ({ area, matchers: area.files.flatMap((glob) => { try { return [{ glob, re: globToRegExp(glob) }]; } catch { // A malformed glob (e.g. unbalanced `{`) compiles to an invalid // RegExp. Skip it rather than aborting the entire scan — topology // config is user-authored, so one typo must not take down // `fragments check`. Its files simply fall through to "Unassigned". if (typeof console !== "undefined") { console.warn( `[topology] ignoring invalid glob in area "${area.id}": ${JSON.stringify(glob)}` ); } return []; } }), })); compiledCache.set(topology, ordered); return ordered; } /** Normalize to a POSIX, repo-relative path before matching. */ function normalizePath(repoRelPath: string): string { return repoRelPath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, ""); } /** * Resolve the product area for a repo-relative path. First match by * `(priority desc, declaration order)`. Returns `null` when nothing matches — * the caller buckets that as the explicit "Unassigned" area (never dropped; * dropping unmatched evidence would make coverage lie). */ export function resolveArea(repoRelPath: string, topology: Topology): AreaMatch | null { const path = normalizePath(repoRelPath); for (const { area, matchers } of compile(topology)) { for (const { glob, re } of matchers) { if (re.test(path)) { return { areaId: area.id, areaName: area.name, criticality: area.criticality, owners: area.owners, matchedGlob: glob, }; } } } return null; }