import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { getAgentPath } from "../../src/shared/agent-paths"; import { diagnostic, type DiagnosticMessage } from "../../src/shared/diagnostics"; export type ConditionalRuleWhen = "file-accessed" | "always-if-present" | "manual"; export type ConditionalRuleLayer = "global" | "project"; export interface ConditionalRule { name: string; globs: string[]; priority: number; when: ConditionalRuleWhen; body: string; layer: ConditionalRuleLayer; filePath: string; } export interface LoadConditionalRulesResult { rules: ConditionalRule[]; diagnostics: DiagnosticMessage[]; } interface ParsedFrontmatter { values: Partial>; body: string; unknownKeys: string[]; malformed: string[]; } const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; const ALLOWED_KEYS = new Set(["name", "globs", "priority", "when"]); const VALID_WHEN = new Set(["file-accessed", "always-if-present", "manual"]); function stripInlineComment(value: string): string { let quote: string | null = null; for (let i = 0; i < value.length; i += 1) { const char = value[i]; if (quote) { if (char === "\\") i += 1; else if (char === quote) quote = null; continue; } if (char === "\"" || char === "'") quote = char; else if (char === "#" && (i === 0 || /\s/.test(value[i - 1] ?? ""))) return value.slice(0, i).trimEnd(); } return value.trimEnd(); } function parseString(value: string): string | null { const trimmed = stripInlineComment(value).trim(); if (!trimmed) return null; if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { const inner = trimmed.slice(1, -1); return trimmed.startsWith("\"") ? inner.replace(/\\(["\\nrt])/g, (_match, escaped: string) => { if (escaped === "n") return "\n"; if (escaped === "r") return "\r"; if (escaped === "t") return "\t"; return escaped; }) : inner.replace(/''/g, "'"); } return trimmed; } function splitArrayItems(value: string): string[] | null { const items: string[] = []; let quote: string | null = null; let current = ""; for (let i = 0; i < value.length; i += 1) { const char = value[i]; if (quote) { current += char; if (char === "\\") { i += 1; if (i < value.length) current += value[i]; } else if (char === quote) { quote = null; } continue; } if (char === "\"" || char === "'") { quote = char; current += char; continue; } if (char === ",") { items.push(current.trim()); current = ""; continue; } current += char; } if (quote) return null; if (current.trim() || value.trim().endsWith(",")) items.push(current.trim()); return items; } function parseInlineStringArray(value: string): string[] | null { const trimmed = stripInlineComment(value).trim(); if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null; const inner = trimmed.slice(1, -1).trim(); if (!inner) return []; const rawItems = splitArrayItems(inner); if (!rawItems) return null; const parsed = rawItems.map((item) => parseString(item)); return parsed.every((item): item is string => typeof item === "string") ? parsed : null; } function parseFrontmatter(raw: string, filePath: string): ParsedFrontmatter | { diagnostic: DiagnosticMessage } { const match = raw.match(FRONTMATTER_RE); if (!match) { return { diagnostic: diagnostic( "warning", raw.startsWith("---") ? `conditional-rules: skipped "${filePath}" — malformed YAML frontmatter block` : `conditional-rules: skipped "${filePath}" — missing YAML frontmatter block`, ), }; } const frontmatter = match[1] ?? ""; const body = raw.slice(match[0].length); const lines = frontmatter.split(/\r?\n/); const values: ParsedFrontmatter["values"] = {}; const unknownKeys: string[] = []; const malformed: string[] = []; for (let i = 0; i < lines.length; i += 1) { const line = lines[i]; if (!line.trim() || line.trimStart().startsWith("#")) continue; const keyMatch = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/); if (!keyMatch) { if (!/^\s+/.test(line)) malformed.push(line.trim()); continue; } const [, key, rawValue = ""] = keyMatch; if (!ALLOWED_KEYS.has(key)) { unknownKeys.push(key); continue; } if (key === "name") { values.name = parseString(rawValue) ?? ""; } else if (key === "priority") { const parsed = Number(stripInlineComment(rawValue).trim()); if (Number.isFinite(parsed)) values.priority = parsed; else malformed.push(line.trim()); } else if (key === "when") { const parsed = parseString(rawValue) as ConditionalRuleWhen | null; if (parsed && VALID_WHEN.has(parsed)) values.when = parsed; else malformed.push(line.trim()); } else if (key === "globs") { if (rawValue.trim()) { const parsed = parseInlineStringArray(rawValue); if (parsed) values.globs = parsed; else malformed.push(line.trim()); } else { const globs: string[] = []; while (i + 1 < lines.length) { const next = lines[i + 1]; const itemMatch = next.match(/^\s*-\s*(.+?)\s*$/); if (!itemMatch) break; i += 1; const parsed = parseString(itemMatch[1] ?? ""); if (parsed) globs.push(parsed); else malformed.push(next.trim()); } values.globs = globs; } } } return { values, body, unknownKeys, malformed }; } function readLayerFiles(dir: string): string[] { if (!existsSync(dir)) return []; try { return readdirSync(dir, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) .map((entry) => join(dir, entry.name)) .sort(); } catch { return []; } } function loadRuleFile(filePath: string, layer: ConditionalRuleLayer): { rule?: ConditionalRule; diagnostics: DiagnosticMessage[] } { const diagnostics: DiagnosticMessage[] = []; let raw: string; try { raw = readFileSync(filePath, "utf8"); } catch (error) { return { diagnostics: [diagnostic("warning", `conditional-rules: skipped "${filePath}" — ${error instanceof Error ? error.message : "read failed"}`)], }; } const parsed = parseFrontmatter(raw, filePath); if ("diagnostic" in parsed) return { diagnostics: [parsed.diagnostic] }; if (parsed.unknownKeys.length > 0) { diagnostics.push(diagnostic("warning", `conditional-rules: skipped "${filePath}" — unknown frontmatter key(s): ${parsed.unknownKeys.join(", ")}`)); return { diagnostics }; } if (parsed.malformed.length > 0) { diagnostics.push(diagnostic("warning", `conditional-rules: skipped "${filePath}" — malformed YAML frontmatter: ${parsed.malformed.join("; ")}`)); return { diagnostics }; } const name = typeof parsed.values.name === "string" ? parsed.values.name.trim() : ""; const globs = Array.isArray(parsed.values.globs) ? parsed.values.globs.filter((glob) => glob.trim()) : []; if (!name || globs.length === 0) { diagnostics.push(diagnostic("warning", `conditional-rules: skipped "${filePath}" — missing required frontmatter field(s): ${!name ? "name" : ""}${!name && globs.length === 0 ? ", " : ""}${globs.length === 0 ? "globs" : ""}`)); return { diagnostics }; } return { diagnostics, rule: { name, globs, priority: parsed.values.priority ?? 50, when: parsed.values.when ?? "file-accessed", body: parsed.body.trimEnd(), layer, filePath, }, }; } export function loadConditionalRules(cwd: string): LoadConditionalRulesResult { const layers: Array<{ layer: ConditionalRuleLayer; dir: string }> = [ { layer: "global", dir: getAgentPath("rules", "conditional") }, { layer: "project", dir: join(cwd, ".pi", "rules", "conditional") }, ]; const diagnostics: DiagnosticMessage[] = []; const rulesByName = new Map(); const seenByLayer: Record> = { global: new Set(), project: new Set(), }; for (const { layer, dir } of layers) { for (const filePath of readLayerFiles(dir)) { const loaded = loadRuleFile(filePath, layer); diagnostics.push(...loaded.diagnostics); if (!loaded.rule) continue; const seen = seenByLayer[layer]; if (seen.has(loaded.rule.name)) { diagnostics.push(diagnostic("error", `conditional-rules: skipped "${filePath}" — duplicate rule name "${loaded.rule.name}" in ${layer} layer`)); continue; } seen.add(loaded.rule.name); const existing = rulesByName.get(loaded.rule.name); if (existing && existing.layer !== layer) { if (layer === "project") { diagnostics.push(diagnostic("info", `conditional-rules: project rule "${loaded.rule.name}" overrides global rule from "${existing.filePath}"`)); rulesByName.set(loaded.rule.name, loaded.rule); } continue; } rulesByName.set(loaded.rule.name, loaded.rule); } } return { rules: Array.from(rulesByName.values()), diagnostics }; }