430 lines
12 KiB
TypeScript
430 lines
12 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import type { Task, Project } from "./types";
|
|
|
|
// ─── Main Entry ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse a task file (markdown or YAML) into a Project structure.
|
|
* Supports:
|
|
* - Fio README format (numbered tasks with dependency graph)
|
|
* - Simple checkbox format (- [ ] task)
|
|
* - YAML format (tasks: [...])
|
|
*/
|
|
export function parseTaskFile(filePath: string): Project {
|
|
const absolutePath = path.resolve(filePath);
|
|
const content = fs.readFileSync(absolutePath, "utf-8");
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const dir = path.dirname(absolutePath);
|
|
|
|
if (ext === ".yaml" || ext === ".yml") {
|
|
return parseYaml(content, absolutePath, dir);
|
|
}
|
|
|
|
// Markdown: detect format
|
|
if (hasDependenciesSection(content)) {
|
|
return parseFioFormat(content, absolutePath, dir);
|
|
}
|
|
return parseSimpleCheckbox(content, absolutePath, dir);
|
|
}
|
|
|
|
// ─── Fio Format Parser ───────────────────────────────────────────────────────
|
|
|
|
function hasDependenciesSection(content: string): boolean {
|
|
return /^##\s+Dependencies\s*$/m.test(content);
|
|
}
|
|
|
|
function parseFioFormat(
|
|
content: string,
|
|
sourcePath: string,
|
|
sourceDir: string,
|
|
): Project {
|
|
const lines = content.split("\n");
|
|
const tasks: Task[] = [];
|
|
const dependencies: Record<string, string[]> = {};
|
|
let inTasks = false;
|
|
let inDeps = false;
|
|
|
|
for (const line of lines) {
|
|
if (/^##\s+Tasks\s*$/m.test(line)) {
|
|
inTasks = true;
|
|
inDeps = false;
|
|
continue;
|
|
}
|
|
if (/^##\s+Dependencies\s*$/m.test(line)) {
|
|
inTasks = false;
|
|
inDeps = true;
|
|
continue;
|
|
}
|
|
if (
|
|
/^##\s/.test(line) &&
|
|
!/^##\s+Tasks/.test(line) &&
|
|
!/^##\s+Dependencies/.test(line)
|
|
) {
|
|
inTasks = false;
|
|
inDeps = false;
|
|
continue;
|
|
}
|
|
|
|
if (inTasks) {
|
|
// Match all tasks on a line (supports compact single-line formats)
|
|
const taskPattern =
|
|
/-+\s+\[(.)\]\s+(\d+)\s+[—–:-]\s+(.+?)(?:\s+(?=-+\s+\[)|\s*→\s*`([^`]+)`|$)/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = taskPattern.exec(line)) !== null) {
|
|
const [, status, id, title, file] = match;
|
|
const timeoutMs = parseTimeoutFromLine(line);
|
|
tasks.push({
|
|
id: `0${id}`,
|
|
title: title.trim(),
|
|
description: undefined,
|
|
file: file || undefined,
|
|
status: charToStatus(status),
|
|
dependencies: [],
|
|
timeoutMs,
|
|
index: tasks.length,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (inDeps) {
|
|
// Format 2: Arrow notation with multiple targets
|
|
// "01 -> 02,03,06 (description)" means 02, 03, 06 depend on 01
|
|
// Supports optional markdown list prefix: "- 01 -> 02,03,06"
|
|
const arrowMatch = line.match(
|
|
/^(?:\s*[-*]\s+)?(\d+)\s*->\s*([\d,\s]+?)(?:\s*\(|$)/,
|
|
);
|
|
if (arrowMatch) {
|
|
const [, from, targets] = arrowMatch;
|
|
const fromId = `0${from}`;
|
|
const targetIds = targets
|
|
.split(",")
|
|
.map((t) => t.trim())
|
|
.filter((t) => t)
|
|
.map((t) => `0${t}`);
|
|
|
|
// Each target depends on the source
|
|
for (const toId of targetIds) {
|
|
if (!dependencies[toId]) dependencies[toId] = [];
|
|
dependencies[toId].push(fromId);
|
|
}
|
|
}
|
|
|
|
// Format 1: Natural language "X depends on A, B, C"
|
|
// Supports optional markdown list prefix: "- 13 depends on 17, 18, 19"
|
|
const dependsMatch = line.match(
|
|
/^(?:\s*[-*]\s+)?(\d+)\s+depends\s+on\s+([\d,\s]+)/i,
|
|
);
|
|
if (dependsMatch) {
|
|
const [, taskId, depsList] = dependsMatch;
|
|
const taskIdPadded = `0${taskId}`;
|
|
const depIds = depsList
|
|
.split(",")
|
|
.map((t) => t.trim())
|
|
.filter((t) => t)
|
|
.map((t) => `0${t}`);
|
|
|
|
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
|
|
dependencies[taskIdPadded].push(...depIds);
|
|
}
|
|
|
|
// Parse meta blocks for task configuration (timeout, etc.)
|
|
const metaMatch = line.match(
|
|
/^0?(\d+)\s+\[timeout\]\s*=?\s*(\d+)(?:m|min|s|ms)?/i,
|
|
);
|
|
if (metaMatch) {
|
|
const [, taskId, value, unit] = metaMatch;
|
|
const task = tasks.find((t) => t.id === `0${taskId}`);
|
|
if (task) {
|
|
task.timeoutMs = parseTimeoutValue(Number(value), unit);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract exit criteria
|
|
const exitCriteria: string[] = [];
|
|
const exitIdx = lines.findIndex((l) => /^##\s+Exit\s+Criteria/i.test(l));
|
|
if (exitIdx >= 0) {
|
|
for (let i = exitIdx + 1; i < lines.length; i++) {
|
|
if (/^##\s/.test(lines[i])) break;
|
|
const m = lines[i].match(/^-\s+(.+)$/);
|
|
if (m) exitCriteria.push(m[1].trim());
|
|
}
|
|
}
|
|
|
|
// Extract objective from top-level heading
|
|
const objectiveMatch = content.match(/^#\s+(.+)$/m);
|
|
const objective = objectiveMatch ? objectiveMatch[1].trim() : undefined;
|
|
|
|
// Apply dependencies map to task.dependencies arrays
|
|
for (const task of tasks) {
|
|
if (dependencies[task.id]) {
|
|
task.dependencies = dependencies[task.id];
|
|
}
|
|
}
|
|
|
|
return {
|
|
tasks,
|
|
dependencies,
|
|
sourcePath,
|
|
sourceDir,
|
|
exitCriteria,
|
|
objective,
|
|
};
|
|
}
|
|
|
|
// ─── Simple Checkbox Parser ──────────────────────────────────────────────────
|
|
|
|
function parseSimpleCheckbox(
|
|
content: string,
|
|
sourcePath: string,
|
|
sourceDir: string,
|
|
): Project {
|
|
const tasks: Task[] = [];
|
|
const lines = content.split("\n");
|
|
let idx = 0;
|
|
|
|
for (const line of lines) {
|
|
const match = line.match(/^-+\s+\[(.)\]\s+(.+)$/);
|
|
if (match) {
|
|
const [, statusChar, title] = match;
|
|
const id = `${String(idx).padStart(2, "0")}`;
|
|
tasks.push({
|
|
id,
|
|
title: title.trim(),
|
|
status: charToStatus(statusChar),
|
|
dependencies: [],
|
|
});
|
|
idx++;
|
|
}
|
|
}
|
|
|
|
return { tasks, dependencies: {}, sourcePath, sourceDir };
|
|
}
|
|
|
|
// ─── YAML Parser ─────────────────────────────────────────────────────────────
|
|
|
|
function parseYaml(
|
|
content: string,
|
|
sourcePath: string,
|
|
sourceDir: string,
|
|
): Project {
|
|
// Lazy-load yaml (may not be installed)
|
|
let YAML: typeof import("yaml");
|
|
try {
|
|
YAML = require("yaml");
|
|
} catch {
|
|
throw new Error(
|
|
"YAML parsing requires the 'yaml' package. Run: npm install yaml",
|
|
);
|
|
}
|
|
|
|
const doc = YAML.parse(content);
|
|
const tasks: Task[] = [];
|
|
|
|
if (doc.tasks && Array.isArray(doc.tasks)) {
|
|
doc.tasks.forEach((t: any, idx: number) => {
|
|
tasks.push({
|
|
id: t.id || `${String(idx).padStart(2, "0")}`,
|
|
title: t.title || t.name || `Task ${idx}`,
|
|
description: t.description,
|
|
file: t.file,
|
|
status: (t.status as Task["status"]) || "pending",
|
|
dependencies: t.depends_on || t.dependencies || [],
|
|
parallelGroup: t.parallel_group,
|
|
timeoutMs: parseTimeoutFromMeta(t.timeout),
|
|
index: idx,
|
|
});
|
|
});
|
|
}
|
|
|
|
return {
|
|
tasks,
|
|
dependencies: doc.dependencies || {},
|
|
sourcePath,
|
|
sourceDir,
|
|
exitCriteria: doc.exit_criteria || doc.exitCriteria,
|
|
objective: doc.objective,
|
|
};
|
|
}
|
|
|
|
// ─── Task Spec Reader ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Read the detailed task specification from a task file
|
|
*/
|
|
export function readTaskSpec(taskDir: string, taskFile: string): string {
|
|
const fullPath = path.resolve(taskDir, taskFile);
|
|
if (!fs.existsSync(fullPath)) return "";
|
|
return fs.readFileSync(fullPath, "utf-8");
|
|
}
|
|
|
|
// ─── Task File Updater ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Update task status in the source markdown file
|
|
*/
|
|
export function updateTaskInFile(
|
|
filePath: string,
|
|
taskId: string,
|
|
status: Task["status"],
|
|
): void {
|
|
let content = fs.readFileSync(filePath, "utf-8");
|
|
const char = statusToChar(status);
|
|
|
|
// Try Fio numbered format first
|
|
const fioPattern = new RegExp(
|
|
`(^-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)}\\s*[—–-])`,
|
|
"m",
|
|
);
|
|
if (fioPattern.test(content)) {
|
|
content = content.replace(fioPattern, `$1${char}$3`);
|
|
fs.writeFileSync(filePath, content, "utf-8");
|
|
return;
|
|
}
|
|
|
|
// Try simple checkbox format
|
|
const simplePattern = new RegExp(
|
|
`(-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)})`,
|
|
"m",
|
|
);
|
|
if (simplePattern.test(content)) {
|
|
content = content.replace(simplePattern, `$1${char}$3`);
|
|
fs.writeFileSync(filePath, content, "utf-8");
|
|
}
|
|
}
|
|
|
|
// ─── Auto-Detect Dependencies ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Auto-detect dependencies by analyzing task file references
|
|
*/
|
|
export function autoDetectDependencies(project: Project): Project {
|
|
const tasks = project.tasks.map((t) => ({
|
|
...t,
|
|
dependencies: [...t.dependencies],
|
|
}));
|
|
const taskFiles = new Map(
|
|
tasks
|
|
.filter((t) => t.file)
|
|
.map((t) => [path.resolve(project.sourceDir, t.file!), t]),
|
|
);
|
|
|
|
for (const [filePath, task] of taskFiles) {
|
|
if (!fs.existsSync(filePath)) continue;
|
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
|
|
// Check if this task's file references another task's file
|
|
for (const [file, refTask] of taskFiles) {
|
|
if (refTask.id === task.id) continue;
|
|
if (content.includes(file) || content.includes(refTask.title)) {
|
|
if (!task.dependencies.includes(refTask.id)) {
|
|
task.dependencies.push(refTask.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const dependencies: Record<string, string[]> = {};
|
|
for (const task of tasks) {
|
|
if (task.dependencies.length > 0) {
|
|
dependencies[task.id] = task.dependencies;
|
|
}
|
|
}
|
|
|
|
return { ...project, tasks, dependencies };
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
// ─── Timeout Parsing ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse timeout from a task line (e.g., "timeout: 15m" or "# timeout=30s")
|
|
*/
|
|
function parseTimeoutFromLine(line: string): number | undefined {
|
|
// Match patterns like "timeout: 15m", "# timeout=30s", "timeout: 5min"
|
|
const match = line.match(/(?:timeout|timelimit)[\s:=]+(\d+)(?:m|min|s|ms)?/i);
|
|
if (match) {
|
|
return parseTimeoutValue(Number(match[1]), match[2]);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Parse a timeout value with unit suffix
|
|
*/
|
|
function parseTimeoutValue(value: number, unit?: string): number {
|
|
const u = (unit || "m").toLowerCase();
|
|
switch (u) {
|
|
case "ms":
|
|
return value;
|
|
case "s":
|
|
return value * 1000;
|
|
case "m":
|
|
case "min":
|
|
return value * 60 * 1000;
|
|
default:
|
|
return value * 60 * 1000; // default to minutes
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse timeout from YAML meta field (string or number)
|
|
* Supports: "15m", "30s", "5min", 15 (minutes), 900000 (ms)
|
|
*/
|
|
function parseTimeoutFromMeta(
|
|
timeout: string | number | undefined,
|
|
): number | undefined {
|
|
if (timeout === undefined) return undefined;
|
|
|
|
if (typeof timeout === "number") {
|
|
// Assume minutes if < 1000, milliseconds if >= 1000
|
|
return timeout < 1000 ? timeout * 60 * 1000 : timeout;
|
|
}
|
|
|
|
const match = timeout.match(/^(\d+)(ms|s|m|min)?$/i);
|
|
if (match) {
|
|
return parseTimeoutValue(Number(match[1]), match[2]);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function charToStatus(char: string): Task["status"] {
|
|
switch (char) {
|
|
case " ":
|
|
return "pending";
|
|
case "~":
|
|
return "in_progress";
|
|
case "x":
|
|
return "completed";
|
|
case "!":
|
|
return "failed";
|
|
case "-":
|
|
return "skipped";
|
|
default:
|
|
return "pending";
|
|
}
|
|
}
|
|
|
|
function statusToChar(status: Task["status"]): string {
|
|
switch (status) {
|
|
case "pending":
|
|
return " ";
|
|
case "in_progress":
|
|
return "~";
|
|
case "completed":
|
|
return "x";
|
|
case "failed":
|
|
return "!";
|
|
case "skipped":
|
|
return "-";
|
|
}
|
|
}
|
|
|
|
function escapeRegex(str: string): string {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|