266 lines
5.5 KiB
TypeScript
266 lines
5.5 KiB
TypeScript
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<string, number>();
|
|
|
|
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<string, number>();
|
|
const seen = new Set<string>();
|
|
|
|
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));
|
|
}
|