import type { DirectoryArtifactModel, FileEntry, RoutingMetadataOptions, WorkflowHint, } from "./directory-model.js"; const DEFAULT_TAG_CAP = 8; const DEFAULT_WORKFLOW_HINT_CAP = 5; const STOP_WORDS = new Set([ "the", "and", "for", "with", "from", "into", "onto", "this", "that", "then", "than", "when", "where", "what", "how", "why", "who", "which", "while", "during", "before", "after", "above", "below", "between", "among", "through", "over", "under", "again", "further", "once", "here", "there", "all", "any", "both", "each", "few", "more", "most", "other", "some", "such", "only", "own", "same", "so", "too", "very", "can", "will", "just", "should", "now", "use", "using", "used", "via", "based", "build", "built", "used", "file", "files", "module", "modules", "function", "functions", "class", "classes", "export", "exports", "import", "imports", ]); export function populateRoutingMetadata( model: DirectoryArtifactModel, opts: RoutingMetadataOptions = {}, ): void { const tagCap = opts.tagCap ?? DEFAULT_TAG_CAP; const workflowHintCap = opts.workflowHintCap ?? DEFAULT_WORKFLOW_HINT_CAP; model.tags = generateTags(model.files, tagCap); model.symbols = generateSymbols(model.files, tagCap); model.workflows = generateWorkflows(model, workflowHintCap); } function generateTags(files: FileEntry[], cap: number): string[] { const scores = new Map(); for (const file of files) { // Score words from file purpose for (const word of extractWords(file.purpose)) { scores.set(word, (scores.get(word) ?? 0) + 1); } // Score words from export names (camelCase split) for (const exp of file.exports) { const cleanExp = exp.replace(/^(class|func|method):/, "").split("(")[0]; for (const word of splitCamelCase(cleanExp)) { if (word.length > 1) { scores.set(word, (scores.get(word) ?? 0) + 2); } } } // Score dep path segments for (const dep of file.deps) { for (const segment of dep.split(/[/\-.]/)) { const word = segment.toLowerCase(); if (word.length > 1 && !STOP_WORDS.has(word)) { scores.set(word, (scores.get(word) ?? 0) + 1); } } } // Score from file name (stem, no extension) const stem = file.name.replace(/\.[^.]+$/, ""); for (const word of splitCamelCase(stem)) { if (word.length > 1) { scores.set(word, (scores.get(word) ?? 0) + 2); } } } return Array.from(scores.entries()) .sort((a, b) => b[1] - a[1]) .map(([word]) => word) .slice(0, cap); } function generateSymbols(files: FileEntry[], cap: number): string[] { const scores = new Map(); const seen = new Set(); for (const file of files) { for (const exp of file.exports) { // Prefer clean identifiers over encoded DSL entries when possible let symbol = exp; let score = 1; if (exp.startsWith("class:")) { symbol = exp.slice(6).split(" ")[0]; score = 3; } else if (exp.startsWith("func:")) { symbol = exp.slice(5).split("(")[0]; score = 2; } else if (exp.startsWith("method:")) { symbol = exp.slice(7).split("(")[0]; score = 2; } if (!seen.has(symbol)) { seen.add(symbol); scores.set(symbol, (scores.get(symbol) ?? 0) + score); } } } return Array.from(scores.entries()) .sort((a, b) => b[1] - a[1]) .map(([name]) => name) .slice(0, cap); } function generateWorkflows( model: DirectoryArtifactModel, cap: number, ): WorkflowHint[] { const workflows: WorkflowHint[] = []; const dirName = model.dir.split("/").pop() || model.dir; const baseName = dirName === "." ? "project" : dirName; // Only generate workflows when we have reasonable structural confidence const hasSourceFiles = model.files.some((f) => /\.(ts|tsx|js|jsx|py|go|rs|java)$/.test(f.name), ); if (!hasSourceFiles || model.files.length === 0) { return workflows; } const sourceFiles = model.files.filter( (f) => !/\.(test|spec)\./.test(f.name) && !/\.(md|json|yaml|yml)$/.test(f.name), ); const testFiles = model.files.filter((f) => /\.(test|spec)\./.test(f.name)); const configFiles = model.files.filter( (f) => f.name.includes("config") || /\.(json|yaml|yml|toml)$/.test(f.name) || f.name === ".env", ); const cliFiles = model.files.filter( (f) => f.name.includes("cli") || f.name.includes("command"), ); if (sourceFiles.length > 0) { workflows.push({ task: `change ${baseName} behavior`, read: sourceFiles.slice(0, 3).map((f) => f.name), }); } if (testFiles.length > 0) { workflows.push({ task: `update ${baseName} tests`, read: testFiles.slice(0, 3).map((f) => f.name), }); } if (cliFiles.length > 0) { workflows.push({ task: `change ${baseName} CLI`, read: cliFiles.slice(0, 3).map((f) => f.name), }); } if (configFiles.length > 0) { workflows.push({ task: `change ${baseName} config`, read: configFiles.slice(0, 3).map((f) => f.name), }); } // Add a directory-navigation workflow for non-leaf directories if (model.children.length > 0) { workflows.push({ task: `explore ${baseName} subdirectories`, index: model.children .slice(0, 3) .map((child) => `${child}/.pi-map.index.md`), }); } return workflows.slice(0, cap); } function extractWords(text: string): string[] { return text .toLowerCase() .split(/[^a-z0-9]+/) .filter((w) => w.length > 2 && !STOP_WORDS.has(w)); } function splitCamelCase(str: string): string[] { return str .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .replace(/[_-]+/g, " ") .toLowerCase() .split(/\s+/) .filter((w) => w.length > 1 && !STOP_WORDS.has(w)); }