273 lines
7.9 KiB
JavaScript
273 lines
7.9 KiB
JavaScript
/**
|
|
* Plan Scanner Utility
|
|
* Recursively discovers plan directories and aggregates metadata for dashboard view
|
|
*
|
|
* @module plan-scanner
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { parsePlanTable } = require('./plan-parser.cjs');
|
|
const {
|
|
extractPlanMetadata,
|
|
generateTimelineStats,
|
|
generateActivityHeatmap,
|
|
normalizeStatus
|
|
} = require('./plan-metadata-extractor.cjs');
|
|
|
|
/**
|
|
* Calculate progress statistics from phases array
|
|
* @param {Array<{status: string}>} phases - Array of phase objects with status
|
|
* @returns {{total: number, completed: number, inProgress: number, pending: number, percentage: number}}
|
|
*/
|
|
function calculateProgress(phases) {
|
|
if (!phases || phases.length === 0) {
|
|
return { total: 0, completed: 0, inProgress: 0, pending: 0, percentage: 0 };
|
|
}
|
|
|
|
const stats = {
|
|
total: phases.length,
|
|
completed: 0,
|
|
inProgress: 0,
|
|
pending: 0
|
|
};
|
|
|
|
for (const phase of phases) {
|
|
const status = (phase.status || '').toLowerCase();
|
|
if (status === 'completed' || status === 'done') {
|
|
stats.completed++;
|
|
} else if (status === 'in-progress' || status === 'in progress' || status === 'active') {
|
|
stats.inProgress++;
|
|
} else {
|
|
stats.pending++;
|
|
}
|
|
}
|
|
|
|
stats.percentage = stats.total > 0
|
|
? Math.round((stats.completed / stats.total) * 100)
|
|
: 0;
|
|
|
|
return stats;
|
|
}
|
|
|
|
/**
|
|
* Parse plan name from directory name (strip date prefix)
|
|
* @param {string} dirName - Directory name like "251211-feature-name"
|
|
* @returns {string} - Human readable name like "Feature Name"
|
|
*/
|
|
function parsePlanName(dirName) {
|
|
// Remove date prefix: YYMMDD-, YYYYMMDD-, YYMMDD-HHMM-, YYYYMMDD-HHMM-
|
|
const withoutDate = dirName.replace(/^\d{6,8}(-\d{4})?-/, '');
|
|
|
|
// Convert kebab-case to Title Case
|
|
return withoutDate
|
|
.split('-')
|
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(' ');
|
|
}
|
|
|
|
/**
|
|
* Derive overall status from phase statistics or header status
|
|
* @param {{completed: number, inProgress: number, pending: number, total: number}} stats
|
|
* @param {string} [headerStatus] - Optional status from plan header (e.g., **Status:** completed)
|
|
* @returns {'completed' | 'in-progress' | 'in-review' | 'cancelled' | 'pending'}
|
|
*/
|
|
function deriveStatus(stats, headerStatus) {
|
|
// If header explicitly defines status, use it (normalized)
|
|
if (headerStatus) {
|
|
const normalized = headerStatus.toLowerCase().trim();
|
|
if (normalized.includes('complete') || normalized.includes('done')) {
|
|
return 'completed';
|
|
}
|
|
if (normalized.includes('review')) {
|
|
return 'in-review';
|
|
}
|
|
if (normalized.includes('cancel')) {
|
|
return 'cancelled';
|
|
}
|
|
if (normalized.includes('progress') || normalized.includes('active')) {
|
|
return 'in-progress';
|
|
}
|
|
if (normalized.includes('pending') || normalized.includes('todo') || normalized.includes('planned')) {
|
|
return 'pending';
|
|
}
|
|
}
|
|
|
|
// Otherwise derive from phase stats
|
|
if (stats.completed === stats.total && stats.total > 0) {
|
|
return 'completed';
|
|
}
|
|
if (stats.inProgress > 0 || stats.completed > 0) {
|
|
return 'in-progress';
|
|
}
|
|
return 'pending';
|
|
}
|
|
|
|
/**
|
|
* Get metadata for a single plan
|
|
* @param {string} planFilePath - Absolute path to plan.md
|
|
* @returns {Object|null} - Plan metadata object or null if invalid
|
|
*/
|
|
function getPlanMetadata(planFilePath) {
|
|
try {
|
|
if (!fs.existsSync(planFilePath)) {
|
|
return null;
|
|
}
|
|
|
|
const directory = path.dirname(planFilePath);
|
|
const dirName = path.basename(directory);
|
|
const stats = fs.statSync(planFilePath);
|
|
|
|
// Parse phases from plan table
|
|
const phases = parsePlanTable(planFilePath);
|
|
const progress = calculateProgress(phases);
|
|
|
|
// Extract rich metadata (dates, effort, priority, etc.)
|
|
const richMeta = extractPlanMetadata(planFilePath);
|
|
|
|
return {
|
|
id: dirName,
|
|
name: parsePlanName(dirName),
|
|
path: planFilePath,
|
|
directory: directory,
|
|
phases: progress,
|
|
progress: progress.percentage,
|
|
lastModified: stats.mtime.toISOString(),
|
|
// Use frontmatter status if hasFrontmatter (already normalized), otherwise derive from phases
|
|
status: richMeta.hasFrontmatter && richMeta.headerStatus
|
|
? normalizeStatus(richMeta.headerStatus)
|
|
: deriveStatus(progress, richMeta.headerStatus),
|
|
// Rich metadata
|
|
createdDate: richMeta.createdDate,
|
|
completedDate: richMeta.completedDate,
|
|
durationDays: richMeta.durationDays,
|
|
durationFormatted: richMeta.durationFormatted,
|
|
totalEffortHours: richMeta.totalEffortHours,
|
|
totalEffortFormatted: richMeta.totalEffortFormatted,
|
|
priority: richMeta.priority,
|
|
issue: richMeta.issue,
|
|
branch: richMeta.branch,
|
|
// New frontmatter fields
|
|
description: richMeta.description,
|
|
tags: richMeta.tags || [],
|
|
assignee: richMeta.assignee,
|
|
title: richMeta.title
|
|
};
|
|
} catch (err) {
|
|
console.error(`[plan-scanner] Error reading plan: ${planFilePath}`, err.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if path is safe (within allowed directory, no traversal)
|
|
* @param {string} targetPath - Path to check
|
|
* @param {string} baseDir - Allowed base directory
|
|
* @returns {boolean}
|
|
*/
|
|
function isPathSafe(targetPath, baseDir) {
|
|
const resolved = path.resolve(targetPath);
|
|
const resolvedBase = path.resolve(baseDir);
|
|
|
|
// Must start with base directory
|
|
if (!resolved.startsWith(resolvedBase)) {
|
|
return false;
|
|
}
|
|
|
|
// No null bytes
|
|
if (targetPath.includes('\0')) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Scan directory for plan.md files
|
|
* @param {string} plansDir - Root directory to scan (e.g., ./plans)
|
|
* @param {Object} options - Scan options
|
|
* @param {number} options.maxDepth - Maximum recursion depth (default: 2)
|
|
* @param {string[]} options.exclude - Directory names to exclude (default: ['node_modules', '.git', 'templates', 'reports', 'research'])
|
|
* @returns {Array<Object>} - Array of plan metadata objects sorted by lastModified desc
|
|
*/
|
|
function scanPlans(plansDir, options = {}) {
|
|
const {
|
|
maxDepth = 2,
|
|
exclude = ['node_modules', '.git', 'templates', 'reports', 'research']
|
|
} = options;
|
|
|
|
const resolvedBase = path.resolve(plansDir);
|
|
const plans = [];
|
|
|
|
/**
|
|
* Recursive directory scanner
|
|
* @param {string} dir - Current directory
|
|
* @param {number} depth - Current depth
|
|
*/
|
|
function scanDir(dir, depth) {
|
|
if (depth > maxDepth) return;
|
|
|
|
// Security: validate path
|
|
if (!isPathSafe(dir, resolvedBase)) {
|
|
console.error(`[plan-scanner] Path traversal blocked: ${dir}`);
|
|
return;
|
|
}
|
|
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch (err) {
|
|
console.error(`[plan-scanner] Cannot read directory: ${dir}`, err.message);
|
|
return;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
// Skip excluded directories
|
|
if (exclude.includes(entry.name)) continue;
|
|
|
|
// Skip hidden directories
|
|
if (entry.name.startsWith('.')) continue;
|
|
|
|
const fullPath = path.join(dir, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
// Check for plan.md in this directory
|
|
const planFile = path.join(fullPath, 'plan.md');
|
|
if (fs.existsSync(planFile)) {
|
|
const metadata = getPlanMetadata(planFile);
|
|
if (metadata) {
|
|
plans.push(metadata);
|
|
}
|
|
} else {
|
|
// Recurse into subdirectory
|
|
scanDir(fullPath, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start scanning
|
|
if (fs.existsSync(resolvedBase)) {
|
|
scanDir(resolvedBase, 0);
|
|
} else {
|
|
console.error(`[plan-scanner] Plans directory not found: ${plansDir}`);
|
|
}
|
|
|
|
// Sort by lastModified descending (newest first)
|
|
plans.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
|
|
|
return plans;
|
|
}
|
|
|
|
module.exports = {
|
|
scanPlans,
|
|
getPlanMetadata,
|
|
calculateProgress,
|
|
parsePlanName,
|
|
deriveStatus,
|
|
isPathSafe,
|
|
// Re-export timeline helpers
|
|
generateTimelineStats,
|
|
generateActivityHeatmap
|
|
};
|