import * as fs from 'node:fs'; import * as path from 'node:path'; import { validateDashboard } from '../lib/dashboard-validator.js'; import { log } from './logger.js'; /** Directories to skip when recursively scanning for JSON files */ const SKIP_DIRS = new Set([ 'node_modules', '.git', 'dist', 'build', '.next', '.cache', '__pycache__', '.turbo', 'target', 'obj', ]); function synthesizeMeta() { return { lastAnalyzedAt: new Date().toISOString(), gitCommitHash: '', version: '0.0.0', analyzedFiles: 0, }; } function extractProjectName(data) { if (data.project && typeof data.project === 'object' && !Array.isArray(data.project) && 'name' in data.project && typeof data.project.name === 'string') { return data.project.name; } return null; } function tryParseJsonFile(filePath) { try { const content = fs.readFileSync(filePath, 'utf-8'); const parsed = JSON.parse(content); if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { return { error: 'not a JSON object' }; } return { data: parsed }; } catch (e) { const message = e instanceof Error ? e.message : String(e); return { error: message }; } } function handleSingleFile(absolutePath) { const parseResult = tryParseJsonFile(absolutePath); if ('error' in parseResult) { return { success: false, error: `Failed to parse ${path.basename(absolutePath)}: ${parseResult.error}` }; } const validation = validateDashboard(parseResult.data); if (!validation.valid) { return { success: false, error: `${path.basename(absolutePath)} is not a valid dashboard: ${validation.errors.join(', ')}`, }; } const projectName = extractProjectName(parseResult.data) || path.basename(absolutePath, '.json'); const dirName = path.basename(path.dirname(absolutePath)); const fileName = path.basename(absolutePath); const manifest = [ { dirName, meta: synthesizeMeta(), graphFiles: [{ fileName, projectName }], }, ]; const fileMapping = { [`${dirName}/${fileName}`]: absolutePath, }; return { success: true, manifest, fileMapping }; } function handleProjectDirectory(dirPath, dirName) { const metaPath = path.join(dirPath, 'meta.json'); const metaResult = tryParseJsonFile(metaPath); let meta; if ('error' in metaResult) { log('warn', `Skipping ${dirName}/meta.json: ${metaResult.error}`); return null; } meta = metaResult.data; const graphFiles = []; const fileMapping = {}; // Recursively collect all valid graph .json files from this directory and subdirectories function scanDir(currentPath, relativePrefix) { let entries; try { entries = fs.readdirSync(currentPath); } catch { return; } for (const entry of entries) { const fullPath = path.join(currentPath, entry); let stat; try { stat = fs.statSync(fullPath); } catch { continue; } if (stat.isDirectory()) { // Skip common non-source directories if (SKIP_DIRS.has(entry)) continue; // Recurse into subdirectory const childPrefix = relativePrefix ? `${relativePrefix}/${entry}` : entry; scanDir(fullPath, childPrefix); } else if (stat.isFile() && entry.endsWith('.json') && entry !== 'meta.json') { const parseResult = tryParseJsonFile(fullPath); if ('error' in parseResult) { continue; // Not valid JSON — silently skip } const validation = validateDashboard(parseResult.data); if (!validation.valid) { continue; // Not a dashboard graph file — silently skip } const projectName = extractProjectName(parseResult.data) || path.basename(entry, '.json'); // Use relative path from the project directory as the fileName for nested files const relativeFileName = relativePrefix ? `${relativePrefix}/${entry}` : entry; graphFiles.push({ fileName: relativeFileName, projectName }); fileMapping[`${dirName}/${relativeFileName}`] = fullPath; } } } scanDir(dirPath, ''); if (graphFiles.length === 0) { log('warn', `Skipping ${dirName}: no valid graph files`); return null; } return { entry: { dirName, meta, graphFiles }, fileMapping, }; } /** * Recursively collects all directories containing .json files, * skipping common non-source directories. * Returns a map of relative directory path → absolute directory path. */ function collectJsonDirs(basePath, relativePath = '') { const result = new Map(); const currentPath = relativePath ? path.join(basePath, relativePath) : basePath; let entries; try { entries = fs.readdirSync(currentPath); } catch { return result; } // Check if this directory has any .json files let hasJsonFiles = false; for (const entry of entries) { if (entry.endsWith('.json')) { const filePath = path.join(currentPath, entry); try { const stat = fs.statSync(filePath); if (stat.isFile()) { hasJsonFiles = true; break; } } catch { // skip } } } if (hasJsonFiles) { result.set(relativePath, currentPath); } // Recurse into subdirectories for (const entry of entries) { if (SKIP_DIRS.has(entry)) continue; const entryPath = path.join(currentPath, entry); try { const stat = fs.statSync(entryPath); if (stat.isDirectory()) { const childRelative = relativePath ? `${relativePath}/${entry}` : entry; const childResults = collectJsonDirs(basePath, childRelative); for (const [rel, abs] of childResults) { result.set(rel, abs); } } } catch { // skip } } return result; } function handleFlatDirectory(dirPath) { const dirName = path.basename(dirPath); const manifest = []; const fileMapping = {}; // Recursively collect all directories with .json files const jsonDirs = collectJsonDirs(dirPath); for (const [relativeDirPath, absoluteDirPath] of jsonDirs) { const entries = fs.readdirSync(absoluteDirPath); const graphFiles = []; for (const entry of entries) { if (!entry.endsWith('.json')) continue; const filePath = path.join(absoluteDirPath, entry); try { const stat = fs.statSync(filePath); if (!stat.isFile()) continue; } catch { continue; } const parseResult = tryParseJsonFile(filePath); if ('error' in parseResult) { continue; // Not valid JSON — silently skip } const validation = validateDashboard(parseResult.data); if (!validation.valid) { continue; // Not a dashboard graph file — silently skip } const projectName = extractProjectName(parseResult.data) || path.basename(entry, '.json'); graphFiles.push({ fileName: entry, projectName }); // Build the file mapping key: for top-level files use dirName/fileName, // for nested files use dirName/relativePath/fileName const mappingKey = relativeDirPath ? `${dirName}/${relativeDirPath}/${entry}` : `${dirName}/${entry}`; fileMapping[mappingKey] = filePath; } if (graphFiles.length > 0) { // Use relative path as the manifest entry dirName for nested dirs, // or the base dirName for top-level files const entryDirName = relativeDirPath ? `${dirName}/${relativeDirPath}` : dirName; manifest.push({ dirName: entryDirName, meta: synthesizeMeta(), graphFiles, }); } } if (manifest.length === 0) { return { success: false, error: `No valid dashboard files found in ${dirPath}` }; } return { success: true, manifest, fileMapping }; } /** * Recursively finds all directories containing a meta.json file, * returning their paths relative to the base directory. * Skips common non-source directories. */ function findDirsWithMetaJson(basePath, currentPath) { const results = []; let entries; try { entries = fs.readdirSync(currentPath); } catch { return results; } for (const entry of entries) { if (SKIP_DIRS.has(entry)) continue; const entryPath = path.join(currentPath, entry); try { const stat = fs.statSync(entryPath); if (!stat.isDirectory()) continue; if (fs.existsSync(path.join(entryPath, 'meta.json'))) { // Use relative path from basePath const relativePath = path.relative(basePath, entryPath); results.push(relativePath); } // Continue recursing to find deeper directories with meta.json const childResults = findDirsWithMetaJson(basePath, entryPath); results.push(...childResults); } catch { // Skip entries that can't be stat'd } } return results; } function handleNestedDirectory(dirPath, subdirs) { const manifest = []; const fileMapping = {}; for (const subdir of subdirs) { const subdirPath = path.join(dirPath, subdir); // Use the relative path as dirName (handles nested paths like "a/b/c") const dirName = subdir.replace(/\\/g, '/'); const result = handleProjectDirectory(subdirPath, dirName); if (result) { manifest.push(result.entry); Object.assign(fileMapping, result.fileMapping); } } if (manifest.length === 0) { return { success: false, error: `No valid dashboard files found in ${dirPath}` }; } return { success: true, manifest, fileMapping }; } export function resolveInput(inputPath) { const absolutePath = path.resolve(inputPath); let stat; try { stat = fs.statSync(absolutePath); } catch { return { success: false, error: `Path not found: ${inputPath}` }; } // Case 1: Single file if (stat.isFile()) { return handleSingleFile(absolutePath); } // Case 2: Directory if (stat.isDirectory()) { // Check if directory itself has meta.json → project directory const hasMetaJson = fs.existsSync(path.join(absolutePath, 'meta.json')); if (hasMetaJson) { const dirName = path.basename(absolutePath); const result = handleProjectDirectory(absolutePath, dirName); if (!result) { return { success: false, error: `No valid dashboard files found in ${absolutePath}` }; } return { success: true, manifest: [result.entry], fileMapping: result.fileMapping }; } // Recursively check for subdirectories containing meta.json → nested directory const subdirsWithMeta = findDirsWithMetaJson(absolutePath, absolutePath); if (subdirsWithMeta.length > 0) { return handleNestedDirectory(absolutePath, subdirsWithMeta); } // Flat directory: has .json files but no meta.json, no nested projects return handleFlatDirectory(absolutePath); } return { success: false, error: `Path is not a file or directory: ${inputPath}` }; }