774 lines
24 KiB
TypeScript
774 lines
24 KiB
TypeScript
import * as fs from "node:fs";
|
||
import * as path from "node:path";
|
||
import type { Task, Project, ParallelGroup, Phase } from "./types";
|
||
|
||
// Lazy-loaded yaml package
|
||
let YAML_module: typeof import("yaml") | undefined;
|
||
function loadYaml(): typeof import("yaml") {
|
||
if (YAML_module) return YAML_module;
|
||
try {
|
||
YAML_module = require("yaml");
|
||
} catch {
|
||
throw new Error(
|
||
"YAML parsing requires the 'yaml' package. Run: npm install yaml",
|
||
);
|
||
}
|
||
return YAML_module!;
|
||
}
|
||
|
||
// ─── Main Entry ──────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Parse a task file (markdown or YAML) into a Project structure.
|
||
* Supports:
|
||
* - Fio README format (numbered tasks with dependency graph)
|
||
* - Phased format (## Phase N — Title sections with tasks and dependencies)
|
||
* - 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) || hasPhaseHeadings(content)) {
|
||
return parseFioFormat(content, absolutePath, dir);
|
||
}
|
||
return parseSimpleCheckbox(content, absolutePath, dir);
|
||
}
|
||
|
||
// ─── Fio Format Parser ───────────────────────────────────────────────────────
|
||
|
||
/** Match both markdown heading (## Dependencies) and plain heading (Dependencies). */
|
||
const DEP_HEADING_RE = /^(?:##\s+)?Dependencies\s*$/m;
|
||
/** Match both markdown heading (## Tasks) and plain heading (Tasks). */
|
||
const TASK_HEADING_RE = /^(?:##\s+)?Tasks\s*$/m;
|
||
/** Match other markdown headings (## Something). */
|
||
const ANY_MD_HEADING_RE = /^##\s/;
|
||
/** Match phase headings: ## Phase 1 — Push-to-Talk MVP */
|
||
const PHASE_HEADING_RE = /^\s*##\s+Phase\s+(\d+)\s*[—–:-]\s*(.+)$/i;
|
||
/** Detect plain phase headings too: Phase 1 — Title (no ##) */
|
||
const PHASE_HEADING_PLAIN_RE = /^Phase\s+(\d+)\s*[—–:-]\s*(.+)$/i;
|
||
/**
|
||
* Detect a plain (non-markdown) section heading like "Exit criteria".
|
||
* A plain heading must:
|
||
* - Start with a letter
|
||
* - Contain only letters and spaces
|
||
* - Have no colons (avoids matching "Objective:" and "Status legend:")
|
||
* - Not be a task/dep line (doesn't start with "-")
|
||
*/
|
||
function isPlainSectionHeader(line: string): boolean {
|
||
const trimmed = line.trim();
|
||
return trimmed.length > 0 && /^[A-Za-z][A-Za-z\s]*$/.test(trimmed);
|
||
}
|
||
|
||
function hasDependenciesSection(content: string): boolean {
|
||
return DEP_HEADING_RE.test(content);
|
||
}
|
||
|
||
function hasPhaseHeadings(content: string): boolean {
|
||
return PHASE_HEADING_RE.test(content) || PHASE_HEADING_PLAIN_RE.test(content);
|
||
}
|
||
|
||
function parseFioFormat(
|
||
content: string,
|
||
sourcePath: string,
|
||
sourceDir: string,
|
||
): Project {
|
||
const lines = content.split("\n");
|
||
const tasks: Task[] = [];
|
||
const dependencies: Record<string, string[]> = {};
|
||
const parallelGroups: ParallelGroup[] = [];
|
||
const phases: Phase[] = [];
|
||
let currentPhase: number | null = null;
|
||
let currentPhaseTitle = "";
|
||
let inTasks = false;
|
||
let inDeps = false;
|
||
|
||
for (const line of lines) {
|
||
// Check for phase headings first
|
||
const phaseMatch =
|
||
line.match(PHASE_HEADING_RE) || line.match(PHASE_HEADING_PLAIN_RE);
|
||
if (phaseMatch) {
|
||
// Save previous phase if exists
|
||
if (currentPhase !== null) {
|
||
const phaseTaskIds = tasks
|
||
.filter((t) => t.phase === currentPhase)
|
||
.map((t) => t.id);
|
||
if (phaseTaskIds.length > 0) {
|
||
phases.push({
|
||
number: currentPhase,
|
||
title: currentPhaseTitle,
|
||
taskIds: phaseTaskIds,
|
||
});
|
||
}
|
||
}
|
||
// Start new phase
|
||
currentPhase = parseInt(phaseMatch[1], 10);
|
||
currentPhaseTitle = phaseMatch[2].trim();
|
||
inTasks = true;
|
||
inDeps = false;
|
||
continue;
|
||
}
|
||
|
||
if (TASK_HEADING_RE.test(line)) {
|
||
inTasks = true;
|
||
inDeps = false;
|
||
continue;
|
||
}
|
||
if (DEP_HEADING_RE.test(line)) {
|
||
inTasks = false;
|
||
inDeps = true;
|
||
continue;
|
||
}
|
||
// Reset state on any other section heading — both ##-style and plain
|
||
// BUT NOT phase headings (already handled above)
|
||
if (
|
||
(ANY_MD_HEADING_RE.test(line) || isPlainSectionHeader(line)) &&
|
||
!TASK_HEADING_RE.test(line) &&
|
||
!DEP_HEADING_RE.test(line) &&
|
||
!PHASE_HEADING_RE.test(line) &&
|
||
!PHASE_HEADING_PLAIN_RE.test(line)
|
||
) {
|
||
inTasks = false;
|
||
inDeps = false;
|
||
continue;
|
||
}
|
||
|
||
if (inTasks) {
|
||
// Match all tasks on a line (supports compact single-line formats).
|
||
// ID is digits optionally followed by a single lowercase letter
|
||
// (e.g. "01", "02b", "10c") — see normalizeTaskId for the shape.
|
||
const taskPattern =
|
||
/-+\s+\[(.)\]\s+(\d+[a-z]?)\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: normalizeTaskId(id),
|
||
title: title.trim(),
|
||
description: undefined,
|
||
file: file || undefined,
|
||
status: charToStatus(status),
|
||
dependencies: [],
|
||
timeoutMs,
|
||
index: tasks.length,
|
||
phase: currentPhase ?? undefined,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (inDeps) {
|
||
// Arrow notation (supports both -> and unicode \u2192)
|
||
// "01 -> 02,03,06" means 02, 03, 06 depend on 01
|
||
// "02 \u2192 08" — single arrow with unicode
|
||
// "03 \u2192 04 \u2192 05" — chained: 04 depends on 03, 05 depends on 04
|
||
// "05, 07, 08 \u2192 13" — multi-prereq: 13 depends on 05, 07, 08
|
||
// Supports optional markdown list prefix: "- 01 -> 02,03,06"
|
||
const hasArrow = /->/.test(line) || /\u2192/.test(line);
|
||
if (hasArrow) {
|
||
// Strip optional list prefix and parenthetical description
|
||
const cleaned = line
|
||
.replace(/^(\s*[-*]\s+)?/, "")
|
||
.replace(/\s*\(.*\)\s*$/, "");
|
||
|
||
// Split on arrows to get segments
|
||
const segments = cleaned
|
||
.split(/->|\u2192/)
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
|
||
if (segments.length >= 2) {
|
||
for (let i = 0; i < segments.length - 1; i++) {
|
||
// Left segment: source(s) (comma-separated)
|
||
const fromIds = segments[i]
|
||
.split(",")
|
||
.map((t) => t.trim())
|
||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||
.map((t) => normalizeTaskId(t));
|
||
|
||
// Right segment: target(s) (comma-separated)
|
||
const toIds = segments[i + 1]
|
||
.split(",")
|
||
.map((t) => t.trim())
|
||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||
.map((t) => normalizeTaskId(t));
|
||
|
||
for (const toId of toIds) {
|
||
if (!dependencies[toId]) dependencies[toId] = [];
|
||
for (const fromId of fromIds) {
|
||
if (!dependencies[toId].includes(fromId)) {
|
||
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"
|
||
// Also handles "also depends on": "- 08 also depends on 05, 06"
|
||
// The dep list char class includes lowercase letters so lettered IDs
|
||
// (e.g. "02b") don't truncate the capture. Per-id validation is
|
||
// done by the filter below, so trailing prose can't leak in.
|
||
const dependsMatch = line.match(
|
||
/^(?:\s*[-*]\s+)?(\d+[a-z]?)\s+(?:also\s+)?depends\s+on\s+([\d,\s a-z]+)/i,
|
||
);
|
||
if (dependsMatch) {
|
||
const [, taskId, depsList] = dependsMatch;
|
||
const taskIdPadded = normalizeTaskId(taskId);
|
||
const depIds = depsList
|
||
.split(",")
|
||
.map((t) => t.trim())
|
||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||
.map((t) => normalizeTaskId(t));
|
||
|
||
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
|
||
for (const depId of depIds) {
|
||
if (!dependencies[taskIdPadded].includes(depId)) {
|
||
dependencies[taskIdPadded].push(depId);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Parse meta blocks for task configuration (timeout, etc.)
|
||
const metaMatch = line.match(
|
||
/^0?(\d+[a-z]?)\s+\[timeout\]\s*=?\s*(\d+)(?:m|min|s|ms)?/i,
|
||
);
|
||
if (metaMatch) {
|
||
const [, taskId, value, unit] = metaMatch;
|
||
const task = tasks.find((t) => t.id === normalizeTaskId(taskId));
|
||
if (task) {
|
||
task.timeoutMs = parseTimeoutValue(Number(value), unit);
|
||
}
|
||
}
|
||
|
||
// Format 2: "X, Y, Z can be done in parallel (label)"
|
||
// "- 01, 02, 03, 04 can be done in parallel (Play Store prep)"
|
||
const parallelMatch = line.match(
|
||
/^(?:\s*[-*]\s+)?((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)\s+can\s+be\s+done\s+in\s+parallel(?:\s+\(([^)]+)\))?$/i,
|
||
);
|
||
if (parallelMatch) {
|
||
const [, idsStr, label] = parallelMatch;
|
||
const taskIds = idsStr
|
||
.split(",")
|
||
.map((t) => t.trim())
|
||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||
.map((t) => normalizeTaskId(t));
|
||
|
||
if (taskIds.length > 0) {
|
||
parallelGroups.push({
|
||
index: parallelGroups.length,
|
||
label: label ? label.trim() : undefined,
|
||
taskIds,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Format 3: "A must be done before B, C" or "A, B must be done before C"
|
||
// "- 21 must be done before 22, 23, 24 (backend integration foundation)"
|
||
// "- 02, 03 must be done before 04"
|
||
const mustBeforeMatch = line.match(
|
||
/^(?:\s*[-*]\s+)?((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)\s+must\s+be\s+done\s+before\s+((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)(?:\s+\(([^)]+)\))?$/i,
|
||
);
|
||
if (mustBeforeMatch) {
|
||
const [, fromIdsStr, toIdsStr] = mustBeforeMatch;
|
||
const fromIds = fromIdsStr
|
||
.split(",")
|
||
.map((t) => t.trim())
|
||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||
.map((t) => normalizeTaskId(t));
|
||
const toIds = toIdsStr
|
||
.split(",")
|
||
.map((t) => t.trim())
|
||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||
.map((t) => normalizeTaskId(t));
|
||
|
||
// Each "to" task depends on ALL "from" tasks
|
||
for (const toId of toIds) {
|
||
if (!dependencies[toId]) dependencies[toId] = [];
|
||
for (const fromId of fromIds) {
|
||
if (!dependencies[toId].includes(fromId)) {
|
||
dependencies[toId].push(fromId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Format 4: "X, Y, Z depend on A" or "X depends on A, B, C"
|
||
// "- 22, 23, 24 depend on 21"
|
||
// "- 05, 06 depend on 02, 03, 04"
|
||
// "- 08 also depends on 05, 06" ("also" is ignored)
|
||
// Strip optional "also" before matching
|
||
const cleanedLine = line.replace(/\balso\b/i, "");
|
||
const dependOnMatch = cleanedLine.match(
|
||
/^(?:\s*[-*]\s+)?((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)\s+depend(?:s)?\s+on\s+((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)(?:\s+\(([^)]+)\))?$/i,
|
||
);
|
||
if (dependOnMatch) {
|
||
const [, fromIdsStr, toIdsStr] = dependOnMatch;
|
||
const fromIds = fromIdsStr
|
||
.split(",")
|
||
.map((t) => t.trim())
|
||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||
.map((t) => normalizeTaskId(t));
|
||
const toIds = toIdsStr
|
||
.split(",")
|
||
.map((t) => t.trim())
|
||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||
.map((t) => normalizeTaskId(t));
|
||
|
||
// Each "from" task depends on ALL "to" tasks
|
||
for (const fromId of fromIds) {
|
||
if (!dependencies[fromId]) dependencies[fromId] = [];
|
||
for (const toId of toIds) {
|
||
if (!dependencies[fromId].includes(toId)) {
|
||
dependencies[fromId].push(toId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Save final phase if we were in one
|
||
if (currentPhase !== null) {
|
||
const phaseTaskIds = tasks
|
||
.filter((t) => t.phase === currentPhase)
|
||
.map((t) => t.id);
|
||
if (phaseTaskIds.length > 0) {
|
||
phases.push({
|
||
number: currentPhase,
|
||
title: currentPhaseTitle,
|
||
taskIds: phaseTaskIds,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Add implicit phase-boundary dependencies
|
||
// First task of each phase (except phase 1) depends on last task of previous phase
|
||
if (phases.length > 1) {
|
||
for (let i = 1; i < phases.length; i++) {
|
||
const prevPhase = phases[i - 1];
|
||
const currPhase = phases[i];
|
||
if (prevPhase.taskIds.length === 0 || currPhase.taskIds.length === 0)
|
||
continue;
|
||
|
||
const lastTaskOfPrevPhase =
|
||
prevPhase.taskIds[prevPhase.taskIds.length - 1];
|
||
const firstTaskOfCurrPhase = currPhase.taskIds[0];
|
||
|
||
// Add dependency if not already present
|
||
if (!dependencies[firstTaskOfCurrPhase]) {
|
||
dependencies[firstTaskOfCurrPhase] = [];
|
||
}
|
||
if (!dependencies[firstTaskOfCurrPhase].includes(lastTaskOfPrevPhase)) {
|
||
dependencies[firstTaskOfCurrPhase].push(lastTaskOfPrevPhase);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Extract exit criteria — detect both ## Exit Criteria and plain Exit criteria
|
||
const exitCriteria: string[] = [];
|
||
const exitCriteriaRe = /^(?:##\s+)?Exit\s+Criteria/i;
|
||
const exitIdx = lines.findIndex((l) => exitCriteriaRe.test(l));
|
||
if (exitIdx >= 0) {
|
||
for (let i = exitIdx + 1; i < lines.length; i++) {
|
||
// Stop at any new section heading (##-style or plain)
|
||
if (/^##\s/.test(lines[i]) || isPlainSectionHeader(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];
|
||
}
|
||
}
|
||
|
||
// Apply parallelGroup to tasks
|
||
for (const group of parallelGroups) {
|
||
for (const taskId of group.taskIds) {
|
||
const task = tasks.find((t) => t.id === taskId);
|
||
if (task) {
|
||
task.parallelGroup = group.index;
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
tasks,
|
||
dependencies,
|
||
parallelGroups: parallelGroups.length > 0 ? parallelGroups : undefined,
|
||
phases: phases.length > 0 ? phases : undefined,
|
||
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 {
|
||
const YAML = loadYaml();
|
||
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 file (markdown or YAML).
|
||
*
|
||
* Handles three formats:
|
||
* 1. Fio numbered format: `- [ ] 01 – Title` — matches by task number in the file
|
||
* 2. Simple checkbox: `- [ ] Title` — matches by checkbox position (index)
|
||
* 3. YAML: uses `yaml` library to parse, update, and stringify
|
||
*/
|
||
export function updateTaskInFile(
|
||
filePath: string,
|
||
taskId: string,
|
||
status: Task["status"],
|
||
): void {
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
|
||
// Handle YAML format
|
||
if (ext === ".yaml" || ext === ".yml") {
|
||
updateTaskInYaml(filePath, taskId, status);
|
||
return;
|
||
}
|
||
|
||
let content = fs.readFileSync(filePath, "utf-8");
|
||
const char = statusToChar(status);
|
||
|
||
// Strategy 1: Fio numbered format — match by explicit task ID in the file.
|
||
// For pure-digit IDs, also try the parsed numeric form (parity with the
|
||
// pre-lettered behavior). Lettered IDs ("02b", "02c") only have one valid
|
||
// form — the parseInt fallback would silently drop the letter suffix and
|
||
// create false-positive partial matches, so we skip it for them.
|
||
const idPatterns = new Set([escapeRegex(taskId)]);
|
||
if (!taskId.startsWith("0") && /^\d+$/.test(taskId)) {
|
||
const rawId = parseInt(taskId, 10).toString();
|
||
idPatterns.add(escapeRegex(rawId));
|
||
}
|
||
|
||
for (const idPattern of idPatterns) {
|
||
const fioRegex = new RegExp(
|
||
`(^-\\s+\\[)(.)(\\]\\s+${idPattern}\\s*[—–:-])`,
|
||
"m",
|
||
);
|
||
const match = content.match(fioRegex);
|
||
if (match) {
|
||
content = content.replace(fioRegex, `$1${char}$3`);
|
||
fs.writeFileSync(filePath, content, "utf-8");
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Strategy 2: Simple checkbox by position (task IDs are zero-padded indices)
|
||
const targetIndex = parseInt(taskId, 10);
|
||
if (!isNaN(targetIndex)) {
|
||
const lines = content.split("\n");
|
||
let checkboxIdx = 0;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const m = lines[i].match(/^(\s*-+\s+\[)(.)(\].*)$/);
|
||
if (m) {
|
||
if (checkboxIdx === targetIndex) {
|
||
lines[i] = m[1] + char + m[3];
|
||
fs.writeFileSync(filePath, lines.join("\n"), "utf-8");
|
||
return;
|
||
}
|
||
checkboxIdx++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update task status in a YAML task file using the yaml library's
|
||
* Document API, which preserves comments and formatting.
|
||
*
|
||
* Matches by explicit `id` field first, then falls back to
|
||
* position-based matching (for files without explicit IDs).
|
||
*/
|
||
function updateTaskInYaml(
|
||
filePath: string,
|
||
taskId: string,
|
||
status: Task["status"],
|
||
): void {
|
||
const YAML = loadYaml();
|
||
const content = fs.readFileSync(filePath, "utf-8");
|
||
const doc = YAML.parseDocument(content);
|
||
const tasks = doc.get("tasks");
|
||
if (!tasks || !YAML.isSeq(tasks)) return;
|
||
|
||
// Build alternate ID forms for matching. For lettered IDs ("02b"), the
|
||
// verbatim form is the only valid pattern — parseInt would drop the suffix.
|
||
const idVariants: string[] = [taskId];
|
||
if (/^\d+$/.test(taskId)) {
|
||
idVariants.push(parseInt(taskId, 10).toString());
|
||
}
|
||
|
||
// Strategy 1: Match by explicit id field
|
||
for (const item of tasks.items) {
|
||
if (!YAML.isMap(item)) continue;
|
||
const idVal = item.get("id");
|
||
if (idVal === undefined || idVal === null) continue;
|
||
const idStr = String(idVal);
|
||
if (idVariants.includes(idStr)) {
|
||
item.set("status", status);
|
||
fs.writeFileSync(filePath, String(doc), "utf-8");
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Strategy 2: Fall back to position-based matching
|
||
// (for YAML files without explicit id fields)
|
||
const targetIndex = parseInt(taskId, 10);
|
||
if (!isNaN(targetIndex) && targetIndex < tasks.items.length) {
|
||
const item = tasks.items[targetIndex];
|
||
if (YAML.isMap(item)) {
|
||
item.set("status", status);
|
||
fs.writeFileSync(filePath, String(doc), "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;
|
||
}
|
||
|
||
/**
|
||
* Normalize a task ID: zero-pad the digit portion to 2 chars, preserve any
|
||
* single lowercase letter suffix. Idempotent on already-normalized IDs.
|
||
*
|
||
* "1" → "01"
|
||
* "2" → "02"
|
||
* "2b" → "02b"
|
||
* "02b" → "02b"
|
||
* "10" → "10"
|
||
* "10b" → "10b"
|
||
*
|
||
* Pass-through for IDs that don't match the expected shape (defensive — the
|
||
* upstream regexes restrict matches, but a stray value should not be silently
|
||
* re-shaped).
|
||
*/
|
||
function normalizeTaskId(id: string): string {
|
||
const match = id.match(/^(\d+)([a-z])?$/);
|
||
if (!match) return id;
|
||
const [, digits, letter] = match;
|
||
return digits.padStart(2, "0") + (letter ?? "");
|
||
}
|
||
|
||
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, "\\$&");
|
||
}
|